- Go 94.5%
- Shell 2.6%
- HTML 1.6%
- Nix 0.7%
- JavaScript 0.4%
- Other 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
ci / no-committed-secrets (push) Successful in 14s
ci / nix-build (push) Successful in 2m1s
release / changes (push) Successful in 31s
release / publish (push) Has been skipped
ci / go-build (push) Successful in 2m50s
ci / lint (push) Successful in 3m55s
ci / go-test (push) Failing after 4m47s
prod (infra/Pulumi.prod.yaml) is live on homefirst.cloud: the first deploy served Let's Encrypt staging certificates and the box now uses the production CA. Cloud accounts are on main, no longer a side branch. |
||
| .forgejo/workflows | ||
| docs | ||
| infra | ||
| internal | ||
| nix | ||
| scripts | ||
| .envrc | ||
| .gitignore | ||
| accounthost.go | ||
| accounthost_test.go | ||
| AGENTS.md | ||
| devenv.nix | ||
| Dockerfile | ||
| enroll.go | ||
| enroll_test.go | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| LICENSE | ||
| main.go | ||
| main_test.go | ||
| README.md | ||
| secretfiles.go | ||
| secretfiles_test.go | ||
| VERSION | ||
homefirst.cloud
The hosted control plane for Home First.
It implements the server side of the packages/connect contract for the
self-hosted apps (tendant, openPRM): a home server dials out one connection, and
the cloud acts as its public address for third-party events — it registers
webhooks/push subscriptions with providers (Gmail, Slack, GitHub, …) and proxies
what they send down to the home.
License: AGPL-3.0 (see
LICENSE). Hosting: developed in the open at git.bcnelson.dev/home-first/home-first-cloud, with a read-only mirror on Codeberg. Design: the full hosting & protocol plan is indocs/CLOUD-PLAN.md; cloud accounts indocs/ACCOUNTS-PLAN.md.
Design in one paragraph
One small always-on box (GCP e2-micro, single Go container) terminates TLS and
holds each home's outbound gRPC connection. The cloud is a dumb event
proxy: providers call hooks.homefirst.cloud/h/<id>, the cloud checks the
provider's signature (it holds every secret, since it makes every subscribe
call), acks, buffers, and forwards raw headers + body plus the verification
result to the home, which feeds its own intake. The cloud also makes the provider-side
subscribe/renew calls using short-lived access tokens the home lends per call —
refresh tokens never leave the home. State (endpoints, subscriptions, the event
buffer) lives in CockroachDB Serverless; each copy keeps the streams it
holds in memory and agrees with the others through the database, so several
copies can run behind a load balancer (the box runs one today). Remote access
is out of v1.
Status — Slack events (build-order step 6, cloud side)
The cloud serves the v1 gRPC contract (homefirst.connect.v1, defined in the
monorepo's packages/connect), the public webhook ingress and the OAuth
redirect for Home First's shared provider registrations:
- One TLS port in production (
HOMEFIRST_CLOUD_TLS=acme): HTTPS and gRPC share:8443(the box publishes it ashooks.homefirst.cloud:443), routed bycontent-type: application/grpc; the certificate comes from CertMagic in-process (ACME DNS-01 against Google Cloud DNS, credentials from the box's service account); plain:8080only answers/healthzand redirects to HTTPS. WithoutHOMEFIRST_CLOUD_TLS(development, tests) it is plaintext HTTP on:8080and h2c gRPC on:8081. Settings:main.go's header andinternal/edge. - gRPC:
Registerwith version negotiation and sunset signalling, server auth on every RPC, theSessionstream (heartbeats, delivery + ack, replay of un-acked deliveries on reconnect),Subscribe/Renew/Unsubscribethrough the provider registry, andStartOAuth/MintToken(refresh) for Google, Slack and Linear.Callis stillUnimplemented(step 7). - HTTP on
:8080:GET|POST /h/<endpoint id>— the provider-facing ingress (signature checked in the cloud, buffered, acked, forwarded raw to the home;/h/gmailis Gmail's one shared Pub/Sub push endpoint, routed by mailbox;/h/slackis the Slack app's one Events API Request URL, fanned out to every subscribed user who can see an event and deduped on Slack's retries),GET /oauth/<provider>/callback— the one redirect URI each provider registration lists (the cloud exchanges the code and hands the tokens to the home over its session; it stores no user token),GET /healthz→ok(liveness),GET /readyz(readiness: 503 while draining or without the database),GET /version→ release + served protocol majors. - Cloud accounts (branch
accounts, not deployed;docs/ACCOUNTS-PLAN.md): withHOMEFIRST_CLOUD_ACCOUNT_URLthe same listeners also serve the account hostaccount.homefirst.cloud, split byHost— sign-up and sign-in (email + password, Google, passkeys), linking a server to an account (gRPCStartLink/PollLink/Unlink, approved at/link), per-server grants enforced on every RPC (NEEDS_GRANTwith an approve URL), and optional Stripe billing (Checkout, Customer Portal, webhook at/billing/webhook). Its settings are inaccounthost.go's header. - Providers: Linear (webhooks + OAuth); Google (OAuth) with the push
providers
gmail(users.watchto Home First's Pub/Sub topic; the push OIDC token is verified against Google's JWKS; enabled byHOMEFIRST_CLOUD_GMAIL_TOPIC+_GMAIL_PUSH_SERVICE_ACCOUNT),google-calendarandgoogle-drive(notification channels with a per-subscription channel token); Slack (OAuth, and withHOMEFIRST_CLOUD_SLACK_SIGNING_SECRETthe Events API:slacksubscriptions per user,url_verification,tokens_revoked/app_uninstalledrouted to the affected homes; the app manifest isdocs/slack-app-manifest.yaml). Home First's client credentials come fromHOMEFIRST_CLOUD_<PROVIDER>_CLIENT_ID/_CLIENT_SECRET; a provider without them is "not configured". - Renewal: a sweeper asks the owning home (
RenewalDue) toRenewexpiring subscriptions with a fresh token, marks unrenewed ones lapsed (and says so with a pastexpires_at), and tells offline homes on their next connect.Renewrenews at the provider only when due (lapsed, or inside the same window); the home'sRenewafter every reconnect otherwise just checks ownership and, for Gmail/Slack, the token's account — no channel churn. - State in CockroachDB/Postgres (
DATABASE_URL): endpoints, subscriptions, AES-GCM-encrypted provider secrets, the event buffer (72 h TTL by default), pending OAuth authorizations (10 min, single use, PKCE verifier sealed) and an audit log. WithoutDATABASE_URLit runs in memory (dev only, one copy). - Replica-aware: two or more copies can serve homes together on one
database. Session ids are signed tokens (Register and the Session stream may
hit different copies), one session per server across copies (newest wins),
an event or notice for a home on another copy rings that copy, which polls
for it within about a second (an idle poll is one cheap read), an OAuth
callback on the wrong copy hands the sealed, single-use code to the copy
holding the session, the sweeps run under DB leases, SIGTERM drains (homes
reconnect elsewhere;
GET /readyzfails), and CertMagic can keep its data in the database (HOMEFIRST_CLOUD_TLS_STORAGE=db). Design, costs and limits: CLOUD-PLAN.md "Running more than one copy".
Servers authenticate with their own keys (security phase 1): an operator
issues a one-time enrollment code (serverIds in infra/, or
homefirst-cloud enroll-code <server-id> on the box), the home spends it
once to register its public keys, and then signs every RPC; webhook payloads
and OAuth tokens are sealed to the home before the cloud keeps or sends them
(CLOUD-PLAN.md "Server authentication and encrypt to
home",
"Security roadmap"). The full environment is documented at the top of
main.go. Next (see
AGENTS.md and the design doc): TLS (CertMagic) and mobile push.
Develop
direnv allow # provisions the Go + buf/protobuf toolchain (devenv)
just build # CGO_ENABLED=0 go build ./...
just test # go test -race ./...
just dev # run locally → HTTP /healthz + /version + /h/<id> on :8080, gRPC on :8081
Locally with TLS (one port, like production) using a certificate from files,
e.g. from mkcert localhost:
HOMEFIRST_CLOUD_TLS=files HOMEFIRST_CLOUD_TLS_CERT_FILE=localhost.pem \
HOMEFIRST_CLOUD_TLS_KEY_FILE=localhost-key.pem go run . # HTTPS + gRPC on :8443
A home connects with HOMEFIRST_CLOUD_ADDR=hooks.homefirst.cloud:443 (TLS,
system roots; never HOMEFIRST_CLOUD_INSECURE against the real cloud),
HOMEFIRST_SERVER_ID and HOMEFIRST_CLOUD_TOKEN. Deploying:
infra/README.md "First deploy — checklist".
The SQL store tests (and the SQL variants of the end-to-end and multi-copy
tests — internal/server/cluster_test.go runs two and three copies on one
database) need a database; they skip without one. Point TEST_DATABASE_URL at any Postgres or
CockroachDB server you can create databases on — each test makes and drops its
own:
TEST_DATABASE_URL='postgres://root@127.0.0.1:26257/defaultdb?sslmode=disable' just test # cockroach start-single-node --insecure
For a throwaway Postgres, listen on TCP only (a unix socket path under a deep
scratch directory can exceed the 107-byte limit):
pg_ctl -D <dir> -o "-c listen_addresses=127.0.0.1 -p 55433 -c unix_socket_directories=''" start.
The connect contract is an ordinary tagged module dependency
(git.bcnelson.dev/home-first/homefirst/packages/connect), resolved straight from
Forgejo — so this repo builds on its own, with no sibling checkout required.
Evolving the contract is therefore a two-step: land the change in the monorepo's
packages/connect, tag it (packages/connect/vX.Y.Z), then bump the require
here. For day-to-day co-development, add a temporary replace pointing at a local
checkout and drop it before committing:
go mod edit -replace git.bcnelson.dev/home-first/homefirst/packages/connect=../homefirst/packages/connect
# ... iterate ...
go mod edit -dropreplace git.bcnelson.dev/home-first/homefirst/packages/connect
CI & releases
CI is Forgejo Actions (.forgejo/workflows/), on the self-hosted romeo
runner:
ci.yml— vet + build both modules, lint,go test -race, on every push/PR.release.yml— builds the multi-arch image from the flake (nixdockerTools, no Docker daemon — job containers get no docker socket) and pushes it to Forgejo's registry asgit.bcnelson.dev/home-first/home-first-cloud. A push tomainpublishes:latest; aYYYY.M.NNtag publishes that version plus its channel. Needs repo secretsREGISTRY_USER/REGISTRY_TOKEN(a Forgejo token with scopewrite:package, held by a user with package write on the org).
Run the workflows locally before pushing with just ci-local (see
scripts/ci-local.sh); just images builds the release images the way CI does.