Server keys in etc/, bind in docker compose
CI / test (push) Successful in 5s

- bin/gen-server-keys.sh: generate Ed25519 keypair to etc/server-service.{pub,key,env}
- main.go: read keys from file (ADMIN_PUBLIC_KEY_FILE) when env empty
- docker-compose: env_file etc/server-service.env, mount etc/
- bin/up.sh: auto-run gen-server-keys if etc/server-service.env missing
- ErrRegistrationNotConfigured for clearer 503 when keys not set
- etc/README.md, etc/.gitignore
- bin/gen-admin-key.sh for one-off key gen
- .env.example

Made-with: Cursor
This commit is contained in:
2026-03-01 13:02:40 +00:00
parent a5a97a0ad9
commit 18328706bd
14 changed files with 129 additions and 16 deletions
+6
View File
@@ -0,0 +1,6 @@
# Required for registration-by-signature and bootstrap admin.
# Generate with: ./bin/gen-admin-key.sh
ADMIN_PUBLIC_KEY=
# Optional: override service key for registration (defaults to ADMIN_PUBLIC_KEY)
# SERVICE_PUBLIC_KEY=
+3 -1
View File
@@ -13,6 +13,7 @@ This file gives future coding agents a fast path map for this repository.
- TypeScript API client: `libs/geo-api-client/` - TypeScript API client: `libs/geo-api-client/`
- CI workflow: `.gitea/workflows/ci.yml` - CI workflow: `.gitea/workflows/ci.yml`
- Architecture/planning docs: `docs/` - Architecture/planning docs: `docs/`
- Server keys: `etc/` (generated by `./bin/gen-server-keys.sh`)
## Most common commands ## Most common commands
@@ -21,7 +22,8 @@ From repo root:
```bash ```bash
go test ./... go test ./...
go run ./cmd/api go run ./cmd/api
docker compose up --build -d ./bin/gen-server-keys.sh # before first docker up (creates etc/server-service.*)
./bin/up.sh # or: docker compose up --build -d
docker compose down docker compose down
docker compose --profile test run --rm test # run tests as root (avoids var/ permission issues) docker compose --profile test run --rm test # run tests as root (avoids var/ permission issues)
``` ```
+7 -2
View File
@@ -33,17 +33,22 @@ Local default (for development): `http://localhost:8122`.
Optional environment variables: Optional environment variables:
- `ADDR` (default `:8122`) - `ADDR` (default `:8122`)
- `ADMIN_PUBLIC_KEY` (bootstrap initial inviter/admin user) - `ADMIN_PUBLIC_KEY` **required for registration**: bootstrap admin + service key for `register-by-signature`. Generate with `./bin/gen-admin-key.sh`
- `SERVICE_PUBLIC_KEY` (public key users sign to register; defaults to `ADMIN_PUBLIC_KEY`) - `SERVICE_PUBLIC_KEY` (public key users sign to register; defaults to `ADMIN_PUBLIC_KEY`)
**Deployment:** Set `ADMIN_PUBLIC_KEY` before starting. Without it, `/v1/service-key` returns 503 and registration is disabled.
## Docker Compose ## Docker Compose
Build and run the backend service: Generate server keys (creates `etc/server-service.*`), then build and run:
```bash ```bash
./bin/gen-server-keys.sh
COMPOSE_BAKE=true docker compose up --build -d COMPOSE_BAKE=true docker compose up --build -d
``` ```
Or use `./bin/up.sh` which runs the key generation if needed.
This starts: This starts:
- `db` (`postgis/postgis`) on `5432` - `db` (`postgis/postgis`) on `5432`
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# Generate Ed25519 keypair for ADMIN_PUBLIC_KEY (one-off, prints to stdout).
# For etc/ + docker compose, use ./bin/gen-server-keys.sh instead.
set -e
cd "$(dirname "$0")/.."
(cd libs/geo-api-client && bun run build 2>/dev/null) || true
cd libs/geo-api-client
bun -e "
import { generateKeyPair } from './dist/index.js';
const k = await generateKeyPair();
console.log('ADMIN_PUBLIC_KEY=' + k.publicKey);
console.log('# Private key (keep secret, bootstrap only): ' + k.privateKey);
"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Generate server-service Ed25519 keypair. Output in etc/ for docker compose.
# Clients download the public key via GET /v1/service-key.
set -e
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
mkdir -p "$ROOT/etc"
(cd "$ROOT/libs/geo-api-client" && bun run build 2>/dev/null) || true
cd "$ROOT/libs/geo-api-client"
OUT=$(bun -e "
import { generateKeyPair } from './dist/index.js';
const k = await generateKeyPair();
console.log(k.publicKey);
console.log(k.privateKey);
")
PUB=$(echo "$OUT" | head -1)
PRIV=$(echo "$OUT" | tail -1)
echo "$PUB" > "$ROOT/etc/server-service.pub"
echo "$PRIV" > "$ROOT/etc/server-service.key"
echo "ADMIN_PUBLIC_KEY=$PUB" > "$ROOT/etc/server-service.env"
echo "SERVICE_PUBLIC_KEY=$PUB" >> "$ROOT/etc/server-service.env"
chmod 600 "$ROOT/etc/server-service.key" "$ROOT/etc/server-service.env" 2>/dev/null || true
echo "Wrote etc/server-service.pub, etc/server-service.key, etc/server-service.env"
+4
View File
@@ -1,4 +1,8 @@
#!/bin/bash #!/bin/bash
set -e set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
if [ ! -f etc/server-service.env ]; then
echo "Generating server keys (etc/server-service.env)..."
./bin/gen-server-keys.sh
fi
docker compose up --build -d docker compose up --build -d
+19
View File
@@ -4,6 +4,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
"momswap/backend/internal/app" "momswap/backend/internal/app"
@@ -16,6 +17,16 @@ func main() {
adminPublicKey := os.Getenv("ADMIN_PUBLIC_KEY") adminPublicKey := os.Getenv("ADMIN_PUBLIC_KEY")
servicePublicKey := getEnv("SERVICE_PUBLIC_KEY", adminPublicKey) servicePublicKey := getEnv("SERVICE_PUBLIC_KEY", adminPublicKey)
if adminPublicKey == "" {
adminPublicKey = readKeyFile(getEnv("ADMIN_PUBLIC_KEY_FILE", "etc/server-service.pub"))
}
if servicePublicKey == "" {
servicePublicKey = readKeyFile(getEnv("SERVICE_PUBLIC_KEY_FILE", "etc/server-service.pub"))
}
if servicePublicKey == "" {
servicePublicKey = adminPublicKey
}
memory := store.NewMemoryStore() memory := store.NewMemoryStore()
service := app.NewService(memory, app.Config{ service := app.NewService(memory, app.Config{
ChallengeTTL: 5 * time.Minute, ChallengeTTL: 5 * time.Minute,
@@ -37,3 +48,11 @@ func getEnv(key, fallback string) string {
} }
return v return v
} }
func readKeyFile(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
+8
View File
@@ -23,10 +23,14 @@ services:
target: runtime target: runtime
image: momswap-backend:latest image: momswap-backend:latest
container_name: momswap-backend-api container_name: momswap-backend-api
env_file:
- etc/server-service.env
environment: environment:
ADDR: ":8122" ADDR: ":8122"
ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}" ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}"
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable"
volumes:
- ./etc:/app/etc:ro
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -42,10 +46,14 @@ services:
target: dev target: dev
image: momswap-backend:dev image: momswap-backend:dev
container_name: momswap-backend-api-dev container_name: momswap-backend-api-dev
env_file:
- etc/server-service.env
environment: environment:
ADDR: ":8122" ADDR: ":8122"
ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}" ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}"
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable"
volumes:
- ./etc:/src/etc:ro
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
+3 -1
View File
@@ -68,10 +68,12 @@ Key methods:
When `SERVICE_PUBLIC_KEY` (or `ADMIN_PUBLIC_KEY`) is set, users can register without an invitation: When `SERVICE_PUBLIC_KEY` (or `ADMIN_PUBLIC_KEY`) is set, users can register without an invitation:
1. `GET /v1/service-key` — fetch the API public key to sign. 1. `GET /v1/service-key` — fetch the server public key (clients use this for registration and further signed communication).
2. Sign that key with your private key. 2. Sign that key with your private key.
3. `POST /v1/auth/register-by-signature` with `{ publicKey, signature }`. 3. `POST /v1/auth/register-by-signature` with `{ publicKey, signature }`.
Server keys are generated with `./bin/gen-server-keys.sh` and stored in `etc/`.
## Example (TypeScript app) ## Example (TypeScript app)
```ts ```ts
+3
View File
@@ -0,0 +1,3 @@
server-service.pub
server-service.key
server-service.env
+26
View File
@@ -0,0 +1,26 @@
# Server Service Keys
Server Ed25519 keypair for client authentication and registration.
## Generate
```bash
./bin/gen-server-keys.sh
```
Creates:
- `server-service.pub` — public key; clients download via `GET /v1/service-key`
- `server-service.key` — private key (keep secret)
- `server-service.env` — env vars for docker compose (`ADMIN_PUBLIC_KEY`, `SERVICE_PUBLIC_KEY`)
## Client Usage
Clients fetch the server public key and use it to:
1. **Register** — sign the server pubkey, post to `POST /v1/auth/register-by-signature`
2. **Verify server identity** — for future signed responses or request validation
## Docker Compose
The api service uses `env_file: etc/server-service.env` and mounts `./etc` so keys are available. Run `./bin/gen-server-keys.sh` before first `docker compose up`.
+11 -10
View File
@@ -12,15 +12,16 @@ import (
) )
var ( var (
ErrUnauthorized = errors.New("unauthorized") ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden") ErrForbidden = errors.New("forbidden")
ErrBadRequest = errors.New("bad request") ErrBadRequest = errors.New("bad request")
ErrInviteInvalid = errors.New("invite invalid") ErrRegistrationNotConfigured = errors.New("registration by signature not configured; set ADMIN_PUBLIC_KEY")
ErrInviteExpired = errors.New("invite expired") ErrInviteInvalid = errors.New("invite invalid")
ErrInviteExhaust = errors.New("invite exhausted") ErrInviteExpired = errors.New("invite expired")
ErrAlreadyUser = errors.New("user already registered") ErrInviteExhaust = errors.New("invite exhausted")
ErrCollectionMiss = errors.New("collection missing") ErrAlreadyUser = errors.New("user already registered")
ErrFeatureMiss = errors.New("feature missing") ErrCollectionMiss = errors.New("collection missing")
ErrFeatureMiss = errors.New("feature missing")
) )
type Config struct { type Config struct {
@@ -62,7 +63,7 @@ func (s *Service) ServicePublicKey() string {
func (s *Service) RegisterBySignature(publicKey, signature string) error { func (s *Service) RegisterBySignature(publicKey, signature string) error {
if s.servicePublicKey == "" { if s.servicePublicKey == "" {
return fmt.Errorf("%w: registration by signature not configured", ErrBadRequest) return ErrRegistrationNotConfigured
} }
if publicKey == "" { if publicKey == "" {
return fmt.Errorf("%w: missing public key", ErrBadRequest) return fmt.Errorf("%w: missing public key", ErrBadRequest)
+3 -1
View File
@@ -88,6 +88,8 @@ func statusFromErr(err error) int {
return http.StatusBadRequest return http.StatusBadRequest
case errors.Is(err, app.ErrAlreadyUser): case errors.Is(err, app.ErrAlreadyUser):
return http.StatusConflict return http.StatusConflict
case errors.Is(err, app.ErrRegistrationNotConfigured):
return http.StatusServiceUnavailable
case errors.Is(err, app.ErrInviteInvalid), case errors.Is(err, app.ErrInviteInvalid),
errors.Is(err, app.ErrInviteExpired), errors.Is(err, app.ErrInviteExpired),
errors.Is(err, app.ErrInviteExhaust): errors.Is(err, app.ErrInviteExhaust):
@@ -165,7 +167,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
func (a *API) getServiceKey(w http.ResponseWriter, _ *http.Request) { func (a *API) getServiceKey(w http.ResponseWriter, _ *http.Request) {
pk := a.service.ServicePublicKey() pk := a.service.ServicePublicKey()
if pk == "" { if pk == "" {
writeErr(w, app.ErrBadRequest) writeErr(w, app.ErrRegistrationNotConfigured)
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"publicKey": pk}) writeJSON(w, http.StatusOK, map[string]string{"publicKey": pk})
+1 -1
View File
@@ -47,7 +47,7 @@ createApp({
try { try {
await client.registerBySigningServiceKey(state.publicKey, state.privateKey); await client.registerBySigningServiceKey(state.publicKey, state.privateKey);
} catch (err) { } catch (err) {
if (!err.message.includes("already registered") && !err.message.includes("not configured")) { if (!err.message.includes("already registered") && !err.message.includes("not configured") && !err.message.includes("ADMIN_PUBLIC_KEY")) {
throw err; throw err;
} }
// Proceed to login: already registered or registration disabled (invitation flow) // Proceed to login: already registered or registration disabled (invitation flow)