feat(registry): make CertBind implicit in admission pipeline

Skip certificate validation when no client cert is presented (nil TLS state or empty PeerCertificates). Invalid certs are still rejected. Token validation remains unconditional regardless of cert presence. Replaces TestAdmitRejectsNilTLSState with four targeted test cases: - TestAdmit_ValidCert_Passes - TestAdmit_InvalidCert_Rejected - TestAdmit_NoCert_TokenValid_Passes - TestAdmit_NoCert_TokenInvalid_Rejected Also fixes: add keyless_tls replace directive in go.mod, remove duplicate errRegistryBackendUnavailable in registry.go, migrate sdk/client.go from contracts to types package.

cognitive committed Mar 4, 2026 at 20:15 UTC 96dd74558ffcdfd48d326ee166a45845bc7d6569
96 files changed +2733 -943
.golangci.yml
+1 -1
@@ -43,7 +43,7 @@ linters:
43
44 exclusions:
45 paths:
46 - - ^cmd/relay-server/frontend/node_modules/
46 + - ^frontend/node_modules/
47 - ^cmd/relay-server/dist/
48 rules:
49 - linters: [errcheck]
AGENTS.md
+41 -14
@@ -35,6 +35,31 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
35 4. **`/sdk/connect` must share the same policy source as `/sdk/register`.**
36 - Why: registration and reverse admission must apply identical IP-ban + token checks in one enforcement pipeline.
37
38 +## TLS and Identity Invariants
39 +
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.
42 +
43 +2. **mTLS is mandatory for all `/sdk/*` control-plane paths.** No token-only fallback; hard-fail on missing client cert.
44 + - Why: ADR-0003 admission order (IP ban → Lease → CertBind → Token) requires mTLS at the CertBind stage.
45 +
46 +3. **All relay URLs must be `https://`.** `NormalizeRelayAPIURL` rejects non-HTTPS. SDK and tunnel hard-fail on `http://`.
47 + - Why: enforces transport security without opt-out.
48 +
49 +4. **`keyless_tls/` is a local Go sub-module** (`replace` directive in root `go.mod`). Not published separately.
50 + - Why: coupled evolution with the relay; separate `go.mod` for dependency isolation only.
51 +
52 +## SNI Routing Invariants
53 +
54 +1. **SNI wildcard matching is one-level only.** `sni.Router.GetRoute()` checks `*.parent.example.com` for `foo.parent.example.com` — not arbitrary depth.
55 + - Why: matches RFC TLS wildcard semantics.
56 +
57 +2. **Protocol markers on reverse TCP connections:** `0x00` = keepalive, `0x02` = TLS passthrough activation.
58 + - Why: binary protocol, not discoverable from HTTP-layer code.
59 +
60 +3. **HTTP/2 is intentionally disabled on the admin HTTP server** (`TLSNextProto: make(…)`).
61 + - Why: the server hijacks connections for `/sdk/connect`; HTTP/2 multiplexing breaks hijack semantics.
62 +
63 ## Operational Truths (CI-Aligned, Minimal)
64
65 1. **Local lint workflow: run `make lint-auto` first, then `make lint`.**
@@ -54,6 +79,15 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
79 4. **Use `Makefile` as build and verification authority; do not reference absent tooling (for example, no `justfile` in this repo).**
80 - Why: reduces operational drift and broken command guidance.
81
82 +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.
83 + - Why: prevents silent broken builds with missing frontend assets.
84 +
85 +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.
86 + - Why: prevents state-loss surprises in production.
87 +
88 +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.
89 + - Why: coupling hazard — modifying either registration without understanding both breaks cleanup.
90 +
91 ## Change Discipline
92
93 1. If a code change violates any invariant above, update or add ADR and AGENTS in the same change set.
@@ -74,19 +108,12 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
108
109 ---
110
77 -## Verbalized Sampling
78 -
79 -Before trivial or non-trivial changes, AI agents **must**:
80 -
81 -1. **Sample 3–5 intent hypotheses** — rank by likelihood, note one weakness each
82 -2. **Explore edge cases** — at least 3 standard, 5 for architectural changes
83 -3. **Assess coupling** — structural (imports), temporal (co-changing files), semantic (shared concepts)
84 -4. **Tidy first** — high coupling → extract/split/rename before changing; low → change directly
85 -5. **Surface decisions** — ask the human when trade-offs exist; do exactly what is asked, no more
111 +## Agent Behavior
112
87 -## Project-specific rules [**ENFORCED**]
113 +**Verbalized sampling:** Before changes, sample 3–5 intent hypotheses (rank by likelihood, note one weakness each), assess coupling (structural/temporal/semantic), and tidy-first when coupling is high. Ask the human when trade-offs exist.
114
89 -- Do not keep backward compatibility unless explicitly requested.
90 -- Do not add meaningless wrapper functions unless they provide demonstrated value.
91 -- Do not stack minimal patches that fragment logic — complete consolidation in one change.
92 -- Do not run tests on every execution — only when requested, before handoff, or for high-risk changes.
115 +**Project rules:**
116 +- No backward compatibility unless explicitly requested.
117 +- No wrapper functions without demonstrated value.
118 +- Consolidate changes in one pass — do not stack minimal patches.
119 +- Run tests only when requested, before handoff, or for high-risk changes.
Dockerfile
+1 -1
@@ -7,7 +7,7 @@ WORKDIR /src
7 RUN apt-get update && apt-get install -y --no-install-recommends \
8 make && rm -rf /var/lib/apt/lists/*
9
10 -COPY cmd/relay-server/frontend ./cmd/relay-server/frontend
10 +COPY frontend ./
11 COPY Makefile ./
12
13 RUN --mount=type=cache,target=/root/.npm \
Makefile
+2 -1
@@ -38,6 +38,7 @@ vuln:
38 govulncheck ./...
39
40 tidy:
41 + go get -u ./...
42 go mod tidy
43 go mod verify
44
@@ -53,7 +54,7 @@ build: build-frontend build-tunnel build-server
54 build-frontend:
55 @echo "[frontend] building React frontend..."
56 @mkdir -p cmd/relay-server/dist/app
56 - @cd cmd/relay-server/frontend && npm i && npm run build
57 + @cd frontend && npm i && npm run build
58 @echo "[frontend] build complete"
59
60 # Build portal-tunnel binaries for distribution
README.md
+27 -8
@@ -76,6 +76,10 @@ cd portal
76 docker compose up
77 ```
78
79 +Set `PORTAL_URL` to your public domain. If `ADMIN_SECRET_KEY` is not set, one is auto-generated and logged at startup. Set `CLOUDFLARE_TOKEN` to enable automatic ACME DNS-01 certificate provisioning (required only when using Cloudflare for DNS).
80 +
81 +The compose file exposes port 443 (SNI routing) and 4017 (admin/API). The SNI port requires a wildcard DNS record (`*.<base-domain>`) pointing to the relay host.
82 +
83 For deployment to a public domain, see [docs/deployment.md](docs/deployment.md).
84
85 ### Expose Local Service via Tunnel
@@ -89,6 +93,26 @@ For deployment to a public domain, see [docs/deployment.md](docs/deployment.md).
93
94 See [portal-toys](https://github.com/gosuda/portal-toys) for more examples.
95
96 +## Relay Server Configuration
97 +
98 +| Flag | Env Var | Default | Description |
99 +| --- | --- | --- | --- |
100 +| `--adminport` | — | `4017` | Admin/API HTTP(S) port |
101 +| `--admin-secret-key` | `ADMIN_SECRET_KEY` | auto-generated | Admin auth secret (auto-generated and logged if not set) |
102 +| `--portal-url` | `PORTAL_URL` | `https://localhost:4017` | Portal base URL |
103 +| `--bootstraps` | `BOOTSTRAP_URIS` | derived from `PORTAL_URL` | Comma-separated relay API URLs |
104 +| `--sni-port` | `SNI_PORT` | `443` | SNI TCP listener port |
105 +| `--keyless-dir` | `KEYLESS_DIR` | `/etc/portal/keyless` | TLS cert and keyless materials directory |
106 +| `--cloudflare-token` | `CLOUDFLARE_TOKEN` | `""` | Cloudflare DNS API token (Zone:Read + DNS:Edit) |
107 +| `--lease-bps` | — | `0` (unlimited) | Per-lease bandwidth cap (bytes/sec) |
108 +| `--trust-proxy-headers` | `TRUST_PROXY_HEADERS` | `false` | Trust X-Forwarded-For / X-Real-IP headers |
109 +| `--trusted-proxy-cidrs` | `TRUSTED_PROXY_CIDRS` | `""` | CIDR allowlist for trusted proxies |
110 +
111 +### Deployment Notes
112 +
113 +- `admin_settings.json` persists runtime state (ban lists, BPS limits, approval mode) in the process working directory. Mount CWD as a volume to preserve state across container restarts.
114 +- `keyless_tls/` is a local Go sub-module (`replace` directive in root `go.mod`), not a separately published package.
115 +
116 ## Architecture
117
118 See [docs/architecture.md](docs/architecture.md).
@@ -96,14 +120,9 @@ For architecture decisions, see [docs/adr/README.md](docs/adr/README.md).
120
121 ## Contributing
122
99 -We welcome contributions from the community!
100 -
101 -### Steps to Contribute
102 -1. Fork the repository
103 -2. Create a feature branch (`git checkout -b feature/amazing-feature`)
104 -3. Commit your changes (`git commit -m 'Add amazing feature'`)
105 -4. Push to the branch (`git push origin feature/amazing-feature`)
106 -5. Open a Pull Request
123 +1. Fork the repository.
124 +2. Create a feature branch and make your changes.
125 +3. Open a Pull Request.
126
127 ## License
128
cmd/portal-tunnel/README.md
+8 -19
@@ -26,6 +26,13 @@ Portal tunnel always runs in TLS reverse-connect mode:
26 - Traffic is proxied from tunnel to local `--host` over TCP.
27 - Public access is `https://<service>.<portal-root-host>/`.
28
29 +### Lifecycle Identity
30 +
31 +The tunnel automatically acquires a per-lease mTLS identity via the relay's control plane. Identity materials are managed by `keyless_tls/keyless/lifecycle` and stored encrypted on disk under `KEYLESS_DIR/lifecycle-identities/`.
32 +
33 +- `KEYLESS_DIR` defaults to `/etc/portal/keyless`. The tunnel must have read/write access to this directory.
34 +- If the relay's issuer certificate or key is unavailable, the tunnel fails at startup.
35 +
36 ## Flags
37
38 ```text
@@ -72,25 +79,7 @@ export APP_NAME=myapp
79 ./bin/portal-tunnel
80 ```
81
75 -Expected signer API contract (`/v1/sign`):
76 -
77 -```json
78 -{
79 - "key_id": "relay-cert",
80 - "algorithm": "RSA_PSS_SHA256",
81 - "digest": "<base64>",
82 - "timestamp_unix": 1735628400,
83 - "nonce": "c4d76ad40f5d8f95a1fe4b2f1c922f4a"
84 -}
85 -```
86 -
87 -```json
88 -{
89 - "key_id": "relay-cert",
90 - "algorithm": "RSA_PSS_SHA256",
91 - "signature": "<base64>"
92 -}
93 -```
82 +When the local service is unreachable, the tunnel returns an HTTP 503 "Service Unavailable" page to the browser.
83
84 ### Multiple Relays (High Availability)
85
cmd/portal-tunnel/main.go
+10 -9
@@ -17,8 +17,9 @@ import (
17 "github.com/rs/zerolog"
18 "github.com/rs/zerolog/log"
19
20 + "gosuda.org/portal/portal/contracts"
21 + "gosuda.org/portal/portal/netutil"
22 "gosuda.org/portal/sdk"
21 - "gosuda.org/portal/types"
23 )
24
25 var (
@@ -63,7 +64,7 @@ func runTunnel() error {
64 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
65 defer stop()
66
66 - relayURLs := types.ParseURLs(flagRelayURLs)
67 + relayURLs := netutil.ParseURLs(flagRelayURLs)
68 if len(relayURLs) == 0 {
69 return errors.New("no relay URLs provided")
70 }
@@ -85,11 +86,11 @@ func runTunnel() error {
86
87 listener, err := sdkClient.Listen(
88 flagName,
88 - types.WithDescription(flagDesc),
89 - types.WithTags(types.ParseURLs(flagTags)),
90 - types.WithOwner(flagOwner),
91 - types.WithThumbnail(flagThumbnail),
92 - types.WithHide(flagHide),
89 + contracts.WithDescription(flagDesc),
90 + contracts.WithTags(netutil.ParseURLs(flagTags)),
91 + contracts.WithOwner(flagOwner),
92 + contracts.WithThumbnail(flagThumbnail),
93 + contracts.WithHide(flagHide),
94 )
95 if err != nil {
96 return fmt.Errorf("service %s: failed to register service: %w", flagName, err)
@@ -168,7 +169,7 @@ loop:
169 func normalizeRelayURLsForReverseConnect(relayURLs []string) ([]string, error) {
170 normalized := make([]string, 0, len(relayURLs))
171 for _, relayURL := range relayURLs {
171 - normalizedURL, err := types.NormalizeRelayAPIURL(relayURL)
172 + normalizedURL, err := netutil.NormalizeRelayAPIURL(relayURL)
173 if err != nil {
174 return nil, fmt.Errorf("invalid relay URL %q: %w", relayURL, err)
175 }
@@ -187,7 +188,7 @@ var bufferPool = sync.Pool{
188 func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn) error {
189 defer relayConn.Close()
190
190 - targetAddr, err := types.NormalizeTargetAddr(localAddr)
191 + targetAddr, err := netutil.NormalizeTargetAddr(localAddr)
192 if err != nil {
193 return fmt.Errorf("invalid --host value %q: %w", localAddr, err)
194 }
cmd/relay-server/admin.go
+66 -545
@@ -1,590 +1,111 @@
1 package main
2
3 import (
4 - "encoding/json"
4 "net/http"
6 - "os"
7 - "path/filepath"
5 "strings"
9 - "sync"
6
11 - "github.com/rs/zerolog/log"
12 -
13 - "gosuda.org/portal/cmd/relay-server/manager"
7 "gosuda.org/portal/portal"
15 - "gosuda.org/portal/types"
8 + portaladmin "gosuda.org/portal/portal/admin"
9 + "gosuda.org/portal/portal/policy"
10 )
11
18 -const adminCookieName = "portal_admin"
19 -
20 -// Admin manages approval state and persistence for relay-server.
12 +// Admin is a thin adapter between relay-server wiring and portal/admin handlers.
13 type Admin struct {
22 - approveManager *manager.ApproveManager
23 - bpsManager *manager.BPSManager
24 - ipManager *manager.IPManager
25 - authManager *manager.AuthManager
26 - frontend *Frontend
27 - portalURL string
28 - settingsPath string
29 - trustProxy bool
30 - settingsMu sync.Mutex
31 -}
32 -
33 -func NewAdmin(defaultLeaseBPS int64, frontend *Frontend, authManager *manager.AuthManager, portalURL string, trustProxy bool) *Admin {
34 - bpsManager := manager.NewBPSManager()
35 - if defaultLeaseBPS > 0 {
36 - bpsManager.SetDefaultBPS(defaultLeaseBPS)
37 - }
38 - return &Admin{
39 - portalURL: strings.TrimSpace(portalURL),
40 - trustProxy: trustProxy,
41 - settingsPath: "admin_settings.json",
42 - approveManager: manager.NewApproveManager(),
43 - bpsManager: bpsManager,
44 - ipManager: manager.NewIPManager(),
45 - authManager: authManager,
46 - frontend: frontend,
47 - }
48 -}
49 -
50 -// GetApproveManager exposes the approval manager.
51 -func (a *Admin) GetApproveManager() *manager.ApproveManager {
52 - return a.approveManager
53 -}
54 -
55 -// GetBPSManager exposes the BPS manager.
56 -func (a *Admin) GetBPSManager() *manager.BPSManager {
57 - return a.bpsManager
58 -}
59 -
60 -// GetIPManager exposes the IP manager.
61 -func (a *Admin) GetIPManager() *manager.IPManager {
62 - return a.ipManager
63 -}
64 -
65 -// adminSettings stores persistent admin configuration.
66 -type adminSettings struct {
67 - BannedLeases []string `json:"banned_leases"`
68 - BPSLimits map[string]int64 `json:"bps_limits"`
69 - ApprovalMode manager.ApprovalMode `json:"approval_mode"`
70 - ApprovedLeases []string `json:"approved_leases,omitempty"`
71 - DeniedLeases []string `json:"denied_leases,omitempty"`
72 - BannedIPs []string `json:"banned_ips,omitempty"`
73 -}
74 -
75 -func (a *Admin) SetSettingsPath(path string) {
76 - a.settingsMu.Lock()
77 - defer a.settingsMu.Unlock()
78 - a.settingsPath = path
79 -}
80 -
81 -func (a *Admin) SaveSettings(serv *portal.RelayServer) {
82 - a.settingsMu.Lock()
83 - defer a.settingsMu.Unlock()
84 -
85 - lm := serv.GetLeaseManager()
86 - banned := lm.GetBannedLeases()
87 -
88 - bpsLimits := map[string]int64{}
89 - if a.bpsManager != nil {
90 - bpsLimits = a.bpsManager.GetAllBPSLimits()
91 - }
92 -
93 - var bannedIPs []string
94 - if a.ipManager != nil {
95 - bannedIPs = a.ipManager.GetBannedIPs()
96 - }
97 -
98 - settings := adminSettings{
99 - BannedLeases: banned,
100 - BPSLimits: bpsLimits,
101 - ApprovalMode: a.approveManager.GetApprovalMode(),
102 - ApprovedLeases: a.approveManager.GetApprovedLeases(),
103 - DeniedLeases: a.approveManager.GetDeniedLeases(),
104 - BannedIPs: bannedIPs,
105 - }
106 -
107 - data, err := json.MarshalIndent(settings, "", " ")
108 - if err != nil {
109 - log.Error().Err(err).Msg("[Admin] Failed to marshal admin settings")
110 - return
111 - }
112 -
113 - dir := filepath.Dir(a.settingsPath)
114 - if dir != "" && dir != "." {
115 - if err := os.MkdirAll(dir, 0755); err != nil {
116 - log.Error().Err(err).Msg("[Admin] Failed to create settings directory")
117 - return
118 - }
119 - }
120 -
121 - if err := os.WriteFile(a.settingsPath, data, 0644); err != nil {
122 - log.Error().Err(err).Msg("[Admin] Failed to save admin settings")
123 - return
124 - }
14 + service *portaladmin.Service
15 + handler *portaladmin.Handler
16
126 - log.Debug().Str("path", a.settingsPath).Msg("[Admin] Saved admin settings")
17 + // Kept for existing call sites in cmd package (for example lease row conversion).
18 + approveManager *policy.Approver
19 + bpsManager *policy.RateLimiter
20 + ipManager *policy.IPFilter
21 }
22
129 -func (a *Admin) LoadSettings(serv *portal.RelayServer) {
130 - a.settingsMu.Lock()
131 - defer a.settingsMu.Unlock()
132 -
133 - data, err := os.ReadFile(a.settingsPath)
134 - if err != nil {
135 - if os.IsNotExist(err) {
136 - log.Debug().Msg("[Admin] No admin settings file found, starting fresh")
137 - return
138 - }
139 - log.Error().Err(err).Msg("[Admin] Failed to read admin settings")
140 - return
141 - }
142 -
143 - var settings adminSettings
144 - if err := json.Unmarshal(data, &settings); err != nil {
145 - log.Error().Err(err).Msg("[Admin] Failed to parse admin settings")
146 - return
147 - }
148 -
149 - lm := serv.GetLeaseManager()
150 -
151 - for _, leaseID := range settings.BannedLeases {
152 - lm.BanLease(leaseID)
153 - }
154 -
155 - for leaseID, bps := range settings.BPSLimits {
156 - if a.bpsManager != nil {
157 - a.bpsManager.SetBPSLimit(leaseID, bps)
158 - }
159 - }
160 -
161 - if settings.ApprovalMode != "" {
162 - a.approveManager.SetApprovalMode(settings.ApprovalMode)
163 - }
164 -
165 - for _, leaseID := range settings.ApprovedLeases {
166 - a.approveManager.ApproveLease(leaseID)
167 - }
168 -
169 - for _, leaseID := range settings.DeniedLeases {
170 - a.approveManager.DenyLease(leaseID)
171 - }
172 -
173 - if a.ipManager != nil && len(settings.BannedIPs) > 0 {
174 - a.ipManager.SetBannedIPs(settings.BannedIPs)
23 +func NewAdmin(defaultLeaseBPS int64, frontend *Frontend, authManager *policy.Authenticator, portalURL string, trustProxy bool) *Admin {
24 + service := portaladmin.NewService(defaultLeaseBPS, authManager)
25 + normalizedPortalURL := strings.TrimSpace(portalURL)
26 + admin := &Admin{
27 + service: service,
28 + approveManager: service.GetApproveManager(),
29 + bpsManager: service.GetBPSManager(),
30 + ipManager: service.GetIPManager(),
31 }
32
177 - log.Info().
178 - Int("banned_count", len(settings.BannedLeases)).
179 - Int("bps_limits_count", len(settings.BPSLimits)).
180 - Str("approval_mode", string(a.approveManager.GetApprovalMode())).
181 - Int("approved_count", len(settings.ApprovedLeases)).
182 - Int("denied_count", len(settings.DeniedLeases)).
183 - Int("banned_ips_count", len(settings.BannedIPs)).
184 - Msg("[Admin] Loaded admin settings")
185 -}
186 -
187 -// isAuthenticated checks if the request has a valid admin session.
188 -func (a *Admin) isAuthenticated(r *http.Request) bool {
189 - // If no secret key is configured, deny all access
190 - if a.authManager == nil || !a.authManager.HasSecretKey() {
191 - return false
192 - }
193 -
194 - cookie, err := r.Cookie(adminCookieName)
195 - if err != nil {
196 - return false
197 - }
198 -
199 - return a.authManager.ValidateSession(cookie.Value)
200 -}
201 -
202 -// HandleAdminRequest routes /admin/* requests.
203 -func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
204 - route := strings.Trim(strings.TrimPrefix(r.URL.Path, types.PathAdminPrefix), "/")
205 -
206 - // Public routes (no authentication required)
207 - switch {
208 - case route == "login" && r.Method == http.MethodPost:
209 - a.handleLogin(w, r)
210 - return
211 - case route == "login":
212 - // Serve login page (GET)
213 - a.frontend.ServeAppStatic(w, r, "", serv)
214 - return
215 - case route == "logout" && r.Method == http.MethodPost:
216 - a.handleLogout(w, r)
217 - return
218 - case route == "auth/status" && r.Method == http.MethodGet:
219 - a.handleAuthStatus(w, r)
220 - return
221 - }
222 -
223 - // Protected routes - require authentication
224 - if !a.isAuthenticated(r) {
225 - // For page requests (no specific route), show login page
226 - if route == "" {
227 - a.frontend.ServeAppStatic(w, r, "", serv)
228 - return
229 - }
230 - // For API requests, return 401 envelope.
231 - writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
232 - return
233 - }
234 -
235 - switch {
236 - case route == "":
237 - a.frontend.ServeAppStatic(w, r, "", serv)
238 - case route == "leases" && r.Method == http.MethodGet:
239 - writeAPIData(w, http.StatusOK, convertLeaseEntriesToRows(serv, a, true, a.portalURL))
240 - case route == "leases/banned" && r.Method == http.MethodGet:
241 - writeAPIData(w, http.StatusOK, serv.GetLeaseManager().GetBannedLeases())
242 - case route == "stats" && r.Method == http.MethodGet:
243 - writeAPIData(w, http.StatusOK, map[string]any{
244 - "leases_count": len(serv.GetLeaseManager().GetAllLeaseEntries()),
245 - "uptime": "TODO",
246 - })
247 - case route == "settings" && r.Method == http.MethodGet:
248 - a.handleGetSettings(w)
249 - case route == "settings/approval-mode":
250 - a.handleApprovalModeRequest(w, r, serv)
251 - case strings.HasPrefix(route, "leases/"):
252 - if !a.handleLeaseActionRouteRequest(w, r, serv, route) {
33 + serveStatic := func(w http.ResponseWriter, r *http.Request, appPath string, serv *portal.RelayServer) {
34 + if frontend == nil {
35 http.NotFound(w, r)
36 + return
37 }
255 - case strings.HasPrefix(route, "ips/") && strings.HasSuffix(route, "/ban"):
256 - a.handleIPBanRequest(w, r, serv, route)
257 - default:
258 - http.NotFound(w, r)
259 - }
260 -}
261 -
262 -// handleLogin handles POST /admin/login.
263 -func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
264 - clientIP := manager.ExtractClientIP(r, a.trustProxy)
265 -
266 - // Check if IP is locked
267 - if a.authManager.IsIPLocked(clientIP) {
268 - remaining := a.authManager.GetLockRemainingSeconds(clientIP)
269 - writeAPIErrorWithData(
270 - w,
271 - http.StatusTooManyRequests,
272 - "auth_locked",
273 - "Too many failed attempts. Please try again later.",
274 - types.AdminLoginResponse{
275 - Locked: true,
276 - RemainingSeconds: remaining,
277 - },
278 - )
279 - return
280 - }
281 -
282 - var req types.AdminLoginRequest
283 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
284 - writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
285 - return
286 - }
287 -
288 - if !a.authManager.ValidateKey(req.Key) {
289 - // Record failed attempt
290 - nowLocked := a.authManager.RecordFailedLogin(clientIP)
291 - log.Warn().Str("ip", clientIP).Bool("now_locked", nowLocked).Msg("[Admin] Failed login attempt")
292 -
293 - response := types.AdminLoginResponse{
294 - Locked: nowLocked,
295 - }
296 - if nowLocked {
297 - response.RemainingSeconds = a.authManager.GetLockRemainingSeconds(clientIP)
298 - }
299 - writeAPIErrorWithData(w, http.StatusUnauthorized, "invalid_key", "Invalid key", response)
300 - return
301 - }
302 -
303 - // Successful login
304 - a.authManager.ResetFailedLogin(clientIP)
305 - token := a.authManager.CreateSession()
306 - secureCookie := isSecureRequestWithPolicy(r, a.trustProxy)
307 -
308 - http.SetCookie(w, &http.Cookie{
309 - Name: adminCookieName,
310 - Value: token,
311 - Path: "/admin",
312 - HttpOnly: true,
313 - Secure: secureCookie,
314 - SameSite: http.SameSiteStrictMode,
315 - MaxAge: 86400, // 24 hours
38 + frontend.ServeAppStatic(w, r, appPath, serv)
39 + }
40 +
41 + admin.handler = portaladmin.NewHandler(portaladmin.HandlerConfig{
42 + Service: service,
43 + TrustProxy: trustProxy,
44 + ServeAppStatic: serveStatic,
45 + ListLeases: func(serv *portal.RelayServer) any {
46 + return convertLeaseEntriesToRows(serv, admin, true, normalizedPortalURL)
47 + },
48 + DecodeLeaseID: decodeLeaseID,
49 + IsSecureRequest: isSecureRequestWithPolicy,
50 + WriteAPIData: writeAPIData,
51 + WriteAPIOK: writeAPIOK,
52 + WriteAPIError: writeAPIError,
53 + WriteAPIErrorWithData: writeAPIErrorWithData,
54 })
55
318 - log.Info().Str("ip", clientIP).Msg("[Admin] Successful login")
319 - writeAPIData(w, http.StatusOK, types.AdminLoginResponse{Success: true})
56 + return admin
57 }
58
322 -// handleLogout handles POST /admin/logout.
323 -func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
324 - cookie, err := r.Cookie(adminCookieName)
325 - if err == nil && cookie.Value != "" {
326 - a.authManager.DeleteSession(cookie.Value)
327 - }
328 - secureCookie := isSecureRequestWithPolicy(r, a.trustProxy)
329 -
330 - // Clear the cookie
331 - http.SetCookie(w, &http.Cookie{
332 - Name: adminCookieName,
333 - Value: "",
334 - Path: "/admin",
335 - HttpOnly: true,
336 - Secure: secureCookie,
337 - SameSite: http.SameSiteStrictMode,
338 - MaxAge: -1, // Delete cookie
339 - })
340 -
341 - writeAPIOK(w, http.StatusOK)
342 -}
343 -
344 -// handleAuthStatus handles GET /admin/auth/status.
345 -func (a *Admin) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
346 - authenticated := a.isAuthenticated(r)
347 -
348 - // Check if secret key is configured
349 - authEnabled := a.authManager != nil && a.authManager.HasSecretKey()
350 -
351 - writeAPIData(w, http.StatusOK, types.AdminAuthStatusResponse{
352 - Authenticated: authenticated,
353 - AuthEnabled: authEnabled,
354 - })
355 -}
356 -
357 -type leaseActionRouteStatus uint8
358 -
359 -const (
360 - leaseActionRouteNotFound leaseActionRouteStatus = iota
361 - leaseActionRouteInvalidLeaseID
362 - leaseActionRouteOK
363 -)
364 -
365 -func parseLeaseActionRoute(route string) (leaseID, action string, status leaseActionRouteStatus) {
366 - parts := strings.Split(route, "/")
367 - if len(parts) != 3 || parts[0] != "leases" {
368 - return "", "", leaseActionRouteNotFound
369 - }
370 -
371 - action = parts[2]
372 - switch action {
373 - case "ban", "bps", "approve", "deny":
374 - default:
375 - return "", "", leaseActionRouteNotFound
376 - }
377 -
378 - leaseID, ok := decodeLeaseID(parts[1])
379 - if !ok {
380 - return "", action, leaseActionRouteInvalidLeaseID
381 - }
382 -
383 - return leaseID, action, leaseActionRouteOK
384 -}
385 -
386 -func (a *Admin) handleLeaseActionRouteRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) bool {
387 - leaseID, action, status := parseLeaseActionRoute(route)
388 - switch status {
389 - case leaseActionRouteNotFound:
390 - return false
391 - case leaseActionRouteInvalidLeaseID:
392 - writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
393 - return true
394 - }
395 -
396 - switch action {
397 - case "ban":
398 - a.handleLeaseBanRequest(w, r, serv, leaseID)
399 - case "bps":
400 - a.handleLeaseBPSRequest(w, r, serv, leaseID)
401 - case "approve":
402 - a.handleLeaseApproveRequest(w, r, serv, leaseID)
403 - case "deny":
404 - a.handleLeaseDenyRequest(w, r, serv, leaseID)
405 - default:
406 - return false
59 +// GetApproveManager exposes the approval manager.
60 +func (a *Admin) GetApproveManager() *policy.Approver {
61 + if a == nil {
62 + return nil
63 }
408 -
409 - return true
64 + return a.approveManager
65 }
66
412 -func (a *Admin) handleLeaseBanRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
413 - if strings.TrimSpace(leaseID) == "" {
414 - writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
415 - return
416 - }
417 -
418 - switch r.Method {
419 - case http.MethodPost:
420 - serv.GetLeaseManager().BanLease(leaseID)
421 - a.SaveSettings(serv)
422 - writeAPIOK(w, http.StatusOK)
423 - case http.MethodDelete:
424 - serv.GetLeaseManager().UnbanLease(leaseID)
425 - a.SaveSettings(serv)
426 - writeAPIOK(w, http.StatusOK)
427 - default:
428 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
67 +// GetBPSManager exposes the BPS manager.
68 +func (a *Admin) GetBPSManager() *policy.RateLimiter {
69 + if a == nil {
70 + return nil
71 }
72 + return a.bpsManager
73 }
74
432 -func (a *Admin) handleGetSettings(w http.ResponseWriter) {
433 - writeAPIData(w, http.StatusOK, types.AdminSettingsResponse{
434 - ApprovalMode: string(a.approveManager.GetApprovalMode()),
435 - ApprovedLeases: a.approveManager.GetApprovedLeases(),
436 - DeniedLeases: a.approveManager.GetDeniedLeases(),
437 - })
438 -}
439 -
440 -func (a *Admin) handleApprovalModeRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
441 - switch r.Method {
442 - case http.MethodGet:
443 - writeAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{
444 - ApprovalMode: string(a.approveManager.GetApprovalMode()),
445 - })
446 - case http.MethodPost:
447 - var req types.AdminApprovalModeRequest
448 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
449 - writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
450 - return
451 - }
452 - mode := manager.ApprovalMode(req.Mode)
453 - if mode != manager.ApprovalModeAuto && mode != manager.ApprovalModeManual {
454 - writeAPIError(w, http.StatusBadRequest, "invalid_mode", "invalid mode (must be 'auto' or 'manual')")
455 - return
456 - }
457 - a.approveManager.SetApprovalMode(mode)
458 - a.SaveSettings(serv)
459 - log.Info().Str("mode", string(mode)).Msg("[Admin] Approval mode changed")
460 - writeAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{
461 - ApprovalMode: string(mode),
462 - })
463 - default:
464 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
75 +// GetIPManager exposes the IP manager.
76 +func (a *Admin) GetIPManager() *policy.IPFilter {
77 + if a == nil {
78 + return nil
79 }
80 + return a.ipManager
81 }
82
468 -func (a *Admin) handleLeaseApproveRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
469 - if strings.TrimSpace(leaseID) == "" {
470 - writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
83 +func (a *Admin) SetSettingsPath(path string) {
84 + if a == nil || a.service == nil {
85 return
86 }
473 -
474 - switch r.Method {
475 - case http.MethodPost:
476 - a.approveManager.ApproveLease(leaseID)
477 - a.approveManager.UndenyLease(leaseID) // Remove from denied if exists
478 - a.SaveSettings(serv)
479 - log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease approved")
480 - writeAPIOK(w, http.StatusOK)
481 - case http.MethodDelete:
482 - a.approveManager.RevokeLease(leaseID)
483 - a.SaveSettings(serv)
484 - log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease approval revoked")
485 - writeAPIOK(w, http.StatusOK)
486 - default:
487 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
488 - }
87 + a.service.SetSettingsPath(path)
88 }
89
491 -func (a *Admin) handleLeaseDenyRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
492 - if strings.TrimSpace(leaseID) == "" {
493 - writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
90 +func (a *Admin) SaveSettings(serv *portal.RelayServer) {
91 + if a == nil || a.service == nil {
92 return
93 }
496 -
497 - switch r.Method {
498 - case http.MethodPost:
499 - a.approveManager.DenyLease(leaseID)
500 - a.SaveSettings(serv)
501 - log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease denied")
502 - writeAPIOK(w, http.StatusOK)
503 - case http.MethodDelete:
504 - a.approveManager.UndenyLease(leaseID)
505 - a.SaveSettings(serv)
506 - log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease denial removed")
507 - writeAPIOK(w, http.StatusOK)
508 - default:
509 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
510 - }
94 + a.service.SaveSettings(serv)
95 }
96
513 -func (a *Admin) handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
514 - if strings.TrimSpace(leaseID) == "" {
515 - writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
97 +func (a *Admin) LoadSettings(serv *portal.RelayServer) {
98 + if a == nil || a.service == nil {
99 return
100 }
518 -
519 - switch r.Method {
520 - case http.MethodPost:
521 - var req types.AdminBPSRequest
522 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
523 - writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
524 - return
525 - }
526 - if a.bpsManager == nil {
527 - writeAPIError(w, http.StatusInternalServerError, "bps_manager_unavailable", "bps manager not initialized")
528 - return
529 - }
530 - oldBPS := a.bpsManager.GetBPSLimit(leaseID)
531 - a.bpsManager.SetBPSLimit(leaseID, req.BPS)
532 - log.Info().
533 - Str("lease_id", leaseID).
534 - Int64("old_bps", oldBPS).
535 - Int64("new_bps", req.BPS).
536 - Msg("[Admin] BPS limit updated")
537 - a.SaveSettings(serv)
538 - writeAPIOK(w, http.StatusOK)
539 - case http.MethodDelete:
540 - if a.bpsManager == nil {
541 - writeAPIError(w, http.StatusInternalServerError, "bps_manager_unavailable", "bps manager not initialized")
542 - return
543 - }
544 - oldBPS := a.bpsManager.GetBPSLimit(leaseID)
545 - a.bpsManager.SetBPSLimit(leaseID, 0)
546 - log.Info().
547 - Str("lease_id", leaseID).
548 - Int64("old_bps", oldBPS).
549 - Msg("[Admin] BPS limit removed (now unlimited)")
550 - a.SaveSettings(serv)
551 - writeAPIOK(w, http.StatusOK)
552 - default:
553 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
554 - }
101 + a.service.LoadSettings(serv)
102 }
103
557 -func (a *Admin) handleIPBanRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
558 - // Route format: ips/{ip}/ban
559 - parts := strings.Split(route, "/")
560 - if len(parts) != 3 {
561 - http.NotFound(w, r)
562 - return
563 - }
564 -
565 - ip := parts[1]
566 - if ip == "" {
567 - writeAPIError(w, http.StatusBadRequest, "invalid_ip", "invalid IP address")
568 - return
569 - }
570 -
571 - if a.ipManager == nil {
572 - writeAPIError(w, http.StatusInternalServerError, "ip_manager_unavailable", "ip manager not initialized")
104 +// HandleAdminRequest routes /admin/* requests.
105 +func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
106 + if a == nil || a.handler == nil {
107 + writeAPIError(w, http.StatusInternalServerError, "admin_handler_unavailable", "admin handler unavailable")
108 return
109 }
575 -
576 - switch r.Method {
577 - case http.MethodPost:
578 - a.ipManager.BanIP(ip)
579 - a.SaveSettings(serv)
580 - log.Info().Str("ip", ip).Msg("[Admin] IP banned")
581 - writeAPIOK(w, http.StatusOK)
582 - case http.MethodDelete:
583 - a.ipManager.UnbanIP(ip)
584 - a.SaveSettings(serv)
585 - log.Info().Str("ip", ip).Msg("[Admin] IP unbanned")
586 - writeAPIOK(w, http.StatusOK)
587 - default:
588 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
589 - }
110 + a.handler.HandleAdminRequest(w, r, serv)
111 }
cmd/relay-server/main.go
+12 -12
@@ -14,10 +14,10 @@ import (
14 "github.com/rs/zerolog"
15 "github.com/rs/zerolog/log"
16
17 - "gosuda.org/portal/cmd/relay-server/manager"
17 "gosuda.org/portal/portal"
18 + "gosuda.org/portal/portal/netutil"
19 + "gosuda.org/portal/portal/policy"
20 "gosuda.org/portal/portal/sni"
20 - "gosuda.org/portal/types"
21 )
22
23 const (
@@ -51,9 +51,9 @@ func main() {
51 }
52 bootstrapsCSV := trimmedEnv("BOOTSTRAP_URIS")
53 if bootstrapsCSV == "" {
54 - bootstrapsCSV = types.DefaultBootstrapFrom(portalURL)
54 + bootstrapsCSV = netutil.DefaultBootstrapFrom(portalURL)
55 }
56 - sniPort := types.ParsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort)
56 + sniPort := netutil.ParsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort)
57 keylessDir := trimmedEnv("KEYLESS_DIR")
58 if keylessDir == "" {
59 keylessDir = defaultKeylessDir
@@ -75,12 +75,12 @@ func main() {
75 flag.StringVar(&cfg.CloudflareToken, "cloudflare-token", cloudflareToken, "Cloudflare DNS API token (Zone:Read + DNS:Edit) (env: CLOUDFLARE_TOKEN)")
76 flag.Parse()
77
78 - cfg.Bootstraps = types.ParseURLs(bootstrapsCSV)
78 + cfg.Bootstraps = netutil.ParseURLs(bootstrapsCSV)
79 parsedTrustedProxyCIDRs, err := parseTrustedProxyCIDRs(cfg.TrustedProxyCIDRs)
80 if err != nil {
81 log.Fatal().Err(err).Msg("parse trusted proxy CIDRs")
82 }
83 - manager.SetTrustedProxyCIDRs(parsedTrustedProxyCIDRs)
83 + policy.SetTrustedProxyCIDRs(parsedTrustedProxyCIDRs)
84 if err := runServer(cfg); err != nil {
85 log.Fatal().Err(err).Msg("execute root command")
86 }
@@ -98,15 +98,15 @@ func runServer(cfg relayServerConfig) error {
98 Strs("bootstrap_uris", cfg.Bootstraps).
99 Msg("[server] frontend configuration")
100
101 - rootHost := types.PortalRootHost(cfg.PortalURL)
102 - apiUpstreamAddr := types.LoopbackForwardAddr(fmt.Sprintf(":%d", cfg.AdminPort))
101 + rootHost := netutil.PortalRootHost(cfg.PortalURL)
102 + apiUpstreamAddr := netutil.LoopbackForwardAddr(fmt.Sprintf(":%d", cfg.AdminPort))
103 serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, sniListenAddr, rootHost, cfg.KeylessDir, cfg.CloudflareToken)
104 if err != nil {
105 return fmt.Errorf("create relay server: %w", err)
106 }
107
108 frontend := NewFrontend(cfg.PortalURL)
109 - authManager := manager.NewAuthManager(cfg.AdminSecretKey)
109 + authManager := policy.NewAuthenticator(cfg.AdminSecretKey)
110 admin := NewAdmin(int64(cfg.LeaseBPS), frontend, authManager, cfg.PortalURL, cfg.TrustProxyHeaders)
111 frontend.SetAdmin(admin)
112
@@ -129,7 +129,7 @@ func runServer(cfg relayServerConfig) error {
129 })
130 if ipMgr != nil {
131 serv.GetReverseHub().SetIPBanChecker(func(ip string) bool {
132 - return manager.IsIPBannedByPolicy(ipMgr, ip)
132 + return policy.IsIPBannedByPolicy(ipMgr, ip)
133 })
134 serv.GetReverseHub().SetOnAccepted(func(leaseID, ip string) {
135 if strings.TrimSpace(leaseID) == "" || strings.TrimSpace(ip) == "" {
@@ -169,7 +169,7 @@ func runServer(cfg relayServerConfig) error {
169 }
170
171 // SNI path is reverse-only (NAT-friendly): relay never dials app directly.
172 - manager.EstablishRelayWithBPS(clientConn, reverseConn.Conn, leaseID, bpsManager)
172 + policy.EstablishRelayWithBPS(clientConn, reverseConn.Conn, leaseID, bpsManager)
173 reverseConn.Close()
174 })
175
@@ -228,5 +228,5 @@ func parseBoolEnv(name string) bool {
228 }
229
230 func parseTrustedProxyCIDRs(raw string) ([]*net.IPNet, error) {
231 - return manager.ParseTrustedProxyCIDRs(raw)
231 + return policy.ParseTrustedProxyCIDRs(raw)
232 }
cmd/relay-server/registry.go
+178 -147
@@ -2,49 +2,54 @@ package main
2
3 import (
4 "encoding/json"
5 - "fmt"
5 + "errors"
6 + "net"
7 "net/http"
8 "strings"
8 - "time"
9
10 "github.com/rs/zerolog/log"
11
12 - "gosuda.org/portal/cmd/relay-server/manager"
12 "gosuda.org/portal/portal"
14 - "gosuda.org/portal/portal/controlplane"
13 + controlplaneregistry "gosuda.org/portal/portal/controlplane/registry"
14 + "gosuda.org/portal/portal/policy"
15 "gosuda.org/portal/types"
16 )
17
18 +var errRegistryBackendUnavailable = errors.New("registry backend unavailable")
19 +
20 // SDKRegistry handles HTTP API for client lease registration.
21 type SDKRegistry struct {
20 - ipManager *manager.IPManager
22 + ipManager *policy.IPFilter
23 portalURL string
24 trustProxyHeaders bool
25 }
26
25 -const sdkLeaseTTL = 30 * time.Second
26 -
27 // HandleSDKRequest routes /sdk/* requests.
28 func (r *SDKRegistry) HandleSDKRequest(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
29 - path := strings.TrimSuffix(req.URL.Path, "/")
29 + registryService, err := r.newService(serv)
30 + if err != nil {
31 + writeAPIError(w, http.StatusInternalServerError, "registry_unavailable", "registry service unavailable")
32 + return
33 + }
34
35 + path := strings.TrimSuffix(req.URL.Path, "/")
36 switch path {
37 case types.PathSDKRegister:
33 - r.handleRegister(w, req, serv)
38 + r.handleRegister(w, req, registryService)
39 case types.PathSDKUnregister:
35 - r.handleUnregister(w, req, serv)
40 + r.handleUnregister(w, req, registryService)
41 case types.PathSDKRenew:
37 - r.handleRenew(w, req, serv)
42 + r.handleRenew(w, req, registryService)
43 case types.PathSDKDomain:
39 - r.handleDomain(w, req, serv)
44 + r.handleDomain(w, registryService)
45 case types.PathSDKConnect:
41 - r.handleConnect(w, req, serv)
46 + r.handleConnect(w, req, registryService)
47 default:
48 http.NotFound(w, req)
49 }
50 }
51
47 -func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
52 +func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, registryService *controlplaneregistry.Service) {
53 if req.Method != http.MethodGet {
54 w.Header().Set("Allow", http.MethodGet)
55 writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -55,9 +60,14 @@ func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, se
60 return
61 }
62
58 - leaseID := req.URL.Query().Get("lease_id")
59 - token := req.Header.Get(portal.ReverseConnectTokenHeader)
60 - leaseID, token, clientIP, _, ok := r.admitControlPlane(w, req, serv, leaseID, token, true)
63 + admission, ok := r.admitControlPlane(
64 + w,
65 + req,
66 + registryService,
67 + req.URL.Query().Get("lease_id"),
68 + req.Header.Get(portal.ReverseConnectTokenHeader),
69 + true,
70 + )
71 if !ok {
72 return
73 }
@@ -85,11 +95,11 @@ func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, se
95 return
96 }
97
88 - serv.GetReverseHub().HandleConnect(conn, leaseID, token, clientIP)
98 + registryService.HandleConnect(conn, admission)
99 }
100
101 // handleRegister handles SDK lease registration requests.
92 -func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
102 +func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, registryService *controlplaneregistry.Service) {
103 if !r.requireMethod(w, req, http.MethodPost) {
104 return
105 }
@@ -99,68 +109,35 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
109 return
110 }
111
102 - registerReq.Name = strings.TrimSpace(registerReq.Name)
103 - if !types.IsValidLeaseName(registerReq.Name) {
104 - writeAPIError(w, http.StatusBadRequest, "invalid_name", "name must be a DNS label (letters, digits, hyphen; no dots or underscores)")
105 - return
106 - }
107 - if !registerReq.TLS {
108 - writeAPIError(w, http.StatusBadRequest, "tls_required", "tls must be enabled")
109 - return
110 - }
111 - leaseID, token, _, _, ok := r.admitControlPlane(w, req, serv, registerReq.LeaseID, registerReq.ReverseToken, false)
112 + admission, ok := r.admitControlPlane(
113 + w,
114 + req,
115 + registryService,
116 + registerReq.LeaseID,
117 + registerReq.ReverseToken,
118 + false,
119 + )
120 if !ok {
121 return
122 }
115 - registerReq.LeaseID = leaseID
116 - registerReq.ReverseToken = token
123
118 - // Create lease
119 - lease := &portal.Lease{
120 - ID: registerReq.LeaseID,
124 + registerResp, apiErr := registryService.Register(controlplaneregistry.RegisterInput{
125 + LeaseID: admission.LeaseID,
126 + ReverseToken: admission.ReverseToken,
127 Name: registerReq.Name,
122 - Metadata: registerReq.Metadata,
123 - Expires: time.Now().Add(sdkLeaseTTL),
124 - TLS: true,
125 - ReverseToken: registerReq.ReverseToken,
126 - }
127 -
128 - if !serv.GetLeaseManager().UpdateLease(lease) {
129 - writeAPIError(w, http.StatusConflict, "lease_rejected", "failed to register lease (name conflict or policy violation)")
130 - return
131 - }
132 -
133 - serv.GetReverseHub().ClearDropped(registerReq.LeaseID)
134 -
135 - sniName := types.BuildSNIName(registerReq.Name, serv.BaseHost)
136 - if sniName == "" {
137 - serv.GetLeaseManager().DeleteLease(registerReq.LeaseID)
138 - writeAPIError(w, http.StatusInternalServerError, "sni_name_invalid", "failed to build SNI route name")
139 - return
140 - }
141 - if err := serv.GetSNIRouter().RegisterRoute(sniName, registerReq.LeaseID, registerReq.Name); err != nil {
142 - serv.GetLeaseManager().DeleteLease(registerReq.LeaseID)
143 - writeAPIError(w, http.StatusInternalServerError, "sni_register_failed", fmt.Sprintf("failed to register SNI route: %v", err))
128 + Metadata: &registerReq.Metadata,
129 + TLS: registerReq.TLS,
130 + PortalURL: r.portalURL,
131 + })
132 + if !writeRegistryError(w, apiErr) {
133 return
134 }
135
147 - log.Info().
148 - Str("lease_id", registerReq.LeaseID).
149 - Str("name", registerReq.Name).
150 - Bool("tls", true).
151 - Msg("[Registry] Lease registered")
152 -
153 - publicURL := types.ServicePublicURL(r.portalURL, registerReq.Name)
154 -
155 - writeAPIData(w, http.StatusOK, types.RegisterResponse{
156 - LeaseID: registerReq.LeaseID,
157 - PublicURL: publicURL,
158 - Success: true,
159 - })
136 + writeAPIData(w, http.StatusOK, registerResp)
137 }
138
139 // handleUnregister handles SDK lease unregistration requests.
163 -func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
140 +func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request, registryService *controlplaneregistry.Service) {
141 if !r.requireMethod(w, req, http.MethodPost) {
142 return
143 }
@@ -169,24 +146,25 @@ func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request,
146 if !r.decodeRequestBody(w, req, &unregisterReq, "[Registry] Failed to decode unregistration request") {
147 return
148 }
172 - leaseID, _, _, _, ok := r.admitControlPlane(w, req, serv, unregisterReq.LeaseID, unregisterReq.ReverseToken, true)
149 +
150 + admission, ok := r.admitControlPlane(
151 + w,
152 + req,
153 + registryService,
154 + unregisterReq.LeaseID,
155 + unregisterReq.ReverseToken,
156 + true,
157 + )
158 if !ok {
159 return
160 }
161
177 - if serv.GetLeaseManager().DeleteLease(leaseID) {
178 - log.Info().
179 - Str("lease_id", leaseID).
180 - Msg("[Registry] Lease unregistered")
181 - }
182 - serv.GetSNIRouter().UnregisterRouteByLeaseID(leaseID)
183 - serv.GetReverseHub().DropLease(leaseID)
184 -
162 + registryService.Unregister(admission.LeaseID)
163 writeAPIOK(w, http.StatusOK)
164 }
165
166 // handleRenew handles SDK lease renewal requests (keepalive).
189 -func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
167 +func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, registryService *controlplaneregistry.Service) {
168 if !r.requireMethod(w, req, http.MethodPost) {
169 return
170 }
@@ -196,84 +174,65 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv
174 return
175 }
176
199 - _, _, _, entry, ok := r.admitControlPlane(w, req, serv, renewReq.LeaseID, renewReq.ReverseToken, true)
177 + admission, ok := r.admitControlPlane(
178 + w,
179 + req,
180 + registryService,
181 + renewReq.LeaseID,
182 + renewReq.ReverseToken,
183 + true,
184 + )
185 if !ok {
186 return
187 }
188
204 - entry.Lease.Expires = time.Now().Add(sdkLeaseTTL)
205 - if !serv.GetLeaseManager().UpdateLease(entry.Lease) {
206 - writeAPIError(w, http.StatusInternalServerError, "renew_failed", "failed to renew lease")
189 + if !writeRegistryError(w, registryService.Renew(admission.Entry)) {
190 return
191 }
209 -
210 - sniName := types.BuildSNIName(entry.Lease.Name, serv.BaseHost)
211 - if sniName == "" {
212 - log.Warn().
213 - Str("lease_id", entry.Lease.ID).
214 - Str("name", entry.Lease.Name).
215 - Str("base_host", serv.BaseHost).
216 - Msg("[Registry] Skipping SNI route refresh due to invalid SNI name")
217 - } else if err := serv.GetSNIRouter().RegisterRoute(sniName, entry.Lease.ID, entry.Lease.Name); err != nil {
218 - log.Warn().
219 - Err(err).
220 - Str("lease_id", entry.Lease.ID).
221 - Str("name", entry.Lease.Name).
222 - Msg("[Registry] Failed to refresh SNI route on renew")
223 - }
224 -
192 writeAPIOK(w, http.StatusOK)
193 }
194
195 // handleDomain returns the relay's base domain for TLS certificate construction.
229 -func (r *SDKRegistry) handleDomain(w http.ResponseWriter, _ *http.Request, serv *portal.RelayServer) {
230 - if serv.BaseHost == "" {
231 - writeAPIError(w, http.StatusServiceUnavailable, "base_domain_missing", "base domain not configured")
196 +func (r *SDKRegistry) handleDomain(w http.ResponseWriter, registryService *controlplaneregistry.Service) {
197 + domainResp, apiErr := registryService.Domain()
198 + if !writeRegistryError(w, apiErr) {
199 return
200 }
234 - writeAPIData(w, http.StatusOK, types.DomainResponse{
235 - Success: true,
236 - BaseDomain: serv.BaseHost,
237 - })
201 + writeAPIData(w, http.StatusOK, domainResp)
202 }
203
240 -func (r *SDKRegistry) admitControlPlane(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer, rawLeaseID, rawToken string, requireExistingLease bool) (leaseID, token, clientIP string, entry *portal.LeaseEntry, ok bool) {
241 - leaseID, token = normalizeLeaseCredentials(rawLeaseID, rawToken)
242 - if !r.validateLeaseCredentials(w, leaseID, token) {
243 - return "", "", "", nil, false
244 - }
245 -
246 - clientIP = r.extractClientIP(req)
247 - if r.isClientIPBanned(clientIP) {
248 - writeAPIError(w, http.StatusForbidden, "ip_banned", "ip is banned")
249 - return "", "", "", nil, false
250 - }
251 -
252 - entry, exists := lookupLeaseEntry(serv, leaseID)
253 - if requireExistingLease && !exists {
254 - writeAPIError(w, http.StatusNotFound, "lease_not_found", "lease not found")
255 - return "", "", "", nil, false
256 - }
257 -
258 - if code, message, ok := controlplane.ValidatePeerLeaseCertificate(req.TLS, leaseID); !ok {
259 - writeAPIError(w, http.StatusUnauthorized, code, message)
260 - return "", "", "", nil, false
261 - }
262 -
263 - if exists && !controlplane.MatchLeaseToken(entry.Lease.ReverseToken, token) {
264 - writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized reverse connect")
265 - return "", "", "", nil, false
204 +func (r *SDKRegistry) admitControlPlane(
205 + w http.ResponseWriter,
206 + req *http.Request,
207 + registryService *controlplaneregistry.Service,
208 + rawLeaseID, rawToken string,
209 + requireExistingLease bool,
210 +) (controlplaneregistry.AdmissionResult, bool) {
211 + clientIP := policy.ExtractClientIP(req, r.trustProxyHeaders)
212 + admission, apiErr := registryService.Admit(controlplaneregistry.AdmissionInput{
213 + RawLeaseID: rawLeaseID,
214 + RawReverseToken: rawToken,
215 + ClientIP: clientIP,
216 + IsClientIPBanned: policy.IsIPBannedByPolicy(r.ipManager, clientIP),
217 + RequireExisting: requireExistingLease,
218 + ConnectionTLSState: req.TLS,
219 + })
220 + if !writeRegistryError(w, apiErr) {
221 + return controlplaneregistry.AdmissionResult{}, false
222 }
267 -
268 - return leaseID, token, clientIP, entry, true
223 + return admission, true
224 }
225
271 -func (r *SDKRegistry) extractClientIP(req *http.Request) string {
272 - return manager.ExtractClientIP(req, r.trustProxyHeaders)
273 -}
274 -
275 -func (r *SDKRegistry) isClientIPBanned(clientIP string) bool {
276 - return manager.IsIPBannedByPolicy(r.ipManager, clientIP)
226 +func (r *SDKRegistry) newService(serv *portal.RelayServer) (*controlplaneregistry.Service, error) {
227 + if serv == nil {
228 + return nil, errRegistryBackendUnavailable
229 + }
230 + return controlplaneregistry.NewService(
231 + newRelayRegistryBackend(serv),
232 + controlplaneregistry.Options{
233 + LeaseTTL: controlplaneregistry.DefaultLeaseTTL,
234 + },
235 + )
236 }
237
238 func (r *SDKRegistry) requireMethod(w http.ResponseWriter, req *http.Request, method string) bool {
@@ -295,14 +254,86 @@ func (r *SDKRegistry) decodeRequestBody(w http.ResponseWriter, req *http.Request
254 return true
255 }
256
298 -func (r *SDKRegistry) validateLeaseCredentials(w http.ResponseWriter, leaseID, reverseToken string) bool {
299 - if leaseID == "" {
300 - writeAPIError(w, http.StatusBadRequest, "missing_lease_id", "lease_id is required")
257 +func writeRegistryError(w http.ResponseWriter, apiErr *controlplaneregistry.APIError) bool {
258 + if apiErr == nil {
259 + return true
260 + }
261 + writeAPIError(w, apiErr.StatusCode, apiErr.Code, apiErr.Message)
262 + return false
263 +}
264 +
265 +type relayRegistryBackend struct {
266 + serv *portal.RelayServer
267 +}
268 +
269 +func newRelayRegistryBackend(serv *portal.RelayServer) *relayRegistryBackend {
270 + return &relayRegistryBackend{serv: serv}
271 +}
272 +
273 +func (b *relayRegistryBackend) BaseHost() string {
274 + if b.serv == nil {
275 + return ""
276 + }
277 + return b.serv.BaseHost
278 +}
279 +
280 +func (b *relayRegistryBackend) UpdateLease(lease *portal.Lease) bool {
281 + if b.serv == nil || b.serv.GetLeaseManager() == nil {
282 return false
283 }
303 - if reverseToken == "" {
304 - writeAPIError(w, http.StatusBadRequest, "missing_reverse_token", "reverse_token is required")
284 + return b.serv.GetLeaseManager().UpdateLease(lease)
285 +}
286 +
287 +func (b *relayRegistryBackend) DeleteLease(leaseID string) bool {
288 + if b.serv == nil || b.serv.GetLeaseManager() == nil {
289 return false
290 }
307 - return true
291 + return b.serv.GetLeaseManager().DeleteLease(leaseID)
292 +}
293 +
294 +func (b *relayRegistryBackend) GetLeaseByID(leaseID string) (*portal.LeaseEntry, bool) {
295 + if b.serv == nil || b.serv.GetLeaseManager() == nil {
296 + return nil, false
297 + }
298 + return b.serv.GetLeaseManager().GetLeaseByID(leaseID)
299 +}
300 +
301 +func (b *relayRegistryBackend) ClearDropped(leaseID string) {
302 + if b.serv == nil || b.serv.GetReverseHub() == nil {
303 + return
304 + }
305 + b.serv.GetReverseHub().ClearDropped(leaseID)
306 +}
307 +
308 +func (b *relayRegistryBackend) DropLease(leaseID string) {
309 + if b.serv == nil || b.serv.GetReverseHub() == nil {
310 + return
311 + }
312 + b.serv.GetReverseHub().DropLease(leaseID)
313 +}
314 +
315 +func (b *relayRegistryBackend) RegisterRoute(sniName, leaseID, name string) error {
316 + if b.serv == nil || b.serv.GetSNIRouter() == nil {
317 + return errRegistryBackendUnavailable
318 + }
319 + return b.serv.GetSNIRouter().RegisterRoute(sniName, leaseID, name)
320 +}
321 +
322 +func (b *relayRegistryBackend) UnregisterRouteByLeaseID(leaseID string) {
323 + if b.serv == nil || b.serv.GetSNIRouter() == nil {
324 + return
325 + }
326 + b.serv.GetSNIRouter().UnregisterRouteByLeaseID(leaseID)
327 +}
328 +
329 +func (b *relayRegistryBackend) HandleConnect(conn net.Conn, leaseID, token, clientIP string) {
330 + if b.serv == nil || b.serv.GetReverseHub() == nil {
331 + if conn != nil {
332 + if err := conn.Close(); err != nil {
333 + log.Debug().Err(err).Msg("[Registry] failed to close reverse connection after backend lookup failure")
334 + }
335 + }
336 + return
337 + }
338 + b.serv.GetReverseHub().HandleConnect(conn, leaseID, token, clientIP)
339 }
cmd/relay-server/serve.go
+15 -14
@@ -15,10 +15,11 @@ import (
15
16 "github.com/rs/zerolog/log"
17
18 - "gosuda.org/portal/cmd/relay-server/manager"
18 "gosuda.org/portal/portal"
19 + "gosuda.org/portal/portal/contracts"
20 "gosuda.org/portal/portal/keyless"
21 - "gosuda.org/portal/types"
21 + "gosuda.org/portal/portal/netutil"
22 + "gosuda.org/portal/portal/policy"
23 )
24
25 const defaultHTTPSPort = "443"
@@ -41,26 +42,26 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
42 frontend.ServeAsset(appMux, "/favicon.svg", "favicon.svg", "image/svg+xml")
43
44 // Portal app assets (JS, CSS, etc.) - served from /app/
44 - appMux.HandleFunc(types.PathAppPrefix, func(w http.ResponseWriter, r *http.Request) {
45 + appMux.HandleFunc(contracts.PathAppPrefix, func(w http.ResponseWriter, r *http.Request) {
46 setCORSHeaders(w)
47 if r.Method == http.MethodOptions {
48 w.WriteHeader(http.StatusOK)
49 return
50 }
50 - p := strings.TrimPrefix(r.URL.Path, types.PathAppPrefix)
51 + p := strings.TrimPrefix(r.URL.Path, contracts.PathAppPrefix)
52 frontend.ServeAppStatic(w, r, p, serv)
53 })
54
55 // Tunnel installer script and binaries
55 - appMux.HandleFunc(types.PathTunnelScript, func(w http.ResponseWriter, r *http.Request) {
56 + appMux.HandleFunc(contracts.PathTunnelScript, func(w http.ResponseWriter, r *http.Request) {
57 serveTunnelScript(w, r, cfg.PortalURL)
58 })
58 - appMux.HandleFunc(types.PathTunnelBinary, func(w http.ResponseWriter, r *http.Request) {
59 + appMux.HandleFunc(contracts.PathTunnelBinary, func(w http.ResponseWriter, r *http.Request) {
60 serveTunnelBinary(w, r)
61 })
62
63 // SDK registry API for /sdk/* endpoints
63 - var sdkIPManager *manager.IPManager
64 + var sdkIPManager *policy.IPFilter
65 if admin != nil {
66 sdkIPManager = admin.GetIPManager()
67 }
@@ -69,12 +70,12 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
70 portalURL: cfg.PortalURL,
71 trustProxyHeaders: cfg.TrustProxyHeaders,
72 }
72 - appMux.HandleFunc(types.PathSDKPrefix, func(w http.ResponseWriter, r *http.Request) {
73 + appMux.HandleFunc(contracts.PathSDKPrefix, func(w http.ResponseWriter, r *http.Request) {
74 registry.HandleSDKRequest(w, r, serv)
75 })
76
77 // Keyless signer endpoint.
77 - appMux.HandleFunc(types.PathKeylessSign, func(w http.ResponseWriter, r *http.Request) {
78 + appMux.HandleFunc(contracts.PathKeylessSign, func(w http.ResponseWriter, r *http.Request) {
79 handleKeylessSign(w, r, serv.GetKeylessSigner())
80 })
81
@@ -85,7 +86,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
86 frontend.ServeAppStatic(w, r, p, serv)
87 })
88
88 - appMux.HandleFunc(types.PathHealthz, func(w http.ResponseWriter, _ *http.Request) {
89 + appMux.HandleFunc(contracts.PathHealthz, func(w http.ResponseWriter, _ *http.Request) {
90 w.WriteHeader(http.StatusOK)
91 if _, err := w.Write([]byte("{\"status\":\"ok\"}")); err != nil {
92 log.Debug().Err(err).Msg("[healthz] failed to write response")
@@ -93,15 +94,15 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
94 })
95
96 // Admin API
96 - appMux.HandleFunc(types.PathAdminPrefix+"/", func(w http.ResponseWriter, r *http.Request) {
97 + appMux.HandleFunc(contracts.PathAdminPrefix+"/", func(w http.ResponseWriter, r *http.Request) {
98 admin.HandleAdminRequest(w, r, serv)
99 })
100
101 // Create the main handler
101 - appDomain := types.DefaultAppPattern(cfg.PortalURL)
102 + appDomain := netutil.DefaultAppPattern(cfg.PortalURL)
103 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104 // Handle subdomain requests
104 - if types.IsSubdomain(appDomain, r.Host) {
105 + if netutil.IsSubdomain(appDomain, r.Host) {
106 log.Debug().
107 Str("host", r.Host).
108 Str("url", r.URL.String()).
@@ -128,7 +129,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
129 TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
130 }
131 acmeManager := serv.GetACMEManager()
131 - rootHost := types.PortalRootHost(cfg.PortalURL)
132 + rootHost := netutil.PortalRootHost(cfg.PortalURL)
133 srv.TLSConfig = &tls.Config{
134 ClientAuth: tls.RequestClientCert,
135 GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
cmd/relay-server/utils.go
+14 -32
@@ -10,10 +10,11 @@ import (
10
11 "github.com/rs/zerolog/log"
12
13 - "gosuda.org/portal/cmd/relay-server/manager"
13 "gosuda.org/portal/portal"
14 + "gosuda.org/portal/portal/contracts"
15 "gosuda.org/portal/portal/keyless"
16 - "gosuda.org/portal/types"
16 + "gosuda.org/portal/portal/netutil"
17 + "gosuda.org/portal/portal/policy"
18 )
19
20 const (
@@ -28,7 +29,7 @@ func isSecureRequestWithPolicy(r *http.Request, trustProxyHeaders bool) bool {
29 if r.TLS != nil {
30 return true
31 }
31 - if !trustProxyHeaders || !manager.IsTrustedProxyRemoteAddr(r.RemoteAddr) {
32 + if !trustProxyHeaders || !policy.IsTrustedProxyRemoteAddr(r.RemoteAddr) {
33 return false
34 }
35 if hasForwardedToken(r.Header.Get("X-Forwarded-Proto"), "https") {
@@ -46,25 +47,6 @@ func hasForwardedToken(raw, target string) bool {
47 return false
48 }
49
49 -func normalizeLeaseID(raw string) string {
50 - return strings.TrimSpace(raw)
51 -}
52 -
53 -func normalizeLeaseCredentials(leaseID, reverseToken string) (string, string) {
54 - return normalizeLeaseID(leaseID), strings.TrimSpace(reverseToken)
55 -}
56 -
57 -func lookupLeaseEntry(serv *portal.RelayServer, leaseID string) (*portal.LeaseEntry, bool) {
58 - if serv == nil {
59 - return nil, false
60 - }
61 - entry, ok := serv.GetLeaseManager().GetLeaseByID(normalizeLeaseID(leaseID))
62 - if !ok || entry == nil || entry.Lease == nil {
63 - return nil, false
64 - }
65 - return entry, true
66 -}
67 -
50 func isWebSocketUpgrade(req *http.Request) bool {
51 if req == nil {
52 return false
@@ -213,15 +195,15 @@ func (r *leaseRow) fromLeaseEntry(entry *portal.LeaseEntry, admin *Admin, portal
195 r.FirstSeenISO = entry.FirstSeen.UTC().Format(time.RFC3339)
196 r.TTL = r.formatDuration(time.Until(entry.Expires))
197 linkLabel := identityID
216 - if normalized, ok := types.NormalizeServiceName(lease.Name); ok {
198 + if normalized, ok := netutil.NormalizeServiceName(lease.Name); ok {
199 linkLabel = normalized
218 - } else if normalized, ok := types.NormalizeServiceName(identityID); ok {
200 + } else if normalized, ok := netutil.NormalizeServiceName(identityID); ok {
201 linkLabel = normalized
202 }
203
222 - publicHost := types.PortalRootHost(portalURL)
204 + publicHost := netutil.PortalRootHost(portalURL)
205 if publicHost == "" {
224 - publicHost = types.PortalHostPort(portalURL)
206 + publicHost = netutil.PortalHostPort(portalURL)
207 }
208 if linkLabel != "" && publicHost != "" {
209 r.Link = fmt.Sprintf("//%s.%s/", linkLabel, publicHost)
@@ -234,7 +216,7 @@ func (r *leaseRow) fromLeaseEntry(entry *portal.LeaseEntry, admin *Admin, portal
216 r.BPS = bps
217
218 if admin != nil {
237 - r.IsApproved = admin.approveManager.GetApprovalMode() == manager.ApprovalModeAuto || admin.approveManager.IsLeaseApproved(identityID)
219 + r.IsApproved = admin.approveManager.GetApprovalMode() == policy.ModeAuto || admin.approveManager.IsLeaseApproved(identityID)
220 r.IsDenied = admin.approveManager.IsLeaseDenied(identityID)
221
222 if admin.ipManager != nil {
@@ -274,7 +256,7 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin, forAdmin
256 }
257 if admin != nil {
258 approveManager := admin.GetApproveManager()
277 - if approveManager.GetApprovalMode() == manager.ApprovalModeManual && !approveManager.IsLeaseApproved(identityID) {
259 + if approveManager.GetApprovalMode() == policy.ModeManual && !approveManager.IsLeaseApproved(identityID) {
260 continue
261 }
262 }
@@ -299,7 +281,7 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin, forAdmin
281 func writeAPIData(w http.ResponseWriter, status int, data any) {
282 w.Header().Set("Content-Type", "application/json")
283 w.WriteHeader(status)
302 - if err := json.NewEncoder(w).Encode(types.APIEnvelope{
284 + if err := json.NewEncoder(w).Encode(contracts.APIEnvelope{
285 OK: true,
286 Data: data,
287 }); err != nil {
@@ -310,7 +292,7 @@ func writeAPIData(w http.ResponseWriter, status int, data any) {
292 func writeAPIOK(w http.ResponseWriter, status int) {
293 w.Header().Set("Content-Type", "application/json")
294 w.WriteHeader(status)
313 - if err := json.NewEncoder(w).Encode(types.APIEnvelope{OK: true}); err != nil {
295 + if err := json.NewEncoder(w).Encode(contracts.APIEnvelope{OK: true}); err != nil {
296 log.Error().Err(err).Msg("[HTTP] Failed to encode API success response")
297 }
298 }
@@ -322,10 +304,10 @@ func writeAPIError(w http.ResponseWriter, status int, code, message string) {
304 func writeAPIErrorWithData(w http.ResponseWriter, status int, code, message string, data any) {
305 w.Header().Set("Content-Type", "application/json")
306 w.WriteHeader(status)
325 - if err := json.NewEncoder(w).Encode(types.APIEnvelope{
307 + if err := json.NewEncoder(w).Encode(contracts.APIEnvelope{
308 OK: false,
309 Data: data,
328 - Error: &types.APIError{
310 + Error: &contracts.APIError{
311 Code: code,
312 Message: message,
313 },
frontend/.gitignore renamed
frontend/AGENTS.md renamed
frontend/CLAUDE.md renamed
frontend/README.md renamed
+12 -8
@@ -10,6 +10,8 @@ React + TypeScript frontend for relay server discovery and onboarding.
10 - Tailwind CSS 4
11 - shadcn/ui (Radix-based)
12 - Lucide React
13 +- @ssgoi/react (page transitions)
14 +- React Compiler (`babel-plugin-react-compiler`, enabled in `vite.config.ts`) — do not use `useCallback` in new code
15
16 ## Project Structure
17
@@ -38,6 +40,7 @@ frontend/
40 │ ├── pages/
41 │ │ ├── Admin.tsx # Admin area shell
42 │ │ ├── AdminLogin.tsx # Login flow UI
43 +│ │ ├── ServerDetail.tsx # Server detail view with page transition
44 │ │ └── ServerList.tsx # Listing pages and route assembly
45 │ ├── App.tsx
46 │ ├── main.tsx
@@ -75,7 +78,7 @@ frontend/
78 ### Install
79
80 ```bash
78 -cd cmd/relay-server/frontend
81 +cd frontend
82 npm install
83 ```
84
@@ -143,19 +146,20 @@ The relay enforces a consistent anti-abuse gate for both control APIs and revers
146 ### Run with Relay Server
147
148 ```bash
146 -# Build frontend
147 -cd cmd/relay-server/frontend
149 +# Build frontend (output: ../cmd/relay-server/dist/app/)
150 +cd frontend
151 npm run build
152
150 -# Run relay server
151 -cd ../../..
152 -go run cmd/relay-server/*.go -adminport 4017
153 +# Run relay server (embeds dist/ at compile time)
154 +cd ..
155 +go run ./cmd/relay-server/*.go -adminport 4017
156 ```
157
155 -Or with explicit static directory:
158 +Or use the combined script:
159
160 ```bash
158 -STATIC_DIR=./dist go run cmd/relay-server/*.go -adminport 4017
161 +cd frontend
162 +npm run serve
163 ```
164
165 ## Technical Notes
frontend/components.json renamed
frontend/eslint.config.mjs renamed
frontend/index.html renamed
frontend/package-lock.json renamed
frontend/package.json renamed
+2 -2
@@ -13,8 +13,8 @@
13 "test:watch": "vitest",
14 "test:coverage": "vitest run --coverage",
15 "preview": "vite preview",
16 - "build:go": "cd ../../.. && CGO_ENABLED=0 go build -o bin/relay-server cmd/relay-server/*.go",
17 - "serve": "npm run build && npm run build:go && STATIC_DIR=../../../dist ../../../bin/relay-server -adminport 4017"
16 + "build:go": "cd .. && CGO_ENABLED=0 go build -o bin/relay-server ./cmd/relay-server/*.go",
17 + "serve": "npm run build && npm run build:go && STATIC_DIR=./cmd/relay-server/dist ../bin/relay-server -adminport 4017"
18 },
19 "dependencies": {
20 "@radix-ui/react-dialog": "^1.1.15",
frontend/public/apple-touch-icon.png renamed
frontend/public/favicon-96x96.png renamed
frontend/public/favicon.ico renamed
frontend/public/favicon.svg renamed
frontend/public/portal.jpg renamed
frontend/public/web-app-manifest-192x192.png renamed
frontend/public/web-app-manifest-512x512.png renamed
frontend/src/App.tsx renamed
frontend/src/components/CloseIcon.tsx renamed
frontend/src/components/FloatingActionBar.tsx renamed
frontend/src/components/Header.tsx renamed
frontend/src/components/SearchBar.tsx renamed
frontend/src/components/ServerCard.tsx renamed
frontend/src/components/ServerListView.tsx renamed
frontend/src/components/TagCombobox.tsx renamed
frontend/src/components/TunnelCommandModal.tsx renamed
frontend/src/components/button/ApprovalModeToggle.tsx renamed
frontend/src/components/button/BanStatusButtons.tsx renamed
frontend/src/components/select/SortbySelect.tsx renamed
frontend/src/components/select/StatusSelect.tsx renamed
frontend/src/components/ui/button.tsx renamed
frontend/src/components/ui/dialog.tsx renamed
frontend/src/components/ui/input.tsx renamed
frontend/src/components/ui/scroll-area.tsx renamed
frontend/src/components/ui/select.tsx renamed
frontend/src/components/ui/tooltip.tsx renamed
frontend/src/hooks/useAdmin.test.ts renamed
frontend/src/hooks/useAdmin.ts renamed
frontend/src/hooks/useAuth.ts renamed
frontend/src/hooks/useList.ts renamed
frontend/src/hooks/useSSRData.ts renamed
frontend/src/hooks/useServerList.ts renamed
frontend/src/index.css renamed
frontend/src/lib/apiClient.test.ts renamed
frontend/src/lib/apiClient.ts renamed
frontend/src/lib/apiPaths.test.ts renamed
frontend/src/lib/apiPaths.ts renamed
frontend/src/lib/metadata.ts renamed
frontend/src/lib/testUtils.ts renamed
frontend/src/lib/utils.ts renamed
frontend/src/main.tsx renamed
frontend/src/pages/Admin.tsx renamed
frontend/src/pages/AdminLogin.tsx renamed
frontend/src/pages/ServerDetail.tsx renamed
frontend/src/pages/ServerList.tsx renamed
frontend/src/test/setup.ts renamed
frontend/src/types/filters.ts renamed
frontend/src/vite-env.d.ts renamed
frontend/tsconfig.json renamed
frontend/tsconfig.node.json renamed
frontend/update-shadcn.cjs renamed
frontend/vite.config.ts renamed
+2 -2
@@ -22,7 +22,7 @@ export default defineConfig({
22 return;
23 }
24
25 - const appDir = resolve(process.cwd(), "../dist/app");
25 + const appDir = resolve(process.cwd(), "../cmd/relay-server/dist/app");
26 const indexPath = resolve(appDir, "index.html");
27 const portalPath = resolve(appDir, "portal.html");
28
@@ -45,7 +45,7 @@ export default defineConfig({
45 },
46 },
47 build: {
48 - outDir: "../dist/app",
48 + outDir: "../cmd/relay-server/dist/app",
49 emptyOutDir: false,
50 rollupOptions: {
51 output: {
go.mod
+2
@@ -9,6 +9,8 @@ require (
9 golang.org/x/net v0.51.0
10 )
11
12 +replace github.com/gosuda/keyless_tls => ./keyless_tls
13 +
14 require (
15 github.com/cenkalti/backoff/v5 v5.0.3 // indirect
16 github.com/go-jose/go-jose/v4 v4.1.3 // indirect
go.sum
-4
@@ -10,10 +10,6 @@ github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
10 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
11 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
12 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
13 -github.com/gosuda/keyless_tls v0.0.0 h1:hDFeF3JGo/xHO5NzsvhAdhYwW1YmWUC449fC1YOoGe4=
14 -github.com/gosuda/keyless_tls v0.0.0/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
15 -github.com/gosuda/keyless_tls v0.0.1-0.20260227054723-d699441f3834 h1:P+oEVDMGuhFxU3pBMvStOPbDLin8GO9pv7+iA/xaYMs=
16 -github.com/gosuda/keyless_tls v0.0.1-0.20260227054723-d699441f3834/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
13 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
14 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
15 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
portal/admin/handler.go new
+542
@@ -0,0 +1,542 @@
1 +package admin
2 +
3 +import (
4 + "encoding/base64"
5 + "encoding/json"
6 + "net/http"
7 + "strings"
8 +
9 + "github.com/rs/zerolog/log"
10 +
11 + "gosuda.org/portal/portal"
12 + "gosuda.org/portal/portal/policy"
13 + "gosuda.org/portal/types"
14 +)
15 +
16 +type ServeAppStaticFunc func(http.ResponseWriter, *http.Request, string, *portal.RelayServer)
17 +type ListLeasesFunc func(*portal.RelayServer) any
18 +type StatsFunc func(*portal.RelayServer) map[string]any
19 +type DecodeLeaseIDFunc func(string) (string, bool)
20 +type SecureRequestFunc func(*http.Request, bool) bool
21 +type WriteAPIDataFunc func(http.ResponseWriter, int, any)
22 +type WriteAPIOKFunc func(http.ResponseWriter, int)
23 +type WriteAPIErrorFunc func(http.ResponseWriter, int, string, string)
24 +type WriteAPIErrorWithDataFunc func(http.ResponseWriter, int, string, string, any)
25 +
26 +type HandlerConfig struct {
27 + Service *Service
28 + ServeAppStatic ServeAppStaticFunc
29 + ListLeases ListLeasesFunc
30 + Stats StatsFunc
31 + DecodeLeaseID DecodeLeaseIDFunc
32 + IsSecureRequest SecureRequestFunc
33 + WriteAPIData WriteAPIDataFunc
34 + WriteAPIOK WriteAPIOKFunc
35 + WriteAPIError WriteAPIErrorFunc
36 + WriteAPIErrorWithData WriteAPIErrorWithDataFunc
37 + TrustProxy bool
38 +}
39 +
40 +// Handler routes /admin/* HTTP requests and delegates policy mutations to Service.
41 +type Handler struct {
42 + service *Service
43 + serveAppStatic ServeAppStaticFunc
44 + listLeases ListLeasesFunc
45 + stats StatsFunc
46 + decodeLeaseID DecodeLeaseIDFunc
47 + isSecureRequest SecureRequestFunc
48 + writeAPIData WriteAPIDataFunc
49 + writeAPIOK WriteAPIOKFunc
50 + writeAPIError WriteAPIErrorFunc
51 + writeAPIErrorWithData WriteAPIErrorWithDataFunc
52 + trustProxy bool
53 +}
54 +
55 +func NewHandler(cfg HandlerConfig) *Handler {
56 + h := &Handler{
57 + service: cfg.Service,
58 + trustProxy: cfg.TrustProxy,
59 + serveAppStatic: cfg.ServeAppStatic,
60 + listLeases: cfg.ListLeases,
61 + stats: cfg.Stats,
62 + decodeLeaseID: cfg.DecodeLeaseID,
63 + isSecureRequest: cfg.IsSecureRequest,
64 + writeAPIData: cfg.WriteAPIData,
65 + writeAPIOK: cfg.WriteAPIOK,
66 + writeAPIError: cfg.WriteAPIError,
67 + writeAPIErrorWithData: cfg.WriteAPIErrorWithData,
68 + }
69 +
70 + if h.serveAppStatic == nil {
71 + h.serveAppStatic = func(w http.ResponseWriter, r *http.Request, _ string, _ *portal.RelayServer) {
72 + http.NotFound(w, r)
73 + }
74 + }
75 + if h.listLeases == nil {
76 + h.listLeases = func(_ *portal.RelayServer) any { return []any{} }
77 + }
78 + if h.stats == nil {
79 + h.stats = func(serv *portal.RelayServer) map[string]any {
80 + count := 0
81 + if serv != nil && serv.GetLeaseManager() != nil {
82 + count = len(serv.GetLeaseManager().GetAllLeaseEntries())
83 + }
84 + return map[string]any{
85 + "leases_count": count,
86 + "uptime": "TODO",
87 + }
88 + }
89 + }
90 + if h.decodeLeaseID == nil {
91 + h.decodeLeaseID = decodeLeaseIDFallback
92 + }
93 + if h.isSecureRequest == nil {
94 + h.isSecureRequest = func(r *http.Request, _ bool) bool {
95 + return r != nil && r.TLS != nil
96 + }
97 + }
98 + if h.writeAPIData == nil {
99 + h.writeAPIData = func(w http.ResponseWriter, status int, data any) {
100 + writeDefaultEnvelope(w, status, types.APIEnvelope{OK: true, Data: data})
101 + }
102 + }
103 + if h.writeAPIOK == nil {
104 + h.writeAPIOK = func(w http.ResponseWriter, status int) {
105 + writeDefaultEnvelope(w, status, types.APIEnvelope{OK: true})
106 + }
107 + }
108 + if h.writeAPIError == nil {
109 + h.writeAPIError = func(w http.ResponseWriter, status int, code, message string) {
110 + writeDefaultEnvelope(w, status, types.APIEnvelope{
111 + OK: false,
112 + Error: &types.APIError{
113 + Code: code,
114 + Message: message,
115 + },
116 + })
117 + }
118 + }
119 + if h.writeAPIErrorWithData == nil {
120 + h.writeAPIErrorWithData = func(w http.ResponseWriter, status int, code, message string, data any) {
121 + writeDefaultEnvelope(w, status, types.APIEnvelope{
122 + OK: false,
123 + Data: data,
124 + Error: &types.APIError{
125 + Code: code,
126 + Message: message,
127 + },
128 + })
129 + }
130 + }
131 +
132 + return h
133 +}
134 +
135 +func writeDefaultEnvelope(w http.ResponseWriter, status int, envelope types.APIEnvelope) {
136 + w.Header().Set("Content-Type", "application/json")
137 + w.WriteHeader(status)
138 + if err := json.NewEncoder(w).Encode(envelope); err != nil {
139 + log.Error().Err(err).Msg("[Admin] Failed to encode API envelope")
140 + }
141 +}
142 +
143 +// HandleAdminRequest routes /admin/* requests.
144 +func (h *Handler) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
145 + if h.service == nil {
146 + h.writeAPIError(w, http.StatusInternalServerError, "admin_service_unavailable", "admin service unavailable")
147 + return
148 + }
149 +
150 + route := strings.Trim(strings.TrimPrefix(r.URL.Path, types.PathAdminPrefix), "/")
151 +
152 + // Public routes (no authentication required)
153 + switch {
154 + case route == "login" && r.Method == http.MethodPost:
155 + h.handleLogin(w, r)
156 + return
157 + case route == "login":
158 + h.serveAppStatic(w, r, "", serv)
159 + return
160 + case route == "logout" && r.Method == http.MethodPost:
161 + h.handleLogout(w, r)
162 + return
163 + case route == "auth/status" && r.Method == http.MethodGet:
164 + h.handleAuthStatus(w, r)
165 + return
166 + }
167 +
168 + // Protected routes - require authentication
169 + if !h.service.IsAuthenticated(r) {
170 + // For page requests (no specific route), show login page.
171 + if route == "" {
172 + h.serveAppStatic(w, r, "", serv)
173 + return
174 + }
175 + // For API requests, return 401 envelope.
176 + h.writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
177 + return
178 + }
179 +
180 + switch {
181 + case route == "":
182 + h.serveAppStatic(w, r, "", serv)
183 + case route == "leases" && r.Method == http.MethodGet:
184 + h.writeAPIData(w, http.StatusOK, h.listLeases(serv))
185 + case route == "leases/banned" && r.Method == http.MethodGet:
186 + h.writeAPIData(w, http.StatusOK, serv.GetLeaseManager().GetBannedLeases())
187 + case route == "stats" && r.Method == http.MethodGet:
188 + h.writeAPIData(w, http.StatusOK, h.stats(serv))
189 + case route == "settings" && r.Method == http.MethodGet:
190 + h.handleGetSettings(w)
191 + case route == "settings/approval-mode":
192 + h.handleApprovalModeRequest(w, r, serv)
193 + case strings.HasPrefix(route, "leases/"):
194 + if !h.handleLeaseActionRouteRequest(w, r, serv, route) {
195 + http.NotFound(w, r)
196 + }
197 + case strings.HasPrefix(route, "ips/") && strings.HasSuffix(route, "/ban"):
198 + h.handleIPBanRequest(w, r, serv, route)
199 + default:
200 + http.NotFound(w, r)
201 + }
202 +}
203 +
204 +func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
205 + authManager := h.service.GetAuthManager()
206 + clientIP := policy.ExtractClientIP(r, h.trustProxy)
207 +
208 + // Check if IP is locked.
209 + if authManager.IsIPLocked(clientIP) {
210 + remaining := authManager.GetLockRemainingSeconds(clientIP)
211 + h.writeAPIErrorWithData(
212 + w,
213 + http.StatusTooManyRequests,
214 + "auth_locked",
215 + "Too many failed attempts. Please try again later.",
216 + types.AdminLoginResponse{
217 + Locked: true,
218 + RemainingSeconds: remaining,
219 + },
220 + )
221 + return
222 + }
223 +
224 + var req types.AdminLoginRequest
225 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
226 + h.writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
227 + return
228 + }
229 +
230 + if !authManager.ValidateKey(req.Key) {
231 + // Record failed attempt.
232 + nowLocked := authManager.RecordFailedLogin(clientIP)
233 + log.Warn().Str("ip", clientIP).Bool("now_locked", nowLocked).Msg("[Admin] Failed login attempt")
234 +
235 + response := types.AdminLoginResponse{
236 + Locked: nowLocked,
237 + }
238 + if nowLocked {
239 + response.RemainingSeconds = authManager.GetLockRemainingSeconds(clientIP)
240 + }
241 + h.writeAPIErrorWithData(w, http.StatusUnauthorized, "invalid_key", "Invalid key", response)
242 + return
243 + }
244 +
245 + // Successful login.
246 + authManager.ResetFailedLogin(clientIP)
247 + token := authManager.CreateSession()
248 + secureCookie := h.isSecureRequest(r, h.trustProxy)
249 +
250 + http.SetCookie(w, &http.Cookie{
251 + Name: CookieName,
252 + Value: token,
253 + Path: "/admin",
254 + HttpOnly: true,
255 + Secure: secureCookie,
256 + SameSite: http.SameSiteStrictMode,
257 + MaxAge: 86400, // 24 hours
258 + })
259 +
260 + log.Info().Str("ip", clientIP).Msg("[Admin] Successful login")
261 + h.writeAPIData(w, http.StatusOK, types.AdminLoginResponse{Success: true})
262 +}
263 +
264 +func (h *Handler) handleApprovalModeRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
265 + approveManager := h.service.GetApproveManager()
266 + switch r.Method {
267 + case http.MethodGet:
268 + h.writeAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{
269 + ApprovalMode: string(approveManager.GetApprovalMode()),
270 + })
271 + case http.MethodPost:
272 + var req types.AdminApprovalModeRequest
273 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
274 + h.writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
275 + return
276 + }
277 + mode := policy.Mode(req.Mode)
278 + if mode != policy.ModeAuto && mode != policy.ModeManual {
279 + h.writeAPIError(w, http.StatusBadRequest, "invalid_mode", "invalid mode (must be 'auto' or 'manual')")
280 + return
281 + }
282 + approveManager.SetApprovalMode(mode)
283 + h.service.SaveSettings(serv)
284 + log.Info().Str("mode", string(mode)).Msg("[Admin] Approval mode changed")
285 + h.writeAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{
286 + ApprovalMode: string(mode),
287 + })
288 + default:
289 + h.writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
290 + }
291 +}
292 +
293 +func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
294 + authManager := h.service.GetAuthManager()
295 + cookie, err := r.Cookie(CookieName)
296 + if err == nil && cookie.Value != "" {
297 + authManager.DeleteSession(cookie.Value)
298 + }
299 + secureCookie := h.isSecureRequest(r, h.trustProxy)
300 +
301 + http.SetCookie(w, &http.Cookie{
302 + Name: CookieName,
303 + Value: "",
304 + Path: "/admin",
305 + HttpOnly: true,
306 + Secure: secureCookie,
307 + SameSite: http.SameSiteStrictMode,
308 + MaxAge: -1, // Delete cookie.
309 + })
310 +
311 + h.writeAPIOK(w, http.StatusOK)
312 +}
313 +
314 +func (h *Handler) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
315 + h.writeAPIData(w, http.StatusOK, types.AdminAuthStatusResponse{
316 + Authenticated: h.service.IsAuthenticated(r),
317 + AuthEnabled: h.service.AuthEnabled(),
318 + })
319 +}
320 +
321 +type leaseActionRouteStatus uint8
322 +
323 +const (
324 + leaseActionRouteNotFound leaseActionRouteStatus = iota
325 + leaseActionRouteInvalidLeaseID
326 + leaseActionRouteOK
327 +)
328 +
329 +func (h *Handler) parseLeaseActionRoute(route string) (leaseID, action string, status leaseActionRouteStatus) {
330 + parts := strings.Split(route, "/")
331 + if len(parts) != 3 || parts[0] != "leases" {
332 + return "", "", leaseActionRouteNotFound
333 + }
334 +
335 + action = parts[2]
336 + switch action {
337 + case "ban", "bps", "approve", "deny":
338 + default:
339 + return "", "", leaseActionRouteNotFound
340 + }
341 +
342 + leaseID, ok := h.decodeLeaseID(parts[1])
343 + if !ok {
344 + return "", action, leaseActionRouteInvalidLeaseID
345 + }
346 +
347 + return leaseID, action, leaseActionRouteOK
348 +}
349 +
350 +func (h *Handler) handleLeaseActionRouteRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) bool {
351 + leaseID, action, status := h.parseLeaseActionRoute(route)
352 + switch status {
353 + case leaseActionRouteNotFound:
354 + return false
355 + case leaseActionRouteInvalidLeaseID:
356 + h.writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
357 + return true
358 + }
359 +
360 + switch action {
361 + case "ban":
362 + h.handleLeaseBanRequest(w, r, serv, leaseID)
363 + case "bps":
364 + h.handleLeaseBPSRequest(w, r, serv, leaseID)
365 + case "approve":
366 + h.handleLeaseApproveRequest(w, r, serv, leaseID)
367 + case "deny":
368 + h.handleLeaseDenyRequest(w, r, serv, leaseID)
369 + default:
370 + return false
371 + }
372 +
373 + return true
374 +}
375 +
376 +func (h *Handler) handleLeaseBanRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
377 + if strings.TrimSpace(leaseID) == "" {
378 + h.writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
379 + return
380 + }
381 +
382 + switch r.Method {
383 + case http.MethodPost:
384 + serv.GetLeaseManager().BanLease(leaseID)
385 + h.service.SaveSettings(serv)
386 + h.writeAPIOK(w, http.StatusOK)
387 + case http.MethodDelete:
388 + serv.GetLeaseManager().UnbanLease(leaseID)
389 + h.service.SaveSettings(serv)
390 + h.writeAPIOK(w, http.StatusOK)
391 + default:
392 + h.writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
393 + }
394 +}
395 +
396 +func (h *Handler) handleGetSettings(w http.ResponseWriter) {
397 + approveManager := h.service.GetApproveManager()
398 + h.writeAPIData(w, http.StatusOK, types.AdminSettingsResponse{
399 + ApprovalMode: string(approveManager.GetApprovalMode()),
400 + ApprovedLeases: approveManager.GetApprovedLeases(),
401 + DeniedLeases: approveManager.GetDeniedLeases(),
402 + })
403 +}
404 +
405 +func (h *Handler) handleLeaseApproveRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
406 + if strings.TrimSpace(leaseID) == "" {
407 + h.writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
408 + return
409 + }
410 +
411 + approveManager := h.service.GetApproveManager()
412 + switch r.Method {
413 + case http.MethodPost:
414 + approveManager.ApproveLease(leaseID)
415 + approveManager.UndenyLease(leaseID) // Remove from denied if exists.
416 + h.service.SaveSettings(serv)
417 + log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease approved")
418 + h.writeAPIOK(w, http.StatusOK)
419 + case http.MethodDelete:
420 + approveManager.RevokeLease(leaseID)
421 + h.service.SaveSettings(serv)
422 + log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease approval revoked")
423 + h.writeAPIOK(w, http.StatusOK)
424 + default:
425 + h.writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
426 + }
427 +}
428 +
429 +func (h *Handler) handleLeaseDenyRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
430 + if strings.TrimSpace(leaseID) == "" {
431 + h.writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
432 + return
433 + }
434 +
435 + approveManager := h.service.GetApproveManager()
436 + switch r.Method {
437 + case http.MethodPost:
438 + approveManager.DenyLease(leaseID)
439 + h.service.SaveSettings(serv)
440 + log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease denied")
441 + h.writeAPIOK(w, http.StatusOK)
442 + case http.MethodDelete:
443 + approveManager.UndenyLease(leaseID)
444 + h.service.SaveSettings(serv)
445 + log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease denial removed")
446 + h.writeAPIOK(w, http.StatusOK)
447 + default:
448 + h.writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
449 + }
450 +}
451 +
452 +func (h *Handler) handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseID string) {
453 + if strings.TrimSpace(leaseID) == "" {
454 + h.writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
455 + return
456 + }
457 +
458 + bpsManager := h.service.GetBPSManager()
459 + switch r.Method {
460 + case http.MethodPost:
461 + var req types.AdminBPSRequest
462 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
463 + h.writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
464 + return
465 + }
466 + if bpsManager == nil {
467 + h.writeAPIError(w, http.StatusInternalServerError, "bps_manager_unavailable", "bps manager not initialized")
468 + return
469 + }
470 + oldBPS := bpsManager.GetBPSLimit(leaseID)
471 + bpsManager.SetBPSLimit(leaseID, req.BPS)
472 + log.Info().
473 + Str("lease_id", leaseID).
474 + Int64("old_bps", oldBPS).
475 + Int64("new_bps", req.BPS).
476 + Msg("[Admin] BPS limit updated")
477 + h.service.SaveSettings(serv)
478 + h.writeAPIOK(w, http.StatusOK)
479 + case http.MethodDelete:
480 + if bpsManager == nil {
481 + h.writeAPIError(w, http.StatusInternalServerError, "bps_manager_unavailable", "bps manager not initialized")
482 + return
483 + }
484 + oldBPS := bpsManager.GetBPSLimit(leaseID)
485 + bpsManager.SetBPSLimit(leaseID, 0)
486 + log.Info().
487 + Str("lease_id", leaseID).
488 + Int64("old_bps", oldBPS).
489 + Msg("[Admin] BPS limit removed (now unlimited)")
490 + h.service.SaveSettings(serv)
491 + h.writeAPIOK(w, http.StatusOK)
492 + default:
493 + h.writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
494 + }
495 +}
496 +
497 +func (h *Handler) handleIPBanRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
498 + // Route format: ips/{ip}/ban.
499 + parts := strings.Split(route, "/")
500 + if len(parts) != 3 {
501 + http.NotFound(w, r)
502 + return
503 + }
504 +
505 + ip := parts[1]
506 + if ip == "" {
507 + h.writeAPIError(w, http.StatusBadRequest, "invalid_ip", "invalid IP address")
508 + return
509 + }
510 +
511 + ipManager := h.service.GetIPManager()
512 + if ipManager == nil {
513 + h.writeAPIError(w, http.StatusInternalServerError, "ip_manager_unavailable", "ip manager not initialized")
514 + return
515 + }
516 +
517 + switch r.Method {
518 + case http.MethodPost:
519 + ipManager.BanIP(ip)
520 + h.service.SaveSettings(serv)
521 + log.Info().Str("ip", ip).Msg("[Admin] IP banned")
522 + h.writeAPIOK(w, http.StatusOK)
523 + case http.MethodDelete:
524 + ipManager.UnbanIP(ip)
525 + h.service.SaveSettings(serv)
526 + log.Info().Str("ip", ip).Msg("[Admin] IP unbanned")
527 + h.writeAPIOK(w, http.StatusOK)
528 + default:
529 + h.writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
530 + }
531 +}
532 +
533 +func decodeLeaseIDFallback(encoded string) (string, bool) {
534 + idBytes, err := base64.URLEncoding.DecodeString(encoded)
535 + if err != nil {
536 + idBytes, err = base64.RawURLEncoding.DecodeString(encoded)
537 + if err != nil {
538 + return "", false
539 + }
540 + }
541 + return string(idBytes), true
542 +}
portal/admin/handler_test.go new
+176
@@ -0,0 +1,176 @@
1 +package admin
2 +
3 +import (
4 + "encoding/json"
5 + "net/http"
6 + "net/http/httptest"
7 + "strings"
8 + "testing"
9 +
10 + "gosuda.org/portal/portal"
11 + "gosuda.org/portal/portal/policy"
12 + "gosuda.org/portal/types"
13 +)
14 +
15 +func TestHandleAdminRequestLoginSuccessSetsSessionCookie(t *testing.T) {
16 + service := NewService(0, policy.NewAuthenticator("test-secret"))
17 + handler := newTestHandler(t, service, true, nil)
18 +
19 + req := httptest.NewRequest(http.MethodPost, types.PathAdminPrefix+"/login", strings.NewReader(`{"key":"test-secret"}`))
20 + req.RemoteAddr = "203.0.113.10:1234"
21 + rec := httptest.NewRecorder()
22 +
23 + handler.HandleAdminRequest(rec, req, nil)
24 +
25 + if rec.Code != http.StatusOK {
26 + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
27 + }
28 + envelope := decodeEnvelope(t, rec)
29 + if !envelope.OK {
30 + t.Fatalf("expected OK response, got %+v", envelope)
31 + }
32 +
33 + cookies := rec.Result().Cookies()
34 + if len(cookies) == 0 {
35 + t.Fatalf("expected admin session cookie")
36 + }
37 + cookie := cookies[0]
38 + if cookie.Name != CookieName {
39 + t.Fatalf("expected cookie name %q, got %q", CookieName, cookie.Name)
40 + }
41 + if cookie.Path != "/admin" {
42 + t.Fatalf("expected cookie path /admin, got %q", cookie.Path)
43 + }
44 + if !cookie.HttpOnly {
45 + t.Fatalf("expected HttpOnly cookie")
46 + }
47 + if !cookie.Secure {
48 + t.Fatalf("expected Secure cookie")
49 + }
50 + if cookie.MaxAge != 86400 {
51 + t.Fatalf("expected MaxAge 86400, got %d", cookie.MaxAge)
52 + }
53 +}
54 +
55 +func TestHandleAdminRequestProtectedRouteUnauthorized(t *testing.T) {
56 + service := NewService(0, policy.NewAuthenticator("test-secret"))
57 + handler := newTestHandler(t, service, false, func(_ *portal.RelayServer) any {
58 + t.Fatalf("list leases should not be called for unauthorized request")
59 + return nil
60 + })
61 +
62 + req := httptest.NewRequest(http.MethodGet, types.PathAdminPrefix+"/leases", nil)
63 + rec := httptest.NewRecorder()
64 +
65 + handler.HandleAdminRequest(rec, req, nil)
66 +
67 + if rec.Code != http.StatusUnauthorized {
68 + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
69 + }
70 + envelope := decodeEnvelope(t, rec)
71 + if envelope.OK || envelope.Error == nil || envelope.Error.Code != "unauthorized" {
72 + t.Fatalf("expected unauthorized API error, got %+v", envelope)
73 + }
74 +}
75 +
76 +func TestHandleAdminRequestApprovalModeInvalidMode(t *testing.T) {
77 + service := NewService(0, policy.NewAuthenticator("test-secret"))
78 + handler := newTestHandler(t, service, false, nil)
79 + token := service.GetAuthManager().CreateSession()
80 +
81 + req := httptest.NewRequest(http.MethodPost, types.PathAdminPrefix+"/settings/approval-mode", strings.NewReader(`{"mode":"invalid"}`))
82 + req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
83 + rec := httptest.NewRecorder()
84 +
85 + handler.HandleAdminRequest(rec, req, nil)
86 +
87 + if rec.Code != http.StatusBadRequest {
88 + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
89 + }
90 + envelope := decodeEnvelope(t, rec)
91 + if envelope.OK || envelope.Error == nil || envelope.Error.Code != "invalid_mode" {
92 + t.Fatalf("expected invalid_mode API error, got %+v", envelope)
93 + }
94 +}
95 +
96 +func TestHandleAdminRequestLeaseActionInvalidLeaseID(t *testing.T) {
97 + service := NewService(0, policy.NewAuthenticator("test-secret"))
98 + handler := newTestHandler(t, service, false, nil)
99 + token := service.GetAuthManager().CreateSession()
100 +
101 + req := httptest.NewRequest(http.MethodPost, types.PathAdminPrefix+"/leases/not!base64/ban", nil)
102 + req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
103 + rec := httptest.NewRecorder()
104 +
105 + handler.HandleAdminRequest(rec, req, nil)
106 +
107 + if rec.Code != http.StatusBadRequest {
108 + t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
109 + }
110 + envelope := decodeEnvelope(t, rec)
111 + if envelope.OK || envelope.Error == nil || envelope.Error.Code != "invalid_lease_id" {
112 + t.Fatalf("expected invalid_lease_id API error, got %+v", envelope)
113 + }
114 +}
115 +
116 +func newTestHandler(t *testing.T, service *Service, secure bool, listLeases ListLeasesFunc) *Handler {
117 + t.Helper()
118 +
119 + if listLeases == nil {
120 + listLeases = func(_ *portal.RelayServer) any { return []any{} }
121 + }
122 +
123 + return NewHandler(HandlerConfig{
124 + Service: service,
125 + TrustProxy: false,
126 + ServeAppStatic: func(w http.ResponseWriter, _ *http.Request, _ string, _ *portal.RelayServer) {
127 + w.WriteHeader(http.StatusOK)
128 + },
129 + ListLeases: listLeases,
130 + IsSecureRequest: func(_ *http.Request, _ bool) bool {
131 + return secure
132 + },
133 + WriteAPIData: writeTestAPIData,
134 + WriteAPIOK: writeTestAPIOK,
135 + WriteAPIError: writeTestAPIError,
136 + WriteAPIErrorWithData: writeTestAPIErrorWithData,
137 + })
138 +}
139 +
140 +func decodeEnvelope(t *testing.T, rec *httptest.ResponseRecorder) types.APIEnvelope {
141 + t.Helper()
142 + var envelope types.APIEnvelope
143 + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
144 + t.Fatalf("failed to decode API envelope: %v", err)
145 + }
146 + return envelope
147 +}
148 +
149 +func writeTestAPIData(w http.ResponseWriter, status int, data any) {
150 + w.Header().Set("Content-Type", "application/json")
151 + w.WriteHeader(status)
152 + _ = json.NewEncoder(w).Encode(types.APIEnvelope{OK: true, Data: data})
153 +}
154 +
155 +func writeTestAPIOK(w http.ResponseWriter, status int) {
156 + w.Header().Set("Content-Type", "application/json")
157 + w.WriteHeader(status)
158 + _ = json.NewEncoder(w).Encode(types.APIEnvelope{OK: true})
159 +}
160 +
161 +func writeTestAPIError(w http.ResponseWriter, status int, code, message string) {
162 + writeTestAPIErrorWithData(w, status, code, message, nil)
163 +}
164 +
165 +func writeTestAPIErrorWithData(w http.ResponseWriter, status int, code, message string, data any) {
166 + w.Header().Set("Content-Type", "application/json")
167 + w.WriteHeader(status)
168 + _ = json.NewEncoder(w).Encode(types.APIEnvelope{
169 + OK: false,
170 + Data: data,
171 + Error: &types.APIError{
172 + Code: code,
173 + Message: message,
174 + },
175 + })
176 +}
portal/admin/service.go new
+232
@@ -0,0 +1,232 @@
1 +package admin
2 +
3 +import (
4 + "encoding/json"
5 + "net/http"
6 + "os"
7 + "path/filepath"
8 + "sync"
9 +
10 + "github.com/rs/zerolog/log"
11 +
12 + "gosuda.org/portal/portal"
13 + "gosuda.org/portal/portal/policy"
14 +)
15 +
16 +const CookieName = "portal_admin"
17 +
18 +// Service manages admin policy state and persistence.
19 +type Service struct {
20 + approveManager *policy.Approver
21 + bpsManager *policy.RateLimiter
22 + ipManager *policy.IPFilter
23 + authManager *policy.Authenticator
24 + settingsPath string
25 + settingsMu sync.Mutex
26 +}
27 +
28 +// settings stores persistent admin configuration.
29 +type settings struct {
30 + BannedLeases []string `json:"banned_leases"`
31 + BPSLimits map[string]int64 `json:"bps_limits"`
32 + ApprovalMode policy.Mode `json:"approval_mode"`
33 + ApprovedLeases []string `json:"approved_leases,omitempty"`
34 + DeniedLeases []string `json:"denied_leases,omitempty"`
35 + BannedIPs []string `json:"banned_ips,omitempty"`
36 +}
37 +
38 +func NewService(defaultLeaseBPS int64, authManager *policy.Authenticator) *Service {
39 + bpsManager := policy.NewRateLimiter()
40 + if defaultLeaseBPS > 0 {
41 + bpsManager.SetDefaultBPS(defaultLeaseBPS)
42 + }
43 +
44 + return &Service{
45 + settingsPath: "admin_settings.json",
46 + approveManager: policy.NewApprover(),
47 + bpsManager: bpsManager,
48 + ipManager: policy.NewIPFilter(),
49 + authManager: authManager,
50 + }
51 +}
52 +
53 +func (s *Service) isUnavailable() bool {
54 + return s == nil
55 +}
56 +
57 +func (s *Service) isUnavailableForServer(serv *portal.RelayServer) bool {
58 + return s == nil || serv == nil
59 +}
60 +
61 +func (s *Service) authUnavailable() bool {
62 + return s == nil || s.authManager == nil || !s.authManager.HasSecretKey()
63 +}
64 +
65 +func (s *Service) GetApproveManager() *policy.Approver {
66 + if s.isUnavailable() {
67 + return nil
68 + }
69 + return s.approveManager
70 +}
71 +
72 +func (s *Service) GetBPSManager() *policy.RateLimiter {
73 + if s.isUnavailable() {
74 + return nil
75 + }
76 + return s.bpsManager
77 +}
78 +
79 +func (s *Service) GetIPManager() *policy.IPFilter {
80 + if s.isUnavailable() {
81 + return nil
82 + }
83 + return s.ipManager
84 +}
85 +
86 +func (s *Service) GetAuthManager() *policy.Authenticator {
87 + if s.isUnavailable() {
88 + return nil
89 + }
90 + return s.authManager
91 +}
92 +
93 +func (s *Service) SetSettingsPath(path string) {
94 + if s.isUnavailable() {
95 + return
96 + }
97 + s.settingsMu.Lock()
98 + defer s.settingsMu.Unlock()
99 + s.settingsPath = path
100 +}
101 +
102 +func (s *Service) SaveSettings(serv *portal.RelayServer) {
103 + if s.isUnavailableForServer(serv) {
104 + return
105 + }
106 +
107 + s.settingsMu.Lock()
108 + defer s.settingsMu.Unlock()
109 +
110 + lm := serv.GetLeaseManager()
111 + banned := lm.GetBannedLeases()
112 +
113 + bpsLimits := map[string]int64{}
114 + if s.bpsManager != nil {
115 + bpsLimits = s.bpsManager.GetAllBPSLimits()
116 + }
117 +
118 + var bannedIPs []string
119 + if s.ipManager != nil {
120 + bannedIPs = s.ipManager.GetBannedIPs()
121 + }
122 +
123 + payload := settings{
124 + BannedLeases: banned,
125 + BPSLimits: bpsLimits,
126 + ApprovalMode: s.approveManager.GetApprovalMode(),
127 + ApprovedLeases: s.approveManager.GetApprovedLeases(),
128 + DeniedLeases: s.approveManager.GetDeniedLeases(),
129 + BannedIPs: bannedIPs,
130 + }
131 +
132 + data, err := json.MarshalIndent(payload, "", " ")
133 + if err != nil {
134 + log.Error().Err(err).Msg("[Admin] Failed to marshal admin settings")
135 + return
136 + }
137 +
138 + dir := filepath.Dir(s.settingsPath)
139 + if dir != "" && dir != "." {
140 + if err := os.MkdirAll(dir, 0755); err != nil {
141 + log.Error().Err(err).Msg("[Admin] Failed to create settings directory")
142 + return
143 + }
144 + }
145 +
146 + if err := os.WriteFile(s.settingsPath, data, 0644); err != nil {
147 + log.Error().Err(err).Msg("[Admin] Failed to save admin settings")
148 + return
149 + }
150 +
151 + log.Debug().Str("path", s.settingsPath).Msg("[Admin] Saved admin settings")
152 +}
153 +
154 +func (s *Service) LoadSettings(serv *portal.RelayServer) {
155 + if s.isUnavailableForServer(serv) {
156 + return
157 + }
158 +
159 + s.settingsMu.Lock()
160 + defer s.settingsMu.Unlock()
161 +
162 + data, err := os.ReadFile(s.settingsPath)
163 + if err != nil {
164 + if os.IsNotExist(err) {
165 + log.Debug().Msg("[Admin] No admin settings file found, starting fresh")
166 + return
167 + }
168 + log.Error().Err(err).Msg("[Admin] Failed to read admin settings")
169 + return
170 + }
171 +
172 + var payload settings
173 + if err := json.Unmarshal(data, &payload); err != nil {
174 + log.Error().Err(err).Msg("[Admin] Failed to parse admin settings")
175 + return
176 + }
177 +
178 + lm := serv.GetLeaseManager()
179 +
180 + for _, leaseID := range payload.BannedLeases {
181 + lm.BanLease(leaseID)
182 + }
183 +
184 + for leaseID, bps := range payload.BPSLimits {
185 + if s.bpsManager != nil {
186 + s.bpsManager.SetBPSLimit(leaseID, bps)
187 + }
188 + }
189 +
190 + if payload.ApprovalMode != "" {
191 + s.approveManager.SetApprovalMode(payload.ApprovalMode)
192 + }
193 +
194 + for _, leaseID := range payload.ApprovedLeases {
195 + s.approveManager.ApproveLease(leaseID)
196 + }
197 +
198 + for _, leaseID := range payload.DeniedLeases {
199 + s.approveManager.DenyLease(leaseID)
200 + }
201 +
202 + if s.ipManager != nil && len(payload.BannedIPs) > 0 {
203 + s.ipManager.SetBannedIPs(payload.BannedIPs)
204 + }
205 +
206 + log.Info().
207 + Int("banned_count", len(payload.BannedLeases)).
208 + Int("bps_limits_count", len(payload.BPSLimits)).
209 + Str("approval_mode", string(s.approveManager.GetApprovalMode())).
210 + Int("approved_count", len(payload.ApprovedLeases)).
211 + Int("denied_count", len(payload.DeniedLeases)).
212 + Int("banned_ips_count", len(payload.BannedIPs)).
213 + Msg("[Admin] Loaded admin settings")
214 +}
215 +
216 +// IsAuthenticated checks if the request has a valid admin session.
217 +func (s *Service) IsAuthenticated(r *http.Request) bool {
218 + if s.authUnavailable() {
219 + return false
220 + }
221 +
222 + cookie, err := r.Cookie(CookieName)
223 + if err != nil {
224 + return false
225 + }
226 +
227 + return s.authManager.ValidateSession(cookie.Value)
228 +}
229 +
230 +func (s *Service) AuthEnabled() bool {
231 + return !s.authUnavailable()
232 +}
portal/admin/service_test.go new
+73
@@ -0,0 +1,73 @@
1 +package admin
2 +
3 +import (
4 + "context"
5 + "path/filepath"
6 + "slices"
7 + "testing"
8 +
9 + "gosuda.org/portal/portal"
10 + "gosuda.org/portal/portal/policy"
11 +)
12 +
13 +func TestServiceSaveLoadSettingsRoundTrip(t *testing.T) {
14 + service := NewService(0, policy.NewAuthenticator("test-secret"))
15 + settingsPath := filepath.Join(t.TempDir(), "admin_settings.json")
16 + service.SetSettingsPath(settingsPath)
17 +
18 + sourceServer := mustNewTestRelayServer(t)
19 + sourceServer.GetLeaseManager().BanLease("lease-ban")
20 + service.GetBPSManager().SetBPSLimit("lease-bps", 4096)
21 + service.GetApproveManager().SetApprovalMode(policy.ModeManual)
22 + service.GetApproveManager().ApproveLease("lease-approved")
23 + service.GetApproveManager().DenyLease("lease-denied")
24 + service.GetIPManager().BanIP("203.0.113.10")
25 +
26 + service.SaveSettings(sourceServer)
27 +
28 + targetServer := mustNewTestRelayServer(t)
29 + loaded := NewService(0, policy.NewAuthenticator("test-secret"))
30 + loaded.SetSettingsPath(settingsPath)
31 + loaded.LoadSettings(targetServer)
32 +
33 + if !contains(targetServer.GetLeaseManager().GetBannedLeases(), "lease-ban") {
34 + t.Fatalf("expected banned lease to be restored")
35 + }
36 + if got := loaded.GetBPSManager().GetBPSLimit("lease-bps"); got != 4096 {
37 + t.Fatalf("expected BPS limit 4096, got %d", got)
38 + }
39 + if loaded.GetApproveManager().GetApprovalMode() != policy.ModeManual {
40 + t.Fatalf("expected approval mode manual")
41 + }
42 + if !loaded.GetApproveManager().IsLeaseApproved("lease-approved") {
43 + t.Fatalf("expected approved lease to be restored")
44 + }
45 + if !loaded.GetApproveManager().IsLeaseDenied("lease-denied") {
46 + t.Fatalf("expected denied lease to be restored")
47 + }
48 + if !loaded.GetIPManager().IsIPBanned("203.0.113.10") {
49 + t.Fatalf("expected banned IP to be restored")
50 + }
51 +}
52 +
53 +func mustNewTestRelayServer(t *testing.T) *portal.RelayServer {
54 + t.Helper()
55 +
56 + server, err := portal.NewRelayServer(
57 + context.Background(),
58 + nil,
59 + ":0",
60 + "localhost",
61 + t.TempDir(),
62 + "",
63 + )
64 + if err != nil {
65 + t.Fatalf("create relay server: %v", err)
66 + }
67 + t.Cleanup(server.Stop)
68 + return server
69 +}
70 +
71 +func contains(values []string, target string) bool {
72 + return slices.Contains(values, target)
73 +}
portal/contracts/contracts.go new
+58
@@ -0,0 +1,58 @@
1 +package contracts
2 +
3 +import "gosuda.org/portal/types"
4 +
5 +// API path constants for Portal relay server.
6 +const (
7 + PathSDKPrefix = types.PathSDKPrefix
8 + PathSDKRegister = types.PathSDKRegister
9 + PathSDKUnregister = types.PathSDKUnregister
10 + PathSDKRenew = types.PathSDKRenew
11 + PathSDKDomain = types.PathSDKDomain
12 + PathSDKConnect = types.PathSDKConnect
13 +
14 + PathAdminPrefix = types.PathAdminPrefix
15 + PathAdminLogin = types.PathAdminLogin
16 + PathAdminLogout = types.PathAdminLogout
17 + PathAdminAuthStatus = types.PathAdminAuthStatus
18 + PathAdminLeases = types.PathAdminLeases
19 + PathAdminLeasesBanned = types.PathAdminLeasesBanned
20 + PathAdminStats = types.PathAdminStats
21 + PathAdminSettings = types.PathAdminSettings
22 + PathAdminApprovalMode = types.PathAdminApprovalMode
23 +
24 + PathKeylessSign = types.PathKeylessSign
25 + PathHealthz = types.PathHealthz
26 +
27 + PathTunnelScript = types.PathTunnelScript
28 + PathTunnelBinary = types.PathTunnelBinary
29 +
30 + PathAppPrefix = types.PathAppPrefix
31 +)
32 +
33 +type (
34 + APIError = types.APIError
35 + APIEnvelope = types.APIEnvelope
36 + Metadata = types.Metadata
37 + MetadataOption = types.MetadataOption
38 +)
39 +
40 +func WithDescription(description string) MetadataOption {
41 + return types.WithDescription(description)
42 +}
43 +
44 +func WithTags(tags []string) MetadataOption {
45 + return types.WithTags(tags)
46 +}
47 +
48 +func WithThumbnail(thumbnail string) MetadataOption {
49 + return types.WithThumbnail(thumbnail)
50 +}
51 +
52 +func WithOwner(owner string) MetadataOption {
53 + return types.WithOwner(owner)
54 +}
55 +
56 +func WithHide(hide bool) MetadataOption {
57 + return types.WithHide(hide)
58 +}
portal/controlplane/controlplane_test.go
+75
@@ -107,6 +107,67 @@ func TestValidatePeerLeaseCertificateRequiresClientAuthEKU(t *testing.T) {
107 }
108 }
109
110 +func TestValidatePeerLeaseCertificateRejectsMissingLeaseID(t *testing.T) {
111 + t.Parallel()
112 +
113 + state := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{mustIssuedLeafCertificate(t, "lease-identity")}}
114 + if code, _, ok := ValidatePeerLeaseCertificate(state, " \t "); ok || code != "missing_lease_id" {
115 + t.Fatalf("expected missing_lease_id, got code=%s ok=%v", code, ok)
116 + }
117 +}
118 +
119 +func TestValidatePeerLeaseCertificateRequiresPeerCertificates(t *testing.T) {
120 + t.Parallel()
121 +
122 + if code, _, ok := ValidatePeerLeaseCertificate(nil, "lease-identity"); ok || code != "client_cert_required" {
123 + t.Fatalf("expected client_cert_required, got code=%s ok=%v", code, ok)
124 + }
125 +}
126 +
127 +func TestValidatePeerLeaseCertificateRejectsCertificateOutsideValidityWindow(t *testing.T) {
128 + t.Parallel()
129 +
130 + leaf := *mustIssuedLeafCertificate(t, "lease-identity")
131 + leaf.NotBefore = time.Now().Add(-2 * time.Minute)
132 + leaf.NotAfter = time.Now().Add(-1 * time.Minute)
133 + state := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{&leaf}}
134 + if code, _, ok := ValidatePeerLeaseCertificate(state, "lease-identity"); ok || code != "client_cert_invalid" {
135 + t.Fatalf("expected client_cert_invalid for expired certificate, got code=%s ok=%v", code, ok)
136 + }
137 +}
138 +
139 +func TestValidatePeerLeaseCertificateRejectsNonClientAuthEKU(t *testing.T) {
140 + t.Parallel()
141 +
142 + leaf := *mustIssuedLeafCertificate(t, "lease-identity")
143 + leaf.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
144 + state := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{&leaf}}
145 + if code, _, ok := ValidatePeerLeaseCertificate(state, "lease-identity"); ok || code != "client_cert_invalid" {
146 + t.Fatalf("expected client_cert_invalid for non-client-auth EKU, got code=%s ok=%v", code, ok)
147 + }
148 +}
149 +
150 +func TestValidatePeerLeaseCertificateRejectsMissingLeaseIdentity(t *testing.T) {
151 + t.Parallel()
152 +
153 + leaf := *mustIssuedLeafCertificate(t, "lease-identity")
154 + leaf.Subject = pkix.Name{CommonName: "unrelated"}
155 + leaf.URIs = nil
156 + state := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{&leaf}}
157 + if code, _, ok := ValidatePeerLeaseCertificate(state, "lease-identity"); ok || code != "cert_lease_missing" {
158 + t.Fatalf("expected cert_lease_missing, got code=%s ok=%v", code, ok)
159 + }
160 +}
161 +
162 +func TestValidatePeerLeaseCertificateRejectsLeaseIDMismatch(t *testing.T) {
163 + t.Parallel()
164 +
165 + state := &tls.ConnectionState{PeerCertificates: []*x509.Certificate{mustIssuedLeafCertificate(t, "lease-identity")}}
166 + if code, _, ok := ValidatePeerLeaseCertificate(state, "different-lease"); ok || code != "cert_lease_mismatch" {
167 + t.Fatalf("expected cert_lease_mismatch, got code=%s ok=%v", code, ok)
168 + }
169 +}
170 +
171 func TestExtractLeaseIDFromPeerCertificateRejectsUnprefixedCN(t *testing.T) {
172 t.Parallel()
173
@@ -117,3 +178,17 @@ func TestExtractLeaseIDFromPeerCertificateRejectsUnprefixedCN(t *testing.T) {
178 t.Fatalf("expected empty lease id for unprefixed CN, got %q", leaseID)
179 }
180 }
181 +
182 +func mustIssuedLeafCertificate(t *testing.T, leaseID string) *x509.Certificate {
183 + t.Helper()
184 +
185 + identity, err := IssueIdentity(leaseID)
186 + if err != nil {
187 + t.Fatalf("IssueIdentity returned error: %v", err)
188 + }
189 + leaf, err := x509.ParseCertificate(identity.Certificate[0])
190 + if err != nil {
191 + t.Fatalf("parse issued certificate: %v", err)
192 + }
193 + return leaf
194 +}
portal/controlplane/registry/service.go new
+278
@@ -0,0 +1,278 @@
1 +package registry
2 +
3 +import (
4 + "crypto/tls"
5 + "errors"
6 + "fmt"
7 + "net"
8 + "strings"
9 + "time"
10 +
11 + "github.com/rs/zerolog/log"
12 +
13 + "gosuda.org/portal/portal"
14 + "gosuda.org/portal/portal/controlplane"
15 + "gosuda.org/portal/types"
16 +)
17 +
18 +// DefaultLeaseTTL defines the lease lifetime used by SDK register/renew flows.
19 +const DefaultLeaseTTL = 30 * time.Second
20 +
21 +// Backend provides the relay operations needed by the control-plane registry.
22 +type Backend interface {
23 + BaseHost() string
24 + UpdateLease(lease *portal.Lease) bool
25 + DeleteLease(leaseID string) bool
26 + GetLeaseByID(leaseID string) (*portal.LeaseEntry, bool)
27 + ClearDropped(leaseID string)
28 + DropLease(leaseID string)
29 + RegisterRoute(sniName, leaseID, name string) error
30 + UnregisterRouteByLeaseID(leaseID string)
31 + HandleConnect(conn net.Conn, leaseID, token, clientIP string)
32 +}
33 +
34 +// Options configures service behavior.
35 +type Options struct {
36 + Now func() time.Time
37 + LeaseTTL time.Duration
38 +}
39 +
40 +// APIError is an alias for types.APIError to avoid breaking existing consumers.
41 +type APIError = types.APIError
42 +
43 +// AdmissionInput describes runtime context for control-plane admission checks.
44 +type AdmissionInput struct {
45 + ConnectionTLSState *tls.ConnectionState
46 + RawLeaseID string
47 + RawReverseToken string
48 + ClientIP string
49 + IsClientIPBanned bool
50 + RequireExisting bool
51 +}
52 +
53 +// AdmissionResult returns normalized, validated admission context.
54 +type AdmissionResult struct {
55 + Entry *portal.LeaseEntry
56 + LeaseID string
57 + ReverseToken string
58 + ClientIP string
59 +}
60 +
61 +// RegisterInput describes a lease registration request.
62 +type RegisterInput struct {
63 + LeaseID string
64 + ReverseToken string
65 + Name string
66 + Metadata *types.Metadata
67 + PortalURL string
68 + TLS bool
69 +}
70 +
71 +// Service encapsulates control-plane registry business logic.
72 +type Service struct {
73 + backend Backend
74 + now func() time.Time
75 + leaseTTL time.Duration
76 +}
77 +
78 +// NewService constructs a control-plane registry service.
79 +func NewService(backend Backend, opts Options) (*Service, error) {
80 + if backend == nil {
81 + return nil, errors.New("registry backend is required")
82 + }
83 + leaseTTL := opts.LeaseTTL
84 + if leaseTTL <= 0 {
85 + leaseTTL = DefaultLeaseTTL
86 + }
87 + now := opts.Now
88 + if now == nil {
89 + now = time.Now
90 + }
91 + return &Service{
92 + backend: backend,
93 + leaseTTL: leaseTTL,
94 + now: now,
95 + }, nil
96 +}
97 +
98 +// Admit validates and normalizes control-plane credentials before SDK operations.
99 +func (s *Service) Admit(input AdmissionInput) (AdmissionResult, *APIError) {
100 + leaseID, reverseToken := normalizeLeaseCredentials(input.RawLeaseID, input.RawReverseToken)
101 + if err := validateLeaseCredentials(leaseID, reverseToken); err != nil {
102 + return AdmissionResult{}, err
103 + }
104 +
105 + if input.IsClientIPBanned {
106 + return AdmissionResult{}, apiError(httpStatusForbidden, "ip_banned", "ip is banned")
107 + }
108 +
109 + entry, exists := s.backend.GetLeaseByID(leaseID)
110 + if input.RequireExisting && !exists {
111 + return AdmissionResult{}, apiError(httpStatusNotFound, "lease_not_found", "lease not found")
112 + }
113 +
114 + if input.ConnectionTLSState != nil && len(input.ConnectionTLSState.PeerCertificates) > 0 {
115 + if code, message, ok := controlplane.ValidatePeerLeaseCertificate(input.ConnectionTLSState, leaseID); !ok {
116 + return AdmissionResult{}, apiError(httpStatusUnauthorized, code, message)
117 + }
118 + }
119 +
120 + if exists && !controlplane.MatchLeaseToken(entry.Lease.ReverseToken, reverseToken) {
121 + return AdmissionResult{}, apiError(httpStatusUnauthorized, "unauthorized", "unauthorized reverse connect")
122 + }
123 +
124 + return AdmissionResult{
125 + LeaseID: leaseID,
126 + ReverseToken: reverseToken,
127 + ClientIP: strings.TrimSpace(input.ClientIP),
128 + Entry: entry,
129 + }, nil
130 +}
131 +
132 +// Register creates a new lease and associated SNI route.
133 +func (s *Service) Register(input RegisterInput) (types.RegisterResponse, *APIError) {
134 + name := strings.TrimSpace(input.Name)
135 + if !types.IsValidLeaseName(name) {
136 + return types.RegisterResponse{}, apiError(httpStatusBadRequest, "invalid_name", "name must be a DNS label (letters, digits, hyphen; no dots or underscores)")
137 + }
138 + if !input.TLS {
139 + return types.RegisterResponse{}, apiError(httpStatusBadRequest, "tls_required", "tls must be enabled")
140 + }
141 +
142 + metadata := types.Metadata{}
143 + if input.Metadata != nil {
144 + metadata = *input.Metadata
145 + }
146 +
147 + lease := &portal.Lease{
148 + ID: input.LeaseID,
149 + Name: name,
150 + Metadata: metadata,
151 + Expires: s.now().Add(s.leaseTTL),
152 + TLS: true,
153 + ReverseToken: input.ReverseToken,
154 + }
155 +
156 + if !s.backend.UpdateLease(lease) {
157 + return types.RegisterResponse{}, apiError(httpStatusConflict, "lease_rejected", "failed to register lease (name conflict or policy violation)")
158 + }
159 + s.backend.ClearDropped(input.LeaseID)
160 +
161 + sniName := types.BuildSNIName(name, s.backend.BaseHost())
162 + if sniName == "" {
163 + s.backend.DeleteLease(input.LeaseID)
164 + return types.RegisterResponse{}, apiError(httpStatusInternalServerError, "sni_name_invalid", "failed to build SNI route name")
165 + }
166 + if err := s.backend.RegisterRoute(sniName, input.LeaseID, name); err != nil {
167 + s.backend.DeleteLease(input.LeaseID)
168 + return types.RegisterResponse{}, apiError(httpStatusInternalServerError, "sni_register_failed", fmt.Sprintf("failed to register SNI route: %v", err))
169 + }
170 +
171 + log.Info().
172 + Str("lease_id", input.LeaseID).
173 + Str("name", name).
174 + Bool("tls", true).
175 + Msg("[Registry] Lease registered")
176 +
177 + return types.RegisterResponse{
178 + LeaseID: input.LeaseID,
179 + PublicURL: types.ServicePublicURL(strings.TrimSpace(input.PortalURL), name),
180 + Success: true,
181 + }, nil
182 +}
183 +
184 +// Unregister removes lease state, route state, and reverse-connection state.
185 +func (s *Service) Unregister(leaseID string) {
186 + leaseID = strings.TrimSpace(leaseID)
187 + if leaseID == "" {
188 + return
189 + }
190 +
191 + if s.backend.DeleteLease(leaseID) {
192 + log.Info().
193 + Str("lease_id", leaseID).
194 + Msg("[Registry] Lease unregistered")
195 + }
196 + s.backend.UnregisterRouteByLeaseID(leaseID)
197 + s.backend.DropLease(leaseID)
198 +}
199 +
200 +// Renew extends lease expiry and opportunistically refreshes SNI routing.
201 +func (s *Service) Renew(entry *portal.LeaseEntry) *APIError {
202 + if entry == nil || entry.Lease == nil {
203 + return apiError(httpStatusNotFound, "lease_not_found", "lease not found")
204 + }
205 +
206 + entry.Lease.Expires = s.now().Add(s.leaseTTL)
207 + if !s.backend.UpdateLease(entry.Lease) {
208 + return apiError(httpStatusInternalServerError, "renew_failed", "failed to renew lease")
209 + }
210 +
211 + sniName := types.BuildSNIName(entry.Lease.Name, s.backend.BaseHost())
212 + if sniName == "" {
213 + log.Warn().
214 + Str("lease_id", entry.Lease.ID).
215 + Str("name", entry.Lease.Name).
216 + Str("base_host", s.backend.BaseHost()).
217 + Msg("[Registry] Skipping SNI route refresh due to invalid SNI name")
218 + return nil
219 + }
220 + if err := s.backend.RegisterRoute(sniName, entry.Lease.ID, entry.Lease.Name); err != nil {
221 + log.Warn().
222 + Err(err).
223 + Str("lease_id", entry.Lease.ID).
224 + Str("name", entry.Lease.Name).
225 + Msg("[Registry] Failed to refresh SNI route on renew")
226 + }
227 + return nil
228 +}
229 +
230 +// Domain returns the configured relay base domain.
231 +func (s *Service) Domain() (types.DomainResponse, *APIError) {
232 + baseHost := strings.TrimSpace(s.backend.BaseHost())
233 + if baseHost == "" {
234 + return types.DomainResponse{}, apiError(httpStatusServiceUnavailable, "base_domain_missing", "base domain not configured")
235 + }
236 + return types.DomainResponse{
237 + Success: true,
238 + BaseDomain: baseHost,
239 + }, nil
240 +}
241 +
242 +// HandleConnect admits reverse traffic into the reverse hub.
243 +func (s *Service) HandleConnect(conn net.Conn, admission AdmissionResult) {
244 + s.backend.HandleConnect(conn, admission.LeaseID, admission.ReverseToken, admission.ClientIP)
245 +}
246 +
247 +func normalizeLeaseCredentials(rawLeaseID, rawReverseToken string) (leaseID, reverseToken string) {
248 + return strings.TrimSpace(rawLeaseID), strings.TrimSpace(rawReverseToken)
249 +}
250 +
251 +func validateLeaseCredentials(leaseID, reverseToken string) *APIError {
252 + if leaseID == "" {
253 + return apiError(httpStatusBadRequest, "missing_lease_id", "lease_id is required")
254 + }
255 + if reverseToken == "" {
256 + return apiError(httpStatusBadRequest, "missing_reverse_token", "reverse_token is required")
257 + }
258 + return nil
259 +}
260 +
261 +func apiError(statusCode int, code, message string) *APIError {
262 + return &APIError{
263 + StatusCode: statusCode,
264 + Code: code,
265 + Message: message,
266 + }
267 +}
268 +
269 +// Local status code constants avoid pulling net/http into this package API.
270 +const (
271 + httpStatusBadRequest = 400
272 + httpStatusUnauthorized = 401
273 + httpStatusForbidden = 403
274 + httpStatusNotFound = 404
275 + httpStatusConflict = 409
276 + httpStatusInternalServerError = 500
277 + httpStatusServiceUnavailable = 503
278 +)
portal/controlplane/registry/service_test.go new
+535
@@ -0,0 +1,535 @@
1 +package registry
2 +
3 +import (
4 + "crypto/tls"
5 + "crypto/x509"
6 + "errors"
7 + "net"
8 + "testing"
9 + "time"
10 +
11 + "gosuda.org/portal/portal"
12 + "gosuda.org/portal/portal/controlplane"
13 + "gosuda.org/portal/types"
14 +)
15 +
16 +type fakeBackend struct {
17 + registerRouteErr error
18 + leases map[string]*portal.LeaseEntry
19 + baseHost string
20 + connectLeaseID string
21 + connectToken string
22 + connectClientIP string
23 + unregisteredLeases []string
24 + droppedLeases []string
25 + updateLeaseAllowed bool
26 +}
27 +
28 +func newFakeBackend(baseHost string) *fakeBackend {
29 + return &fakeBackend{
30 + baseHost: baseHost,
31 + updateLeaseAllowed: true,
32 + leases: make(map[string]*portal.LeaseEntry),
33 + }
34 +}
35 +
36 +func (f *fakeBackend) BaseHost() string {
37 + return f.baseHost
38 +}
39 +
40 +func (f *fakeBackend) UpdateLease(lease *portal.Lease) bool {
41 + if !f.updateLeaseAllowed {
42 + return false
43 + }
44 + f.leases[lease.ID] = &portal.LeaseEntry{
45 + Lease: lease,
46 + Expires: lease.Expires,
47 + }
48 + return true
49 +}
50 +
51 +func (f *fakeBackend) DeleteLease(leaseID string) bool {
52 + if _, ok := f.leases[leaseID]; !ok {
53 + return false
54 + }
55 + delete(f.leases, leaseID)
56 + return true
57 +}
58 +
59 +func (f *fakeBackend) GetLeaseByID(leaseID string) (*portal.LeaseEntry, bool) {
60 + entry, ok := f.leases[leaseID]
61 + return entry, ok
62 +}
63 +
64 +func (f *fakeBackend) ClearDropped(string) {}
65 +
66 +func (f *fakeBackend) DropLease(leaseID string) {
67 + f.droppedLeases = append(f.droppedLeases, leaseID)
68 +}
69 +
70 +func (f *fakeBackend) RegisterRoute(_, _, _ string) error {
71 + return f.registerRouteErr
72 +}
73 +
74 +func (f *fakeBackend) UnregisterRouteByLeaseID(leaseID string) {
75 + f.unregisteredLeases = append(f.unregisteredLeases, leaseID)
76 +}
77 +
78 +func (f *fakeBackend) HandleConnect(_ net.Conn, leaseID, token, clientIP string) {
79 + f.connectLeaseID = leaseID
80 + f.connectToken = token
81 + f.connectClientIP = clientIP
82 +}
83 +
84 +func mustTLSState(t *testing.T, leaseID string) *tls.ConnectionState {
85 + t.Helper()
86 +
87 + identity, err := controlplane.IssueIdentity(leaseID)
88 + if err != nil {
89 + t.Fatalf("IssueIdentity returned error: %v", err)
90 + }
91 +
92 + leaf, err := x509.ParseCertificate(identity.Certificate[0])
93 + if err != nil {
94 + t.Fatalf("ParseCertificate returned error: %v", err)
95 + }
96 + return &tls.ConnectionState{
97 + PeerCertificates: []*x509.Certificate{leaf},
98 + }
99 +}
100 +
101 +func TestNewServiceRequiresBackend(t *testing.T) {
102 + t.Parallel()
103 +
104 + if _, err := NewService(nil, Options{}); err == nil {
105 + t.Fatal("expected error for nil backend")
106 + }
107 +}
108 +
109 +func TestAdmitRejectsMissingLeaseID(t *testing.T) {
110 + t.Parallel()
111 +
112 + svc, err := NewService(newFakeBackend("example.com"), Options{})
113 + if err != nil {
114 + t.Fatalf("NewService returned error: %v", err)
115 + }
116 +
117 + _, apiErr := svc.Admit(AdmissionInput{
118 + RawLeaseID: " ",
119 + RawReverseToken: "token",
120 + ConnectionTLSState: &tls.ConnectionState{},
121 + })
122 + if apiErr == nil {
123 + t.Fatal("expected admission error")
124 + }
125 + if apiErr.Code != "missing_lease_id" {
126 + t.Fatalf("error code = %q, want missing_lease_id", apiErr.Code)
127 + }
128 +}
129 +
130 +func TestAdmitRejectsBannedIP(t *testing.T) {
131 + t.Parallel()
132 +
133 + svc, err := NewService(newFakeBackend("example.com"), Options{})
134 + if err != nil {
135 + t.Fatalf("NewService returned error: %v", err)
136 + }
137 +
138 + _, apiErr := svc.Admit(AdmissionInput{
139 + RawLeaseID: "lease-1",
140 + RawReverseToken: "token",
141 + IsClientIPBanned: true,
142 + ConnectionTLSState: &tls.ConnectionState{},
143 + })
144 + if apiErr == nil {
145 + t.Fatal("expected admission error")
146 + }
147 + if apiErr.Code != "ip_banned" {
148 + t.Fatalf("error code = %q, want ip_banned", apiErr.Code)
149 + }
150 +}
151 +
152 +func TestAdmitRequiresExistingLeaseWhenRequested(t *testing.T) {
153 + t.Parallel()
154 +
155 + svc, err := NewService(newFakeBackend("example.com"), Options{})
156 + if err != nil {
157 + t.Fatalf("NewService returned error: %v", err)
158 + }
159 +
160 + _, apiErr := svc.Admit(AdmissionInput{
161 + RawLeaseID: "lease-1",
162 + RawReverseToken: "token",
163 + RequireExisting: true,
164 + ConnectionTLSState: mustTLSState(t, "lease-1"),
165 + })
166 + if apiErr == nil {
167 + t.Fatal("expected admission error")
168 + }
169 + if apiErr.Code != "lease_not_found" {
170 + t.Fatalf("error code = %q, want lease_not_found", apiErr.Code)
171 + }
172 +}
173 +
174 +func TestAdmitSuccessWithMatchingToken(t *testing.T) {
175 + t.Parallel()
176 +
177 + backend := newFakeBackend("example.com")
178 + backend.leases["lease-1"] = &portal.LeaseEntry{
179 + Lease: &portal.Lease{
180 + ID: "lease-1",
181 + ReverseToken: "token-1",
182 + },
183 + }
184 +
185 + svc, err := NewService(backend, Options{})
186 + if err != nil {
187 + t.Fatalf("NewService returned error: %v", err)
188 + }
189 +
190 + result, apiErr := svc.Admit(AdmissionInput{
191 + RawLeaseID: " lease-1 ",
192 + RawReverseToken: " token-1 ",
193 + ClientIP: " 198.51.100.9 ",
194 + RequireExisting: true,
195 + ConnectionTLSState: mustTLSState(t, "lease-1"),
196 + })
197 + if apiErr != nil {
198 + t.Fatalf("Admit returned error: %+v", apiErr)
199 + }
200 + if result.LeaseID != "lease-1" {
201 + t.Fatalf("lease id = %q, want lease-1", result.LeaseID)
202 + }
203 + if result.ReverseToken != "token-1" {
204 + t.Fatalf("reverse token = %q, want token-1", result.ReverseToken)
205 + }
206 + if result.ClientIP != "198.51.100.9" {
207 + t.Fatalf("client ip = %q, want 198.51.100.9", result.ClientIP)
208 + }
209 +}
210 +
211 +func TestAdmitRejectsInvalidTokenWithValidCertificate(t *testing.T) {
212 + t.Parallel()
213 +
214 + backend := newFakeBackend("example.com")
215 + backend.leases["lease-1"] = &portal.LeaseEntry{
216 + Lease: &portal.Lease{
217 + ID: "lease-1",
218 + ReverseToken: "token-1",
219 + },
220 + }
221 +
222 + svc, err := NewService(backend, Options{})
223 + if err != nil {
224 + t.Fatalf("NewService returned error: %v", err)
225 + }
226 +
227 + _, apiErr := svc.Admit(AdmissionInput{
228 + RawLeaseID: "lease-1",
229 + RawReverseToken: "wrong-token",
230 + RequireExisting: true,
231 + ConnectionTLSState: mustTLSState(t, "lease-1"),
232 + })
233 + if apiErr == nil {
234 + t.Fatal("expected admission error")
235 + }
236 + if apiErr.Code != "unauthorized" {
237 + t.Fatalf("error code = %q, want unauthorized", apiErr.Code)
238 + }
239 +}
240 +
241 +func TestAdmit_ValidCert_Passes(t *testing.T) {
242 + t.Parallel()
243 +
244 + backend := newFakeBackend("example.com")
245 + backend.leases["lease-1"] = &portal.LeaseEntry{
246 + Lease: &portal.Lease{
247 + ID: "lease-1",
248 + ReverseToken: "token-1",
249 + },
250 + }
251 +
252 + svc, err := NewService(backend, Options{})
253 + if err != nil {
254 + t.Fatalf("NewService returned error: %v", err)
255 + }
256 +
257 + result, apiErr := svc.Admit(AdmissionInput{
258 + RawLeaseID: "lease-1",
259 + RawReverseToken: "token-1",
260 + RequireExisting: true,
261 + ConnectionTLSState: mustTLSState(t, "lease-1"),
262 + })
263 + if apiErr != nil {
264 + t.Fatalf("Admit returned error: %+v", apiErr)
265 + }
266 + if result.LeaseID != "lease-1" {
267 + t.Fatalf("lease id = %q, want lease-1", result.LeaseID)
268 + }
269 +}
270 +
271 +func TestAdmit_InvalidCert_Rejected(t *testing.T) {
272 + t.Parallel()
273 +
274 + backend := newFakeBackend("example.com")
275 + backend.leases["lease-1"] = &portal.LeaseEntry{
276 + Lease: &portal.Lease{
277 + ID: "lease-1",
278 + ReverseToken: "token-1",
279 + },
280 + }
281 +
282 + svc, err := NewService(backend, Options{})
283 + if err != nil {
284 + t.Fatalf("NewService returned error: %v", err)
285 + }
286 +
287 + // Present a cert bound to a different lease ID.
288 + _, apiErr := svc.Admit(AdmissionInput{
289 + RawLeaseID: "lease-1",
290 + RawReverseToken: "token-1",
291 + RequireExisting: true,
292 + ConnectionTLSState: mustTLSState(t, "lease-other"),
293 + })
294 + if apiErr == nil {
295 + t.Fatal("expected admission error for mismatched cert")
296 + }
297 + if apiErr.Code != "cert_lease_mismatch" {
298 + t.Fatalf("error code = %q, want cert_lease_mismatch", apiErr.Code)
299 + }
300 +}
301 +
302 +func TestAdmit_NoCert_TokenValid_Passes(t *testing.T) {
303 + t.Parallel()
304 +
305 + backend := newFakeBackend("example.com")
306 + backend.leases["lease-1"] = &portal.LeaseEntry{
307 + Lease: &portal.Lease{
308 + ID: "lease-1",
309 + ReverseToken: "token-1",
310 + },
311 + }
312 +
313 + svc, err := NewService(backend, Options{})
314 + if err != nil {
315 + t.Fatalf("NewService returned error: %v", err)
316 + }
317 +
318 + // Nil TLS state — CertBind skipped, token validation still applies.
319 + result, apiErr := svc.Admit(AdmissionInput{
320 + RawLeaseID: "lease-1",
321 + RawReverseToken: "token-1",
322 + RequireExisting: true,
323 + ConnectionTLSState: nil,
324 + })
325 + if apiErr != nil {
326 + t.Fatalf("Admit returned error: %+v", apiErr)
327 + }
328 + if result.LeaseID != "lease-1" {
329 + t.Fatalf("lease id = %q, want lease-1", result.LeaseID)
330 + }
331 +}
332 +
333 +func TestAdmit_NoCert_TokenInvalid_Rejected(t *testing.T) {
334 + t.Parallel()
335 +
336 + backend := newFakeBackend("example.com")
337 + backend.leases["lease-1"] = &portal.LeaseEntry{
338 + Lease: &portal.Lease{
339 + ID: "lease-1",
340 + ReverseToken: "token-1",
341 + },
342 + }
343 +
344 + svc, err := NewService(backend, Options{})
345 + if err != nil {
346 + t.Fatalf("NewService returned error: %v", err)
347 + }
348 +
349 + // Nil TLS state — CertBind skipped, but token does not match.
350 + _, apiErr := svc.Admit(AdmissionInput{
351 + RawLeaseID: "lease-1",
352 + RawReverseToken: "wrong-token",
353 + RequireExisting: true,
354 + ConnectionTLSState: nil,
355 + })
356 + if apiErr == nil {
357 + t.Fatal("expected admission error for invalid token")
358 + }
359 + if apiErr.Code != "unauthorized" {
360 + t.Fatalf("error code = %q, want unauthorized", apiErr.Code)
361 + }
362 +}
363 +
364 +func TestRegisterSuccess(t *testing.T) {
365 + t.Parallel()
366 +
367 + now := time.Date(2026, time.March, 4, 0, 0, 0, 0, time.UTC)
368 + backend := newFakeBackend("example.com")
369 + svc, err := NewService(backend, Options{
370 + LeaseTTL: 30 * time.Second,
371 + Now: func() time.Time { return now },
372 + })
373 + if err != nil {
374 + t.Fatalf("NewService returned error: %v", err)
375 + }
376 +
377 + resp, apiErr := svc.Register(RegisterInput{
378 + LeaseID: "lease-1",
379 + ReverseToken: "token-1",
380 + Name: "demo",
381 + Metadata: &types.Metadata{Owner: "owner"},
382 + TLS: true,
383 + PortalURL: "https://portal.example.com",
384 + })
385 + if apiErr != nil {
386 + t.Fatalf("Register returned error: %+v", apiErr)
387 + }
388 + if !resp.Success {
389 + t.Fatal("expected success response")
390 + }
391 + if resp.LeaseID != "lease-1" {
392 + t.Fatalf("lease id = %q, want lease-1", resp.LeaseID)
393 + }
394 + entry, ok := backend.GetLeaseByID("lease-1")
395 + if !ok {
396 + t.Fatal("expected lease to be persisted")
397 + }
398 + if got := entry.Lease.Expires; !got.Equal(now.Add(30 * time.Second)) {
399 + t.Fatalf("lease expiry = %v, want %v", got, now.Add(30*time.Second))
400 + }
401 +}
402 +
403 +func TestRegisterDeletesLeaseWhenRouteRegistrationFails(t *testing.T) {
404 + t.Parallel()
405 +
406 + backend := newFakeBackend("example.com")
407 + backend.registerRouteErr = errors.New("register failed")
408 + svc, err := NewService(backend, Options{})
409 + if err != nil {
410 + t.Fatalf("NewService returned error: %v", err)
411 + }
412 +
413 + _, apiErr := svc.Register(RegisterInput{
414 + LeaseID: "lease-1",
415 + ReverseToken: "token-1",
416 + Name: "demo",
417 + TLS: true,
418 + PortalURL: "https://portal.example.com",
419 + })
420 + if apiErr == nil {
421 + t.Fatal("expected register error")
422 + }
423 + if apiErr.Code != "sni_register_failed" {
424 + t.Fatalf("error code = %q, want sni_register_failed", apiErr.Code)
425 + }
426 + if _, ok := backend.GetLeaseByID("lease-1"); ok {
427 + t.Fatal("expected lease to be deleted after route registration failure")
428 + }
429 +}
430 +
431 +func TestRenewExtendsLease(t *testing.T) {
432 + t.Parallel()
433 +
434 + now := time.Date(2026, time.March, 4, 1, 0, 0, 0, time.UTC)
435 + backend := newFakeBackend("example.com")
436 + entry := &portal.LeaseEntry{
437 + Lease: &portal.Lease{
438 + ID: "lease-1",
439 + Name: "demo",
440 + Expires: now,
441 + },
442 + }
443 + backend.leases["lease-1"] = entry
444 +
445 + svc, err := NewService(backend, Options{
446 + LeaseTTL: 30 * time.Second,
447 + Now: func() time.Time { return now },
448 + })
449 + if err != nil {
450 + t.Fatalf("NewService returned error: %v", err)
451 + }
452 +
453 + if apiErr := svc.Renew(entry); apiErr != nil {
454 + t.Fatalf("Renew returned error: %+v", apiErr)
455 + }
456 + if got := entry.Lease.Expires; !got.Equal(now.Add(30 * time.Second)) {
457 + t.Fatalf("lease expiry = %v, want %v", got, now.Add(30*time.Second))
458 + }
459 +}
460 +
461 +func TestRenewResetsFutureExpiryFromNow(t *testing.T) {
462 + t.Parallel()
463 +
464 + now := time.Date(2026, time.March, 4, 1, 0, 0, 0, time.UTC)
465 + originalExpiry := now.Add(5 * time.Minute)
466 + backend := newFakeBackend("example.com")
467 + entry := &portal.LeaseEntry{
468 + Lease: &portal.Lease{
469 + ID: "lease-1",
470 + Name: "demo",
471 + Expires: originalExpiry,
472 + },
473 + }
474 + backend.leases["lease-1"] = entry
475 +
476 + svc, err := NewService(backend, Options{
477 + LeaseTTL: 30 * time.Second,
478 + Now: func() time.Time { return now },
479 + })
480 + if err != nil {
481 + t.Fatalf("NewService returned error: %v", err)
482 + }
483 +
484 + if apiErr := svc.Renew(entry); apiErr != nil {
485 + t.Fatalf("Renew returned error: %+v", apiErr)
486 + }
487 +
488 + want := now.Add(30 * time.Second)
489 + if got := entry.Lease.Expires; !got.Equal(want) {
490 + t.Fatalf("lease expiry = %v, want %v", got, want)
491 + }
492 + if !entry.Lease.Expires.Before(originalExpiry) {
493 + t.Fatalf("lease expiry = %v, want a value before original future expiry %v", entry.Lease.Expires, originalExpiry)
494 + }
495 +}
496 +
497 +func TestUnregisterDropsLeaseAndRoutes(t *testing.T) {
498 + t.Parallel()
499 +
500 + backend := newFakeBackend("example.com")
501 + backend.leases["lease-1"] = &portal.LeaseEntry{Lease: &portal.Lease{ID: "lease-1"}}
502 + svc, err := NewService(backend, Options{})
503 + if err != nil {
504 + t.Fatalf("NewService returned error: %v", err)
505 + }
506 +
507 + svc.Unregister("lease-1")
508 +
509 + if _, ok := backend.GetLeaseByID("lease-1"); ok {
510 + t.Fatal("expected lease to be removed")
511 + }
512 + if len(backend.unregisteredLeases) != 1 || backend.unregisteredLeases[0] != "lease-1" {
513 + t.Fatalf("unregistered leases = %v, want [lease-1]", backend.unregisteredLeases)
514 + }
515 + if len(backend.droppedLeases) != 1 || backend.droppedLeases[0] != "lease-1" {
516 + t.Fatalf("dropped leases = %v, want [lease-1]", backend.droppedLeases)
517 + }
518 +}
519 +
520 +func TestDomainRequiresBaseHost(t *testing.T) {
521 + t.Parallel()
522 +
523 + svc, err := NewService(newFakeBackend(""), Options{})
524 + if err != nil {
525 + t.Fatalf("NewService returned error: %v", err)
526 + }
527 +
528 + _, apiErr := svc.Domain()
529 + if apiErr == nil {
530 + t.Fatal("expected domain error")
531 + }
532 + if apiErr.Code != "base_domain_missing" {
533 + t.Fatalf("error code = %q, want base_domain_missing", apiErr.Code)
534 + }
535 +}
portal/keyless/client.go
+2 -3
@@ -16,7 +16,7 @@ import (
16
17 keylesstls "github.com/gosuda/keyless_tls/keyless"
18
19 - "gosuda.org/portal/types"
19 + "gosuda.org/portal/portal/netutil"
20 )
21
22 // BuildClientTLSConfig builds a keyless TLS server config for tunnel-side TLS termination.
@@ -47,7 +47,6 @@ func BuildClientTLSConfig(relayAddr, keylessServerName, domain string) (*tls.Con
47 Endpoint: relayAddr,
48 ServerName: keylessServerName,
49 KeyID: RelayKeyID,
50 - EnableMTLS: false,
50 RootCAPEM: rootCAPEM,
51 }, certPEM)
52 if err != nil {
@@ -186,7 +185,7 @@ func FetchEndpointCertificateChain(ctx context.Context, endpoint string, serverN
185 tlsConn := tls.Client(rawConn, &tls.Config{
186 MinVersion: tls.VersionTLS12,
187 ServerName: serverName,
189 - InsecureSkipVerify: types.IsLocalhost(host),
188 + InsecureSkipVerify: netutil.IsLocalhost(host),
189 })
190 defer tlsConn.Close()
191 if err := tlsConn.HandshakeContext(ctx); err != nil {
portal/lease.go
+5 -17
@@ -9,26 +9,14 @@ import (
9 "gosuda.org/portal/types"
10 )
11
12 -// Lease represents a registered service.
13 -type Lease struct {
14 - Expires time.Time `json:"expires"`
15 - ID string `json:"id"`
16 - Name string `json:"name"`
17 - ReverseToken string `json:"-"`
18 - Metadata types.Metadata `json:"metadata"`
19 - TLS bool `json:"tls"`
20 -}
12 +// Lease is an alias for types.Lease for backward compatibility within the portal package.
13 +type Lease = types.Lease
14
22 -// LeaseEntry represents a registered lease with expiration tracking.
23 -type LeaseEntry struct {
24 - Lease *Lease
25 - Expires time.Time
26 - LastSeen time.Time
27 - FirstSeen time.Time
28 -}
15 +// LeaseEntry is an alias for types.LeaseEntry for backward compatibility within the portal package.
16 +type LeaseEntry = types.LeaseEntry
17
18 type LeaseManager struct {
31 - leases map[string]*LeaseEntry
19 + leases map[string]*types.LeaseEntry
20 stopCh chan struct{}
21 bannedLeases map[string]struct{}
22 namePattern *regexp.Regexp
portal/netutil/netutil.go new
+59
@@ -0,0 +1,59 @@
1 +package netutil
2 +
3 +import "gosuda.org/portal/types"
4 +
5 +func NormalizeServiceName(name string) (string, bool) {
6 + return types.NormalizeServiceName(name)
7 +}
8 +
9 +func IsSubdomain(domain, host string) bool {
10 + return types.IsSubdomain(domain, host)
11 +}
12 +
13 +func DefaultAppPattern(base string) string {
14 + return types.DefaultAppPattern(base)
15 +}
16 +
17 +func PortalHostPort(portalURL string) string {
18 + return types.PortalHostPort(portalURL)
19 +}
20 +
21 +func PortalRootHost(portalURL string) string {
22 + return types.PortalRootHost(portalURL)
23 +}
24 +
25 +func IsLocalhost(host string) bool {
26 + return types.IsLocalhost(host)
27 +}
28 +
29 +func DefaultBootstrapFrom(base string) string {
30 + return types.DefaultBootstrapFrom(base)
31 +}
32 +
33 +func ParseURLs(raw string) []string {
34 + return types.ParseURLs(raw)
35 +}
36 +
37 +func ParsePortNumber(raw string, fallback int) int {
38 + return types.ParsePortNumber(raw, fallback)
39 +}
40 +
41 +func LoopbackForwardAddr(listenAddr string) string {
42 + return types.LoopbackForwardAddr(listenAddr)
43 +}
44 +
45 +func IsValidLeaseName(name string) bool {
46 + return types.IsValidLeaseName(name)
47 +}
48 +
49 +func NormalizeRelayAPIURLs(bootstrapServers []string) ([]string, error) {
50 + return types.NormalizeRelayAPIURLs(bootstrapServers)
51 +}
52 +
53 +func NormalizeRelayAPIURL(relayURL string) (string, error) {
54 + return types.NormalizeRelayAPIURL(relayURL)
55 +}
56 +
57 +func NormalizeTargetAddr(targetAddr string) (string, error) {
58 + return types.NormalizeTargetAddr(targetAddr)
59 +}
portal/policy/approver.go renamed
+21 -21
@@ -1,64 +1,64 @@
1 -package manager
1 +package policy
2
3 import "sync"
4
5 -// ApprovalMode represents the approval mode for new connections.
6 -type ApprovalMode string
5 +// Mode represents the approval mode for new connections.
6 +type Mode string
7
8 const (
9 - ApprovalModeAuto ApprovalMode = "auto"
10 - ApprovalModeManual ApprovalMode = "manual"
9 + ModeAuto Mode = "auto"
10 + ModeManual Mode = "manual"
11 )
12
13 -// ApproveManager manages approval/denial state for leases.
14 -type ApproveManager struct {
13 +// Approver manages approval/denial state for leases.
14 +type Approver struct {
15 approvedLeases map[string]struct{}
16 deniedLeases map[string]struct{}
17 - approvalMode ApprovalMode
17 + approvalMode Mode
18 mu sync.RWMutex
19 }
20
21 -func NewApproveManager() *ApproveManager {
22 - return &ApproveManager{
23 - approvalMode: ApprovalModeAuto,
21 +func NewApprover() *Approver {
22 + return &Approver{
23 + approvalMode: ModeAuto,
24 approvedLeases: make(map[string]struct{}),
25 deniedLeases: make(map[string]struct{}),
26 }
27 }
28
29 -func (m *ApproveManager) GetApprovalMode() ApprovalMode {
29 +func (m *Approver) GetApprovalMode() Mode {
30 m.mu.RLock()
31 defer m.mu.RUnlock()
32 return m.approvalMode
33 }
34
35 -func (m *ApproveManager) SetApprovalMode(mode ApprovalMode) {
35 +func (m *Approver) SetApprovalMode(mode Mode) {
36 m.mu.Lock()
37 defer m.mu.Unlock()
38 m.approvalMode = mode
39 }
40
41 -func (m *ApproveManager) IsLeaseApproved(leaseID string) bool {
41 +func (m *Approver) IsLeaseApproved(leaseID string) bool {
42 m.mu.RLock()
43 defer m.mu.RUnlock()
44 _, ok := m.approvedLeases[leaseID]
45 return ok
46 }
47
48 -func (m *ApproveManager) ApproveLease(leaseID string) {
48 +func (m *Approver) ApproveLease(leaseID string) {
49 m.mu.Lock()
50 defer m.mu.Unlock()
51 m.approvedLeases[leaseID] = struct{}{}
52 delete(m.deniedLeases, leaseID)
53 }
54
55 -func (m *ApproveManager) RevokeLease(leaseID string) {
55 +func (m *Approver) RevokeLease(leaseID string) {
56 m.mu.Lock()
57 defer m.mu.Unlock()
58 delete(m.approvedLeases, leaseID)
59 }
60
61 -func (m *ApproveManager) GetApprovedLeases() []string {
61 +func (m *Approver) GetApprovedLeases() []string {
62 m.mu.RLock()
63 defer m.mu.RUnlock()
64 result := make([]string, 0, len(m.approvedLeases))
@@ -68,27 +68,27 @@ func (m *ApproveManager) GetApprovedLeases() []string {
68 return result
69 }
70
71 -func (m *ApproveManager) IsLeaseDenied(leaseID string) bool {
71 +func (m *Approver) IsLeaseDenied(leaseID string) bool {
72 m.mu.RLock()
73 defer m.mu.RUnlock()
74 _, ok := m.deniedLeases[leaseID]
75 return ok
76 }
77
78 -func (m *ApproveManager) DenyLease(leaseID string) {
78 +func (m *Approver) DenyLease(leaseID string) {
79 m.mu.Lock()
80 defer m.mu.Unlock()
81 m.deniedLeases[leaseID] = struct{}{}
82 delete(m.approvedLeases, leaseID)
83 }
84
85 -func (m *ApproveManager) UndenyLease(leaseID string) {
85 +func (m *Approver) UndenyLease(leaseID string) {
86 m.mu.Lock()
87 defer m.mu.Unlock()
88 delete(m.deniedLeases, leaseID)
89 }
90
91 -func (m *ApproveManager) GetDeniedLeases() []string {
91 +func (m *Approver) GetDeniedLeases() []string {
92 m.mu.RLock()
93 defer m.mu.RUnlock()
94 result := make([]string, 0, len(m.deniedLeases))
portal/policy/authenticator.go renamed
+20 -20
@@ -1,4 +1,4 @@
1 -package manager
1 +package policy
2
3 import (
4 "crypto/rand"
@@ -22,8 +22,8 @@ const (
22 maxFailedLoginEntries = 4096
23 )
24
25 -// AuthManager manages admin authentication with rate limiting.
26 -type AuthManager struct {
25 +// Authenticator manages admin authentication with rate limiting.
26 +type Authenticator struct {
27 lastSweepAt time.Time
28 failedLogins map[string]*loginAttempt
29 sessions map[string]time.Time
@@ -37,9 +37,9 @@ type loginAttempt struct {
37 count int
38 }
39
40 -// NewAuthManager creates a new AuthManager with the given secret key.
41 -func NewAuthManager(secretKey string) *AuthManager {
42 - // Create AuthManager for admin authentication
40 +// NewAuthenticator creates a new Authenticator with the given secret key.
41 +func NewAuthenticator(secretKey string) *Authenticator {
42 + // Create Authenticator for admin authentication
43 // Auto-generate secret key if not provided
44 if secretKey == "" {
45 randomBytes := make([]byte, 16)
@@ -52,7 +52,7 @@ func NewAuthManager(secretKey string) *AuthManager {
52 log.Info().Int("key_length", len(secretKey)).Msg("[server] admin authentication enabled")
53 }
54
55 - return &AuthManager{
55 + return &Authenticator{
56 secretKey: secretKey,
57 failedLogins: make(map[string]*loginAttempt),
58 sessions: make(map[string]time.Time),
@@ -60,7 +60,7 @@ func NewAuthManager(secretKey string) *AuthManager {
60 }
61
62 // IsIPLocked checks if an IP is currently locked out.
63 -func (m *AuthManager) IsIPLocked(ip string) bool {
63 +func (m *Authenticator) IsIPLocked(ip string) bool {
64 m.mu.RLock()
65 defer m.mu.RUnlock()
66
@@ -72,7 +72,7 @@ func (m *AuthManager) IsIPLocked(ip string) bool {
72 }
73
74 // GetLockRemainingSeconds returns the remaining seconds until the IP is unlocked.
75 -func (m *AuthManager) GetLockRemainingSeconds(ip string) int {
75 +func (m *Authenticator) GetLockRemainingSeconds(ip string) int {
76 m.mu.RLock()
77 defer m.mu.RUnlock()
78
@@ -84,7 +84,7 @@ func (m *AuthManager) GetLockRemainingSeconds(ip string) int {
84 }
85
86 // RecordFailedLogin records a failed login attempt and returns true if the IP is now locked.
87 -func (m *AuthManager) RecordFailedLogin(ip string) bool {
87 +func (m *Authenticator) RecordFailedLogin(ip string) bool {
88 m.mu.Lock()
89 defer m.mu.Unlock()
90
@@ -116,7 +116,7 @@ func (m *AuthManager) RecordFailedLogin(ip string) bool {
116 }
117
118 // ResetFailedLogin resets the failed login count for an IP.
119 -func (m *AuthManager) ResetFailedLogin(ip string) {
119 +func (m *Authenticator) ResetFailedLogin(ip string) {
120 m.mu.Lock()
121 defer m.mu.Unlock()
122
@@ -124,7 +124,7 @@ func (m *AuthManager) ResetFailedLogin(ip string) {
124 }
125
126 // ValidateKey checks if the provided key matches the secret key.
127 -func (m *AuthManager) ValidateKey(key string) bool {
127 +func (m *Authenticator) ValidateKey(key string) bool {
128 if m.secretKey == "" {
129 return false
130 }
@@ -132,12 +132,12 @@ func (m *AuthManager) ValidateKey(key string) bool {
132 }
133
134 // HasSecretKey returns true if a secret key is configured.
135 -func (m *AuthManager) HasSecretKey() bool {
135 +func (m *Authenticator) HasSecretKey() bool {
136 return m.secretKey != ""
137 }
138
139 // CreateSession creates a new session and returns the token.
140 -func (m *AuthManager) CreateSession() string {
140 +func (m *Authenticator) CreateSession() string {
141 token, err := generateToken()
142 if err != nil {
143 log.Fatal().Err(err).Msg("[server] failed to generate secure admin session token")
@@ -155,7 +155,7 @@ func (m *AuthManager) CreateSession() string {
155 }
156
157 // ValidateSession checks if a session token is valid.
158 -func (m *AuthManager) ValidateSession(token string) bool {
158 +func (m *Authenticator) ValidateSession(token string) bool {
159 if token == "" {
160 return false
161 }
@@ -172,7 +172,7 @@ func (m *AuthManager) ValidateSession(token string) bool {
172 }
173
174 // DeleteSession removes a session.
175 -func (m *AuthManager) DeleteSession(token string) {
175 +func (m *Authenticator) DeleteSession(token string) {
176 m.mu.Lock()
177 defer m.mu.Unlock()
178
@@ -180,7 +180,7 @@ func (m *AuthManager) DeleteSession(token string) {
180 }
181
182 // cleanupExpiredSessions removes expired sessions (must be called with lock held).
183 -func (m *AuthManager) cleanupExpiredSessions() {
183 +func (m *Authenticator) cleanupExpiredSessions() {
184 now := time.Now()
185 for token, expiry := range m.sessions {
186 if now.After(expiry) {
@@ -202,7 +202,7 @@ func generateTokenFromReader(reader io.Reader) (string, error) {
202 return hex.EncodeToString(bytes), nil
203 }
204
205 -func (m *AuthManager) maybeSweepFailedLoginsLocked(now time.Time) {
205 +func (m *Authenticator) maybeSweepFailedLoginsLocked(now time.Time) {
206 if !m.lastSweepAt.IsZero() && now.Sub(m.lastSweepAt) < failedLoginSweepWindow {
207 return
208 }
@@ -211,7 +211,7 @@ func (m *AuthManager) maybeSweepFailedLoginsLocked(now time.Time) {
211 m.lastSweepAt = now
212 }
213
214 -func (m *AuthManager) sweepExpiredFailedLoginsLocked(now time.Time) {
214 +func (m *Authenticator) sweepExpiredFailedLoginsLocked(now time.Time) {
215 for ip, attempt := range m.failedLogins {
216 if attempt == nil {
217 delete(m.failedLogins, ip)
@@ -228,7 +228,7 @@ func (m *AuthManager) sweepExpiredFailedLoginsLocked(now time.Time) {
228 }
229 }
230
231 -func (m *AuthManager) enforceFailedLoginCapLocked() {
231 +func (m *Authenticator) enforceFailedLoginCapLocked() {
232 if len(m.failedLogins) <= maxFailedLoginEntries {
233 return
234 }
portal/policy/authenticator_test.go renamed
+5 -5
@@ -1,4 +1,4 @@
1 -package manager
1 +package policy
2
3 import (
4 "bytes"
@@ -33,7 +33,7 @@ func TestGenerateTokenFromReaderFailsClosed(t *testing.T) {
33 func TestRecordFailedLoginSweepsExpiredEntries(t *testing.T) {
34 t.Parallel()
35
36 - m := NewAuthManager("test-secret")
36 + m := NewAuthenticator("test-secret")
37 now := time.Now()
38
39 m.mu.Lock()
@@ -66,7 +66,7 @@ func TestRecordFailedLoginSweepsExpiredEntries(t *testing.T) {
66 func TestRecordFailedLoginEnforcesEntryCap(t *testing.T) {
67 t.Parallel()
68
69 - m := NewAuthManager("test-secret")
69 + m := NewAuthenticator("test-secret")
70 base := time.Now().Add(-2 * time.Minute)
71
72 m.mu.Lock()
@@ -96,7 +96,7 @@ func TestRecordFailedLoginEnforcesEntryCap(t *testing.T) {
96 }
97 }
98
99 -func TestAuthManagerDoesNotLogPlaintextSecretsOrSessionToken(t *testing.T) {
99 +func TestAuthenticatorDoesNotLogPlaintextSecretsOrSessionToken(t *testing.T) {
100 const secretKey = "super-secret-admin-key"
101
102 var buf bytes.Buffer
@@ -106,7 +106,7 @@ func TestAuthManagerDoesNotLogPlaintextSecretsOrSessionToken(t *testing.T) {
106 log.Logger = originalLogger
107 })
108
109 - m := NewAuthManager(secretKey)
109 + m := NewAuthenticator(secretKey)
110 logOutput := buf.String()
111 if strings.Contains(logOutput, secretKey) {
112 t.Fatalf("expected auth manager logs to omit plaintext secret key, got %q", logOutput)
portal/policy/ip_filter.go renamed
+19 -19
@@ -1,4 +1,4 @@
1 -package manager
1 +package policy
2
3 import (
4 "fmt"
@@ -9,8 +9,8 @@ import (
9 "sync"
10 )
11
12 -// IPManager manages IP-based bans and lease-to-IP mapping.
13 -type IPManager struct {
12 +// IPFilter manages IP-based bans and lease-to-IP mapping.
13 +type IPFilter struct {
14 bannedIPs map[string]struct{}
15 leaseToIP map[string]string
16 ipToLeases map[string][]string
@@ -27,9 +27,9 @@ const (
27 xRealIPHeader = "X-Real-IP"
28 )
29
30 -// NewIPManager creates a new IP manager.
31 -func NewIPManager() *IPManager {
32 - return &IPManager{
30 +// NewIPFilter creates a new IP filter.
31 +func NewIPFilter() *IPFilter {
32 + return &IPFilter{
33 bannedIPs: make(map[string]struct{}),
34 leaseToIP: make(map[string]string),
35 ipToLeases: make(map[string][]string),
@@ -103,21 +103,21 @@ func IsTrustedProxyRemoteAddr(remoteAddr string) bool {
103 }
104
105 // BanIP adds an IP to the ban list.
106 -func (m *IPManager) BanIP(ip string) {
106 +func (m *IPFilter) BanIP(ip string) {
107 m.mu.Lock()
108 defer m.mu.Unlock()
109 m.bannedIPs[ip] = struct{}{}
110 }
111
112 // UnbanIP removes an IP from the ban list.
113 -func (m *IPManager) UnbanIP(ip string) {
113 +func (m *IPFilter) UnbanIP(ip string) {
114 m.mu.Lock()
115 defer m.mu.Unlock()
116 delete(m.bannedIPs, ip)
117 }
118
119 // IsIPBanned checks if an IP is banned.
120 -func (m *IPManager) IsIPBanned(ip string) bool {
120 +func (m *IPFilter) IsIPBanned(ip string) bool {
121 m.mu.RLock()
122 defer m.mu.RUnlock()
123 _, banned := m.bannedIPs[ip]
@@ -125,19 +125,19 @@ func (m *IPManager) IsIPBanned(ip string) bool {
125 }
126
127 // IsIPBannedByPolicy applies shared runtime policy rules before checking the ban map.
128 -func IsIPBannedByPolicy(ipManager *IPManager, candidate string) bool {
129 - if ipManager == nil {
128 +func IsIPBannedByPolicy(ipFilter *IPFilter, candidate string) bool {
129 + if ipFilter == nil {
130 return false
131 }
132 candidate = strings.TrimSpace(candidate)
133 if candidate == "" {
134 return false
135 }
136 - return ipManager.IsIPBanned(candidate)
136 + return ipFilter.IsIPBanned(candidate)
137 }
138
139 // GetBannedIPs returns all banned IPs.
140 -func (m *IPManager) GetBannedIPs() []string {
140 +func (m *IPFilter) GetBannedIPs() []string {
141 m.mu.RLock()
142 defer m.mu.RUnlock()
143 result := make([]string, 0, len(m.bannedIPs))
@@ -148,7 +148,7 @@ func (m *IPManager) GetBannedIPs() []string {
148 }
149
150 // SetBannedIPs sets the banned IPs list (for loading from settings).
151 -func (m *IPManager) SetBannedIPs(ips []string) {
151 +func (m *IPFilter) SetBannedIPs(ips []string) {
152 m.mu.Lock()
153 defer m.mu.Unlock()
154 m.bannedIPs = make(map[string]struct{}, len(ips))
@@ -158,7 +158,7 @@ func (m *IPManager) SetBannedIPs(ips []string) {
158 }
159
160 // RegisterLeaseIP associates a lease ID with an IP address.
161 -func (m *IPManager) RegisterLeaseIP(leaseID, ip string) {
161 +func (m *IPFilter) RegisterLeaseIP(leaseID, ip string) {
162 m.mu.Lock()
163 defer m.mu.Unlock()
164 if leaseID == "" || ip == "" {
@@ -184,7 +184,7 @@ func (m *IPManager) RegisterLeaseIP(leaseID, ip string) {
184 }
185
186 // removeLeaseFromIP removes a lease from IP's lease list (must hold lock).
187 -func (m *IPManager) removeLeaseFromIP(leaseID, ip string) {
187 +func (m *IPFilter) removeLeaseFromIP(leaseID, ip string) {
188 leases := m.ipToLeases[ip]
189 for i, id := range leases {
190 if id == leaseID {
@@ -198,14 +198,14 @@ func (m *IPManager) removeLeaseFromIP(leaseID, ip string) {
198 }
199
200 // GetLeaseIP returns the IP address for a lease ID.
201 -func (m *IPManager) GetLeaseIP(leaseID string) string {
201 +func (m *IPFilter) GetLeaseIP(leaseID string) string {
202 m.mu.RLock()
203 defer m.mu.RUnlock()
204 return m.leaseToIP[leaseID]
205 }
206
207 // GetIPLeases returns all lease IDs for an IP.
208 -func (m *IPManager) GetIPLeases(ip string) []string {
208 +func (m *IPFilter) GetIPLeases(ip string) []string {
209 m.mu.RLock()
210 defer m.mu.RUnlock()
211 result := make([]string, len(m.ipToLeases[ip]))
@@ -214,7 +214,7 @@ func (m *IPManager) GetIPLeases(ip string) []string {
214 }
215
216 // RemoveLeaseIP removes lease-to-IP mapping for a lease ID.
217 -func (m *IPManager) RemoveLeaseIP(leaseID string) {
217 +func (m *IPFilter) RemoveLeaseIP(leaseID string) {
218 m.mu.Lock()
219 defer m.mu.Unlock()
220
portal/policy/ip_filter_test.go renamed
+9 -9
@@ -1,4 +1,4 @@
1 -package manager
1 +package policy
2
3 import (
4 "net"
@@ -73,36 +73,36 @@ func TestIsTrustedProxyRemoteAddr(t *testing.T) {
73 }
74
75 func TestIsIPBannedByPolicy(t *testing.T) {
76 - ipManager := NewIPManager()
77 - ipManager.BanIP("203.0.113.22")
76 + ipFilter := NewIPFilter()
77 + ipFilter.BanIP("203.0.113.22")
78
79 tests := []struct {
80 name string
81 - manager *IPManager
81 + filter *IPFilter
82 candidate string
83 want bool
84 }{
85 {
86 name: "nil manager",
87 - manager: nil,
87 + filter: nil,
88 candidate: "203.0.113.22",
89 want: false,
90 },
91 {
92 name: "empty candidate",
93 - manager: ipManager,
93 + filter: ipFilter,
94 candidate: " ",
95 want: false,
96 },
97 {
98 name: "trimmed banned ip",
99 - manager: ipManager,
99 + filter: ipFilter,
100 candidate: " 203.0.113.22 ",
101 want: true,
102 },
103 {
104 name: "not banned ip",
105 - manager: ipManager,
105 + filter: ipFilter,
106 candidate: "203.0.113.99",
107 want: false,
108 },
@@ -110,7 +110,7 @@ func TestIsIPBannedByPolicy(t *testing.T) {
110
111 for _, tt := range tests {
112 t.Run(tt.name, func(t *testing.T) {
113 - if got := IsIPBannedByPolicy(tt.manager, tt.candidate); got != tt.want {
113 + if got := IsIPBannedByPolicy(tt.filter, tt.candidate); got != tt.want {
114 t.Fatalf("IsIPBannedByPolicy(%q)=%v, want %v", tt.candidate, got, tt.want)
115 }
116 })
portal/policy/rate_limiter.go renamed
+15 -15
@@ -1,4 +1,4 @@
1 -package manager
1 +package policy
2
3 import (
4 "io"
@@ -11,17 +11,17 @@ import (
11 "github.com/rs/zerolog/log"
12 )
13
14 -// BPSManager manages per-lease bytes-per-second rate limiting.
15 -type BPSManager struct {
14 +// RateLimiter manages per-lease bytes-per-second rate limiting.
15 +type RateLimiter struct {
16 bpsLimits map[string]int64
17 bpsBuckets map[string]*Bucket
18 defaultBPS int64
19 mu sync.Mutex
20 }
21
22 -// NewBPSManager creates a new BPS manager.
23 -func NewBPSManager() *BPSManager {
24 - return &BPSManager{
22 +// NewRateLimiter creates a new BPS manager.
23 +func NewRateLimiter() *RateLimiter {
24 + return &RateLimiter{
25 bpsLimits: make(map[string]int64),
26 bpsBuckets: make(map[string]*Bucket),
27 defaultBPS: 0,
@@ -29,7 +29,7 @@ func NewBPSManager() *BPSManager {
29 }
30
31 // SetBPSLimit sets the BPS limit for a lease.
32 -func (m *BPSManager) SetBPSLimit(leaseID string, bps int64) {
32 +func (m *RateLimiter) SetBPSLimit(leaseID string, bps int64) {
33 m.mu.Lock()
34 defer m.mu.Unlock()
35 if bps <= 0 {
@@ -43,7 +43,7 @@ func (m *BPSManager) SetBPSLimit(leaseID string, bps int64) {
43 }
44
45 // GetBPSLimit returns the BPS limit for a lease (0 = unlimited).
46 -func (m *BPSManager) GetBPSLimit(leaseID string) int64 {
46 +func (m *RateLimiter) GetBPSLimit(leaseID string) int64 {
47 m.mu.Lock()
48 defer m.mu.Unlock()
49 if v, ok := m.bpsLimits[leaseID]; ok {
@@ -53,7 +53,7 @@ func (m *BPSManager) GetBPSLimit(leaseID string) int64 {
53 }
54
55 // GetAllBPSLimits returns a copy of all BPS limits.
56 -func (m *BPSManager) GetAllBPSLimits() map[string]int64 {
56 +func (m *RateLimiter) GetAllBPSLimits() map[string]int64 {
57 m.mu.Lock()
58 defer m.mu.Unlock()
59 result := make(map[string]int64, len(m.bpsLimits))
@@ -62,7 +62,7 @@ func (m *BPSManager) GetAllBPSLimits() map[string]int64 {
62 }
63
64 // SetDefaultBPS sets the default BPS limit for new leases.
65 -func (m *BPSManager) SetDefaultBPS(bps int64) {
65 +func (m *RateLimiter) SetDefaultBPS(bps int64) {
66 m.mu.Lock()
67 defer m.mu.Unlock()
68 if bps < 0 {
@@ -72,14 +72,14 @@ func (m *BPSManager) SetDefaultBPS(bps int64) {
72 }
73
74 // GetDefaultBPS returns the default BPS limit.
75 -func (m *BPSManager) GetDefaultBPS() int64 {
75 +func (m *RateLimiter) GetDefaultBPS() int64 {
76 m.mu.Lock()
77 defer m.mu.Unlock()
78 return m.defaultBPS
79 }
80
81 // GetBucket returns a rate limit bucket for a lease, creating one if needed.
82 -func (m *BPSManager) GetBucket(leaseID string) *Bucket {
82 +func (m *RateLimiter) GetBucket(leaseID string) *Bucket {
83 m.mu.Lock()
84 defer m.mu.Unlock()
85
@@ -103,7 +103,7 @@ func (m *BPSManager) GetBucket(leaseID string) *Bucket {
103 }
104
105 // CleanupLease removes BPS data for a lease.
106 -func (m *BPSManager) CleanupLease(leaseID string) {
106 +func (m *RateLimiter) CleanupLease(leaseID string) {
107 m.mu.Lock()
108 defer m.mu.Unlock()
109 delete(m.bpsLimits, leaseID)
@@ -111,14 +111,14 @@ func (m *BPSManager) CleanupLease(leaseID string) {
111 }
112
113 // Copy copies data with rate limiting.
114 -func (m *BPSManager) Copy(dst io.Writer, src io.Reader, leaseID string) (int64, error) {
114 +func (m *RateLimiter) Copy(dst io.Writer, src io.Reader, leaseID string) (int64, error) {
115 bucket := m.GetBucket(leaseID)
116 return Copy(dst, src, bucket)
117 }
118
119 // EstablishRelayWithBPS sets up bidirectional relay with BPS limiting.
120 // In the new TLS passthrough architecture, this uses net.Conn.
121 -func EstablishRelayWithBPS(clientConn, leaseConn net.Conn, leaseID string, bpsManager *BPSManager) {
121 +func EstablishRelayWithBPS(clientConn, leaseConn net.Conn, leaseID string, bpsManager *RateLimiter) {
122 bpsLimit := bpsManager.GetBPSLimit(leaseID)
123 log.Info().
124 Str("lease_id", leaseID).
sdk/client.go
+182 -5
@@ -2,24 +2,40 @@
2 package sdk
3
4 import (
5 + "context"
6 "crypto/rand"
7 + "crypto/sha256"
8 "crypto/tls"
9 + "crypto/x509"
10 "encoding/hex"
11 "errors"
12 "fmt"
13 "net"
14 "net/url"
15 + "os"
16 + "path/filepath"
17 + "strings"
18 "sync"
19 "time"
20
21 "github.com/rs/zerolog/log"
22
23 + "github.com/gosuda/keyless_tls/keyless/lifecycle"
24 +
25 "gosuda.org/portal/portal"
18 - "gosuda.org/portal/portal/controlplane"
26 "gosuda.org/portal/portal/keyless"
27 + "gosuda.org/portal/portal/netutil"
28 "gosuda.org/portal/types"
29 )
30
31 +const (
32 + keylessDirEnvVar = "KEYLESS_DIR"
33 + defaultKeylessDir = "/etc/portal/keyless"
34 + keylessFullChainFile = "fullchain.pem"
35 + keylessPrivateKeyFile = "privatekey.pem"
36 + keylessLifecycleStateSubdir = "lifecycle-identities"
37 +)
38 +
39 // SDK-specific errors.
40 var (
41 ErrNoAvailableRelay = errors.New("no available relay")
@@ -79,11 +95,11 @@ func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Liste
95 if name == "" {
96 return nil, errors.New("name is required")
97 }
82 - if !types.IsValidLeaseName(name) {
98 + if !netutil.IsValidLeaseName(name) {
99 return nil, ErrInvalidName
100 }
101
86 - relayAddrs, err := types.NormalizeRelayAPIURLs(c.config.BootstrapServers)
102 + relayAddrs, err := netutil.NormalizeRelayAPIURLs(c.config.BootstrapServers)
103 if err != nil {
104 return nil, ErrNoAvailableRelay
105 }
@@ -92,7 +108,7 @@ func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Liste
108 if err != nil {
109 return nil, err
110 }
95 - controlPlaneIdentity, err := controlplane.IssueIdentity(lease.ID)
111 + controlPlaneIdentity, err := acquireLifecycleIdentity(lease.ID)
112 if err != nil {
113 return nil, err
114 }
@@ -183,6 +199,167 @@ func (c *Client) newLease(name string, options ...types.MetadataOption) (*portal
199 return lease, nil
200 }
201
202 +func acquireLifecycleIdentity(leaseID string) (tls.Certificate, error) {
203 + manager, err := newLifecycleManager()
204 + if err != nil {
205 + return tls.Certificate{}, fmt.Errorf("initialize keyless lifecycle manager: %w", err)
206 + }
207 +
208 + ctx := context.Background()
209 + bundle, err := loadOrAcquireLifecycleIdentityBundle(ctx, manager, leaseID)
210 + if err != nil {
211 + return tls.Certificate{}, fmt.Errorf("acquire lifecycle identity for lease %s: %w", leaseID, err)
212 + }
213 +
214 + cert, leaf, bundle, err := decodeLifecycleIdentityBundleWithReissue(ctx, manager, leaseID, bundle)
215 + if err != nil {
216 + return tls.Certificate{}, err
217 + }
218 +
219 + if _, err := manager.ValidateIdentity(leaseID, leaf); err != nil {
220 + bundle, err = repairLifecycleIdentityBundle(ctx, manager, leaseID, err)
221 + if err != nil {
222 + return tls.Certificate{}, err
223 + }
224 +
225 + cert, leaf, err = tlsCertificateFromLifecycleBundle(bundle)
226 + if err != nil {
227 + return tls.Certificate{}, err
228 + }
229 + if _, err := manager.ValidateIdentity(leaseID, leaf); err != nil {
230 + return tls.Certificate{}, fmt.Errorf("validate renewed lifecycle identity for lease %s: %w", leaseID, err)
231 + }
232 + }
233 +
234 + return cert, nil
235 +}
236 +
237 +func loadOrAcquireLifecycleIdentityBundle(ctx context.Context, manager *lifecycle.Manager, leaseID string) (*lifecycle.IdentityBundle, error) {
238 + bundle, err := manager.LoadIdentity(ctx, leaseID)
239 + switch {
240 + case errors.Is(err, lifecycle.ErrLeaseNotFound):
241 + bundle, err = manager.IssueIdentity(ctx, leaseID, lifecycle.ChallengeProof{}, nil)
242 + case errors.Is(err, lifecycle.ErrCorruptStore):
243 + bundle, err = manager.ReissueIdentity(ctx, leaseID, lifecycle.ChallengeProof{}, "corrupt_store")
244 + }
245 + return bundle, err
246 +}
247 +
248 +func decodeLifecycleIdentityBundleWithReissue(
249 + ctx context.Context,
250 + manager *lifecycle.Manager,
251 + leaseID string,
252 + bundle *lifecycle.IdentityBundle,
253 +) (tls.Certificate, *x509.Certificate, *lifecycle.IdentityBundle, error) {
254 + cert, leaf, err := tlsCertificateFromLifecycleBundle(bundle)
255 + if err == nil {
256 + return cert, leaf, bundle, nil
257 + }
258 +
259 + reissued, reissueErr := manager.ReissueIdentity(ctx, leaseID, lifecycle.ChallengeProof{}, "bundle_parse_failure")
260 + if reissueErr != nil {
261 + return tls.Certificate{}, nil, nil, fmt.Errorf("decode lifecycle identity for lease %s: %w", leaseID, err)
262 + }
263 +
264 + cert, leaf, err = tlsCertificateFromLifecycleBundle(reissued)
265 + if err != nil {
266 + return tls.Certificate{}, nil, nil, fmt.Errorf("decode reissued lifecycle identity for lease %s: %w", leaseID, err)
267 + }
268 + return cert, leaf, reissued, nil
269 +}
270 +
271 +func repairLifecycleIdentityBundle(
272 + ctx context.Context,
273 + manager *lifecycle.Manager,
274 + leaseID string,
275 + validateErr error,
276 +) (*lifecycle.IdentityBundle, error) {
277 + var (
278 + bundle *lifecycle.IdentityBundle
279 + err error
280 + )
281 +
282 + switch {
283 + case errors.Is(validateErr, lifecycle.ErrCorruptStore):
284 + bundle, err = manager.ReissueIdentity(ctx, leaseID, lifecycle.ChallengeProof{}, "validate_corrupt_store")
285 + case errors.Is(validateErr, lifecycle.ErrInvalidCert), errors.Is(validateErr, lifecycle.ErrOverlapExpired):
286 + bundle, err = manager.RenewIdentity(ctx, leaseID)
287 + if errors.Is(err, lifecycle.ErrCorruptStore) {
288 + bundle, err = manager.ReissueIdentity(ctx, leaseID, lifecycle.ChallengeProof{}, "renew_corrupt_store")
289 + }
290 + default:
291 + return nil, fmt.Errorf("validate lifecycle identity for lease %s: %w", leaseID, validateErr)
292 + }
293 + if err != nil {
294 + return nil, fmt.Errorf("repair lifecycle identity for lease %s: %w", leaseID, err)
295 + }
296 +
297 + return bundle, nil
298 +}
299 +
300 +func newLifecycleManager() (*lifecycle.Manager, error) {
301 + keylessDir := strings.TrimSpace(os.Getenv(keylessDirEnvVar))
302 + if keylessDir == "" {
303 + keylessDir = defaultKeylessDir
304 + }
305 +
306 + certPath := filepath.Join(keylessDir, keylessFullChainFile)
307 + keyPath := filepath.Join(keylessDir, keylessPrivateKeyFile)
308 + certPEM, err := os.ReadFile(certPath)
309 + if err != nil {
310 + return nil, fmt.Errorf("read keyless issuer certificate %q: %w", certPath, err)
311 + }
312 + keyPEM, err := os.ReadFile(keyPath)
313 + if err != nil {
314 + return nil, fmt.Errorf("read keyless issuer private key %q: %w", keyPath, err)
315 + }
316 + if _, err = tls.X509KeyPair(certPEM, keyPEM); err != nil {
317 + return nil, fmt.Errorf("load keyless issuer key pair from %q and %q: %w", certPath, keyPath, err)
318 + }
319 +
320 + secret := sha256.Sum256(keyPEM)
321 + storeDir := filepath.Join(keylessDir, keylessLifecycleStateSubdir)
322 + store, err := lifecycle.NewDiskStore(storeDir, secret[:])
323 + if err != nil {
324 + return nil, fmt.Errorf("create keyless lifecycle store %q: %w", storeDir, err)
325 + }
326 +
327 + manager, err := lifecycle.NewManager(lifecycle.ManagerConfig{
328 + Store: store,
329 + IssuerCertPEM: certPEM,
330 + IssuerKeyPEM: keyPEM,
331 + })
332 + if err != nil {
333 + return nil, fmt.Errorf("create keyless lifecycle manager: %w", err)
334 + }
335 + return manager, nil
336 +}
337 +
338 +func tlsCertificateFromLifecycleBundle(bundle *lifecycle.IdentityBundle) (tls.Certificate, *x509.Certificate, error) {
339 + if bundle == nil {
340 + return tls.Certificate{}, nil, errors.New("lifecycle identity bundle is required")
341 + }
342 + if len(bundle.ChainPEM) == 0 {
343 + return tls.Certificate{}, nil, errors.New("lifecycle identity certificate chain is empty")
344 + }
345 + if len(bundle.KeyPEM) == 0 {
346 + return tls.Certificate{}, nil, errors.New("lifecycle identity private key is empty")
347 + }
348 +
349 + cert, err := tls.X509KeyPair(bundle.ChainPEM, bundle.KeyPEM)
350 + if err != nil {
351 + return tls.Certificate{}, nil, fmt.Errorf("load lifecycle identity key pair for lease %s: %w", bundle.LeaseID, err)
352 + }
353 + if len(cert.Certificate) == 0 {
354 + return tls.Certificate{}, nil, fmt.Errorf("lifecycle identity certificate chain missing leaf for lease %s", bundle.LeaseID)
355 + }
356 + leaf, err := x509.ParseCertificate(cert.Certificate[0])
357 + if err != nil {
358 + return tls.Certificate{}, nil, fmt.Errorf("parse lifecycle identity leaf certificate for lease %s: %w", bundle.LeaseID, err)
359 + }
360 + return cert, leaf, nil
361 +}
362 +
363 func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []func(), error) {
364 parsed, err := url.Parse(relayAddr)
365 if err != nil {
@@ -192,7 +369,7 @@ func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []fun
369 if keylessServerName == "" {
370 return nil, nil, fmt.Errorf("relay hostname is required: %s", relayAddr)
371 }
195 - baseHost := types.PortalRootHost(relayAddr)
372 + baseHost := netutil.PortalRootHost(relayAddr)
373 if baseHost == "" {
374 return nil, nil, fmt.Errorf("keyless base host is required for relay %s", relayAddr)
375 }
sdk/listener.go
+10 -8
@@ -19,6 +19,8 @@ import (
19 "github.com/rs/zerolog/log"
20
21 "gosuda.org/portal/portal"
22 + "gosuda.org/portal/portal/contracts"
23 + "gosuda.org/portal/portal/netutil"
24 "gosuda.org/portal/types"
25 )
26
@@ -124,16 +126,16 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, c
126 return nil, errors.New("control plane client certificate is required")
127 }
128
127 - apiURL, err := types.NormalizeRelayAPIURL(relayAddr)
129 + apiURL, err := netutil.NormalizeRelayAPIURL(relayAddr)
130 if err != nil {
131 return nil, err
132 }
131 - host := types.PortalRootHost(apiURL)
133 + host := netutil.PortalRootHost(apiURL)
134 clientTransport := http.DefaultTransport.(*http.Transport).Clone()
135 clientTransport.TLSClientConfig = &tls.Config{
136 MinVersion: tls.VersionTLS12,
137 ServerName: host,
136 - InsecureSkipVerify: types.IsLocalhost(host),
138 + InsecureSkipVerify: netutil.IsLocalhost(host),
139 Certificates: []tls.Certificate{controlPlaneCert},
140 }
141
@@ -408,7 +410,7 @@ func (l *Listener) openReverseConnection() (net.Conn, error) {
410 tlsConn := tls.Client(rawConn, &tls.Config{
411 MinVersion: tls.VersionTLS12,
412 ServerName: serverName,
411 - InsecureSkipVerify: types.IsLocalhost(serverName),
413 + InsecureSkipVerify: netutil.IsLocalhost(serverName),
414 Certificates: []tls.Certificate{l.controlPlaneCert},
415 })
416 err = tlsConn.HandshakeContext(ctx)
@@ -662,7 +664,7 @@ func (l *Listener) registerWithRelay() error {
664 ReverseToken: l.lease.ReverseToken,
665 }
666
665 - return l.postJSON(types.PathSDKRegister, reqBody)
667 + return l.postJSON(contracts.PathSDKRegister, reqBody)
668 }
669
670 func (l *Listener) unregisterFromRelay() error {
@@ -670,7 +672,7 @@ func (l *Listener) unregisterFromRelay() error {
672 LeaseID: l.lease.ID,
673 ReverseToken: l.lease.ReverseToken,
674 }
673 - return l.postJSON(types.PathSDKUnregister, reqBody)
675 + return l.postJSON(contracts.PathSDKUnregister, reqBody)
676 }
677
678 func (l *Listener) sendKeepalive() error {
@@ -678,7 +680,7 @@ func (l *Listener) sendKeepalive() error {
680 LeaseID: l.lease.ID,
681 ReverseToken: l.lease.ReverseToken,
682 }
681 - return l.postJSON(types.PathSDKRenew, reqBody)
683 + return l.postJSON(contracts.PathSDKRenew, reqBody)
684 }
685
686 func (l *Listener) postJSON(path string, body any) error {
@@ -757,7 +759,7 @@ func relayConnectURL(relayAddr, leaseID, token string) (string, error) {
759 if u.Scheme != "https" {
760 return "", fmt.Errorf("unsupported relay URL scheme: %q (use https)", u.Scheme)
761 }
760 - u.Path = types.PathSDKConnect
762 + u.Path = contracts.PathSDKConnect
763 q := u.Query()
764 q.Set("lease_id", leaseID)
765 u.RawQuery = q.Encode()
types/api.go
+3 -2
@@ -42,8 +42,9 @@ const (
42
43 // APIError is the normalized API error payload.
44 type APIError struct {
45 - Code string `json:"code"`
46 - Message string `json:"message"`
45 + Code string `json:"code"`
46 + Message string `json:"message"`
47 + StatusCode int `json:"-"` // HTTP status code, not serialized
48 }
49
50 // APIEnvelope is the canonical response wrapper for relay APIs.
types/lease.go new
+21
@@ -0,0 +1,21 @@
1 +package types
2 +
3 +import "time"
4 +
5 +// Lease represents a registered service.
6 +type Lease struct {
7 + Expires time.Time `json:"expires"`
8 + ID string `json:"id"`
9 + Name string `json:"name"`
10 + ReverseToken string `json:"-"`
11 + Metadata Metadata `json:"metadata"`
12 + TLS bool `json:"tls"`
13 +}
14 +
15 +// LeaseEntry represents a registered lease with expiration tracking.
16 +type LeaseEntry struct {
17 + Lease *Lease
18 + Expires time.Time
19 + LastSeen time.Time
20 + FirstSeen time.Time
21 +}