refactor(admin): extract admin logic to portal/admin

- Move admin HTTP handlers & state from cmd/relay-server/admin.go - Introduce portaladmin.Service & portaladmin.Handler - cmd/relay-server/admin.go now thin adapter - Remove admin_settings.json persistence from Admin - Consolidate frontend path to frontend/ - Update Dockerfile & Makefile references

cognitive committed Mar 5, 2026 at 02:41 UTC 37c850a2bc5e46eedab4dd3353c7934b1aaa36ad
1 file changed +17 -29
AGENTS.md
+17 -29
@@ -21,6 +21,9 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
21 4. **Keep explicit root-domain fallback behavior through SNI no-route handling to admin/API listener.**
22 - Why: preserves intended control-plane vs tenant routing split (ADR-0001).
23
24 +5. **All leases require TLS=true.** The register endpoint rejects `TLS=false`. The `RegisterRequest.TLS` field exists but non-TLS leases are not permitted.
25 + - Why: enforces end-to-end transport security for all tenant routes.
26 +
27 ## Security and Anti-Abuse Invariants
28
29 1. **Admin-managed policy is authoritative for runtime security controls.**
@@ -37,16 +40,17 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
40
41 ## TLS and Identity Invariants
42
40 -1. **Relay holds the TLS private key; SDK/tunnel never does.** SDK calls `/v1/sign` on the relay via `RemoteSigner` for all private key operations.
41 - - Why: prevents key material leakage to untrusted tunnel endpoints.
43 +1. **Relay holds the TLS private key for admin/API (root domain) only.** SDK calls `/v1/sign` on the relay via `RemoteSigner` for admin/API TLS termination. For SNI-passthrough routes, the relay peeks the ClientHello for SNI then bridges the raw encrypted connection — the backend/tunnel endpoint terminates TLS and holds those keys, not the relay.
44 + - Why: admin/API key material stays on the relay; tenant TLS passthrough avoids key distribution to the relay entirely.
45
46 2. **mTLS is implicit (optional) for `/sdk/*` control-plane paths.** When a client cert is presented, the relay validates it (CertBind stage). When absent, CertBind is skipped and token auth alone is used.
47 - `KEYLESS_DIR` env var presence triggers SDK lifecycle identity issuance and client cert presentation. When unset, the SDK operates in token-only mode.
45 - - Keyless TLS (`RemoteSigner` for `/v1/sign`) is independent of mTLS — always used for TLS termination regardless of client cert presence.
48 + - Keyless TLS (`RemoteSigner` for `/v1/sign`) is independent of mTLS — always used for admin/API TLS termination regardless of client cert presence.
49 - Why: ADR-0003 admission order is IP ban → Lease → [CertBind if cert present] → Token. Invalid certs are still rejected; absent certs skip CertBind.
50
51 3. **All relay URLs must be `https://`.** `NormalizeRelayAPIURL` rejects non-HTTPS. SDK and tunnel hard-fail on `http://`.
52 - Why: enforces transport security without opt-out.
53 +
54 4. **`keyless_tls/` is published to `github.com/gosuda/keyless_tls`** and vendored as a directory for co-development. Root `go.mod` pins a specific pseudo-version or tag — no `replace` directive.
55 - Why: enables external consumers while keeping co-development convenient. Pin version after pushing upstream changes.
56
@@ -55,38 +59,28 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
59 1. **SNI wildcard matching is one-level only.** `sni.Router.GetRoute()` checks `*.parent.example.com` for `foo.parent.example.com` — not arbitrary depth.
60 - Why: matches RFC TLS wildcard semantics.
61
58 -2. **Protocol markers on reverse TCP connections:** `0x00` = keepalive, `0x02` = TLS passthrough activation.
62 +2. **Protocol markers on reverse TCP connections:** `0x00` = keepalive, `0x01` = non-TLS start, `0x02` = TLS passthrough activation. The SNI router does true TLS passthrough — it peeks the ClientHello for routing, then bridges the raw encrypted connection without terminating TLS.
63 - Why: binary protocol, not discoverable from HTTP-layer code.
64
65 3. **HTTP/2 is intentionally disabled on the admin HTTP server** (`TLSNextProto: make(…)`).
66 - Why: the server hijacks connections for `/sdk/connect`; HTTP/2 multiplexing breaks hijack semantics.
67
64 -## Operational Truths (CI-Aligned, Minimal)
65 -
66 -1. **Local lint workflow: run `make lint-auto` first, then `make lint`.**
67 - - Why: `lint-auto` applies safe rewrites locally, while `lint` is the strict non-mutating gate that matches CI.
68 +## API Response Contract
69
69 -2. **Use CI-equivalent verification when validating high-risk changes:**
70 - - `make vet`
71 - - `make lint`
72 - - `make test`
73 - - `make vuln`
74 - - Why: these are the enforced checks in `.github/workflows/ci.yml`.
75 - - Note: `make tidy` is a local maintenance/pre-release step and is not currently part of the CI workflow.
70 +1. **All HTTP responses use the `APIEnvelope` wrapper:** `{ ok: bool, data?: any, error?: { code, message } }` (defined in `types/api.go`). Write responses through `writeAPIData()`, `writeAPIOK()`, or `writeAPIError()` helpers — never raw JSON.
71 + - Why: cross-cutting contract across all endpoints; inconsistent envelopes break SDK and frontend parsing.
72
77 -3. **Assume Go toolchain baseline from `go.mod`.**
78 - - Why: CI resolves Go from `go.mod`; avoid stale version assumptions.
73 +## Operational Truths (CI-Aligned, Minimal)
74
80 -4. **Use `Makefile` as build and verification authority; do not reference absent tooling (for example, no `justfile` in this repo).**
81 - - Why: reduces operational drift and broken command guidance.
75 +1. **CI verification commands:** `make vet`, `make lint`, `make test`, `make vuln`. These are the enforced checks in `.github/workflows/ci.yml`. Note: `make tidy` is a local maintenance/pre-release step, not part of CI.
76
83 -5. **`make build-server` does NOT call `make build-frontend`.** If called alone, `//go:embed dist/*` will be stale or empty. The Dockerfile calls both explicitly in order.
77 +2. **`make build-server` does NOT call `make build-frontend`.** If called alone, `//go:embed dist/*` will be stale or empty. The Dockerfile calls both explicitly in order.
78 - Why: prevents silent broken builds with missing frontend assets.
79
86 -6. **`admin_settings.json` persists in the process CWD**, not in `KEYLESS_DIR`. State is lost on container restart unless CWD is a mounted volume.
80 +3. **`admin_settings.json` persists in the process CWD**, not in `KEYLESS_DIR`. State is lost on container restart unless CWD is a mounted volume.
81 - Why: prevents state-loss surprises in production.
82
89 -7. **`onLeaseDeleted` has dual registration.** `portal/relay.go` registers one callback; `cmd/relay-server/main.go` overwrites it with a broader one (adds IP/BPS cleanup). The outer callback supersedes.
83 +4. **`onLeaseDeleted` has dual registration.** `portal/relay.go` registers one callback; `cmd/relay-server/main.go` overwrites it with a broader one (adds IP/BPS cleanup). The outer callback supersedes.
84 - Why: coupling hazard — modifying either registration without understanding both breaks cleanup.
85
86 ## Change Discipline
@@ -99,13 +93,7 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
93
94 ## Go Conventions
95
102 -**Format:** `gofmt -w . && goimports -w .` before every commit. Imports: stdlib → external → internal (blank-line separated), local prefix `github.com/gosuda`.
103 -
104 -**CGo:** always disabled — `CGO_ENABLED=0`. Pure Go only.
105 -
106 -**Module:** commit `go.mod`+`go.sum`, never `go.work` · pin toolchain in `go.mod` · `go mod tidy && go mod verify && govulncheck ./...` pre-release · `os.Root` (Go 1.24+) for directory-scoped I/O.
107 -
108 -**Concurrency:** `errgroup.Group` over `WaitGroup` · `errgroup.SetLimit` for bounded work · `context.WithTimeout` over `time.After` in loops (timer leak) · no bare `go func()` — creator owns lifecycle.
96 +**Imports:** stdlib → external → internal (blank-line separated), local prefix `github.com/gosuda`. **Concurrency:** `errgroup.Group` over `WaitGroup` · `errgroup.SetLimit` for bounded work · `context.WithTimeout` over `time.After` in loops (timer leak) · no bare `go func()` — creator owns lifecycle. **I/O:** `os.Root` (Go 1.24+) for directory-scoped file operations.
97
98 ---
99