From 939f3a9a14ef92fabe4a4554bc8b4926d376867b Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Fri, 10 Apr 2026 20:35:44 +0100 Subject: Update naming, separate grpc targets --- .gitignore | 9 +- README.md | 12 +- REUSE.toml | 4 +- Taskfile.yml | 70 +- buf.lock | 6 + buf.yaml | 9 + cmd/mirum-server/actor.go | 252 + cmd/mirum-server/api_cli.go | 424 + cmd/mirum-server/cert.go | 67 + cmd/mirum-server/cert_test.go | 142 + cmd/mirum-server/config.go | 64 + cmd/mirum-server/database.go | 1499 ++++ cmd/mirum-server/id.go | 302 + cmd/mirum-server/id_test.go | 76 + cmd/mirum-server/main.go | 254 + cmd/mirum-server/proto/api.proto | 341 + cmd/mirum-server/proto/buf.gen.yaml | 16 + cmd/mirum-server/server.go | 97 + cmd/mirum-server/server_admin.go | 535 ++ cmd/mirum-server/server_grpc.go | 123 + cmd/mirum-server/server_web.go | 390 + cmd/mirum-server/static.go | 235 + cmd/mirum-server/static_dev.go | 36 + cmd/mirum-server/static_prod.go | 32 + cmd/mirum-server/web/api/client.ts | 34 + cmd/mirum-server/web/components.json | 25 + .../web/components/pages/dashboard.tsx | 68 + .../web/components/pages/error.tsx | 47 + .../web/components/pages/login.tsx | 87 + cmd/mirum-server/web/components/ui/alert.tsx | 79 + cmd/mirum-server/web/components/ui/button.tsx | 70 + cmd/mirum-server/web/components/ui/card.tsx | 106 + cmd/mirum-server/web/components/ui/field.tsx | 239 + cmd/mirum-server/web/components/ui/input.tsx | 22 + cmd/mirum-server/web/components/ui/label.tsx | 25 + .../web/components/ui/separator.tsx | 30 + cmd/mirum-server/web/entries/dashboard.tsx | 8 + cmd/mirum-server/web/entries/error.tsx | 8 + cmd/mirum-server/web/entries/login.tsx | 8 + cmd/mirum-server/web/index.css | 133 + cmd/mirum-server/web/lib/errors.ts | 84 + cmd/mirum-server/web/lib/mount.tsx | 24 + cmd/mirum-server/web/lib/utils.ts | 24 + cmd/mirum-server/web/package-lock.json | 7388 +++++++++++++++++ cmd/mirum-server/web/package.json | 52 + cmd/mirum-server/web/shell.html | 35 + cmd/mirum-server/web/tsconfig.json | 33 + cmd/mirum-server/web/vite.config.ts | 46 + cmd/mirum-worker/client.go | 117 + cmd/mirum-worker/config.go | 45 + cmd/mirum-worker/main.go | 62 + cmd/mirumd/actor.go | 252 - cmd/mirumd/api_cli.go | 424 - cmd/mirumd/cert.go | 67 - cmd/mirumd/cert_test.go | 142 - cmd/mirumd/config.go | 64 - cmd/mirumd/database.go | 1499 ---- cmd/mirumd/id.go | 302 - cmd/mirumd/id_test.go | 76 - cmd/mirumd/main.go | 254 - cmd/mirumd/server.go | 97 - cmd/mirumd/server_admin.go | 535 -- cmd/mirumd/server_grpc.go | 123 - cmd/mirumd/server_web.go | 390 - cmd/mirumd/static.go | 235 - cmd/mirumd/static_dev.go | 36 - cmd/mirumd/static_prod.go | 32 - cmd/mirumd/web/api/client.ts | 34 - cmd/mirumd/web/components.json | 25 - cmd/mirumd/web/components/pages/dashboard.tsx | 68 - cmd/mirumd/web/components/pages/error.tsx | 47 - cmd/mirumd/web/components/pages/login.tsx | 87 - cmd/mirumd/web/components/ui/alert.tsx | 79 - cmd/mirumd/web/components/ui/button.tsx | 70 - cmd/mirumd/web/components/ui/card.tsx | 106 - cmd/mirumd/web/components/ui/field.tsx | 239 - cmd/mirumd/web/components/ui/input.tsx | 22 - cmd/mirumd/web/components/ui/label.tsx | 25 - cmd/mirumd/web/components/ui/separator.tsx | 30 - cmd/mirumd/web/entries/dashboard.tsx | 8 - cmd/mirumd/web/entries/error.tsx | 8 - cmd/mirumd/web/entries/login.tsx | 8 - cmd/mirumd/web/index.css | 133 - cmd/mirumd/web/lib/errors.ts | 84 - cmd/mirumd/web/lib/mount.tsx | 24 - cmd/mirumd/web/lib/utils.ts | 24 - cmd/mirumd/web/package-lock.json | 7388 ----------------- cmd/mirumd/web/package.json | 52 - cmd/mirumd/web/shell.html | 35 - cmd/mirumd/web/tsconfig.json | 33 - cmd/mirumd/web/vite.config.ts | 46 - cmd/mirumw/client.go | 117 - cmd/mirumw/config.go | 45 - cmd/mirumw/main.go | 62 - internal/config/config.go | 2 +- internal/protocol/platform.go | 76 +- internal/protocol/proto/buf.gen.yaml | 11 + internal/protocol/proto/wire.proto | 141 + nfpm.yaml | 40 +- pkg/mirum-server.service | 64 + pkg/mirum-worker@.service | 48 + pkg/mirumd.service | 64 - pkg/mirumd.yaml | 27 - pkg/mirumw-default.yaml | 18 - pkg/mirumw@.service | 48 - pkg/scripts/postinstall.sh | 6 +- pkg/scripts/preinstall.sh | 2 +- pkg/scripts/preremove.sh | 8 +- pkg/server/config.yaml | 27 + pkg/worker/default.yaml | 18 + proto/admin.proto | 341 - proto/buf.gen.yaml | 16 - proto/buf.lock | 6 - proto/buf.yaml | 6 - proto/mirum.proto | 141 - 115 files changed, 14225 insertions(+), 14206 deletions(-) create mode 100644 buf.lock create mode 100644 buf.yaml create mode 100644 cmd/mirum-server/actor.go create mode 100644 cmd/mirum-server/api_cli.go create mode 100644 cmd/mirum-server/cert.go create mode 100644 cmd/mirum-server/cert_test.go create mode 100644 cmd/mirum-server/config.go create mode 100644 cmd/mirum-server/database.go create mode 100644 cmd/mirum-server/id.go create mode 100644 cmd/mirum-server/id_test.go create mode 100644 cmd/mirum-server/main.go create mode 100644 cmd/mirum-server/proto/api.proto create mode 100644 cmd/mirum-server/proto/buf.gen.yaml create mode 100644 cmd/mirum-server/server.go create mode 100644 cmd/mirum-server/server_admin.go create mode 100644 cmd/mirum-server/server_grpc.go create mode 100644 cmd/mirum-server/server_web.go create mode 100644 cmd/mirum-server/static.go create mode 100644 cmd/mirum-server/static_dev.go create mode 100644 cmd/mirum-server/static_prod.go create mode 100644 cmd/mirum-server/web/api/client.ts create mode 100644 cmd/mirum-server/web/components.json create mode 100644 cmd/mirum-server/web/components/pages/dashboard.tsx create mode 100644 cmd/mirum-server/web/components/pages/error.tsx create mode 100644 cmd/mirum-server/web/components/pages/login.tsx create mode 100644 cmd/mirum-server/web/components/ui/alert.tsx create mode 100644 cmd/mirum-server/web/components/ui/button.tsx create mode 100644 cmd/mirum-server/web/components/ui/card.tsx create mode 100644 cmd/mirum-server/web/components/ui/field.tsx create mode 100644 cmd/mirum-server/web/components/ui/input.tsx create mode 100644 cmd/mirum-server/web/components/ui/label.tsx create mode 100644 cmd/mirum-server/web/components/ui/separator.tsx create mode 100644 cmd/mirum-server/web/entries/dashboard.tsx create mode 100644 cmd/mirum-server/web/entries/error.tsx create mode 100644 cmd/mirum-server/web/entries/login.tsx create mode 100644 cmd/mirum-server/web/index.css create mode 100644 cmd/mirum-server/web/lib/errors.ts create mode 100644 cmd/mirum-server/web/lib/mount.tsx create mode 100644 cmd/mirum-server/web/lib/utils.ts create mode 100644 cmd/mirum-server/web/package-lock.json create mode 100644 cmd/mirum-server/web/package.json create mode 100644 cmd/mirum-server/web/shell.html create mode 100644 cmd/mirum-server/web/tsconfig.json create mode 100644 cmd/mirum-server/web/vite.config.ts create mode 100644 cmd/mirum-worker/client.go create mode 100644 cmd/mirum-worker/config.go create mode 100644 cmd/mirum-worker/main.go delete mode 100644 cmd/mirumd/actor.go delete mode 100644 cmd/mirumd/api_cli.go delete mode 100644 cmd/mirumd/cert.go delete mode 100644 cmd/mirumd/cert_test.go delete mode 100644 cmd/mirumd/config.go delete mode 100644 cmd/mirumd/database.go delete mode 100644 cmd/mirumd/id.go delete mode 100644 cmd/mirumd/id_test.go delete mode 100644 cmd/mirumd/main.go delete mode 100644 cmd/mirumd/server.go delete mode 100644 cmd/mirumd/server_admin.go delete mode 100644 cmd/mirumd/server_grpc.go delete mode 100644 cmd/mirumd/server_web.go delete mode 100644 cmd/mirumd/static.go delete mode 100644 cmd/mirumd/static_dev.go delete mode 100644 cmd/mirumd/static_prod.go delete mode 100644 cmd/mirumd/web/api/client.ts delete mode 100644 cmd/mirumd/web/components.json delete mode 100644 cmd/mirumd/web/components/pages/dashboard.tsx delete mode 100644 cmd/mirumd/web/components/pages/error.tsx delete mode 100644 cmd/mirumd/web/components/pages/login.tsx delete mode 100644 cmd/mirumd/web/components/ui/alert.tsx delete mode 100644 cmd/mirumd/web/components/ui/button.tsx delete mode 100644 cmd/mirumd/web/components/ui/card.tsx delete mode 100644 cmd/mirumd/web/components/ui/field.tsx delete mode 100644 cmd/mirumd/web/components/ui/input.tsx delete mode 100644 cmd/mirumd/web/components/ui/label.tsx delete mode 100644 cmd/mirumd/web/components/ui/separator.tsx delete mode 100644 cmd/mirumd/web/entries/dashboard.tsx delete mode 100644 cmd/mirumd/web/entries/error.tsx delete mode 100644 cmd/mirumd/web/entries/login.tsx delete mode 100644 cmd/mirumd/web/index.css delete mode 100644 cmd/mirumd/web/lib/errors.ts delete mode 100644 cmd/mirumd/web/lib/mount.tsx delete mode 100644 cmd/mirumd/web/lib/utils.ts delete mode 100644 cmd/mirumd/web/package-lock.json delete mode 100644 cmd/mirumd/web/package.json delete mode 100644 cmd/mirumd/web/shell.html delete mode 100644 cmd/mirumd/web/tsconfig.json delete mode 100644 cmd/mirumd/web/vite.config.ts delete mode 100644 cmd/mirumw/client.go delete mode 100644 cmd/mirumw/config.go delete mode 100644 cmd/mirumw/main.go create mode 100644 internal/protocol/proto/buf.gen.yaml create mode 100644 internal/protocol/proto/wire.proto create mode 100644 pkg/mirum-server.service create mode 100644 pkg/mirum-worker@.service delete mode 100644 pkg/mirumd.service delete mode 100644 pkg/mirumd.yaml delete mode 100644 pkg/mirumw-default.yaml delete mode 100644 pkg/mirumw@.service create mode 100644 pkg/server/config.yaml create mode 100644 pkg/worker/default.yaml delete mode 100644 proto/admin.proto delete mode 100644 proto/buf.gen.yaml delete mode 100644 proto/buf.lock delete mode 100644 proto/buf.yaml delete mode 100644 proto/mirum.proto diff --git a/.gitignore b/.gitignore index cec9f17..e9e681e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,10 @@ /dev /build /.task -cmd/mirumd/static -cmd/mirumd/web/node_modules +cmd/mirum-server/static +cmd/mirum-server/web/node_modules # grpc generated code -/internal/protocol/pb -/cmd/mirumd/web/gen +/internal/protocol/wirepb +/cmd/mirum-server/apipb +/cmd/mirum-server/web/gen diff --git a/README.md b/README.md index 4e30f73..8292f0d 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ echo "deb [signed-by=/usr/share/keyrings/mirum.gpg] https://dl.mirum.dev/apt/ ni sudo apt update && sudo apt install mirum # Start the server -sudo systemctl enable --now mirumd +sudo systemctl enable --now mirum-server # Start a worker (optional, can run on a different host) -sudo systemctl enable --now mirumw@default +sudo systemctl enable --now mirum-worker@default ``` **Fedora/RHEL:** @@ -34,10 +34,10 @@ sudo curl -o /etc/yum.repos.d/mirum-nightly.repo https://dl.mirum.dev/rpm/nightl sudo dnf install mirum # Start the server -sudo systemctl enable --now mirumd +sudo systemctl enable --now mirum-server # Start a worker (optional, can run on a different host) -sudo systemctl enable --now mirumw@default +sudo systemctl enable --now mirum-worker@default ``` **openSUSE:** @@ -49,10 +49,10 @@ sudo zypper refresh sudo zypper install mirum # Start the server -sudo systemctl enable --now mirumd +sudo systemctl enable --now mirum-server # Start a worker (optional, can run on a different host) -sudo systemctl enable --now mirumw@default +sudo systemctl enable --now mirum-worker@default ``` ## Contributing diff --git a/REUSE.toml b/REUSE.toml index 455fc7e..2be7092 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -10,12 +10,12 @@ path = [ "CONCEPT.md", "README.md", "VERSION", - "cmd/mirumd/web/*.json", + "cmd/mirum-server/web/*.json", "go.mod", "go.sum", "pkg/dl/*", "pkg/logo.svg", - "proto/buf.*", + "buf.*", ] SPDX-FileCopyrightText = "2026 Nikolay Govorov " SPDX-License-Identifier = "AGPL-3.0-or-later" diff --git a/Taskfile.yml b/Taskfile.yml index b3090e6..8178989 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -70,22 +70,26 @@ tasks: proto: desc: Generate ConnectRPC code from proto files sources: - - proto/*.proto - - proto/buf.gen.yaml - - proto/buf.yaml + - internal/protocol/proto/*.proto + - internal/protocol/proto/buf.gen.yaml + - cmd/mirum-server/proto/*.proto + - cmd/mirum-server/proto/buf.gen.yaml + - buf.yaml generates: - - internal/protocol/pb/**/*.go - - cmd/mirumd/web/gen/**/*.ts + - internal/protocol/wirepb/**/*.go + - cmd/mirum-server/apipb/**/*.go + - cmd/mirum-server/web/gen/**/*.ts cmds: - - rm -rf internal/protocol/pb cmd/mirumd/web/gen - - cd proto && buf generate - # admin_pb.ts imports file_buf_validate_validate from buf/validate/validate_pb.js. + - rm -rf internal/protocol/wirepb cmd/mirum-server/apipb cmd/mirum-server/web/gen + - cd internal/protocol/proto && buf generate + - cd cmd/mirum-server/proto && buf generate + # api_pb.ts imports file_buf_validate_validate from buf/validate/validate_pb.js. # buf.validate carries only server-side field annotations that the browser # never decodes, so replace the 200+ KB generated schema with an empty # descriptor stub that satisfies the GenFile type. - - mkdir -p cmd/mirumd/web/gen/buf/validate + - mkdir -p cmd/mirum-server/web/gen/buf/validate - | - cat > cmd/mirumd/web/gen/buf/validate/validate_pb.ts <<'EOF' + cat > cmd/mirum-server/web/gen/buf/validate/validate_pb.ts <<'EOF' // Stub: see Taskfile.yml proto task. buf.validate is server-only. import { fileDesc, type GenFile } from "@bufbuild/protobuf/codegenv2" export const file_buf_validate_validate: GenFile = /*@__PURE__*/ fileDesc("") @@ -93,7 +97,7 @@ tasks: web:install: desc: Install frontend dependencies - dir: cmd/mirumd/web + dir: cmd/mirum-server/web sources: - package.json - package-lock.json @@ -103,8 +107,8 @@ tasks: - npm ci web:build: - desc: Build the frontend into cmd/mirumd/static - dir: cmd/mirumd/web + desc: Build the frontend into cmd/mirum-server/static + dir: cmd/mirum-server/web deps: [web:install, proto] sources: - api/**/* @@ -123,7 +127,7 @@ tasks: web:size: desc: Check frontend bundle size budgets - dir: cmd/mirumd/web + dir: cmd/mirum-server/web deps: [web:build] sources: - ../static/assets/* @@ -132,8 +136,8 @@ tasks: - npm run size web:dev: - desc: Run Vite dev server (use with `go run -tags dev ./cmd/mirumd`) - dir: cmd/mirumd/web + desc: Run Vite dev server (use with `go run -tags dev ./cmd/mirum-server`) + dir: cmd/mirum-server/web deps: [web:install, proto] prefix: vite env: @@ -154,8 +158,8 @@ tasks: test -f dev/server.crt || openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout dev/server.key -out dev/server.crt - -days 365 -nodes -subj "/CN=mirumd" - -addext "subjectAltName=DNS:mirumd,DNS:localhost" + -days 365 -nodes -subj "/CN=mirum-server" + -addext "subjectAltName=DNS:mirum-server,DNS:localhost" - >- test -f dev/worker.key || openssl genpkey -algorithm ed25519 -out dev/worker.key @@ -164,13 +168,13 @@ tasks: openssl pkey -in dev/worker.key -pubout -out dev/worker.pub devenv:config: - desc: Write dev/mirumd.yaml if missing + desc: Write dev/mirum-server.yaml if missing status: - - test -f dev/mirumd.yaml + - test -f dev/mirum-server.yaml cmds: - mkdir -p dev - | - cat > dev/mirumd.yaml < dev/mirum-server.yaml < {{.DIST_DIR}}/build.env diff --git a/buf.lock b/buf.lock new file mode 100644 index 0000000..d15a117 --- /dev/null +++ b/buf.lock @@ -0,0 +1,6 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/bufbuild/protovalidate + commit: 80ab13bee0bf4272b6161a72bf7034e0 + digest: b5:1aa6a965be5d02d64e1d81954fa2e78ef9d1e33a0c30f92bc2626039006a94deb3a5b05f14ed8893f5c3ffce444ac008f7e968188ad225c4c29c813aa5f2daa1 diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..ddd7a3c --- /dev/null +++ b/buf.yaml @@ -0,0 +1,9 @@ +# Copyright (c) 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +version: v2 +modules: + - path: internal/protocol/proto + - path: cmd/mirum-server/proto +deps: + - buf.build/bufbuild/protovalidate diff --git a/cmd/mirum-server/actor.go b/cmd/mirum-server/actor.go new file mode 100644 index 0000000..03d30ba --- /dev/null +++ b/cmd/mirum-server/actor.go @@ -0,0 +1,252 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "errors" + "slices" + + "dimidiumlabs/mirum/cmd/mirum-server/apipb" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +var ( + ErrPermissionDenied = errors.New("database: permission denied") + ErrUnauthenticated = errors.New("database: authentication required") +) + +var anonPermissions = []apipb.Perm{ + apipb.Perm_PERM_ORG_READ, +} + +var userGlobalPermissions = []apipb.Perm{ + apipb.Perm_PERM_ORG_READ, + apipb.Perm_PERM_ORG_WRITE, + apipb.Perm_PERM_USER_READ, +} + +// rolePermissions is the single source of truth for role → perm bundles. +// RLS checks only tenancy (membership); action authz lives here. +var rolePermissions = map[string][]apipb.Perm{ + "owner": { + apipb.Perm_PERM_ORG_READ, + apipb.Perm_PERM_ORG_WRITE, + apipb.Perm_PERM_ORG_DELETE, + apipb.Perm_PERM_ORG_MEMBER_READ, + apipb.Perm_PERM_ORG_MEMBER_WRITE, + apipb.Perm_PERM_WORKER_READ, + apipb.Perm_PERM_WORKER_WRITE, + }, + "admin": { + apipb.Perm_PERM_ORG_READ, + apipb.Perm_PERM_ORG_WRITE, + apipb.Perm_PERM_ORG_MEMBER_READ, + apipb.Perm_PERM_ORG_MEMBER_WRITE, + apipb.Perm_PERM_WORKER_READ, + apipb.Perm_PERM_WORKER_WRITE, + }, + "member": { + apipb.Perm_PERM_ORG_READ, + apipb.Perm_PERM_ORG_MEMBER_READ, + apipb.Perm_PERM_WORKER_READ, + }, +} + +// Actor is the principal making a database request. It carries identity, +// display metadata, and coarse capability. Zero value is invalid: dbID +// panics, so a missing initialisation cannot silently grant privileges. +// +// Synthetic actors (System/Operator/Anon) live only as Go constants — +// they are not rows in the users table, so they cannot be logged in as +// even if somebody writes a password into the DB. +// +// Authorization is divided into two planes: +// - Tenancy: an actor can only see a subset of resources to which +// they have access (public or through organization membership). +// Any select statement will return only records accessible to the actor. +// - RBAC: what the actor can do with records (create/read/write) is implemented here. +// Any rights we grant here are a strict subset of the Tenancy rights. +// The list of perms can be either explicit (for tokens) or implied (for user roles). +type Actor struct { + kind actorKind + id uuid.UUID + email string + superuser bool +} + +type actorKind uint8 + +const ( + actorInvalid actorKind = iota + actorUser + actorOperator + actorSystem + actorAnon +) + +// ActorKind is the exported form of actorKind for audit sinks and logging. +type ActorKind uint8 + +const ( + KindInvalid ActorKind = iota + KindUser + KindOperator + KindSystem + KindAnon +) + +var ( + anonUUID = uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff") + systemUUID = uuid.MustParse("00000000-0000-0000-0000-000000000001") + operatorUUID = uuid.MustParse("00000000-0000-0000-0000-000000000002") +) + +// UserActor identifies an authenticated user from a session or token. +func UserActor(id UserID, email string, superuser bool) Actor { + if id.IsZero() { + panic("database: UserActor with nil UUID") + } + if email == "" { + panic("database: UserActor with empty email") + } + return Actor{kind: actorUser, id: id.UUID(), email: email, superuser: superuser} +} + +// OperatorActor is the principal for externally invoked privileged +// operations (admin socket). Distinguishable from System in audit logs. +func OperatorActor() Actor { + return Actor{kind: actorOperator, id: operatorUUID, email: "operator@mirum.local", superuser: true} +} + +// SystemActor is the principal for internal machinery (mTLS handshake, +// session bootstrap, background jobs). Not an operator action. +func SystemActor() Actor { + return Actor{kind: actorSystem, id: systemUUID, email: "system@mirum.local", superuser: true} +} + +// AnonActor is the principal for unauthenticated public requests. +func AnonActor() Actor { + return Actor{kind: actorAnon, id: anonUUID, email: "anonymous@mirum.local"} +} + +func (a Actor) Kind() ActorKind { + switch a.kind { + case actorUser: + return KindUser + case actorOperator: + return KindOperator + case actorSystem: + return KindSystem + case actorAnon: + return KindAnon + } + return KindInvalid +} + +func (a Actor) UserID() UserID { return UserID(a.id) } +func (a Actor) Email() string { return a.email } +func (a Actor) IsSuperuser() bool { return a.superuser } + +// dbID returns the UUID to write into app.user_id. Panics on zero value. +func (a Actor) dbID() uuid.UUID { + if a.id == uuid.Nil { + panic("database: zero-value Actor; use UserActor/SystemActor/OperatorActor/AnonActor") + } + return a.id +} + +// kindString returns the string written into app.actor_kind. +// It must match the values tested by app_issuper() in the SQL migration. +func (a Actor) kindString() string { + switch a.kind { + case actorUser: + return "user" + case actorOperator: + return "operator" + case actorSystem: + return "system" + case actorAnon: + return "anon" + } + panic("database: zero-value Actor; use UserActor/SystemActor/OperatorActor/AnonActor") +} + +// checkGlobal checks a global-scope perm (no specific org). Pure, no DB. +func checkGlobal(actor Actor, perm apipb.Perm) error { + switch actor.kind { + case actorOperator, actorSystem: + return nil + case actorAnon: + if slices.Contains(anonPermissions, perm) { + return nil + } + return ErrUnauthenticated + case actorUser: + if actor.superuser { + return nil + } + if slices.Contains(userGlobalPermissions, perm) { + return nil + } + return ErrPermissionDenied + default: + return ErrPermissionDenied + } +} + +// checkPerm checks an org-scoped perm within an existing transaction. +func checkPerm(ctx context.Context, tx pgx.Tx, actor Actor, orgID OrgID, perm apipb.Perm) error { + switch actor.kind { + case actorOperator, actorSystem: + return nil + case actorAnon: + return ErrUnauthenticated + case actorUser: + if actor.superuser { + return nil + } + var role string + err := tx.QueryRow(ctx, + `SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2`, + orgID, actor.id, + ).Scan(&role) + if err != nil { + return ErrPermissionDenied + } + if !slices.Contains(rolePermissions[role], perm) { + return ErrPermissionDenied + } + return nil + default: + return ErrPermissionDenied + } +} + +// checkSystem checks that actor is the internal system principal. +func checkSystem(actor Actor) error { + if actor.kind == actorSystem { + return nil + } + return ErrPermissionDenied +} + +// checkSelf checks that actor is the target user or superuser. +func checkSelf(actor Actor, targetID UserID) error { + switch actor.kind { + case actorOperator, actorSystem: + return nil + case actorAnon: + return ErrUnauthenticated + case actorUser: + if actor.superuser || targetID == actor.UserID() { + return nil + } + return ErrPermissionDenied + default: + return ErrPermissionDenied + } +} diff --git a/cmd/mirum-server/api_cli.go b/cmd/mirum-server/api_cli.go new file mode 100644 index 0000000..8220c65 --- /dev/null +++ b/cmd/mirum-server/api_cli.go @@ -0,0 +1,424 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +// Console CLI is generated from api.proto at startup via protoreflect. +// RPC name is camelCase-split into a cobra path: UserCreate -> "user create", +// OrgMemberAdd -> "org member add". Flags come from request fields, dispatch +// goes through reflect on apipbconnect.ConsoleClient. + +import ( + "context" + "crypto/ed25519" + "crypto/x509" + "encoding/base64" + "fmt" + "os" + "reflect" + "strings" + "unicode" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/known/timestamppb" + + "dimidiumlabs/mirum/cmd/mirum-server/apipb" + "dimidiumlabs/mirum/cmd/mirum-server/apipb/apipbconnect" +) + +// mkClient is called per-invocation so persistent flags (e.g. --socket) are +// already parsed by the time it runs. +func buildConsoleCLI(root *cobra.Command, mkClient func() apipbconnect.ConsoleClient) { + methods := apipb.File_api_proto.Services().ByName("Console").Methods() + for i := 0; i < methods.Len(); i++ { + md := methods.Get(i) + path := splitCamel(string(md.Name())) + parent := ensureGroups(root, path[:len(path)-1]) + parent.AddCommand(buildMethodCmd(md, path[len(path)-1], mkClient)) + } +} + +// "OrgMemberAdd" -> ["org","member","add"]. +func splitCamel(s string) []string { + var parts []string + start := 0 + for i := 1; i < len(s); i++ { + if unicode.IsUpper(rune(s[i])) { + parts = append(parts, strings.ToLower(s[start:i])) + start = i + } + } + return append(parts, strings.ToLower(s[start:])) +} + +func ensureGroups(root *cobra.Command, path []string) *cobra.Command { + parent := root + for _, name := range path { + var next *cobra.Command + for _, c := range parent.Commands() { + if c.Name() == name { + next = c + break + } + } + if next == nil { + next = &cobra.Command{Use: name, Short: "Manage " + name} + parent.AddCommand(next) + } + parent = next + } + return parent +} + +// fieldSetter writes one flag value into the request message. +type fieldSetter func(*pflag.FlagSet, protoreflect.Message) error + +func buildMethodCmd(md protoreflect.MethodDescriptor, leaf string, mkClient func() apipbconnect.ConsoleClient) *cobra.Command { + reqDesc := md.Input() + rpcName := string(md.Name()) + cmd := &cobra.Command{ + Use: leaf, + Short: rpcName, + } + setters := registerRequestFlags(cmd, reqDesc) + cmd.Run = func(c *cobra.Command, _ []string) { + req, err := buildRequest(reqDesc, c.Flags(), setters) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + resp, err := dispatchConsole(mkClient(), rpcName, req) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + printResponse(resp) + } + return cmd +} + +func registerRequestFlags(cmd *cobra.Command, desc protoreflect.MessageDescriptor) []fieldSetter { + var setters []fieldSetter + fields := desc.Fields() + for i := 0; i < fields.Len(); i++ { + if s := registerField(cmd, fields.Get(i)); s != nil { + setters = append(setters, s) + } + } + return setters +} + +func registerField(cmd *cobra.Command, fd protoreflect.FieldDescriptor) fieldSetter { + flagName := strings.ReplaceAll(string(fd.Name()), "_", "-") + // Required iff non-optional in the schema; bools default to false, so + // marking them required makes no sense. + required := !fd.HasOptionalKeyword() && fd.Kind() != protoreflect.BoolKind + flags := cmd.Flags() + + markRequired := func() { + if required { + _ = cmd.MarkFlagRequired(flagName) + } + } + + switch fd.Kind() { + case protoreflect.StringKind: + flags.String(flagName, "", string(fd.Name())) + markRequired() + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + v, _ := fs.GetString(flagName) + if v == "" { + return nil + } + m.Set(fd, protoreflect.ValueOfString(v)) + return nil + } + + case protoreflect.BoolKind: + flags.Bool(flagName, false, string(fd.Name())) + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + // Preserve "unset" vs "false" for optional bools. + if fd.HasOptionalKeyword() && !fs.Changed(flagName) { + return nil + } + v, _ := fs.GetBool(flagName) + m.Set(fd, protoreflect.ValueOfBool(v)) + return nil + } + + case protoreflect.BytesKind: + flags.String(flagName, "", string(fd.Name())) + markRequired() + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + v, _ := fs.GetString(flagName) + if v == "" { + return nil + } + b, err := parseBytesFlag(string(fd.Name()), v) + if err != nil { + return fmt.Errorf("--%s: %w", flagName, err) + } + m.Set(fd, protoreflect.ValueOfBytes(b)) + return nil + } + + case protoreflect.EnumKind: + flags.String(flagName, "", string(fd.Name())) + markRequired() + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + v, _ := fs.GetString(flagName) + if v == "" { + return nil + } + n, err := parseEnumFlag(fd.Enum(), v) + if err != nil { + return fmt.Errorf("--%s: %w", flagName, err) + } + m.Set(fd, protoreflect.ValueOfEnum(n)) + return nil + } + + case protoreflect.MessageKind: + return registerMessageField(cmd, fd, flagName, required) + } + + if required { + panic(fmt.Sprintf("consolecli: unhandled required field %s (kind=%s)", fd.FullName(), fd.Kind())) + } + return nil +} + +// Only UserRef/OrgRef are flattened (to the string arm of their oneof); +// PageRequest is skipped; anything else required panics at startup so +// schema changes can't silently send malformed requests. +func registerMessageField(cmd *cobra.Command, fd protoreflect.FieldDescriptor, flagName string, required bool) fieldSetter { + flags := cmd.Flags() + markRequired := func() { + if required { + _ = cmd.MarkFlagRequired(flagName) + } + } + + switch fd.Message().FullName() { + case "mirum.api.UserRef": + flags.String(flagName, "", "user email") + markRequired() + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + v, _ := fs.GetString(flagName) + if v == "" { + return nil + } + ref := &apipb.UserRef{Ref: &apipb.UserRef_Email{Email: v}} + m.Set(fd, protoreflect.ValueOfMessage(ref.ProtoReflect())) + return nil + } + + case "mirum.api.OrgRef": + flags.String(flagName, "", "org slug") + markRequired() + return func(fs *pflag.FlagSet, m protoreflect.Message) error { + v, _ := fs.GetString(flagName) + if v == "" { + return nil + } + ref := &apipb.OrgRef{Ref: &apipb.OrgRef_Slug{Slug: v}} + m.Set(fd, protoreflect.ValueOfMessage(ref.ProtoReflect())) + return nil + } + + case "mirum.api.PageRequest": + return nil + } + + if required { + panic(fmt.Sprintf("consolecli: unhandled required message field %s (type=%s)", fd.FullName(), fd.Message().FullName())) + } + return nil +} + +// Console schema uses bytes only for UUIDs (id / *_id) and ed25519 PKIX keys. +func parseBytesFlag(fieldName, v string) ([]byte, error) { + switch { + case fieldName == "id" || strings.HasSuffix(fieldName, "_id"): + id, err := ParseAnyID(v) + if err != nil { + return nil, fmt.Errorf("invalid id: %w", err) + } + return id[:], nil + case strings.Contains(fieldName, "key"): + der, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return nil, fmt.Errorf("invalid base64: %w", err) + } + pub, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil, fmt.Errorf("invalid public key: %w", err) + } + ed, ok := pub.(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("not an ed25519 key") + } + return ed, nil + } + return nil, fmt.Errorf("unsupported bytes field %q", fieldName) +} + +// Accepts both short ("admin") and full ("ROLE_ADMIN") forms. +func parseEnumFlag(ed protoreflect.EnumDescriptor, v string) (protoreflect.EnumNumber, error) { + want := strings.ToUpper(v) + values := ed.Values() + if ev := values.ByName(protoreflect.Name(want)); ev != nil { + return ev.Number(), nil + } + prefix := strings.ToUpper(string(ed.Name())) + "_" + if ev := values.ByName(protoreflect.Name(prefix + want)); ev != nil { + return ev.Number(), nil + } + return 0, fmt.Errorf("unknown %s value %q", ed.Name(), v) +} + +func buildRequest(desc protoreflect.MessageDescriptor, flags *pflag.FlagSet, setters []fieldSetter) (proto.Message, error) { + mt, err := protoregistry.GlobalTypes.FindMessageByName(desc.FullName()) + if err != nil { + return nil, fmt.Errorf("find message %s: %w", desc.FullName(), err) + } + m := mt.New() + for _, set := range setters { + if err := set(flags, m); err != nil { + return nil, err + } + } + return m.Interface(), nil +} + +// reflect.New on *connect.Request[T] is equivalent to connect.NewRequest(req): +// Msg is the only public field, the rest are initialised lazily at send time. +func dispatchConsole(client apipbconnect.ConsoleClient, name string, req proto.Message) (proto.Message, error) { + cv := reflect.ValueOf(client) + method := cv.MethodByName(name) + if !method.IsValid() { + return nil, fmt.Errorf("unknown console method %q", name) + } + // method signature: + // func(context.Context, *connect.Request[T]) (*connect.Response[U], error) + reqPtrType := method.Type().In(1) // *connect.Request[T] + reqWrap := reflect.New(reqPtrType.Elem()) + reqWrap.Elem().FieldByName("Msg").Set(reflect.ValueOf(req)) + + out := method.Call([]reflect.Value{ + reflect.ValueOf(context.Background()), + reqWrap, + }) + if errV := out[1]; !errV.IsNil() { + return nil, errV.Interface().(error) + } + return out[0].Elem().FieldByName("Msg").Interface().(proto.Message), nil +} + +// Shape-driven printer: empty → "ok", single bytes id → UUID, single message +// → TSV of scalars, repeated → one TSV line per element, anything else → TSV +// of top-level scalars. PageResponse metadata is ignored. +func printResponse(resp proto.Message) { + m := resp.ProtoReflect() + fields := m.Descriptor().Fields() + + var meaningful []protoreflect.FieldDescriptor + for i := 0; i < fields.Len(); i++ { + f := fields.Get(i) + if f.Kind() == protoreflect.MessageKind && f.Message().FullName() == "mirum.api.PageResponse" { + continue + } + meaningful = append(meaningful, f) + } + + if len(meaningful) == 0 { + fmt.Println("ok") + return + } + if len(meaningful) == 1 { + f := meaningful[0] + v := m.Get(f) + switch { + case f.IsList(): + list := v.List() + for j := 0; j < list.Len(); j++ { + item := list.Get(j) + if f.Kind() == protoreflect.MessageKind { + fmt.Println(formatMessageTSV(item.Message())) + } else { + fmt.Println(formatScalar(f, item)) + } + } + case f.Kind() == protoreflect.MessageKind: + fmt.Println(formatMessageTSV(v.Message())) + default: + fmt.Println(formatScalar(f, v)) + } + return + } + fmt.Println(formatMessageTSV(m)) +} + +func formatMessageTSV(m protoreflect.Message) string { + var parts []string + fields := m.Descriptor().Fields() + for i := 0; i < fields.Len(); i++ { + f := fields.Get(i) + if f.IsList() || f.IsMap() { + continue + } + if f.HasOptionalKeyword() && !m.Has(f) { + continue + } + if f.Kind() == protoreflect.MessageKind { + if f.Message().FullName() == "google.protobuf.Timestamp" { + ts := m.Get(f).Message().Interface().(*timestamppb.Timestamp) + parts = append(parts, ts.AsTime().Format("2006-01-02")) + continue + } + nested := m.Get(f).Message() + if nested.IsValid() { + parts = append(parts, formatMessageTSV(nested)) + } + continue + } + parts = append(parts, formatScalar(f, m.Get(f))) + } + return strings.Join(parts, "\t") +} + +func formatScalar(fd protoreflect.FieldDescriptor, v protoreflect.Value) string { + switch fd.Kind() { + case protoreflect.StringKind: + return v.String() + case protoreflect.BoolKind: + if v.Bool() { + return "true" + } + return "false" + case protoreflect.BytesKind: + return formatBytes(v.Bytes()) + case protoreflect.EnumKind: + ev := fd.Enum().Values().ByNumber(v.Enum()) + if ev == nil { + return fmt.Sprintf("%d", v.Enum()) + } + name := string(ev.Name()) + if idx := strings.IndexByte(name, '_'); idx >= 0 { + name = name[idx+1:] + } + return strings.ToLower(name) + } + return v.String() +} + +func formatBytes(b []byte) string { + if len(b) == 16 { + return FormatAnyID(b) + } + return base64.StdEncoding.EncodeToString(b) +} diff --git a/cmd/mirum-server/cert.go b/cmd/mirum-server/cert.go new file mode 100644 index 0000000..67128d5 --- /dev/null +++ b/cmd/mirum-server/cert.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "crypto/tls" + "log/slog" + "os" + "sync" + "time" +) + +// certReloader serves a TLS cert/key pair and reloads it when either file +// changes on disk (e.g. after a letsencrypt renewal). +type certReloader struct { + certFile string + keyFile string + + mu sync.Mutex + cert *tls.Certificate + certMod time.Time + keyMod time.Time +} + +func newCertReloader(certFile, keyFile string) *certReloader { + r := &certReloader{certFile: certFile, keyFile: keyFile} + if _, err := r.GetCertificate(nil); err != nil { + slog.Warn("initial cert load failed", "cert", certFile, "err", err) + } + return r +} + +// GetCertificate plugs into tls.Config.GetCertificate. On a transient +// reload error it returns the last good pair so a mid-renewal race +// (cert swapped but key still being written) doesn't break handshakes. +func (r *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { + cs, cerr := os.Stat(r.certFile) + ks, kerr := os.Stat(r.keyFile) + + r.mu.Lock() + defer r.mu.Unlock() + + if cerr == nil && kerr == nil && r.cert != nil && + cs.ModTime().Equal(r.certMod) && ks.ModTime().Equal(r.keyMod) { + return r.cert, nil + } + + cert, err := tls.LoadX509KeyPair(r.certFile, r.keyFile) + if err != nil { + if r.cert != nil { + slog.Warn("cert reload failed, serving cached", "cert", r.certFile, "err", err) + return r.cert, nil + } + return nil, err + } + + r.cert = &cert + if cerr == nil { + r.certMod = cs.ModTime() + } + if kerr == nil { + r.keyMod = ks.ModTime() + } + slog.Info("cert loaded", "cert", r.certFile) + return r.cert, nil +} diff --git a/cmd/mirum-server/cert_test.go b/cmd/mirum-server/cert_test.go new file mode 100644 index 0000000..794b2f6 --- /dev/null +++ b/cmd/mirum-server/cert_test.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +func writeTestPair(t *testing.T, certPath, keyPath string) { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + t.Fatal(err) + } + + der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: serial, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + }, &x509.Certificate{SerialNumber: serial}, pub, priv) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(certPath, + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + 0o644); err != nil { + t.Fatal(err) + } + + keyDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, + pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), + 0o600); err != nil { + t.Fatal(err) + } +} + +func TestCertReloader_Cached(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + writeTestPair(t, certPath, keyPath) + + r := newCertReloader(certPath, keyPath) + + first, err := r.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + second, err := r.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatal("expected same pointer on cache hit") + } +} + +func TestCertReloader_ReloadsOnMtimeChange(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + writeTestPair(t, certPath, keyPath) + + r := newCertReloader(certPath, keyPath) + first, err := r.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + + writeTestPair(t, certPath, keyPath) + future := time.Now().Add(time.Second) + if err := os.Chtimes(certPath, future, future); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(keyPath, future, future); err != nil { + t.Fatal(err) + } + + second, err := r.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatal("expected new pointer after mtime change") + } +} + +func TestCertReloader_FallbackOnReloadError(t *testing.T) { + dir := t.TempDir() + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + writeTestPair(t, certPath, keyPath) + + r := newCertReloader(certPath, keyPath) + good, err := r.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(certPath, []byte("garbage"), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(time.Second) + if err := os.Chtimes(certPath, future, future); err != nil { + t.Fatal(err) + } + + fallback, err := r.GetCertificate(nil) + if err != nil { + t.Fatalf("expected last-good fallback, got error: %v", err) + } + if fallback != good { + t.Fatal("expected cached cert on corrupted file") + } +} + +func TestCertReloader_ErrorOnFirstLoad(t *testing.T) { + r := newCertReloader("/nonexistent/cert.pem", "/nonexistent/key.pem") + if _, err := r.GetCertificate(nil); err == nil { + t.Fatal("expected error on missing files") + } +} diff --git a/cmd/mirum-server/config.go b/cmd/mirum-server/config.go new file mode 100644 index 0000000..48d6ed2 --- /dev/null +++ b/cmd/mirum-server/config.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type tlsConfig struct { + Cert string `yaml:"cert"` + Key string `yaml:"key"` +} + +type appConfig struct { + WebAddr string `yaml:"web_addr"` + GrpcAddr string `yaml:"grpc_addr"` + AdminSocket string `yaml:"admin_socket"` + DatabaseUri string `yaml:"database_uri"` + Pepper string `yaml:"pepper"` + + GrpcTls tlsConfig `yaml:"grpc_tls"` + WebTls *tlsConfig `yaml:"web_tls"` // optional + + TrustedProxies []string `yaml:"trusted_proxies"` // CIDR list, empty = trust RemoteAddr only + + GitHubToken string `yaml:"token"` + WebhookSecret string `yaml:"webhook_secret"` +} + +func getConfig(filename string) (*appConfig, error) { + cfg := &appConfig{ + GrpcAddr: ":2026", + WebAddr: ":3000", + AdminSocket: "/run/mirum-server/admin.sock", + } + + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + if cfg.DatabaseUri == "" { + return nil, fmt.Errorf("error: database_uri is required") + } + if cfg.WebhookSecret == "" { + return nil, fmt.Errorf("error: webhook_secret is required") + } + if cfg.Pepper == "" { + return nil, fmt.Errorf("error: pepper is required") + } + if cfg.GrpcTls.Cert == "" || cfg.GrpcTls.Key == "" { + return nil, fmt.Errorf("error: grpc_tls.cert and grpc_tls.key are required") + } + + return cfg, nil +} diff --git a/cmd/mirum-server/database.go b/cmd/mirum-server/database.go new file mode 100644 index 0000000..8655dbc --- /dev/null +++ b/cmd/mirum-server/database.go @@ -0,0 +1,1499 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "log/slog" + "net/mail" + "regexp" + "strings" + "time" + + "dimidiumlabs/mirum/cmd/mirum-server/apipb" + "dimidiumlabs/mirum/internal/config" + + sb "github.com/huandu/go-sqlbuilder" + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/tern/v2/migrate" + "golang.org/x/crypto/argon2" +) + +var ( + ErrAcquire = errors.New("database: failed to acquire connection") + ErrAlreadyMember = errors.New("database: already a member") + ErrEmailTaken = errors.New("database: email already taken") + ErrInvalidCreds = errors.New("database: invalid credentials") + ErrInvalidEmail = errors.New("database: invalid email") + ErrInvalidRole = errors.New("database: invalid role") + ErrInvalidSlug = errors.New("database: invalid slug") + ErrLastOwner = errors.New("database: last owner") + ErrMigrate = errors.New("database: failed to create migrator") + ErrNotImplemented = errors.New("database: filter not implemented") + ErrNotMember = errors.New("database: not a member") + ErrOpen = errors.New("database: failed to open") + ErrOrgNotFound = errors.New("database: organization not found") + ErrPing = errors.New("database: failed to ping") + ErrReservedEmail = errors.New("database: email uses a reserved domain") + ErrSlugTaken = errors.New("database: slug already taken") + ErrSoleOwner = errors.New("database: sole owner of an organization") + ErrUserNotFound = errors.New("database: user not found") + ErrWorkerNotFound = errors.New("database: worker not found") +) + +// reservedEmailSuffix is the domain carved out for synthetic actors +// (system/operator/anon). Real users cannot register with this suffix. +const reservedEmailSuffix = "@mirum.local" + +const ( + saltLen = 16 + + argonTime = 3 + argonMemory = 64 * 1024 // 64 MB + argonKeyLen = 32 + argonThreads = 2 +) + +var slugRe = regexp.MustCompile(`^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$`) + +// UserRef identifies a user by ID or email. +type UserRef struct { + id UserID + email string +} + +func UserByID(id UserID) UserRef { return UserRef{id: id} } + +func UserByEmail(email string) UserRef { return UserRef{email: email} } + +func (r UserRef) where() (string, any) { + if !r.id.IsZero() { + return "id", r.id + } + return "email", r.email +} + +// OrgRef identifies an organization by ID or slug. +type OrgRef struct { + id OrgID + slug string +} + +func OrgByID(id OrgID) OrgRef { return OrgRef{id: id} } + +func OrgBySlug(slug string) OrgRef { return OrgRef{slug: slug} } + +func (r OrgRef) IsZero() bool { return r.id.IsZero() && r.slug == "" } + +func (r OrgRef) where() (string, any) { + if !r.id.IsZero() { + return "id", r.id + } + return "slug", r.slug +} + +// DB wraps a pgx connection pool. +type DB struct { + Pool *pgxpool.Pool +} + +// User holds info about a user. +type User struct { + ID UserID + Email string + CreatedAt time.Time +} + +// Organization holds info about an organization. +type Organization struct { + ID OrgID + Name string + Slug string + Public bool + CreatedAt time.Time +} + +// OrgMember pairs a user with their role in an organization. +type OrgMember struct { + User User + Role string + JoinedAt time.Time +} + +// Worker holds info about a registered worker. +type Worker struct { + ID WorkerID + OrgID *OrgID + PublicKey []byte + CreatedAt time.Time +} + +// DatabaseOpen connects to PostgreSQL and returns a DB. +func DatabaseOpen(ctx context.Context, dsn string) (*DB, error) { + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, errors.Join(ErrOpen, err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, errors.Join(ErrPing, err) + } + + return &DB{Pool: pool}, nil +} + +// Close closes the connection pool. +func (db *DB) Close() { + db.Pool.Close() +} + +// apicall starts a transaction and sets the RLS actor. Both app.user_id +// and app.actor_kind are populated: app_issuper() checks actor_kind for +// System/Operator principals, and app.user_id for real user superusers. +func (db *DB) apicall(ctx context.Context, actor Actor, access, validate, doit func(pgx.Tx) error) error { + tx, err := db.Pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, + `SELECT set_config('app.user_id', $1, true), + set_config('app.actor_kind', $2, true)`, + actor.dbID().String(), actor.kindString(), + ); err != nil { + return err + } + + if err := access(tx); err != nil { + slog.Debug("access denied", "err", err) + return err + } + if validate != nil { + if err := validate(tx); err != nil { + slog.Debug("validation failed", "err", err) + return err + } + } + if err := doit(tx); err != nil { + slog.Debug("exec failed", "err", err) + return err + } + + return tx.Commit(ctx) +} + +// Migrate applies all pending migrations. +func (db *DB) Migrate(ctx context.Context) error { + conn, err := db.Pool.Acquire(ctx) + if err != nil { + return errors.Join(ErrAcquire, err) + } + defer conn.Release() + + migrator, err := migrate.NewMigrator(ctx, conn.Conn(), "schema_version") + if err != nil { + return errors.Join(ErrMigrate, err) + } + + migrator.AppendMigration("create_users", ` + CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + superuser BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + + CREATE FUNCTION app_user_id() RETURNS uuid STABLE AS $$ + SELECT current_setting('app.user_id', true)::uuid; + $$ LANGUAGE sql; + + -- app_issuper has two independent branches: + -- (a) runtime setting app.actor_kind is 'system' or 'operator' — + -- set only by apicall from Go for synthetic principals and by + -- DML migrations; cannot be injected via login since there is + -- no matching users row to authenticate against. + -- (b) the current app.user_id resolves to a users row with + -- superuser = true — real support-agent style superusers. + CREATE FUNCTION app_issuper() RETURNS boolean STABLE AS $$ + SELECT + current_setting('app.actor_kind', true) IN ('system', 'operator') + OR EXISTS ( + SELECT 1 FROM users + WHERE id = current_setting('app.user_id', true)::uuid + AND superuser = true + ); + $$ LANGUAGE sql; + `, ` + DROP FUNCTION app_issuper; + DROP FUNCTION app_user_id; + DROP TABLE users; + `) + + migrator.AppendMigration("create_sessions", ` + CREATE TABLE sessions ( + token TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL + ); + CREATE INDEX sessions_expires_at ON sessions (expires_at); + `, ` + DROP TABLE sessions; + `) + + migrator.AppendMigration("create_organizations", ` + CREATE TABLE organizations ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + public BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `, ` + DROP TABLE organizations; + `) + + migrator.AppendMigration("create_org_members", ` + CREATE TABLE org_members ( + org_id UUID NOT NULL REFERENCES organizations(id), + user_id UUID NOT NULL REFERENCES users(id), + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (org_id, user_id) + ); + CREATE INDEX org_members_user_id ON org_members (user_id); + + CREATE FUNCTION is_member(org uuid) RETURNS boolean STABLE AS $$ + SELECT EXISTS ( + SELECT 1 FROM org_members + WHERE org_id = org AND user_id = app_user_id() + ); + $$ LANGUAGE sql; + + CREATE FUNCTION is_authenticated() RETURNS boolean STABLE AS $$ + SELECT current_setting('app.actor_kind', true) NOT IN ('', 'anon'); + $$ LANGUAGE sql; + `, ` + DROP FUNCTION is_authenticated; + DROP FUNCTION is_member; + DROP TABLE org_members; + `) + + migrator.AppendMigration("create_workers", ` + CREATE TABLE workers ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + org_id UUID REFERENCES organizations(id), + public_key BYTEA NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ + ); + `, ` + DROP TABLE workers + `) + + migrator.AppendMigration("rls_users", ` + ALTER TABLE users ENABLE ROW LEVEL SECURITY; + ALTER TABLE users FORCE ROW LEVEL SECURITY; + + CREATE POLICY superuser ON users FOR ALL USING (app_issuper()); + CREATE POLICY self ON users FOR ALL USING (id = app_user_id()); + CREATE POLICY shared_org ON users FOR SELECT USING (EXISTS ( + SELECT 1 FROM org_members target + JOIN org_members mine ON mine.org_id = target.org_id + WHERE target.user_id = users.id + AND mine.user_id = app_user_id() + )); + CREATE POLICY write_auth ON users AS RESTRICTIVE FOR INSERT WITH CHECK (is_authenticated()); + CREATE POLICY update_auth ON users AS RESTRICTIVE FOR UPDATE USING (is_authenticated()); + CREATE POLICY delete_auth ON users AS RESTRICTIVE FOR DELETE USING (is_authenticated()); + `, ` + DROP POLICY write_auth ON users; + DROP POLICY update_auth ON users; + DROP POLICY delete_auth ON users; + DROP POLICY superuser ON users; + DROP POLICY self ON users; + DROP POLICY shared_org ON users; + + ALTER TABLE users DISABLE ROW LEVEL SECURITY; + `) + + migrator.AppendMigration("rls_sessions", ` + ALTER TABLE sessions ENABLE ROW LEVEL SECURITY; + ALTER TABLE sessions FORCE ROW LEVEL SECURITY; + + CREATE POLICY superuser ON sessions FOR ALL USING (app_issuper()); + CREATE POLICY own_sessions ON sessions FOR ALL USING (user_id = app_user_id()); + CREATE POLICY write_auth ON sessions AS RESTRICTIVE FOR INSERT WITH CHECK (is_authenticated()); + CREATE POLICY update_auth ON sessions AS RESTRICTIVE FOR UPDATE USING (is_authenticated()); + CREATE POLICY delete_auth ON sessions AS RESTRICTIVE FOR DELETE USING (is_authenticated()); + `, ` + DROP POLICY write_auth ON sessions; + DROP POLICY update_auth ON sessions; + DROP POLICY delete_auth ON sessions; + DROP POLICY superuser ON sessions; + DROP POLICY own_sessions ON sessions; + + ALTER TABLE sessions DISABLE ROW LEVEL SECURITY; + `) + + migrator.AppendMigration("rls_organizations", ` + ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; + ALTER TABLE organizations FORCE ROW LEVEL SECURITY; + + CREATE POLICY superuser ON organizations FOR ALL USING (app_issuper()); + CREATE POLICY public_org ON organizations FOR ALL USING (public); + CREATE POLICY member_org ON organizations FOR ALL USING (is_member(id)); + CREATE POLICY write_auth ON organizations AS RESTRICTIVE FOR INSERT WITH CHECK (is_authenticated()); + CREATE POLICY update_auth ON organizations AS RESTRICTIVE FOR UPDATE USING (is_authenticated()); + CREATE POLICY delete_auth ON organizations AS RESTRICTIVE FOR DELETE USING (is_authenticated()); + `, ` + DROP POLICY write_auth ON organizations; + DROP POLICY update_auth ON organizations; + DROP POLICY delete_auth ON organizations; + DROP POLICY superuser ON organizations; + DROP POLICY public_org ON organizations; + DROP POLICY member_org ON organizations; + + ALTER TABLE organizations DISABLE ROW LEVEL SECURITY; + `) + + migrator.AppendMigration("rls_org_members", ` + ALTER TABLE org_members ENABLE ROW LEVEL SECURITY; + ALTER TABLE org_members FORCE ROW LEVEL SECURITY; + + CREATE POLICY superuser ON org_members FOR ALL USING (app_issuper()); + + -- Self path (own membership rows) uses a pure column predicate + -- so is_member's inner query can resolve without recursion. + CREATE POLICY self_member ON org_members FOR ALL USING (user_id = app_user_id()); + CREATE POLICY org_member ON org_members FOR ALL USING (is_member(org_id)); + CREATE POLICY write_auth ON org_members AS RESTRICTIVE FOR INSERT WITH CHECK (is_authenticated()); + CREATE POLICY update_auth ON org_members AS RESTRICTIVE FOR UPDATE USING (is_authenticated()); + CREATE POLICY delete_auth ON org_members AS RESTRICTIVE FOR DELETE USING (is_authenticated()); + `, ` + DROP POLICY write_auth ON org_members; + DROP POLICY update_auth ON org_members; + DROP POLICY delete_auth ON org_members; + DROP POLICY superuser ON org_members; + DROP POLICY self_member ON org_members; + DROP POLICY org_member ON org_members; + + ALTER TABLE org_members DISABLE ROW LEVEL SECURITY; + `) + + migrator.AppendMigration("rls_workers", ` + ALTER TABLE workers ENABLE ROW LEVEL SECURITY; + ALTER TABLE workers FORCE ROW LEVEL SECURITY; + + CREATE POLICY superuser ON workers FOR ALL USING (app_issuper()); + CREATE POLICY org_worker ON workers FOR ALL USING ( + org_id IS NOT NULL AND is_member(org_id) + ); + CREATE POLICY write_auth ON workers AS RESTRICTIVE FOR INSERT WITH CHECK (is_authenticated()); + CREATE POLICY update_auth ON workers AS RESTRICTIVE FOR UPDATE USING (is_authenticated()); + CREATE POLICY delete_auth ON workers AS RESTRICTIVE FOR DELETE USING (is_authenticated()); + `, ` + DROP POLICY write_auth ON workers; + DROP POLICY update_auth ON workers; + DROP POLICY delete_auth ON workers; + DROP POLICY superuser ON workers; + DROP POLICY org_worker ON workers; + + ALTER TABLE workers DISABLE ROW LEVEL SECURITY; + `) + + return migrator.Migrate(ctx) +} + +// UserCreate hashes the password with argon2id and inserts a new user. +// The pepper is a server-side secret not stored in the database. +func (db *DB) UserCreate(ctx context.Context, actor Actor, email, password string, pepper []byte) (UserID, error) { + var id UserID + err := db.apicall( + ctx, actor, + func(tx pgx.Tx) error { + if !actor.IsSuperuser() { + return ErrPermissionDenied + } + + return nil + }, + func(tx pgx.Tx) error { + if strings.HasSuffix(strings.ToLower(email), reservedEmailSuffix) { + return ErrReservedEmail + } + + return nil + }, + func(tx pgx.Tx) error { + hash, err := hashPassword(password, pepper) + if err != nil { + return err + } + + if err := tx.QueryRow(ctx, + `INSERT INTO users (email, password) VALUES ($1, $2) RETURNING id`, + email, hash, + ).Scan(&id); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return ErrEmailTaken + } + + return err + } + return nil + }, + ) + return id, err +} + +// UserGet returns a user by ref (ID or email). +func (db *DB) UserGet(ctx context.Context, actor Actor, ref UserRef) (*User, error) { + var u User + err := db.apicall( + ctx, actor, + func(tx pgx.Tx) error { return checkGlobal(actor, apipb.Perm_PERM_USER_READ) }, + nil, + func(tx pgx.Tx) error { + col, val := ref.where() + + q := sb.PostgreSQL.NewSelectBuilder() + sql, args := q.Select("id", "email", "created_at"). + From("users"). + Where(q.Equal(col, val), q.IsNull("deleted_at")). + Build() + + if err := tx.QueryRow(ctx, sql, args...).Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrUserNotFound + } + return err + } + + return nil + }, + ) + return &u, err +} + +// UserList returns a page of users and the total count. +func (db *DB) UserList(ctx context.Context, actor Actor, cursor UserID, limit int, filter string) ([]User, int, error) { + var users []User + var total int + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { return checkGlobal(actor, apipb.Perm_PERM_USER_READ) }, + func(tx pgx.Tx) error { + if filter != "" { + return ErrNotImplemented + } + return nil + }, + func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM users WHERE deleted_at IS NULL`, + ).Scan(&total); err != nil { + return err + } + + q := sb.PostgreSQL.NewSelectBuilder() + q.Select("id", "email", "created_at"). + From("users"). + Where(q.IsNull("deleted_at")). + OrderBy("id"). + Limit(limit) + if !cursor.IsZero() { + q.Where(q.GreaterThan("id", cursor)) + } + + sql, args := q.Build() + rows, err := tx.Query(ctx, sql, args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil { + return err + } + users = append(users, u) + } + return rows.Err() + }, + ) + return users, total, err +} + +// UserUpdate updates a user's email and/or password. +// Invalidates all sessions when password changes. +func (db *DB) UserUpdate(ctx context.Context, actor Actor, ref UserRef, email *string, password *string, pepper []byte) error { + if email == nil && password == nil { + return nil + } + + var id UserID + return db.apicall(ctx, actor, + func(tx pgx.Tx) error { + var err error + id, err = resolveUser(ctx, tx, ref) + if err != nil { + return err + } + return checkSelf(actor, id) + }, + func(tx pgx.Tx) error { + if email != nil && strings.HasSuffix(strings.ToLower(*email), reservedEmailSuffix) { + return ErrReservedEmail + } + return nil + }, + func(tx pgx.Tx) error { + ub := sb.PostgreSQL.NewUpdateBuilder() + ub.Update("users") + + if email != nil { + ub.SetMore(ub.Assign("email", *email)) + } + if password != nil { + hash, err := hashPassword(*password, pepper) + if err != nil { + return err + } + ub.SetMore(ub.Assign("password", hash)) + } + ub.Where(ub.Equal("id", id)) + + sql, args := ub.Build() + if _, err := tx.Exec(ctx, sql, args...); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return ErrEmailTaken + } + return err + } + + if password != nil { + if _, err := tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id); err != nil { + return err + } + } + return nil + }, + ) +} + +// UserDelete soft-deletes a user. +// Fails if the user is the sole owner of any organization. +func (db *DB) UserDelete(ctx context.Context, actor Actor, ref UserRef) error { + var id UserID + return db.apicall(ctx, actor, + func(tx pgx.Tx) error { + var err error + id, err = resolveUser(ctx, tx, ref) + if err != nil { + return err + } + return checkSelf(actor, id) + }, + func(tx pgx.Tx) error { + return checkNotSoleOwner(ctx, tx, id) + }, + func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM org_members WHERE user_id = $1`, id); err != nil { + return err + } + if _, err := tx.Exec(ctx, + `UPDATE users SET email = id::text, password = '', deleted_at = now() WHERE id = $1`, id, + ); err != nil { + return err + } + return nil + }, + ) +} + +// UserVerifyPassword checks credentials and returns the user ID. +func (db *DB) UserVerifyPassword(ctx context.Context, actor Actor, email, password string, pepper []byte) (UserID, error) { + var id UserID + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { return checkSystem(actor) }, + nil, + func(tx pgx.Tx) error { + var hash string + if err := tx.QueryRow(ctx, + `SELECT id, password FROM users WHERE email = $1 AND deleted_at IS NULL`, + email, + ).Scan(&id, &hash); err != nil { + return ErrInvalidCreds + } + if !verifyHash(password, hash, pepper) { + return ErrInvalidCreds + } + return nil + }, + ) + return id, err +} + +// UserSessionGet resolves a session token into the Actor it authenticates. +// Returns an invalid zero Actor on any error; callers must check err. +func (db *DB) UserSessionGet(ctx context.Context, actor Actor, token string) (Actor, error) { + var ( + userID UserID + email string + superuser bool + ) + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { return checkSystem(actor) }, + nil, + func(tx pgx.Tx) error { + var expiresAt time.Time + h := hashToken(token) + + if err := tx.QueryRow(ctx, + `SELECT s.user_id, u.email, u.superuser, s.expires_at + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token = $1 AND s.expires_at > now() AND u.deleted_at IS NULL`, + h, + ).Scan(&userID, &email, &superuser, &expiresAt); err != nil { + return err + } + + if time.Until(expiresAt) < config.SessionTTL/2 { + if _, err := tx.Exec(ctx, + `UPDATE sessions SET expires_at = now() + $2 WHERE token = $1`, + h, config.SessionTTL, + ); err != nil { + return err + } + } + return nil + }, + ) + if err != nil { + return Actor{}, err + } + return UserActor(userID, email, superuser), nil +} + +// UserSessionCreate generates a random token, stores its hash, and returns the token. +func (db *DB) UserSessionCreate(ctx context.Context, actor Actor, userID UserID) (string, error) { + var token string + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { return checkSystem(actor) }, + nil, + func(tx pgx.Tx) error { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return err + } + token = base64.RawURLEncoding.EncodeToString(buf) + + if _, err := tx.Exec(ctx, + `INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, now() + $3)`, + hashToken(token), userID, config.SessionTTL, + ); err != nil { + return err + } + return nil + }, + ) + return token, err +} + +// UserSessionDelete removes a session (logout). +func (db *DB) UserSessionDelete(ctx context.Context, actor Actor, token string) error { + return db.apicall(ctx, actor, + func(tx pgx.Tx) error { return checkSystem(actor) }, + nil, + func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, hashToken(token)) + return err + }, + ) +} + +// UserSessionPurgeExpired deletes all expired sessions. Runs as SystemActor. +func (db *DB) UserSessionPurgeExpired(ctx context.Context) error { + return db.apicall(ctx, SystemActor(), + func(tx pgx.Tx) error { return checkSystem(SystemActor()) }, + nil, + func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, `DELETE FROM sessions WHERE expires_at < now()`) + return err + }, + ) +} + +// OrgGet returns an org by ref (ID or slug). +func (db *DB) OrgGet(ctx context.Context, actor Actor, ref OrgRef) (*Organization, error) { + var o Organization + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { return checkGlobal(actor, apipb.Perm_PERM_ORG_READ) }, + nil, + func(tx pgx.Tx) error { + col, val := ref.where() + q := sb.PostgreSQL.NewSelectBuilder() + + sql, args := q.Select("id", "name", "slug", "public", "created_at"). + From("organizations"). + Where(q.Equal(col, val), q.IsNull("deleted_at")). + Build() + + if err := tx.QueryRow(ctx, sql, args...).Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrOrgNotFound + } + return err + } + return nil + }, + ) + return &o, err +} + +// OrgCreate creates an org and adds the owner as the first member. +func (db *DB) OrgCreate(ctx context.Context, actor Actor, name, slug string, public bool, owner UserRef) (OrgID, error) { + var orgID OrgID + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { return checkGlobal(actor, apipb.Perm_PERM_ORG_WRITE) }, + nil, + func(tx pgx.Tx) error { + userID, err := resolveUser(ctx, tx, owner) + if err != nil { + return err + } + + if err := tx.QueryRow(ctx, + `INSERT INTO organizations (name, slug, public) VALUES ($1, $2, $3) RETURNING id`, + name, slug, public, + ).Scan(&orgID); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return ErrSlugTaken + } + return err + } + + _, err = tx.Exec(ctx, + `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`, + orgID, userID, + ) + return err + }, + ) + return orgID, err +} + +// OrgUpdate updates an org's name, slug, and/or public flag. +func (db *DB) OrgUpdate(ctx context.Context, actor Actor, ref OrgRef, name *string, slug *string, public *bool) error { + if name == nil && slug == nil && public == nil { + return nil + } + + var id OrgID + return db.apicall(ctx, actor, + func(tx pgx.Tx) error { + var err error + id, err = resolveOrg(ctx, tx, ref) + if err != nil { + return err + } + return checkPerm(ctx, tx, actor, id, apipb.Perm_PERM_ORG_WRITE) + }, + nil, + func(tx pgx.Tx) error { + ub := sb.PostgreSQL.NewUpdateBuilder() + ub.Update("organizations") + if name != nil { + ub.SetMore(ub.Assign("name", *name)) + } + if slug != nil { + ub.SetMore(ub.Assign("slug", *slug)) + } + if public != nil { + ub.SetMore(ub.Assign("public", *public)) + } + ub.Where(ub.Equal("id", id)) + + sql, args := ub.Build() + tag, err := tx.Exec(ctx, sql, args...) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return ErrSlugTaken + } + return err + } + if tag.RowsAffected() == 0 { + return ErrOrgNotFound + } + return nil + }, + ) +} + +// OrgDelete soft-deletes an org and removes all members. +func (db *DB) OrgDelete(ctx context.Context, actor Actor, ref OrgRef) error { + var id OrgID + return db.apicall(ctx, actor, + func(tx pgx.Tx) error { + var err error + id, err = resolveOrg(ctx, tx, ref) + if err != nil { + return err + } + return checkPerm(ctx, tx, actor, id, apipb.Perm_PERM_ORG_DELETE) + }, + nil, + func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `DELETE FROM org_members WHERE org_id = $1`, id); err != nil { + return err + } + tag, err := tx.Exec(ctx, + `UPDATE organizations SET slug = id::text, deleted_at = now() WHERE id = $1`, id, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrOrgNotFound + } + return nil + }, + ) +} + +// OrgList returns a page of orgs and the total count. +func (db *DB) OrgList(ctx context.Context, actor Actor, cursor OrgID, limit int, filter string) ([]Organization, int, error) { + var orgs []Organization + var total int + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { return checkGlobal(actor, apipb.Perm_PERM_ORG_READ) }, + func(tx pgx.Tx) error { + if filter != "" { + return ErrNotImplemented + } + return nil + }, + func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM organizations WHERE deleted_at IS NULL`, + ).Scan(&total); err != nil { + return err + } + + q := sb.PostgreSQL.NewSelectBuilder() + q.Select("id", "name", "slug", "public", "created_at"). + From("organizations"). + Where(q.IsNull("deleted_at")). + OrderBy("id"). + Limit(limit) + if !cursor.IsZero() { + q.Where(q.GreaterThan("id", cursor)) + } + + sql, args := q.Build() + rows, err := tx.Query(ctx, sql, args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var o Organization + if err := rows.Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt); err != nil { + return err + } + orgs = append(orgs, o) + } + return rows.Err() + }, + ) + return orgs, total, err +} + +// OrgMemberGet returns a single member's info. +func (db *DB) OrgMemberGet(ctx context.Context, actor Actor, org OrgRef, user UserRef) (*OrgMember, error) { + var m OrgMember + var orgID OrgID + var userID UserID + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { + var err error + orgID, err = resolveOrg(ctx, tx, org) + if err != nil { + return err + } + return checkPerm(ctx, tx, actor, orgID, apipb.Perm_PERM_ORG_MEMBER_READ) + }, + nil, + func(tx pgx.Tx) error { + var err error + userID, err = resolveUser(ctx, tx, user) + if err != nil { + return err + } + + if err := tx.QueryRow(ctx, + `SELECT u.id, u.email, u.created_at, om.role, om.created_at + FROM org_members om + JOIN users u ON u.id = om.user_id + WHERE om.org_id = $1 AND om.user_id = $2`, orgID, userID, + ).Scan(&m.User.ID, &m.User.Email, &m.User.CreatedAt, &m.Role, &m.JoinedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotMember + } + return err + } + return nil + }, + ) + return &m, err +} + +// OrgMembersList returns a page of members for an org. +func (db *DB) OrgMembersList(ctx context.Context, actor Actor, org OrgRef, cursor UserID, limit int, filter string) ([]OrgMember, int, error) { + var members []OrgMember + var total int + var orgID OrgID + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { + var err error + orgID, err = resolveOrg(ctx, tx, org) + if err != nil { + return err + } + return checkPerm(ctx, tx, actor, orgID, apipb.Perm_PERM_ORG_MEMBER_READ) + }, + func(tx pgx.Tx) error { + if filter != "" { + return ErrNotImplemented + } + return nil + }, + func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM org_members WHERE org_id = $1`, orgID, + ).Scan(&total); err != nil { + return err + } + + q := sb.PostgreSQL.NewSelectBuilder() + q.Select("u.id", "u.email", "u.created_at", "m.role", "m.created_at"). + From("org_members m"). + Join("users u", "u.id = m.user_id"). + Where(q.Equal("m.org_id", orgID), q.IsNull("u.deleted_at")). + OrderBy("u.id"). + Limit(limit) + if !cursor.IsZero() { + q.Where(q.GreaterThan("u.id", cursor)) + } + + sql, args := q.Build() + rows, err := tx.Query(ctx, sql, args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var m OrgMember + if err := rows.Scan(&m.User.ID, &m.User.Email, &m.User.CreatedAt, &m.Role, &m.JoinedAt); err != nil { + return err + } + members = append(members, m) + } + return rows.Err() + }, + ) + return members, total, err +} + +// OrgMemberAdd adds a user to an org with the given role. +func (db *DB) OrgMemberAdd(ctx context.Context, actor Actor, org OrgRef, user UserRef, role string) error { + var orgID OrgID + return db.apicall(ctx, actor, + func(tx pgx.Tx) error { + var err error + orgID, err = resolveOrg(ctx, tx, org) + if err != nil { + return err + } + return checkPerm(ctx, tx, actor, orgID, apipb.Perm_PERM_ORG_MEMBER_WRITE) + }, + nil, + func(tx pgx.Tx) error { + userID, err := resolveUser(ctx, tx, user) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, + `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, $3)`, + orgID, userID, role, + ); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return ErrAlreadyMember + } + return err + } + return nil + }, + ) +} + +// OrgMemberUpdateRole changes a member's role. Fails if demoting the last owner. +func (db *DB) OrgMemberUpdateRole(ctx context.Context, actor Actor, org OrgRef, user UserRef, newRole string) error { + var orgID OrgID + var userID UserID + return db.apicall(ctx, actor, + func(tx pgx.Tx) error { + var err error + orgID, err = resolveOrg(ctx, tx, org) + if err != nil { + return err + } + return checkPerm(ctx, tx, actor, orgID, apipb.Perm_PERM_ORG_MEMBER_WRITE) + }, + nil, + func(tx pgx.Tx) error { + var err error + userID, err = resolveUser(ctx, tx, user) + if err != nil { + return err + } + + var currentRole string + if err := tx.QueryRow(ctx, + `SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2 FOR UPDATE`, + orgID, userID, + ).Scan(¤tRole); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotMember + } + return err + } + + if currentRole == "owner" && newRole != "owner" { + var ownerCount int + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`, + orgID, + ).Scan(&ownerCount); err != nil { + return err + } + if ownerCount <= 1 { + return ErrLastOwner + } + } + + _, err = tx.Exec(ctx, + `UPDATE org_members SET role = $1 WHERE org_id = $2 AND user_id = $3`, + newRole, orgID, userID, + ) + return err + }, + ) +} + +// OrgMemberRemove removes a user from an org. Fails if they are the last owner. +func (db *DB) OrgMemberRemove(ctx context.Context, actor Actor, org OrgRef, user UserRef) error { + var orgID OrgID + return db.apicall(ctx, actor, + func(tx pgx.Tx) error { + var err error + orgID, err = resolveOrg(ctx, tx, org) + if err != nil { + return err + } + return checkPerm(ctx, tx, actor, orgID, apipb.Perm_PERM_ORG_MEMBER_WRITE) + }, + nil, + func(tx pgx.Tx) error { + userID, err := resolveUser(ctx, tx, user) + if err != nil { + return err + } + + var role string + if err := tx.QueryRow(ctx, + `SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2 FOR UPDATE`, + orgID, userID, + ).Scan(&role); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotMember + } + return err + } + + if role == "owner" { + var ownerCount int + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`, + orgID, + ).Scan(&ownerCount); err != nil { + return err + } + if ownerCount <= 1 { + return ErrLastOwner + } + } + + _, err = tx.Exec(ctx, + `DELETE FROM org_members WHERE org_id = $1 AND user_id = $2`, + orgID, userID, + ) + return err + }, + ) +} + +// WorkerGet returns a worker by ID. +func (db *DB) WorkerGet(ctx context.Context, actor Actor, id WorkerID) (*Worker, error) { + var w Worker + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { + var orgID *OrgID + if err := tx.QueryRow(ctx, + `SELECT org_id FROM workers WHERE id = $1 AND revoked_at IS NULL`, id, + ).Scan(&orgID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrWorkerNotFound + } + return err + } + if orgID != nil { + return checkPerm(ctx, tx, actor, *orgID, apipb.Perm_PERM_WORKER_READ) + } + if !actor.IsSuperuser() { + return ErrPermissionDenied + } + return nil + }, + nil, + func(tx pgx.Tx) error { + return tx.QueryRow(ctx, + `SELECT id, public_key, org_id, created_at FROM workers WHERE id = $1 AND revoked_at IS NULL`, id, + ).Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt) + }, + ) + return &w, err +} + +// WorkerCreate registers a new worker with the given public key and optional org. +func (db *DB) WorkerCreate(ctx context.Context, actor Actor, publicKey []byte, org *OrgRef) (WorkerID, error) { + var workerID WorkerID + var orgID *OrgID + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { + if org != nil { + id, err := resolveOrg(ctx, tx, *org) + if err != nil { + return err + } + orgID = &id + return checkPerm(ctx, tx, actor, id, apipb.Perm_PERM_WORKER_WRITE) + } + if !actor.IsSuperuser() { + return ErrPermissionDenied + } + return nil + }, + nil, + func(tx pgx.Tx) error { + return tx.QueryRow(ctx, + `INSERT INTO workers (public_key, org_id) VALUES ($1, $2) RETURNING id`, + publicKey, orgID, + ).Scan(&workerID) + }, + ) + return workerID, err +} + +// WorkerDelete soft-deletes a worker by ID. +func (db *DB) WorkerDelete(ctx context.Context, actor Actor, id WorkerID) error { + return db.apicall(ctx, actor, + func(tx pgx.Tx) error { + var orgID *OrgID + if err := tx.QueryRow(ctx, + `SELECT org_id FROM workers WHERE id = $1 AND revoked_at IS NULL`, id, + ).Scan(&orgID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrWorkerNotFound + } + return err + } + if orgID != nil { + return checkPerm(ctx, tx, actor, *orgID, apipb.Perm_PERM_WORKER_WRITE) + } + if !actor.IsSuperuser() { + return ErrPermissionDenied + } + return nil + }, + nil, + func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, + `UPDATE workers SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, id, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrWorkerNotFound + } + return nil + }, + ) +} + +// WorkerList returns a page of workers and the total count. +func (db *DB) WorkerList(ctx context.Context, actor Actor, cursor WorkerID, limit int, filter string) ([]Worker, int, error) { + var workers []Worker + var total int + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { + if actor.kind == actorAnon { + return ErrUnauthenticated + } + return nil + }, + func(tx pgx.Tx) error { + if filter != "" { + return ErrNotImplemented + } + return nil + }, + func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM workers WHERE revoked_at IS NULL`, + ).Scan(&total); err != nil { + return err + } + + q := sb.PostgreSQL.NewSelectBuilder() + q.Select("id", "public_key", "org_id", "created_at"). + From("workers"). + Where(q.IsNull("revoked_at")). + OrderBy("id"). + Limit(limit) + if !cursor.IsZero() { + q.Where(q.GreaterThan("id", cursor)) + } + + sql, args := q.Build() + rows, err := tx.Query(ctx, sql, args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var w Worker + if err := rows.Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt); err != nil { + return err + } + workers = append(workers, w) + } + return rows.Err() + }, + ) + return workers, total, err +} + +// WorkerLookup finds an active worker by its ed25519 public key. +func (db *DB) WorkerLookup(ctx context.Context, actor Actor, publicKey []byte) (*Worker, error) { + var w Worker + err := db.apicall(ctx, actor, + func(tx pgx.Tx) error { return checkSystem(actor) }, + nil, + func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, + `SELECT id, public_key, org_id, created_at FROM workers WHERE public_key = $1 AND revoked_at IS NULL`, + publicKey, + ).Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrWorkerNotFound + } + return err + } + return nil + }, + ) + return &w, err +} + +// hashToken returns the hex-encoded SHA-256 of a session token. +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return fmt.Sprintf("%x", h) +} + +// verifyHash parses a PHC-format argon2id string and compares. +// Format: $argon2id$v=19$m=65536,t=3,p=2$$ +func verifyHash(password, encoded string, pepper []byte) bool { + // $argon2id$v=19$m=65536,t=3,p=2$salt$key → 6 parts + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + + var memory, time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return false + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + expectedKey, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + + mac := hmac.New(sha256.New, pepper) + mac.Write([]byte(password)) + peppered := mac.Sum(nil) + + key := argon2.IDKey(peppered, salt, time, memory, threads, uint32(len(expectedKey))) + + return subtle.ConstantTimeCompare(key, expectedKey) == 1 +} + +// hashPassword produces a PHC-format string: +// $argon2id$v=19$m=65536,t=3,p=2$$ +func hashPassword(password string, pepper []byte) (string, error) { + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + + // Apply pepper: HMAC-SHA256(pepper, password) + mac := hmac.New(sha256.New, pepper) + mac.Write([]byte(password)) + peppered := mac.Sum(nil) + + key := argon2.IDKey(peppered, salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key), + ), nil +} + +// checkNotSoleOwner returns ErrSoleOwner if the user is the only owner of any org. +func checkNotSoleOwner(ctx context.Context, tx pgx.Tx, userID UserID) error { + var slug string + err := tx.QueryRow(ctx, + `SELECT o.slug FROM org_members m + JOIN organizations o ON o.id = m.org_id + WHERE m.role = 'owner' AND o.deleted_at IS NULL + GROUP BY o.id, o.slug + HAVING count(*) = 1 AND bool_or(m.user_id = $1) + LIMIT 1`, userID, + ).Scan(&slug) + if err == nil { + return ErrSoleOwner + } + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + return err +} + +// resolveUser locks and returns the user ID within a transaction. +func resolveUser(ctx context.Context, tx pgx.Tx, ref UserRef) (UserID, error) { + col, val := ref.where() + q := sb.PostgreSQL.NewSelectBuilder() + + sql, args := q.Select("id").From("users"). + Where(q.Equal(col, val), q.IsNull("deleted_at")). + ForUpdate(). + Build() + + var id UserID + if err := tx.QueryRow(ctx, sql, args...).Scan(&id); err != nil { + var zero UserID + if errors.Is(err, pgx.ErrNoRows) { + return zero, ErrUserNotFound + } + + return zero, err + } + + return id, nil +} + +// resolveOrg locks and returns the org ID within a transaction. +func resolveOrg(ctx context.Context, tx pgx.Tx, ref OrgRef) (OrgID, error) { + col, val := ref.where() + q := sb.PostgreSQL.NewSelectBuilder() + + sql, args := q.Select("id").From("organizations"). + Where(q.Equal(col, val), q.IsNull("deleted_at")). + ForUpdate(). + Build() + + var id OrgID + if err := tx.QueryRow(ctx, sql, args...).Scan(&id); err != nil { + var zero OrgID + if errors.Is(err, pgx.ErrNoRows) { + return zero, ErrOrgNotFound + } + + return zero, err + } + + return id, nil +} + +// ValidateEmail checks that the value is a valid email address. +func ValidateEmail(value string) error { + if _, err := mail.ParseAddress(value); err != nil { + return ErrInvalidEmail + } + return nil +} + +// ValidateSlug checks format and returns the normalized (lowercased) slug. +func ValidateSlug(value string) (string, error) { + if len(value) < 2 || len(value) > 64 || !slugRe.MatchString(value) { + return "", ErrInvalidSlug + } + return strings.ToLower(value), nil +} + +// ValidateRole checks that the value is a valid role string. +// Derives valid roles from rolePermissions — single source of truth. +func ValidateRole(value string) error { + if _, ok := rolePermissions[value]; !ok { + return ErrInvalidRole + } + return nil +} diff --git a/cmd/mirum-server/id.go b/cmd/mirum-server/id.go new file mode 100644 index 0000000..09ec141 --- /dev/null +++ b/cmd/mirum-server/id.go @@ -0,0 +1,302 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "database/sql/driver" + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/google/uuid" +) + +var ( + ErrBadID = errors.New("database: bad id") + ErrBadIDPrefix = errors.New("database: wrong id prefix") +) + +type ( + OrgID = ID[OrgKind] + UserID = ID[UserKind] + WorkerID = ID[WorkerKind] +) + +// IDKind is a phantom-type tag that distinguishes otherwise-identical +// 16-byte IDs at the Go type level. Each tag is a zero-sized struct +// that carries only its 3-letter prefix. +type IDKind interface { + UserKind | OrgKind | WorkerKind + Prefix() string +} + +type ( + UserKind struct{} + OrgKind struct{} + WorkerKind struct{} +) + +func (OrgKind) Prefix() string { return "org" } +func (UserKind) Prefix() string { return "usr" } +func (WorkerKind) Prefix() string { return "wrk" } + +// ID[K] is a typed UUIDv7. Instantiations with different K are distinct +// types, so passing a UserID where an OrgID is expected is a compile +// error. Cross-kind conversion requires an explicit cast, visible in +// review. +type ID[K IDKind] uuid.UUID + +func NewID[K IDKind]() ID[K] { + return ID[K](uuid.Must(uuid.NewV7())) +} + +func IDFromBytes[K IDKind](b []byte) (ID[K], error) { + var zero ID[K] + if len(b) != 16 { + return zero, fmt.Errorf("%w: got %d bytes", ErrBadID, len(b)) + } + u := uuid.UUID(b) + if err := validateV7(u); err != nil { + return zero, err + } + return ID[K](u), nil +} + +func validateV7(u uuid.UUID) error { + if v := u.Variant(); v != uuid.RFC4122 { + return fmt.Errorf("%w: variant %s", ErrBadID, v) + } + if v := u.Version(); v != 7 { + return fmt.Errorf("%w: version %d, want 7", ErrBadID, v) + } + return nil +} + +func (id ID[K]) UUID() uuid.UUID { return uuid.UUID(id) } +func (id ID[K]) Bytes() []byte { return id[:] } + +func (id ID[K]) IsZero() bool { + var zero ID[K] + return id == zero +} + +// String returns the prefixed form for logs, JSON, errors and anywhere +// the entity type isn't obvious from context. For URL path segments +// where the route already names the type, use Bare(). +func (id ID[K]) String() string { + var k K + return k.Prefix() + "_" + encodeBase58(uuid.UUID(id)) +} + +// Bare returns the base58 form without a type prefix — ≤ 22 chars. +// Use this for URL path segments where the route already identifies +// the entity ("/org/:id"); use String() everywhere else. +func (id ID[K]) Bare() string { + return encodeBase58(uuid.UUID(id)) +} + +func (id ID[K]) LogValue() slog.Value { + return slog.StringValue(id.String()) +} + +func (id ID[K]) MarshalText() ([]byte, error) { + return []byte(id.String()), nil +} + +func (id *ID[K]) UnmarshalText(b []byte) error { + parsed, err := ParseID[K](string(b)) + if err != nil { + return err + } + *id = parsed + return nil +} + +func (id *ID[K]) Scan(src any) error { + var u uuid.UUID + if err := u.Scan(src); err != nil { + return err + } + *id = ID[K](u) + return nil +} + +func (id ID[K]) Value() (driver.Value, error) { + return uuid.UUID(id).Value() +} + +// ParseID accepts the prefixed base58 ("_"), bare base58 +// (≤ 22 chars, as returned by Bare()), or a canonical UUID string. +// Bare forms stay supported so existing CLI flags, manual SQL lookups +// and URL path parameters keep working without a flag day. +func ParseID[K IDKind](s string) (ID[K], error) { + var k K + u, err := parseID(k.Prefix(), s) + return ID[K](u), err +} + +func MustParseID[K IDKind](s string) ID[K] { + id, err := ParseID[K](s) + if err != nil { + panic(err) + } + return id +} + +// ParseAnyID parses a prefixed, bare base58, or canonical UUID string +// into raw 16 bytes. Unlike ParseID[K], it does not require a known +// entity kind — any 3-letter prefix is stripped silently. Intended for +// CLI boundary code that dispatches on proto field names. +func ParseAnyID(s string) ([16]byte, error) { + if s == "" { + return [16]byte{}, fmt.Errorf("%w: empty", ErrBadID) + } + // Strip any typed prefix. + if len(s) > 4 && s[3] == '_' { + s = s[4:] + } + if len(s) <= 22 { + return decodeBase58(s) + } + u, err := uuid.Parse(s) + if err != nil { + return [16]byte{}, fmt.Errorf("%w: %w", ErrBadID, err) + } + return u, nil +} + +// FormatAnyID formats raw 16-byte ID as bare base58. Returns base64 for +// non-16-byte inputs as a fallback. +func FormatAnyID(b []byte) string { + if len(b) == 16 { + return encodeBase58(uuid.UUID(b)) + } + return fmt.Sprintf("%x", b) +} + +func parseID(prefix, s string) (uuid.UUID, error) { + if s == "" { + return uuid.Nil, fmt.Errorf("%w: empty", ErrBadID) + } + + if rest, ok := strings.CutPrefix(s, prefix+"_"); ok { + return decodeBase58(rest) + } + + // Typed prefix with the wrong value — reject so a cross-type paste + // doesn't silently fall through to the bare path. + if len(s) > 4 && s[3] == '_' { + return uuid.Nil, fmt.Errorf("%w: want %q, got %q", ErrBadIDPrefix, prefix, s[:3]) + } + + // Bare form: base58 (≤ 22 chars, from Bare()) or canonical UUID + // (32–36 chars, from manual SQL / legacy CLI). + if len(s) <= 22 { + return decodeBase58(s) + } + + u, err := uuid.Parse(s) + if err != nil { + return uuid.Nil, fmt.Errorf("%w: %w", ErrBadID, err) + } + + return u, nil +} + +// --- base58 (Bitcoin alphabet) --- +// +// 16 bytes fit in ≤ 22 base58 chars: log₅₈(2¹²⁸) ≈ 21.86. +// UUIDv7 values in the post-1970 range always have a non-zero +// leading byte, so encoded length is effectively a constant 21-22. + +const b58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +var b58Index [256]byte + +func init() { + for i := range b58Index { + b58Index[i] = 0xff + } + for i := 0; i < len(b58Alphabet); i++ { + b58Index[b58Alphabet[i]] = byte(i) + } +} + +func encodeBase58(src uuid.UUID) string { + zeros := 0 + for zeros < 16 && src[zeros] == 0 { + zeros++ + } + + buf := src // array copy; long-division is in place + start := zeros + out := make([]byte, 0, 22) + + for start < 16 { + rem := 0 + for i := start; i < 16; i++ { + v := rem*256 + int(buf[i]) + buf[i] = byte(v / 58) + rem = v % 58 + } + out = append(out, b58Alphabet[rem]) + for start < 16 && buf[start] == 0 { + start++ + } + } + + for i := 0; i < zeros; i++ { + out = append(out, b58Alphabet[0]) + } + + // Reverse into big-endian order. + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + + return string(out) +} + +// decodeBase58 is strict about length — any input that round-trips to a +// value of a different byte length is rejected; we never want a +// 15-byte or 17-byte payload masquerading as a UUID. +func decodeBase58(s string) (uuid.UUID, error) { + if s == "" { + return uuid.Nil, fmt.Errorf("%w: empty", ErrBadID) + } + zeros := 0 + for zeros < len(s) && s[zeros] == b58Alphabet[0] { + zeros++ + } + + var out uuid.UUID + for i := zeros; i < len(s); i++ { + v := b58Index[s[i]] + if v == 0xff { + return uuid.Nil, fmt.Errorf("%w: bad base58 char %q", ErrBadID, s[i]) + } + carry := int(v) + for j := 15; j >= 0; j-- { + acc := int(out[j])*58 + carry + out[j] = byte(acc) + carry = acc >> 8 + } + if carry != 0 { + return uuid.Nil, fmt.Errorf("%w: overflow", ErrBadID) + } + } + + // The '1' prefix count in the string must equal the leading-zero + // byte count in the result — any mismatch means the input decoded + // to a different byte length than a UUID. + actualZeros := 0 + for actualZeros < 16 && out[actualZeros] == 0 { + actualZeros++ + } + if actualZeros != zeros { + return uuid.Nil, fmt.Errorf("%w: length mismatch", ErrBadID) + } + return out, nil +} diff --git a/cmd/mirum-server/id_test.go b/cmd/mirum-server/id_test.go new file mode 100644 index 0000000..0d06fc1 --- /dev/null +++ b/cmd/mirum-server/id_test.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "testing" + + "github.com/google/uuid" +) + +func TestIDRoundTrip(t *testing.T) { + for range 50 { + id := NewID[UserKind]() + got, err := ParseID[UserKind](id.String()) + if err != nil { + t.Fatalf("parse prefixed: %v", err) + } + if got != id { + t.Fatalf("prefixed: got %v, want %v", got, id) + } + got, err = ParseID[UserKind](id.Bare()) + if err != nil { + t.Fatalf("parse bare: %v", err) + } + if got != id { + t.Fatalf("bare: got %v, want %v", got, id) + } + } +} + +func TestIDRoundTripCanonicalUUID(t *testing.T) { + id := NewID[OrgKind]() + got, err := ParseID[OrgKind](id.UUID().String()) + if err != nil { + t.Fatalf("parse canonical: %v", err) + } + if got != id { + t.Fatalf("canonical: got %v, want %v", got, id) + } +} + +func TestIDCrossPrefixRejected(t *testing.T) { + id := NewID[UserKind]() + _, err := ParseID[OrgKind](id.String()) + if err == nil { + t.Fatal("expected error parsing usr_ as org") + } +} + +func TestBase58EdgeCases(t *testing.T) { + cases := [][16]byte{ + {}, // all zeros + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, // minimal + {0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, // max + } + for _, raw := range cases { + enc := encodeBase58(uuid.UUID(raw)) + dec, err := decodeBase58(enc) + if err != nil { + t.Fatalf("decode(%q): %v", enc, err) + } + if dec != uuid.UUID(raw) { + t.Fatalf("roundtrip: got %x, want %x", dec, raw) + } + } +} + +func TestIDFromBytesRejectsV4(t *testing.T) { + v4 := uuid.Must(uuid.NewRandom()) // v4 + _, err := IDFromBytes[UserKind](v4[:]) + if err == nil { + t.Fatal("expected v4 rejection") + } +} diff --git a/cmd/mirum-server/main.go b/cmd/mirum-server/main.go new file mode 100644 index 0000000..c592f71 --- /dev/null +++ b/cmd/mirum-server/main.go @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + + "dimidiumlabs/mirum/cmd/mirum-server/apipb/apipbconnect" + "dimidiumlabs/mirum/internal/config" + "dimidiumlabs/mirum/internal/forges" + "dimidiumlabs/mirum/internal/protocol/wirepb" + "dimidiumlabs/mirum/internal/supervisor" + + "github.com/coreos/go-systemd/v22/activation" + "github.com/spf13/cobra" +) + +func hardenServer(s *http.Server) *http.Server { + s.IdleTimeout = config.HTTPIdleTimeout + s.MaxHeaderBytes = config.HTTPMaxHeaderBytes + s.ReadHeaderTimeout = config.HTTPReadHeaderTimeout + return s +} + +func main() { + var socketPath string + + root := &cobra.Command{Use: "mirum-server", Short: "Mirum CI server"} + root.PersistentFlags().StringVar(&socketPath, "socket", "", "admin socket path (default from config or /run/mirum-server/admin.sock)") + root.AddGroup(&cobra.Group{ID: "main", Title: "Commands:"}) + + daemonCmd := &cobra.Command{ + Use: "daemon", + Short: "Start the server", + GroupID: "main", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + configFile, _ := cmd.Flags().GetString("config") + return daemon(configFile, socketPath) + }, + } + daemonCmd.Flags().String("config", "", "path to config file") + _ = daemonCmd.MarkFlagRequired("config") + root.AddCommand(daemonCmd) + + // Console subcommands are generated from api.proto via reflection. + buildConsoleCLI(root, func() apipbconnect.ConsoleClient { return consoleClient(socketPath) }) + for _, c := range root.Commands() { + if c.GroupID == "" { + c.GroupID = "main" + } + } + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func daemon(configFile, socketFlag string) error { + cfg, err := getConfig(configFile) + if err != nil { + slog.Error("config parsing failed", "err", err) + return err + } + + if socketFlag != "" { + cfg.AdminSocket = socketFlag + } + + slog.Info("config loaded", "configfile", configFile) + + sup := supervisor.Detect() + ctx, cancel := context.WithCancel(sup.WaitForStop(context.Background())) + defer cancel() + + db, err := DatabaseOpen(ctx, cfg.DatabaseUri) + if err != nil { + slog.Error("couldn't open database", "err", err) + return err + } + + srv := &server{ + db: db, + cfg: cfg, + forge: &forges.GitHub{Secret: cfg.WebhookSecret, Token: cfg.GitHubToken}, + queue: make(chan *wirepb.Task, config.TaskQueueCapacity), + } + defer srv.Close() + + if err := db.Migrate(ctx); err != nil { + slog.Error("migration failed", "err", err) + return err + } + + slog.Info("database ready") + + go srv.PurgeSessions(ctx) + + consolePath, consoleHandler := NewConsoleHandler(srv) + + webSrv := hardenServer(NewWebServer(ctx, srv, consolePath, consoleHandler)) + grpcSrv := hardenServer(NewGrpcServer(ctx, srv)) + + adminMux := http.NewServeMux() + adminMux.Handle(consolePath, consoleHandler) + adminSrv := hardenServer(&http.Server{ + Handler: adminMux, + ConnContext: func(ctx context.Context, _ net.Conn) context.Context { + return context.WithValue(ctx, actorKey{}, OperatorActor()) + }, + BaseContext: func(_ net.Listener) context.Context { + return ctx + }, + }) + + grpcLn, webLn, adminLn, err := listeners(cfg) + if err != nil { + slog.Error("listeners failed", "err", err) + return err + } + + slog.Info("listening", "grpc", grpcLn.Addr(), "web", webLn.Addr(), "admin", cfg.AdminSocket) + + errs := make(chan error, 3) + serve := func(name string, fn func() error) { + go func() { + err := fn() + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + if err != nil { + err = fmt.Errorf("%s server: %w", name, err) + } + errs <- err + }() + } + serve("web", func() error { + if webSrv.TLSConfig != nil { + return webSrv.ServeTLS(webLn, "", "") + } + return webSrv.Serve(webLn) + }) + serve("grpc", func() error { return grpcSrv.ServeTLS(grpcLn, "", "") }) + serve("admin", func() error { return adminSrv.Serve(adminLn) }) + + sup.Ready() + go sup.StartWatchdog(ctx) + + var runErr error + select { + case <-ctx.Done(): + slog.Info("shutting down") + case err := <-errs: + runErr = err + // Propagate the crash to all handler contexts so Poll and + // other long-lived RPCs exit via ctx.Done(); Shutdown below + // then completes without waiting on them. + cancel() + if err != nil { + slog.Error("server exited, shutting down peers", "err", err) + } else { + slog.Warn("server exited unexpectedly, shutting down peers") + } + } + + sup.Stopping() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), config.HTTPShutdownTimeout) + defer shutdownCancel() + + shutdown := func(name string, s *http.Server) { + if err := s.Shutdown(shutdownCtx); err != nil { + slog.Error("server shutdown", "name", name, "err", err) + } + } + + shutdown("web", webSrv) + shutdown("grpc", grpcSrv) + shutdown("admin", adminSrv) + + return runErr +} + +// listeners returns gRPC, web, and admin listeners. +// With systemd socket activation it expects two named fds: "grpc" and "web". +// Without socket activation it falls back to configured addresses. +func listeners(cfg *appConfig) (grpcLn, webLn, adminLn net.Listener, err error) { + named, err := activation.ListenersWithNames() + if err != nil { + return nil, nil, nil, fmt.Errorf("socket activation: %w", err) + } + + if lns := named["grpc"]; len(lns) > 0 { + grpcLn = lns[0] + } else if grpcLn, err = net.Listen("tcp", cfg.GrpcAddr); err != nil { + return nil, nil, nil, err + } + defer func() { + if err != nil && grpcLn != nil { + _ = grpcLn.Close() + } + }() + + if lns := named["web"]; len(lns) > 0 { + webLn = lns[0] + } else if webLn, err = net.Listen("tcp", cfg.WebAddr); err != nil { + return nil, nil, nil, err + } + defer func() { + if err != nil && webLn != nil { + _ = webLn.Close() + } + }() + + _ = os.Remove(cfg.AdminSocket) + if adminLn, err = net.Listen("unix", cfg.AdminSocket); err != nil { + return nil, nil, nil, err + } + defer func() { + if err != nil && adminLn != nil { + _ = adminLn.Close() + } + }() + + if err = os.Chmod(cfg.AdminSocket, 0o660); err != nil { + return nil, nil, nil, fmt.Errorf("chmod admin socket: %w", err) + } + + return grpcLn, webLn, adminLn, nil +} + +func consoleClient(socketPath string) apipbconnect.ConsoleClient { + if socketPath == "" { + socketPath = "/run/mirum-server/admin.sock" + } + return apipbconnect.NewConsoleClient( + &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + }, + "http://localhost.unix", + ) +} diff --git a/cmd/mirum-server/proto/api.proto b/cmd/mirum-server/proto/api.proto new file mode 100644 index 0000000..16a635e --- /dev/null +++ b/cmd/mirum-server/proto/api.proto @@ -0,0 +1,341 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +syntax = "proto3"; + +package mirum.api; + +import "buf/validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "dimidiumlabs/mirum/cmd/mirum-server/apipb"; + +service Console { + rpc UserCreate(UserCreateRequest) returns (UserCreateResponse); + rpc UserGet(UserGetRequest) returns (UserGetResponse); + rpc UserList(UserListRequest) returns (UserListResponse); + rpc UserUpdate(UserUpdateRequest) returns (UserUpdateResponse); + rpc UserDelete(UserDeleteRequest) returns (UserDeleteResponse); + + rpc OrgCreate(OrgCreateRequest) returns (OrgCreateResponse); + rpc OrgGet(OrgGetRequest) returns (OrgGetResponse); + rpc OrgList(OrgListRequest) returns (OrgListResponse); + rpc OrgUpdate(OrgUpdateRequest) returns (OrgUpdateResponse); + rpc OrgDelete(OrgDeleteRequest) returns (OrgDeleteResponse); + + rpc OrgMemberAdd(OrgMemberAddRequest) returns (OrgMemberAddResponse); + rpc OrgMemberGet(OrgMemberGetRequest) returns (OrgMemberGetResponse); + rpc OrgMemberList(OrgMemberListRequest) returns (OrgMemberListResponse); + rpc OrgMemberUpdate(OrgMemberUpdateRequest) returns (OrgMemberUpdateResponse); + rpc OrgMemberRemove(OrgMemberRemoveRequest) returns (OrgMemberRemoveResponse); + + rpc WorkerCreate(WorkerCreateRequest) returns (WorkerCreateResponse); + rpc WorkerGet(WorkerGetRequest) returns (WorkerGetResponse); + rpc WorkerList(WorkerListRequest) returns (WorkerListResponse); + rpc WorkerDelete(WorkerDeleteRequest) returns (WorkerDeleteResponse); +} + +// Common types + +enum Role { + ROLE_NONE = 0; + ROLE_OWNER = 1; + ROLE_ADMIN = 2; + ROLE_MEMBER = 3; +} + +enum Perm { + PERM_NONE = 0; + + PERM_USER_READ = 1; + PERM_USER_WRITE = 2; + PERM_USER_DELETE = 3; + + PERM_ORG_READ = 4; + PERM_ORG_WRITE = 5; + PERM_ORG_DELETE = 6; + PERM_ORG_MEMBER_READ = 7; + PERM_ORG_MEMBER_WRITE = 8; + + PERM_WORKER_READ = 9; + PERM_WORKER_WRITE = 10; +} + +// ErrorInfo is attached as a ConnectError detail on every error. +// The ConnectError message field is always empty — the server never sends +// human-readable text. Clients switch on reason to select user-facing copy. +message ErrorInfo { + ErrorReason reason = 1; + + // Short stable identifiers for UI targeting (e.g. {"field": "slug"}). + // Never human-readable text. + map metadata = 2; +} + +enum ErrorReason { + ERROR_REASON_UNSPECIFIED = 0; + ERROR_REASON_INTERNAL = 1; + + // Lookup failures + ERROR_REASON_USER_NOT_FOUND = 10; + ERROR_REASON_ORG_NOT_FOUND = 11; + ERROR_REASON_WORKER_NOT_FOUND = 12; + ERROR_REASON_MEMBER_NOT_FOUND = 13; + + // Conflicts + ERROR_REASON_EMAIL_TAKEN = 20; + ERROR_REASON_SLUG_TAKEN = 21; + ERROR_REASON_ALREADY_MEMBER = 22; + + // State preconditions + ERROR_REASON_LAST_OWNER = 30; + ERROR_REASON_SOLE_OWNER = 31; + + // Validation + ERROR_REASON_INVALID_SLUG = 40; + ERROR_REASON_INVALID_ROLE = 41; + ERROR_REASON_RESERVED_EMAIL = 42; + + // Auth + ERROR_REASON_UNAUTHENTICATED = 50; + ERROR_REASON_PERMISSION_DENIED = 51; + ERROR_REASON_INVALID_CREDENTIALS = 52; + ERROR_REASON_INVALID_CSRF = 53; + + // Infrastructure + ERROR_REASON_RATE_LIMITED = 60; + ERROR_REASON_UNAVAILABLE = 61; + ERROR_REASON_UNIMPLEMENTED = 62; +} + +message UserRef { + oneof ref { + option (buf.validate.oneof).required = true; + bytes id = 1 [(buf.validate.field).bytes = { + min_len: 16 + max_len: 16 + }]; + string email = 2 [(buf.validate.field).string.email = true]; + } +} + +message OrgRef { + oneof ref { + option (buf.validate.oneof).required = true; + bytes id = 1 [(buf.validate.field).bytes = { + min_len: 16 + max_len: 16 + }]; + string slug = 2 [(buf.validate.field).string = { + min_len: 2 + max_len: 64 + }]; + } +} + +message PageRequest { + bytes cursor = 1; + int32 page_size = 2; +} +message PageResponse { + bytes next_cursor = 1; + int32 total_count = 2; +} + +// User management + +message User { + bytes id = 1; + string email = 2; + google.protobuf.Timestamp created_at = 3; +} + +message UserCreateRequest { + string email = 1 [(buf.validate.field).string.email = true]; + string password = 2 [(buf.validate.field).string.min_len = 1]; +} +message UserCreateResponse { + bytes id = 1; +} + +message UserGetRequest { + UserRef user = 1 [(buf.validate.field).required = true]; +} +message UserGetResponse { + User user = 1; +} + +message UserListRequest { + optional PageRequest page = 1; + optional string filter = 2; +} +message UserListResponse { + PageResponse page = 1; + repeated User users = 2; +} + +message UserUpdateRequest { + UserRef user = 1 [(buf.validate.field).required = true]; + optional string email = 2 [(buf.validate.field).string.email = true]; + optional string password = 3 [(buf.validate.field).string.min_len = 1]; +} +message UserUpdateResponse {} + +message UserDeleteRequest { + UserRef user = 1 [(buf.validate.field).required = true]; +} +message UserDeleteResponse {} + +// Organization management + +message Org { + bytes id = 1; + string name = 2; + string slug = 3; + bool public = 4; + google.protobuf.Timestamp created_at = 5; +} + +message OrgCreateRequest { + string name = 1 [(buf.validate.field).string.min_len = 1]; + string slug = 2 [(buf.validate.field).string = { + min_len: 2 + max_len: 64 + }]; + UserRef owner = 3 [(buf.validate.field).required = true]; + bool public = 4; +} +message OrgCreateResponse { + bytes id = 1; +} + +message OrgGetRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; +} +message OrgGetResponse { + Org org = 1; +} + +message OrgListRequest { + optional PageRequest page = 1; + optional string filter = 2; +} +message OrgListResponse { + PageResponse page = 1; + repeated Org organizations = 2; +} + +message OrgUpdateRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; + optional string name = 2 [(buf.validate.field).string.min_len = 1]; + optional string slug = 3 [(buf.validate.field).string = { + min_len: 2 + max_len: 64 + }]; + optional bool public = 4; +} +message OrgUpdateResponse {} + +message OrgDeleteRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; +} +message OrgDeleteResponse {} + +// Organization members + +message OrgMemberInfo { + User user = 1; + Role role = 2; + google.protobuf.Timestamp joined_at = 3; +} + +message OrgMemberGetRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; + UserRef user = 2 [(buf.validate.field).required = true]; +} +message OrgMemberGetResponse { + OrgMemberInfo member = 1; +} + +message OrgMemberAddRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; + UserRef user = 2 [(buf.validate.field).required = true]; + Role role = 3 [(buf.validate.field).enum = { + defined_only: true + not_in: [0] + }]; +} +message OrgMemberAddResponse {} + +message OrgMemberListRequest { + optional PageRequest page = 1; + OrgRef org = 2 [(buf.validate.field).required = true]; + optional string filter = 3; +} +message OrgMemberListResponse { + PageResponse page = 1; + repeated OrgMemberInfo members = 2; +} + +message OrgMemberUpdateRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; + UserRef user = 2 [(buf.validate.field).required = true]; + Role role = 3 [(buf.validate.field).enum = { + defined_only: true + not_in: [0] + }]; +} +message OrgMemberUpdateResponse {} + +message OrgMemberRemoveRequest { + OrgRef org = 1 [(buf.validate.field).required = true]; + UserRef user = 2 [(buf.validate.field).required = true]; +} +message OrgMemberRemoveResponse {} + +// Worker management + +message Worker { + bytes id = 1; + bytes public_key = 2; + optional bytes org_id = 3; + google.protobuf.Timestamp created_at = 4; +} + +message WorkerCreateRequest { + bytes public_key = 1 [(buf.validate.field).bytes = { + min_len: 32 + max_len: 32 + }]; + optional OrgRef org = 2; +} +message WorkerCreateResponse { + bytes id = 1; +} + +message WorkerGetRequest { + bytes id = 1 [(buf.validate.field).bytes = { + min_len: 16 + max_len: 16 + }]; +} +message WorkerGetResponse { + Worker worker = 1; +} + +message WorkerListRequest { + optional PageRequest page = 1; + optional string filter = 2; +} +message WorkerListResponse { + PageResponse page = 1; + repeated Worker workers = 2; +} + +message WorkerDeleteRequest { + bytes id = 1 [(buf.validate.field).bytes = { + min_len: 16 + max_len: 16 + }]; +} +message WorkerDeleteResponse {} diff --git a/cmd/mirum-server/proto/buf.gen.yaml b/cmd/mirum-server/proto/buf.gen.yaml new file mode 100644 index 0000000..a9a94e2 --- /dev/null +++ b/cmd/mirum-server/proto/buf.gen.yaml @@ -0,0 +1,16 @@ +# Copyright (c) 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +version: v2 +plugins: + - local: protoc-gen-go + out: ../apipb + opt: paths=source_relative + - local: protoc-gen-connect-go + out: ../apipb + opt: paths=source_relative + - remote: buf.build/bufbuild/es:v2.11.0 + out: ../web/gen + opt: + - target=ts + - import_extension=js diff --git a/cmd/mirum-server/server.go b/cmd/mirum-server/server.go new file mode 100644 index 0000000..5b4bbf0 --- /dev/null +++ b/cmd/mirum-server/server.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "time" + + "dimidiumlabs/mirum/internal/config" + "dimidiumlabs/mirum/internal/forges" + "dimidiumlabs/mirum/internal/protocol/wirepb" +) + +// server holds the shared application state. +type server struct { + cfg *appConfig + db *DB + forge forges.Forge + + queue chan *wirepb.Task + tasks sync.Map // task_id → *forges.PushEvent + taskCounter atomic.Int64 +} + +// Close releases resources owned by the server. Call exactly once, after +// all HTTP servers have finished Shutdown. +func (s *server) Close() { + close(s.queue) + s.db.Close() +} + +// PurgeSessions periodically deletes expired sessions until ctx is cancelled. +func (s *server) PurgeSessions(ctx context.Context) { + ticker := time.NewTicker(config.SessionPurgeInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := s.db.UserSessionPurgeExpired(ctx); err != nil { + slog.Error("purge sessions", "err", err) + } + case <-ctx.Done(): + return + } + } +} + +func (s *server) enqueue(ev *forges.PushEvent) string { + s.taskCounter.Add(1) + id := fmt.Sprintf("task-%d", s.taskCounter.Load()) + + slog.Info("push", "repo", ev.Owner+"/"+ev.Repo, "branch", ev.Branch, "sha", ev.SHA[:8], "task", id) + + s.tasks.Store(id, ev) + _ = s.forge.SetStatus(context.Background(), ev, forges.StatusPending, "Queued") + + s.queue <- &wirepb.Task{ + Id: id, + CloneUrl: s.forge.AuthURL(ev.CloneURL), + Branch: ev.Branch, + Sha: ev.SHA, + RepoFullName: ev.Owner + "/" + ev.Repo, + } + + return id +} + +func (s *server) complete(ctx context.Context, taskID string, success bool, errMsg string) error { + val, ok := s.tasks.LoadAndDelete(taskID) + if !ok { + return fmt.Errorf("unknown task: %s", taskID) + } + ev := val.(*forges.PushEvent) + + st := forges.StatusSuccess + desc := "Build passed" + if !success { + st = forges.StatusFailure + desc = "Build failed" + if errMsg != "" { + desc = errMsg + } + } + + if err := s.forge.SetStatus(ctx, ev, st, desc); err != nil { + slog.Error("set status", "task", taskID, "err", err) + } + + slog.Info("task complete", "id", taskID, "success", success) + return nil +} diff --git a/cmd/mirum-server/server_admin.go b/cmd/mirum-server/server_admin.go new file mode 100644 index 0000000..a79c377 --- /dev/null +++ b/cmd/mirum-server/server_admin.go @@ -0,0 +1,535 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + + "connectrpc.com/connect" + "connectrpc.com/validate" + "google.golang.org/protobuf/types/known/timestamppb" + + "dimidiumlabs/mirum/cmd/mirum-server/apipb" + "dimidiumlabs/mirum/cmd/mirum-server/apipb/apipbconnect" +) + +// NewConsoleHandler creates the ConnectRPC handler with validation. +// Authorization is handled inside DB methods, not by an interceptor. +func NewConsoleHandler(srv *server) (string, http.Handler) { + as := &consoleService{srv: srv} + return apipbconnect.NewConsoleHandler(as, + connect.WithInterceptors(validate.NewInterceptor()), + ) +} + +type consoleService struct { + apipbconnect.UnimplementedConsoleHandler + srv *server +} + +// --- Error mapping --- + +// newAPIError builds a ConnectError with an empty message string and attaches +// an ErrorInfo detail carrying the domain reason. Clients switch on reason to +// pick user-facing text; the wire never carries human-readable strings. +func newAPIError(code connect.Code, reason apipb.ErrorReason, metadata map[string]string) error { + e := connect.NewError(code, nil) + if d, err := connect.NewErrorDetail(&apipb.ErrorInfo{Reason: reason, Metadata: metadata}); err == nil { + e.AddDetail(d) + } + return e +} + +var errSpecs = []struct { + err error + code connect.Code + reason apipb.ErrorReason +}{ + {ErrUserNotFound, connect.CodeNotFound, apipb.ErrorReason_ERROR_REASON_USER_NOT_FOUND}, + {ErrOrgNotFound, connect.CodeNotFound, apipb.ErrorReason_ERROR_REASON_ORG_NOT_FOUND}, + {ErrWorkerNotFound, connect.CodeNotFound, apipb.ErrorReason_ERROR_REASON_WORKER_NOT_FOUND}, + {ErrNotMember, connect.CodeNotFound, apipb.ErrorReason_ERROR_REASON_MEMBER_NOT_FOUND}, + {ErrEmailTaken, connect.CodeAlreadyExists, apipb.ErrorReason_ERROR_REASON_EMAIL_TAKEN}, + {ErrSlugTaken, connect.CodeAlreadyExists, apipb.ErrorReason_ERROR_REASON_SLUG_TAKEN}, + {ErrAlreadyMember, connect.CodeAlreadyExists, apipb.ErrorReason_ERROR_REASON_ALREADY_MEMBER}, + {ErrLastOwner, connect.CodeFailedPrecondition, apipb.ErrorReason_ERROR_REASON_LAST_OWNER}, + {ErrSoleOwner, connect.CodeFailedPrecondition, apipb.ErrorReason_ERROR_REASON_SOLE_OWNER}, + {ErrInvalidSlug, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_SLUG}, + {ErrInvalidRole, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_ROLE}, + {ErrReservedEmail, connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_RESERVED_EMAIL}, + {ErrPermissionDenied, connect.CodePermissionDenied, apipb.ErrorReason_ERROR_REASON_PERMISSION_DENIED}, + {ErrUnauthenticated, connect.CodeUnauthenticated, apipb.ErrorReason_ERROR_REASON_UNAUTHENTICATED}, + {ErrNotImplemented, connect.CodeUnimplemented, apipb.ErrorReason_ERROR_REASON_UNIMPLEMENTED}, +} + +func mapErr(err error) error { + if err == nil { + return nil + } + for _, s := range errSpecs { + if errors.Is(err, s.err) { + return newAPIError(s.code, s.reason, nil) + } + } + slog.Error("unmapped handler error", "err", err) + return newAPIError(connect.CodeInternal, apipb.ErrorReason_ERROR_REASON_INTERNAL, nil) +} + +// --- Ref converters --- + +func userRef(r *apipb.UserRef) (UserRef, error) { + switch v := r.GetRef().(type) { + case *apipb.UserRef_Id: + id, err := IDFromBytes[UserKind](v.Id) + if err != nil { + return UserRef{}, err + } + return UserByID(id), nil + case *apipb.UserRef_Email: + return UserByEmail(v.Email), nil + default: + return UserRef{}, nil + } +} + +func orgRef(r *apipb.OrgRef) (OrgRef, error) { + switch v := r.GetRef().(type) { + case *apipb.OrgRef_Id: + id, err := IDFromBytes[OrgKind](v.Id) + if err != nil { + return OrgRef{}, err + } + return OrgByID(id), nil + case *apipb.OrgRef_Slug: + return OrgBySlug(v.Slug), nil + default: + return OrgRef{}, nil + } +} + +// --- Role converters --- + +var roleToString = map[apipb.Role]string{ + apipb.Role_ROLE_OWNER: "owner", + apipb.Role_ROLE_ADMIN: "admin", + apipb.Role_ROLE_MEMBER: "member", +} + +var roleToProto = map[string]apipb.Role{ + "owner": apipb.Role_ROLE_OWNER, + "admin": apipb.Role_ROLE_ADMIN, + "member": apipb.Role_ROLE_MEMBER, +} + +// --- Page helpers --- + +const ( + defaultPageSize = 50 + maxPageSize = 200 +) + +func pageParams[K IDKind](p *apipb.PageRequest) (cursor ID[K], limit int, err error) { + limit = defaultPageSize + if p != nil { + if p.PageSize > 0 && int(p.PageSize) < maxPageSize { + limit = int(p.PageSize) + } else if int(p.PageSize) >= maxPageSize { + limit = maxPageSize + } + if len(p.Cursor) == 16 { + cursor, err = IDFromBytes[K](p.Cursor) + if err != nil { + return + } + } + } + return +} + +func pageResponse[K IDKind](items int, limit int, lastID ID[K], total int) *apipb.PageResponse { + resp := &apipb.PageResponse{TotalCount: int32(total)} + if items == limit { + resp.NextCursor = lastID.Bytes() + } + return resp +} + +// --- Proto converters --- + +func userToProto(u User) *apipb.User { + return &apipb.User{ + Id: u.ID.Bytes(), Email: u.Email, CreatedAt: timestamppb.New(u.CreatedAt), + } +} + +func orgToProto(o Organization) *apipb.Org { + return &apipb.Org{ + Id: o.ID.Bytes(), Name: o.Name, Slug: o.Slug, + Public: o.Public, CreatedAt: timestamppb.New(o.CreatedAt), + } +} + +func memberToProto(m OrgMember) *apipb.OrgMemberInfo { + return &apipb.OrgMemberInfo{ + User: userToProto(m.User), Role: roleToProto[m.Role], + JoinedAt: timestamppb.New(m.JoinedAt), + } +} + +func workerToProto(w Worker) *apipb.Worker { + pw := &apipb.Worker{ + Id: w.ID.Bytes(), PublicKey: w.PublicKey, CreatedAt: timestamppb.New(w.CreatedAt), + } + if w.OrgID != nil { + pw.OrgId = w.OrgID.Bytes() + } + return pw +} + +// --- User handlers --- + +func (a *consoleService) UserCreate(ctx context.Context, req *connect.Request[apipb.UserCreateRequest]) (*connect.Response[apipb.UserCreateResponse], error) { + id, err := a.srv.db.UserCreate(ctx, ActorFromContext(ctx), req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)) + if err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.UserCreateResponse{Id: id.Bytes()}), nil +} + +func (a *consoleService) UserGet(ctx context.Context, req *connect.Request[apipb.UserGetRequest]) (*connect.Response[apipb.UserGetResponse], error) { + ref, err := userRef(req.Msg.User) + if err != nil { + return nil, mapErr(err) + } + u, err := a.srv.db.UserGet(ctx, ActorFromContext(ctx), ref) + if err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.UserGetResponse{User: userToProto(*u)}), nil +} + +func (a *consoleService) UserList(ctx context.Context, req *connect.Request[apipb.UserListRequest]) (*connect.Response[apipb.UserListResponse], error) { + cursor, limit, err := pageParams[UserKind](req.Msg.Page) + if err != nil { + return nil, mapErr(err) + } + filter := "" + if req.Msg.Filter != nil { + filter = *req.Msg.Filter + } + + users, total, err := a.srv.db.UserList(ctx, ActorFromContext(ctx), cursor, limit, filter) + if err != nil { + return nil, mapErr(err) + } + + out := make([]*apipb.User, len(users)) + for i := range users { + out[i] = userToProto(users[i]) + } + + var lastID UserID + if len(users) > 0 { + lastID = users[len(users)-1].ID + } + + return connect.NewResponse(&apipb.UserListResponse{ + Page: pageResponse(len(users), limit, lastID, total), + Users: out, + }), nil +} + +func (a *consoleService) UserUpdate(ctx context.Context, req *connect.Request[apipb.UserUpdateRequest]) (*connect.Response[apipb.UserUpdateResponse], error) { + ref, err := userRef(req.Msg.User) + if err != nil { + return nil, mapErr(err) + } + if err := a.srv.db.UserUpdate(ctx, ActorFromContext(ctx), ref, req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)); err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.UserUpdateResponse{}), nil +} + +func (a *consoleService) UserDelete(ctx context.Context, req *connect.Request[apipb.UserDeleteRequest]) (*connect.Response[apipb.UserDeleteResponse], error) { + ref, err := userRef(req.Msg.User) + if err != nil { + return nil, mapErr(err) + } + if err := a.srv.db.UserDelete(ctx, ActorFromContext(ctx), ref); err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.UserDeleteResponse{}), nil +} + +// --- Org handlers --- + +func (a *consoleService) OrgCreate(ctx context.Context, req *connect.Request[apipb.OrgCreateRequest]) (*connect.Response[apipb.OrgCreateResponse], error) { + slug, err := ValidateSlug(req.Msg.Slug) + if err != nil { + return nil, mapErr(err) + } + owner, err := userRef(req.Msg.Owner) + if err != nil { + return nil, mapErr(err) + } + id, err := a.srv.db.OrgCreate(ctx, ActorFromContext(ctx), req.Msg.Name, slug, req.Msg.Public, owner) + if err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.OrgCreateResponse{Id: id.Bytes()}), nil +} + +func (a *consoleService) OrgGet(ctx context.Context, req *connect.Request[apipb.OrgGetRequest]) (*connect.Response[apipb.OrgGetResponse], error) { + ref, err := orgRef(req.Msg.Org) + if err != nil { + return nil, mapErr(err) + } + o, err := a.srv.db.OrgGet(ctx, ActorFromContext(ctx), ref) + if err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.OrgGetResponse{Org: orgToProto(*o)}), nil +} + +func (a *consoleService) OrgList(ctx context.Context, req *connect.Request[apipb.OrgListRequest]) (*connect.Response[apipb.OrgListResponse], error) { + cursor, limit, err := pageParams[OrgKind](req.Msg.Page) + if err != nil { + return nil, mapErr(err) + } + filter := "" + if req.Msg.Filter != nil { + filter = *req.Msg.Filter + } + + orgs, total, err := a.srv.db.OrgList(ctx, ActorFromContext(ctx), cursor, limit, filter) + if err != nil { + return nil, mapErr(err) + } + + out := make([]*apipb.Org, len(orgs)) + for i := range orgs { + out[i] = orgToProto(orgs[i]) + } + + var lastID OrgID + if len(orgs) > 0 { + lastID = orgs[len(orgs)-1].ID + } + + return connect.NewResponse(&apipb.OrgListResponse{ + Page: pageResponse(len(orgs), limit, lastID, total), + Organizations: out, + }), nil +} + +func (a *consoleService) OrgUpdate(ctx context.Context, req *connect.Request[apipb.OrgUpdateRequest]) (*connect.Response[apipb.OrgUpdateResponse], error) { + var slug *string + if req.Msg.Slug != nil { + s, err := ValidateSlug(*req.Msg.Slug) + if err != nil { + return nil, mapErr(err) + } + slug = &s + } + ref, err := orgRef(req.Msg.Org) + if err != nil { + return nil, mapErr(err) + } + if err := a.srv.db.OrgUpdate(ctx, ActorFromContext(ctx), ref, req.Msg.Name, slug, req.Msg.Public); err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.OrgUpdateResponse{}), nil +} + +func (a *consoleService) OrgDelete(ctx context.Context, req *connect.Request[apipb.OrgDeleteRequest]) (*connect.Response[apipb.OrgDeleteResponse], error) { + ref, err := orgRef(req.Msg.Org) + if err != nil { + return nil, mapErr(err) + } + if err := a.srv.db.OrgDelete(ctx, ActorFromContext(ctx), ref); err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.OrgDeleteResponse{}), nil +} + +// --- OrgMember handlers --- + +func (a *consoleService) OrgMemberAdd(ctx context.Context, req *connect.Request[apipb.OrgMemberAddRequest]) (*connect.Response[apipb.OrgMemberAddResponse], error) { + role, ok := roleToString[req.Msg.Role] + if !ok { + return nil, newAPIError(connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_ROLE, nil) + } + org, err := orgRef(req.Msg.Org) + if err != nil { + return nil, mapErr(err) + } + user, err := userRef(req.Msg.User) + if err != nil { + return nil, mapErr(err) + } + if err := a.srv.db.OrgMemberAdd(ctx, ActorFromContext(ctx), org, user, role); err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.OrgMemberAddResponse{}), nil +} + +func (a *consoleService) OrgMemberGet(ctx context.Context, req *connect.Request[apipb.OrgMemberGetRequest]) (*connect.Response[apipb.OrgMemberGetResponse], error) { + org, err := orgRef(req.Msg.Org) + if err != nil { + return nil, mapErr(err) + } + user, err := userRef(req.Msg.User) + if err != nil { + return nil, mapErr(err) + } + m, err := a.srv.db.OrgMemberGet(ctx, ActorFromContext(ctx), org, user) + if err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.OrgMemberGetResponse{Member: memberToProto(*m)}), nil +} + +func (a *consoleService) OrgMemberList(ctx context.Context, req *connect.Request[apipb.OrgMemberListRequest]) (*connect.Response[apipb.OrgMemberListResponse], error) { + cursor, limit, err := pageParams[UserKind](req.Msg.Page) + if err != nil { + return nil, mapErr(err) + } + filter := "" + if req.Msg.Filter != nil { + filter = *req.Msg.Filter + } + org, err := orgRef(req.Msg.Org) + if err != nil { + return nil, mapErr(err) + } + + members, total, err := a.srv.db.OrgMembersList(ctx, ActorFromContext(ctx), org, cursor, limit, filter) + if err != nil { + return nil, mapErr(err) + } + + out := make([]*apipb.OrgMemberInfo, len(members)) + for i := range members { + out[i] = memberToProto(members[i]) + } + + var lastID UserID + if len(members) > 0 { + lastID = members[len(members)-1].User.ID + } + + return connect.NewResponse(&apipb.OrgMemberListResponse{ + Page: pageResponse(len(members), limit, lastID, total), + Members: out, + }), nil +} + +func (a *consoleService) OrgMemberUpdate(ctx context.Context, req *connect.Request[apipb.OrgMemberUpdateRequest]) (*connect.Response[apipb.OrgMemberUpdateResponse], error) { + role, ok := roleToString[req.Msg.Role] + if !ok { + return nil, newAPIError(connect.CodeInvalidArgument, apipb.ErrorReason_ERROR_REASON_INVALID_ROLE, nil) + } + org, err := orgRef(req.Msg.Org) + if err != nil { + return nil, mapErr(err) + } + user, err := userRef(req.Msg.User) + if err != nil { + return nil, mapErr(err) + } + if err := a.srv.db.OrgMemberUpdateRole(ctx, ActorFromContext(ctx), org, user, role); err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.OrgMemberUpdateResponse{}), nil +} + +func (a *consoleService) OrgMemberRemove(ctx context.Context, req *connect.Request[apipb.OrgMemberRemoveRequest]) (*connect.Response[apipb.OrgMemberRemoveResponse], error) { + org, err := orgRef(req.Msg.Org) + if err != nil { + return nil, mapErr(err) + } + user, err := userRef(req.Msg.User) + if err != nil { + return nil, mapErr(err) + } + if err := a.srv.db.OrgMemberRemove(ctx, ActorFromContext(ctx), org, user); err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.OrgMemberRemoveResponse{}), nil +} + +// --- Worker handlers --- + +func (a *consoleService) WorkerCreate(ctx context.Context, req *connect.Request[apipb.WorkerCreateRequest]) (*connect.Response[apipb.WorkerCreateResponse], error) { + var org *OrgRef + if req.Msg.Org != nil { + r, err := orgRef(req.Msg.Org) + if err != nil { + return nil, mapErr(err) + } + org = &r + } + id, err := a.srv.db.WorkerCreate(ctx, ActorFromContext(ctx), req.Msg.PublicKey, org) + if err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.WorkerCreateResponse{Id: id.Bytes()}), nil +} + +func (a *consoleService) WorkerGet(ctx context.Context, req *connect.Request[apipb.WorkerGetRequest]) (*connect.Response[apipb.WorkerGetResponse], error) { + wid, err := IDFromBytes[WorkerKind](req.Msg.Id) + if err != nil { + return nil, mapErr(err) + } + w, err := a.srv.db.WorkerGet(ctx, ActorFromContext(ctx), wid) + if err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.WorkerGetResponse{Worker: workerToProto(*w)}), nil +} + +func (a *consoleService) WorkerList(ctx context.Context, req *connect.Request[apipb.WorkerListRequest]) (*connect.Response[apipb.WorkerListResponse], error) { + cursor, limit, err := pageParams[WorkerKind](req.Msg.Page) + if err != nil { + return nil, mapErr(err) + } + filter := "" + if req.Msg.Filter != nil { + filter = *req.Msg.Filter + } + + workers, total, err := a.srv.db.WorkerList(ctx, ActorFromContext(ctx), cursor, limit, filter) + if err != nil { + return nil, mapErr(err) + } + + out := make([]*apipb.Worker, len(workers)) + for i := range workers { + out[i] = workerToProto(workers[i]) + } + + var lastID WorkerID + if len(workers) > 0 { + lastID = workers[len(workers)-1].ID + } + + return connect.NewResponse(&apipb.WorkerListResponse{ + Page: pageResponse(len(workers), limit, lastID, total), + Workers: out, + }), nil +} + +func (a *consoleService) WorkerDelete(ctx context.Context, req *connect.Request[apipb.WorkerDeleteRequest]) (*connect.Response[apipb.WorkerDeleteResponse], error) { + wid, err := IDFromBytes[WorkerKind](req.Msg.Id) + if err != nil { + return nil, mapErr(err) + } + if err := a.srv.db.WorkerDelete(ctx, ActorFromContext(ctx), wid); err != nil { + return nil, mapErr(err) + } + return connect.NewResponse(&apipb.WorkerDeleteResponse{}), nil +} diff --git a/cmd/mirum-server/server_grpc.go b/cmd/mirum-server/server_grpc.go new file mode 100644 index 0000000..a2f7cf0 --- /dev/null +++ b/cmd/mirum-server/server_grpc.go @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "crypto/ed25519" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "time" + + "connectrpc.com/connect" + "connectrpc.com/validate" + + "dimidiumlabs/mirum/internal/config" + "dimidiumlabs/mirum/internal/protocol" + "dimidiumlabs/mirum/internal/protocol/wirepb" + "dimidiumlabs/mirum/internal/protocol/wirepb/wirepbconnect" +) + +func NewGrpcServer(ctx context.Context, srv *server) *http.Server { + gsrv := &grpcService{srv: srv} + + path, handler := wirepbconnect.NewWorkerHandler(gsrv, + connect.WithInterceptors(validate.NewInterceptor()), + ) + + mux := http.NewServeMux() + mux.Handle(path, workerLog(handler)) + + certs := newCertReloader(srv.cfg.GrpcTls.Cert, srv.cfg.GrpcTls.Key) + + return &http.Server{ + Handler: mux, + BaseContext: func(_ net.Listener) context.Context { + return ctx + }, + TLSConfig: &tls.Config{ + NextProtos: []string{"h2"}, + MinVersion: tls.VersionTLS13, + ClientAuth: tls.RequireAnyClientCert, + GetCertificate: certs.GetCertificate, + VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return errors.New("client certificate required") + } + + c, err := x509.ParseCertificate(rawCerts[0]) + if err != nil { + return fmt.Errorf("parse client cert: %w", err) + } + + pubKey, ok := c.PublicKey.(ed25519.PublicKey) + if !ok { + return errors.New("ed25519 certificate required") + } + + if _, err := srv.db.WorkerLookup(context.Background(), SystemActor(), pubKey); err != nil { + return fmt.Errorf("unknown worker: %w", err) + } + + // Clock skew: NotBefore is set to time.Now() when the cert was generated. + // Checked here (once per TLS handshake), not in the interceptor, + // because HTTP/2 reuses the connection and NotBefore would go stale. + if skew := time.Since(c.NotBefore).Abs(); skew > config.WorkerClockSkewLimit { + return fmt.Errorf("%w: %s", protocol.ErrClockSkew, skew.Truncate(time.Second)) + } + + return nil + }, + }, + } +} + +// grpcService is the ConnectRPC transport adapter over server. +type grpcService struct { + wirepbconnect.UnimplementedWorkerHandler + srv *server +} + +func (g *grpcService) Poll(ctx context.Context, req *connect.Request[wirepb.PollRequest]) (*connect.Response[wirepb.Task], error) { + select { + case task, ok := <-g.srv.queue: + if !ok { + return nil, connect.NewError(connect.CodeUnavailable, fmt.Errorf("server is shutting down")) + } + slog.Info("task dispatched", "id", task.Id, "repo", task.RepoFullName) + return connect.NewResponse(task), nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (g *grpcService) Complete(ctx context.Context, req *connect.Request[wirepb.TaskResult]) (*connect.Response[wirepb.CompleteResponse], error) { + if err := g.srv.complete(ctx, req.Msg.TaskId, req.Msg.Success, req.Msg.Error); err != nil { + return nil, err + } + return connect.NewResponse(&wirepb.CompleteResponse{}), nil +} + +// workerLog logs worker metadata from the mTLS client certificate +// and sets the server version response header. +func workerLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { + if meta := protocol.ParseWorkerMeta(r.TLS.PeerCertificates[0]); meta != nil { + slog.Info("worker request", + "name", meta.Name, + "version", meta.Version, + "path", r.URL.Path, + ) + } + } + w.Header().Set("X-Server-Version", protocol.VersionString()) + next.ServeHTTP(w, r) + }) +} diff --git a/cmd/mirum-server/server_web.go b/cmd/mirum-server/server_web.go new file mode 100644 index 0000000..8e62e53 --- /dev/null +++ b/cmd/mirum-server/server_web.go @@ -0,0 +1,390 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "crypto/tls" + "encoding/base64" + "errors" + "io" + "log/slog" + "net" + "net/http" + "runtime/debug" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httprate" + + "dimidiumlabs/mirum/cmd/mirum-server/apipb" + "dimidiumlabs/mirum/internal/config" + "dimidiumlabs/mirum/internal/forges" +) + +// __Host- prefixed cookies can only be set with Secure, Path=/, and no +// Domain attribute. Browsers silently reject violations, so subdomain and +// network attackers cannot forge them. +const ( + sessionCookie = "__Host-session" + csrfCookie = "__Host-csrf" +) + +func NewWebServer(ctx context.Context, srv *server, consolePath string, consoleHandler http.Handler) *http.Server { + h := &webHandler{ + srv: srv, + assets: newAssetResolver(), + } + + r := chi.NewRouter() + + r.Use(middleware.CleanPath) + r.Use(middleware.StripSlashes) + r.Use(middleware.RequestID) + r.Use(middleware.Logger) + r.Use(h.recoverer) + r.Use(middleware.Compress(5)) + r.Use(middleware.Heartbeat("/ping")) + r.Use(middleware.Timeout(config.WebRequestTimeout)) + r.Use(middleware.RequestSize(config.WebMaxBodyBytes)) + r.Use(trustedProxyMiddleware(srv.cfg.TrustedProxies)) + + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", csp) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") + w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + w.Header().Set("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()") + + if srv.cfg.WebTls != nil { + w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload") + } + + next.ServeHTTP(w, r) + }) + }) + + // The authorization session sets the user to ctx + r.Use(h.SessionMiddleware) + + r.With(middleware.SetHeader("Cache-Control", "public, max-age=31536000, immutable")). + Mount("/assets", assetsHandler()) + + r.Get("/", authonly(h.index)) + r.Post("/webhook", h.webhook) + + r.Route("/auth", func(r chi.Router) { + r.Use(middleware.NoCache) + r.Use(httprate.LimitByIP(config.AuthRateLimit, config.AuthRateWindow)) + r.Use(middleware.RequestSize(config.AuthMaxBodyBytes)) + + r.Get("/login", h.loginPage) + r.Post("/login", h.login) + r.Post("/logout", h.logout) + }) + + r.With(middleware.NoCache, httprate.LimitByIP(config.APIRateLimit, config.APIRateWindow)). + Mount("/api/v1", http.StripPrefix("/api/v1", consoleHandler)) + + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + h.renderError(w, r, http.StatusNotFound) + }) + r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { + h.renderError(w, r, http.StatusMethodNotAllowed) + }) + + var tlsCfg *tls.Config + if srv.cfg.WebTls != nil { + certs := newCertReloader(srv.cfg.WebTls.Cert, srv.cfg.WebTls.Key) + tlsCfg = &tls.Config{ + MinVersion: tls.VersionTLS13, + GetCertificate: certs.GetCertificate, + } + } + + return &http.Server{ + Handler: r, + TLSConfig: tlsCfg, + BaseContext: func(_ net.Listener) context.Context { + return ctx + }, + } +} + +type actorKey struct{} + +type webHandler struct { + srv *server + assets *assetResolver +} + +// ActorFromContext returns the authenticated actor, or AnonActor if none. +func ActorFromContext(ctx context.Context) Actor { + if v, ok := ctx.Value(actorKey{}).(Actor); ok { + return v + } + return AnonActor() +} + +// SessionMiddleware resolves the session cookie and puts the Actor in context. +func (h *webHandler) SessionMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil { + if actor, err := h.srv.db.UserSessionGet(r.Context(), SystemActor(), c.Value); err == nil { + ctx := context.WithValue(r.Context(), actorKey{}, actor) + r = r.WithContext(ctx) + } + } + next.ServeHTTP(w, r) + }) +} + +type authedHandler func(w http.ResponseWriter, r *http.Request, actor Actor) + +func authonly(next authedHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor := ActorFromContext(r.Context()) + if actor.Kind() == KindAnon { + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) + return + } + next(w, r, actor) + } +} + +func (h *webHandler) index(w http.ResponseWriter, r *http.Request, actor Actor) { + h.assets.renderPage(w, "dashboard", http.StatusOK, map[string]any{ + "user": map[string]string{"email": actor.Email()}, + "csrf": csrfToken(w, r), + }) +} + +// renderError serves the error page with the given HTTP status. +func (h *webHandler) renderError(w http.ResponseWriter, r *http.Request, status int) { + h.assets.renderPage(w, "error", status, map[string]any{"status": status}) +} + +// recoverer catches panics, logs them, and renders the 500 page so the +// client sees something more useful than chi's plaintext default. The +// http.ErrAbortHandler sentinel is re-raised so net/http's server +// machinery can recognise an intentional handler abort. +func (h *webHandler) recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + rvr := recover() + if rvr == nil { + return + } + if rvr == http.ErrAbortHandler { + panic(rvr) + } + slog.Error("panic", + "err", rvr, + "path", r.URL.Path, + "stack", string(debug.Stack()), + ) + h.renderError(w, r, http.StatusInternalServerError) + }() + next.ServeHTTP(w, r) + }) +} + +func (h *webHandler) webhook(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body", http.StatusBadRequest) + return + } + + ev, err := h.srv.forge.Webhook(r, body) + if errors.Is(err, forges.ErrInvalidSignature) { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if ev == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + h.srv.enqueue(ev) + w.WriteHeader(http.StatusAccepted) +} + +func (h *webHandler) loginPage(w http.ResponseWriter, r *http.Request) { + h.renderLogin(w, r, http.StatusOK, apipb.ErrorReason_ERROR_REASON_UNSPECIFIED) +} + +// renderLogin is the single entry point for every login-flow outcome that +// lands back on the login page. Reason == UNSPECIFIED means no error banner. +// No caller writes error text itself — the client maps reason → copy. +func (h *webHandler) renderLogin(w http.ResponseWriter, r *http.Request, status int, reason apipb.ErrorReason) { + data := map[string]any{"csrf": csrfToken(w, r)} + if reason != apipb.ErrorReason_ERROR_REASON_UNSPECIFIED { + data["errorReason"] = int32(reason) + } + h.assets.renderPage(w, "login", status, data) +} + +func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { + if !csrfOK(r) { + clearCookie(w, csrfCookie) + h.renderLogin(w, r, http.StatusForbidden, apipb.ErrorReason_ERROR_REASON_INVALID_CSRF) + return + } + + email := r.FormValue("email") + password := r.FormValue("password") + + userID, err := h.srv.db.UserVerifyPassword(r.Context(), SystemActor(), email, password, []byte(h.srv.cfg.Pepper)) + if err != nil { + h.renderLogin(w, r, http.StatusUnauthorized, apipb.ErrorReason_ERROR_REASON_INVALID_CREDENTIALS) + return + } + + token, err := h.srv.db.UserSessionCreate(r.Context(), SystemActor(), userID) + if err != nil { + slog.Error("create session failed", "err", err) + h.renderLogin(w, r, http.StatusInternalServerError, apipb.ErrorReason_ERROR_REASON_INTERNAL) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(config.SessionTTL.Seconds()), + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) { + if !csrfOK(r) { + // Forged logout attempt — ignore silently. Session stays valid, + // user ends up wherever / takes them. + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if c, err := r.Cookie(sessionCookie); err == nil { + h.srv.db.UserSessionDelete(r.Context(), SystemActor(), c.Value) + } + clearCookie(w, sessionCookie) + clearCookie(w, csrfCookie) + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) +} + +// csrfToken returns the current CSRF token, setting a cookie if absent. +func csrfToken(w http.ResponseWriter, r *http.Request) string { + if c, err := r.Cookie(csrfCookie); err == nil && c.Value != "" { + return c.Value + } + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + token := base64.RawURLEncoding.EncodeToString(b) + http.SetCookie(w, &http.Cookie{ + Name: csrfCookie, + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: int(config.SessionTTL.Seconds()), + }) + return token +} + +// csrfOK checks that the form field or X-CSRF-Token header matches the +// cookie (double-submit). Form posts use the hidden "csrf" field; API calls +// from the SPA pass the token via the X-CSRF-Token header. +func csrfOK(r *http.Request) bool { + cookie, err := r.Cookie(csrfCookie) + if err != nil || cookie.Value == "" { + return false + } + token := r.FormValue("csrf") + if token == "" { + token = r.Header.Get("X-CSRF-Token") + } + return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(token)) == 1 +} + +func clearCookie(w http.ResponseWriter, name string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + MaxAge: -1, + }) +} + +// trustedProxyMiddleware resolves the real client IP from X-Forwarded-For, +// walking right-to-left and stopping at the first untrusted hop. +// Empty cidrs = trust RemoteAddr only (safe default). +func trustedProxyMiddleware(cidrs []string) func(http.Handler) http.Handler { + nets := make([]*net.IPNet, 0, len(cidrs)) + for _, c := range cidrs { + _, n, err := net.ParseCIDR(c) + if err != nil { + panic("invalid trusted_proxies CIDR: " + c) + } + nets = append(nets, n) + } + + isTrusted := func(ip net.IP) bool { + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(nets) == 0 { + next.ServeHTTP(w, r) + return + } + + host, _, _ := net.SplitHostPort(r.RemoteAddr) + ip := net.ParseIP(host) + if ip == nil || !isTrusted(ip) { + // RemoteAddr is not a trusted proxy — use as-is. + next.ServeHTTP(w, r) + return + } + + // Walk X-Forwarded-For right to left. + xff := strings.Split(r.Header.Get("X-Forwarded-For"), ",") + for i := len(xff) - 1; i >= 0; i-- { + candidate := strings.TrimSpace(xff[i]) + ip = net.ParseIP(candidate) + if ip == nil { + break // garbage — stop, don't trust anything further left + } + if !isTrusted(ip) { + r.RemoteAddr = candidate + ":0" + break + } + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/cmd/mirum-server/static.go b/cmd/mirum-server/static.go new file mode 100644 index 0000000..bc304ba --- /dev/null +++ b/cmd/mirum-server/static.go @@ -0,0 +1,235 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "crypto/sha512" + "embed" + "encoding/base64" + "encoding/json" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "path" + "strings" +) + +//go:embed web/*.html +var templateFS embed.FS + +var shellTmpl = template.Must(template.ParseFS(templateFS, "web/shell.html")) + +type assetRef struct { + Href string + Integrity string // "sha384-BASE64" or empty +} + +type pageAssets struct { + CSS []assetRef // + Scripts []assetRef // + {{- if .Preamble}} + + {{- end}} + {{- range .Scripts}} + + {{- end}} + + diff --git a/cmd/mirum-server/web/tsconfig.json b/cmd/mirum-server/web/tsconfig.json new file mode 100644 index 0000000..90b2f46 --- /dev/null +++ b/cmd/mirum-server/web/tsconfig.json @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@/*": ["./*"] }, + + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2023", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + }, + "include": ["components", "api", "lib", "entries", "gen"] +} diff --git a/cmd/mirum-server/web/vite.config.ts b/cmd/mirum-server/web/vite.config.ts new file mode 100644 index 0000000..6de11ff --- /dev/null +++ b/cmd/mirum-server/web/vite.config.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { readdirSync } from "node:fs" +import { resolve, parse } from "node:path" +import { defineConfig } from "vite" + +import react from "@vitejs/plugin-react" +import tailwindcss from "@tailwindcss/vite" + +const input = readdirSync(resolve(__dirname, "entries")).reduce((acc, file) => { + const {name} = parse(file); + acc[name] = resolve(__dirname, "entries", file) + return acc +}, {} as Record) + +export default defineConfig({ + clearScreen: false, + plugins: [react(), tailwindcss()], + resolve: { + alias: { "@": resolve(__dirname) }, + }, + server: { + host: "127.0.0.1", + origin: "http://localhost:5173", + cors: { origin: "http://localhost:3000" }, + }, + build: { + outDir: "../static", + manifest: true, + emptyOutDir: true, + rollupOptions: { + input, + output: { + entryFileNames: "assets/[name].[hash].js", + chunkFileNames: "assets/[name].[hash].js", + assetFileNames: "assets/[name].[hash][extname]", + manualChunks(id) { + if (id.includes("node_modules/react") || id.includes("node_modules/react-dom")) { + return "vendor" + } + }, + }, + }, + }, +}) diff --git a/cmd/mirum-worker/client.go b/cmd/mirum-worker/client.go new file mode 100644 index 0000000..1525904 --- /dev/null +++ b/cmd/mirum-worker/client.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "log/slog" + "net/http" + "os" + "runtime" + + "connectrpc.com/connect" + + "dimidiumlabs/mirum/internal/executor" + "dimidiumlabs/mirum/internal/protocol" + "dimidiumlabs/mirum/internal/protocol/wirepb" + "dimidiumlabs/mirum/internal/protocol/wirepb/wirepbconnect" +) + +type client struct { + cfg *config + http *http.Client + handle wirepbconnect.WorkerClient +} + +func dial(ctx context.Context, cfg *config) (*client, error) { + name := cfg.Name + if name == "" { + name, _ = os.Hostname() + } + + meta := &protocol.WorkerMeta{ + Os: runtime.GOOS, + Arch: runtime.GOARCH, + Name: name, + Runtime: workerRuntime, + Version: protocol.VersionString(), + } + + tlsCfg := &tls.Config{ + NextProtos: []string{"h2"}, + MinVersion: tls.VersionTLS13, + GetClientCertificate: func(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) { + key, err := protocol.LoadPrivateKey(cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("load key: %w", err) + } + + cert, err := protocol.SelfSignedCert(key, meta) + return &cert, err + }, + } + + if cfg.TLSCA != "" { + caCert, err := os.ReadFile(cfg.TLSCA) + if err != nil { + return nil, fmt.Errorf("read CA cert: %w", err) + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("failed to parse CA cert") + } + + tlsCfg.RootCAs = pool + } + + c := &client{ + cfg: cfg, + http: &http.Client{ + Transport: &http.Transport{TLSClientConfig: tlsCfg, ForceAttemptHTTP2: true}, + }, + } + c.handle = wirepbconnect.NewWorkerClient(c.http, "https://"+cfg.Server, connect.WithGRPC()) + + slog.Info("dialing", "server", cfg.Server) + + return c, nil +} + +func (c *client) close() {} + +func (c *client) work(ctx context.Context) error { + for ctx.Err() == nil { + resp, err := c.handle.Poll(ctx, connect.NewRequest(&wirepb.PollRequest{})) + if err != nil { + return fmt.Errorf("poll: %w", err) + } + + for _, w := range resp.Header().Values("X-Warning") { + slog.Warn("server warning", "msg", w) + } + + task := resp.Msg + slog.Info("task received", "id", task.Id, "repo", task.RepoFullName) + + execErr := executor.Run(task.CloneUrl, task.Branch) + + result := &wirepb.TaskResult{TaskId: task.Id, Success: execErr == nil} + if execErr != nil { + result.Error = execErr.Error() + slog.Error("task failed", "id", task.Id, "err", execErr) + } else { + slog.Info("task passed", "id", task.Id) + } + + if _, err := c.handle.Complete(ctx, connect.NewRequest(result)); err != nil { + return fmt.Errorf("complete: %w", err) + } + } + + return ctx.Err() +} diff --git a/cmd/mirum-worker/config.go b/cmd/mirum-worker/config.go new file mode 100644 index 0000000..366fdf4 --- /dev/null +++ b/cmd/mirum-worker/config.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Runtime type of this worker binary. Different worker types +// (mirum-worker-vm, mirum-worker-docker, etc.) will have different values. +const workerRuntime = "host" + +type config struct { + Name string `yaml:"name"` + Server string `yaml:"server"` + KeyFile string `yaml:"key_file"` + TLSCA string `yaml:"tls_ca"` // custom CA cert for self-signed/dev +} + +func getConfig(filename string) (*config, error) { + cfg := &config{ + Server: "localhost:2026", + } + + if filename != "" { + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("couldn't read config: %w", err) + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("couldn't parse config: %w", err) + } + } + + if cfg.KeyFile == "" { + return nil, fmt.Errorf("error: key_file is required") + } + + return cfg, nil +} diff --git a/cmd/mirum-worker/main.go b/cmd/mirum-worker/main.go new file mode 100644 index 0000000..e2132b5 --- /dev/null +++ b/cmd/mirum-worker/main.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "flag" + "log/slog" + "os" + + "dimidiumlabs/mirum/internal/protocol" + "dimidiumlabs/mirum/internal/supervisor" +) + +func main() { + configFile := flag.String("config", "", "path to config file") + flag.Parse() + + cfg, err := getConfig(*configFile) + if err != nil { + slog.Error("config", "err", err) + os.Exit(1) + } + + sup := supervisor.Detect() + ctx := sup.WaitForStop(context.Background()) + + sup.Ready() + go sup.StartWatchdog(ctx) + + backoff := protocol.NewBackoff() + + for ctx.Err() == nil { + c, err := dial(ctx, cfg) + if err != nil { + slog.Error("connect failed", "err", err) + if !backoff.Wait(ctx) { + break + } + + continue + } + + slog.Info("connected", "server", cfg.Server) + + if err := c.work(ctx); err != nil && ctx.Err() == nil { + slog.Error("work loop failed", "err", err) + c.close() + if !backoff.Wait(ctx) { + break + } + continue + } + + backoff.Reset() + c.close() + } + + slog.Info("shutting down") + sup.Stopping() +} diff --git a/cmd/mirumd/actor.go b/cmd/mirumd/actor.go deleted file mode 100644 index bdd49c2..0000000 --- a/cmd/mirumd/actor.go +++ /dev/null @@ -1,252 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "context" - "errors" - "slices" - - "dimidiumlabs/mirum/internal/protocol/pb" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" -) - -var ( - ErrPermissionDenied = errors.New("database: permission denied") - ErrUnauthenticated = errors.New("database: authentication required") -) - -var anonPermissions = []pb.Perm{ - pb.Perm_PERM_ORG_READ, -} - -var userGlobalPermissions = []pb.Perm{ - pb.Perm_PERM_ORG_READ, - pb.Perm_PERM_ORG_WRITE, - pb.Perm_PERM_USER_READ, -} - -// rolePermissions is the single source of truth for role → perm bundles. -// RLS checks only tenancy (membership); action authz lives here. -var rolePermissions = map[string][]pb.Perm{ - "owner": { - pb.Perm_PERM_ORG_READ, - pb.Perm_PERM_ORG_WRITE, - pb.Perm_PERM_ORG_DELETE, - pb.Perm_PERM_ORG_MEMBER_READ, - pb.Perm_PERM_ORG_MEMBER_WRITE, - pb.Perm_PERM_WORKER_READ, - pb.Perm_PERM_WORKER_WRITE, - }, - "admin": { - pb.Perm_PERM_ORG_READ, - pb.Perm_PERM_ORG_WRITE, - pb.Perm_PERM_ORG_MEMBER_READ, - pb.Perm_PERM_ORG_MEMBER_WRITE, - pb.Perm_PERM_WORKER_READ, - pb.Perm_PERM_WORKER_WRITE, - }, - "member": { - pb.Perm_PERM_ORG_READ, - pb.Perm_PERM_ORG_MEMBER_READ, - pb.Perm_PERM_WORKER_READ, - }, -} - -// Actor is the principal making a database request. It carries identity, -// display metadata, and coarse capability. Zero value is invalid: dbID -// panics, so a missing initialisation cannot silently grant privileges. -// -// Synthetic actors (System/Operator/Anon) live only as Go constants — -// they are not rows in the users table, so they cannot be logged in as -// even if somebody writes a password into the DB. -// -// Authorization is divided into two planes: -// - Tenancy: an actor can only see a subset of resources to which -// they have access (public or through organization membership). -// Any select statement will return only records accessible to the actor. -// - RBAC: what the actor can do with records (create/read/write) is implemented here. -// Any rights we grant here are a strict subset of the Tenancy rights. -// The list of perms can be either explicit (for tokens) or implied (for user roles). -type Actor struct { - kind actorKind - id uuid.UUID - email string - superuser bool -} - -type actorKind uint8 - -const ( - actorInvalid actorKind = iota - actorUser - actorOperator - actorSystem - actorAnon -) - -// ActorKind is the exported form of actorKind for audit sinks and logging. -type ActorKind uint8 - -const ( - KindInvalid ActorKind = iota - KindUser - KindOperator - KindSystem - KindAnon -) - -var ( - anonUUID = uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff") - systemUUID = uuid.MustParse("00000000-0000-0000-0000-000000000001") - operatorUUID = uuid.MustParse("00000000-0000-0000-0000-000000000002") -) - -// UserActor identifies an authenticated user from a session or token. -func UserActor(id UserID, email string, superuser bool) Actor { - if id.IsZero() { - panic("database: UserActor with nil UUID") - } - if email == "" { - panic("database: UserActor with empty email") - } - return Actor{kind: actorUser, id: id.UUID(), email: email, superuser: superuser} -} - -// OperatorActor is the principal for externally invoked privileged -// operations (admin socket). Distinguishable from System in audit logs. -func OperatorActor() Actor { - return Actor{kind: actorOperator, id: operatorUUID, email: "operator@mirum.local", superuser: true} -} - -// SystemActor is the principal for internal machinery (mTLS handshake, -// session bootstrap, background jobs). Not an operator action. -func SystemActor() Actor { - return Actor{kind: actorSystem, id: systemUUID, email: "system@mirum.local", superuser: true} -} - -// AnonActor is the principal for unauthenticated public requests. -func AnonActor() Actor { - return Actor{kind: actorAnon, id: anonUUID, email: "anonymous@mirum.local"} -} - -func (a Actor) Kind() ActorKind { - switch a.kind { - case actorUser: - return KindUser - case actorOperator: - return KindOperator - case actorSystem: - return KindSystem - case actorAnon: - return KindAnon - } - return KindInvalid -} - -func (a Actor) UserID() UserID { return UserID(a.id) } -func (a Actor) Email() string { return a.email } -func (a Actor) IsSuperuser() bool { return a.superuser } - -// dbID returns the UUID to write into app.user_id. Panics on zero value. -func (a Actor) dbID() uuid.UUID { - if a.id == uuid.Nil { - panic("database: zero-value Actor; use UserActor/SystemActor/OperatorActor/AnonActor") - } - return a.id -} - -// kindString returns the string written into app.actor_kind. -// It must match the values tested by app_issuper() in the SQL migration. -func (a Actor) kindString() string { - switch a.kind { - case actorUser: - return "user" - case actorOperator: - return "operator" - case actorSystem: - return "system" - case actorAnon: - return "anon" - } - panic("database: zero-value Actor; use UserActor/SystemActor/OperatorActor/AnonActor") -} - -// checkGlobal checks a global-scope perm (no specific org). Pure, no DB. -func checkGlobal(actor Actor, perm pb.Perm) error { - switch actor.kind { - case actorOperator, actorSystem: - return nil - case actorAnon: - if slices.Contains(anonPermissions, perm) { - return nil - } - return ErrUnauthenticated - case actorUser: - if actor.superuser { - return nil - } - if slices.Contains(userGlobalPermissions, perm) { - return nil - } - return ErrPermissionDenied - default: - return ErrPermissionDenied - } -} - -// checkPerm checks an org-scoped perm within an existing transaction. -func checkPerm(ctx context.Context, tx pgx.Tx, actor Actor, orgID OrgID, perm pb.Perm) error { - switch actor.kind { - case actorOperator, actorSystem: - return nil - case actorAnon: - return ErrUnauthenticated - case actorUser: - if actor.superuser { - return nil - } - var role string - err := tx.QueryRow(ctx, - `SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2`, - orgID, actor.id, - ).Scan(&role) - if err != nil { - return ErrPermissionDenied - } - if !slices.Contains(rolePermissions[role], perm) { - return ErrPermissionDenied - } - return nil - default: - return ErrPermissionDenied - } -} - -// checkSystem checks that actor is the internal system principal. -func checkSystem(actor Actor) error { - if actor.kind == actorSystem { - return nil - } - return ErrPermissionDenied -} - -// checkSelf checks that actor is the target user or superuser. -func checkSelf(actor Actor, targetID UserID) error { - switch actor.kind { - case actorOperator, actorSystem: - return nil - case actorAnon: - return ErrUnauthenticated - case actorUser: - if actor.superuser || targetID == actor.UserID() { - return nil - } - return ErrPermissionDenied - default: - return ErrPermissionDenied - } -} diff --git a/cmd/mirumd/api_cli.go b/cmd/mirumd/api_cli.go deleted file mode 100644 index 6d53511..0000000 --- a/cmd/mirumd/api_cli.go +++ /dev/null @@ -1,424 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -// Admin CLI is generated from admin.proto at startup via protoreflect. -// RPC name is camelCase-split into a cobra path: UserCreate -> "user create", -// OrgMemberAdd -> "org member add". Flags come from request fields, dispatch -// goes through reflect on pbconnect.AdminClient. - -import ( - "context" - "crypto/ed25519" - "crypto/x509" - "encoding/base64" - "fmt" - "os" - "reflect" - "strings" - "unicode" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/reflect/protoreflect" - "google.golang.org/protobuf/reflect/protoregistry" - "google.golang.org/protobuf/types/known/timestamppb" - - "dimidiumlabs/mirum/internal/protocol/pb" - "dimidiumlabs/mirum/internal/protocol/pb/pbconnect" -) - -// mkClient is called per-invocation so persistent flags (e.g. --socket) are -// already parsed by the time it runs. -func buildAdminCLI(root *cobra.Command, mkClient func() pbconnect.AdminClient) { - methods := pb.File_admin_proto.Services().ByName("Admin").Methods() - for i := 0; i < methods.Len(); i++ { - md := methods.Get(i) - path := splitCamel(string(md.Name())) - parent := ensureGroups(root, path[:len(path)-1]) - parent.AddCommand(buildMethodCmd(md, path[len(path)-1], mkClient)) - } -} - -// "OrgMemberAdd" -> ["org","member","add"]. -func splitCamel(s string) []string { - var parts []string - start := 0 - for i := 1; i < len(s); i++ { - if unicode.IsUpper(rune(s[i])) { - parts = append(parts, strings.ToLower(s[start:i])) - start = i - } - } - return append(parts, strings.ToLower(s[start:])) -} - -func ensureGroups(root *cobra.Command, path []string) *cobra.Command { - parent := root - for _, name := range path { - var next *cobra.Command - for _, c := range parent.Commands() { - if c.Name() == name { - next = c - break - } - } - if next == nil { - next = &cobra.Command{Use: name, Short: "Manage " + name} - parent.AddCommand(next) - } - parent = next - } - return parent -} - -// fieldSetter writes one flag value into the request message. -type fieldSetter func(*pflag.FlagSet, protoreflect.Message) error - -func buildMethodCmd(md protoreflect.MethodDescriptor, leaf string, mkClient func() pbconnect.AdminClient) *cobra.Command { - reqDesc := md.Input() - rpcName := string(md.Name()) - cmd := &cobra.Command{ - Use: leaf, - Short: rpcName, - } - setters := registerRequestFlags(cmd, reqDesc) - cmd.Run = func(c *cobra.Command, _ []string) { - req, err := buildRequest(reqDesc, c.Flags(), setters) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - resp, err := dispatchAdmin(mkClient(), rpcName, req) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - printResponse(resp) - } - return cmd -} - -func registerRequestFlags(cmd *cobra.Command, desc protoreflect.MessageDescriptor) []fieldSetter { - var setters []fieldSetter - fields := desc.Fields() - for i := 0; i < fields.Len(); i++ { - if s := registerField(cmd, fields.Get(i)); s != nil { - setters = append(setters, s) - } - } - return setters -} - -func registerField(cmd *cobra.Command, fd protoreflect.FieldDescriptor) fieldSetter { - flagName := strings.ReplaceAll(string(fd.Name()), "_", "-") - // Required iff non-optional in the schema; bools default to false, so - // marking them required makes no sense. - required := !fd.HasOptionalKeyword() && fd.Kind() != protoreflect.BoolKind - flags := cmd.Flags() - - markRequired := func() { - if required { - _ = cmd.MarkFlagRequired(flagName) - } - } - - switch fd.Kind() { - case protoreflect.StringKind: - flags.String(flagName, "", string(fd.Name())) - markRequired() - return func(fs *pflag.FlagSet, m protoreflect.Message) error { - v, _ := fs.GetString(flagName) - if v == "" { - return nil - } - m.Set(fd, protoreflect.ValueOfString(v)) - return nil - } - - case protoreflect.BoolKind: - flags.Bool(flagName, false, string(fd.Name())) - return func(fs *pflag.FlagSet, m protoreflect.Message) error { - // Preserve "unset" vs "false" for optional bools. - if fd.HasOptionalKeyword() && !fs.Changed(flagName) { - return nil - } - v, _ := fs.GetBool(flagName) - m.Set(fd, protoreflect.ValueOfBool(v)) - return nil - } - - case protoreflect.BytesKind: - flags.String(flagName, "", string(fd.Name())) - markRequired() - return func(fs *pflag.FlagSet, m protoreflect.Message) error { - v, _ := fs.GetString(flagName) - if v == "" { - return nil - } - b, err := parseBytesFlag(string(fd.Name()), v) - if err != nil { - return fmt.Errorf("--%s: %w", flagName, err) - } - m.Set(fd, protoreflect.ValueOfBytes(b)) - return nil - } - - case protoreflect.EnumKind: - flags.String(flagName, "", string(fd.Name())) - markRequired() - return func(fs *pflag.FlagSet, m protoreflect.Message) error { - v, _ := fs.GetString(flagName) - if v == "" { - return nil - } - n, err := parseEnumFlag(fd.Enum(), v) - if err != nil { - return fmt.Errorf("--%s: %w", flagName, err) - } - m.Set(fd, protoreflect.ValueOfEnum(n)) - return nil - } - - case protoreflect.MessageKind: - return registerMessageField(cmd, fd, flagName, required) - } - - if required { - panic(fmt.Sprintf("admincli: unhandled required field %s (kind=%s)", fd.FullName(), fd.Kind())) - } - return nil -} - -// Only UserRef/OrgRef are flattened (to the string arm of their oneof); -// PageRequest is skipped; anything else required panics at startup so -// schema changes can't silently send malformed requests. -func registerMessageField(cmd *cobra.Command, fd protoreflect.FieldDescriptor, flagName string, required bool) fieldSetter { - flags := cmd.Flags() - markRequired := func() { - if required { - _ = cmd.MarkFlagRequired(flagName) - } - } - - switch fd.Message().FullName() { - case "mirum.UserRef": - flags.String(flagName, "", "user email") - markRequired() - return func(fs *pflag.FlagSet, m protoreflect.Message) error { - v, _ := fs.GetString(flagName) - if v == "" { - return nil - } - ref := &pb.UserRef{Ref: &pb.UserRef_Email{Email: v}} - m.Set(fd, protoreflect.ValueOfMessage(ref.ProtoReflect())) - return nil - } - - case "mirum.OrgRef": - flags.String(flagName, "", "org slug") - markRequired() - return func(fs *pflag.FlagSet, m protoreflect.Message) error { - v, _ := fs.GetString(flagName) - if v == "" { - return nil - } - ref := &pb.OrgRef{Ref: &pb.OrgRef_Slug{Slug: v}} - m.Set(fd, protoreflect.ValueOfMessage(ref.ProtoReflect())) - return nil - } - - case "mirum.PageRequest": - return nil - } - - if required { - panic(fmt.Sprintf("admincli: unhandled required message field %s (type=%s)", fd.FullName(), fd.Message().FullName())) - } - return nil -} - -// Admin schema uses bytes only for UUIDs (id / *_id) and ed25519 PKIX keys. -func parseBytesFlag(fieldName, v string) ([]byte, error) { - switch { - case fieldName == "id" || strings.HasSuffix(fieldName, "_id"): - id, err := ParseAnyID(v) - if err != nil { - return nil, fmt.Errorf("invalid id: %w", err) - } - return id[:], nil - case strings.Contains(fieldName, "key"): - der, err := base64.StdEncoding.DecodeString(v) - if err != nil { - return nil, fmt.Errorf("invalid base64: %w", err) - } - pub, err := x509.ParsePKIXPublicKey(der) - if err != nil { - return nil, fmt.Errorf("invalid public key: %w", err) - } - ed, ok := pub.(ed25519.PublicKey) - if !ok { - return nil, fmt.Errorf("not an ed25519 key") - } - return ed, nil - } - return nil, fmt.Errorf("unsupported bytes field %q", fieldName) -} - -// Accepts both short ("admin") and full ("ROLE_ADMIN") forms. -func parseEnumFlag(ed protoreflect.EnumDescriptor, v string) (protoreflect.EnumNumber, error) { - want := strings.ToUpper(v) - values := ed.Values() - if ev := values.ByName(protoreflect.Name(want)); ev != nil { - return ev.Number(), nil - } - prefix := strings.ToUpper(string(ed.Name())) + "_" - if ev := values.ByName(protoreflect.Name(prefix + want)); ev != nil { - return ev.Number(), nil - } - return 0, fmt.Errorf("unknown %s value %q", ed.Name(), v) -} - -func buildRequest(desc protoreflect.MessageDescriptor, flags *pflag.FlagSet, setters []fieldSetter) (proto.Message, error) { - mt, err := protoregistry.GlobalTypes.FindMessageByName(desc.FullName()) - if err != nil { - return nil, fmt.Errorf("find message %s: %w", desc.FullName(), err) - } - m := mt.New() - for _, set := range setters { - if err := set(flags, m); err != nil { - return nil, err - } - } - return m.Interface(), nil -} - -// reflect.New on *connect.Request[T] is equivalent to connect.NewRequest(req): -// Msg is the only public field, the rest are initialised lazily at send time. -func dispatchAdmin(client pbconnect.AdminClient, name string, req proto.Message) (proto.Message, error) { - cv := reflect.ValueOf(client) - method := cv.MethodByName(name) - if !method.IsValid() { - return nil, fmt.Errorf("unknown admin method %q", name) - } - // method signature: - // func(context.Context, *connect.Request[T]) (*connect.Response[U], error) - reqPtrType := method.Type().In(1) // *connect.Request[T] - reqWrap := reflect.New(reqPtrType.Elem()) - reqWrap.Elem().FieldByName("Msg").Set(reflect.ValueOf(req)) - - out := method.Call([]reflect.Value{ - reflect.ValueOf(context.Background()), - reqWrap, - }) - if errV := out[1]; !errV.IsNil() { - return nil, errV.Interface().(error) - } - return out[0].Elem().FieldByName("Msg").Interface().(proto.Message), nil -} - -// Shape-driven printer: empty → "ok", single bytes id → UUID, single message -// → TSV of scalars, repeated → one TSV line per element, anything else → TSV -// of top-level scalars. PageResponse metadata is ignored. -func printResponse(resp proto.Message) { - m := resp.ProtoReflect() - fields := m.Descriptor().Fields() - - var meaningful []protoreflect.FieldDescriptor - for i := 0; i < fields.Len(); i++ { - f := fields.Get(i) - if f.Kind() == protoreflect.MessageKind && f.Message().FullName() == "mirum.PageResponse" { - continue - } - meaningful = append(meaningful, f) - } - - if len(meaningful) == 0 { - fmt.Println("ok") - return - } - if len(meaningful) == 1 { - f := meaningful[0] - v := m.Get(f) - switch { - case f.IsList(): - list := v.List() - for j := 0; j < list.Len(); j++ { - item := list.Get(j) - if f.Kind() == protoreflect.MessageKind { - fmt.Println(formatMessageTSV(item.Message())) - } else { - fmt.Println(formatScalar(f, item)) - } - } - case f.Kind() == protoreflect.MessageKind: - fmt.Println(formatMessageTSV(v.Message())) - default: - fmt.Println(formatScalar(f, v)) - } - return - } - fmt.Println(formatMessageTSV(m)) -} - -func formatMessageTSV(m protoreflect.Message) string { - var parts []string - fields := m.Descriptor().Fields() - for i := 0; i < fields.Len(); i++ { - f := fields.Get(i) - if f.IsList() || f.IsMap() { - continue - } - if f.HasOptionalKeyword() && !m.Has(f) { - continue - } - if f.Kind() == protoreflect.MessageKind { - if f.Message().FullName() == "google.protobuf.Timestamp" { - ts := m.Get(f).Message().Interface().(*timestamppb.Timestamp) - parts = append(parts, ts.AsTime().Format("2006-01-02")) - continue - } - nested := m.Get(f).Message() - if nested.IsValid() { - parts = append(parts, formatMessageTSV(nested)) - } - continue - } - parts = append(parts, formatScalar(f, m.Get(f))) - } - return strings.Join(parts, "\t") -} - -func formatScalar(fd protoreflect.FieldDescriptor, v protoreflect.Value) string { - switch fd.Kind() { - case protoreflect.StringKind: - return v.String() - case protoreflect.BoolKind: - if v.Bool() { - return "true" - } - return "false" - case protoreflect.BytesKind: - return formatBytes(v.Bytes()) - case protoreflect.EnumKind: - ev := fd.Enum().Values().ByNumber(v.Enum()) - if ev == nil { - return fmt.Sprintf("%d", v.Enum()) - } - name := string(ev.Name()) - if idx := strings.IndexByte(name, '_'); idx >= 0 { - name = name[idx+1:] - } - return strings.ToLower(name) - } - return v.String() -} - -func formatBytes(b []byte) string { - if len(b) == 16 { - return FormatAnyID(b) - } - return base64.StdEncoding.EncodeToString(b) -} diff --git a/cmd/mirumd/cert.go b/cmd/mirumd/cert.go deleted file mode 100644 index 67128d5..0000000 --- a/cmd/mirumd/cert.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "crypto/tls" - "log/slog" - "os" - "sync" - "time" -) - -// certReloader serves a TLS cert/key pair and reloads it when either file -// changes on disk (e.g. after a letsencrypt renewal). -type certReloader struct { - certFile string - keyFile string - - mu sync.Mutex - cert *tls.Certificate - certMod time.Time - keyMod time.Time -} - -func newCertReloader(certFile, keyFile string) *certReloader { - r := &certReloader{certFile: certFile, keyFile: keyFile} - if _, err := r.GetCertificate(nil); err != nil { - slog.Warn("initial cert load failed", "cert", certFile, "err", err) - } - return r -} - -// GetCertificate plugs into tls.Config.GetCertificate. On a transient -// reload error it returns the last good pair so a mid-renewal race -// (cert swapped but key still being written) doesn't break handshakes. -func (r *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { - cs, cerr := os.Stat(r.certFile) - ks, kerr := os.Stat(r.keyFile) - - r.mu.Lock() - defer r.mu.Unlock() - - if cerr == nil && kerr == nil && r.cert != nil && - cs.ModTime().Equal(r.certMod) && ks.ModTime().Equal(r.keyMod) { - return r.cert, nil - } - - cert, err := tls.LoadX509KeyPair(r.certFile, r.keyFile) - if err != nil { - if r.cert != nil { - slog.Warn("cert reload failed, serving cached", "cert", r.certFile, "err", err) - return r.cert, nil - } - return nil, err - } - - r.cert = &cert - if cerr == nil { - r.certMod = cs.ModTime() - } - if kerr == nil { - r.keyMod = ks.ModTime() - } - slog.Info("cert loaded", "cert", r.certFile) - return r.cert, nil -} diff --git a/cmd/mirumd/cert_test.go b/cmd/mirumd/cert_test.go deleted file mode 100644 index 794b2f6..0000000 --- a/cmd/mirumd/cert_test.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "crypto/ed25519" - "crypto/rand" - "crypto/x509" - "encoding/pem" - "math/big" - "os" - "path/filepath" - "testing" - "time" -) - -func writeTestPair(t *testing.T, certPath, keyPath string) { - t.Helper() - - pub, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - if err != nil { - t.Fatal(err) - } - - der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ - SerialNumber: serial, - NotBefore: time.Now(), - NotAfter: time.Now().Add(time.Hour), - }, &x509.Certificate{SerialNumber: serial}, pub, priv) - if err != nil { - t.Fatal(err) - } - - if err := os.WriteFile(certPath, - pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), - 0o644); err != nil { - t.Fatal(err) - } - - keyDER, err := x509.MarshalPKCS8PrivateKey(priv) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(keyPath, - pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), - 0o600); err != nil { - t.Fatal(err) - } -} - -func TestCertReloader_Cached(t *testing.T) { - dir := t.TempDir() - certPath := filepath.Join(dir, "cert.pem") - keyPath := filepath.Join(dir, "key.pem") - writeTestPair(t, certPath, keyPath) - - r := newCertReloader(certPath, keyPath) - - first, err := r.GetCertificate(nil) - if err != nil { - t.Fatal(err) - } - second, err := r.GetCertificate(nil) - if err != nil { - t.Fatal(err) - } - if first != second { - t.Fatal("expected same pointer on cache hit") - } -} - -func TestCertReloader_ReloadsOnMtimeChange(t *testing.T) { - dir := t.TempDir() - certPath := filepath.Join(dir, "cert.pem") - keyPath := filepath.Join(dir, "key.pem") - writeTestPair(t, certPath, keyPath) - - r := newCertReloader(certPath, keyPath) - first, err := r.GetCertificate(nil) - if err != nil { - t.Fatal(err) - } - - writeTestPair(t, certPath, keyPath) - future := time.Now().Add(time.Second) - if err := os.Chtimes(certPath, future, future); err != nil { - t.Fatal(err) - } - if err := os.Chtimes(keyPath, future, future); err != nil { - t.Fatal(err) - } - - second, err := r.GetCertificate(nil) - if err != nil { - t.Fatal(err) - } - if first == second { - t.Fatal("expected new pointer after mtime change") - } -} - -func TestCertReloader_FallbackOnReloadError(t *testing.T) { - dir := t.TempDir() - certPath := filepath.Join(dir, "cert.pem") - keyPath := filepath.Join(dir, "key.pem") - writeTestPair(t, certPath, keyPath) - - r := newCertReloader(certPath, keyPath) - good, err := r.GetCertificate(nil) - if err != nil { - t.Fatal(err) - } - - if err := os.WriteFile(certPath, []byte("garbage"), 0o644); err != nil { - t.Fatal(err) - } - future := time.Now().Add(time.Second) - if err := os.Chtimes(certPath, future, future); err != nil { - t.Fatal(err) - } - - fallback, err := r.GetCertificate(nil) - if err != nil { - t.Fatalf("expected last-good fallback, got error: %v", err) - } - if fallback != good { - t.Fatal("expected cached cert on corrupted file") - } -} - -func TestCertReloader_ErrorOnFirstLoad(t *testing.T) { - r := newCertReloader("/nonexistent/cert.pem", "/nonexistent/key.pem") - if _, err := r.GetCertificate(nil); err == nil { - t.Fatal("expected error on missing files") - } -} diff --git a/cmd/mirumd/config.go b/cmd/mirumd/config.go deleted file mode 100644 index 854e7ec..0000000 --- a/cmd/mirumd/config.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "fmt" - "os" - - "gopkg.in/yaml.v3" -) - -type tlsConfig struct { - Cert string `yaml:"cert"` - Key string `yaml:"key"` -} - -type appConfig struct { - WebAddr string `yaml:"web_addr"` - GrpcAddr string `yaml:"grpc_addr"` - AdminSocket string `yaml:"admin_socket"` - DatabaseUri string `yaml:"database_uri"` - Pepper string `yaml:"pepper"` - - GrpcTls tlsConfig `yaml:"grpc_tls"` - WebTls *tlsConfig `yaml:"web_tls"` // optional - - TrustedProxies []string `yaml:"trusted_proxies"` // CIDR list, empty = trust RemoteAddr only - - GitHubToken string `yaml:"token"` - WebhookSecret string `yaml:"webhook_secret"` -} - -func getConfig(filename string) (*appConfig, error) { - cfg := &appConfig{ - GrpcAddr: ":2026", - WebAddr: ":3000", - AdminSocket: "/run/mirumd/admin.sock", - } - - data, err := os.ReadFile(filename) - if err != nil { - return nil, err - } - - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, err - } - - if cfg.DatabaseUri == "" { - return nil, fmt.Errorf("error: database_uri is required") - } - if cfg.WebhookSecret == "" { - return nil, fmt.Errorf("error: webhook_secret is required") - } - if cfg.Pepper == "" { - return nil, fmt.Errorf("error: pepper is required") - } - if cfg.GrpcTls.Cert == "" || cfg.GrpcTls.Key == "" { - return nil, fmt.Errorf("error: grpc_tls.cert and grpc_tls.key are required") - } - - return cfg, nil -} diff --git a/cmd/mirumd/database.go b/cmd/mirumd/database.go deleted file mode 100644 index 6c03f7a..0000000 --- a/cmd/mirumd/database.go +++ /dev/null @@ -1,1499 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "context" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "crypto/subtle" - "encoding/base64" - "errors" - "fmt" - "log/slog" - "net/mail" - "regexp" - "strings" - "time" - - "dimidiumlabs/mirum/internal/config" - "dimidiumlabs/mirum/internal/protocol/pb" - - sb "github.com/huandu/go-sqlbuilder" - "github.com/jackc/pgerrcode" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/jackc/tern/v2/migrate" - "golang.org/x/crypto/argon2" -) - -var ( - ErrAcquire = errors.New("database: failed to acquire connection") - ErrAlreadyMember = errors.New("database: already a member") - ErrEmailTaken = errors.New("database: email already taken") - ErrInvalidCreds = errors.New("database: invalid credentials") - ErrInvalidEmail = errors.New("database: invalid email") - ErrInvalidRole = errors.New("database: invalid role") - ErrInvalidSlug = errors.New("database: invalid slug") - ErrLastOwner = errors.New("database: last owner") - ErrMigrate = errors.New("database: failed to create migrator") - ErrNotImplemented = errors.New("database: filter not implemented") - ErrNotMember = errors.New("database: not a member") - ErrOpen = errors.New("database: failed to open") - ErrOrgNotFound = errors.New("database: organization not found") - ErrPing = errors.New("database: failed to ping") - ErrReservedEmail = errors.New("database: email uses a reserved domain") - ErrSlugTaken = errors.New("database: slug already taken") - ErrSoleOwner = errors.New("database: sole owner of an organization") - ErrUserNotFound = errors.New("database: user not found") - ErrWorkerNotFound = errors.New("database: worker not found") -) - -// reservedEmailSuffix is the domain carved out for synthetic actors -// (system/operator/anon). Real users cannot register with this suffix. -const reservedEmailSuffix = "@mirum.local" - -const ( - saltLen = 16 - - argonTime = 3 - argonMemory = 64 * 1024 // 64 MB - argonKeyLen = 32 - argonThreads = 2 -) - -var slugRe = regexp.MustCompile(`^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$`) - -// UserRef identifies a user by ID or email. -type UserRef struct { - id UserID - email string -} - -func UserByID(id UserID) UserRef { return UserRef{id: id} } - -func UserByEmail(email string) UserRef { return UserRef{email: email} } - -func (r UserRef) where() (string, any) { - if !r.id.IsZero() { - return "id", r.id - } - return "email", r.email -} - -// OrgRef identifies an organization by ID or slug. -type OrgRef struct { - id OrgID - slug string -} - -func OrgByID(id OrgID) OrgRef { return OrgRef{id: id} } - -func OrgBySlug(slug string) OrgRef { return OrgRef{slug: slug} } - -func (r OrgRef) IsZero() bool { return r.id.IsZero() && r.slug == "" } - -func (r OrgRef) where() (string, any) { - if !r.id.IsZero() { - return "id", r.id - } - return "slug", r.slug -} - -// DB wraps a pgx connection pool. -type DB struct { - Pool *pgxpool.Pool -} - -// User holds info about a user. -type User struct { - ID UserID - Email string - CreatedAt time.Time -} - -// Organization holds info about an organization. -type Organization struct { - ID OrgID - Name string - Slug string - Public bool - CreatedAt time.Time -} - -// OrgMember pairs a user with their role in an organization. -type OrgMember struct { - User User - Role string - JoinedAt time.Time -} - -// Worker holds info about a registered worker. -type Worker struct { - ID WorkerID - OrgID *OrgID - PublicKey []byte - CreatedAt time.Time -} - -// DatabaseOpen connects to PostgreSQL and returns a DB. -func DatabaseOpen(ctx context.Context, dsn string) (*DB, error) { - pool, err := pgxpool.New(ctx, dsn) - if err != nil { - return nil, errors.Join(ErrOpen, err) - } - - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, errors.Join(ErrPing, err) - } - - return &DB{Pool: pool}, nil -} - -// Close closes the connection pool. -func (db *DB) Close() { - db.Pool.Close() -} - -// apicall starts a transaction and sets the RLS actor. Both app.user_id -// and app.actor_kind are populated: app_issuper() checks actor_kind for -// System/Operator principals, and app.user_id for real user superusers. -func (db *DB) apicall(ctx context.Context, actor Actor, access, validate, doit func(pgx.Tx) error) error { - tx, err := db.Pool.Begin(ctx) - if err != nil { - return err - } - defer tx.Rollback(ctx) - - if _, err := tx.Exec(ctx, - `SELECT set_config('app.user_id', $1, true), - set_config('app.actor_kind', $2, true)`, - actor.dbID().String(), actor.kindString(), - ); err != nil { - return err - } - - if err := access(tx); err != nil { - slog.Debug("access denied", "err", err) - return err - } - if validate != nil { - if err := validate(tx); err != nil { - slog.Debug("validation failed", "err", err) - return err - } - } - if err := doit(tx); err != nil { - slog.Debug("exec failed", "err", err) - return err - } - - return tx.Commit(ctx) -} - -// Migrate applies all pending migrations. -func (db *DB) Migrate(ctx context.Context) error { - conn, err := db.Pool.Acquire(ctx) - if err != nil { - return errors.Join(ErrAcquire, err) - } - defer conn.Release() - - migrator, err := migrate.NewMigrator(ctx, conn.Conn(), "schema_version") - if err != nil { - return errors.Join(ErrMigrate, err) - } - - migrator.AppendMigration("create_users", ` - CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - email TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - superuser BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - deleted_at TIMESTAMPTZ - ); - - CREATE FUNCTION app_user_id() RETURNS uuid STABLE AS $$ - SELECT current_setting('app.user_id', true)::uuid; - $$ LANGUAGE sql; - - -- app_issuper has two independent branches: - -- (a) runtime setting app.actor_kind is 'system' or 'operator' — - -- set only by apicall from Go for synthetic principals and by - -- DML migrations; cannot be injected via login since there is - -- no matching users row to authenticate against. - -- (b) the current app.user_id resolves to a users row with - -- superuser = true — real support-agent style superusers. - CREATE FUNCTION app_issuper() RETURNS boolean STABLE AS $$ - SELECT - current_setting('app.actor_kind', true) IN ('system', 'operator') - OR EXISTS ( - SELECT 1 FROM users - WHERE id = current_setting('app.user_id', true)::uuid - AND superuser = true - ); - $$ LANGUAGE sql; - `, ` - DROP FUNCTION app_issuper; - DROP FUNCTION app_user_id; - DROP TABLE users; - `) - - migrator.AppendMigration("create_sessions", ` - CREATE TABLE sessions ( - token TEXT PRIMARY KEY, - user_id UUID NOT NULL REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - expires_at TIMESTAMPTZ NOT NULL - ); - CREATE INDEX sessions_expires_at ON sessions (expires_at); - `, ` - DROP TABLE sessions; - `) - - migrator.AppendMigration("create_organizations", ` - CREATE TABLE organizations ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - name TEXT NOT NULL, - slug TEXT NOT NULL UNIQUE, - public BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - deleted_at TIMESTAMPTZ - ); - `, ` - DROP TABLE organizations; - `) - - migrator.AppendMigration("create_org_members", ` - CREATE TABLE org_members ( - org_id UUID NOT NULL REFERENCES organizations(id), - user_id UUID NOT NULL REFERENCES users(id), - role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')), - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (org_id, user_id) - ); - CREATE INDEX org_members_user_id ON org_members (user_id); - - CREATE FUNCTION is_member(org uuid) RETURNS boolean STABLE AS $$ - SELECT EXISTS ( - SELECT 1 FROM org_members - WHERE org_id = org AND user_id = app_user_id() - ); - $$ LANGUAGE sql; - - CREATE FUNCTION is_authenticated() RETURNS boolean STABLE AS $$ - SELECT current_setting('app.actor_kind', true) NOT IN ('', 'anon'); - $$ LANGUAGE sql; - `, ` - DROP FUNCTION is_authenticated; - DROP FUNCTION is_member; - DROP TABLE org_members; - `) - - migrator.AppendMigration("create_workers", ` - CREATE TABLE workers ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - org_id UUID REFERENCES organizations(id), - public_key BYTEA NOT NULL UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - revoked_at TIMESTAMPTZ - ); - `, ` - DROP TABLE workers - `) - - migrator.AppendMigration("rls_users", ` - ALTER TABLE users ENABLE ROW LEVEL SECURITY; - ALTER TABLE users FORCE ROW LEVEL SECURITY; - - CREATE POLICY superuser ON users FOR ALL USING (app_issuper()); - CREATE POLICY self ON users FOR ALL USING (id = app_user_id()); - CREATE POLICY shared_org ON users FOR SELECT USING (EXISTS ( - SELECT 1 FROM org_members target - JOIN org_members mine ON mine.org_id = target.org_id - WHERE target.user_id = users.id - AND mine.user_id = app_user_id() - )); - CREATE POLICY write_auth ON users AS RESTRICTIVE FOR INSERT WITH CHECK (is_authenticated()); - CREATE POLICY update_auth ON users AS RESTRICTIVE FOR UPDATE USING (is_authenticated()); - CREATE POLICY delete_auth ON users AS RESTRICTIVE FOR DELETE USING (is_authenticated()); - `, ` - DROP POLICY write_auth ON users; - DROP POLICY update_auth ON users; - DROP POLICY delete_auth ON users; - DROP POLICY superuser ON users; - DROP POLICY self ON users; - DROP POLICY shared_org ON users; - - ALTER TABLE users DISABLE ROW LEVEL SECURITY; - `) - - migrator.AppendMigration("rls_sessions", ` - ALTER TABLE sessions ENABLE ROW LEVEL SECURITY; - ALTER TABLE sessions FORCE ROW LEVEL SECURITY; - - CREATE POLICY superuser ON sessions FOR ALL USING (app_issuper()); - CREATE POLICY own_sessions ON sessions FOR ALL USING (user_id = app_user_id()); - CREATE POLICY write_auth ON sessions AS RESTRICTIVE FOR INSERT WITH CHECK (is_authenticated()); - CREATE POLICY update_auth ON sessions AS RESTRICTIVE FOR UPDATE USING (is_authenticated()); - CREATE POLICY delete_auth ON sessions AS RESTRICTIVE FOR DELETE USING (is_authenticated()); - `, ` - DROP POLICY write_auth ON sessions; - DROP POLICY update_auth ON sessions; - DROP POLICY delete_auth ON sessions; - DROP POLICY superuser ON sessions; - DROP POLICY own_sessions ON sessions; - - ALTER TABLE sessions DISABLE ROW LEVEL SECURITY; - `) - - migrator.AppendMigration("rls_organizations", ` - ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; - ALTER TABLE organizations FORCE ROW LEVEL SECURITY; - - CREATE POLICY superuser ON organizations FOR ALL USING (app_issuper()); - CREATE POLICY public_org ON organizations FOR ALL USING (public); - CREATE POLICY member_org ON organizations FOR ALL USING (is_member(id)); - CREATE POLICY write_auth ON organizations AS RESTRICTIVE FOR INSERT WITH CHECK (is_authenticated()); - CREATE POLICY update_auth ON organizations AS RESTRICTIVE FOR UPDATE USING (is_authenticated()); - CREATE POLICY delete_auth ON organizations AS RESTRICTIVE FOR DELETE USING (is_authenticated()); - `, ` - DROP POLICY write_auth ON organizations; - DROP POLICY update_auth ON organizations; - DROP POLICY delete_auth ON organizations; - DROP POLICY superuser ON organizations; - DROP POLICY public_org ON organizations; - DROP POLICY member_org ON organizations; - - ALTER TABLE organizations DISABLE ROW LEVEL SECURITY; - `) - - migrator.AppendMigration("rls_org_members", ` - ALTER TABLE org_members ENABLE ROW LEVEL SECURITY; - ALTER TABLE org_members FORCE ROW LEVEL SECURITY; - - CREATE POLICY superuser ON org_members FOR ALL USING (app_issuper()); - - -- Self path (own membership rows) uses a pure column predicate - -- so is_member's inner query can resolve without recursion. - CREATE POLICY self_member ON org_members FOR ALL USING (user_id = app_user_id()); - CREATE POLICY org_member ON org_members FOR ALL USING (is_member(org_id)); - CREATE POLICY write_auth ON org_members AS RESTRICTIVE FOR INSERT WITH CHECK (is_authenticated()); - CREATE POLICY update_auth ON org_members AS RESTRICTIVE FOR UPDATE USING (is_authenticated()); - CREATE POLICY delete_auth ON org_members AS RESTRICTIVE FOR DELETE USING (is_authenticated()); - `, ` - DROP POLICY write_auth ON org_members; - DROP POLICY update_auth ON org_members; - DROP POLICY delete_auth ON org_members; - DROP POLICY superuser ON org_members; - DROP POLICY self_member ON org_members; - DROP POLICY org_member ON org_members; - - ALTER TABLE org_members DISABLE ROW LEVEL SECURITY; - `) - - migrator.AppendMigration("rls_workers", ` - ALTER TABLE workers ENABLE ROW LEVEL SECURITY; - ALTER TABLE workers FORCE ROW LEVEL SECURITY; - - CREATE POLICY superuser ON workers FOR ALL USING (app_issuper()); - CREATE POLICY org_worker ON workers FOR ALL USING ( - org_id IS NOT NULL AND is_member(org_id) - ); - CREATE POLICY write_auth ON workers AS RESTRICTIVE FOR INSERT WITH CHECK (is_authenticated()); - CREATE POLICY update_auth ON workers AS RESTRICTIVE FOR UPDATE USING (is_authenticated()); - CREATE POLICY delete_auth ON workers AS RESTRICTIVE FOR DELETE USING (is_authenticated()); - `, ` - DROP POLICY write_auth ON workers; - DROP POLICY update_auth ON workers; - DROP POLICY delete_auth ON workers; - DROP POLICY superuser ON workers; - DROP POLICY org_worker ON workers; - - ALTER TABLE workers DISABLE ROW LEVEL SECURITY; - `) - - return migrator.Migrate(ctx) -} - -// UserCreate hashes the password with argon2id and inserts a new user. -// The pepper is a server-side secret not stored in the database. -func (db *DB) UserCreate(ctx context.Context, actor Actor, email, password string, pepper []byte) (UserID, error) { - var id UserID - err := db.apicall( - ctx, actor, - func(tx pgx.Tx) error { - if !actor.IsSuperuser() { - return ErrPermissionDenied - } - - return nil - }, - func(tx pgx.Tx) error { - if strings.HasSuffix(strings.ToLower(email), reservedEmailSuffix) { - return ErrReservedEmail - } - - return nil - }, - func(tx pgx.Tx) error { - hash, err := hashPassword(password, pepper) - if err != nil { - return err - } - - if err := tx.QueryRow(ctx, - `INSERT INTO users (email, password) VALUES ($1, $2) RETURNING id`, - email, hash, - ).Scan(&id); err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { - return ErrEmailTaken - } - - return err - } - return nil - }, - ) - return id, err -} - -// UserGet returns a user by ref (ID or email). -func (db *DB) UserGet(ctx context.Context, actor Actor, ref UserRef) (*User, error) { - var u User - err := db.apicall( - ctx, actor, - func(tx pgx.Tx) error { return checkGlobal(actor, pb.Perm_PERM_USER_READ) }, - nil, - func(tx pgx.Tx) error { - col, val := ref.where() - - q := sb.PostgreSQL.NewSelectBuilder() - sql, args := q.Select("id", "email", "created_at"). - From("users"). - Where(q.Equal(col, val), q.IsNull("deleted_at")). - Build() - - if err := tx.QueryRow(ctx, sql, args...).Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrUserNotFound - } - return err - } - - return nil - }, - ) - return &u, err -} - -// UserList returns a page of users and the total count. -func (db *DB) UserList(ctx context.Context, actor Actor, cursor UserID, limit int, filter string) ([]User, int, error) { - var users []User - var total int - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { return checkGlobal(actor, pb.Perm_PERM_USER_READ) }, - func(tx pgx.Tx) error { - if filter != "" { - return ErrNotImplemented - } - return nil - }, - func(tx pgx.Tx) error { - if err := tx.QueryRow(ctx, - `SELECT count(*) FROM users WHERE deleted_at IS NULL`, - ).Scan(&total); err != nil { - return err - } - - q := sb.PostgreSQL.NewSelectBuilder() - q.Select("id", "email", "created_at"). - From("users"). - Where(q.IsNull("deleted_at")). - OrderBy("id"). - Limit(limit) - if !cursor.IsZero() { - q.Where(q.GreaterThan("id", cursor)) - } - - sql, args := q.Build() - rows, err := tx.Query(ctx, sql, args...) - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - var u User - if err := rows.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil { - return err - } - users = append(users, u) - } - return rows.Err() - }, - ) - return users, total, err -} - -// UserUpdate updates a user's email and/or password. -// Invalidates all sessions when password changes. -func (db *DB) UserUpdate(ctx context.Context, actor Actor, ref UserRef, email *string, password *string, pepper []byte) error { - if email == nil && password == nil { - return nil - } - - var id UserID - return db.apicall(ctx, actor, - func(tx pgx.Tx) error { - var err error - id, err = resolveUser(ctx, tx, ref) - if err != nil { - return err - } - return checkSelf(actor, id) - }, - func(tx pgx.Tx) error { - if email != nil && strings.HasSuffix(strings.ToLower(*email), reservedEmailSuffix) { - return ErrReservedEmail - } - return nil - }, - func(tx pgx.Tx) error { - ub := sb.PostgreSQL.NewUpdateBuilder() - ub.Update("users") - - if email != nil { - ub.SetMore(ub.Assign("email", *email)) - } - if password != nil { - hash, err := hashPassword(*password, pepper) - if err != nil { - return err - } - ub.SetMore(ub.Assign("password", hash)) - } - ub.Where(ub.Equal("id", id)) - - sql, args := ub.Build() - if _, err := tx.Exec(ctx, sql, args...); err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { - return ErrEmailTaken - } - return err - } - - if password != nil { - if _, err := tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id); err != nil { - return err - } - } - return nil - }, - ) -} - -// UserDelete soft-deletes a user. -// Fails if the user is the sole owner of any organization. -func (db *DB) UserDelete(ctx context.Context, actor Actor, ref UserRef) error { - var id UserID - return db.apicall(ctx, actor, - func(tx pgx.Tx) error { - var err error - id, err = resolveUser(ctx, tx, ref) - if err != nil { - return err - } - return checkSelf(actor, id) - }, - func(tx pgx.Tx) error { - return checkNotSoleOwner(ctx, tx, id) - }, - func(tx pgx.Tx) error { - if _, err := tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id); err != nil { - return err - } - if _, err := tx.Exec(ctx, `DELETE FROM org_members WHERE user_id = $1`, id); err != nil { - return err - } - if _, err := tx.Exec(ctx, - `UPDATE users SET email = id::text, password = '', deleted_at = now() WHERE id = $1`, id, - ); err != nil { - return err - } - return nil - }, - ) -} - -// UserVerifyPassword checks credentials and returns the user ID. -func (db *DB) UserVerifyPassword(ctx context.Context, actor Actor, email, password string, pepper []byte) (UserID, error) { - var id UserID - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { return checkSystem(actor) }, - nil, - func(tx pgx.Tx) error { - var hash string - if err := tx.QueryRow(ctx, - `SELECT id, password FROM users WHERE email = $1 AND deleted_at IS NULL`, - email, - ).Scan(&id, &hash); err != nil { - return ErrInvalidCreds - } - if !verifyHash(password, hash, pepper) { - return ErrInvalidCreds - } - return nil - }, - ) - return id, err -} - -// UserSessionGet resolves a session token into the Actor it authenticates. -// Returns an invalid zero Actor on any error; callers must check err. -func (db *DB) UserSessionGet(ctx context.Context, actor Actor, token string) (Actor, error) { - var ( - userID UserID - email string - superuser bool - ) - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { return checkSystem(actor) }, - nil, - func(tx pgx.Tx) error { - var expiresAt time.Time - h := hashToken(token) - - if err := tx.QueryRow(ctx, - `SELECT s.user_id, u.email, u.superuser, s.expires_at - FROM sessions s JOIN users u ON u.id = s.user_id - WHERE s.token = $1 AND s.expires_at > now() AND u.deleted_at IS NULL`, - h, - ).Scan(&userID, &email, &superuser, &expiresAt); err != nil { - return err - } - - if time.Until(expiresAt) < config.SessionTTL/2 { - if _, err := tx.Exec(ctx, - `UPDATE sessions SET expires_at = now() + $2 WHERE token = $1`, - h, config.SessionTTL, - ); err != nil { - return err - } - } - return nil - }, - ) - if err != nil { - return Actor{}, err - } - return UserActor(userID, email, superuser), nil -} - -// UserSessionCreate generates a random token, stores its hash, and returns the token. -func (db *DB) UserSessionCreate(ctx context.Context, actor Actor, userID UserID) (string, error) { - var token string - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { return checkSystem(actor) }, - nil, - func(tx pgx.Tx) error { - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - return err - } - token = base64.RawURLEncoding.EncodeToString(buf) - - if _, err := tx.Exec(ctx, - `INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, now() + $3)`, - hashToken(token), userID, config.SessionTTL, - ); err != nil { - return err - } - return nil - }, - ) - return token, err -} - -// UserSessionDelete removes a session (logout). -func (db *DB) UserSessionDelete(ctx context.Context, actor Actor, token string) error { - return db.apicall(ctx, actor, - func(tx pgx.Tx) error { return checkSystem(actor) }, - nil, - func(tx pgx.Tx) error { - _, err := tx.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, hashToken(token)) - return err - }, - ) -} - -// UserSessionPurgeExpired deletes all expired sessions. Runs as SystemActor. -func (db *DB) UserSessionPurgeExpired(ctx context.Context) error { - return db.apicall(ctx, SystemActor(), - func(tx pgx.Tx) error { return checkSystem(SystemActor()) }, - nil, - func(tx pgx.Tx) error { - _, err := tx.Exec(ctx, `DELETE FROM sessions WHERE expires_at < now()`) - return err - }, - ) -} - -// OrgGet returns an org by ref (ID or slug). -func (db *DB) OrgGet(ctx context.Context, actor Actor, ref OrgRef) (*Organization, error) { - var o Organization - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { return checkGlobal(actor, pb.Perm_PERM_ORG_READ) }, - nil, - func(tx pgx.Tx) error { - col, val := ref.where() - q := sb.PostgreSQL.NewSelectBuilder() - - sql, args := q.Select("id", "name", "slug", "public", "created_at"). - From("organizations"). - Where(q.Equal(col, val), q.IsNull("deleted_at")). - Build() - - if err := tx.QueryRow(ctx, sql, args...).Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrOrgNotFound - } - return err - } - return nil - }, - ) - return &o, err -} - -// OrgCreate creates an org and adds the owner as the first member. -func (db *DB) OrgCreate(ctx context.Context, actor Actor, name, slug string, public bool, owner UserRef) (OrgID, error) { - var orgID OrgID - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { return checkGlobal(actor, pb.Perm_PERM_ORG_WRITE) }, - nil, - func(tx pgx.Tx) error { - userID, err := resolveUser(ctx, tx, owner) - if err != nil { - return err - } - - if err := tx.QueryRow(ctx, - `INSERT INTO organizations (name, slug, public) VALUES ($1, $2, $3) RETURNING id`, - name, slug, public, - ).Scan(&orgID); err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { - return ErrSlugTaken - } - return err - } - - _, err = tx.Exec(ctx, - `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`, - orgID, userID, - ) - return err - }, - ) - return orgID, err -} - -// OrgUpdate updates an org's name, slug, and/or public flag. -func (db *DB) OrgUpdate(ctx context.Context, actor Actor, ref OrgRef, name *string, slug *string, public *bool) error { - if name == nil && slug == nil && public == nil { - return nil - } - - var id OrgID - return db.apicall(ctx, actor, - func(tx pgx.Tx) error { - var err error - id, err = resolveOrg(ctx, tx, ref) - if err != nil { - return err - } - return checkPerm(ctx, tx, actor, id, pb.Perm_PERM_ORG_WRITE) - }, - nil, - func(tx pgx.Tx) error { - ub := sb.PostgreSQL.NewUpdateBuilder() - ub.Update("organizations") - if name != nil { - ub.SetMore(ub.Assign("name", *name)) - } - if slug != nil { - ub.SetMore(ub.Assign("slug", *slug)) - } - if public != nil { - ub.SetMore(ub.Assign("public", *public)) - } - ub.Where(ub.Equal("id", id)) - - sql, args := ub.Build() - tag, err := tx.Exec(ctx, sql, args...) - if err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { - return ErrSlugTaken - } - return err - } - if tag.RowsAffected() == 0 { - return ErrOrgNotFound - } - return nil - }, - ) -} - -// OrgDelete soft-deletes an org and removes all members. -func (db *DB) OrgDelete(ctx context.Context, actor Actor, ref OrgRef) error { - var id OrgID - return db.apicall(ctx, actor, - func(tx pgx.Tx) error { - var err error - id, err = resolveOrg(ctx, tx, ref) - if err != nil { - return err - } - return checkPerm(ctx, tx, actor, id, pb.Perm_PERM_ORG_DELETE) - }, - nil, - func(tx pgx.Tx) error { - if _, err := tx.Exec(ctx, `DELETE FROM org_members WHERE org_id = $1`, id); err != nil { - return err - } - tag, err := tx.Exec(ctx, - `UPDATE organizations SET slug = id::text, deleted_at = now() WHERE id = $1`, id, - ) - if err != nil { - return err - } - if tag.RowsAffected() == 0 { - return ErrOrgNotFound - } - return nil - }, - ) -} - -// OrgList returns a page of orgs and the total count. -func (db *DB) OrgList(ctx context.Context, actor Actor, cursor OrgID, limit int, filter string) ([]Organization, int, error) { - var orgs []Organization - var total int - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { return checkGlobal(actor, pb.Perm_PERM_ORG_READ) }, - func(tx pgx.Tx) error { - if filter != "" { - return ErrNotImplemented - } - return nil - }, - func(tx pgx.Tx) error { - if err := tx.QueryRow(ctx, - `SELECT count(*) FROM organizations WHERE deleted_at IS NULL`, - ).Scan(&total); err != nil { - return err - } - - q := sb.PostgreSQL.NewSelectBuilder() - q.Select("id", "name", "slug", "public", "created_at"). - From("organizations"). - Where(q.IsNull("deleted_at")). - OrderBy("id"). - Limit(limit) - if !cursor.IsZero() { - q.Where(q.GreaterThan("id", cursor)) - } - - sql, args := q.Build() - rows, err := tx.Query(ctx, sql, args...) - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - var o Organization - if err := rows.Scan(&o.ID, &o.Name, &o.Slug, &o.Public, &o.CreatedAt); err != nil { - return err - } - orgs = append(orgs, o) - } - return rows.Err() - }, - ) - return orgs, total, err -} - -// OrgMemberGet returns a single member's info. -func (db *DB) OrgMemberGet(ctx context.Context, actor Actor, org OrgRef, user UserRef) (*OrgMember, error) { - var m OrgMember - var orgID OrgID - var userID UserID - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { - var err error - orgID, err = resolveOrg(ctx, tx, org) - if err != nil { - return err - } - return checkPerm(ctx, tx, actor, orgID, pb.Perm_PERM_ORG_MEMBER_READ) - }, - nil, - func(tx pgx.Tx) error { - var err error - userID, err = resolveUser(ctx, tx, user) - if err != nil { - return err - } - - if err := tx.QueryRow(ctx, - `SELECT u.id, u.email, u.created_at, om.role, om.created_at - FROM org_members om - JOIN users u ON u.id = om.user_id - WHERE om.org_id = $1 AND om.user_id = $2`, orgID, userID, - ).Scan(&m.User.ID, &m.User.Email, &m.User.CreatedAt, &m.Role, &m.JoinedAt); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotMember - } - return err - } - return nil - }, - ) - return &m, err -} - -// OrgMembersList returns a page of members for an org. -func (db *DB) OrgMembersList(ctx context.Context, actor Actor, org OrgRef, cursor UserID, limit int, filter string) ([]OrgMember, int, error) { - var members []OrgMember - var total int - var orgID OrgID - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { - var err error - orgID, err = resolveOrg(ctx, tx, org) - if err != nil { - return err - } - return checkPerm(ctx, tx, actor, orgID, pb.Perm_PERM_ORG_MEMBER_READ) - }, - func(tx pgx.Tx) error { - if filter != "" { - return ErrNotImplemented - } - return nil - }, - func(tx pgx.Tx) error { - if err := tx.QueryRow(ctx, - `SELECT count(*) FROM org_members WHERE org_id = $1`, orgID, - ).Scan(&total); err != nil { - return err - } - - q := sb.PostgreSQL.NewSelectBuilder() - q.Select("u.id", "u.email", "u.created_at", "m.role", "m.created_at"). - From("org_members m"). - Join("users u", "u.id = m.user_id"). - Where(q.Equal("m.org_id", orgID), q.IsNull("u.deleted_at")). - OrderBy("u.id"). - Limit(limit) - if !cursor.IsZero() { - q.Where(q.GreaterThan("u.id", cursor)) - } - - sql, args := q.Build() - rows, err := tx.Query(ctx, sql, args...) - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - var m OrgMember - if err := rows.Scan(&m.User.ID, &m.User.Email, &m.User.CreatedAt, &m.Role, &m.JoinedAt); err != nil { - return err - } - members = append(members, m) - } - return rows.Err() - }, - ) - return members, total, err -} - -// OrgMemberAdd adds a user to an org with the given role. -func (db *DB) OrgMemberAdd(ctx context.Context, actor Actor, org OrgRef, user UserRef, role string) error { - var orgID OrgID - return db.apicall(ctx, actor, - func(tx pgx.Tx) error { - var err error - orgID, err = resolveOrg(ctx, tx, org) - if err != nil { - return err - } - return checkPerm(ctx, tx, actor, orgID, pb.Perm_PERM_ORG_MEMBER_WRITE) - }, - nil, - func(tx pgx.Tx) error { - userID, err := resolveUser(ctx, tx, user) - if err != nil { - return err - } - if _, err := tx.Exec(ctx, - `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, $3)`, - orgID, userID, role, - ); err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { - return ErrAlreadyMember - } - return err - } - return nil - }, - ) -} - -// OrgMemberUpdateRole changes a member's role. Fails if demoting the last owner. -func (db *DB) OrgMemberUpdateRole(ctx context.Context, actor Actor, org OrgRef, user UserRef, newRole string) error { - var orgID OrgID - var userID UserID - return db.apicall(ctx, actor, - func(tx pgx.Tx) error { - var err error - orgID, err = resolveOrg(ctx, tx, org) - if err != nil { - return err - } - return checkPerm(ctx, tx, actor, orgID, pb.Perm_PERM_ORG_MEMBER_WRITE) - }, - nil, - func(tx pgx.Tx) error { - var err error - userID, err = resolveUser(ctx, tx, user) - if err != nil { - return err - } - - var currentRole string - if err := tx.QueryRow(ctx, - `SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2 FOR UPDATE`, - orgID, userID, - ).Scan(¤tRole); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotMember - } - return err - } - - if currentRole == "owner" && newRole != "owner" { - var ownerCount int - if err := tx.QueryRow(ctx, - `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`, - orgID, - ).Scan(&ownerCount); err != nil { - return err - } - if ownerCount <= 1 { - return ErrLastOwner - } - } - - _, err = tx.Exec(ctx, - `UPDATE org_members SET role = $1 WHERE org_id = $2 AND user_id = $3`, - newRole, orgID, userID, - ) - return err - }, - ) -} - -// OrgMemberRemove removes a user from an org. Fails if they are the last owner. -func (db *DB) OrgMemberRemove(ctx context.Context, actor Actor, org OrgRef, user UserRef) error { - var orgID OrgID - return db.apicall(ctx, actor, - func(tx pgx.Tx) error { - var err error - orgID, err = resolveOrg(ctx, tx, org) - if err != nil { - return err - } - return checkPerm(ctx, tx, actor, orgID, pb.Perm_PERM_ORG_MEMBER_WRITE) - }, - nil, - func(tx pgx.Tx) error { - userID, err := resolveUser(ctx, tx, user) - if err != nil { - return err - } - - var role string - if err := tx.QueryRow(ctx, - `SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2 FOR UPDATE`, - orgID, userID, - ).Scan(&role); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotMember - } - return err - } - - if role == "owner" { - var ownerCount int - if err := tx.QueryRow(ctx, - `SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'owner'`, - orgID, - ).Scan(&ownerCount); err != nil { - return err - } - if ownerCount <= 1 { - return ErrLastOwner - } - } - - _, err = tx.Exec(ctx, - `DELETE FROM org_members WHERE org_id = $1 AND user_id = $2`, - orgID, userID, - ) - return err - }, - ) -} - -// WorkerGet returns a worker by ID. -func (db *DB) WorkerGet(ctx context.Context, actor Actor, id WorkerID) (*Worker, error) { - var w Worker - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { - var orgID *OrgID - if err := tx.QueryRow(ctx, - `SELECT org_id FROM workers WHERE id = $1 AND revoked_at IS NULL`, id, - ).Scan(&orgID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrWorkerNotFound - } - return err - } - if orgID != nil { - return checkPerm(ctx, tx, actor, *orgID, pb.Perm_PERM_WORKER_READ) - } - if !actor.IsSuperuser() { - return ErrPermissionDenied - } - return nil - }, - nil, - func(tx pgx.Tx) error { - return tx.QueryRow(ctx, - `SELECT id, public_key, org_id, created_at FROM workers WHERE id = $1 AND revoked_at IS NULL`, id, - ).Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt) - }, - ) - return &w, err -} - -// WorkerCreate registers a new worker with the given public key and optional org. -func (db *DB) WorkerCreate(ctx context.Context, actor Actor, publicKey []byte, org *OrgRef) (WorkerID, error) { - var workerID WorkerID - var orgID *OrgID - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { - if org != nil { - id, err := resolveOrg(ctx, tx, *org) - if err != nil { - return err - } - orgID = &id - return checkPerm(ctx, tx, actor, id, pb.Perm_PERM_WORKER_WRITE) - } - if !actor.IsSuperuser() { - return ErrPermissionDenied - } - return nil - }, - nil, - func(tx pgx.Tx) error { - return tx.QueryRow(ctx, - `INSERT INTO workers (public_key, org_id) VALUES ($1, $2) RETURNING id`, - publicKey, orgID, - ).Scan(&workerID) - }, - ) - return workerID, err -} - -// WorkerDelete soft-deletes a worker by ID. -func (db *DB) WorkerDelete(ctx context.Context, actor Actor, id WorkerID) error { - return db.apicall(ctx, actor, - func(tx pgx.Tx) error { - var orgID *OrgID - if err := tx.QueryRow(ctx, - `SELECT org_id FROM workers WHERE id = $1 AND revoked_at IS NULL`, id, - ).Scan(&orgID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrWorkerNotFound - } - return err - } - if orgID != nil { - return checkPerm(ctx, tx, actor, *orgID, pb.Perm_PERM_WORKER_WRITE) - } - if !actor.IsSuperuser() { - return ErrPermissionDenied - } - return nil - }, - nil, - func(tx pgx.Tx) error { - tag, err := tx.Exec(ctx, - `UPDATE workers SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, id, - ) - if err != nil { - return err - } - if tag.RowsAffected() == 0 { - return ErrWorkerNotFound - } - return nil - }, - ) -} - -// WorkerList returns a page of workers and the total count. -func (db *DB) WorkerList(ctx context.Context, actor Actor, cursor WorkerID, limit int, filter string) ([]Worker, int, error) { - var workers []Worker - var total int - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { - if actor.kind == actorAnon { - return ErrUnauthenticated - } - return nil - }, - func(tx pgx.Tx) error { - if filter != "" { - return ErrNotImplemented - } - return nil - }, - func(tx pgx.Tx) error { - if err := tx.QueryRow(ctx, - `SELECT count(*) FROM workers WHERE revoked_at IS NULL`, - ).Scan(&total); err != nil { - return err - } - - q := sb.PostgreSQL.NewSelectBuilder() - q.Select("id", "public_key", "org_id", "created_at"). - From("workers"). - Where(q.IsNull("revoked_at")). - OrderBy("id"). - Limit(limit) - if !cursor.IsZero() { - q.Where(q.GreaterThan("id", cursor)) - } - - sql, args := q.Build() - rows, err := tx.Query(ctx, sql, args...) - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - var w Worker - if err := rows.Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt); err != nil { - return err - } - workers = append(workers, w) - } - return rows.Err() - }, - ) - return workers, total, err -} - -// WorkerLookup finds an active worker by its ed25519 public key. -func (db *DB) WorkerLookup(ctx context.Context, actor Actor, publicKey []byte) (*Worker, error) { - var w Worker - err := db.apicall(ctx, actor, - func(tx pgx.Tx) error { return checkSystem(actor) }, - nil, - func(tx pgx.Tx) error { - if err := tx.QueryRow(ctx, - `SELECT id, public_key, org_id, created_at FROM workers WHERE public_key = $1 AND revoked_at IS NULL`, - publicKey, - ).Scan(&w.ID, &w.PublicKey, &w.OrgID, &w.CreatedAt); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrWorkerNotFound - } - return err - } - return nil - }, - ) - return &w, err -} - -// hashToken returns the hex-encoded SHA-256 of a session token. -func hashToken(token string) string { - h := sha256.Sum256([]byte(token)) - return fmt.Sprintf("%x", h) -} - -// verifyHash parses a PHC-format argon2id string and compares. -// Format: $argon2id$v=19$m=65536,t=3,p=2$$ -func verifyHash(password, encoded string, pepper []byte) bool { - // $argon2id$v=19$m=65536,t=3,p=2$salt$key → 6 parts - parts := strings.Split(encoded, "$") - if len(parts) != 6 || parts[1] != "argon2id" { - return false - } - - var memory, time uint32 - var threads uint8 - if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { - return false - } - - salt, err := base64.RawStdEncoding.DecodeString(parts[4]) - if err != nil { - return false - } - expectedKey, err := base64.RawStdEncoding.DecodeString(parts[5]) - if err != nil { - return false - } - - mac := hmac.New(sha256.New, pepper) - mac.Write([]byte(password)) - peppered := mac.Sum(nil) - - key := argon2.IDKey(peppered, salt, time, memory, threads, uint32(len(expectedKey))) - - return subtle.ConstantTimeCompare(key, expectedKey) == 1 -} - -// hashPassword produces a PHC-format string: -// $argon2id$v=19$m=65536,t=3,p=2$$ -func hashPassword(password string, pepper []byte) (string, error) { - salt := make([]byte, saltLen) - if _, err := rand.Read(salt); err != nil { - return "", err - } - - // Apply pepper: HMAC-SHA256(pepper, password) - mac := hmac.New(sha256.New, pepper) - mac.Write([]byte(password)) - peppered := mac.Sum(nil) - - key := argon2.IDKey(peppered, salt, argonTime, argonMemory, argonThreads, argonKeyLen) - - return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", - argon2.Version, - argonMemory, argonTime, argonThreads, - base64.RawStdEncoding.EncodeToString(salt), - base64.RawStdEncoding.EncodeToString(key), - ), nil -} - -// checkNotSoleOwner returns ErrSoleOwner if the user is the only owner of any org. -func checkNotSoleOwner(ctx context.Context, tx pgx.Tx, userID UserID) error { - var slug string - err := tx.QueryRow(ctx, - `SELECT o.slug FROM org_members m - JOIN organizations o ON o.id = m.org_id - WHERE m.role = 'owner' AND o.deleted_at IS NULL - GROUP BY o.id, o.slug - HAVING count(*) = 1 AND bool_or(m.user_id = $1) - LIMIT 1`, userID, - ).Scan(&slug) - if err == nil { - return ErrSoleOwner - } - if errors.Is(err, pgx.ErrNoRows) { - return nil - } - return err -} - -// resolveUser locks and returns the user ID within a transaction. -func resolveUser(ctx context.Context, tx pgx.Tx, ref UserRef) (UserID, error) { - col, val := ref.where() - q := sb.PostgreSQL.NewSelectBuilder() - - sql, args := q.Select("id").From("users"). - Where(q.Equal(col, val), q.IsNull("deleted_at")). - ForUpdate(). - Build() - - var id UserID - if err := tx.QueryRow(ctx, sql, args...).Scan(&id); err != nil { - var zero UserID - if errors.Is(err, pgx.ErrNoRows) { - return zero, ErrUserNotFound - } - - return zero, err - } - - return id, nil -} - -// resolveOrg locks and returns the org ID within a transaction. -func resolveOrg(ctx context.Context, tx pgx.Tx, ref OrgRef) (OrgID, error) { - col, val := ref.where() - q := sb.PostgreSQL.NewSelectBuilder() - - sql, args := q.Select("id").From("organizations"). - Where(q.Equal(col, val), q.IsNull("deleted_at")). - ForUpdate(). - Build() - - var id OrgID - if err := tx.QueryRow(ctx, sql, args...).Scan(&id); err != nil { - var zero OrgID - if errors.Is(err, pgx.ErrNoRows) { - return zero, ErrOrgNotFound - } - - return zero, err - } - - return id, nil -} - -// ValidateEmail checks that the value is a valid email address. -func ValidateEmail(value string) error { - if _, err := mail.ParseAddress(value); err != nil { - return ErrInvalidEmail - } - return nil -} - -// ValidateSlug checks format and returns the normalized (lowercased) slug. -func ValidateSlug(value string) (string, error) { - if len(value) < 2 || len(value) > 64 || !slugRe.MatchString(value) { - return "", ErrInvalidSlug - } - return strings.ToLower(value), nil -} - -// ValidateRole checks that the value is a valid role string. -// Derives valid roles from rolePermissions — single source of truth. -func ValidateRole(value string) error { - if _, ok := rolePermissions[value]; !ok { - return ErrInvalidRole - } - return nil -} diff --git a/cmd/mirumd/id.go b/cmd/mirumd/id.go deleted file mode 100644 index 09ec141..0000000 --- a/cmd/mirumd/id.go +++ /dev/null @@ -1,302 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "database/sql/driver" - "errors" - "fmt" - "log/slog" - "strings" - - "github.com/google/uuid" -) - -var ( - ErrBadID = errors.New("database: bad id") - ErrBadIDPrefix = errors.New("database: wrong id prefix") -) - -type ( - OrgID = ID[OrgKind] - UserID = ID[UserKind] - WorkerID = ID[WorkerKind] -) - -// IDKind is a phantom-type tag that distinguishes otherwise-identical -// 16-byte IDs at the Go type level. Each tag is a zero-sized struct -// that carries only its 3-letter prefix. -type IDKind interface { - UserKind | OrgKind | WorkerKind - Prefix() string -} - -type ( - UserKind struct{} - OrgKind struct{} - WorkerKind struct{} -) - -func (OrgKind) Prefix() string { return "org" } -func (UserKind) Prefix() string { return "usr" } -func (WorkerKind) Prefix() string { return "wrk" } - -// ID[K] is a typed UUIDv7. Instantiations with different K are distinct -// types, so passing a UserID where an OrgID is expected is a compile -// error. Cross-kind conversion requires an explicit cast, visible in -// review. -type ID[K IDKind] uuid.UUID - -func NewID[K IDKind]() ID[K] { - return ID[K](uuid.Must(uuid.NewV7())) -} - -func IDFromBytes[K IDKind](b []byte) (ID[K], error) { - var zero ID[K] - if len(b) != 16 { - return zero, fmt.Errorf("%w: got %d bytes", ErrBadID, len(b)) - } - u := uuid.UUID(b) - if err := validateV7(u); err != nil { - return zero, err - } - return ID[K](u), nil -} - -func validateV7(u uuid.UUID) error { - if v := u.Variant(); v != uuid.RFC4122 { - return fmt.Errorf("%w: variant %s", ErrBadID, v) - } - if v := u.Version(); v != 7 { - return fmt.Errorf("%w: version %d, want 7", ErrBadID, v) - } - return nil -} - -func (id ID[K]) UUID() uuid.UUID { return uuid.UUID(id) } -func (id ID[K]) Bytes() []byte { return id[:] } - -func (id ID[K]) IsZero() bool { - var zero ID[K] - return id == zero -} - -// String returns the prefixed form for logs, JSON, errors and anywhere -// the entity type isn't obvious from context. For URL path segments -// where the route already names the type, use Bare(). -func (id ID[K]) String() string { - var k K - return k.Prefix() + "_" + encodeBase58(uuid.UUID(id)) -} - -// Bare returns the base58 form without a type prefix — ≤ 22 chars. -// Use this for URL path segments where the route already identifies -// the entity ("/org/:id"); use String() everywhere else. -func (id ID[K]) Bare() string { - return encodeBase58(uuid.UUID(id)) -} - -func (id ID[K]) LogValue() slog.Value { - return slog.StringValue(id.String()) -} - -func (id ID[K]) MarshalText() ([]byte, error) { - return []byte(id.String()), nil -} - -func (id *ID[K]) UnmarshalText(b []byte) error { - parsed, err := ParseID[K](string(b)) - if err != nil { - return err - } - *id = parsed - return nil -} - -func (id *ID[K]) Scan(src any) error { - var u uuid.UUID - if err := u.Scan(src); err != nil { - return err - } - *id = ID[K](u) - return nil -} - -func (id ID[K]) Value() (driver.Value, error) { - return uuid.UUID(id).Value() -} - -// ParseID accepts the prefixed base58 ("_"), bare base58 -// (≤ 22 chars, as returned by Bare()), or a canonical UUID string. -// Bare forms stay supported so existing CLI flags, manual SQL lookups -// and URL path parameters keep working without a flag day. -func ParseID[K IDKind](s string) (ID[K], error) { - var k K - u, err := parseID(k.Prefix(), s) - return ID[K](u), err -} - -func MustParseID[K IDKind](s string) ID[K] { - id, err := ParseID[K](s) - if err != nil { - panic(err) - } - return id -} - -// ParseAnyID parses a prefixed, bare base58, or canonical UUID string -// into raw 16 bytes. Unlike ParseID[K], it does not require a known -// entity kind — any 3-letter prefix is stripped silently. Intended for -// CLI boundary code that dispatches on proto field names. -func ParseAnyID(s string) ([16]byte, error) { - if s == "" { - return [16]byte{}, fmt.Errorf("%w: empty", ErrBadID) - } - // Strip any typed prefix. - if len(s) > 4 && s[3] == '_' { - s = s[4:] - } - if len(s) <= 22 { - return decodeBase58(s) - } - u, err := uuid.Parse(s) - if err != nil { - return [16]byte{}, fmt.Errorf("%w: %w", ErrBadID, err) - } - return u, nil -} - -// FormatAnyID formats raw 16-byte ID as bare base58. Returns base64 for -// non-16-byte inputs as a fallback. -func FormatAnyID(b []byte) string { - if len(b) == 16 { - return encodeBase58(uuid.UUID(b)) - } - return fmt.Sprintf("%x", b) -} - -func parseID(prefix, s string) (uuid.UUID, error) { - if s == "" { - return uuid.Nil, fmt.Errorf("%w: empty", ErrBadID) - } - - if rest, ok := strings.CutPrefix(s, prefix+"_"); ok { - return decodeBase58(rest) - } - - // Typed prefix with the wrong value — reject so a cross-type paste - // doesn't silently fall through to the bare path. - if len(s) > 4 && s[3] == '_' { - return uuid.Nil, fmt.Errorf("%w: want %q, got %q", ErrBadIDPrefix, prefix, s[:3]) - } - - // Bare form: base58 (≤ 22 chars, from Bare()) or canonical UUID - // (32–36 chars, from manual SQL / legacy CLI). - if len(s) <= 22 { - return decodeBase58(s) - } - - u, err := uuid.Parse(s) - if err != nil { - return uuid.Nil, fmt.Errorf("%w: %w", ErrBadID, err) - } - - return u, nil -} - -// --- base58 (Bitcoin alphabet) --- -// -// 16 bytes fit in ≤ 22 base58 chars: log₅₈(2¹²⁸) ≈ 21.86. -// UUIDv7 values in the post-1970 range always have a non-zero -// leading byte, so encoded length is effectively a constant 21-22. - -const b58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - -var b58Index [256]byte - -func init() { - for i := range b58Index { - b58Index[i] = 0xff - } - for i := 0; i < len(b58Alphabet); i++ { - b58Index[b58Alphabet[i]] = byte(i) - } -} - -func encodeBase58(src uuid.UUID) string { - zeros := 0 - for zeros < 16 && src[zeros] == 0 { - zeros++ - } - - buf := src // array copy; long-division is in place - start := zeros - out := make([]byte, 0, 22) - - for start < 16 { - rem := 0 - for i := start; i < 16; i++ { - v := rem*256 + int(buf[i]) - buf[i] = byte(v / 58) - rem = v % 58 - } - out = append(out, b58Alphabet[rem]) - for start < 16 && buf[start] == 0 { - start++ - } - } - - for i := 0; i < zeros; i++ { - out = append(out, b58Alphabet[0]) - } - - // Reverse into big-endian order. - for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { - out[i], out[j] = out[j], out[i] - } - - return string(out) -} - -// decodeBase58 is strict about length — any input that round-trips to a -// value of a different byte length is rejected; we never want a -// 15-byte or 17-byte payload masquerading as a UUID. -func decodeBase58(s string) (uuid.UUID, error) { - if s == "" { - return uuid.Nil, fmt.Errorf("%w: empty", ErrBadID) - } - zeros := 0 - for zeros < len(s) && s[zeros] == b58Alphabet[0] { - zeros++ - } - - var out uuid.UUID - for i := zeros; i < len(s); i++ { - v := b58Index[s[i]] - if v == 0xff { - return uuid.Nil, fmt.Errorf("%w: bad base58 char %q", ErrBadID, s[i]) - } - carry := int(v) - for j := 15; j >= 0; j-- { - acc := int(out[j])*58 + carry - out[j] = byte(acc) - carry = acc >> 8 - } - if carry != 0 { - return uuid.Nil, fmt.Errorf("%w: overflow", ErrBadID) - } - } - - // The '1' prefix count in the string must equal the leading-zero - // byte count in the result — any mismatch means the input decoded - // to a different byte length than a UUID. - actualZeros := 0 - for actualZeros < 16 && out[actualZeros] == 0 { - actualZeros++ - } - if actualZeros != zeros { - return uuid.Nil, fmt.Errorf("%w: length mismatch", ErrBadID) - } - return out, nil -} diff --git a/cmd/mirumd/id_test.go b/cmd/mirumd/id_test.go deleted file mode 100644 index 0d06fc1..0000000 --- a/cmd/mirumd/id_test.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "testing" - - "github.com/google/uuid" -) - -func TestIDRoundTrip(t *testing.T) { - for range 50 { - id := NewID[UserKind]() - got, err := ParseID[UserKind](id.String()) - if err != nil { - t.Fatalf("parse prefixed: %v", err) - } - if got != id { - t.Fatalf("prefixed: got %v, want %v", got, id) - } - got, err = ParseID[UserKind](id.Bare()) - if err != nil { - t.Fatalf("parse bare: %v", err) - } - if got != id { - t.Fatalf("bare: got %v, want %v", got, id) - } - } -} - -func TestIDRoundTripCanonicalUUID(t *testing.T) { - id := NewID[OrgKind]() - got, err := ParseID[OrgKind](id.UUID().String()) - if err != nil { - t.Fatalf("parse canonical: %v", err) - } - if got != id { - t.Fatalf("canonical: got %v, want %v", got, id) - } -} - -func TestIDCrossPrefixRejected(t *testing.T) { - id := NewID[UserKind]() - _, err := ParseID[OrgKind](id.String()) - if err == nil { - t.Fatal("expected error parsing usr_ as org") - } -} - -func TestBase58EdgeCases(t *testing.T) { - cases := [][16]byte{ - {}, // all zeros - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, // minimal - {0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, // max - } - for _, raw := range cases { - enc := encodeBase58(uuid.UUID(raw)) - dec, err := decodeBase58(enc) - if err != nil { - t.Fatalf("decode(%q): %v", enc, err) - } - if dec != uuid.UUID(raw) { - t.Fatalf("roundtrip: got %x, want %x", dec, raw) - } - } -} - -func TestIDFromBytesRejectsV4(t *testing.T) { - v4 := uuid.Must(uuid.NewRandom()) // v4 - _, err := IDFromBytes[UserKind](v4[:]) - if err == nil { - t.Fatal("expected v4 rejection") - } -} diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go deleted file mode 100644 index 73aa3f9..0000000 --- a/cmd/mirumd/main.go +++ /dev/null @@ -1,254 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "context" - "errors" - "fmt" - "log/slog" - "net" - "net/http" - "os" - - "dimidiumlabs/mirum/internal/config" - "dimidiumlabs/mirum/internal/forges" - "dimidiumlabs/mirum/internal/protocol/pb" - "dimidiumlabs/mirum/internal/protocol/pb/pbconnect" - "dimidiumlabs/mirum/internal/supervisor" - - "github.com/coreos/go-systemd/v22/activation" - "github.com/spf13/cobra" -) - -func hardenServer(s *http.Server) *http.Server { - s.IdleTimeout = config.HTTPIdleTimeout - s.MaxHeaderBytes = config.HTTPMaxHeaderBytes - s.ReadHeaderTimeout = config.HTTPReadHeaderTimeout - return s -} - -func main() { - var socketPath string - - root := &cobra.Command{Use: "mirumd", Short: "Mirum CI server"} - root.PersistentFlags().StringVar(&socketPath, "socket", "", "admin socket path (default from config or /run/mirumd/admin.sock)") - root.AddGroup(&cobra.Group{ID: "main", Title: "Commands:"}) - - daemonCmd := &cobra.Command{ - Use: "daemon", - Short: "Start the server", - GroupID: "main", - SilenceUsage: true, - SilenceErrors: true, - RunE: func(cmd *cobra.Command, args []string) error { - configFile, _ := cmd.Flags().GetString("config") - return daemon(configFile, socketPath) - }, - } - daemonCmd.Flags().String("config", "", "path to config file") - _ = daemonCmd.MarkFlagRequired("config") - root.AddCommand(daemonCmd) - - // Admin subcommands are generated from admin.proto via reflection. - buildAdminCLI(root, func() pbconnect.AdminClient { return adminClient(socketPath) }) - for _, c := range root.Commands() { - if c.GroupID == "" { - c.GroupID = "main" - } - } - - if err := root.Execute(); err != nil { - os.Exit(1) - } -} - -func daemon(configFile, socketFlag string) error { - cfg, err := getConfig(configFile) - if err != nil { - slog.Error("config parsing failed", "err", err) - return err - } - - if socketFlag != "" { - cfg.AdminSocket = socketFlag - } - - slog.Info("config loaded", "configfile", configFile) - - sup := supervisor.Detect() - ctx, cancel := context.WithCancel(sup.WaitForStop(context.Background())) - defer cancel() - - db, err := DatabaseOpen(ctx, cfg.DatabaseUri) - if err != nil { - slog.Error("couldn't open database", "err", err) - return err - } - - srv := &server{ - db: db, - cfg: cfg, - forge: &forges.GitHub{Secret: cfg.WebhookSecret, Token: cfg.GitHubToken}, - queue: make(chan *pb.Task, config.TaskQueueCapacity), - } - defer srv.Close() - - if err := db.Migrate(ctx); err != nil { - slog.Error("migration failed", "err", err) - return err - } - - slog.Info("database ready") - - go srv.PurgeSessions(ctx) - - adminPath, adminHandler := NewAdminHandler(srv) - - webSrv := hardenServer(NewWebServer(ctx, srv, adminPath, adminHandler)) - grpcSrv := hardenServer(NewGrpcServer(ctx, srv)) - - adminMux := http.NewServeMux() - adminMux.Handle(adminPath, adminHandler) - adminSrv := hardenServer(&http.Server{ - Handler: adminMux, - ConnContext: func(ctx context.Context, _ net.Conn) context.Context { - return context.WithValue(ctx, actorKey{}, OperatorActor()) - }, - BaseContext: func(_ net.Listener) context.Context { - return ctx - }, - }) - - grpcLn, webLn, adminLn, err := listeners(cfg) - if err != nil { - slog.Error("listeners failed", "err", err) - return err - } - - slog.Info("listening", "grpc", grpcLn.Addr(), "web", webLn.Addr(), "admin", cfg.AdminSocket) - - errs := make(chan error, 3) - serve := func(name string, fn func() error) { - go func() { - err := fn() - if errors.Is(err, http.ErrServerClosed) { - err = nil - } - if err != nil { - err = fmt.Errorf("%s server: %w", name, err) - } - errs <- err - }() - } - serve("web", func() error { - if webSrv.TLSConfig != nil { - return webSrv.ServeTLS(webLn, "", "") - } - return webSrv.Serve(webLn) - }) - serve("grpc", func() error { return grpcSrv.ServeTLS(grpcLn, "", "") }) - serve("admin", func() error { return adminSrv.Serve(adminLn) }) - - sup.Ready() - go sup.StartWatchdog(ctx) - - var runErr error - select { - case <-ctx.Done(): - slog.Info("shutting down") - case err := <-errs: - runErr = err - // Propagate the crash to all handler contexts so Poll and - // other long-lived RPCs exit via ctx.Done(); Shutdown below - // then completes without waiting on them. - cancel() - if err != nil { - slog.Error("server exited, shutting down peers", "err", err) - } else { - slog.Warn("server exited unexpectedly, shutting down peers") - } - } - - sup.Stopping() - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), config.HTTPShutdownTimeout) - defer shutdownCancel() - - shutdown := func(name string, s *http.Server) { - if err := s.Shutdown(shutdownCtx); err != nil { - slog.Error("server shutdown", "name", name, "err", err) - } - } - - shutdown("web", webSrv) - shutdown("grpc", grpcSrv) - shutdown("admin", adminSrv) - - return runErr -} - -// listeners returns gRPC, web, and admin listeners. -// With systemd socket activation it expects two named fds: "grpc" and "web". -// Without socket activation it falls back to configured addresses. -func listeners(cfg *appConfig) (grpcLn, webLn, adminLn net.Listener, err error) { - named, err := activation.ListenersWithNames() - if err != nil { - return nil, nil, nil, fmt.Errorf("socket activation: %w", err) - } - - if lns := named["grpc"]; len(lns) > 0 { - grpcLn = lns[0] - } else if grpcLn, err = net.Listen("tcp", cfg.GrpcAddr); err != nil { - return nil, nil, nil, err - } - defer func() { - if err != nil && grpcLn != nil { - _ = grpcLn.Close() - } - }() - - if lns := named["web"]; len(lns) > 0 { - webLn = lns[0] - } else if webLn, err = net.Listen("tcp", cfg.WebAddr); err != nil { - return nil, nil, nil, err - } - defer func() { - if err != nil && webLn != nil { - _ = webLn.Close() - } - }() - - _ = os.Remove(cfg.AdminSocket) - if adminLn, err = net.Listen("unix", cfg.AdminSocket); err != nil { - return nil, nil, nil, err - } - defer func() { - if err != nil && adminLn != nil { - _ = adminLn.Close() - } - }() - - if err = os.Chmod(cfg.AdminSocket, 0o660); err != nil { - return nil, nil, nil, fmt.Errorf("chmod admin socket: %w", err) - } - - return grpcLn, webLn, adminLn, nil -} - -func adminClient(socketPath string) pbconnect.AdminClient { - if socketPath == "" { - socketPath = "/run/mirumd/admin.sock" - } - return pbconnect.NewAdminClient( - &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", socketPath) - }, - }, - }, - "http://localhost.unix", - ) -} diff --git a/cmd/mirumd/server.go b/cmd/mirumd/server.go deleted file mode 100644 index 11585d9..0000000 --- a/cmd/mirumd/server.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "context" - "fmt" - "log/slog" - "sync" - "sync/atomic" - "time" - - "dimidiumlabs/mirum/internal/config" - "dimidiumlabs/mirum/internal/forges" - "dimidiumlabs/mirum/internal/protocol/pb" -) - -// server holds the shared application state. -type server struct { - cfg *appConfig - db *DB - forge forges.Forge - - queue chan *pb.Task - tasks sync.Map // task_id → *forges.PushEvent - taskCounter atomic.Int64 -} - -// Close releases resources owned by the server. Call exactly once, after -// all HTTP servers have finished Shutdown. -func (s *server) Close() { - close(s.queue) - s.db.Close() -} - -// PurgeSessions periodically deletes expired sessions until ctx is cancelled. -func (s *server) PurgeSessions(ctx context.Context) { - ticker := time.NewTicker(config.SessionPurgeInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - if err := s.db.UserSessionPurgeExpired(ctx); err != nil { - slog.Error("purge sessions", "err", err) - } - case <-ctx.Done(): - return - } - } -} - -func (s *server) enqueue(ev *forges.PushEvent) string { - s.taskCounter.Add(1) - id := fmt.Sprintf("task-%d", s.taskCounter.Load()) - - slog.Info("push", "repo", ev.Owner+"/"+ev.Repo, "branch", ev.Branch, "sha", ev.SHA[:8], "task", id) - - s.tasks.Store(id, ev) - _ = s.forge.SetStatus(context.Background(), ev, forges.StatusPending, "Queued") - - s.queue <- &pb.Task{ - Id: id, - CloneUrl: s.forge.AuthURL(ev.CloneURL), - Branch: ev.Branch, - Sha: ev.SHA, - RepoFullName: ev.Owner + "/" + ev.Repo, - } - - return id -} - -func (s *server) complete(ctx context.Context, taskID string, success bool, errMsg string) error { - val, ok := s.tasks.LoadAndDelete(taskID) - if !ok { - return fmt.Errorf("unknown task: %s", taskID) - } - ev := val.(*forges.PushEvent) - - st := forges.StatusSuccess - desc := "Build passed" - if !success { - st = forges.StatusFailure - desc = "Build failed" - if errMsg != "" { - desc = errMsg - } - } - - if err := s.forge.SetStatus(ctx, ev, st, desc); err != nil { - slog.Error("set status", "task", taskID, "err", err) - } - - slog.Info("task complete", "id", taskID, "success", success) - return nil -} diff --git a/cmd/mirumd/server_admin.go b/cmd/mirumd/server_admin.go deleted file mode 100644 index 7e1dae1..0000000 --- a/cmd/mirumd/server_admin.go +++ /dev/null @@ -1,535 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "context" - "errors" - "log/slog" - "net/http" - - "connectrpc.com/connect" - "connectrpc.com/validate" - "google.golang.org/protobuf/types/known/timestamppb" - - "dimidiumlabs/mirum/internal/protocol/pb" - "dimidiumlabs/mirum/internal/protocol/pb/pbconnect" -) - -// NewAdminHandler creates the ConnectRPC handler with validation. -// Authorization is handled inside DB methods, not by an interceptor. -func NewAdminHandler(srv *server) (string, http.Handler) { - as := &adminService{srv: srv} - return pbconnect.NewAdminHandler(as, - connect.WithInterceptors(validate.NewInterceptor()), - ) -} - -type adminService struct { - pbconnect.UnimplementedAdminHandler - srv *server -} - -// --- Error mapping --- - -// newAPIError builds a ConnectError with an empty message string and attaches -// an ErrorInfo detail carrying the domain reason. Clients switch on reason to -// pick user-facing text; the wire never carries human-readable strings. -func newAPIError(code connect.Code, reason pb.ErrorReason, metadata map[string]string) error { - e := connect.NewError(code, nil) - if d, err := connect.NewErrorDetail(&pb.ErrorInfo{Reason: reason, Metadata: metadata}); err == nil { - e.AddDetail(d) - } - return e -} - -var errSpecs = []struct { - err error - code connect.Code - reason pb.ErrorReason -}{ - {ErrUserNotFound, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_USER_NOT_FOUND}, - {ErrOrgNotFound, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_ORG_NOT_FOUND}, - {ErrWorkerNotFound, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_WORKER_NOT_FOUND}, - {ErrNotMember, connect.CodeNotFound, pb.ErrorReason_ERROR_REASON_MEMBER_NOT_FOUND}, - {ErrEmailTaken, connect.CodeAlreadyExists, pb.ErrorReason_ERROR_REASON_EMAIL_TAKEN}, - {ErrSlugTaken, connect.CodeAlreadyExists, pb.ErrorReason_ERROR_REASON_SLUG_TAKEN}, - {ErrAlreadyMember, connect.CodeAlreadyExists, pb.ErrorReason_ERROR_REASON_ALREADY_MEMBER}, - {ErrLastOwner, connect.CodeFailedPrecondition, pb.ErrorReason_ERROR_REASON_LAST_OWNER}, - {ErrSoleOwner, connect.CodeFailedPrecondition, pb.ErrorReason_ERROR_REASON_SOLE_OWNER}, - {ErrInvalidSlug, connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_SLUG}, - {ErrInvalidRole, connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_ROLE}, - {ErrReservedEmail, connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_RESERVED_EMAIL}, - {ErrPermissionDenied, connect.CodePermissionDenied, pb.ErrorReason_ERROR_REASON_PERMISSION_DENIED}, - {ErrUnauthenticated, connect.CodeUnauthenticated, pb.ErrorReason_ERROR_REASON_UNAUTHENTICATED}, - {ErrNotImplemented, connect.CodeUnimplemented, pb.ErrorReason_ERROR_REASON_UNIMPLEMENTED}, -} - -func mapErr(err error) error { - if err == nil { - return nil - } - for _, s := range errSpecs { - if errors.Is(err, s.err) { - return newAPIError(s.code, s.reason, nil) - } - } - slog.Error("unmapped handler error", "err", err) - return newAPIError(connect.CodeInternal, pb.ErrorReason_ERROR_REASON_INTERNAL, nil) -} - -// --- Ref converters --- - -func userRef(r *pb.UserRef) (UserRef, error) { - switch v := r.GetRef().(type) { - case *pb.UserRef_Id: - id, err := IDFromBytes[UserKind](v.Id) - if err != nil { - return UserRef{}, err - } - return UserByID(id), nil - case *pb.UserRef_Email: - return UserByEmail(v.Email), nil - default: - return UserRef{}, nil - } -} - -func orgRef(r *pb.OrgRef) (OrgRef, error) { - switch v := r.GetRef().(type) { - case *pb.OrgRef_Id: - id, err := IDFromBytes[OrgKind](v.Id) - if err != nil { - return OrgRef{}, err - } - return OrgByID(id), nil - case *pb.OrgRef_Slug: - return OrgBySlug(v.Slug), nil - default: - return OrgRef{}, nil - } -} - -// --- Role converters --- - -var roleToString = map[pb.Role]string{ - pb.Role_ROLE_OWNER: "owner", - pb.Role_ROLE_ADMIN: "admin", - pb.Role_ROLE_MEMBER: "member", -} - -var roleToProto = map[string]pb.Role{ - "owner": pb.Role_ROLE_OWNER, - "admin": pb.Role_ROLE_ADMIN, - "member": pb.Role_ROLE_MEMBER, -} - -// --- Page helpers --- - -const ( - defaultPageSize = 50 - maxPageSize = 200 -) - -func pageParams[K IDKind](p *pb.PageRequest) (cursor ID[K], limit int, err error) { - limit = defaultPageSize - if p != nil { - if p.PageSize > 0 && int(p.PageSize) < maxPageSize { - limit = int(p.PageSize) - } else if int(p.PageSize) >= maxPageSize { - limit = maxPageSize - } - if len(p.Cursor) == 16 { - cursor, err = IDFromBytes[K](p.Cursor) - if err != nil { - return - } - } - } - return -} - -func pageResponse[K IDKind](items int, limit int, lastID ID[K], total int) *pb.PageResponse { - resp := &pb.PageResponse{TotalCount: int32(total)} - if items == limit { - resp.NextCursor = lastID.Bytes() - } - return resp -} - -// --- Proto converters --- - -func userToProto(u User) *pb.User { - return &pb.User{ - Id: u.ID.Bytes(), Email: u.Email, CreatedAt: timestamppb.New(u.CreatedAt), - } -} - -func orgToProto(o Organization) *pb.Org { - return &pb.Org{ - Id: o.ID.Bytes(), Name: o.Name, Slug: o.Slug, - Public: o.Public, CreatedAt: timestamppb.New(o.CreatedAt), - } -} - -func memberToProto(m OrgMember) *pb.OrgMemberInfo { - return &pb.OrgMemberInfo{ - User: userToProto(m.User), Role: roleToProto[m.Role], - JoinedAt: timestamppb.New(m.JoinedAt), - } -} - -func workerToProto(w Worker) *pb.Worker { - pw := &pb.Worker{ - Id: w.ID.Bytes(), PublicKey: w.PublicKey, CreatedAt: timestamppb.New(w.CreatedAt), - } - if w.OrgID != nil { - pw.OrgId = w.OrgID.Bytes() - } - return pw -} - -// --- User handlers --- - -func (a *adminService) UserCreate(ctx context.Context, req *connect.Request[pb.UserCreateRequest]) (*connect.Response[pb.UserCreateResponse], error) { - id, err := a.srv.db.UserCreate(ctx, ActorFromContext(ctx), req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.UserCreateResponse{Id: id.Bytes()}), nil -} - -func (a *adminService) UserGet(ctx context.Context, req *connect.Request[pb.UserGetRequest]) (*connect.Response[pb.UserGetResponse], error) { - ref, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - u, err := a.srv.db.UserGet(ctx, ActorFromContext(ctx), ref) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.UserGetResponse{User: userToProto(*u)}), nil -} - -func (a *adminService) UserList(ctx context.Context, req *connect.Request[pb.UserListRequest]) (*connect.Response[pb.UserListResponse], error) { - cursor, limit, err := pageParams[UserKind](req.Msg.Page) - if err != nil { - return nil, mapErr(err) - } - filter := "" - if req.Msg.Filter != nil { - filter = *req.Msg.Filter - } - - users, total, err := a.srv.db.UserList(ctx, ActorFromContext(ctx), cursor, limit, filter) - if err != nil { - return nil, mapErr(err) - } - - out := make([]*pb.User, len(users)) - for i := range users { - out[i] = userToProto(users[i]) - } - - var lastID UserID - if len(users) > 0 { - lastID = users[len(users)-1].ID - } - - return connect.NewResponse(&pb.UserListResponse{ - Page: pageResponse(len(users), limit, lastID, total), - Users: out, - }), nil -} - -func (a *adminService) UserUpdate(ctx context.Context, req *connect.Request[pb.UserUpdateRequest]) (*connect.Response[pb.UserUpdateResponse], error) { - ref, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.UserUpdate(ctx, ActorFromContext(ctx), ref, req.Msg.Email, req.Msg.Password, []byte(a.srv.cfg.Pepper)); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.UserUpdateResponse{}), nil -} - -func (a *adminService) UserDelete(ctx context.Context, req *connect.Request[pb.UserDeleteRequest]) (*connect.Response[pb.UserDeleteResponse], error) { - ref, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.UserDelete(ctx, ActorFromContext(ctx), ref); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.UserDeleteResponse{}), nil -} - -// --- Org handlers --- - -func (a *adminService) OrgCreate(ctx context.Context, req *connect.Request[pb.OrgCreateRequest]) (*connect.Response[pb.OrgCreateResponse], error) { - slug, err := ValidateSlug(req.Msg.Slug) - if err != nil { - return nil, mapErr(err) - } - owner, err := userRef(req.Msg.Owner) - if err != nil { - return nil, mapErr(err) - } - id, err := a.srv.db.OrgCreate(ctx, ActorFromContext(ctx), req.Msg.Name, slug, req.Msg.Public, owner) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.OrgCreateResponse{Id: id.Bytes()}), nil -} - -func (a *adminService) OrgGet(ctx context.Context, req *connect.Request[pb.OrgGetRequest]) (*connect.Response[pb.OrgGetResponse], error) { - ref, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - o, err := a.srv.db.OrgGet(ctx, ActorFromContext(ctx), ref) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.OrgGetResponse{Org: orgToProto(*o)}), nil -} - -func (a *adminService) OrgList(ctx context.Context, req *connect.Request[pb.OrgListRequest]) (*connect.Response[pb.OrgListResponse], error) { - cursor, limit, err := pageParams[OrgKind](req.Msg.Page) - if err != nil { - return nil, mapErr(err) - } - filter := "" - if req.Msg.Filter != nil { - filter = *req.Msg.Filter - } - - orgs, total, err := a.srv.db.OrgList(ctx, ActorFromContext(ctx), cursor, limit, filter) - if err != nil { - return nil, mapErr(err) - } - - out := make([]*pb.Org, len(orgs)) - for i := range orgs { - out[i] = orgToProto(orgs[i]) - } - - var lastID OrgID - if len(orgs) > 0 { - lastID = orgs[len(orgs)-1].ID - } - - return connect.NewResponse(&pb.OrgListResponse{ - Page: pageResponse(len(orgs), limit, lastID, total), - Organizations: out, - }), nil -} - -func (a *adminService) OrgUpdate(ctx context.Context, req *connect.Request[pb.OrgUpdateRequest]) (*connect.Response[pb.OrgUpdateResponse], error) { - var slug *string - if req.Msg.Slug != nil { - s, err := ValidateSlug(*req.Msg.Slug) - if err != nil { - return nil, mapErr(err) - } - slug = &s - } - ref, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.OrgUpdate(ctx, ActorFromContext(ctx), ref, req.Msg.Name, slug, req.Msg.Public); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.OrgUpdateResponse{}), nil -} - -func (a *adminService) OrgDelete(ctx context.Context, req *connect.Request[pb.OrgDeleteRequest]) (*connect.Response[pb.OrgDeleteResponse], error) { - ref, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.OrgDelete(ctx, ActorFromContext(ctx), ref); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.OrgDeleteResponse{}), nil -} - -// --- OrgMember handlers --- - -func (a *adminService) OrgMemberAdd(ctx context.Context, req *connect.Request[pb.OrgMemberAddRequest]) (*connect.Response[pb.OrgMemberAddResponse], error) { - role, ok := roleToString[req.Msg.Role] - if !ok { - return nil, newAPIError(connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_ROLE, nil) - } - org, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - user, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.OrgMemberAdd(ctx, ActorFromContext(ctx), org, user, role); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.OrgMemberAddResponse{}), nil -} - -func (a *adminService) OrgMemberGet(ctx context.Context, req *connect.Request[pb.OrgMemberGetRequest]) (*connect.Response[pb.OrgMemberGetResponse], error) { - org, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - user, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - m, err := a.srv.db.OrgMemberGet(ctx, ActorFromContext(ctx), org, user) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.OrgMemberGetResponse{Member: memberToProto(*m)}), nil -} - -func (a *adminService) OrgMemberList(ctx context.Context, req *connect.Request[pb.OrgMemberListRequest]) (*connect.Response[pb.OrgMemberListResponse], error) { - cursor, limit, err := pageParams[UserKind](req.Msg.Page) - if err != nil { - return nil, mapErr(err) - } - filter := "" - if req.Msg.Filter != nil { - filter = *req.Msg.Filter - } - org, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - - members, total, err := a.srv.db.OrgMembersList(ctx, ActorFromContext(ctx), org, cursor, limit, filter) - if err != nil { - return nil, mapErr(err) - } - - out := make([]*pb.OrgMemberInfo, len(members)) - for i := range members { - out[i] = memberToProto(members[i]) - } - - var lastID UserID - if len(members) > 0 { - lastID = members[len(members)-1].User.ID - } - - return connect.NewResponse(&pb.OrgMemberListResponse{ - Page: pageResponse(len(members), limit, lastID, total), - Members: out, - }), nil -} - -func (a *adminService) OrgMemberUpdate(ctx context.Context, req *connect.Request[pb.OrgMemberUpdateRequest]) (*connect.Response[pb.OrgMemberUpdateResponse], error) { - role, ok := roleToString[req.Msg.Role] - if !ok { - return nil, newAPIError(connect.CodeInvalidArgument, pb.ErrorReason_ERROR_REASON_INVALID_ROLE, nil) - } - org, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - user, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.OrgMemberUpdateRole(ctx, ActorFromContext(ctx), org, user, role); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.OrgMemberUpdateResponse{}), nil -} - -func (a *adminService) OrgMemberRemove(ctx context.Context, req *connect.Request[pb.OrgMemberRemoveRequest]) (*connect.Response[pb.OrgMemberRemoveResponse], error) { - org, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - user, err := userRef(req.Msg.User) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.OrgMemberRemove(ctx, ActorFromContext(ctx), org, user); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.OrgMemberRemoveResponse{}), nil -} - -// --- Worker handlers --- - -func (a *adminService) WorkerCreate(ctx context.Context, req *connect.Request[pb.WorkerCreateRequest]) (*connect.Response[pb.WorkerCreateResponse], error) { - var org *OrgRef - if req.Msg.Org != nil { - r, err := orgRef(req.Msg.Org) - if err != nil { - return nil, mapErr(err) - } - org = &r - } - id, err := a.srv.db.WorkerCreate(ctx, ActorFromContext(ctx), req.Msg.PublicKey, org) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.WorkerCreateResponse{Id: id.Bytes()}), nil -} - -func (a *adminService) WorkerGet(ctx context.Context, req *connect.Request[pb.WorkerGetRequest]) (*connect.Response[pb.WorkerGetResponse], error) { - wid, err := IDFromBytes[WorkerKind](req.Msg.Id) - if err != nil { - return nil, mapErr(err) - } - w, err := a.srv.db.WorkerGet(ctx, ActorFromContext(ctx), wid) - if err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.WorkerGetResponse{Worker: workerToProto(*w)}), nil -} - -func (a *adminService) WorkerList(ctx context.Context, req *connect.Request[pb.WorkerListRequest]) (*connect.Response[pb.WorkerListResponse], error) { - cursor, limit, err := pageParams[WorkerKind](req.Msg.Page) - if err != nil { - return nil, mapErr(err) - } - filter := "" - if req.Msg.Filter != nil { - filter = *req.Msg.Filter - } - - workers, total, err := a.srv.db.WorkerList(ctx, ActorFromContext(ctx), cursor, limit, filter) - if err != nil { - return nil, mapErr(err) - } - - out := make([]*pb.Worker, len(workers)) - for i := range workers { - out[i] = workerToProto(workers[i]) - } - - var lastID WorkerID - if len(workers) > 0 { - lastID = workers[len(workers)-1].ID - } - - return connect.NewResponse(&pb.WorkerListResponse{ - Page: pageResponse(len(workers), limit, lastID, total), - Workers: out, - }), nil -} - -func (a *adminService) WorkerDelete(ctx context.Context, req *connect.Request[pb.WorkerDeleteRequest]) (*connect.Response[pb.WorkerDeleteResponse], error) { - wid, err := IDFromBytes[WorkerKind](req.Msg.Id) - if err != nil { - return nil, mapErr(err) - } - if err := a.srv.db.WorkerDelete(ctx, ActorFromContext(ctx), wid); err != nil { - return nil, mapErr(err) - } - return connect.NewResponse(&pb.WorkerDeleteResponse{}), nil -} diff --git a/cmd/mirumd/server_grpc.go b/cmd/mirumd/server_grpc.go deleted file mode 100644 index a41604d..0000000 --- a/cmd/mirumd/server_grpc.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "context" - "crypto/ed25519" - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "log/slog" - "net" - "net/http" - "time" - - "connectrpc.com/connect" - "connectrpc.com/validate" - - "dimidiumlabs/mirum/internal/config" - "dimidiumlabs/mirum/internal/protocol" - "dimidiumlabs/mirum/internal/protocol/pb" - "dimidiumlabs/mirum/internal/protocol/pb/pbconnect" -) - -func NewGrpcServer(ctx context.Context, srv *server) *http.Server { - gsrv := &grpcService{srv: srv} - - path, handler := pbconnect.NewMirumHandler(gsrv, - connect.WithInterceptors(validate.NewInterceptor()), - ) - - mux := http.NewServeMux() - mux.Handle(path, workerLog(handler)) - - certs := newCertReloader(srv.cfg.GrpcTls.Cert, srv.cfg.GrpcTls.Key) - - return &http.Server{ - Handler: mux, - BaseContext: func(_ net.Listener) context.Context { - return ctx - }, - TLSConfig: &tls.Config{ - NextProtos: []string{"h2"}, - MinVersion: tls.VersionTLS13, - ClientAuth: tls.RequireAnyClientCert, - GetCertificate: certs.GetCertificate, - VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { - if len(rawCerts) == 0 { - return errors.New("client certificate required") - } - - c, err := x509.ParseCertificate(rawCerts[0]) - if err != nil { - return fmt.Errorf("parse client cert: %w", err) - } - - pubKey, ok := c.PublicKey.(ed25519.PublicKey) - if !ok { - return errors.New("ed25519 certificate required") - } - - if _, err := srv.db.WorkerLookup(context.Background(), SystemActor(), pubKey); err != nil { - return fmt.Errorf("unknown worker: %w", err) - } - - // Clock skew: NotBefore is set to time.Now() when the cert was generated. - // Checked here (once per TLS handshake), not in the interceptor, - // because HTTP/2 reuses the connection and NotBefore would go stale. - if skew := time.Since(c.NotBefore).Abs(); skew > config.WorkerClockSkewLimit { - return fmt.Errorf("%w: %s", protocol.ErrClockSkew, skew.Truncate(time.Second)) - } - - return nil - }, - }, - } -} - -// grpcService is the ConnectRPC transport adapter over server. -type grpcService struct { - pbconnect.UnimplementedMirumHandler - srv *server -} - -func (g *grpcService) Poll(ctx context.Context, req *connect.Request[pb.PollRequest]) (*connect.Response[pb.Task], error) { - select { - case task, ok := <-g.srv.queue: - if !ok { - return nil, connect.NewError(connect.CodeUnavailable, fmt.Errorf("server is shutting down")) - } - slog.Info("task dispatched", "id", task.Id, "repo", task.RepoFullName) - return connect.NewResponse(task), nil - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -func (g *grpcService) Complete(ctx context.Context, req *connect.Request[pb.TaskResult]) (*connect.Response[pb.CompleteResponse], error) { - if err := g.srv.complete(ctx, req.Msg.TaskId, req.Msg.Success, req.Msg.Error); err != nil { - return nil, err - } - return connect.NewResponse(&pb.CompleteResponse{}), nil -} - -// workerLog logs worker metadata from the mTLS client certificate -// and sets the server version response header. -func workerLog(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { - if meta := protocol.ParseWorkerMeta(r.TLS.PeerCertificates[0]); meta != nil { - slog.Info("worker request", - "name", meta.Name, - "version", meta.Version, - "path", r.URL.Path, - ) - } - } - w.Header().Set("X-Server-Version", protocol.VersionString()) - next.ServeHTTP(w, r) - }) -} diff --git a/cmd/mirumd/server_web.go b/cmd/mirumd/server_web.go deleted file mode 100644 index 13c5154..0000000 --- a/cmd/mirumd/server_web.go +++ /dev/null @@ -1,390 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "context" - "crypto/rand" - "crypto/subtle" - "crypto/tls" - "encoding/base64" - "errors" - "io" - "log/slog" - "net" - "net/http" - "runtime/debug" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/httprate" - - "dimidiumlabs/mirum/internal/config" - "dimidiumlabs/mirum/internal/forges" - "dimidiumlabs/mirum/internal/protocol/pb" -) - -// __Host- prefixed cookies can only be set with Secure, Path=/, and no -// Domain attribute. Browsers silently reject violations, so subdomain and -// network attackers cannot forge them. -const ( - sessionCookie = "__Host-session" - csrfCookie = "__Host-csrf" -) - -func NewWebServer(ctx context.Context, srv *server, adminPath string, adminHandler http.Handler) *http.Server { - h := &webHandler{ - srv: srv, - assets: newAssetResolver(), - } - - r := chi.NewRouter() - - r.Use(middleware.CleanPath) - r.Use(middleware.StripSlashes) - r.Use(middleware.RequestID) - r.Use(middleware.Logger) - r.Use(h.recoverer) - r.Use(middleware.Compress(5)) - r.Use(middleware.Heartbeat("/ping")) - r.Use(middleware.Timeout(config.WebRequestTimeout)) - r.Use(middleware.RequestSize(config.WebMaxBodyBytes)) - r.Use(trustedProxyMiddleware(srv.cfg.TrustedProxies)) - - r.Use(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Security-Policy", csp) - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") - w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") - w.Header().Set("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()") - - if srv.cfg.WebTls != nil { - w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload") - } - - next.ServeHTTP(w, r) - }) - }) - - // The authorization session sets the user to ctx - r.Use(h.SessionMiddleware) - - r.With(middleware.SetHeader("Cache-Control", "public, max-age=31536000, immutable")). - Mount("/assets", assetsHandler()) - - r.Get("/", authonly(h.index)) - r.Post("/webhook", h.webhook) - - r.Route("/auth", func(r chi.Router) { - r.Use(middleware.NoCache) - r.Use(httprate.LimitByIP(config.AuthRateLimit, config.AuthRateWindow)) - r.Use(middleware.RequestSize(config.AuthMaxBodyBytes)) - - r.Get("/login", h.loginPage) - r.Post("/login", h.login) - r.Post("/logout", h.logout) - }) - - r.With(middleware.NoCache, httprate.LimitByIP(config.APIRateLimit, config.APIRateWindow)). - Mount("/api/v1", http.StripPrefix("/api/v1", adminHandler)) - - r.NotFound(func(w http.ResponseWriter, r *http.Request) { - h.renderError(w, r, http.StatusNotFound) - }) - r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { - h.renderError(w, r, http.StatusMethodNotAllowed) - }) - - var tlsCfg *tls.Config - if srv.cfg.WebTls != nil { - certs := newCertReloader(srv.cfg.WebTls.Cert, srv.cfg.WebTls.Key) - tlsCfg = &tls.Config{ - MinVersion: tls.VersionTLS13, - GetCertificate: certs.GetCertificate, - } - } - - return &http.Server{ - Handler: r, - TLSConfig: tlsCfg, - BaseContext: func(_ net.Listener) context.Context { - return ctx - }, - } -} - -type actorKey struct{} - -type webHandler struct { - srv *server - assets *assetResolver -} - -// ActorFromContext returns the authenticated actor, or AnonActor if none. -func ActorFromContext(ctx context.Context) Actor { - if v, ok := ctx.Value(actorKey{}).(Actor); ok { - return v - } - return AnonActor() -} - -// SessionMiddleware resolves the session cookie and puts the Actor in context. -func (h *webHandler) SessionMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie(sessionCookie); err == nil { - if actor, err := h.srv.db.UserSessionGet(r.Context(), SystemActor(), c.Value); err == nil { - ctx := context.WithValue(r.Context(), actorKey{}, actor) - r = r.WithContext(ctx) - } - } - next.ServeHTTP(w, r) - }) -} - -type authedHandler func(w http.ResponseWriter, r *http.Request, actor Actor) - -func authonly(next authedHandler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - actor := ActorFromContext(r.Context()) - if actor.Kind() == KindAnon { - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) - return - } - next(w, r, actor) - } -} - -func (h *webHandler) index(w http.ResponseWriter, r *http.Request, actor Actor) { - h.assets.renderPage(w, "dashboard", http.StatusOK, map[string]any{ - "user": map[string]string{"email": actor.Email()}, - "csrf": csrfToken(w, r), - }) -} - -// renderError serves the error page with the given HTTP status. -func (h *webHandler) renderError(w http.ResponseWriter, r *http.Request, status int) { - h.assets.renderPage(w, "error", status, map[string]any{"status": status}) -} - -// recoverer catches panics, logs them, and renders the 500 page so the -// client sees something more useful than chi's plaintext default. The -// http.ErrAbortHandler sentinel is re-raised so net/http's server -// machinery can recognise an intentional handler abort. -func (h *webHandler) recoverer(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - rvr := recover() - if rvr == nil { - return - } - if rvr == http.ErrAbortHandler { - panic(rvr) - } - slog.Error("panic", - "err", rvr, - "path", r.URL.Path, - "stack", string(debug.Stack()), - ) - h.renderError(w, r, http.StatusInternalServerError) - }() - next.ServeHTTP(w, r) - }) -} - -func (h *webHandler) webhook(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "read body", http.StatusBadRequest) - return - } - - ev, err := h.srv.forge.Webhook(r, body) - if errors.Is(err, forges.ErrInvalidSignature) { - http.Error(w, "invalid signature", http.StatusUnauthorized) - return - } - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if ev == nil { - w.WriteHeader(http.StatusNoContent) - return - } - - h.srv.enqueue(ev) - w.WriteHeader(http.StatusAccepted) -} - -func (h *webHandler) loginPage(w http.ResponseWriter, r *http.Request) { - h.renderLogin(w, r, http.StatusOK, pb.ErrorReason_ERROR_REASON_UNSPECIFIED) -} - -// renderLogin is the single entry point for every login-flow outcome that -// lands back on the login page. Reason == UNSPECIFIED means no error banner. -// No caller writes error text itself — the client maps reason → copy. -func (h *webHandler) renderLogin(w http.ResponseWriter, r *http.Request, status int, reason pb.ErrorReason) { - data := map[string]any{"csrf": csrfToken(w, r)} - if reason != pb.ErrorReason_ERROR_REASON_UNSPECIFIED { - data["errorReason"] = int32(reason) - } - h.assets.renderPage(w, "login", status, data) -} - -func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { - if !csrfOK(r) { - clearCookie(w, csrfCookie) - h.renderLogin(w, r, http.StatusForbidden, pb.ErrorReason_ERROR_REASON_INVALID_CSRF) - return - } - - email := r.FormValue("email") - password := r.FormValue("password") - - userID, err := h.srv.db.UserVerifyPassword(r.Context(), SystemActor(), email, password, []byte(h.srv.cfg.Pepper)) - if err != nil { - h.renderLogin(w, r, http.StatusUnauthorized, pb.ErrorReason_ERROR_REASON_INVALID_CREDENTIALS) - return - } - - token, err := h.srv.db.UserSessionCreate(r.Context(), SystemActor(), userID) - if err != nil { - slog.Error("create session failed", "err", err) - h.renderLogin(w, r, http.StatusInternalServerError, pb.ErrorReason_ERROR_REASON_INTERNAL) - return - } - - http.SetCookie(w, &http.Cookie{ - Name: sessionCookie, - Value: token, - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - MaxAge: int(config.SessionTTL.Seconds()), - }) - http.Redirect(w, r, "/", http.StatusSeeOther) -} - -func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) { - if !csrfOK(r) { - // Forged logout attempt — ignore silently. Session stays valid, - // user ends up wherever / takes them. - http.Redirect(w, r, "/", http.StatusSeeOther) - return - } - if c, err := r.Cookie(sessionCookie); err == nil { - h.srv.db.UserSessionDelete(r.Context(), SystemActor(), c.Value) - } - clearCookie(w, sessionCookie) - clearCookie(w, csrfCookie) - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) -} - -// csrfToken returns the current CSRF token, setting a cookie if absent. -func csrfToken(w http.ResponseWriter, r *http.Request) string { - if c, err := r.Cookie(csrfCookie); err == nil && c.Value != "" { - return c.Value - } - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - panic("crypto/rand failed: " + err.Error()) - } - token := base64.RawURLEncoding.EncodeToString(b) - http.SetCookie(w, &http.Cookie{ - Name: csrfCookie, - Value: token, - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteStrictMode, - MaxAge: int(config.SessionTTL.Seconds()), - }) - return token -} - -// csrfOK checks that the form field or X-CSRF-Token header matches the -// cookie (double-submit). Form posts use the hidden "csrf" field; API calls -// from the SPA pass the token via the X-CSRF-Token header. -func csrfOK(r *http.Request) bool { - cookie, err := r.Cookie(csrfCookie) - if err != nil || cookie.Value == "" { - return false - } - token := r.FormValue("csrf") - if token == "" { - token = r.Header.Get("X-CSRF-Token") - } - return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(token)) == 1 -} - -func clearCookie(w http.ResponseWriter, name string) { - http.SetCookie(w, &http.Cookie{ - Name: name, - Value: "", - Path: "/", - HttpOnly: true, - Secure: true, - MaxAge: -1, - }) -} - -// trustedProxyMiddleware resolves the real client IP from X-Forwarded-For, -// walking right-to-left and stopping at the first untrusted hop. -// Empty cidrs = trust RemoteAddr only (safe default). -func trustedProxyMiddleware(cidrs []string) func(http.Handler) http.Handler { - nets := make([]*net.IPNet, 0, len(cidrs)) - for _, c := range cidrs { - _, n, err := net.ParseCIDR(c) - if err != nil { - panic("invalid trusted_proxies CIDR: " + c) - } - nets = append(nets, n) - } - - isTrusted := func(ip net.IP) bool { - for _, n := range nets { - if n.Contains(ip) { - return true - } - } - return false - } - - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if len(nets) == 0 { - next.ServeHTTP(w, r) - return - } - - host, _, _ := net.SplitHostPort(r.RemoteAddr) - ip := net.ParseIP(host) - if ip == nil || !isTrusted(ip) { - // RemoteAddr is not a trusted proxy — use as-is. - next.ServeHTTP(w, r) - return - } - - // Walk X-Forwarded-For right to left. - xff := strings.Split(r.Header.Get("X-Forwarded-For"), ",") - for i := len(xff) - 1; i >= 0; i-- { - candidate := strings.TrimSpace(xff[i]) - ip = net.ParseIP(candidate) - if ip == nil { - break // garbage — stop, don't trust anything further left - } - if !isTrusted(ip) { - r.RemoteAddr = candidate + ":0" - break - } - } - - next.ServeHTTP(w, r) - }) - } -} diff --git a/cmd/mirumd/static.go b/cmd/mirumd/static.go deleted file mode 100644 index bc304ba..0000000 --- a/cmd/mirumd/static.go +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "crypto/sha512" - "embed" - "encoding/base64" - "encoding/json" - "fmt" - "html/template" - "io/fs" - "log/slog" - "net/http" - "path" - "strings" -) - -//go:embed web/*.html -var templateFS embed.FS - -var shellTmpl = template.Must(template.ParseFS(templateFS, "web/shell.html")) - -type assetRef struct { - Href string - Integrity string // "sha384-BASE64" or empty -} - -type pageAssets struct { - CSS []assetRef // - Scripts []assetRef // - {{- if .Preamble}} - - {{- end}} - {{- range .Scripts}} - - {{- end}} - - diff --git a/cmd/mirumd/web/tsconfig.json b/cmd/mirumd/web/tsconfig.json deleted file mode 100644 index 90b2f46..0000000 --- a/cmd/mirumd/web/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { "@/*": ["./*"] }, - - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2023", - "useDefineForClassFields": true, - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "module": "ESNext", - "types": ["vite/client"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - }, - "include": ["components", "api", "lib", "entries", "gen"] -} diff --git a/cmd/mirumd/web/vite.config.ts b/cmd/mirumd/web/vite.config.ts deleted file mode 100644 index 6de11ff..0000000 --- a/cmd/mirumd/web/vite.config.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -import { readdirSync } from "node:fs" -import { resolve, parse } from "node:path" -import { defineConfig } from "vite" - -import react from "@vitejs/plugin-react" -import tailwindcss from "@tailwindcss/vite" - -const input = readdirSync(resolve(__dirname, "entries")).reduce((acc, file) => { - const {name} = parse(file); - acc[name] = resolve(__dirname, "entries", file) - return acc -}, {} as Record) - -export default defineConfig({ - clearScreen: false, - plugins: [react(), tailwindcss()], - resolve: { - alias: { "@": resolve(__dirname) }, - }, - server: { - host: "127.0.0.1", - origin: "http://localhost:5173", - cors: { origin: "http://localhost:3000" }, - }, - build: { - outDir: "../static", - manifest: true, - emptyOutDir: true, - rollupOptions: { - input, - output: { - entryFileNames: "assets/[name].[hash].js", - chunkFileNames: "assets/[name].[hash].js", - assetFileNames: "assets/[name].[hash][extname]", - manualChunks(id) { - if (id.includes("node_modules/react") || id.includes("node_modules/react-dom")) { - return "vendor" - } - }, - }, - }, - }, -}) diff --git a/cmd/mirumw/client.go b/cmd/mirumw/client.go deleted file mode 100644 index 7f3886d..0000000 --- a/cmd/mirumw/client.go +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "context" - "crypto/tls" - "crypto/x509" - "fmt" - "log/slog" - "net/http" - "os" - "runtime" - - "connectrpc.com/connect" - - "dimidiumlabs/mirum/internal/executor" - "dimidiumlabs/mirum/internal/protocol" - "dimidiumlabs/mirum/internal/protocol/pb" - "dimidiumlabs/mirum/internal/protocol/pb/pbconnect" -) - -type client struct { - cfg *config - http *http.Client - handle pbconnect.MirumClient -} - -func dial(ctx context.Context, cfg *config) (*client, error) { - name := cfg.Name - if name == "" { - name, _ = os.Hostname() - } - - meta := &protocol.WorkerMeta{ - Os: runtime.GOOS, - Arch: runtime.GOARCH, - Name: name, - Runtime: workerRuntime, - Version: protocol.VersionString(), - } - - tlsCfg := &tls.Config{ - NextProtos: []string{"h2"}, - MinVersion: tls.VersionTLS13, - GetClientCertificate: func(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) { - key, err := protocol.LoadPrivateKey(cfg.KeyFile) - if err != nil { - return nil, fmt.Errorf("load key: %w", err) - } - - cert, err := protocol.SelfSignedCert(key, meta) - return &cert, err - }, - } - - if cfg.TLSCA != "" { - caCert, err := os.ReadFile(cfg.TLSCA) - if err != nil { - return nil, fmt.Errorf("read CA cert: %w", err) - } - - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to parse CA cert") - } - - tlsCfg.RootCAs = pool - } - - c := &client{ - cfg: cfg, - http: &http.Client{ - Transport: &http.Transport{TLSClientConfig: tlsCfg, ForceAttemptHTTP2: true}, - }, - } - c.handle = pbconnect.NewMirumClient(c.http, "https://"+cfg.Server, connect.WithGRPC()) - - slog.Info("dialing", "server", cfg.Server) - - return c, nil -} - -func (c *client) close() {} - -func (c *client) work(ctx context.Context) error { - for ctx.Err() == nil { - resp, err := c.handle.Poll(ctx, connect.NewRequest(&pb.PollRequest{})) - if err != nil { - return fmt.Errorf("poll: %w", err) - } - - for _, w := range resp.Header().Values("X-Warning") { - slog.Warn("server warning", "msg", w) - } - - task := resp.Msg - slog.Info("task received", "id", task.Id, "repo", task.RepoFullName) - - execErr := executor.Run(task.CloneUrl, task.Branch) - - result := &pb.TaskResult{TaskId: task.Id, Success: execErr == nil} - if execErr != nil { - result.Error = execErr.Error() - slog.Error("task failed", "id", task.Id, "err", execErr) - } else { - slog.Info("task passed", "id", task.Id) - } - - if _, err := c.handle.Complete(ctx, connect.NewRequest(result)); err != nil { - return fmt.Errorf("complete: %w", err) - } - } - - return ctx.Err() -} diff --git a/cmd/mirumw/config.go b/cmd/mirumw/config.go deleted file mode 100644 index 50e82d3..0000000 --- a/cmd/mirumw/config.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "fmt" - "os" - - "gopkg.in/yaml.v3" -) - -// Runtime type of this worker binary. Different worker types -// (mirumw-vm, mirumw-docker, etc.) will have different values. -const workerRuntime = "host" - -type config struct { - Name string `yaml:"name"` - Server string `yaml:"server"` - KeyFile string `yaml:"key_file"` - TLSCA string `yaml:"tls_ca"` // custom CA cert for self-signed/dev -} - -func getConfig(filename string) (*config, error) { - cfg := &config{ - Server: "localhost:2026", - } - - if filename != "" { - data, err := os.ReadFile(filename) - if err != nil { - return nil, fmt.Errorf("couldn't read config: %w", err) - } - - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("couldn't parse config: %w", err) - } - } - - if cfg.KeyFile == "" { - return nil, fmt.Errorf("error: key_file is required") - } - - return cfg, nil -} diff --git a/cmd/mirumw/main.go b/cmd/mirumw/main.go deleted file mode 100644 index e2132b5..0000000 --- a/cmd/mirumw/main.go +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package main - -import ( - "context" - "flag" - "log/slog" - "os" - - "dimidiumlabs/mirum/internal/protocol" - "dimidiumlabs/mirum/internal/supervisor" -) - -func main() { - configFile := flag.String("config", "", "path to config file") - flag.Parse() - - cfg, err := getConfig(*configFile) - if err != nil { - slog.Error("config", "err", err) - os.Exit(1) - } - - sup := supervisor.Detect() - ctx := sup.WaitForStop(context.Background()) - - sup.Ready() - go sup.StartWatchdog(ctx) - - backoff := protocol.NewBackoff() - - for ctx.Err() == nil { - c, err := dial(ctx, cfg) - if err != nil { - slog.Error("connect failed", "err", err) - if !backoff.Wait(ctx) { - break - } - - continue - } - - slog.Info("connected", "server", cfg.Server) - - if err := c.work(ctx); err != nil && ctx.Err() == nil { - slog.Error("work loop failed", "err", err) - c.close() - if !backoff.Wait(ctx) { - break - } - continue - } - - backoff.Reset() - c.close() - } - - slog.Info("shutting down") - sup.Stopping() -} diff --git a/internal/config/config.go b/internal/config/config.go index c9d0521..3d1256c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Nikolay Govorov // SPDX-License-Identifier: AGPL-3.0-or-later -// Package config holds hard-coded tunables shared across mirumd and mirumw: +// Package config holds hard-coded tunables shared across mirum-server and mirum-worker: // timeouts, sizes, intervals, and limits that are not (yet) exposed through // the YAML user config. Grouping them here keeps magic numbers out of call // sites and gives a single place to audit defaults. diff --git a/internal/protocol/platform.go b/internal/protocol/platform.go index 638a664..d48da44 100644 --- a/internal/protocol/platform.go +++ b/internal/protocol/platform.go @@ -11,7 +11,7 @@ import ( "strings" mirum "dimidiumlabs/mirum" - "dimidiumlabs/mirum/internal/protocol/pb" + "dimidiumlabs/mirum/internal/protocol/wirepb" ) var ErrInvalidVersion = errors.New("invalid version string") @@ -22,53 +22,53 @@ var ( Patch uint32 ) -var osMap = map[string]pb.Os{ - "linux": pb.Os_OS_LINUX, - "darwin": pb.Os_OS_DARWIN, - "windows": pb.Os_OS_WINDOWS, - "freebsd": pb.Os_OS_FREEBSD, - "openbsd": pb.Os_OS_OPENBSD, - "netbsd": pb.Os_OS_NETBSD, - "dragonfly": pb.Os_OS_DRAGONFLY, - "illumos": pb.Os_OS_ILLUMOS, - "solaris": pb.Os_OS_SOLARIS, - "aix": pb.Os_OS_AIX, - "plan9": pb.Os_OS_PLAN9, - "android": pb.Os_OS_ANDROID, - "ios": pb.Os_OS_IOS, - "js": pb.Os_OS_JS, - "wasip1": pb.Os_OS_WASIP1, +var osMap = map[string]wirepb.Os{ + "linux": wirepb.Os_OS_LINUX, + "darwin": wirepb.Os_OS_DARWIN, + "windows": wirepb.Os_OS_WINDOWS, + "freebsd": wirepb.Os_OS_FREEBSD, + "openbsd": wirepb.Os_OS_OPENBSD, + "netbsd": wirepb.Os_OS_NETBSD, + "dragonfly": wirepb.Os_OS_DRAGONFLY, + "illumos": wirepb.Os_OS_ILLUMOS, + "solaris": wirepb.Os_OS_SOLARIS, + "aix": wirepb.Os_OS_AIX, + "plan9": wirepb.Os_OS_PLAN9, + "android": wirepb.Os_OS_ANDROID, + "ios": wirepb.Os_OS_IOS, + "js": wirepb.Os_OS_JS, + "wasip1": wirepb.Os_OS_WASIP1, } -var archMap = map[string]pb.Arch{ - "amd64": pb.Arch_ARCH_AMD64, - "arm64": pb.Arch_ARCH_ARM64, - "386": pb.Arch_ARCH_386, - "arm": pb.Arch_ARCH_ARM, - "riscv64": pb.Arch_ARCH_RISCV64, - "ppc64le": pb.Arch_ARCH_PPC64LE, - "ppc64": pb.Arch_ARCH_PPC64, - "s390x": pb.Arch_ARCH_S390X, - "mips64le": pb.Arch_ARCH_MIPS64LE, - "mips64": pb.Arch_ARCH_MIPS64, - "mipsle": pb.Arch_ARCH_MIPSLE, - "mips": pb.Arch_ARCH_MIPS, - "loong64": pb.Arch_ARCH_LOONG64, - "wasm": pb.Arch_ARCH_WASM, +var archMap = map[string]wirepb.Arch{ + "amd64": wirepb.Arch_ARCH_AMD64, + "arm64": wirepb.Arch_ARCH_ARM64, + "386": wirepb.Arch_ARCH_386, + "arm": wirepb.Arch_ARCH_ARM, + "riscv64": wirepb.Arch_ARCH_RISCV64, + "ppc64le": wirepb.Arch_ARCH_PPC64LE, + "ppc64": wirepb.Arch_ARCH_PPC64, + "s390x": wirepb.Arch_ARCH_S390X, + "mips64le": wirepb.Arch_ARCH_MIPS64LE, + "mips64": wirepb.Arch_ARCH_MIPS64, + "mipsle": wirepb.Arch_ARCH_MIPSLE, + "mips": wirepb.Arch_ARCH_MIPS, + "loong64": wirepb.Arch_ARCH_LOONG64, + "wasm": wirepb.Arch_ARCH_WASM, } -func DetectOs() pb.Os { +func DetectOs() wirepb.Os { if v, ok := osMap[runtime.GOOS]; ok { return v } - return pb.Os_OS_UNSPECIFIED + return wirepb.Os_OS_UNSPECIFIED } -func DetectArch() pb.Arch { +func DetectArch() wirepb.Arch { if v, ok := archMap[runtime.GOARCH]; ok { return v } - return pb.Arch_ARCH_UNSPECIFIED + return wirepb.Arch_ARCH_UNSPECIFIED } func init() { @@ -99,6 +99,6 @@ func VersionString() string { return fmt.Sprintf("%d.%d.%d", Major, Minor, Patch) } -func VersionProto() *pb.Version { - return &pb.Version{Major: Major, Minor: Minor, Patch: Patch} +func VersionProto() *wirepb.Version { + return &wirepb.Version{Major: Major, Minor: Minor, Patch: Patch} } diff --git a/internal/protocol/proto/buf.gen.yaml b/internal/protocol/proto/buf.gen.yaml new file mode 100644 index 0000000..8e9a8e9 --- /dev/null +++ b/internal/protocol/proto/buf.gen.yaml @@ -0,0 +1,11 @@ +# Copyright (c) 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +version: v2 +plugins: + - local: protoc-gen-go + out: ../wirepb + opt: paths=source_relative + - local: protoc-gen-connect-go + out: ../wirepb + opt: paths=source_relative diff --git a/internal/protocol/proto/wire.proto b/internal/protocol/proto/wire.proto new file mode 100644 index 0000000..73668e7 --- /dev/null +++ b/internal/protocol/proto/wire.proto @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +syntax = "proto3"; + +package mirum.wire; + +option go_package = "dimidiumlabs/mirum/internal/protocol/wirepb"; + +// Describes the contract between the worker and the server. +// GRPC is the only contract between them, so to implement your own worker, +// you only need to implement this service. +// +// Authentication is handled via mTLS: the worker presents a self-signed +// X.509 certificate containing its ed25519 public key. The server verifies +// the key against its database during the TLS handshake. +// +// Worker metadata (name, version, os, arch) and clock skew detection +// are embedded in the certificate (URI SAN and NotBefore). +service Worker { + // When a worker has free resources, it requests a task from the server. + // The call will block if the server currently has no tasks. + rpc Poll(PollRequest) returns (Task); + + // When the task is completed, the worker reports the result to the server. + rpc Complete(TaskResult) returns (CompleteResponse); +} + +message Version { + uint32 major = 1; + uint32 minor = 2; + uint32 patch = 3; +} + +enum Os { + OS_UNSPECIFIED = 0; + + // Major + OS_LINUX = 1; + OS_DARWIN = 2; + OS_WINDOWS = 3; + + // BSD family + OS_FREEBSD = 4; + OS_OPENBSD = 5; + OS_NETBSD = 6; + OS_DRAGONFLY = 7; + + // Unix / POSIX + OS_ILLUMOS = 8; + OS_SOLARIS = 9; + OS_AIX = 10; + OS_HURD = 11; + + // Research / exotic + OS_PLAN9 = 12; + OS_HAIKU = 13; + OS_REDOX = 14; + OS_FUCHSIA = 15; + OS_SERENITY = 16; + + // Embedded / real-time + OS_ZEPHYR = 17; + OS_NUTTX = 18; + + // Go-supported + OS_ANDROID = 19; + OS_IOS = 20; + OS_JS = 21; + OS_WASIP1 = 22; +} + +enum Arch { + ARCH_UNSPECIFIED = 0; + + // Common + ARCH_386 = 1; + ARCH_ARM = 2; + ARCH_AMD64 = 3; + ARCH_ARM64 = 4; + + // RISC-V + ARCH_RISCV64 = 5; + ARCH_RISCV32 = 6; + + // Power + ARCH_PPC = 7; + ARCH_PPC64 = 8; + ARCH_PPC64LE = 9; + + // IBM + ARCH_S390 = 10; + ARCH_S390X = 11; + + // MIPS + ARCH_MIPS64LE = 12; + ARCH_MIPS64 = 13; + ARCH_MIPSLE = 14; + ARCH_MIPS = 15; + + // Chinese + ARCH_LOONG64 = 16; + ARCH_SW64 = 17; + + // SPARC + ARCH_SPARC64 = 18; + ARCH_SPARC = 19; + + // Other + ARCH_ALPHA = 20; + ARCH_HPPA = 21; + ARCH_M68K = 22; + ARCH_SH4 = 23; + ARCH_IA64 = 24; + ARCH_WASM = 25; + ARCH_XTENSA = 26; + ARCH_ARC = 27; + ARCH_CSKY = 28; + ARCH_HEXAGON = 29; + ARCH_MICROBLAZE = 30; + ARCH_NIOS2 = 31; + ARCH_OPENRISC = 32; +} + +message PollRequest {} + +message Task { + string id = 1; + string clone_url = 2; + string branch = 3; + string sha = 4; + string repo_full_name = 5; +} + +message TaskResult { + string task_id = 1; + bool success = 2; + string error = 3; +} + +message CompleteResponse {} diff --git a/nfpm.yaml b/nfpm.yaml index f19f0a3..62181e7 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -18,55 +18,55 @@ contents: - src: ./README.md dst: /usr/share/doc/mirum/README.md - - src: build/mirumd - dst: /usr/local/bin/mirumd + - src: build/mirum-server + dst: /usr/local/bin/mirum-server file_info: mode: 0755 - - src: build/mirumw - dst: /usr/local/bin/mirumw + - src: build/mirum-worker + dst: /usr/local/bin/mirum-worker file_info: mode: 0755 - - src: pkg/mirumd.yaml - dst: /etc/mirum/mirumd.yaml + - src: pkg/server/config.yaml + dst: /etc/mirum/server/config.yaml type: config|noreplace file_info: mode: 0640 owner: root - group: mirumd + group: mirum-server - - src: pkg/mirumw-default.yaml - dst: /etc/mirum/mirumw-default.yaml + - src: pkg/worker/default.yaml + dst: /etc/mirum/worker/default.yaml type: config|noreplace file_info: mode: 0640 owner: root - group: mirumw + group: mirum-worker - - src: pkg/mirumd.service - dst: /usr/lib/systemd/system/mirumd.service + - src: pkg/mirum-server.service + dst: /usr/lib/systemd/system/mirum-server.service file_info: mode: 0644 - - src: pkg/mirumw@.service - dst: /usr/lib/systemd/system/mirumw@.service + - src: pkg/mirum-worker@.service + dst: /usr/lib/systemd/system/mirum-worker@.service file_info: mode: 0644 - - dst: /var/lib/mirumd + - dst: /var/lib/mirum-server type: dir file_info: mode: 0750 - owner: mirumd - group: mirumd + owner: mirum-server + group: mirum-server - - dst: /var/lib/mirumw + - dst: /var/lib/mirum-worker type: dir file_info: mode: 0750 - owner: mirumw - group: mirumw + owner: mirum-worker + group: mirum-worker scripts: diff --git a/pkg/mirum-server.service b/pkg/mirum-server.service new file mode 100644 index 0000000..69e440f --- /dev/null +++ b/pkg/mirum-server.service @@ -0,0 +1,64 @@ +# Copyright (c) 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +[Unit] +Description=Mirum daemon (modern CI platform) +Requires=network-online.target +After=time-sync.target network-online.target remote-fs.target nss-lookup.target postgresql.service +Wants=time-sync.target + +# Socket activation (optional): +# Create mirum-server.socket with named file descriptors "web" and "grpc": +# +# [Socket] +# ListenStream=0.0.0.0:3000 +# FileDescriptorName=web +# +# [Socket] +# ListenStream=0.0.0.0:2026 +# FileDescriptorName=grpc +# +# Without socket activation the daemon binds www_addr and grpc_addr from config. + +[Service] +Type=notify +User=mirum-server +Group=mirum-server +Restart=always +RestartSec=30 +WatchdogSec=30 +NotifyAccess=main +ExecPaths=/usr/local/bin/mirum-server /usr/lib +ExecStart=/usr/local/bin/mirum-server daemon --config=/etc/mirum/server/config.yaml +LimitCORE=infinity +LimitNOFILE=500000 +AmbientCapabilities=CAP_NET_BIND_SERVICE + +# %p is resolved to the systemd unit name +LogsDirectory=%p +StateDirectory=%p +CacheDirectory=%p +RuntimeDirectory=%p + +UMask=0077 +LockPersonality=yes +NoNewPrivileges=yes +PrivateDevices=yes +PrivateTmp=true +ProcSubset=pid +ProtectClock=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectProc=invisible +ProtectSystem=strict +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=yes +RestrictSUIDSGID=yes + +[Install] +# service should not start from the rescue shell (rescue.target). +WantedBy=multi-user.target diff --git a/pkg/mirum-worker@.service b/pkg/mirum-worker@.service new file mode 100644 index 0000000..dbbc5e0 --- /dev/null +++ b/pkg/mirum-worker@.service @@ -0,0 +1,48 @@ +# Copyright (c) 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +[Unit] +Description=Mirum worker %i (modern CI platform) +Requires=network-online.target +After=time-sync.target network-online.target remote-fs.target nss-lookup.target +Wants=time-sync.target + +[Service] +Type=notify +User=mirum-worker +Group=mirum-worker +Restart=always +RestartSec=30 +WatchdogSec=30 +NotifyAccess=main +ExecPaths=/usr/local/bin/mirum-worker +ExecStart=/usr/local/bin/mirum-worker --config=/etc/mirum/worker/%i.yaml +LimitCORE=infinity +LimitNOFILE=500000 +AmbientCapabilities= + +# %p is resolved to the systemd unit name +LogsDirectory=%p +StateDirectory=%p +CacheDirectory=%p +RuntimeDirectory=%p + +UMask=0077 +LockPersonality=yes +NoNewPrivileges=yes +PrivateTmp=true +ProtectClock=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=yes +RestrictSUIDSGID=yes + +[Install] +# service should not start from the rescue shell (rescue.target). +WantedBy=multi-user.target diff --git a/pkg/mirumd.service b/pkg/mirumd.service deleted file mode 100644 index ab46526..0000000 --- a/pkg/mirumd.service +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (c) 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -[Unit] -Description=Mirum daemon (modern CI platform) -Requires=network-online.target -After=time-sync.target network-online.target remote-fs.target nss-lookup.target postgresql.service -Wants=time-sync.target - -# Socket activation (optional): -# Create mirumd.socket with named file descriptors "web" and "grpc": -# -# [Socket] -# ListenStream=0.0.0.0:3000 -# FileDescriptorName=web -# -# [Socket] -# ListenStream=0.0.0.0:2026 -# FileDescriptorName=grpc -# -# Without socket activation the daemon binds www_addr and grpc_addr from config. - -[Service] -Type=notify -User=mirumd -Group=mirumd -Restart=always -RestartSec=30 -WatchdogSec=30 -NotifyAccess=main -ExecPaths=/usr/local/bin/mirumd /usr/lib -ExecStart=/usr/local/bin/mirumd daemon --config=/etc/mirum/mirumd.yaml -LimitCORE=infinity -LimitNOFILE=500000 -AmbientCapabilities=CAP_NET_BIND_SERVICE - -# %p is resolved to the systemd unit name -LogsDirectory=%p -StateDirectory=%p -CacheDirectory=%p -RuntimeDirectory=%p - -UMask=0077 -LockPersonality=yes -NoNewPrivileges=yes -PrivateDevices=yes -PrivateTmp=true -ProcSubset=pid -ProtectClock=yes -ProtectControlGroups=yes -ProtectHome=yes -ProtectHostname=yes -ProtectKernelLogs=yes -ProtectKernelModules=yes -ProtectKernelTunables=yes -ProtectProc=invisible -ProtectSystem=strict -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX -RestrictNamespaces=yes -RestrictSUIDSGID=yes - -[Install] -# service should not start from the rescue shell (rescue.target). -WantedBy=multi-user.target diff --git a/pkg/mirumd.yaml b/pkg/mirumd.yaml deleted file mode 100644 index 4828d9b..0000000 --- a/pkg/mirumd.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -# Ignored when the corresponding systemd socket activation fd is present. -# See mirumd.socket for details (FileDescriptorName=grpc / web). -grpc_addr: :2026 -web_addr: :3000 -admin_socket: /run/mirumd/admin.sock -database_uri: "" -webhook_secret: "" -token: "" -pepper: "" - -grpc_tls: - cert: "" - key: "" - -# Optional — omit or leave empty to disable TLS on the web listener. -# web_tls: -# cert: "" -# key: "" - -# CIDR list of trusted reverse proxies for X-Forwarded-For resolution. -# Empty = trust RemoteAddr only (safe default). -trusted_proxies: - - 127.0.0.0/8 - - ::1/128 diff --git a/pkg/mirumw-default.yaml b/pkg/mirumw-default.yaml deleted file mode 100644 index 3fc2076..0000000 --- a/pkg/mirumw-default.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -server: localhost:2026 - -# Ed25519 private key for worker authentication (PEM-encoded PKCS8). -# Generate with: -# openssl genpkey -algorithm Ed25519 -out /etc/mirum/mirumw-default.key -# chmod 600 /etc/mirum/mirumw-default.key -# -# Register the public key on the daemon: -# PUBKEY=$(openssl pkey -in /etc/mirum/mirumw-default.key -pubout -outform der | base64 -w0) -# mirumd --socket /run/mirumd/admin.sock worker add --pubkey "$PUBKEY" -key_file: /etc/mirum/mirumw-default.key - -# Custom CA certificate for self-signed/dev TLS. -# Leave empty to use system trust store. -tls_ca: "" diff --git a/pkg/mirumw@.service b/pkg/mirumw@.service deleted file mode 100644 index cf7ddd5..0000000 --- a/pkg/mirumw@.service +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -[Unit] -Description=Mirum worker %i (modern CI platform) -Requires=network-online.target -After=time-sync.target network-online.target remote-fs.target nss-lookup.target -Wants=time-sync.target - -[Service] -Type=notify -User=mirumw -Group=mirumw -Restart=always -RestartSec=30 -WatchdogSec=30 -NotifyAccess=main -ExecPaths=/usr/local/bin/mirumw -ExecStart=/usr/local/bin/mirumw --config=/etc/mirum/mirumw-%i.yaml -LimitCORE=infinity -LimitNOFILE=500000 -AmbientCapabilities= - -# %p is resolved to the systemd unit name -LogsDirectory=%p -StateDirectory=%p -CacheDirectory=%p -RuntimeDirectory=%p - -UMask=0077 -LockPersonality=yes -NoNewPrivileges=yes -PrivateTmp=true -ProtectClock=yes -ProtectControlGroups=yes -ProtectHome=yes -ProtectHostname=yes -ProtectKernelLogs=yes -ProtectKernelModules=yes -ProtectKernelTunables=yes -ProtectSystem=strict -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX -RestrictNamespaces=yes -RestrictSUIDSGID=yes - -[Install] -# service should not start from the rescue shell (rescue.target). -WantedBy=multi-user.target diff --git a/pkg/scripts/postinstall.sh b/pkg/scripts/postinstall.sh index 2c80442..3479c75 100644 --- a/pkg/scripts/postinstall.sh +++ b/pkg/scripts/postinstall.sh @@ -4,10 +4,10 @@ set -e -if [ -x "/bin/systemctl" ] && [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mirumd.service ]; then +if [ -x "/bin/systemctl" ] && [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mirum-server.service ]; then /bin/systemctl daemon-reload # Don't enable by default, don't know in advance whether it's a daemon or a worker - # /bin/systemctl enable mirumd - # /bin/systemctl enable mirumw + # /bin/systemctl enable mirum-server + # /bin/systemctl enable mirum-worker fi diff --git a/pkg/scripts/preinstall.sh b/pkg/scripts/preinstall.sh index d15fee2..15f9f40 100644 --- a/pkg/scripts/preinstall.sh +++ b/pkg/scripts/preinstall.sh @@ -4,7 +4,7 @@ set -e -for svc in mirumd mirumw; do +for svc in mirum-server mirum-worker; do if ! getent group $svc >/dev/null; then groupadd --system $svc fi diff --git a/pkg/scripts/preremove.sh b/pkg/scripts/preremove.sh index fdb71a8..1c92a81 100644 --- a/pkg/scripts/preremove.sh +++ b/pkg/scripts/preremove.sh @@ -5,9 +5,9 @@ set -e if [ -x "/bin/systemctl" ] && [ -d /run/systemd/system ]; then - /bin/systemctl stop mirumd.service || true - /bin/systemctl disable mirumd.service || true + /bin/systemctl stop mirum-server.service || true + /bin/systemctl disable mirum-server.service || true - /bin/systemctl stop 'mirumw@*' || true - /bin/systemctl disable mirumw@.service || true + /bin/systemctl stop 'mirum-worker@*' || true + /bin/systemctl disable mirum-worker@.service || true fi diff --git a/pkg/server/config.yaml b/pkg/server/config.yaml new file mode 100644 index 0000000..6ea9881 --- /dev/null +++ b/pkg/server/config.yaml @@ -0,0 +1,27 @@ +# Copyright (c) 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Ignored when the corresponding systemd socket activation fd is present. +# See mirum-server.socket for details (FileDescriptorName=grpc / web). +grpc_addr: :2026 +web_addr: :3000 +admin_socket: /run/mirum-server/admin.sock +database_uri: "" +webhook_secret: "" +token: "" +pepper: "" + +grpc_tls: + cert: "" + key: "" + +# Optional — omit or leave empty to disable TLS on the web listener. +# web_tls: +# cert: "" +# key: "" + +# CIDR list of trusted reverse proxies for X-Forwarded-For resolution. +# Empty = trust RemoteAddr only (safe default). +trusted_proxies: + - 127.0.0.0/8 + - ::1/128 diff --git a/pkg/worker/default.yaml b/pkg/worker/default.yaml new file mode 100644 index 0000000..f982ba1 --- /dev/null +++ b/pkg/worker/default.yaml @@ -0,0 +1,18 @@ +# Copyright (c) 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +server: localhost:2026 + +# Ed25519 private key for worker authentication (PEM-encoded PKCS8). +# Generate with: +# openssl genpkey -algorithm Ed25519 -out /etc/mirum/worker/default.key +# chmod 600 /etc/mirum/worker/default.key +# +# Register the public key on the daemon: +# PUBKEY=$(openssl pkey -in /etc/mirum/worker/default.key -pubout -outform der | base64 -w0) +# mirum-server --socket /run/mirum-server/admin.sock worker create --pubkey "$PUBKEY" +key_file: /etc/mirum/worker/default.key + +# Custom CA certificate for self-signed/dev TLS. +# Leave empty to use system trust store. +tls_ca: "" diff --git a/proto/admin.proto b/proto/admin.proto deleted file mode 100644 index 58f4e64..0000000 --- a/proto/admin.proto +++ /dev/null @@ -1,341 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -syntax = "proto3"; - -package mirum; - -import "buf/validate/validate.proto"; -import "google/protobuf/timestamp.proto"; - -option go_package = "dimidiumlabs/mirum/internal/protocol/pb"; - -service Admin { - rpc UserCreate(UserCreateRequest) returns (UserCreateResponse); - rpc UserGet(UserGetRequest) returns (UserGetResponse); - rpc UserList(UserListRequest) returns (UserListResponse); - rpc UserUpdate(UserUpdateRequest) returns (UserUpdateResponse); - rpc UserDelete(UserDeleteRequest) returns (UserDeleteResponse); - - rpc OrgCreate(OrgCreateRequest) returns (OrgCreateResponse); - rpc OrgGet(OrgGetRequest) returns (OrgGetResponse); - rpc OrgList(OrgListRequest) returns (OrgListResponse); - rpc OrgUpdate(OrgUpdateRequest) returns (OrgUpdateResponse); - rpc OrgDelete(OrgDeleteRequest) returns (OrgDeleteResponse); - - rpc OrgMemberAdd(OrgMemberAddRequest) returns (OrgMemberAddResponse); - rpc OrgMemberGet(OrgMemberGetRequest) returns (OrgMemberGetResponse); - rpc OrgMemberList(OrgMemberListRequest) returns (OrgMemberListResponse); - rpc OrgMemberUpdate(OrgMemberUpdateRequest) returns (OrgMemberUpdateResponse); - rpc OrgMemberRemove(OrgMemberRemoveRequest) returns (OrgMemberRemoveResponse); - - rpc WorkerCreate(WorkerCreateRequest) returns (WorkerCreateResponse); - rpc WorkerGet(WorkerGetRequest) returns (WorkerGetResponse); - rpc WorkerList(WorkerListRequest) returns (WorkerListResponse); - rpc WorkerDelete(WorkerDeleteRequest) returns (WorkerDeleteResponse); -} - -// Common types - -enum Role { - ROLE_NONE = 0; - ROLE_OWNER = 1; - ROLE_ADMIN = 2; - ROLE_MEMBER = 3; -} - -enum Perm { - PERM_NONE = 0; - - PERM_USER_READ = 1; - PERM_USER_WRITE = 2; - PERM_USER_DELETE = 3; - - PERM_ORG_READ = 4; - PERM_ORG_WRITE = 5; - PERM_ORG_DELETE = 6; - PERM_ORG_MEMBER_READ = 7; - PERM_ORG_MEMBER_WRITE = 8; - - PERM_WORKER_READ = 9; - PERM_WORKER_WRITE = 10; -} - -// ErrorInfo is attached as a ConnectError detail on every error. -// The ConnectError message field is always empty — the server never sends -// human-readable text. Clients switch on reason to select user-facing copy. -message ErrorInfo { - ErrorReason reason = 1; - - // Short stable identifiers for UI targeting (e.g. {"field": "slug"}). - // Never human-readable text. - map metadata = 2; -} - -enum ErrorReason { - ERROR_REASON_UNSPECIFIED = 0; - ERROR_REASON_INTERNAL = 1; - - // Lookup failures - ERROR_REASON_USER_NOT_FOUND = 10; - ERROR_REASON_ORG_NOT_FOUND = 11; - ERROR_REASON_WORKER_NOT_FOUND = 12; - ERROR_REASON_MEMBER_NOT_FOUND = 13; - - // Conflicts - ERROR_REASON_EMAIL_TAKEN = 20; - ERROR_REASON_SLUG_TAKEN = 21; - ERROR_REASON_ALREADY_MEMBER = 22; - - // State preconditions - ERROR_REASON_LAST_OWNER = 30; - ERROR_REASON_SOLE_OWNER = 31; - - // Validation - ERROR_REASON_INVALID_SLUG = 40; - ERROR_REASON_INVALID_ROLE = 41; - ERROR_REASON_RESERVED_EMAIL = 42; - - // Auth - ERROR_REASON_UNAUTHENTICATED = 50; - ERROR_REASON_PERMISSION_DENIED = 51; - ERROR_REASON_INVALID_CREDENTIALS = 52; - ERROR_REASON_INVALID_CSRF = 53; - - // Infrastructure - ERROR_REASON_RATE_LIMITED = 60; - ERROR_REASON_UNAVAILABLE = 61; - ERROR_REASON_UNIMPLEMENTED = 62; -} - -message UserRef { - oneof ref { - option (buf.validate.oneof).required = true; - bytes id = 1 [(buf.validate.field).bytes = { - min_len: 16 - max_len: 16 - }]; - string email = 2 [(buf.validate.field).string.email = true]; - } -} - -message OrgRef { - oneof ref { - option (buf.validate.oneof).required = true; - bytes id = 1 [(buf.validate.field).bytes = { - min_len: 16 - max_len: 16 - }]; - string slug = 2 [(buf.validate.field).string = { - min_len: 2 - max_len: 64 - }]; - } -} - -message PageRequest { - bytes cursor = 1; - int32 page_size = 2; -} -message PageResponse { - bytes next_cursor = 1; - int32 total_count = 2; -} - -// User management - -message User { - bytes id = 1; - string email = 2; - google.protobuf.Timestamp created_at = 3; -} - -message UserCreateRequest { - string email = 1 [(buf.validate.field).string.email = true]; - string password = 2 [(buf.validate.field).string.min_len = 1]; -} -message UserCreateResponse { - bytes id = 1; -} - -message UserGetRequest { - UserRef user = 1 [(buf.validate.field).required = true]; -} -message UserGetResponse { - User user = 1; -} - -message UserListRequest { - optional PageRequest page = 1; - optional string filter = 2; -} -message UserListResponse { - PageResponse page = 1; - repeated User users = 2; -} - -message UserUpdateRequest { - UserRef user = 1 [(buf.validate.field).required = true]; - optional string email = 2 [(buf.validate.field).string.email = true]; - optional string password = 3 [(buf.validate.field).string.min_len = 1]; -} -message UserUpdateResponse {} - -message UserDeleteRequest { - UserRef user = 1 [(buf.validate.field).required = true]; -} -message UserDeleteResponse {} - -// Organization management - -message Org { - bytes id = 1; - string name = 2; - string slug = 3; - bool public = 4; - google.protobuf.Timestamp created_at = 5; -} - -message OrgCreateRequest { - string name = 1 [(buf.validate.field).string.min_len = 1]; - string slug = 2 [(buf.validate.field).string = { - min_len: 2 - max_len: 64 - }]; - UserRef owner = 3 [(buf.validate.field).required = true]; - bool public = 4; -} -message OrgCreateResponse { - bytes id = 1; -} - -message OrgGetRequest { - OrgRef org = 1 [(buf.validate.field).required = true]; -} -message OrgGetResponse { - Org org = 1; -} - -message OrgListRequest { - optional PageRequest page = 1; - optional string filter = 2; -} -message OrgListResponse { - PageResponse page = 1; - repeated Org organizations = 2; -} - -message OrgUpdateRequest { - OrgRef org = 1 [(buf.validate.field).required = true]; - optional string name = 2 [(buf.validate.field).string.min_len = 1]; - optional string slug = 3 [(buf.validate.field).string = { - min_len: 2 - max_len: 64 - }]; - optional bool public = 4; -} -message OrgUpdateResponse {} - -message OrgDeleteRequest { - OrgRef org = 1 [(buf.validate.field).required = true]; -} -message OrgDeleteResponse {} - -// Organization members - -message OrgMemberInfo { - User user = 1; - Role role = 2; - google.protobuf.Timestamp joined_at = 3; -} - -message OrgMemberGetRequest { - OrgRef org = 1 [(buf.validate.field).required = true]; - UserRef user = 2 [(buf.validate.field).required = true]; -} -message OrgMemberGetResponse { - OrgMemberInfo member = 1; -} - -message OrgMemberAddRequest { - OrgRef org = 1 [(buf.validate.field).required = true]; - UserRef user = 2 [(buf.validate.field).required = true]; - Role role = 3 [(buf.validate.field).enum = { - defined_only: true - not_in: [0] - }]; -} -message OrgMemberAddResponse {} - -message OrgMemberListRequest { - optional PageRequest page = 1; - OrgRef org = 2 [(buf.validate.field).required = true]; - optional string filter = 3; -} -message OrgMemberListResponse { - PageResponse page = 1; - repeated OrgMemberInfo members = 2; -} - -message OrgMemberUpdateRequest { - OrgRef org = 1 [(buf.validate.field).required = true]; - UserRef user = 2 [(buf.validate.field).required = true]; - Role role = 3 [(buf.validate.field).enum = { - defined_only: true - not_in: [0] - }]; -} -message OrgMemberUpdateResponse {} - -message OrgMemberRemoveRequest { - OrgRef org = 1 [(buf.validate.field).required = true]; - UserRef user = 2 [(buf.validate.field).required = true]; -} -message OrgMemberRemoveResponse {} - -// Worker management - -message Worker { - bytes id = 1; - bytes public_key = 2; - optional bytes org_id = 3; - google.protobuf.Timestamp created_at = 4; -} - -message WorkerCreateRequest { - bytes public_key = 1 [(buf.validate.field).bytes = { - min_len: 32 - max_len: 32 - }]; - optional OrgRef org = 2; -} -message WorkerCreateResponse { - bytes id = 1; -} - -message WorkerGetRequest { - bytes id = 1 [(buf.validate.field).bytes = { - min_len: 16 - max_len: 16 - }]; -} -message WorkerGetResponse { - Worker worker = 1; -} - -message WorkerListRequest { - optional PageRequest page = 1; - optional string filter = 2; -} -message WorkerListResponse { - PageResponse page = 1; - repeated Worker workers = 2; -} - -message WorkerDeleteRequest { - bytes id = 1 [(buf.validate.field).bytes = { - min_len: 16 - max_len: 16 - }]; -} -message WorkerDeleteResponse {} diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml deleted file mode 100644 index 0f24cdc..0000000 --- a/proto/buf.gen.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright (c) 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -version: v2 -plugins: - - local: protoc-gen-go - out: ../internal/protocol/pb - opt: paths=source_relative - - local: protoc-gen-connect-go - out: ../internal/protocol/pb - opt: paths=source_relative - - remote: buf.build/bufbuild/es:v2.11.0 - out: ../cmd/mirumd/web/gen - opt: - - target=ts - - import_extension=js diff --git a/proto/buf.lock b/proto/buf.lock deleted file mode 100644 index d15a117..0000000 --- a/proto/buf.lock +++ /dev/null @@ -1,6 +0,0 @@ -# Generated by buf. DO NOT EDIT. -version: v2 -deps: - - name: buf.build/bufbuild/protovalidate - commit: 80ab13bee0bf4272b6161a72bf7034e0 - digest: b5:1aa6a965be5d02d64e1d81954fa2e78ef9d1e33a0c30f92bc2626039006a94deb3a5b05f14ed8893f5c3ffce444ac008f7e968188ad225c4c29c813aa5f2daa1 diff --git a/proto/buf.yaml b/proto/buf.yaml deleted file mode 100644 index 8d594f7..0000000 --- a/proto/buf.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -version: v2 -deps: - - buf.build/bufbuild/protovalidate diff --git a/proto/mirum.proto b/proto/mirum.proto deleted file mode 100644 index dd85e26..0000000 --- a/proto/mirum.proto +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (c) 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -syntax = "proto3"; - -package mirum; - -option go_package = "dimidiumlabs/mirum/internal/protocol/pb"; - -// Describes the contract between the worker and the server. -// GRPC is the only contract between them, so to implement your own worker, -// you only need to implement this service. -// -// Authentication is handled via mTLS: the worker presents a self-signed -// X.509 certificate containing its ed25519 public key. The server verifies -// the key against its database during the TLS handshake. -// -// Worker metadata (name, version, os, arch) and clock skew detection -// are embedded in the certificate (URI SAN and NotBefore). -service Mirum { - // When a worker has free resources, it requests a task from the server. - // The call will block if the server currently has no tasks. - rpc Poll(PollRequest) returns (Task); - - // When the task is completed, the worker reports the result to the server. - rpc Complete(TaskResult) returns (CompleteResponse); -} - -message Version { - uint32 major = 1; - uint32 minor = 2; - uint32 patch = 3; -} - -enum Os { - OS_UNSPECIFIED = 0; - - // Major - OS_LINUX = 1; - OS_DARWIN = 2; - OS_WINDOWS = 3; - - // BSD family - OS_FREEBSD = 4; - OS_OPENBSD = 5; - OS_NETBSD = 6; - OS_DRAGONFLY = 7; - - // Unix / POSIX - OS_ILLUMOS = 8; - OS_SOLARIS = 9; - OS_AIX = 10; - OS_HURD = 11; - - // Research / exotic - OS_PLAN9 = 12; - OS_HAIKU = 13; - OS_REDOX = 14; - OS_FUCHSIA = 15; - OS_SERENITY = 16; - - // Embedded / real-time - OS_ZEPHYR = 17; - OS_NUTTX = 18; - - // Go-supported - OS_ANDROID = 19; - OS_IOS = 20; - OS_JS = 21; - OS_WASIP1 = 22; -} - -enum Arch { - ARCH_UNSPECIFIED = 0; - - // Common - ARCH_386 = 1; - ARCH_ARM = 2; - ARCH_AMD64 = 3; - ARCH_ARM64 = 4; - - // RISC-V - ARCH_RISCV64 = 5; - ARCH_RISCV32 = 6; - - // Power - ARCH_PPC = 7; - ARCH_PPC64 = 8; - ARCH_PPC64LE = 9; - - // IBM - ARCH_S390 = 10; - ARCH_S390X = 11; - - // MIPS - ARCH_MIPS64LE = 12; - ARCH_MIPS64 = 13; - ARCH_MIPSLE = 14; - ARCH_MIPS = 15; - - // Chinese - ARCH_LOONG64 = 16; - ARCH_SW64 = 17; - - // SPARC - ARCH_SPARC64 = 18; - ARCH_SPARC = 19; - - // Other - ARCH_ALPHA = 20; - ARCH_HPPA = 21; - ARCH_M68K = 22; - ARCH_SH4 = 23; - ARCH_IA64 = 24; - ARCH_WASM = 25; - ARCH_XTENSA = 26; - ARCH_ARC = 27; - ARCH_CSKY = 28; - ARCH_HEXAGON = 29; - ARCH_MICROBLAZE = 30; - ARCH_NIOS2 = 31; - ARCH_OPENRISC = 32; -} - -message PollRequest {} - -message Task { - string id = 1; - string clone_url = 2; - string branch = 3; - string sha = 4; - string repo_full_name = 5; -} - -message TaskResult { - string task_id = 1; - bool success = 2; - string error = 3; -} - -message CompleteResponse {} -- Gilti