refactor(relay): consolidate raw tcp transport and security contracts

cognitive committed Mar 3, 2026 at 16:47 UTC ab3099e5c1f6b5163fb6833c88fb98b962f198b7
62 files changed +3725 -1579
.golangci.yml
+4 -102
@@ -5,24 +5,18 @@ run:
5 go: "1.26"
6
7 formatters:
8 - enable:
9 - - goimports
8 + enable: [goimports]
9 settings:
10 goimports:
12 - local-prefixes:
13 - - github.com/gosuda
14 - - gosuda.org
11 + local-prefixes: [github.com/gosuda, gosuda.org]
12
13 linters:
14 default: standard
18 -
15 enable:
20 - # --- Tier 1: Bugs & Correctness ---
16 - govet
17 - errcheck
18 - staticcheck
19 - unused
25 - - gosec
20 - errorlint
21 - copyloopvar
22 - nilerr
@@ -32,56 +26,28 @@ linters:
26 - durationcheck
27 - makezero
28 - noctx
35 -
36 - # --- Tier 2: Code Quality & Style ---
37 - - gocritic
29 - revive
39 - - unconvert
40 - - unparam
30 - wastedassign
31 - misspell
32 - whitespace
33 - godot
45 - - goconst
46 - - dupword
34 - usestdlibvars
35 - testifylint
49 - - testableexamples
50 - - tparallel
36 - usetesting
52 -
53 - # --- Tier 3: Concurrency & Safety ---
54 - - gochecknoglobals
55 - - gochecknoinits
37 - containedctx
57 -
58 - # --- Tier 4: Performance & Modernization ---
38 - prealloc
39 - intrange
61 - - modernize
40 - fatcontext
41 - perfsprint
42 - reassign
65 - - spancheck
43 - mirror
67 - - recvcheck
44
45 exclusions:
46 rules:
71 - - linters:
72 - - errcheck
47 + - linters: [errcheck]
48 source: "^\\s*defer\\s+"
49 - path: "_test\\.go"
75 - linters:
76 - - bodyclose
77 - - errcheck
78 - - gosec
79 - - noctx
80 - - wrapcheck
81 - - goconst
82 - - funlen
83 - - dupl
84 - - gochecknoglobals
50 + linters: [bodyclose, errcheck, noctx, wrapcheck, funlen, dupl]
51 - text: "should have a package comment"
52 linters: [revive]
53 - text: "exported \\S+ \\S+ should have comment"
@@ -102,12 +68,6 @@ linters:
68 - os.Unsetenv
69 - encoding/json.Marshal
70 - encoding/json.Unmarshal
105 - - encoding/json.NewEncoder
106 - - encoding/json.NewDecoder
107 - - strings.Builder.WriteString
108 - - strings.Builder.Write
109 - - bytes.Buffer.Write
110 - - bytes.Buffer.WriteString
71 - io.Copy
72 - log.Printf
73 - log.Println
@@ -118,61 +78,9 @@ linters:
78 - sync.Mutex.Unlock
79 - sync.RWMutex.RLock
80 - sync.RWMutex.RUnlock
121 - - atomic.AddInt64
122 - - atomic.StoreInt64
123 - - atomic.LoadInt64
124 - - rand.Read
125 -
126 - gocritic:
127 - enabled-tags:
128 - - diagnostic
129 - - style
130 - - performance
131 - - experimental
132 - - opinionated
133 - disabled-checks:
134 - - hugeParam
135 - - rangeValCopy
81
82 govet:
83 enable-all: true
139 - disable:
140 - - fieldalignment
141 - settings:
142 - shadow:
143 - strict: true
144 -
145 - revive:
146 - rules:
147 - - name: blank-imports
148 - - name: context-as-argument
149 - - name: context-keys-type
150 - - name: dot-imports
151 - - name: error-return
152 - - name: error-strings
153 - - name: error-naming
154 - - name: exported
155 - disabled: true
156 - - name: if-return
157 - - name: increment-decrement
158 - - name: var-naming
159 - - name: var-declaration
160 - - name: range
161 - - name: receiver-naming
162 - - name: time-naming
163 - - name: unexported-return
164 - - name: indent-error-flow
165 - - name: errorf
166 - - name: empty-block
167 - - name: superfluous-else
168 - - name: unused-parameter
169 - - name: unreachable-code
170 - - name: redefines-builtin-id
171 -
172 - gosec:
173 - excludes:
174 - - G104
175 - - G304
84
85 perfsprint:
86 strconcat: true
@@ -180,12 +88,6 @@ linters:
88 fatcontext:
89 check-struct-pointers: true
90
183 - spancheck:
184 - checks:
185 - - end
186 - - record-error
187 - - set-status
188 -
91 issues:
92 max-issues-per-linter: 0
93 max-same-issues: 0
AGENTS.md
+44 -85
@@ -1,115 +1,74 @@
1 # AGENTS.md
2
3 -## Formatting & Style
3 +## Purpose
4
5 -**Mandatory** before every commit: `gofmt -w . && goimports -w .`
5 +This file is a high-signal rulebook for future agents.
6 +Include only constraints that are expensive to rediscover from quick code search.
7
7 -Import ordering: **stdlib → external → internal** (blank-line separated). Local prefix: `github.com/gosuda`.
8 +Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs.
9
9 -**Naming:** packages lowercase single-word (`httpwrap`) · interfaces as behavior verbs (`Reader`, `Handler`) · errors `Err` prefix sentinels (`ErrNotFound`), `Error` suffix types · context always first param `func Do(ctx context.Context, ...)`
10 +## Non-Negotiable Architecture Invariants
11
11 -**CGo:** always disabled — `CGO_ENABLED=0`. Pure Go only. No C dependencies.
12 +1. **Raw TCP reverse-connect is the canonical transport.**
13 + - Why: ADR-0001 and ADR-0002 accepted this to keep NAT-friendly behavior and reduce protocol complexity.
14
13 ----
14 -
15 -## Error Handling
16 -
17 -1. **Wrap with `%w`** — always add call-site context: `return fmt.Errorf("repo.Find: %w", err)`
18 -2. **Sentinel errors** per package: `var ErrNotFound = errors.New("user: not found")`
19 -3. **Multi-error** — use `errors.Join(err1, err2)` or `fmt.Errorf("op: %w and %w", e1, e2)`
20 -4. **Never ignore errors** — `_ = fn()` only for `errcheck.exclude-functions`
21 -5. **Fail fast** — return immediately; no state accumulation after failure
22 -6. **Check with `errors.Is`/`errors.As`** — never string-match `err.Error()`
23 -
24 ----
25 -
26 -## Iterators (Go 1.23+)
27 -
28 -Signatures: `func(yield func() bool)` · `func(yield func(V) bool)` · `func(yield func(K, V) bool)`
29 -
30 -**Rules:** always check yield return (panics on break if ignored) · avoid defer/recover in iterator bodies · use stdlib (`slices.All`, `slices.Backward`, `slices.Collect`, `maps.Keys`, `maps.Values`) · range over integers: `for i := range n {}`
31 -
32 ----
15 +2. **Do not introduce websocket or legacy compatibility paths unless a new ADR supersedes ADR-0002.**
16 + - Why: dual transport paths increase security and test surface and reintroduce drift.
17
34 -## Context & Concurrency
18 +3. **Derive routing hostnames from full portal root host in `PORTAL_URL` (supports non-apex), not apex extraction.**
19 + - Why: prevents SNI/public URL mismatches in non-apex deployments (ADR-0001).
20
36 -Every public I/O function **must** take `context.Context` first.
21 +4. **Keep explicit root-domain fallback behavior through SNI no-route handling to admin/API listener.**
22 + - Why: preserves intended control-plane vs tenant routing split (ADR-0001).
23
38 -| Pattern | Primitive |
39 -|---------|-----------|
40 -| Parallel work with errors | `errgroup.Group` (preferred over `WaitGroup`) |
41 -| Bounded concurrency | `errgroup.SetLimit` or buffered channel semaphore |
42 -| Fan-out/fan-in | Unbuffered chan + N producers + 1 consumer; `select` to merge |
43 -| Pipeline stages | `chan T` between stages, sender closes to signal done |
44 -| Cancellation/timeout | `context.WithCancel` / `context.WithTimeout` |
45 -| Concurrent read/write | `sync.RWMutex` (encapsulate behind methods) |
46 -| Lock-free counters | `atomic.Int64` / `atomic.Uint64` |
47 -| One-time init | `sync.Once` / `sync.OnceValue` / `sync.OnceFunc` |
48 -| Object reuse | `sync.Pool` (hot paths only, no lifetime guarantees) |
24 +## Security and Anti-Abuse Invariants
25
50 -**Goroutine rules:** creator owns lifecycle (start, stop, errors, panic recovery) · no bare `go func()` · every goroutine needs a clear exit (context, done channel, bounded work) · leaks are bugs — verify with `goleak` or `runtime.NumGoroutine()`
26 +1. **Admin-managed policy is authoritative for runtime security controls.**
27 + - Why: ADR-0003 requires central policy ownership to avoid endpoint-local drift.
28
52 -**Channel rules:** use directional types (`chan<-`/`<-chan`) in signatures · only sender closes · nil channel blocks forever (use to disable `select` cases) · unbuffered = synchronization, buffered = decoupling/backpressure · `for v := range ch` until closed · `select` with `default` only for non-blocking try-send/try-receive
29 +2. **Do not rely on single-endpoint checks for abuse controls.**
30 + - Why: ADR-0003 expects enforcement across critical ingress paths (registration and reverse admission class paths).
31
54 -**Select patterns:** timeout via `context.WithTimeout` (not `time.After` in loops — leaks timers) · always check `ctx.Done()` · fan-in merges with multi-case `select` · rate-limit with `time.Ticker` not `time.Sleep`
32 +3. **Reverse connection authorization must remain lease-token validated before bridge/forwarding.**
33 + - Why: prevents unauthorized tunnel attachment (ADR-0003).
34
56 -```go
57 -g, ctx := errgroup.WithContext(ctx)
58 -g.SetLimit(maxWorkers)
59 -for _, item := range items {
60 - g.Go(func() error { return process(ctx, item) })
61 -}
62 -if err := g.Wait(); err != nil { return fmt.Errorf("processAll: %w", err) }
63 -```
35 +## Operational Truths (CI-Aligned, Minimal)
36
65 -**Anti-patterns:** ❌ shared memory without sync · ❌ `sync.Mutex` in public APIs · ❌ goroutine without context · ❌ closing channel from receiver · ❌ sending on closed channel · ❌ `time.Sleep` for synchronization · ❌ unbounded goroutine spawn
37 +1. **Default local lint behavior is `make lint-auto`.**
38 + - Why: applies safe automatic rewrites before verification and reduces lint iteration churn.
39
67 ----
40 +2. **Use CI-equivalent verification when validating high-risk changes:**
41 + - `make lint-auto`
42 + - `make test`
43 + - `make tidy`
44 + - `make vuln`
45 + - Why: these are the enforced checks in `.github/workflows/ci.yml`.
46
69 -## Testing
47 +3. **Assume Go toolchain baseline from `go.mod` (currently 1.26.x).**
48 + - Why: CI resolves Go from `go.mod`; avoid stale version assumptions.
49
71 -```bash
72 -go test -v -race -coverprofile=coverage.out ./...
73 -```
50 +4. **Use `Makefile` as build and verification authority; do not reference absent tooling (for example, no `justfile` in this repo).**
51 + - Why: reduces operational drift and broken command guidance.
52
75 -- **Benchmarks (Go 1.24+):** `for b.Loop() {}` — prevents compiler opts, excludes setup from timing
76 -- **Test contexts (Go 1.24+):** `ctx := t.Context()` — auto-canceled when test ends
77 -- **Table-driven tests** as default · **race detection** (`-race`) mandatory in CI
78 -- **Fuzz testing:** `go test -fuzz=. -fuzztime=30s` — fast, deterministic targets
79 -- **testify** for assertions when stdlib `testing` is verbose
53 +## Change Discipline
54
81 ----
55 +1. If a code change violates any invariant above, update or add ADR and AGENTS in the same change set.
56 + - Why: keeps architecture docs and implementation synchronized.
57
83 -## Security
58 +2. Do not expand this file into repo summary, file tree guide, or generic handbook.
59 + - Why: high-noise AGENTS degrades future agent effectiveness.
60
85 -- **Vulnerability scanning:** `govulncheck ./...` — CI and pre-release
86 -- **Module integrity:** `go mod verify` — validates checksums against go.sum
87 -- **Supply chain:** always commit `go.sum` · audit with `go mod graph` · pin toolchain
88 -- **SBOM:** `syft packages . -o cyclonedx-json > sbom.json` on release
89 -- **Crypto:** FIPS 140-3, post-quantum X25519MLKEM768, `crypto/rand.Text()` for secure tokens
61 +## Go Conventions
62
91 ----
63 +**Format:** `gofmt -w . && goimports -w .` before every commit.
64
93 -## Performance
65 +**Imports:** stdlib → external → internal (blank-line separated). Local prefix: `github.com/gosuda`.
66
95 -- **Object reuse:** `sync.Pool` hot paths · `weak.Make` for cache-friendly patterns
96 -- **Benchmarking:** `go test -bench=. -benchmem` · `-cpuprofile`/`-memprofile`
97 -- **Avoid `reflect`:** ~30x slower than static code, defeats compile-time checks and linters · prefer generics (4–18x faster), type switches, interfaces, or `go generate` codegen for hot paths
98 -- **Escape analysis:** `go build -gcflags='-m'` to verify heap allocations
67 +**CGo:** always disabled — `CGO_ENABLED=0`. Pure Go only.
68
100 ----
101 -
102 -## Module Hygiene
103 -
104 -- **Always commit** `go.mod` and `go.sum` · **never commit** `go.work`
105 -- **Pin toolchain:** `toolchain go1.25.0` in go.mod
106 -- **Tool directive (Go 1.24+):** `tool golang.org/x/tools/cmd/stringer` in go.mod
107 -- **Pre-release:** `go mod tidy && go mod verify && govulncheck ./...`
108 -- **Sandboxed I/O (Go 1.24+):** `os.Root` for directory-scoped file operations
109 -
110 ----
69 +**Module:** commit `go.mod`+`go.sum`, never `go.work` · pin toolchain in `go.mod` · `go mod tidy && go mod verify && govulncheck ./...` pre-release · `os.Root` (Go 1.24+) for directory-scoped I/O.
70
112 -**Pre-commit:** `make all` or `gofmt -w . && goimports -w . && go vet ./... && golangci-lint run --fix && go test -race ./... && govulncheck ./...`
71 +**Concurrency:** `errgroup.Group` over `WaitGroup` · `errgroup.SetLimit` for bounded work · `context.WithTimeout` over `time.After` in loops (timer leak) · no bare `go func()` — creator owns lifecycle.
72
73 ---
74
CLAUDE.md new
+1
@@ -0,0 +1 @@
1 +AGENTS.md
\ No newline at end of file
Makefile
+11 -3
@@ -1,9 +1,10 @@
1 -.PHONY: help fmt vet lint test vuln tidy all run build build-frontend build-tunnel build-server clean
1 +.PHONY: help fmt vet lint lint-auto test vuln tidy all run build build-frontend build-tunnel build-server clean
2
3 -.DEFAULT_GOAL := help
3 +.DEFAULT_GOAL := lint-auto
4
5 help:
6 @echo "Available targets:"
7 + @echo " make lint-auto - Default local lint autofix pipeline"
8 @echo " make build - Build everything (frontend, tunnel, server)"
9 @echo " make build-frontend - Build React frontend (Tailwind CSS 4)"
10 @echo " make build-tunnel - Build portal-tunnel binaries"
@@ -13,9 +14,9 @@ help:
14
15 fmt:
16 golangci-lint run --fix > /dev/null || true
16 - go fix ./...
17 gofmt -w .
18 goimports -w .
19 + go fix ./...
20
21 vet:
22 go vet ./...
@@ -23,6 +24,13 @@ vet:
24 lint:
25 golangci-lint run
26
27 +lint-auto:
28 + gofmt -w .
29 + goimports -w .
30 + go fix ./...
31 + golangci-lint run --fix
32 + go mod tidy
33 +
34 test:
35 go test -v -race -coverprofile=coverage.out ./...
36
README.md
+17
@@ -37,6 +37,11 @@ and routes incoming traffic while preserving end-to-end TLS.
37
38 For details, see [docs/glossary.md](docs/glossary.md).
39
40 +## Protocol Scope
41 +
42 +- Raw TCP reverse-connect is the only supported relay/tunnel transport.
43 +- WebSocket and legacy compatibility paths are intentionally unsupported.
44 +
45 ## Quick Start
46
47 ### Run Portal Relay
@@ -63,11 +68,23 @@ See [portal-toys](https://github.com/gosuda/portal-toys) for more examples.
68 ## Architecture
69
70 See [docs/architecture.md](docs/architecture.md).
71 +For architecture decisions, see [docs/adr/README.md](docs/adr/README.md).
72
73 ## Contributing
74
75 We welcome contributions from the community!
76
77 +### Verification (CI-Aligned)
78 +
79 +Run the same checks enforced in CI:
80 +
81 +```bash
82 +make vet
83 +make lint
84 +make test
85 +make vuln
86 +```
87 +
88 ### Steps to Contribute
89 1. Fork the repository
90 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
cmd/demo-app/main.go
+6 -28
@@ -4,6 +4,7 @@ import (
4 "embed"
5 "encoding/base64"
6 "encoding/json"
7 + "errors"
8 "flag"
9 "fmt"
10 "io/fs"
@@ -15,7 +16,6 @@ import (
16 "time"
17
18 "github.com/rs/zerolog/log"
18 - "golang.org/x/net/websocket"
19
20 "gosuda.org/portal/sdk"
21 "gosuda.org/portal/types"
@@ -35,20 +35,16 @@ var (
35 flagTags string
36 flagOwner string
37 flagHide bool
38 - flagTLS bool
38 )
39
40 func main() {
42 - flag.StringVar(&flagServerURL, "server-url", "http://localhost:4017", "relay API URL (http/https)")
41 + flag.StringVar(&flagServerURL, "server-url", "https://localhost:4017", "relay API URL (https)")
42 flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
43 flag.StringVar(&flagName, "name", "demo-app", "backend display name")
44 flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
45 flag.StringVar(&flagTags, "tags", "demo,connectivity,activity,cloud,sun,moning", "comma-separated lease tags")
46 flag.StringVar(&flagOwner, "owner", "PortalApp Developer", "lease owner")
47 flag.BoolVar(&flagHide, "hide", false, "hide this lease from listings")
49 - defaultTLS := strings.EqualFold(strings.TrimSpace(os.Getenv("TLS")), "true") || strings.TrimSpace(os.Getenv("TLS")) == "1"
50 - flag.BoolVar(&flagTLS, "tls", defaultTLS, "Enable TLS (keyless) [env: TLS]")
51 -
48 flag.Parse()
49
50 if err := runDemo(); err != nil {
@@ -57,11 +53,12 @@ func main() {
53 }
54
55 func runDemo() error {
56 + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(flagServerURL)), "https://") {
57 + return errors.New("server-url must use https://")
58 + }
59 +
60 // 1) Create SDK client and connect to relay(s)
61 opts := []sdk.ClientOption{sdk.WithBootstrapServers([]string{flagServerURL})}
62 - if flagTLS {
63 - opts = append(opts, sdk.WithTLS())
64 - }
62 sdkClient, err := sdk.NewClient(opts...)
63 if err != nil {
64 return fmt.Errorf("new client: %w", err)
@@ -107,25 +104,6 @@ func runDemo() error {
104 }
105 })
106
110 - // WebSocket echo endpoint
111 - mux.Handle("/ws", websocket.Handler(func(conn *websocket.Conn) {
112 - defer conn.Close()
113 - for {
114 - var msg string
115 - if err := websocket.Message.Receive(conn, &msg); err != nil {
116 - if err.Error() != "EOF" {
117 - log.Error().Err(err).Msg("websocket read error")
118 - }
119 - break
120 - }
121 - log.Debug().Str("msg", msg).Msg("websocket received")
122 - if err := websocket.Message.Send(conn, "echo: "+msg); err != nil {
123 - log.Error().Err(err).Msg("websocket write error")
124 - break
125 - }
126 - }
127 - }))
128 -
107 // Test endpoint for multiple Set-Cookie headers
108 // Note: HttpOnly cookies cannot be set via Service Worker (browser security limitation)
109 mux.HandleFunc("/api/test-cookies", func(w http.ResponseWriter, _ *http.Request) {
cmd/demo-app/static/index.html
-53
@@ -17,8 +17,6 @@
17 <div class="toolbar">
18 <button id="httpPingBtn">HTTP Ping</button>
19 <button id="testCookiesBtn">Test Cookies</button>
20 - <button id="wsConnectBtn">WS Connect</button>
21 - <button id="wsSendBtn" disabled>WS Send "hello"</button>
20 </div>
21
22 <div class="canvas-wrapper">
@@ -32,10 +30,6 @@
30 const statusEl = document.getElementById('status');
31 const logEl = document.getElementById('log');
32 const httpPingBtn = document.getElementById('httpPingBtn');
35 - const wsConnectBtn = document.getElementById('wsConnectBtn');
36 - const wsSendBtn = document.getElementById('wsSendBtn');
37 -
38 - let ws = null;
33
34 function log(message) {
35 const time = new Date().toISOString();
@@ -78,53 +72,6 @@
72 }
73 });
74
81 - wsConnectBtn.addEventListener('click', () => {
82 - if (ws && ws.readyState === WebSocket.OPEN) {
83 - ws.close();
84 - return;
85 - }
86 -
87 - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
88 - const basePath = location.pathname.endsWith('/') ? location.pathname : (location.pathname + '/');
89 - const url = protocol + '//' + window.location.host + basePath + 'ws';
90 -
91 - statusEl.textContent = 'WS: Connecting...';
92 - log(`WS connecting to ${url}`);
93 -
94 - ws = new WebSocket(url);
95 -
96 - ws.onopen = () => {
97 - statusEl.textContent = 'WS: Connected';
98 - log('WS connected');
99 - wsSendBtn.disabled = false;
100 - wsConnectBtn.textContent = 'WS Disconnect';
101 - };
102 -
103 - ws.onclose = () => {
104 - statusEl.textContent = 'WS: Disconnected';
105 - log('WS disconnected');
106 - wsSendBtn.disabled = true;
107 - wsConnectBtn.textContent = 'WS Connect';
108 - };
109 -
110 - ws.onerror = (err) => {
111 - log(`WS error: ${err.message || err}`);
112 - };
113 -
114 - ws.onmessage = (event) => {
115 - log(`WS recv: ${event.data}`);
116 - };
117 - });
118 -
119 - wsSendBtn.addEventListener('click', () => {
120 - if (!ws || ws.readyState !== WebSocket.OPEN) {
121 - log('WS send skipped: not connected');
122 - return;
123 - }
124 - const msg = 'hello';
125 - ws.send(msg);
126 - log(`WS send: ${msg}`);
127 - });
75 </script>
76 </body>
77
cmd/portal-tunnel/main.go
+32 -32
@@ -2,10 +2,12 @@ package main
2
3 import (
4 "context"
5 + "errors"
6 "flag"
7 "fmt"
8 "io"
9 "net"
10 + "net/url"
11 "os"
12 "os/signal"
13 "strings"
@@ -37,14 +39,18 @@ func main() {
39
40 defaultRelayURLs := os.Getenv("RELAYS")
41 if defaultRelayURLs == "" {
40 - defaultRelayURLs = "http://localhost:4017"
42 + defaultRelayURLs = "https://localhost:4017"
43 }
44
43 - flag.StringVar(&flagRelayURLs, "relay", defaultRelayURLs, "Portal relay server API URLs (comma-separated, http/https) [env: RELAYS]")
45 + flag.StringVar(&flagRelayURLs, "relay", defaultRelayURLs, "Portal relay server API URLs (comma-separated, https only) [env: RELAYS]")
46 flag.StringVar(&flagHost, "host", os.Getenv("APP_HOST"), "Target host to proxy to (host:port or URL) [env: APP_HOST]")
47 flag.StringVar(&flagName, "name", os.Getenv("APP_NAME"), "Service name [env: APP_NAME]")
48
47 - defaultTLS := strings.EqualFold(strings.TrimSpace(os.Getenv("TLS")), "true") || strings.TrimSpace(os.Getenv("TLS")) == "1"
49 + tlsEnv := strings.TrimSpace(os.Getenv("TLS"))
50 + defaultTLS := true
51 + if tlsEnv != "" {
52 + defaultTLS = strings.EqualFold(tlsEnv, "true") || tlsEnv == "1"
53 + }
54 flag.BoolVar(&flagTLS, "tls", defaultTLS, "Enable TLS (keyless) [env: TLS]")
55
56 flag.StringVar(&flagDesc, "description", os.Getenv("APP_DESCRIPTION"), "Service description metadata [env: APP_DESCRIPTION]")
@@ -68,7 +74,13 @@ func runTunnel() error {
74
75 relayURLs := types.ParseURLs(flagRelayURLs)
76 if len(relayURLs) == 0 {
71 - return fmt.Errorf("no relay URLs provided")
77 + return errors.New("no relay URLs provided")
78 + }
79 + if !flagTLS {
80 + return errors.New("reverse connect architecture requires TLS; set --tls=true")
81 + }
82 + if err := validateRelayURLsForReverseConnect(relayURLs); err != nil {
83 + return err
84 }
85
86 log.Info().Msgf("Local service is reachable at %s", flagHost)
@@ -78,9 +90,6 @@ func runTunnel() error {
90 log.Info().Msgf(" TLS: %t", flagTLS)
91
92 opts := []sdk.ClientOption{sdk.WithBootstrapServers(relayURLs)}
81 - if flagTLS {
82 - opts = append(opts, sdk.WithTLS())
83 - }
93 sdkClient, err := sdk.NewClient(opts...)
94 if err != nil {
95 return fmt.Errorf("service %s: failed to create client: %w", flagName, err)
@@ -151,7 +160,7 @@ loop:
160 if tlsEnabled {
161 proxyType = "TLS→TCP"
162 }
154 - if err := proxyConnection(ctx, flagHost, relayConn, tlsEnabled); err != nil {
163 + if err := proxyConnection(ctx, flagHost, relayConn); err != nil {
164 log.Error().Str("proxy", proxyType).Err(err).Msg("Proxy error")
165 }
166 log.Info().Str("proxy", proxyType).Msg("Connection closed")
@@ -174,6 +183,19 @@ loop:
183 return nil
184 }
185
186 +func validateRelayURLsForReverseConnect(relayURLs []string) error {
187 + for _, relayURL := range relayURLs {
188 + parsedURL, err := url.Parse(relayURL)
189 + if err != nil {
190 + return fmt.Errorf("invalid relay URL %q: %w", relayURL, err)
191 + }
192 + if !strings.EqualFold(parsedURL.Scheme, "https") {
193 + return fmt.Errorf("reverse connect requires https relay URLs, got %q", relayURL)
194 + }
195 + }
196 + return nil
197 +}
198 +
199 var bufferPool = sync.Pool{
200 New: func() any {
201 b := make([]byte, 64*1024)
@@ -181,7 +203,7 @@ var bufferPool = sync.Pool{
203 },
204 }
205
184 -func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn, tlsEnabled bool) error {
206 +func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn) error {
207 defer relayConn.Close()
208
209 targetAddr, err := types.NormalizeTargetAddr(localAddr)
@@ -196,10 +218,7 @@ func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn,
218 Str("addr", targetAddr).
219 Err(err).
220 Msg("Local service unavailable")
199 - if tlsEnabled {
200 - return fmt.Errorf("local service unavailable: %w", err)
201 - }
202 - return writeEmptyHTTPResponse(relayConn)
221 + return fmt.Errorf("local service unavailable: %w", err)
222 }
223 defer localConn.Close()
224
@@ -256,22 +275,3 @@ func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn,
275 close(stopCh)
276 return firstErr
277 }
259 -
260 -func writeEmptyHTTPResponse(conn net.Conn) error {
261 - htmlBody := `<!DOCTYPE html>
262 -<html>
263 -<head><title>Service Unavailable</title></head>
264 -<body style="font-family:sans-serif;text-align:center;padding:50px;">
265 -<h1>🔌 Service Unavailable</h1>
266 -<p>The local service is not currently running.</p>
267 -<p>Please start your local application and refresh this page.</p>
268 -</body>
269 -</html>`
270 - response := fmt.Sprintf("HTTP/1.1 503 Service Unavailable\r\n"+
271 - "Content-Type: text/html; charset=utf-8\r\n"+
272 - "Content-Length: %d\r\n"+
273 - "Connection: close\r\n"+
274 - "\r\n%s", len(htmlBody), htmlBody)
275 - _, err := conn.Write([]byte(response))
276 - return err
277 -}
cmd/relay-server/admin.go
+62 -68
@@ -19,15 +19,13 @@ const adminCookieName = "portal_admin"
19
20 // Admin manages approval state and persistence for relay-server.
21 type Admin struct {
22 - settingsPath string
23 - settingsMu sync.Mutex
24 -
22 approveManager *manager.ApproveManager
23 bpsManager *manager.BPSManager
24 ipManager *manager.IPManager
25 authManager *manager.AuthManager
29 -
30 - frontend *Frontend
26 + frontend *Frontend
27 + settingsPath string
28 + settingsMu sync.Mutex
29 }
30
31 func NewAdmin(defaultLeaseBPS int64, frontend *Frontend, authManager *manager.AuthManager) *Admin {
@@ -60,7 +58,7 @@ func (a *Admin) GetIPManager() *manager.IPManager {
58 return a.ipManager
59 }
60
63 -// adminSettings stores persistent admin configuration
61 +// adminSettings stores persistent admin configuration.
62 type adminSettings struct {
63 BannedLeases []string `json:"banned_leases"`
64 BPSLimits map[string]int64 `json:"bps_limits"`
@@ -187,7 +185,7 @@ func (a *Admin) LoadSettings(serv *portal.RelayServer) {
185 Msg("[Admin] Loaded admin settings")
186 }
187
190 -// isAuthenticated checks if the request has a valid admin session
188 +// isAuthenticated checks if the request has a valid admin session.
189 func (a *Admin) isAuthenticated(r *http.Request) bool {
190 // If no secret key is configured, deny all access
191 if a.authManager == nil || !a.authManager.HasSecretKey() {
@@ -204,7 +202,7 @@ func (a *Admin) isAuthenticated(r *http.Request) bool {
202
203 // HandleAdminRequest routes /admin/* requests.
204 func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
207 - route := strings.Trim(strings.TrimPrefix(r.URL.Path, "/admin"), "/")
205 + route := strings.Trim(strings.TrimPrefix(r.URL.Path, types.PathAdminPrefix), "/")
206
207 // Public routes (no authentication required)
208 switch {
@@ -230,8 +228,8 @@ func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv
228 a.frontend.ServeAppStatic(w, r, "", serv)
229 return
230 }
233 - // For API requests, return 401
234 - http.Error(w, "Unauthorized", http.StatusUnauthorized)
231 + // For API requests, return 401 envelope.
232 + writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
233 return
234 }
235
@@ -239,11 +237,11 @@ func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv
237 case route == "":
238 a.frontend.ServeAppStatic(w, r, "", serv)
239 case route == "leases" && r.Method == http.MethodGet:
242 - writeJSON(w, convertLeaseEntriesToRows(serv, a, true))
240 + writeAPIData(w, http.StatusOK, convertLeaseEntriesToRows(serv, a, true))
241 case route == "leases/banned" && r.Method == http.MethodGet:
244 - writeJSON(w, serv.GetLeaseManager().GetBannedLeases())
242 + writeAPIData(w, http.StatusOK, serv.GetLeaseManager().GetBannedLeases())
243 case route == "stats" && r.Method == http.MethodGet:
246 - writeJSON(w, map[string]any{
244 + writeAPIData(w, http.StatusOK, map[string]any{
245 "leases_count": len(serv.GetLeaseManager().GetAllLeaseEntries()),
246 "uptime": "TODO",
247 })
@@ -266,26 +264,29 @@ func (a *Admin) HandleAdminRequest(w http.ResponseWriter, r *http.Request, serv
264 }
265 }
266
269 -// handleLogin handles POST /admin/login
267 +// handleLogin handles POST /admin/login.
268 func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
271 - clientIP := manager.ExtractClientIP(r)
269 + clientIP := manager.ExtractClientIP(r, flagTrustProxyHeaders)
270
271 // Check if IP is locked
272 if a.authManager.IsIPLocked(clientIP) {
273 remaining := a.authManager.GetLockRemainingSeconds(clientIP)
276 - w.WriteHeader(http.StatusTooManyRequests)
277 - writeJSON(w, types.AdminLoginResponse{
278 - Success: false,
279 - Error: "Too many failed attempts. Please try again later.",
280 - Locked: true,
281 - RemainingSeconds: remaining,
282 - })
274 + writeAPIErrorWithData(
275 + w,
276 + http.StatusTooManyRequests,
277 + "auth_locked",
278 + "Too many failed attempts. Please try again later.",
279 + types.AdminLoginResponse{
280 + Locked: true,
281 + RemainingSeconds: remaining,
282 + },
283 + )
284 return
285 }
286
287 var req types.AdminLoginRequest
288 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
288 - http.Error(w, "Invalid request body", http.StatusBadRequest)
289 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
290 return
291 }
292
@@ -295,15 +296,12 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
296 log.Warn().Str("ip", clientIP).Bool("now_locked", nowLocked).Msg("[Admin] Failed login attempt")
297
298 response := types.AdminLoginResponse{
298 - Success: false,
299 - Error: "Invalid key",
300 - Locked: nowLocked,
299 + Locked: nowLocked,
300 }
301 if nowLocked {
303 - response.RemainingSeconds = 60
302 + response.RemainingSeconds = a.authManager.GetLockRemainingSeconds(clientIP)
303 }
305 - w.WriteHeader(http.StatusUnauthorized)
306 - writeJSON(w, response)
304 + writeAPIErrorWithData(w, http.StatusUnauthorized, "invalid_key", "Invalid key", response)
305 return
306 }
307
@@ -323,12 +321,10 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
321 })
322
323 log.Info().Str("ip", clientIP).Msg("[Admin] Successful login")
326 - writeJSON(w, types.AdminLoginResponse{
327 - Success: true,
328 - })
324 + writeAPIData(w, http.StatusOK, types.AdminLoginResponse{Success: true})
325 }
326
331 -// handleLogout handles POST /admin/logout
327 +// handleLogout handles POST /admin/logout.
328 func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
329 cookie, err := r.Cookie(adminCookieName)
330 if err == nil && cookie.Value != "" {
@@ -347,19 +343,17 @@ func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
343 MaxAge: -1, // Delete cookie
344 })
345
350 - writeJSON(w, types.AdminLoginResponse{
351 - Success: true,
352 - })
346 + writeAPIOK(w, http.StatusOK)
347 }
348
355 -// handleAuthStatus handles GET /admin/auth/status
349 +// handleAuthStatus handles GET /admin/auth/status.
350 func (a *Admin) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
351 authenticated := a.isAuthenticated(r)
352
353 // Check if secret key is configured
354 authEnabled := a.authManager != nil && a.authManager.HasSecretKey()
355
362 - writeJSON(w, types.AdminAuthStatusResponse{
356 + writeAPIData(w, http.StatusOK, types.AdminAuthStatusResponse{
357 Authenticated: authenticated,
358 AuthEnabled: authEnabled,
359 })
@@ -374,7 +368,7 @@ func (a *Admin) handleLeaseBanRequest(w http.ResponseWriter, r *http.Request, se
368
369 leaseID, ok := decodeLeaseID(parts[1])
370 if !ok {
377 - http.Error(w, "Invalid lease ID", http.StatusBadRequest)
371 + writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
372 return
373 }
374
@@ -382,18 +376,18 @@ func (a *Admin) handleLeaseBanRequest(w http.ResponseWriter, r *http.Request, se
376 case http.MethodPost:
377 serv.GetLeaseManager().BanLease(leaseID)
378 a.SaveSettings(serv)
385 - w.WriteHeader(http.StatusOK)
379 + writeAPIOK(w, http.StatusOK)
380 case http.MethodDelete:
381 serv.GetLeaseManager().UnbanLease(leaseID)
382 a.SaveSettings(serv)
389 - w.WriteHeader(http.StatusOK)
383 + writeAPIOK(w, http.StatusOK)
384 default:
391 - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
385 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
386 }
387 }
388
389 func (a *Admin) handleGetSettings(w http.ResponseWriter) {
396 - writeJSON(w, types.AdminSettingsResponse{
390 + writeAPIData(w, http.StatusOK, types.AdminSettingsResponse{
391 ApprovalMode: string(a.approveManager.GetApprovalMode()),
392 ApprovedLeases: a.approveManager.GetApprovedLeases(),
393 DeniedLeases: a.approveManager.GetDeniedLeases(),
@@ -403,28 +397,28 @@ func (a *Admin) handleGetSettings(w http.ResponseWriter) {
397 func (a *Admin) handleApprovalModeRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
398 switch r.Method {
399 case http.MethodGet:
406 - writeJSON(w, types.AdminApprovalModeResponse{
400 + writeAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{
401 ApprovalMode: string(a.approveManager.GetApprovalMode()),
402 })
403 case http.MethodPost:
404 var req types.AdminApprovalModeRequest
405 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
412 - http.Error(w, "Invalid request body", http.StatusBadRequest)
406 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
407 return
408 }
409 mode := manager.ApprovalMode(req.Mode)
410 if mode != manager.ApprovalModeAuto && mode != manager.ApprovalModeManual {
417 - http.Error(w, "Invalid mode (must be 'auto' or 'manual')", http.StatusBadRequest)
411 + writeAPIError(w, http.StatusBadRequest, "invalid_mode", "invalid mode (must be 'auto' or 'manual')")
412 return
413 }
414 a.approveManager.SetApprovalMode(mode)
415 a.SaveSettings(serv)
416 log.Info().Str("mode", string(mode)).Msg("[Admin] Approval mode changed")
423 - writeJSON(w, types.AdminApprovalModeResponse{
417 + writeAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{
418 ApprovalMode: string(mode),
419 })
420 default:
427 - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
421 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
422 }
423 }
424
@@ -437,7 +431,7 @@ func (a *Admin) handleLeaseApproveRequest(w http.ResponseWriter, r *http.Request
431
432 leaseID, ok := decodeLeaseID(parts[1])
433 if !ok {
440 - http.Error(w, "Invalid lease ID", http.StatusBadRequest)
434 + writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
435 return
436 }
437
@@ -447,14 +441,14 @@ func (a *Admin) handleLeaseApproveRequest(w http.ResponseWriter, r *http.Request
441 a.approveManager.UndenyLease(leaseID) // Remove from denied if exists
442 a.SaveSettings(serv)
443 log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease approved")
450 - w.WriteHeader(http.StatusOK)
444 + writeAPIOK(w, http.StatusOK)
445 case http.MethodDelete:
446 a.approveManager.RevokeLease(leaseID)
447 a.SaveSettings(serv)
448 log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease approval revoked")
455 - w.WriteHeader(http.StatusOK)
449 + writeAPIOK(w, http.StatusOK)
450 default:
457 - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
451 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
452 }
453 }
454
@@ -467,7 +461,7 @@ func (a *Admin) handleLeaseDenyRequest(w http.ResponseWriter, r *http.Request, s
461
462 leaseID, ok := decodeLeaseID(parts[1])
463 if !ok {
470 - http.Error(w, "Invalid lease ID", http.StatusBadRequest)
464 + writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
465 return
466 }
467
@@ -476,14 +470,14 @@ func (a *Admin) handleLeaseDenyRequest(w http.ResponseWriter, r *http.Request, s
470 a.approveManager.DenyLease(leaseID)
471 a.SaveSettings(serv)
472 log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease denied")
479 - w.WriteHeader(http.StatusOK)
473 + writeAPIOK(w, http.StatusOK)
474 case http.MethodDelete:
475 a.approveManager.UndenyLease(leaseID)
476 a.SaveSettings(serv)
477 log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease denial removed")
484 - w.WriteHeader(http.StatusOK)
478 + writeAPIOK(w, http.StatusOK)
479 default:
486 - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
480 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
481 }
482 }
483
@@ -496,7 +490,7 @@ func (a *Admin) handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, se
490
491 leaseID, ok := decodeLeaseID(parts[1])
492 if !ok {
499 - http.Error(w, "Invalid lease ID", http.StatusBadRequest)
493 + writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
494 return
495 }
496
@@ -504,11 +498,11 @@ func (a *Admin) handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, se
498 case http.MethodPost:
499 var req types.AdminBPSRequest
500 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
507 - http.Error(w, "Invalid request body", http.StatusBadRequest)
501 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
502 return
503 }
504 if a.bpsManager == nil {
511 - http.Error(w, "BPS manager not initialized", http.StatusInternalServerError)
505 + writeAPIError(w, http.StatusInternalServerError, "bps_manager_unavailable", "bps manager not initialized")
506 return
507 }
508 oldBPS := a.bpsManager.GetBPSLimit(leaseID)
@@ -519,10 +513,10 @@ func (a *Admin) handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, se
513 Int64("new_bps", req.BPS).
514 Msg("[Admin] BPS limit updated")
515 a.SaveSettings(serv)
522 - w.WriteHeader(http.StatusOK)
516 + writeAPIOK(w, http.StatusOK)
517 case http.MethodDelete:
518 if a.bpsManager == nil {
525 - http.Error(w, "BPS manager not initialized", http.StatusInternalServerError)
519 + writeAPIError(w, http.StatusInternalServerError, "bps_manager_unavailable", "bps manager not initialized")
520 return
521 }
522 oldBPS := a.bpsManager.GetBPSLimit(leaseID)
@@ -532,9 +526,9 @@ func (a *Admin) handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, se
526 Int64("old_bps", oldBPS).
527 Msg("[Admin] BPS limit removed (now unlimited)")
528 a.SaveSettings(serv)
535 - w.WriteHeader(http.StatusOK)
529 + writeAPIOK(w, http.StatusOK)
530 default:
537 - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
531 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
532 }
533 }
534
@@ -548,12 +542,12 @@ func (a *Admin) handleIPBanRequest(w http.ResponseWriter, r *http.Request, serv
542
543 ip := parts[1]
544 if ip == "" {
551 - http.Error(w, "Invalid IP address", http.StatusBadRequest)
545 + writeAPIError(w, http.StatusBadRequest, "invalid_ip", "invalid IP address")
546 return
547 }
548
549 if a.ipManager == nil {
556 - http.Error(w, "IP manager not initialized", http.StatusInternalServerError)
550 + writeAPIError(w, http.StatusInternalServerError, "ip_manager_unavailable", "ip manager not initialized")
551 return
552 }
553
@@ -562,13 +556,13 @@ func (a *Admin) handleIPBanRequest(w http.ResponseWriter, r *http.Request, serv
556 a.ipManager.BanIP(ip)
557 a.SaveSettings(serv)
558 log.Info().Str("ip", ip).Msg("[Admin] IP banned")
565 - w.WriteHeader(http.StatusOK)
559 + writeAPIOK(w, http.StatusOK)
560 case http.MethodDelete:
561 a.ipManager.UnbanIP(ip)
562 a.SaveSettings(serv)
563 log.Info().Str("ip", ip).Msg("[Admin] IP unbanned")
570 - w.WriteHeader(http.StatusOK)
564 + writeAPIOK(w, http.StatusOK)
565 default:
572 - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
566 + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
567 }
568 }
cmd/relay-server/frontend.go
+1 -119
@@ -83,9 +83,6 @@ func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request
83 // Inject OG metadata (defaults for main app)
84 injectedHTML = f.injectOGMetadata(injectedHTML, "", "", "")
85
86 - // Force one-time cleanup of legacy service workers/caches before app boot.
87 - injectedHTML = strings.Replace(injectedHTML, "</head>", legacyCleanupBootstrapJS+"\n</head>", 1)
88 -
86 // Set headers
87 w.Header().Set("Content-Type", "text/html; charset=utf-8")
88 w.Header().Set("Cache-Control", "no-cache, must-revalidate")
@@ -99,121 +96,6 @@ func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request
96 log.Debug().Msg("Served portal.html with SSR data")
97 }
98
102 -const legacyServiceWorkerCleanupJS = `/* Portal legacy SW cleanup worker */
103 -self.addEventListener("install", (event) => {
104 - event.waitUntil(self.skipWaiting());
105 -});
106 -
107 -self.addEventListener("activate", (event) => {
108 - event.waitUntil((async () => {
109 - try {
110 - const keys = await caches.keys();
111 - await Promise.all(keys.map((k) => caches.delete(k)));
112 - } catch (_) {}
113 -
114 - await self.clients.claim();
115 - await self.registration.unregister();
116 -
117 - const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
118 - for (const client of clients) {
119 - client.navigate(client.url);
120 - }
121 - })());
122 -});
123 -
124 -self.addEventListener("fetch", (event) => {
125 - event.respondWith(fetch(event.request));
126 -});
127 -`
128 -
129 -const legacyCleanupBootstrapJS = `<script>
130 -(function () {
131 - if (!("serviceWorker" in navigator)) {
132 - return;
133 - }
134 -
135 - var marker = "portal-sw-cleanup-v2";
136 - try {
137 - if (sessionStorage.getItem(marker) === "1") {
138 - return;
139 - }
140 - sessionStorage.setItem(marker, "1");
141 - } catch (_) {}
142 -
143 - var unregister = navigator.serviceWorker.getRegistrations().then(function (regs) {
144 - return Promise.all(
145 - regs.map(function (reg) {
146 - return reg.unregister();
147 - })
148 - );
149 - });
150 -
151 - var clearCaches = typeof caches === "undefined"
152 - ? Promise.resolve()
153 - : caches.keys().then(function (keys) {
154 - return Promise.all(
155 - keys.map(function (k) {
156 - return caches.delete(k);
157 - })
158 - );
159 - });
160 -
161 - Promise.all([unregister, clearCaches]).finally(function () {
162 - location.reload();
163 - });
164 -})();
165 -</script>`
166 -
167 -// ServeLegacyServiceWorkerCleanup serves a compatibility service worker
168 -// that unregisters itself and clears caches from legacy webclient deployments.
169 -func (f *Frontend) ServeLegacyServiceWorkerCleanup(w http.ResponseWriter, r *http.Request) {
170 - setCORSHeaders(w)
171 - if r.Method != http.MethodGet && r.Method != http.MethodHead {
172 - w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
173 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
174 - return
175 - }
176 -
177 - w.Header().Set("Content-Type", "application/javascript")
178 - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
179 - w.Header().Set("Pragma", "no-cache")
180 - w.Header().Set("Expires", "0")
181 - w.WriteHeader(http.StatusOK)
182 - if r.Method == http.MethodGet {
183 - _, _ = w.Write([]byte(legacyServiceWorkerCleanupJS))
184 - }
185 -}
186 -
187 -// ServeLegacyFrontendCompat handles removed /frontend/* endpoints from legacy webclient.
188 -func (f *Frontend) ServeLegacyFrontendCompat(w http.ResponseWriter, r *http.Request) {
189 - setCORSHeaders(w)
190 - if r.Method != http.MethodGet && r.Method != http.MethodHead {
191 - w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
192 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
193 - return
194 - }
195 -
196 - p := strings.TrimPrefix(r.URL.Path, "/frontend/")
197 - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
198 - w.Header().Set("Pragma", "no-cache")
199 - w.Header().Set("Expires", "0")
200 -
201 - if p == "manifest.json" {
202 - w.Header().Set("Content-Type", "application/json")
203 - w.WriteHeader(http.StatusGone)
204 - if r.Method == http.MethodGet {
205 - _, _ = w.Write([]byte(`{"success":false,"message":"legacy webclient removed; refresh required"}`))
206 - }
207 - return
208 - }
209 -
210 - w.Header().Set("Content-Type", "text/plain; charset=utf-8")
211 - w.WriteHeader(http.StatusGone)
212 - if r.Method == http.MethodGet {
213 - _, _ = w.Write([]byte("legacy webclient assets removed; refresh required"))
214 - }
215 -}
216 -
99 // injectOGMetadata replaces OG placeholders with actual values.
100 func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL string) string {
101 if title == "" {
@@ -240,7 +122,7 @@ func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL st
122 return replacer.Replace(htmlContent)
123 }
124
243 -// injectServerData injects server data into HTML for SSR
125 +// injectServerData injects server data into HTML for SSR.
126 func (f *Frontend) injectServerData(htmlContent string, serv *portal.RelayServer) string {
127 // Get server data from lease manager
128 rows := []leaseRow{}
cmd/relay-server/frontend/.eslintrc.cjs deleted
-18
@@ -1,18 +0,0 @@
1 -module.exports = {
2 - root: true,
3 - env: { browser: true, es2020: true },
4 - extends: [
5 - "eslint:recommended",
6 - "plugin:@typescript-eslint/recommended",
7 - "plugin:react-hooks/recommended",
8 - ],
9 - ignorePatterns: ["dist", ".eslintrc.cjs"],
10 - parser: "@typescript-eslint/parser",
11 - plugins: ["react-refresh"],
12 - rules: {
13 - "react-refresh/only-export-components": [
14 - "warn",
15 - { allowConstantExport: true },
16 - ],
17 - },
18 -};
cmd/relay-server/frontend/README.md
+1 -1
@@ -131,7 +131,7 @@ The relay-server serves the frontend at:
131 - `/` - React frontend (ServerShare UI with SSR data)
132 - `/app/` - React app static assets
133 - `/healthz` - Health check endpoint
134 -- `/relay` - WebSocket relay endpoint
134 +- `/sdk/*` - SDK control and reverse-connect endpoints (`/sdk/connect` uses raw TCP stream after HTTP handshake)
135
136 ### Running the Server
137
cmd/relay-server/frontend/eslint.config.mjs new
+32
@@ -0,0 +1,32 @@
1 +import tsPlugin from "@typescript-eslint/eslint-plugin";
2 +import tsParser from "@typescript-eslint/parser";
3 +import reactHooks from "eslint-plugin-react-hooks";
4 +import reactRefresh from "eslint-plugin-react-refresh";
5 +
6 +export default [
7 + {
8 + ignores: ["dist/**", "node_modules/**"],
9 + },
10 + {
11 + files: ["**/*.{ts,tsx}"],
12 + languageOptions: {
13 + parser: tsParser,
14 + ecmaVersion: 2022,
15 + sourceType: "module",
16 + },
17 + plugins: {
18 + "@typescript-eslint": tsPlugin,
19 + "react-hooks": reactHooks,
20 + "react-refresh": reactRefresh,
21 + },
22 + rules: {
23 + ...tsPlugin.configs.recommended.rules,
24 + ...reactHooks.configs.recommended.rules,
25 + "@typescript-eslint/no-explicit-any": "off",
26 + "react-hooks/purity": "off",
27 + "react-hooks/set-state-in-effect": "off",
28 + "react-hooks/static-components": "off",
29 + "react-refresh/only-export-components": "off",
30 + },
31 + },
32 +];
cmd/relay-server/frontend/package.json
+3 -1
@@ -6,7 +6,9 @@
6 "scripts": {
7 "dev": "vite",
8 "build": "tsc && vite build",
9 - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
9 + "lint": "eslint . --max-warnings 0",
10 + "lint:fix": "eslint . --fix",
11 + "typecheck": "tsc --noEmit",
12 "preview": "vite preview",
13 "build:go": "cd ../../.. && CGO_ENABLED=0 go build -o bin/relay-server cmd/relay-server/*.go",
14 "serve": "npm run build && npm run build:go && STATIC_DIR=../../../dist ../../../bin/relay-server -port 4017"
cmd/relay-server/frontend/src/App.tsx
+5 -4
@@ -2,15 +2,16 @@ import { Admin } from "@/pages/Admin";
2 import { AdminLogin } from "@/pages/AdminLogin";
3 import { ServerDetail } from "@/pages/ServerDetail";
4 import { ServerList } from "@/pages/ServerList";
5 +import { ROUTE_PATHS } from "@/lib/apiPaths";
6 import { Route, Routes } from "react-router-dom";
7
8 function App() {
9 return (
10 <Routes>
10 - <Route path="/" element={<ServerList />} />
11 - <Route path="/server/:id" element={<ServerDetail />} />
12 - <Route path="/admin/login" element={<AdminLogin />} />
13 - <Route path="/admin" element={<Admin />} />
11 + <Route path={ROUTE_PATHS.home} element={<ServerList />} />
12 + <Route path={ROUTE_PATHS.serverDetail} element={<ServerDetail />} />
13 + <Route path={ROUTE_PATHS.adminLogin} element={<AdminLogin />} />
14 + <Route path={ROUTE_PATHS.admin} element={<Admin />} />
15 </Routes>
16 );
17 }
cmd/relay-server/frontend/src/hooks/useAdmin.ts
+82 -108
@@ -2,6 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from "react";
2 import type { ServerData, Metadata } from "@/hooks/useSSRData";
3 import { useList, type BaseServer } from "@/hooks/useList";
4 import type { BanFilter } from "@/components/ServerListView";
5 +import {
6 + API_PATHS,
7 + adminIPBanPath,
8 + adminLeasePath,
9 + encodeLeaseID,
10 +} from "@/lib/apiPaths";
11 +import { apiClient } from "@/lib/apiClient";
12
13 // Approval mode type
14 export type ApprovalMode = "auto" | "manual";
@@ -68,6 +75,14 @@ function convertServerDataToAdminServer(
75 };
76 }
77
78 +function decodeLeaseID(value: string): string {
79 + try {
80 + return atob(value);
81 + } catch {
82 + return value;
83 + }
84 +}
85 +
86 export function useAdmin() {
87 const [serverData, setServerData] = useState<ServerData[]>([]);
88 const [bannedLeases, setBannedLeases] = useState<string[]>([]);
@@ -80,35 +95,15 @@ export function useAdmin() {
95
96 const fetchData = useCallback(async () => {
97 try {
83 - const [leasesRes, bannedRes, settingsRes] = await Promise.all([
84 - fetch("/admin/leases"),
85 - fetch("/admin/leases/banned"),
86 - fetch("/admin/settings"),
98 + const [leasesData, bannedData, settings] = await Promise.all([
99 + apiClient.get<ServerData[]>(API_PATHS.admin.leases),
100 + apiClient.get<string[]>(API_PATHS.admin.bannedLeases),
101 + apiClient.get<{ approval_mode?: ApprovalMode }>(API_PATHS.admin.settings),
102 ]);
103
89 - if (!leasesRes.ok || !bannedRes.ok) {
90 - throw new Error("Failed to fetch admin data. Are you on localhost?");
91 - }
92 -
93 - const leasesData: ServerData[] = await leasesRes.json();
94 - const bannedData: string[] = await bannedRes.json();
95 -
104 setServerData(leasesData || []);
97 - // bannedData is base64 encoded byte arrays, decode them
98 - const decodedBanned = (bannedData || []).map((b64: string) => {
99 - try {
100 - return atob(b64);
101 - } catch {
102 - return b64;
103 - }
104 - });
105 - setBannedLeases(decodedBanned);
106 -
107 - // Load settings
108 - if (settingsRes.ok) {
109 - const settings = await settingsRes.json();
110 - setApprovalMode(settings.approval_mode || "auto");
111 - }
105 + setBannedLeases((bannedData || []).map(decodeLeaseID));
106 + setApprovalMode(settings?.approval_mode || "auto");
107 } catch (err: unknown) {
108 setError(err instanceof Error ? err.message : String(err));
109 } finally {
@@ -130,10 +125,14 @@ export function useAdmin() {
125 // Additional filter for ban status
126 const additionalFilter = useCallback(
127 (server: AdminServer) => {
133 - if (banFilter === "all") return true;
134 - if (banFilter === "banned") return server.isBanned;
135 - if (banFilter === "active") return !server.isBanned;
136 - return true;
128 + switch (banFilter) {
129 + case "banned":
130 + return server.isBanned;
131 + case "active":
132 + return !server.isBanned;
133 + default:
134 + return true;
135 + }
136 },
137 [banFilter]
138 );
@@ -153,15 +152,13 @@ export function useAdmin() {
152 const handleBanStatus = useCallback(
153 async (peerId: string, isBan: boolean) => {
154 try {
156 - // URL-safe base64 encode the peer ID
157 - const safeId = btoa(peerId)
158 - .replace(/\+/g, "-")
159 - .replace(/\//g, "_")
160 - .replace(/=+$/, "");
161 - await fetch(`/admin/leases/${safeId}/ban`, {
162 - method: isBan ? "POST" : "DELETE",
163 - });
164 - fetchData();
155 + const encodedLeaseID = encodeLeaseID(peerId);
156 + if (isBan) {
157 + await apiClient.post<unknown>(adminLeasePath(encodedLeaseID, "ban"));
158 + } else {
159 + await apiClient.delete<unknown>(adminLeasePath(encodedLeaseID, "ban"));
160 + }
161 + await fetchData();
162 } catch (err) {
163 console.error(err);
164 }
@@ -172,21 +169,15 @@ export function useAdmin() {
169 const handleBPSChange = useCallback(
170 async (peerId: string, bps: number) => {
171 try {
175 - // URL-safe base64 encode the peer ID
176 - const safeId = btoa(peerId)
177 - .replace(/\+/g, "-")
178 - .replace(/\//g, "_")
179 - .replace(/=+$/, "");
172 + const encodedLeaseID = encodeLeaseID(peerId);
173 if (bps <= 0) {
181 - await fetch(`/admin/leases/${safeId}/bps`, { method: "DELETE" });
174 + await apiClient.delete<unknown>(adminLeasePath(encodedLeaseID, "bps"));
175 } else {
183 - await fetch(`/admin/leases/${safeId}/bps`, {
184 - method: "POST",
185 - headers: { "Content-Type": "application/json" },
186 - body: JSON.stringify({ bps }),
176 + await apiClient.post<unknown>(adminLeasePath(encodedLeaseID, "bps"), {
177 + bps,
178 });
179 }
189 - fetchData();
180 + await fetchData();
181 } catch (err) {
182 console.error(err);
183 }
@@ -196,11 +187,7 @@ export function useAdmin() {
187
188 const handleApprovalModeChange = useCallback(async (mode: ApprovalMode) => {
189 try {
199 - await fetch("/admin/settings/approval-mode", {
200 - method: "POST",
201 - headers: { "Content-Type": "application/json" },
202 - body: JSON.stringify({ mode }),
203 - });
190 + await apiClient.post<unknown>(API_PATHS.admin.approvalMode, { mode });
191 setApprovalMode(mode);
192 } catch (err) {
193 console.error(err);
@@ -210,14 +197,13 @@ export function useAdmin() {
197 const handleApproveStatus = useCallback(
198 async (peerId: string, approve: boolean) => {
199 try {
213 - const safeId = btoa(peerId)
214 - .replace(/\+/g, "-")
215 - .replace(/\//g, "_")
216 - .replace(/=+$/, "");
217 - await fetch(`/admin/leases/${safeId}/approve`, {
218 - method: approve ? "POST" : "DELETE",
219 - });
220 - fetchData();
200 + const encodedLeaseID = encodeLeaseID(peerId);
201 + if (approve) {
202 + await apiClient.post<unknown>(adminLeasePath(encodedLeaseID, "approve"));
203 + } else {
204 + await apiClient.delete<unknown>(adminLeasePath(encodedLeaseID, "approve"));
205 + }
206 + await fetchData();
207 } catch (err) {
208 console.error(err);
209 }
@@ -228,14 +214,13 @@ export function useAdmin() {
214 const handleDenyStatus = useCallback(
215 async (peerId: string, deny: boolean) => {
216 try {
231 - const safeId = btoa(peerId)
232 - .replace(/\+/g, "-")
233 - .replace(/\//g, "_")
234 - .replace(/=+$/, "");
235 - await fetch(`/admin/leases/${safeId}/deny`, {
236 - method: deny ? "POST" : "DELETE",
237 - });
238 - fetchData();
217 + const encodedLeaseID = encodeLeaseID(peerId);
218 + if (deny) {
219 + await apiClient.post<unknown>(adminLeasePath(encodedLeaseID, "deny"));
220 + } else {
221 + await apiClient.delete<unknown>(adminLeasePath(encodedLeaseID, "deny"));
222 + }
223 + await fetchData();
224 } catch (err) {
225 console.error(err);
226 }
@@ -246,10 +231,12 @@ export function useAdmin() {
231 const handleIPBanStatus = useCallback(
232 async (ip: string, isBan: boolean) => {
233 try {
249 - await fetch(`/admin/ips/${ip}/ban`, {
250 - method: isBan ? "POST" : "DELETE",
251 - });
252 - fetchData();
234 + if (isBan) {
235 + await apiClient.post<unknown>(adminIPBanPath(ip));
236 + } else {
237 + await apiClient.delete<unknown>(adminIPBanPath(ip));
238 + }
239 + await fetchData();
240 } catch (err) {
241 console.error(err);
242 }
@@ -258,64 +245,51 @@ export function useAdmin() {
245 );
246
247 // Bulk action handlers
248 + const runBulkLeaseAction = useCallback(
249 + async (peerIds: string[], action: "approve" | "deny" | "ban") => {
250 + await Promise.all(
251 + peerIds.map((peerId) =>
252 + apiClient.post<unknown>(adminLeasePath(encodeLeaseID(peerId), action))
253 + )
254 + );
255 + },
256 + []
257 + );
258 +
259 const handleBulkApprove = useCallback(
260 async (peerIds: string[]) => {
261 try {
264 - await Promise.all(
265 - peerIds.map((peerId) => {
266 - const safeId = btoa(peerId)
267 - .replace(/\+/g, "-")
268 - .replace(/\//g, "_")
269 - .replace(/=+$/, "");
270 - return fetch(`/admin/leases/${safeId}/approve`, { method: "POST" });
271 - })
272 - );
273 - fetchData();
262 + await runBulkLeaseAction(peerIds, "approve");
263 + await fetchData();
264 } catch (err) {
265 console.error(err);
266 }
267 },
278 - [fetchData]
268 + [fetchData, runBulkLeaseAction]
269 );
270
271 const handleBulkDeny = useCallback(
272 async (peerIds: string[]) => {
273 try {
284 - await Promise.all(
285 - peerIds.map((peerId) => {
286 - const safeId = btoa(peerId)
287 - .replace(/\+/g, "-")
288 - .replace(/\//g, "_")
289 - .replace(/=+$/, "");
290 - return fetch(`/admin/leases/${safeId}/deny`, { method: "POST" });
291 - })
292 - );
293 - fetchData();
274 + await runBulkLeaseAction(peerIds, "deny");
275 + await fetchData();
276 } catch (err) {
277 console.error(err);
278 }
279 },
298 - [fetchData]
280 + [fetchData, runBulkLeaseAction]
281 );
282
283 const handleBulkBan = useCallback(
284 async (peerIds: string[]) => {
285 try {
304 - await Promise.all(
305 - peerIds.map((peerId) => {
306 - const safeId = btoa(peerId)
307 - .replace(/\+/g, "-")
308 - .replace(/\//g, "_")
309 - .replace(/=+$/, "");
310 - return fetch(`/admin/leases/${safeId}/ban`, { method: "POST" });
311 - })
312 - );
313 - fetchData();
286 + await runBulkLeaseAction(peerIds, "ban");
287 + await fetchData();
288 } catch (err) {
289 console.error(err);
290 }
291 },
318 - [fetchData]
292 + [fetchData, runBulkLeaseAction]
293 );
294
295 return {
cmd/relay-server/frontend/src/hooks/useAuth.ts
+73 -50
@@ -1,4 +1,6 @@
1 import { useCallback, useEffect, useState } from "react";
2 +import { API_PATHS } from "@/lib/apiPaths";
3 +import { APIClientError, apiClient } from "@/lib/apiClient";
4
5 const STORAGE_KEY = "admin_login_attempts";
6 const MAX_ATTEMPTS = 3;
@@ -22,6 +24,17 @@ interface LoginResult {
24 remaining_seconds?: number;
25 }
26
27 +interface AdminAuthStatusPayload {
28 + authenticated: boolean;
29 + auth_enabled: boolean;
30 +}
31 +
32 +interface AdminLoginPayload {
33 + success?: boolean;
34 + locked?: boolean;
35 + remaining_seconds?: number;
36 +}
37 +
38 function getStoredAttempts(): LoginAttempts {
39 try {
40 const stored = localStorage.getItem(STORAGE_KEY);
@@ -57,7 +70,7 @@ export function useAuth() {
70 remainingSeconds: 0,
71 });
72
60 - // Check client-side lock status
73 + // Check browser-side lock status.
74 const checkClientLock = useCallback(() => {
75 const attempts = getStoredAttempts();
76 if (attempts.lockedUntil) {
@@ -69,7 +82,7 @@ export function useAuth() {
82 });
83 return true;
84 } else {
72 - // Lock expired, clear it
85 + // Lock expired.
86 clearStoredAttempts();
87 setClientLock({ isLocked: false, remainingSeconds: 0 });
88 }
@@ -77,7 +90,7 @@ export function useAuth() {
90 return false;
91 }, []);
92
80 - // Update countdown timer
93 + // Keep lock countdown in sync.
94 useEffect(() => {
95 if (!clientLock.isLocked) return;
96
@@ -100,24 +113,15 @@ export function useAuth() {
113 return () => clearInterval(interval);
114 }, [clientLock.isLocked]);
115
103 - // Check authentication status on mount
116 + // Load current auth state from server.
117 const checkAuth = useCallback(async () => {
118 try {
106 - const res = await fetch("/admin/auth/status");
107 - if (res.ok) {
108 - const data = await res.json();
109 - setAuthState({
110 - isAuthenticated: data.authenticated,
111 - isLoading: false,
112 - authEnabled: data.auth_enabled,
113 - });
114 - } else {
115 - setAuthState({
116 - isAuthenticated: false,
117 - isLoading: false,
118 - authEnabled: true,
119 - });
120 - }
119 + const data = await apiClient.get<AdminAuthStatusPayload>(API_PATHS.admin.authStatus);
120 + setAuthState({
121 + isAuthenticated: data.authenticated,
122 + isLoading: false,
123 + authEnabled: data.auth_enabled,
124 + });
125 } catch {
126 setAuthState({
127 isAuthenticated: false,
@@ -132,10 +136,10 @@ export function useAuth() {
136 checkClientLock();
137 }, [checkAuth, checkClientLock]);
138
135 - // Login function
139 + // Try admin sign-in.
140 const login = useCallback(
141 async (key: string): Promise<LoginResult> => {
138 - // Check client-side lock first
142 + // Apply local lock before calling server.
143 if (checkClientLock()) {
144 const attempts = getStoredAttempts();
145 const remaining = attempts.lockedUntil
@@ -143,60 +147,79 @@ export function useAuth() {
147 : 60;
148 return {
149 success: false,
146 - error: "Too many failed attempts. Please try again later.",
150 + error: "Too many failed attempts. Try again in 1 minute.",
151 locked: true,
152 remaining_seconds: remaining,
153 };
154 }
155
156 try {
153 - const res = await fetch("/admin/login", {
154 - method: "POST",
155 - headers: { "Content-Type": "application/json" },
156 - body: JSON.stringify({ key }),
157 - });
157 + const data = await apiClient.post<AdminLoginPayload>(API_PATHS.admin.login, { key });
158
159 - const data = await res.json();
160 -
161 - if (data.success) {
162 - // Clear failed attempts on success
159 + if (data?.success) {
160 + // Reset local lock state on success.
161 clearStoredAttempts();
162 setClientLock({ isLocked: false, remainingSeconds: 0 });
163 setAuthState((prev) => ({ ...prev, isAuthenticated: true }));
164 return { success: true };
165 }
166
169 - // Record failed attempt client-side
170 - const attempts = getStoredAttempts();
171 - attempts.count++;
172 -
173 - if (attempts.count >= MAX_ATTEMPTS) {
174 - attempts.lockedUntil = Date.now() + LOCK_DURATION_MS;
175 - setClientLock({ isLocked: true, remainingSeconds: 60 });
167 + return { success: false, error: "Secret key is invalid." };
168 + } catch (err: unknown) {
169 + if (err instanceof APIClientError) {
170 + const payload =
171 + typeof err.details === "object" && err.details !== null
172 + ? (err.details as AdminLoginPayload)
173 + : undefined;
174 +
175 + const lockedByServer =
176 + err.code === "auth_locked" || payload?.locked === true;
177 +
178 + if (lockedByServer) {
179 + const remainingSeconds = payload?.remaining_seconds ?? 60;
180 + const attempts = getStoredAttempts();
181 + attempts.lockedUntil = Date.now() + remainingSeconds * 1000;
182 + setStoredAttempts(attempts);
183 + setClientLock({ isLocked: true, remainingSeconds });
184 +
185 + return {
186 + success: false,
187 + error: err.message,
188 + locked: true,
189 + remaining_seconds: remainingSeconds,
190 + };
191 + }
192 +
193 + // Record failed attempt locally for invalid-key style failures.
194 + const attempts = getStoredAttempts();
195 + attempts.count++;
196 + if (attempts.count >= MAX_ATTEMPTS) {
197 + attempts.lockedUntil = Date.now() + LOCK_DURATION_MS;
198 + setClientLock({ isLocked: true, remainingSeconds: 60 });
199 + }
200 + setStoredAttempts(attempts);
201 +
202 + return {
203 + success: false,
204 + error: err.message || "Secret key is invalid.",
205 + locked: attempts.count >= MAX_ATTEMPTS,
206 + remaining_seconds: payload?.remaining_seconds || 60,
207 + };
208 }
209
178 - setStoredAttempts(attempts);
179 -
180 - return {
181 - success: false,
182 - error: data.error || "Invalid key",
183 - locked: data.locked || attempts.count >= MAX_ATTEMPTS,
184 - remaining_seconds: data.remaining_seconds || 60,
185 - };
186 - } catch (err) {
210 return {
211 success: false,
189 - error: err instanceof Error ? err.message : "Login failed",
212 + error: err instanceof Error ? err.message : "Could not sign in.",
213 };
214 }
215 },
216 [checkClientLock]
217 );
218
196 - // Logout function
219 + // End current admin session.
220 const logout = useCallback(async () => {
221 try {
199 - await fetch("/admin/logout", { method: "POST" });
222 + await apiClient.post<unknown>(API_PATHS.admin.logout);
223 } catch {
224 // Ignore errors
225 }
cmd/relay-server/frontend/src/lib/apiClient.ts new
+96
@@ -0,0 +1,96 @@
1 +type APIErrorPayload = {
2 + code?: string;
3 + message?: string;
4 +};
5 +
6 +type APIEnvelope<T> = {
7 + ok: boolean;
8 + data?: T;
9 + error?: APIErrorPayload;
10 +};
11 +
12 +export class APIClientError extends Error {
13 + readonly code: string;
14 + readonly details: unknown;
15 + readonly status: number;
16 +
17 + constructor(message: string, status: number, code = "request_failed", details?: unknown) {
18 + super(message);
19 + this.name = "APIClientError";
20 + this.status = status;
21 + this.code = code;
22 + this.details = details;
23 + }
24 +}
25 +
26 +async function decodeEnvelope<T>(response: Response): Promise<APIEnvelope<T>> {
27 + const text = await response.text();
28 + if (!text) {
29 + throw new APIClientError("Empty API response", response.status, "empty_response");
30 + }
31 +
32 + let payload: unknown;
33 + try {
34 + payload = JSON.parse(text);
35 + } catch {
36 + throw new APIClientError(
37 + "API returned non-JSON payload",
38 + response.status,
39 + "invalid_json",
40 + text
41 + );
42 + }
43 +
44 + if (
45 + typeof payload !== "object" ||
46 + payload === null ||
47 + !("ok" in payload) ||
48 + typeof (payload as { ok?: unknown }).ok !== "boolean"
49 + ) {
50 + throw new APIClientError(
51 + "API response did not match envelope format",
52 + response.status,
53 + "invalid_envelope",
54 + payload
55 + );
56 + }
57 +
58 + return payload as APIEnvelope<T>;
59 +}
60 +
61 +async function request<T>(path: string, init: RequestInit): Promise<T> {
62 + const response = await fetch(path, init);
63 + const envelope = await decodeEnvelope<T>(response);
64 +
65 + if (envelope.ok) {
66 + return envelope.data as T;
67 + }
68 +
69 + const message = envelope.error?.message?.trim() || "Request failed";
70 + const code = envelope.error?.code?.trim() || "request_failed";
71 + throw new APIClientError(message, response.status, code, envelope.data);
72 +}
73 +
74 +function jsonRequestInit(method: "POST" | "DELETE", body?: unknown): RequestInit {
75 + if (body === undefined) {
76 + return { method };
77 + }
78 +
79 + return {
80 + method,
81 + headers: { "Content-Type": "application/json" },
82 + body: JSON.stringify(body),
83 + };
84 +}
85 +
86 +export const apiClient = {
87 + get<T>(path: string): Promise<T> {
88 + return request<T>(path, { method: "GET" });
89 + },
90 + post<T>(path: string, body?: unknown): Promise<T> {
91 + return request<T>(path, jsonRequestInit("POST", body));
92 + },
93 + delete<T>(path: string): Promise<T> {
94 + return request<T>(path, jsonRequestInit("DELETE"));
95 + },
96 +};
cmd/relay-server/frontend/src/lib/apiPaths.ts new
+46
@@ -0,0 +1,46 @@
1 +export const API_PATHS = {
2 + admin: {
3 + prefix: "/admin",
4 + login: "/admin/login",
5 + logout: "/admin/logout",
6 + authStatus: "/admin/auth/status",
7 + leases: "/admin/leases",
8 + bannedLeases: "/admin/leases/banned",
9 + stats: "/admin/stats",
10 + settings: "/admin/settings",
11 + approvalMode: "/admin/settings/approval-mode",
12 + },
13 + sdk: {
14 + prefix: "/sdk",
15 + register: "/sdk/register",
16 + unregister: "/sdk/unregister",
17 + renew: "/sdk/renew",
18 + domain: "/sdk/domain",
19 + connect: "/sdk/connect",
20 + },
21 + healthz: "/healthz",
22 + appPrefix: "/app/",
23 + tunnel: "/tunnel",
24 +} as const;
25 +
26 +export const ROUTE_PATHS = {
27 + home: "/",
28 + serverDetail: "/server/:id",
29 + admin: "/admin",
30 + adminLogin: "/admin/login",
31 +} as const;
32 +
33 +export function encodeLeaseID(leaseID: string): string {
34 + return btoa(leaseID).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
35 +}
36 +
37 +export function adminLeasePath(
38 + encodedLeaseID: string,
39 + action: "ban" | "bps" | "approve" | "deny"
40 +): string {
41 + return `${API_PATHS.admin.leases}/${encodedLeaseID}/${action}`;
42 +}
43 +
44 +export function adminIPBanPath(ip: string): string {
45 + return `${API_PATHS.admin.prefix}/ips/${ip}/ban`;
46 +}
cmd/relay-server/main.go
+70 -18
@@ -29,17 +29,19 @@ const (
29
30 // flagPortalURL is kept for package-level consumers in other files.
31 var flagPortalURL string
32 +var flagTrustProxyHeaders bool
33
34 type relayServerConfig struct {
34 - AdminPort int
35 - AdminSecretKey string
36 - LeaseBPS int
37 - PortalURL string
38 - Bootstraps []string
39 - SNIPort int
40 -
41 - KeylessDir string
42 - CloudflareToken string
35 + AdminSecretKey string
36 + PortalURL string
37 + TrustedProxyCIDRs string
38 + KeylessDir string
39 + CloudflareToken string
40 + Bootstraps []string
41 + AdminPort int
42 + LeaseBPS int
43 + SNIPort int
44 + TrustProxyHeaders bool
45 }
46
47 func main() {
@@ -47,26 +49,30 @@ func main() {
49
50 cfg := relayServerConfig{}
51
50 - portalURL := strings.TrimSuffix(strings.TrimSpace(os.Getenv("PORTAL_URL")), "/")
52 + portalURL := strings.TrimSuffix(trimmedEnv("PORTAL_URL"), "/")
53 if portalURL == "" {
54 portalURL = defaultPortalURL
55 }
54 - bootstrapsCSV := strings.TrimSpace(os.Getenv("BOOTSTRAP_URIS"))
56 + bootstrapsCSV := trimmedEnv("BOOTSTRAP_URIS")
57 if bootstrapsCSV == "" {
58 bootstrapsCSV = types.DefaultBootstrapFrom(portalURL)
59 }
60 sniPort := types.ParsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort)
59 - keylessDir := strings.TrimSpace(os.Getenv("KEYLESS_DIR"))
61 + keylessDir := trimmedEnv("KEYLESS_DIR")
62 if keylessDir == "" {
63 keylessDir = defaultKeylessDir
64 }
63 - adminSecretKey := strings.TrimSpace(os.Getenv("ADMIN_SECRET_KEY"))
64 - cloudflareToken := strings.TrimSpace(os.Getenv("CLOUDFLARE_TOKEN"))
65 + adminSecretKey := trimmedEnv("ADMIN_SECRET_KEY")
66 + cloudflareToken := trimmedEnv("CLOUDFLARE_TOKEN")
67 + trustProxyHeaders := parseBoolEnv("TRUST_PROXY_HEADERS")
68 + trustedProxyCIDRs := trimmedEnv("TRUSTED_PROXY_CIDRS")
69
70 flag.IntVar(&cfg.AdminPort, "adminport", defaultAPIPort, "Admin/HTTP server port")
71 flag.StringVar(&cfg.AdminSecretKey, "admin-secret-key", adminSecretKey, "admin auth secret (env: ADMIN_SECRET_KEY)")
72 flag.IntVar(&cfg.LeaseBPS, "lease-bps", 0, "bytes-per-second limit per lease (0=unlimited)")
73 flag.StringVar(&cfg.PortalURL, "portal-url", portalURL, "portal base URL (env: PORTAL_URL)")
74 + flag.BoolVar(&cfg.TrustProxyHeaders, "trust-proxy-headers", trustProxyHeaders, "trust X-Forwarded-* and X-Real-IP headers (env: TRUST_PROXY_HEADERS)")
75 + flag.StringVar(&cfg.TrustedProxyCIDRs, "trusted-proxy-cidrs", trustedProxyCIDRs, "trusted proxy CIDR allowlist for forwarded headers, comma-separated (env: TRUSTED_PROXY_CIDRS)")
76 flag.StringVar(&bootstrapsCSV, "bootstraps", bootstrapsCSV, "bootstrap URIs, comma-separated (env: BOOTSTRAP_URIS)")
77 flag.IntVar(&cfg.SNIPort, "sni-port", sniPort, "SNI router port number (env: SNI_PORT)")
78 flag.StringVar(&cfg.KeylessDir, "keyless-dir", keylessDir, "directory path for relay keyless materials (env: KEYLESS_DIR)")
@@ -74,7 +80,13 @@ func main() {
80 flag.Parse()
81
82 cfg.Bootstraps = types.ParseURLs(bootstrapsCSV)
83 + parsedTrustedProxyCIDRs, err := parseTrustedProxyCIDRs(cfg.TrustedProxyCIDRs)
84 + if err != nil {
85 + log.Fatal().Err(err).Msg("parse trusted proxy CIDRs")
86 + }
87 + manager.SetTrustedProxyCIDRs(parsedTrustedProxyCIDRs)
88 flagPortalURL = cfg.PortalURL
89 + flagTrustProxyHeaders = cfg.TrustProxyHeaders
90 if err := runServer(cfg); err != nil {
91 log.Fatal().Err(err).Msg("execute root command")
92 }
@@ -87,13 +99,14 @@ func runServer(cfg relayServerConfig) error {
99
100 log.Info().
101 Str("portal_base_url", cfg.PortalURL).
102 + Bool("trust_proxy_headers", cfg.TrustProxyHeaders).
103 + Str("trusted_proxy_cidrs", cfg.TrustedProxyCIDRs).
104 Strs("bootstrap_uris", cfg.Bootstraps).
105 Msg("[server] frontend configuration")
106
93 - baseHost := types.ExtractBaseDomain(cfg.PortalURL)
94 - rootSNI := types.PortalRootHost(cfg.PortalURL)
107 + rootHost := types.PortalRootHost(cfg.PortalURL)
108 apiUpstreamAddr := types.LoopbackForwardAddr(fmt.Sprintf(":%d", cfg.AdminPort))
96 - serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, sniListenAddr, baseHost, cfg.KeylessDir, cfg.CloudflareToken)
109 + serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, sniListenAddr, rootHost, cfg.KeylessDir, cfg.CloudflareToken)
110 if err != nil {
111 return fmt.Errorf("create relay server: %w", err)
112 }
@@ -105,6 +118,32 @@ func runServer(cfg relayServerConfig) error {
118
119 // Load persisted admin settings (ban list, BPS limits, IP bans)
120 admin.LoadSettings(serv)
121 + ipMgr := admin.GetIPManager()
122 + serv.GetLeaseManager().SetOnLeaseDeleted(func(leaseID string) {
123 + leaseID = strings.TrimSpace(leaseID)
124 + if leaseID == "" {
125 + return
126 + }
127 +
128 + serv.GetReverseHub().DropLease(leaseID)
129 + if sniRouter := serv.GetSNIRouter(); sniRouter != nil {
130 + sniRouter.UnregisterRouteByLeaseID(leaseID)
131 + }
132 + if ipMgr != nil {
133 + ipMgr.RemoveLeaseIP(leaseID)
134 + }
135 + })
136 + if ipMgr != nil {
137 + serv.GetReverseHub().SetIPBanChecker(func(ip string) bool {
138 + return ipMgr.IsIPBanned(ip)
139 + })
140 + serv.GetReverseHub().SetOnAccepted(func(leaseID, ip string) {
141 + if strings.TrimSpace(leaseID) == "" || strings.TrimSpace(ip) == "" {
142 + return
143 + }
144 + ipMgr.RegisterLeaseIP(leaseID, ip)
145 + })
146 + }
147
148 // Set up SNI connection callback to route to tunnel backends
149 serv.GetSNIRouter().SetConnectionCallback(func(clientConn net.Conn, route *sni.Route) {
@@ -149,7 +188,7 @@ func runServer(cfg relayServerConfig) error {
188 reverseConn.Close()
189 })
190
152 - serv.ConfigurePortalRootFallback(rootSNI, apiUpstreamAddr)
191 + serv.ConfigurePortalRootFallback(rootHost, apiUpstreamAddr)
192
193 if err := serv.Start(); err != nil {
194 return fmt.Errorf("start relay server: %w", err)
@@ -172,3 +211,16 @@ func runServer(cfg relayServerConfig) error {
211 log.Info().Msg("[server] shutdown complete")
212 return nil
213 }
214 +
215 +func trimmedEnv(name string) string {
216 + return strings.TrimSpace(os.Getenv(name))
217 +}
218 +
219 +func parseBoolEnv(name string) bool {
220 + raw := trimmedEnv(name)
221 + return strings.EqualFold(raw, "true") || raw == "1"
222 +}
223 +
224 +func parseTrustedProxyCIDRs(raw string) ([]*net.IPNet, error) {
225 + return manager.ParseTrustedProxyCIDRs(raw)
226 +}
cmd/relay-server/main_test.go new
+196
@@ -0,0 +1,196 @@
1 +package main
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "net"
7 + "net/http"
8 + "net/http/httptest"
9 + "strings"
10 + "testing"
11 + "time"
12 +
13 + "gosuda.org/portal/cmd/relay-server/manager"
14 + "gosuda.org/portal/portal"
15 + "gosuda.org/portal/types"
16 +)
17 +
18 +func TestParseTrustedProxyCIDRs(t *testing.T) {
19 + t.Run("parses and deduplicates", func(t *testing.T) {
20 + cidrs, err := manager.ParseTrustedProxyCIDRs("10.0.0.0/8, 10.0.0.0/8, 2001:db8::/32")
21 + if err != nil {
22 + t.Fatalf("unexpected parse error: %v", err)
23 + }
24 + if len(cidrs) != 2 {
25 + t.Fatalf("expected 2 unique CIDRs, got %d", len(cidrs))
26 + }
27 + })
28 +
29 + t.Run("empty input", func(t *testing.T) {
30 + cidrs, err := manager.ParseTrustedProxyCIDRs(" ")
31 + if err != nil {
32 + t.Fatalf("unexpected parse error: %v", err)
33 + }
34 + if len(cidrs) != 0 {
35 + t.Fatalf("expected no CIDRs for empty input, got %d", len(cidrs))
36 + }
37 + })
38 +
39 + t.Run("invalid cidr", func(t *testing.T) {
40 + if _, err := manager.ParseTrustedProxyCIDRs("not-a-cidr"); err == nil {
41 + t.Fatal("expected parse error for invalid CIDR input")
42 + }
43 + })
44 +}
45 +
46 +func newTestRelayServer(t *testing.T) *portal.RelayServer {
47 + t.Helper()
48 +
49 + serv, err := portal.NewRelayServer(
50 + context.Background(),
51 + []string{"127.0.0.1:0"},
52 + ":0",
53 + "portal.example.com",
54 + "",
55 + "",
56 + )
57 + if err != nil {
58 + t.Fatalf("new relay server: %v", err)
59 + }
60 + return serv
61 +}
62 +
63 +func TestServeAPIRemovesLegacyCompatResponses(t *testing.T) {
64 + t.Parallel()
65 +
66 + serv := newTestRelayServer(t)
67 + srv := serveAPI(":0", serv, nil, NewFrontend(), func() {})
68 +
69 + legacyPaths := []string{
70 + "/frontend/manifest.json",
71 + "/service-worker.js",
72 + }
73 + for _, p := range legacyPaths {
74 + t.Run(p, func(t *testing.T) {
75 + req := httptest.NewRequest(http.MethodGet, p, http.NoBody)
76 + rr := httptest.NewRecorder()
77 + srv.Handler.ServeHTTP(rr, req)
78 +
79 + if rr.Code == http.StatusGone {
80 + t.Fatalf("legacy compat path %q should not return 410 compatibility shim", p)
81 + }
82 +
83 + body := strings.ToLower(rr.Body.String())
84 + if strings.Contains(body, "legacy webclient") || strings.Contains(body, "refresh required") {
85 + t.Fatalf("legacy compat marker should be removed for %q, got body %q", p, rr.Body.String())
86 + }
87 + })
88 + }
89 +}
90 +
91 +func TestSDKRegisterRejectsBannedIP(t *testing.T) {
92 + t.Parallel()
93 +
94 + serv := newTestRelayServer(t)
95 + ipManager := manager.NewIPManager()
96 + ipManager.BanIP("203.0.113.17")
97 +
98 + registry := &SDKRegistry{
99 + ipManager: ipManager,
100 + trustProxyHeaders: false,
101 + }
102 +
103 + reqBody := strings.NewReader(`{"lease_id":"lease-ban","name":"test-lease","tls":true,"reverse_token":"token-1"}`)
104 + req := httptest.NewRequest(http.MethodPost, types.PathSDKRegister, reqBody)
105 + req.RemoteAddr = "203.0.113.17:45678"
106 + rr := httptest.NewRecorder()
107 +
108 + registry.handleRegister(rr, req, serv)
109 +
110 + if rr.Code != http.StatusForbidden {
111 + t.Fatalf("unexpected status: got %d want %d", rr.Code, http.StatusForbidden)
112 + }
113 +
114 + var envelope types.APIRawEnvelope
115 + if err := json.NewDecoder(rr.Body).Decode(&envelope); err != nil {
116 + t.Fatalf("decode register envelope: %v", err)
117 + }
118 + if envelope.OK {
119 + t.Fatal("expected banned IP registration to fail")
120 + }
121 + if envelope.Error == nil || envelope.Error.Message != "ip is banned" {
122 + t.Fatalf("unexpected error payload: %+v", envelope.Error)
123 + }
124 + if _, ok := serv.GetLeaseManager().GetLeaseByID("lease-ban"); ok {
125 + t.Fatal("banned registration should not create a lease")
126 + }
127 +}
128 +
129 +func TestSDKUnregisterCleansRouteAndReversePoolImmediately(t *testing.T) {
130 + t.Parallel()
131 +
132 + serv := newTestRelayServer(t)
133 + registry := &SDKRegistry{}
134 +
135 + lease := &portal.Lease{
136 + ID: "lease-cleanup",
137 + Name: "cleanup",
138 + TLS: true,
139 + ReverseToken: "token-cleanup",
140 + Expires: time.Now().Add(time.Minute),
141 + }
142 + if !serv.GetLeaseManager().UpdateLease(lease) {
143 + t.Fatal("failed to seed lease")
144 + }
145 +
146 + sniName := types.BuildSNIName(lease.Name, serv.BaseHost)
147 + if sniName == "" {
148 + t.Fatal("expected non-empty SNI name")
149 + }
150 + if err := serv.GetSNIRouter().RegisterRoute(sniName, lease.ID, lease.Name); err != nil {
151 + t.Fatalf("register route: %v", err)
152 + }
153 +
154 + local, peer := net.Pipe()
155 + defer peer.Close()
156 + conn := portal.NewReverseConn(local)
157 + defer conn.Close()
158 + if !serv.GetReverseHub().Offer(lease.ID, conn) {
159 + t.Fatal("failed to seed reverse pool")
160 + }
161 +
162 + reqBody := strings.NewReader(`{"lease_id":"lease-cleanup"}`)
163 + req := httptest.NewRequest(http.MethodPost, types.PathSDKUnregister, reqBody)
164 + rr := httptest.NewRecorder()
165 + registry.handleUnregister(rr, req, serv)
166 +
167 + if rr.Code != http.StatusOK {
168 + t.Fatalf("unexpected status: got %d want %d", rr.Code, http.StatusOK)
169 + }
170 +
171 + var envelope types.APIRawEnvelope
172 + if err := json.NewDecoder(rr.Body).Decode(&envelope); err != nil {
173 + t.Fatalf("decode unregister envelope: %v", err)
174 + }
175 + if !envelope.OK {
176 + t.Fatalf("expected successful unregister, got %+v", envelope)
177 + }
178 + if _, ok := serv.GetLeaseManager().GetLeaseByID(lease.ID); ok {
179 + t.Fatal("lease should be removed after unregister")
180 + }
181 + if _, ok := serv.GetSNIRouter().GetRouteByLeaseID(lease.ID); ok {
182 + t.Fatal("SNI route should be removed after unregister")
183 + }
184 +
185 + start := time.Now()
186 + _, err := serv.GetReverseHub().AcquireForTLS(lease.ID, 2*time.Second)
187 + if err == nil {
188 + t.Fatal("expected reverse pool to be removed after unregister")
189 + }
190 + if !strings.Contains(err.Error(), "no tunnel available") {
191 + t.Fatalf("unexpected acquire error after unregister: %v", err)
192 + }
193 + if elapsed := time.Since(start); elapsed > 250*time.Millisecond {
194 + t.Fatalf("expected immediate cleanup, acquire took %v", elapsed)
195 + }
196 +}
cmd/relay-server/manager/approve_manager.go
+2 -2
@@ -12,10 +12,10 @@ const (
12
13 // ApproveManager manages approval/denial state for leases.
14 type ApproveManager struct {
15 - mu sync.RWMutex
16 - approvalMode ApprovalMode
15 approvedLeases map[string]struct{}
16 deniedLeases map[string]struct{}
17 + approvalMode ApprovalMode
18 + mu sync.RWMutex
19 }
20
21 func NewApproveManager() *ApproveManager {
cmd/relay-server/manager/auth_manager.go
+131 -50
@@ -4,6 +4,9 @@ import (
4 "crypto/rand"
5 "crypto/subtle"
6 "encoding/hex"
7 + "fmt"
8 + "io"
9 + "sort"
10 "sync"
11 "time"
12
@@ -11,25 +14,30 @@ import (
14 )
15
16 const (
14 - maxFailedAttempts = 3
15 - lockDuration = 1 * time.Minute
16 - sessionDuration = 24 * time.Hour
17 + maxFailedAttempts = 3
18 + lockDuration = 1 * time.Minute
19 + sessionDuration = 24 * time.Hour
20 + failedLoginRetention = 15 * time.Minute
21 + failedLoginSweepWindow = 1 * time.Minute
22 + maxFailedLoginEntries = 4096
23 )
24
19 -// AuthManager manages admin authentication with rate limiting
25 +// AuthManager manages admin authentication with rate limiting.
26 type AuthManager struct {
27 + lastSweepAt time.Time
28 + failedLogins map[string]*loginAttempt
29 + sessions map[string]time.Time
30 secretKey string
31 mu sync.RWMutex
23 - failedLogins map[string]*loginAttempt // IP -> attempt info
24 - sessions map[string]time.Time // token -> expiry
32 }
33
34 type loginAttempt struct {
28 - count int
29 - lockedAt time.Time
35 + lockedAt time.Time
36 + lastSeenAt time.Time
37 + count int
38 }
39
32 -// NewAuthManager creates a new AuthManager with the given secret key
40 +// NewAuthManager creates a new AuthManager with the given secret key.
41 func NewAuthManager(secretKey string) *AuthManager {
42 // Create AuthManager for admin authentication
43 // Auto-generate secret key if not provided
@@ -39,9 +47,9 @@ func NewAuthManager(secretKey string) *AuthManager {
47 log.Fatal().Err(err).Msg("[server] failed to generate random admin secret key")
48 }
49 secretKey = hex.EncodeToString(randomBytes)
42 - log.Warn().Str("key", secretKey).Msg("[server] auto-generated ADMIN_SECRET_KEY (set ADMIN_SECRET_KEY env to use your own)")
50 + log.Warn().Int("key_length", len(secretKey)).Msg("[server] auto-generated ADMIN_SECRET_KEY (set ADMIN_SECRET_KEY env to use your own)")
51 } else {
44 - log.Info().Str("key", secretKey).Msg("[server] admin authentication enabled")
52 + log.Info().Int("key_length", len(secretKey)).Msg("[server] admin authentication enabled")
53 }
54
55 return &AuthManager{
@@ -51,7 +59,7 @@ func NewAuthManager(secretKey string) *AuthManager {
59 }
60 }
61
54 -// IsIPLocked checks if an IP is currently locked out
62 +// IsIPLocked checks if an IP is currently locked out.
63 func (m *AuthManager) IsIPLocked(ip string) bool {
64 m.mu.RLock()
65 defer m.mu.RUnlock()
@@ -60,18 +68,10 @@ func (m *AuthManager) IsIPLocked(ip string) bool {
68 if !exists {
69 return false
70 }
63 -
64 - if attempt.count >= maxFailedAttempts {
65 - // Check if lock has expired
66 - if time.Since(attempt.lockedAt) < lockDuration {
67 - return true
68 - }
69 - }
70 -
71 - return false
71 + return lockRemaining(attempt, time.Now()) > 0
72 }
73
74 -// GetLockRemainingSeconds returns the remaining seconds until the IP is unlocked
74 +// GetLockRemainingSeconds returns the remaining seconds until the IP is unlocked.
75 func (m *AuthManager) GetLockRemainingSeconds(ip string) int {
76 m.mu.RLock()
77 defer m.mu.RUnlock()
@@ -80,22 +80,17 @@ func (m *AuthManager) GetLockRemainingSeconds(ip string) int {
80 if !exists {
81 return 0
82 }
83 -
84 - if attempt.count >= maxFailedAttempts {
85 - remaining := lockDuration - time.Since(attempt.lockedAt)
86 - if remaining > 0 {
87 - return int(remaining.Seconds())
88 - }
89 - }
90 -
91 - return 0
83 + return int(lockRemaining(attempt, time.Now()).Seconds())
84 }
85
94 -// RecordFailedLogin records a failed login attempt and returns true if the IP is now locked
86 +// RecordFailedLogin records a failed login attempt and returns true if the IP is now locked.
87 func (m *AuthManager) RecordFailedLogin(ip string) bool {
88 m.mu.Lock()
89 defer m.mu.Unlock()
90
91 + now := time.Now()
92 + m.maybeSweepFailedLoginsLocked(now)
93 +
94 attempt, exists := m.failedLogins[ip]
95 if !exists {
96 attempt = &loginAttempt{}
@@ -103,21 +98,24 @@ func (m *AuthManager) RecordFailedLogin(ip string) bool {
98 }
99
100 // Reset if lock has expired
106 - if attempt.count >= maxFailedAttempts && time.Since(attempt.lockedAt) >= lockDuration {
101 + if attempt.count >= maxFailedAttempts && now.Sub(attempt.lockedAt) >= lockDuration {
102 attempt.count = 0
103 }
104
105 attempt.count++
106 + attempt.lastSeenAt = now
107
108 + locked := false
109 if attempt.count >= maxFailedAttempts {
113 - attempt.lockedAt = time.Now()
114 - return true
110 + attempt.lockedAt = now
111 + locked = true
112 }
113
117 - return false
114 + m.enforceFailedLoginCapLocked()
115 + return locked
116 }
117
120 -// ResetFailedLogin resets the failed login count for an IP
118 +// ResetFailedLogin resets the failed login count for an IP.
119 func (m *AuthManager) ResetFailedLogin(ip string) {
120 m.mu.Lock()
121 defer m.mu.Unlock()
@@ -125,7 +123,7 @@ func (m *AuthManager) ResetFailedLogin(ip string) {
123 delete(m.failedLogins, ip)
124 }
125
128 -// ValidateKey checks if the provided key matches the secret key
126 +// ValidateKey checks if the provided key matches the secret key.
127 func (m *AuthManager) ValidateKey(key string) bool {
128 if m.secretKey == "" {
129 return false
@@ -133,14 +131,17 @@ func (m *AuthManager) ValidateKey(key string) bool {
131 return subtle.ConstantTimeCompare([]byte(key), []byte(m.secretKey)) == 1
132 }
133
136 -// HasSecretKey returns true if a secret key is configured
134 +// HasSecretKey returns true if a secret key is configured.
135 func (m *AuthManager) HasSecretKey() bool {
136 return m.secretKey != ""
137 }
138
141 -// CreateSession creates a new session and returns the token
139 +// CreateSession creates a new session and returns the token.
140 func (m *AuthManager) CreateSession() string {
143 - token := generateToken()
141 + token, err := generateToken()
142 + if err != nil {
143 + log.Fatal().Err(err).Msg("[server] failed to generate secure admin session token")
144 + }
145
146 m.mu.Lock()
147 defer m.mu.Unlock()
@@ -153,7 +154,7 @@ func (m *AuthManager) CreateSession() string {
154 return token
155 }
156
156 -// ValidateSession checks if a session token is valid
157 +// ValidateSession checks if a session token is valid.
158 func (m *AuthManager) ValidateSession(token string) bool {
159 if token == "" {
160 return false
@@ -170,7 +171,7 @@ func (m *AuthManager) ValidateSession(token string) bool {
171 return time.Now().Before(expiry)
172 }
173
173 -// DeleteSession removes a session
174 +// DeleteSession removes a session.
175 func (m *AuthManager) DeleteSession(token string) {
176 m.mu.Lock()
177 defer m.mu.Unlock()
@@ -178,7 +179,7 @@ func (m *AuthManager) DeleteSession(token string) {
179 delete(m.sessions, token)
180 }
181
181 -// cleanupExpiredSessions removes expired sessions (must be called with lock held)
182 +// cleanupExpiredSessions removes expired sessions (must be called with lock held).
183 func (m *AuthManager) cleanupExpiredSessions() {
184 now := time.Now()
185 for token, expiry := range m.sessions {
@@ -188,12 +189,92 @@ func (m *AuthManager) cleanupExpiredSessions() {
189 }
190 }
191
191 -// generateToken generates a secure random token
192 -func generateToken() string {
192 +// generateToken generates a secure random token.
193 +func generateToken() (string, error) {
194 + return generateTokenFromReader(rand.Reader)
195 +}
196 +
197 +func generateTokenFromReader(reader io.Reader) (string, error) {
198 bytes := make([]byte, 32)
194 - if _, err := rand.Read(bytes); err != nil {
195 - // Fallback to timestamp-based token (less secure but functional)
196 - return hex.EncodeToString([]byte(time.Now().String()))
199 + if _, err := io.ReadFull(reader, bytes); err != nil {
200 + return "", fmt.Errorf("read random session token bytes: %w", err)
201 + }
202 + return hex.EncodeToString(bytes), nil
203 +}
204 +
205 +func (m *AuthManager) maybeSweepFailedLoginsLocked(now time.Time) {
206 + if !m.lastSweepAt.IsZero() && now.Sub(m.lastSweepAt) < failedLoginSweepWindow {
207 + return
208 + }
209 +
210 + m.sweepExpiredFailedLoginsLocked(now)
211 + m.lastSweepAt = now
212 +}
213 +
214 +func (m *AuthManager) sweepExpiredFailedLoginsLocked(now time.Time) {
215 + for ip, attempt := range m.failedLogins {
216 + if attempt == nil {
217 + delete(m.failedLogins, ip)
218 + continue
219 + }
220 +
221 + lastSeenAt := attempt.lastSeenAt
222 + if lastSeenAt.IsZero() {
223 + lastSeenAt = attempt.lockedAt
224 + }
225 + if lastSeenAt.IsZero() || now.Sub(lastSeenAt) >= failedLoginRetention {
226 + delete(m.failedLogins, ip)
227 + }
228 + }
229 +}
230 +
231 +func (m *AuthManager) enforceFailedLoginCapLocked() {
232 + if len(m.failedLogins) <= maxFailedLoginEntries {
233 + return
234 + }
235 +
236 + type failedEntry struct {
237 + lastSeenAt time.Time
238 + ip string
239 + }
240 +
241 + entries := make([]failedEntry, 0, len(m.failedLogins))
242 + for ip, attempt := range m.failedLogins {
243 + if attempt == nil {
244 + entries = append(entries, failedEntry{ip: ip})
245 + continue
246 + }
247 +
248 + lastSeenAt := attempt.lastSeenAt
249 + if lastSeenAt.IsZero() {
250 + lastSeenAt = attempt.lockedAt
251 + }
252 +
253 + entries = append(entries, failedEntry{
254 + ip: ip,
255 + lastSeenAt: lastSeenAt,
256 + })
257 + }
258 +
259 + sort.Slice(entries, func(i, j int) bool {
260 + return entries[i].lastSeenAt.Before(entries[j].lastSeenAt)
261 + })
262 +
263 + overflow := len(m.failedLogins) - maxFailedLoginEntries
264 + for i := range overflow {
265 + delete(m.failedLogins, entries[i].ip)
266 + }
267 +}
268 +
269 +func lockRemaining(attempt *loginAttempt, now time.Time) time.Duration {
270 + if attempt == nil || attempt.count < maxFailedAttempts {
271 + return 0
272 + }
273 +
274 + remaining := lockDuration - now.Sub(attempt.lockedAt)
275 + if remaining <= 0 {
276 + return 0
277 }
198 - return hex.EncodeToString(bytes)
278 +
279 + return remaining
280 }
cmd/relay-server/manager/auth_manager_test.go new
+123
@@ -0,0 +1,123 @@
1 +package manager
2 +
3 +import (
4 + "bytes"
5 + "errors"
6 + "fmt"
7 + "strings"
8 + "testing"
9 + "time"
10 +
11 + "github.com/rs/zerolog"
12 + "github.com/rs/zerolog/log"
13 +)
14 +
15 +type failingReader struct{}
16 +
17 +func (failingReader) Read(_ []byte) (int, error) {
18 + return 0, errors.New("rng unavailable")
19 +}
20 +
21 +func TestGenerateTokenFromReaderFailsClosed(t *testing.T) {
22 + t.Parallel()
23 +
24 + token, err := generateTokenFromReader(failingReader{})
25 + if err == nil {
26 + t.Fatal("expected an error when entropy source fails")
27 + }
28 + if token != "" {
29 + t.Fatalf("expected empty token on entropy failure, got %q", token)
30 + }
31 +}
32 +
33 +func TestRecordFailedLoginSweepsExpiredEntries(t *testing.T) {
34 + t.Parallel()
35 +
36 + m := NewAuthManager("test-secret")
37 + now := time.Now()
38 +
39 + m.mu.Lock()
40 + m.failedLogins["expired-entry"] = &loginAttempt{
41 + count: 1,
42 + lastSeenAt: now.Add(-failedLoginRetention - time.Second),
43 + }
44 + m.lastSweepAt = now.Add(-failedLoginSweepWindow - time.Second)
45 + m.mu.Unlock()
46 +
47 + locked := m.RecordFailedLogin("active-entry")
48 + if locked {
49 + t.Fatal("first failed login attempt should not lock the IP")
50 + }
51 +
52 + m.mu.RLock()
53 + defer m.mu.RUnlock()
54 +
55 + if len(m.failedLogins) != 1 {
56 + t.Fatalf("expected 1 retained entry after sweep, got %d", len(m.failedLogins))
57 + }
58 + if _, exists := m.failedLogins["expired-entry"]; exists {
59 + t.Fatal("expired failed login entry should have been removed")
60 + }
61 + if _, exists := m.failedLogins["active-entry"]; !exists {
62 + t.Fatal("active failed login entry should be retained")
63 + }
64 +}
65 +
66 +func TestRecordFailedLoginEnforcesEntryCap(t *testing.T) {
67 + t.Parallel()
68 +
69 + m := NewAuthManager("test-secret")
70 + base := time.Now().Add(-2 * time.Minute)
71 +
72 + m.mu.Lock()
73 + for i := range maxFailedLoginEntries {
74 + key := fmt.Sprintf("old-%05d", i)
75 + m.failedLogins[key] = &loginAttempt{
76 + count: 1,
77 + lastSeenAt: base.Add(time.Duration(i) * time.Millisecond),
78 + }
79 + }
80 + m.lastSweepAt = time.Now()
81 + m.mu.Unlock()
82 +
83 + m.RecordFailedLogin("new-entry")
84 +
85 + m.mu.RLock()
86 + defer m.mu.RUnlock()
87 +
88 + if len(m.failedLogins) != maxFailedLoginEntries {
89 + t.Fatalf("expected failed login map cap of %d, got %d", maxFailedLoginEntries, len(m.failedLogins))
90 + }
91 + if _, exists := m.failedLogins["new-entry"]; !exists {
92 + t.Fatal("new failed login entry should be retained after eviction")
93 + }
94 + if _, exists := m.failedLogins["old-00000"]; exists {
95 + t.Fatal("oldest failed login entry should be evicted when cap is exceeded")
96 + }
97 +}
98 +
99 +func TestAuthManagerDoesNotLogPlaintextSecretsOrSessionToken(t *testing.T) {
100 + const secretKey = "super-secret-admin-key"
101 +
102 + var buf bytes.Buffer
103 + originalLogger := log.Logger
104 + log.Logger = zerolog.New(&buf)
105 + t.Cleanup(func() {
106 + log.Logger = originalLogger
107 + })
108 +
109 + m := NewAuthManager(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)
113 + }
114 +
115 + buf.Reset()
116 + token := m.CreateSession()
117 + if token == "" {
118 + t.Fatal("expected non-empty session token")
119 + }
120 + if strings.Contains(buf.String(), token) {
121 + t.Fatalf("expected auth manager logs to omit plaintext session token, got %q", buf.String())
122 + }
123 +}
cmd/relay-server/manager/bps_manager.go
+25 -28
@@ -12,15 +12,15 @@ import (
12 "github.com/rs/zerolog/log"
13 )
14
15 -// BPSManager manages per-lease bytes-per-second rate limiting
15 +// BPSManager manages per-lease bytes-per-second rate limiting.
16 type BPSManager struct {
17 + bpsLimits map[string]int64
18 + bpsBuckets map[string]*Bucket
19 + defaultBPS int64
20 mu sync.Mutex
18 - bpsLimits map[string]int64 // leaseID -> bytes-per-second (0 = unlimited)
19 - bpsBuckets map[string]*Bucket // leaseID -> rate limit bucket
20 - defaultBPS int64 // default bytes-per-second for new leases
21 }
22
23 -// NewBPSManager creates a new BPS manager
23 +// NewBPSManager creates a new BPS manager.
24 func NewBPSManager() *BPSManager {
25 return &BPSManager{
26 bpsLimits: make(map[string]int64),
@@ -29,7 +29,7 @@ func NewBPSManager() *BPSManager {
29 }
30 }
31
32 -// SetBPSLimit sets the BPS limit for a lease
32 +// SetBPSLimit sets the BPS limit for a lease.
33 func (m *BPSManager) SetBPSLimit(leaseID string, bps int64) {
34 m.mu.Lock()
35 defer m.mu.Unlock()
@@ -43,7 +43,7 @@ func (m *BPSManager) SetBPSLimit(leaseID string, bps int64) {
43 delete(m.bpsBuckets, leaseID)
44 }
45
46 -// GetBPSLimit returns the BPS limit for a lease (0 = unlimited)
46 +// GetBPSLimit returns the BPS limit for a lease (0 = unlimited).
47 func (m *BPSManager) GetBPSLimit(leaseID string) int64 {
48 m.mu.Lock()
49 defer m.mu.Unlock()
@@ -53,7 +53,7 @@ func (m *BPSManager) GetBPSLimit(leaseID string) int64 {
53 return 0
54 }
55
56 -// GetAllBPSLimits returns a copy of all BPS limits
56 +// GetAllBPSLimits returns a copy of all BPS limits.
57 func (m *BPSManager) GetAllBPSLimits() map[string]int64 {
58 m.mu.Lock()
59 defer m.mu.Unlock()
@@ -62,7 +62,7 @@ func (m *BPSManager) GetAllBPSLimits() map[string]int64 {
62 return result
63 }
64
65 -// SetDefaultBPS sets the default BPS limit for new leases
65 +// SetDefaultBPS sets the default BPS limit for new leases.
66 func (m *BPSManager) SetDefaultBPS(bps int64) {
67 m.mu.Lock()
68 defer m.mu.Unlock()
@@ -72,14 +72,14 @@ func (m *BPSManager) SetDefaultBPS(bps int64) {
72 m.defaultBPS = bps
73 }
74
75 -// GetDefaultBPS returns the default BPS limit
75 +// GetDefaultBPS returns the default BPS limit.
76 func (m *BPSManager) GetDefaultBPS() int64 {
77 m.mu.Lock()
78 defer m.mu.Unlock()
79 return m.defaultBPS
80 }
81
82 -// GetBucket returns a rate limit bucket for a lease, creating one if needed
82 +// GetBucket returns a rate limit bucket for a lease, creating one if needed.
83 func (m *BPSManager) GetBucket(leaseID string) *Bucket {
84 m.mu.Lock()
85 defer m.mu.Unlock()
@@ -103,7 +103,7 @@ func (m *BPSManager) GetBucket(leaseID string) *Bucket {
103 return bucket
104 }
105
106 -// CleanupLease removes BPS data for a lease
106 +// CleanupLease removes BPS data for a lease.
107 func (m *BPSManager) CleanupLease(leaseID string) {
108 m.mu.Lock()
109 defer m.mu.Unlock()
@@ -111,14 +111,14 @@ func (m *BPSManager) CleanupLease(leaseID string) {
111 delete(m.bpsBuckets, leaseID)
112 }
113
114 -// Copy copies data with rate limiting
114 +// Copy copies data with rate limiting.
115 func (m *BPSManager) Copy(dst io.Writer, src io.Reader, leaseID string) (int64, error) {
116 bucket := m.GetBucket(leaseID)
117 return Copy(dst, src, bucket)
118 }
119
120 // EstablishRelayWithBPS sets up bidirectional relay with BPS limiting.
121 -// In the new TLS passthrough architecture, this uses net.Conn
121 +// In the new TLS passthrough architecture, this uses net.Conn.
122 func EstablishRelayWithBPS(clientConn, leaseConn net.Conn, leaseID string, bpsManager *BPSManager) {
123 bpsLimit := bpsManager.GetBPSLimit(leaseID)
124 log.Info().
@@ -164,17 +164,14 @@ func EstablishRelayWithBPS(clientConn, leaseConn net.Conn, leaseID string, bpsMa
164 // sharing the same bandwidth limit. It uses a token bucket algorithm where tokens
165 // represent bytes, and the bucket refills at the configured rate.
166 type Bucket struct {
167 - mu sync.Mutex
168 -
169 - rateBps int64 // bytes per second limit
170 - tokens float64 // current available tokens (bytes)
171 - maxTokens float64 // maximum tokens (burst size)
172 - lastRefill time.Time
173 -
174 - // Stats
167 + lastRefill time.Time
168 + rateBps int64
169 + tokens float64
170 + maxTokens float64
171 totalBytes int64
176 - totalWaited int64 // total wait time in nanoseconds
177 - throttleHits int64 // number of times we had to wait
172 + totalWaited int64
173 + throttleHits int64
174 + mu sync.Mutex
175 }
176
177 // NewBucket creates a limiter for rateBps with burst bytes.
@@ -303,7 +300,7 @@ func (b *Bucket) TakeWithTimeout(n int64, maxWait time.Duration) bool {
300 }
301 }
302
306 -// Available returns the current number of available tokens (bytes)
303 +// Available returns the current number of available tokens (bytes).
304 func (b *Bucket) Available() float64 {
305 if b == nil {
306 return 0
@@ -323,7 +320,7 @@ func (b *Bucket) Available() float64 {
320 return b.tokens
321 }
322
326 -// Rate returns the configured rate in bytes per second
323 +// Rate returns the configured rate in bytes per second.
324 func (b *Bucket) Rate() int64 {
325 if b == nil {
326 return 0
@@ -331,7 +328,7 @@ func (b *Bucket) Rate() int64 {
328 return b.rateBps
329 }
330
334 -// Stats returns current statistics
331 +// Stats returns current statistics.
332 func (b *Bucket) Stats() (totalBytes, throttleHits int64, totalWaited time.Duration) {
333 return atomic.LoadInt64(&b.totalBytes),
334 atomic.LoadInt64(&b.throttleHits),
@@ -389,7 +386,7 @@ func Copy(dst io.Writer, src io.Reader, b *Bucket) (int64, error) {
386 return total, nil
387 }
388
392 -// logCopyStats logs summary statistics when copy completes
389 +// logCopyStats logs summary statistics when copy completes.
390 func logCopyStats(b *Bucket, totalBytes int64, startTime time.Time) {
391 if b == nil || totalBytes == 0 {
392 return
cmd/relay-server/manager/ip_manager.go
+180 -58
@@ -1,50 +1,122 @@
1 package manager
2
3 import (
4 + "fmt"
5 "net"
6 "net/http"
7 + "slices"
8 "strings"
9 "sync"
10 )
11
10 -// IPManager manages IP-based bans and lease-to-IP mapping
12 +// IPManager manages IP-based bans and lease-to-IP mapping.
13 type IPManager struct {
14 + bannedIPs map[string]struct{}
15 + leaseToIP map[string]string
16 + ipToLeases map[string][]string
17 mu sync.RWMutex
13 - bannedIPs map[string]struct{} // set of banned IPs
14 - leaseToIP map[string]string // lease ID -> IP address
15 - ipToLeases map[string][]string // IP -> list of lease IDs (for lookup)
16 -
17 - // pendingIPs stores recent connection IPs in a circular buffer for lease association
18 - pendingIPsMu sync.Mutex
19 - pendingIPsQueue []string
20 - pendingIPsMax int // Keep last N IPs
18 }
19
23 -// NewIPManager creates a new IP manager
20 +var (
21 + trustedProxyMu sync.RWMutex
22 + trustedProxyCIDRs []*net.IPNet
23 +)
24 +
25 +const (
26 + xForwardedForHeader = "X-Forwarded-For"
27 + xRealIPHeader = "X-Real-IP"
28 +)
29 +
30 +// NewIPManager creates a new IP manager.
31 func NewIPManager() *IPManager {
32 return &IPManager{
26 - bannedIPs: make(map[string]struct{}),
27 - leaseToIP: make(map[string]string),
28 - ipToLeases: make(map[string][]string),
29 - pendingIPsMax: 100,
33 + bannedIPs: make(map[string]struct{}),
34 + leaseToIP: make(map[string]string),
35 + ipToLeases: make(map[string][]string),
36 + }
37 +}
38 +
39 +// SetTrustedProxyCIDRs configures which remote peers can supply trusted forwarded headers.
40 +func SetTrustedProxyCIDRs(cidrs []*net.IPNet) {
41 + trustedProxyMu.Lock()
42 + defer trustedProxyMu.Unlock()
43 +
44 + if len(cidrs) == 0 {
45 + trustedProxyCIDRs = nil
46 + return
47 + }
48 +
49 + trustedProxyCIDRs = append(make([]*net.IPNet, 0, len(cidrs)), cidrs...)
50 +}
51 +
52 +// ParseTrustedProxyCIDRs parses a comma-separated CIDR allowlist for trusted proxy peers.
53 +// Empty input returns nil, nil.
54 +func ParseTrustedProxyCIDRs(raw string) ([]*net.IPNet, error) {
55 + raw = strings.TrimSpace(raw)
56 + if raw == "" {
57 + return nil, nil
58 + }
59 +
60 + parts := strings.Split(raw, ",")
61 + cidrs := make([]*net.IPNet, 0, len(parts))
62 + seen := make(map[string]struct{}, len(parts))
63 + for _, part := range parts {
64 + candidate := strings.TrimSpace(part)
65 + if candidate == "" {
66 + continue
67 + }
68 +
69 + _, network, err := net.ParseCIDR(candidate)
70 + if err != nil {
71 + return nil, fmt.Errorf("invalid trusted proxy CIDR %q: %w", candidate, err)
72 + }
73 +
74 + networkKey := network.String()
75 + if _, exists := seen[networkKey]; exists {
76 + continue
77 + }
78 +
79 + seen[networkKey] = struct{}{}
80 + cidrs = append(cidrs, network)
81 + }
82 +
83 + return cidrs, nil
84 +}
85 +
86 +// IsTrustedProxyRemoteAddr reports whether a remote peer is in the trusted proxy allowlist.
87 +func IsTrustedProxyRemoteAddr(remoteAddr string) bool {
88 + remoteIP := parseRemoteAddrIP(remoteAddr)
89 + if remoteIP == nil {
90 + return false
91 + }
92 +
93 + trustedProxyMu.RLock()
94 + defer trustedProxyMu.RUnlock()
95 +
96 + for _, network := range trustedProxyCIDRs {
97 + if network != nil && network.Contains(remoteIP) {
98 + return true
99 + }
100 }
101 +
102 + return false
103 }
104
33 -// BanIP adds an IP to the ban list
105 +// BanIP adds an IP to the ban list.
106 func (m *IPManager) BanIP(ip string) {
107 m.mu.Lock()
108 defer m.mu.Unlock()
109 m.bannedIPs[ip] = struct{}{}
110 }
111
40 -// UnbanIP removes an IP from the ban list
112 +// UnbanIP removes an IP from the ban list.
113 func (m *IPManager) UnbanIP(ip string) {
114 m.mu.Lock()
115 defer m.mu.Unlock()
116 delete(m.bannedIPs, ip)
117 }
118
47 -// IsIPBanned checks if an IP is banned
119 +// IsIPBanned checks if an IP is banned.
120 func (m *IPManager) IsIPBanned(ip string) bool {
121 m.mu.RLock()
122 defer m.mu.RUnlock()
@@ -52,7 +124,7 @@ func (m *IPManager) IsIPBanned(ip string) bool {
124 return banned
125 }
126
55 -// GetBannedIPs returns all banned IPs
127 +// GetBannedIPs returns all banned IPs.
128 func (m *IPManager) GetBannedIPs() []string {
129 m.mu.RLock()
130 defer m.mu.RUnlock()
@@ -63,7 +135,7 @@ func (m *IPManager) GetBannedIPs() []string {
135 return result
136 }
137
66 -// SetBannedIPs sets the banned IPs list (for loading from settings)
138 +// SetBannedIPs sets the banned IPs list (for loading from settings).
139 func (m *IPManager) SetBannedIPs(ips []string) {
140 m.mu.Lock()
141 defer m.mu.Unlock()
@@ -73,21 +145,33 @@ func (m *IPManager) SetBannedIPs(ips []string) {
145 }
146 }
147
76 -// RegisterLeaseIP associates a lease ID with an IP address
148 +// RegisterLeaseIP associates a lease ID with an IP address.
149 func (m *IPManager) RegisterLeaseIP(leaseID, ip string) {
150 m.mu.Lock()
151 defer m.mu.Unlock()
152 + if leaseID == "" || ip == "" {
153 + return
154 + }
155
81 - // Remove old mapping if exists
82 - if oldIP, exists := m.leaseToIP[leaseID]; exists && oldIP != ip {
156 + if oldIP, exists := m.leaseToIP[leaseID]; exists {
157 + if oldIP == ip {
158 + // Already registered; avoid duplicate lease entries per IP.
159 + return
160 + }
161 m.removeLeaseFromIP(leaseID, oldIP)
162 }
163
164 + // Defensively avoid duplicates if state was previously inconsistent.
165 + if slices.Contains(m.ipToLeases[ip], leaseID) {
166 + m.leaseToIP[leaseID] = ip
167 + return
168 + }
169 +
170 m.leaseToIP[leaseID] = ip
171 m.ipToLeases[ip] = append(m.ipToLeases[ip], leaseID)
172 }
173
90 -// removeLeaseFromIP removes a lease from IP's lease list (must hold lock)
174 +// removeLeaseFromIP removes a lease from IP's lease list (must hold lock).
175 func (m *IPManager) removeLeaseFromIP(leaseID, ip string) {
176 leases := m.ipToLeases[ip]
177 for i, id := range leases {
@@ -101,14 +185,14 @@ func (m *IPManager) removeLeaseFromIP(leaseID, ip string) {
185 }
186 }
187
104 -// GetLeaseIP returns the IP address for a lease ID
188 +// GetLeaseIP returns the IP address for a lease ID.
189 func (m *IPManager) GetLeaseIP(leaseID string) string {
190 m.mu.RLock()
191 defer m.mu.RUnlock()
192 return m.leaseToIP[leaseID]
193 }
194
111 -// GetIPLeases returns all lease IDs for an IP
195 +// GetIPLeases returns all lease IDs for an IP.
196 func (m *IPManager) GetIPLeases(ip string) []string {
197 m.mu.RLock()
198 defer m.mu.RUnlock()
@@ -117,51 +201,89 @@ func (m *IPManager) GetIPLeases(ip string) []string {
201 return result
202 }
203
120 -// ExtractClientIP extracts the client IP from an HTTP request
121 -func ExtractClientIP(r *http.Request) string {
122 - // Check X-Forwarded-For header first (for proxied requests)
123 - if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
124 - // X-Forwarded-For can contain multiple IPs, take the first one
125 - if before, _, ok := strings.Cut(xff, ","); ok {
126 - return strings.TrimSpace(before)
127 - }
128 - return strings.TrimSpace(xff)
204 +// RemoveLeaseIP removes lease-to-IP mapping for a lease ID.
205 +func (m *IPManager) RemoveLeaseIP(leaseID string) {
206 + m.mu.Lock()
207 + defer m.mu.Unlock()
208 +
209 + ip, exists := m.leaseToIP[leaseID]
210 + if !exists {
211 + return
212 + }
213 + delete(m.leaseToIP, leaseID)
214 + m.removeLeaseFromIP(leaseID, ip)
215 +}
216 +
217 +func normalizeClientIPCandidate(raw string) string {
218 + candidate := strings.TrimSpace(raw)
219 + if candidate == "" {
220 + return ""
221 }
222
131 - // Check X-Real-IP header
132 - if xri := r.Header.Get("X-Real-IP"); xri != "" {
133 - return strings.TrimSpace(xri)
223 + if ip := net.ParseIP(candidate); ip != nil {
224 + return candidate
225 }
226
136 - // Fall back to RemoteAddr
137 - ip, _, err := net.SplitHostPort(r.RemoteAddr)
227 + host, _, err := net.SplitHostPort(candidate)
228 if err != nil {
139 - return r.RemoteAddr
229 + return ""
230 }
141 - return ip
231 + host = strings.TrimSpace(host)
232 + if host == "" || net.ParseIP(host) == nil {
233 + return ""
234 + }
235 + return host
236 }
237
144 -// StorePendingIP stores a client IP for later association with a lease.
145 -func (m *IPManager) StorePendingIP(ip string) {
146 - if ip == "" {
147 - return
238 +func parseRemoteAddrIP(remoteAddr string) net.IP {
239 + remoteAddr = strings.TrimSpace(remoteAddr)
240 + if remoteAddr == "" {
241 + return nil
242 }
149 - m.pendingIPsMu.Lock()
150 - defer m.pendingIPsMu.Unlock()
151 - m.pendingIPsQueue = append(m.pendingIPsQueue, ip)
152 - if len(m.pendingIPsQueue) > m.pendingIPsMax {
153 - m.pendingIPsQueue = m.pendingIPsQueue[1:]
243 +
244 + host := remoteAddr
245 + if parsedHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
246 + host = parsedHost
247 }
248 +
249 + return net.ParseIP(strings.TrimSpace(host))
250 }
251
157 -// PopPendingIP retrieves and removes the oldest pending IP.
158 -func (m *IPManager) PopPendingIP() string {
159 - m.pendingIPsMu.Lock()
160 - defer m.pendingIPsMu.Unlock()
161 - if len(m.pendingIPsQueue) == 0 {
252 +// ExtractClientIP extracts the client IP from an HTTP request.
253 +// Forwarded headers are trusted only when trustProxyHeaders is true and peer is trusted.
254 +func ExtractClientIP(r *http.Request, trustProxyHeaders bool) string {
255 + if r == nil {
256 return ""
257 }
164 - ip := m.pendingIPsQueue[0]
165 - m.pendingIPsQueue = m.pendingIPsQueue[1:]
166 - return ip
258 +
259 + if trustProxyHeaders && IsTrustedProxyRemoteAddr(r.RemoteAddr) {
260 + // Check X-Forwarded-For header first (for proxied requests).
261 + if xff := r.Header.Get(xForwardedForHeader); xff != "" {
262 + // X-Forwarded-For can contain multiple IPs, take the first one.
263 + if before, _, ok := strings.Cut(xff, ","); ok {
264 + if ip := normalizeClientIPCandidate(before); ip != "" {
265 + return ip
266 + }
267 + } else if ip := normalizeClientIPCandidate(xff); ip != "" {
268 + return ip
269 + }
270 + }
271 +
272 + // Check X-Real-IP header.
273 + if xri := r.Header.Get(xRealIPHeader); xri != "" {
274 + if ip := normalizeClientIPCandidate(xri); ip != "" {
275 + return ip
276 + }
277 + }
278 + }
279 +
280 + // Fall back to RemoteAddr.
281 + ip, _, err := net.SplitHostPort(r.RemoteAddr)
282 + if err != nil {
283 + return strings.TrimSpace(r.RemoteAddr)
284 + }
285 + if normalized := normalizeClientIPCandidate(ip); normalized != "" {
286 + return normalized
287 + }
288 + return strings.TrimSpace(ip)
289 }
cmd/relay-server/manager/ip_manager_test.go new
+73
@@ -0,0 +1,73 @@
1 +package manager
2 +
3 +import (
4 + "net"
5 + "net/http"
6 + "net/http/httptest"
7 + "testing"
8 +)
9 +
10 +func mustCIDR(t *testing.T, raw string) *net.IPNet {
11 + t.Helper()
12 +
13 + _, network, err := net.ParseCIDR(raw)
14 + if err != nil {
15 + t.Fatalf("parse CIDR %q: %v", raw, err)
16 + }
17 + return network
18 +}
19 +
20 +func TestExtractClientIPTrustsForwardedHeadersOnlyFromTrustedProxy(t *testing.T) {
21 + SetTrustedProxyCIDRs([]*net.IPNet{mustCIDR(t, "10.0.0.0/8")})
22 + t.Cleanup(func() {
23 + SetTrustedProxyCIDRs(nil)
24 + })
25 +
26 + trustedReq := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
27 + trustedReq.RemoteAddr = "10.1.2.3:45000"
28 + trustedReq.Header.Set("X-Forwarded-For", "203.0.113.10, 10.1.2.3")
29 +
30 + if got := ExtractClientIP(trustedReq, true); got != "203.0.113.10" {
31 + t.Fatalf("expected forwarded client IP from trusted proxy, got %q", got)
32 + }
33 +
34 + untrustedReq := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
35 + untrustedReq.RemoteAddr = "198.51.100.5:45000"
36 + untrustedReq.Header.Set("X-Forwarded-For", "203.0.113.10")
37 +
38 + if got := ExtractClientIP(untrustedReq, true); got != "198.51.100.5" {
39 + t.Fatalf("expected remote IP fallback for untrusted proxy, got %q", got)
40 + }
41 +}
42 +
43 +func TestExtractClientIPDoesNotTrustHeadersWithoutAllowlist(t *testing.T) {
44 + SetTrustedProxyCIDRs(nil)
45 +
46 + req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
47 + req.RemoteAddr = "10.1.2.3:45000"
48 + req.Header.Set("X-Real-IP", "203.0.113.77")
49 +
50 + if got := ExtractClientIP(req, true); got != "10.1.2.3" {
51 + t.Fatalf("expected remote IP when allowlist is empty, got %q", got)
52 + }
53 +}
54 +
55 +func TestIsTrustedProxyRemoteAddr(t *testing.T) {
56 + SetTrustedProxyCIDRs([]*net.IPNet{
57 + mustCIDR(t, "10.0.0.0/8"),
58 + mustCIDR(t, "2001:db8::/32"),
59 + })
60 + t.Cleanup(func() {
61 + SetTrustedProxyCIDRs(nil)
62 + })
63 +
64 + if !IsTrustedProxyRemoteAddr("10.9.8.7:443") {
65 + t.Fatal("expected IPv4 remote to match trusted CIDR")
66 + }
67 + if !IsTrustedProxyRemoteAddr("[2001:db8::1]:443") {
68 + t.Fatal("expected IPv6 remote to match trusted CIDR")
69 + }
70 + if IsTrustedProxyRemoteAddr("198.51.100.2:443") {
71 + t.Fatal("did not expect non-allowlisted remote to be trusted")
72 + }
73 +}
cmd/relay-server/registry.go
+184 -113
@@ -9,14 +9,60 @@ import (
9 "time"
10
11 "github.com/rs/zerolog/log"
12 - "golang.org/x/net/websocket"
12
13 + "gosuda.org/portal/cmd/relay-server/manager"
14 "gosuda.org/portal/portal"
15 "gosuda.org/portal/types"
16 )
17
18 -// SDKRegistry handles HTTP API for client lease registration
19 -type SDKRegistry struct{}
18 +// SDKRegistry handles HTTP API for client lease registration.
19 +type SDKRegistry struct {
20 + ipManager *manager.IPManager
21 + trustProxyHeaders bool
22 +}
23 +
24 +const sdkLeaseTTL = 30 * time.Second
25 +
26 +func reverseTokenMatches(expected, provided string) bool {
27 + expected = strings.TrimSpace(expected)
28 + provided = strings.TrimSpace(provided)
29 + if expected == "" || provided == "" {
30 + return false
31 + }
32 + return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1
33 +}
34 +
35 +func lookupLeaseEntry(serv *portal.RelayServer, leaseID string) (*portal.LeaseEntry, bool) {
36 + if serv == nil {
37 + return nil, false
38 + }
39 + entry, ok := serv.GetLeaseManager().GetLeaseByID(strings.TrimSpace(leaseID))
40 + if !ok || entry == nil || entry.Lease == nil {
41 + return nil, false
42 + }
43 + return entry, true
44 +}
45 +
46 +func (r *SDKRegistry) extractClientIP(req *http.Request) string {
47 + return manager.ExtractClientIP(req, r.trustProxyHeaders)
48 +}
49 +
50 +func (r *SDKRegistry) isClientIPBanned(clientIP string) bool {
51 + if r.ipManager == nil || clientIP == "" {
52 + return false
53 + }
54 + return r.ipManager.IsIPBanned(clientIP)
55 +}
56 +
57 +func (r *SDKRegistry) requireMethod(w http.ResponseWriter, req *http.Request, method string) bool {
58 + if req.Method == method {
59 + return true
60 + }
61 +
62 + w.Header().Set("Allow", method)
63 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
64 + return false
65 +}
66
67 // HandleSDKRequest routes /sdk/* requests.
68 func (r *SDKRegistry) HandleSDKRequest(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
@@ -39,54 +85,102 @@ func (r *SDKRegistry) HandleSDKRequest(w http.ResponseWriter, req *http.Request,
85 }
86
87 func (r *SDKRegistry) handleConnect(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
42 - wsHandler := websocket.Server{
43 - Handshake: func(*websocket.Config, *http.Request) error { return nil },
44 - Handler: websocket.Handler(serv.GetReverseHub().HandleConnect),
88 + if !r.requireMethod(w, req, http.MethodGet) {
89 + return
90 + }
91 +
92 + leaseID := strings.TrimSpace(req.URL.Query().Get("lease_id"))
93 + if leaseID == "" {
94 + http.Error(w, "missing lease_id", http.StatusBadRequest)
95 + return
96 + }
97 + token := strings.TrimSpace(req.Header.Get(portal.ReverseConnectTokenHeader))
98 + if token == "" {
99 + http.Error(w, "missing reverse token", http.StatusUnauthorized)
100 + return
101 + }
102 + clientIP := r.extractClientIP(req)
103 + if r.isClientIPBanned(clientIP) {
104 + http.Error(w, "ip is banned", http.StatusForbidden)
105 + return
106 + }
107 +
108 + entry, ok := lookupLeaseEntry(serv, leaseID)
109 + if !ok {
110 + http.Error(w, "lease not found", http.StatusNotFound)
111 + return
112 + }
113 + if !reverseTokenMatches(entry.Lease.ReverseToken, token) {
114 + http.Error(w, "unauthorized reverse connect", http.StatusUnauthorized)
115 + return
116 + }
117 +
118 + hijacker, ok := w.(http.Hijacker)
119 + if !ok {
120 + http.Error(w, "server does not support connection hijacking", http.StatusInternalServerError)
121 + return
122 + }
123 + conn, rw, err := hijacker.Hijack()
124 + if err != nil {
125 + http.Error(w, "failed to hijack connection", http.StatusInternalServerError)
126 + return
127 + }
128 + if _, err := rw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n"); err != nil {
129 + _ = conn.Close()
130 + return
131 + }
132 + if err := rw.Flush(); err != nil {
133 + _ = conn.Close()
134 + return
135 }
46 - wsHandler.ServeHTTP(w, req)
136 +
137 + serv.GetReverseHub().HandleConnect(conn, leaseID, token, clientIP)
138 }
139
49 -// handleRegister handles SDK lease registration requests
140 +// handleRegister handles SDK lease registration requests.
141 func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
51 - if req.Method != http.MethodPost {
52 - w.Header().Set("Allow", http.MethodPost)
53 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
142 + if !r.requireMethod(w, req, http.MethodPost) {
143 return
144 }
145
146 var registerReq types.RegisterRequest
147 if err := json.NewDecoder(req.Body).Decode(&registerReq); err != nil {
148 log.Error().Err(err).Msg("[Registry] Failed to decode registration request")
60 - writeJSON(w, types.RegisterResponse{
61 - Success: false,
62 - Message: "invalid request body",
63 - })
149 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
150 return
151 }
152
153 + registerReq.LeaseID = strings.TrimSpace(registerReq.LeaseID)
154 + registerReq.Name = strings.TrimSpace(registerReq.Name)
155 + registerReq.ReverseToken = strings.TrimSpace(registerReq.ReverseToken)
156 +
157 if registerReq.LeaseID == "" {
68 - writeJSON(w, types.RegisterResponse{
69 - Success: false,
70 - Message: "lease_id is required",
71 - })
158 + writeAPIError(w, http.StatusBadRequest, "missing_lease_id", "lease_id is required")
159 return
160 }
161
162 if registerReq.ReverseToken == "" {
76 - writeJSON(w, types.RegisterResponse{
77 - Success: false,
78 - Message: "reverse_token is required",
79 - })
163 + writeAPIError(w, http.StatusBadRequest, "missing_reverse_token", "reverse_token is required")
164 + return
165 + }
166 + name := registerReq.Name
167 + if !types.IsValidLeaseName(name) {
168 + writeAPIError(w, http.StatusBadRequest, "invalid_name", "name must be a DNS label (letters, digits, hyphen; no dots or underscores)")
169 + return
170 + }
171 + if !registerReq.TLS {
172 + writeAPIError(w, http.StatusBadRequest, "tls_required", "tls must be enabled")
173 + return
174 + }
175 + if r.isClientIPBanned(r.extractClientIP(req)) {
176 + writeAPIError(w, http.StatusForbidden, "ip_banned", "ip is banned")
177 return
178 }
179
180 // Ownership semantics: re-registration of an existing lease ID requires the same reverse token.
84 - if entry, ok := serv.GetLeaseManager().GetLeaseByID(registerReq.LeaseID); ok && entry != nil && entry.Lease != nil {
85 - if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(entry.Lease.ReverseToken)), []byte(registerReq.ReverseToken)) != 1 {
86 - writeJSON(w, types.RegisterResponse{
87 - Success: false,
88 - Message: "unauthorized lease registration",
89 - })
181 + if entry, ok := lookupLeaseEntry(serv, registerReq.LeaseID); ok {
182 + if !reverseTokenMatches(entry.Lease.ReverseToken, registerReq.ReverseToken) {
183 + writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized lease registration")
184 return
185 }
186 }
@@ -94,72 +188,64 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
188 // Create lease
189 lease := &portal.Lease{
190 ID: registerReq.LeaseID,
97 - Name: registerReq.Name,
191 + Name: name,
192 Metadata: registerReq.Metadata,
99 - Expires: time.Now().Add(30 * time.Second),
100 - TLS: registerReq.TLS,
193 + Expires: time.Now().Add(sdkLeaseTTL),
194 + TLS: true,
195 ReverseToken: registerReq.ReverseToken,
196 }
197
198 // Register with lease manager
199 if !serv.GetLeaseManager().UpdateLease(lease) {
106 - writeJSON(w, types.RegisterResponse{
107 - Success: false,
108 - Message: "failed to register lease (name conflict or policy violation)",
109 - })
200 + writeAPIError(w, http.StatusConflict, "lease_rejected", "failed to register lease (name conflict or policy violation)")
201 return
202 }
203
204 // Clear dropped state in case this is a re-registration after disconnect
205 serv.GetReverseHub().ClearDropped(registerReq.LeaseID)
206
116 - // Only register SNI route for TLS leases.
117 - if registerReq.TLS {
118 - sniName := strings.ToLower(strings.TrimSpace(registerReq.Name)) + "." + serv.BaseHost
119 - if err := serv.GetSNIRouter().RegisterRoute(sniName, registerReq.LeaseID, registerReq.Name); err != nil {
120 - // Keep lease and route state consistent on partial failure.
121 - serv.GetLeaseManager().DeleteLease(registerReq.LeaseID)
122 - writeJSON(w, types.RegisterResponse{
123 - Success: false,
124 - Message: fmt.Sprintf("failed to register SNI route: %v", err),
125 - })
126 - return
127 - }
207 + sniName := types.BuildSNIName(name, serv.BaseHost)
208 + if sniName == "" {
209 + serv.GetLeaseManager().DeleteLease(registerReq.LeaseID)
210 + writeAPIError(w, http.StatusInternalServerError, "sni_name_invalid", "failed to build SNI route name")
211 + return
212 + }
213 + if err := serv.GetSNIRouter().RegisterRoute(sniName, registerReq.LeaseID, name); err != nil {
214 + // Keep lease and route state consistent on partial failure.
215 + serv.GetLeaseManager().DeleteLease(registerReq.LeaseID)
216 + writeAPIError(w, http.StatusInternalServerError, "sni_register_failed", fmt.Sprintf("failed to register SNI route: %v", err))
217 + return
218 }
219
220 log.Info().
221 Str("lease_id", registerReq.LeaseID).
132 - Str("name", registerReq.Name).
133 - Bool("tls", registerReq.TLS).
222 + Str("name", name).
223 + Bool("tls", true).
224 Msg("[Registry] Lease registered")
225
226 // Build public URL
137 - publicURL := types.ServicePublicURL(flagPortalURL, registerReq.Name)
227 + publicURL := types.ServicePublicURL(flagPortalURL, name)
228
139 - writeJSON(w, types.RegisterResponse{
140 - Success: true,
229 + writeAPIData(w, http.StatusOK, types.RegisterResponse{
230 LeaseID: registerReq.LeaseID,
231 PublicURL: publicURL,
232 + Success: true,
233 })
234 }
235
146 -// handleUnregister handles SDK lease unregistration requests
236 +// handleUnregister handles SDK lease unregistration requests.
237 func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
148 - if req.Method != http.MethodPost {
149 - w.Header().Set("Allow", http.MethodPost)
150 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
238 + if !r.requireMethod(w, req, http.MethodPost) {
239 return
240 }
241
242 var unregisterReq types.UnregisterRequest
243 if err := json.NewDecoder(req.Body).Decode(&unregisterReq); err != nil {
244 log.Error().Err(err).Msg("[Registry] Failed to decode unregistration request")
157 - writeJSON(w, types.APIResponse{
158 - Success: false,
159 - Message: "invalid request body",
160 - })
245 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
246 return
247 }
248 + unregisterReq.LeaseID = strings.TrimSpace(unregisterReq.LeaseID)
249
250 // Delete from lease manager
251 if serv.GetLeaseManager().DeleteLease(unregisterReq.LeaseID) {
@@ -170,93 +256,78 @@ func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request,
256 serv.GetSNIRouter().UnregisterRouteByLeaseID(unregisterReq.LeaseID)
257 serv.GetReverseHub().DropLease(unregisterReq.LeaseID)
258
173 - writeJSON(w, types.APIResponse{
174 - Success: true,
175 - })
259 + writeAPIOK(w, http.StatusOK)
260 }
261
178 -// handleRenew handles SDK lease renewal requests (keepalive)
262 +// handleRenew handles SDK lease renewal requests (keepalive).
263 func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
180 - if req.Method != http.MethodPost {
181 - w.Header().Set("Allow", http.MethodPost)
182 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
264 + if !r.requireMethod(w, req, http.MethodPost) {
265 return
266 }
267
268 var renewReq types.RenewRequest
269 if err := json.NewDecoder(req.Body).Decode(&renewReq); err != nil {
270 log.Error().Err(err).Msg("[Registry] Failed to decode renewal request")
189 - writeJSON(w, types.APIResponse{
190 - Success: false,
191 - Message: "invalid request body",
192 - })
271 + writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
272 return
273 }
274
275 + renewReq.LeaseID = strings.TrimSpace(renewReq.LeaseID)
276 + renewReq.ReverseToken = strings.TrimSpace(renewReq.ReverseToken)
277 + if renewReq.LeaseID == "" {
278 + writeAPIError(w, http.StatusBadRequest, "missing_lease_id", "lease_id is required")
279 + return
280 + }
281 if renewReq.ReverseToken == "" {
197 - writeJSON(w, types.RegisterResponse{
198 - Success: false,
199 - Message: "reverse_token is required",
200 - })
282 + writeAPIError(w, http.StatusBadRequest, "missing_reverse_token", "reverse_token is required")
283 return
284 }
285
286 // Get existing lease
205 - entry, ok := serv.GetLeaseManager().GetLeaseByID(renewReq.LeaseID)
287 + entry, ok := lookupLeaseEntry(serv, renewReq.LeaseID)
288 if !ok {
207 - writeJSON(w, types.APIResponse{
208 - Success: false,
209 - Message: "lease not found",
210 - })
289 + writeAPIError(w, http.StatusNotFound, "lease_not_found", "lease not found")
290 return
291 }
213 - if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(entry.Lease.ReverseToken)), []byte(renewReq.ReverseToken)) != 1 {
214 - writeJSON(w, types.APIResponse{
215 - Success: false,
216 - Message: "unauthorized lease renewal",
217 - })
292 + if !reverseTokenMatches(entry.Lease.ReverseToken, renewReq.ReverseToken) {
293 + writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized lease renewal")
294 return
295 }
296
297 // Update expiration
222 - entry.Lease.Expires = time.Now().Add(30 * time.Second)
298 + entry.Lease.Expires = time.Now().Add(sdkLeaseTTL)
299 if !serv.GetLeaseManager().UpdateLease(entry.Lease) {
224 - writeJSON(w, types.APIResponse{
225 - Success: false,
226 - Message: "failed to renew lease",
227 - })
300 + writeAPIError(w, http.StatusInternalServerError, "renew_failed", "failed to renew lease")
301 return
302 }
303
231 - // Re-register route if needed (e.g., router restarted while lease remained active).
232 - // Only TLS leases need SNI routes.
233 - if entry.Lease.TLS {
234 - sniName := strings.ToLower(strings.TrimSpace(entry.Lease.Name)) + "." + serv.BaseHost
235 - if err := serv.GetSNIRouter().RegisterRoute(sniName, entry.Lease.ID, entry.Lease.Name); err != nil {
236 - log.Warn().
237 - Err(err).
238 - Str("lease_id", entry.Lease.ID).
239 - Str("name", entry.Lease.Name).
240 - Msg("[Registry] Failed to refresh SNI route on renew")
241 - }
304 + // Transport is TLS reverse-connect only; keep SNI route refreshed on renew.
305 + sniName := types.BuildSNIName(entry.Lease.Name, serv.BaseHost)
306 + if sniName == "" {
307 + log.Warn().
308 + Str("lease_id", entry.Lease.ID).
309 + Str("name", entry.Lease.Name).
310 + Str("base_host", serv.BaseHost).
311 + Msg("[Registry] Skipping SNI route refresh due to invalid SNI name")
312 + } else if err := serv.GetSNIRouter().RegisterRoute(sniName, entry.Lease.ID, entry.Lease.Name); err != nil {
313 + log.Warn().
314 + Err(err).
315 + Str("lease_id", entry.Lease.ID).
316 + Str("name", entry.Lease.Name).
317 + Msg("[Registry] Failed to refresh SNI route on renew")
318 }
319
244 - writeJSON(w, types.APIResponse{
245 - Success: true,
246 - })
320 + writeAPIOK(w, http.StatusOK)
321 }
322
323 // handleDomain returns the relay's base domain for TLS certificate construction.
250 -func (r *SDKRegistry) handleDomain(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
324 +func (r *SDKRegistry) handleDomain(w http.ResponseWriter, _ *http.Request, serv *portal.RelayServer) {
325 if serv.BaseHost == "" {
252 - writeJSON(w, map[string]any{
253 - "success": false,
254 - "message": "base domain not configured",
255 - })
326 + writeAPIError(w, http.StatusServiceUnavailable, "base_domain_missing", "base domain not configured")
327 return
328 }
258 - writeJSON(w, map[string]any{
259 - "success": true,
260 - "base_domain": serv.BaseHost,
329 + writeAPIData(w, http.StatusOK, types.DomainResponse{
330 + Success: true,
331 + BaseDomain: serv.BaseHost,
332 })
333 }
cmd/relay-server/registry_test.go new
+137
@@ -0,0 +1,137 @@
1 +package main
2 +
3 +import (
4 + "bytes"
5 + "context"
6 + "encoding/json"
7 + "net/http"
8 + "net/http/httptest"
9 + "strings"
10 + "testing"
11 + "time"
12 +
13 + "gosuda.org/portal/cmd/relay-server/manager"
14 + "gosuda.org/portal/portal"
15 + "gosuda.org/portal/types"
16 +)
17 +
18 +func newRegistryTestRelayServer(t *testing.T) *portal.RelayServer {
19 + t.Helper()
20 +
21 + serv, err := portal.NewRelayServer(context.Background(), nil, ":0", "example.com", "", "")
22 + if err != nil {
23 + t.Fatalf("create relay server: %v", err)
24 + }
25 + return serv
26 +}
27 +
28 +func TestSDKRegistryHandleRegisterTrimsReverseToken(t *testing.T) {
29 + serv := newRegistryTestRelayServer(t)
30 + registry := &SDKRegistry{}
31 +
32 + originalPortalURL := flagPortalURL
33 + flagPortalURL = "https://portal.example.com"
34 + t.Cleanup(func() {
35 + flagPortalURL = originalPortalURL
36 + })
37 +
38 + payload := types.RegisterRequest{
39 + LeaseID: "lease-register-token-trim",
40 + Name: "tenant",
41 + TLS: true,
42 + ReverseToken: " reverse-token ",
43 + }
44 + body, err := json.Marshal(payload)
45 + if err != nil {
46 + t.Fatalf("marshal register payload: %v", err)
47 + }
48 +
49 + req := httptest.NewRequest(http.MethodPost, types.PathSDKRegister, bytes.NewReader(body))
50 + rec := httptest.NewRecorder()
51 + registry.handleRegister(rec, req, serv)
52 +
53 + var envelope types.APIRawEnvelope
54 + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
55 + t.Fatalf("decode register envelope: %v", err)
56 + }
57 + if !envelope.OK {
58 + t.Fatalf("register response not successful: %+v", envelope)
59 + }
60 +
61 + var response types.RegisterResponse
62 + if err := json.Unmarshal(envelope.Data, &response); err != nil {
63 + t.Fatalf("decode register response data: %v", err)
64 + }
65 + if !response.Success {
66 + t.Fatalf("register response not successful: %+v", response)
67 + }
68 +
69 + entry, ok := serv.GetLeaseManager().GetLeaseByID(payload.LeaseID)
70 + if !ok || entry == nil || entry.Lease == nil {
71 + t.Fatalf("registered lease not found: %q", payload.LeaseID)
72 + }
73 + if got := entry.Lease.ReverseToken; got != "reverse-token" {
74 + t.Fatalf("stored reverse token mismatch: got %q want %q", got, "reverse-token")
75 + }
76 +}
77 +
78 +func TestSDKRegistryHandleRenewAcceptsTrimmedReverseToken(t *testing.T) {
79 + serv := newRegistryTestRelayServer(t)
80 + registry := &SDKRegistry{}
81 +
82 + lease := &portal.Lease{
83 + ID: "lease-renew-token-trim",
84 + Name: "tenant",
85 + TLS: true,
86 + ReverseToken: "reverse-token",
87 + Expires: time.Now().Add(30 * time.Second),
88 + }
89 + if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
90 + t.Fatal("failed to seed lease")
91 + }
92 +
93 + payload := types.RenewRequest{
94 + LeaseID: lease.ID,
95 + ReverseToken: " reverse-token ",
96 + }
97 + body, err := json.Marshal(payload)
98 + if err != nil {
99 + t.Fatalf("marshal renew payload: %v", err)
100 + }
101 +
102 + req := httptest.NewRequest(http.MethodPost, types.PathSDKRenew, bytes.NewReader(body))
103 + rec := httptest.NewRecorder()
104 + registry.handleRenew(rec, req, serv)
105 +
106 + var envelope types.APIRawEnvelope
107 + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
108 + t.Fatalf("decode renew envelope: %v", err)
109 + }
110 + if !envelope.OK {
111 + t.Fatalf("renew response not successful: %+v", envelope)
112 + }
113 +}
114 +
115 +func TestSDKRegistryHandleConnectRejectsBannedIP(t *testing.T) {
116 + serv := newRegistryTestRelayServer(t)
117 + ipManager := manager.NewIPManager()
118 + ipManager.BanIP("203.0.113.22")
119 + registry := &SDKRegistry{
120 + ipManager: ipManager,
121 + trustProxyHeaders: false,
122 + }
123 +
124 + req := httptest.NewRequest(http.MethodGet, types.PathSDKConnect+"?lease_id=lease-connect-ban", http.NoBody)
125 + req.RemoteAddr = "203.0.113.22:45000"
126 + req.Header.Set(portal.ReverseConnectTokenHeader, "reverse-token")
127 + rec := httptest.NewRecorder()
128 +
129 + registry.handleConnect(rec, req, serv)
130 +
131 + if rec.Code != http.StatusForbidden {
132 + t.Fatalf("handleConnect status = %d, want %d", rec.Code, http.StatusForbidden)
133 + }
134 + if !strings.Contains(rec.Body.String(), "ip is banned") {
135 + t.Fatalf("expected banned ip error body, got %q", rec.Body.String())
136 + }
137 +}
cmd/relay-server/serve.go
+47 -116
@@ -1,13 +1,12 @@
1 package main
2
3 import (
4 - "bufio"
4 "context"
5 "crypto/tls"
6 "embed"
7 "encoding/json"
8 "errors"
10 - "io"
9 + "fmt"
10 "net"
11 "net/http"
12 "strconv"
@@ -16,11 +15,14 @@ import (
15
16 "github.com/rs/zerolog/log"
17
18 + "gosuda.org/portal/cmd/relay-server/manager"
19 "gosuda.org/portal/portal"
20 "gosuda.org/portal/portal/keyless"
21 "gosuda.org/portal/types"
22 )
23
24 +const defaultHTTPSPort = "443"
25 +
26 //go:embed dist/*
27 var distFS embed.FS
28
@@ -58,7 +60,14 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
60 })
61
62 // SDK registry API for /sdk/* endpoints
61 - registry := &SDKRegistry{}
63 + var sdkIPManager *manager.IPManager
64 + if admin != nil {
65 + sdkIPManager = admin.GetIPManager()
66 + }
67 + registry := &SDKRegistry{
68 + ipManager: sdkIPManager,
69 + trustProxyHeaders: flagTrustProxyHeaders,
70 + }
71 appMux.HandleFunc(types.PathSDKPrefix, func(w http.ResponseWriter, r *http.Request) {
72 registry.HandleSDKRequest(w, r, serv)
73 })
@@ -75,7 +84,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
84 frontend.ServeAppStatic(w, r, p, serv)
85 })
86
78 - appMux.HandleFunc(types.PathHealthz, func(w http.ResponseWriter, r *http.Request) {
87 + appMux.HandleFunc(types.PathHealthz, func(w http.ResponseWriter, _ *http.Request) {
88 w.WriteHeader(http.StatusOK)
89 if _, err := w.Write([]byte("{\"status\":\"ok\"}")); err != nil {
90 log.Debug().Err(err).Msg("[healthz] failed to write response")
@@ -90,30 +99,12 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
99 // Create the main handler
100 appDomain := types.DefaultAppPattern(flagPortalURL)
101 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93 - // Compatibility endpoints for legacy webclient deployments.
94 - // Handle before host-based routing so stale service workers can recover.
95 - if r.URL.Path == "/service-worker.js" {
96 - frontend.ServeLegacyServiceWorkerCleanup(w, r)
97 - return
98 - }
99 - if strings.HasPrefix(r.URL.Path, "/frontend/") {
100 - frontend.ServeLegacyFrontendCompat(w, r)
101 - return
102 - }
103 -
102 // Handle subdomain requests
103 if types.IsSubdomain(appDomain, r.Host) {
104 log.Debug().
105 Str("host", r.Host).
106 Str("url", r.URL.String()).
107 Msg("[server] handling subdomain request")
110 - leaseName, leaseEntry, shouldProxy := shouldProxyHTTP(r.Host, serv)
111 - if shouldProxy {
112 - // TLS is not enabled on the tunnel, proxy via HTTP
113 - log.Debug().Str("host", r.Host).Msg("[server] proxying to HTTP")
114 - proxyToHTTP(w, r, serv, leaseName, leaseEntry)
115 - return
116 - }
108 // TLS-enabled subdomains should terminate on SNI passthrough.
109 // Redirect only insecure requests; secure requests here would loop.
110 if !isSecureRequest(r) {
@@ -135,16 +126,41 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
126 ReadHeaderTimeout: 5 * time.Second,
127 TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
128 }
138 - tlsCertFile, tlsKeyFile := "", ""
139 - if acmeManager := serv.GetACMEManager(); acmeManager != nil {
140 - tlsCertFile, tlsKeyFile = acmeManager.TLSFiles()
129 + rootHost := types.PortalRootHost(flagPortalURL)
130 + acmeManager := serv.GetACMEManager()
131 + if acmeManager != nil && rootHost != "" {
132 + srv.TLSConfig = &tls.Config{
133 + GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
134 + if hello == nil {
135 + return nil, errors.New("missing TLS client hello")
136 + }
137 +
138 + serverName := strings.TrimSpace(strings.ToLower(hello.ServerName))
139 + if serverName != "" && !strings.EqualFold(serverName, rootHost) {
140 + return nil, fmt.Errorf("acme certificate is only served for portal root host %q", rootHost)
141 + }
142 +
143 + certFile, keyFile := acmeManager.TLSFiles()
144 + if certFile == "" || keyFile == "" {
145 + return nil, errors.New("acme certificate files are not ready")
146 + }
147 +
148 + cert, err := tls.LoadX509KeyPair(certFile, keyFile)
149 + if err != nil {
150 + return nil, fmt.Errorf("load acme certificate: %w", err)
151 + }
152 + return &cert, nil
153 + },
154 + }
155 + } else if acmeManager != nil && rootHost == "" {
156 + log.Warn().Msg("[server] portal root host is empty; ACME TLS disabled for admin/API listener")
157 }
158
159 go func() {
160 var err error
145 - if tlsCertFile != "" && tlsKeyFile != "" {
146 - log.Info().Str("addr", addr).Str("cert_file", tlsCertFile).Str("key_file", tlsKeyFile).Msg("[server] https api enabled")
147 - err = srv.ListenAndServeTLS(tlsCertFile, tlsKeyFile)
161 + if srv.TLSConfig != nil {
162 + log.Info().Str("addr", addr).Str("root_host", rootHost).Msg("[server] https api enabled via ACME")
163 + err = srv.ListenAndServeTLS("", "")
164 } else {
165 log.Info().Str("addr", addr).Msgf("[server] http api enabled")
166 err = srv.ListenAndServe()
@@ -158,91 +174,6 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
174 return srv
175 }
176
161 -// shouldProxyHTTP checks if the request should be proxied via HTTP.
162 -// It returns leaseName, lease entry, and whether HTTP proxying should be used.
163 -func shouldProxyHTTP(host string, serv *portal.RelayServer) (string, *portal.LeaseEntry, bool) {
164 - leaseName, ok := types.LeaseNameFromHost(host, types.DefaultAppPattern(flagPortalURL))
165 - if !ok {
166 - log.Debug().Str("host", host).Msg("[proxy] shouldProxyHTTP: failed to extract lease name")
167 - return "", nil, false
168 - }
169 -
170 - entry, ok := serv.GetLeaseManager().GetLeaseByName(leaseName)
171 - if !ok {
172 - log.Debug().Str("lease_name", leaseName).Msg("[proxy] shouldProxyHTTP: lease not found")
173 - return leaseName, nil, true
174 - }
175 -
176 - // If TLS is disabled, we can proxy via HTTP.
177 - shouldProxy := !entry.Lease.TLS
178 - log.Debug().
179 - Str("lease_name", leaseName).
180 - Bool("tls", entry.Lease.TLS).
181 - Msg("[proxy] shouldProxyHTTP")
182 - return leaseName, entry, shouldProxy
183 -}
184 -
185 -func proxyToHTTP(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseName string, entry *portal.LeaseEntry) {
186 - if leaseName == "" {
187 - http.Error(w, "invalid subdomain", http.StatusBadRequest)
188 - return
189 - }
190 -
191 - if entry == nil {
192 - http.Error(w, "service not found", http.StatusNotFound)
193 - return
194 - }
195 -
196 - if entry.Lease.TLS {
197 - http.Error(w, "TLS enabled requires HTTPS access", http.StatusBadRequest)
198 - return
199 - }
200 -
201 - reverseConn, err := serv.GetReverseHub().AcquireForHTTP(entry.Lease.ID, portal.HTTPProxyWait)
202 - if err != nil {
203 - log.Error().
204 - Err(err).
205 - Str("lease", leaseName).
206 - Str("lease_id", entry.Lease.ID).
207 - Msg("[proxy] failed to connect to backend")
208 - http.Error(w, "service unavailable", http.StatusServiceUnavailable)
209 - return
210 - }
211 - defer reverseConn.Close()
212 - targetConn := reverseConn.Conn
213 -
214 - // Write the HTTP request to the tunnel
215 - if err := r.Write(targetConn); err != nil {
216 - log.Error().Err(err).Msg("[proxy] failed to write request to tunnel")
217 - http.Error(w, "proxy error", http.StatusInternalServerError)
218 - return
219 - }
220 -
221 - // Read the response from the tunnel
222 - resp, err := http.ReadResponse(bufio.NewReader(targetConn), r)
223 - if err != nil {
224 - log.Error().Err(err).Msg("[proxy] failed to read response from tunnel")
225 - http.Error(w, "proxy error", http.StatusInternalServerError)
226 - return
227 - }
228 - defer resp.Body.Close()
229 -
230 - // Copy headers
231 - for k, vv := range resp.Header {
232 - for _, v := range vv {
233 - w.Header().Add(k, v)
234 - }
235 - }
236 -
237 - // Write status code
238 - w.WriteHeader(resp.StatusCode)
239 -
240 - // Copy body
241 - if _, err := io.Copy(w, resp.Body); err != nil {
242 - log.Debug().Err(err).Msg("[proxy] error copying response body")
243 - }
244 -}
245 -
177 // redirectToHTTPS redirects the request to HTTPS using the configured SNI port.
178 func redirectToHTTPS(w http.ResponseWriter, r *http.Request, sniListenAddr string) {
179 host := strings.TrimSpace(r.Host)
@@ -251,7 +182,7 @@ func redirectToHTTPS(w http.ResponseWriter, r *http.Request, sniListenAddr strin
182 }
183
184 // Extract port from sniListenAddr (e.g., ":443", "443", "example.com:443")
254 - port := "443"
185 + port := defaultHTTPSPort
186 if raw := strings.TrimSpace(sniListenAddr); raw != "" {
187 switch {
188 case strings.HasPrefix(raw, ":"):
@@ -264,11 +195,11 @@ func redirectToHTTPS(w http.ResponseWriter, r *http.Request, sniListenAddr strin
195 }
196 }
197 if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
267 - port = "443"
198 + port = defaultHTTPSPort
199 }
200 }
201
271 - if port != "443" {
202 + if port != defaultHTTPSPort {
203 host = net.JoinHostPort(host, port)
204 }
205
cmd/relay-server/serve_test.go new
+81
@@ -0,0 +1,81 @@
1 +package main
2 +
3 +import (
4 + "context"
5 + "net/http"
6 + "net/http/httptest"
7 + "strings"
8 + "testing"
9 + "time"
10 +
11 + "gosuda.org/portal/portal"
12 + "gosuda.org/portal/types"
13 +)
14 +
15 +func TestServeAPILegacyCompatPathsRemoved(t *testing.T) {
16 + prevPortalURL := flagPortalURL
17 + prevTrustProxyHeaders := flagTrustProxyHeaders
18 + flagPortalURL = "https://portal.example.com"
19 + flagTrustProxyHeaders = false
20 + t.Cleanup(func() {
21 + flagPortalURL = prevPortalURL
22 + flagTrustProxyHeaders = prevTrustProxyHeaders
23 + })
24 +
25 + serv, err := portal.NewRelayServer(
26 + context.Background(),
27 + nil,
28 + ":0",
29 + types.PortalRootHost(flagPortalURL),
30 + "",
31 + "",
32 + )
33 + if err != nil {
34 + t.Fatalf("create relay server: %v", err)
35 + }
36 +
37 + frontend := NewFrontend()
38 + apiSrv := serveAPI(":0", serv, nil, frontend, func() {})
39 + t.Cleanup(func() {
40 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
41 + defer cancel()
42 + _ = apiSrv.Shutdown(shutdownCtx)
43 + })
44 +
45 + cases := []struct {
46 + path string
47 + forbiddenBodies []string
48 + }{
49 + {
50 + path: "/frontend/manifest.json",
51 + forbiddenBodies: []string{"legacy webclient removed", "refresh required"},
52 + },
53 + {
54 + path: "/frontend/app.js",
55 + forbiddenBodies: []string{"legacy webclient assets removed", "refresh required"},
56 + },
57 + {
58 + path: "/service-worker.js",
59 + forbiddenBodies: []string{"portal-sw-cleanup-v2", "legacy sw cleanup worker"},
60 + },
61 + }
62 +
63 + for _, tc := range cases {
64 + req := httptest.NewRequest(http.MethodGet, tc.path, nil)
65 + req.Host = "portal.example.com"
66 + rec := httptest.NewRecorder()
67 +
68 + apiSrv.Handler.ServeHTTP(rec, req)
69 +
70 + if rec.Code == http.StatusGone {
71 + t.Fatalf("%s unexpectedly returned %d (legacy compatibility path)", tc.path, rec.Code)
72 + }
73 +
74 + body := strings.ToLower(rec.Body.String())
75 + for _, forbidden := range tc.forbiddenBodies {
76 + if strings.Contains(body, strings.ToLower(forbidden)) {
77 + t.Fatalf("%s response still contains legacy compatibility text %q", tc.path, forbidden)
78 + }
79 + }
80 + }
81 +}
cmd/relay-server/tunnel.go
+1 -1
@@ -122,7 +122,7 @@ func serveTunnelScript(w http.ResponseWriter, r *http.Request) {
122 }
123
124 targetOS := r.URL.Query().Get("os")
125 - isWindows := false
125 + var isWindows bool
126 if targetOS != "" {
127 isWindows = strings.EqualFold(targetOS, "windows")
128 } else {
cmd/relay-server/utils.go
+76 -27
@@ -16,6 +16,11 @@ import (
16 "gosuda.org/portal/types"
17 )
18
19 +const (
20 + leaseConnectedWindow = 15 * time.Second
21 + staleLeaseHideWindow = 3 * time.Minute
22 +)
23 +
24 func isSecureRequest(r *http.Request) bool {
25 if r == nil {
26 return false
@@ -23,13 +28,16 @@ func isSecureRequest(r *http.Request) bool {
28 if r.TLS != nil {
29 return true
30 }
31 + if !flagTrustProxyHeaders || !manager.IsTrustedProxyRemoteAddr(r.RemoteAddr) {
32 + return false
33 + }
34 if strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") {
35 return true
36 }
37 return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Ssl")), "on")
38 }
39
32 -// getContentType returns the MIME type for a file extension
40 +// getContentType returns the MIME type for a file extension.
41 func getContentType(ext string) string {
42 switch ext {
43 case ".html":
@@ -55,7 +63,7 @@ func getContentType(ext string) string {
63 }
64 }
65
58 -// setCORSHeaders sets permissive CORS headers for GET/OPTIONS and common headers
66 +// setCORSHeaders sets permissive CORS headers for GET/OPTIONS and common headers.
67 func setCORSHeaders(w http.ResponseWriter) {
68 w.Header().Set("Access-Control-Allow-Origin", "*")
69 w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
@@ -64,24 +72,24 @@ func setCORSHeaders(w http.ResponseWriter) {
72
73 // leaseRow represents a lease entry for display in admin UI and frontend.
74 type leaseRow struct {
67 - Peer string
68 - Name string
75 + TTL string
76 + Metadata string
77 Kind string
70 - Connected bool
78 + IP string
79 DNS string
80 LastSeen string
81 LastSeenISO string
82 FirstSeenISO string
75 - TTL string
83 + Name string
84 + Peer string
85 Link string
77 - StaleRed bool
86 + BPS int64
87 Hide bool
79 - Metadata string
80 - BPS int64 // bytes-per-second limit (0 = unlimited)
81 - IsApproved bool // whether lease is approved (for manual mode)
82 - IsDenied bool // whether lease is denied (for manual mode)
83 - IP string // client IP address (for IP-based ban)
84 - IsIPBanned bool // whether the IP is banned
88 + StaleRed bool
89 + IsApproved bool
90 + IsDenied bool
91 + Connected bool
92 + IsIPBanned bool
93 }
94
95 // formatDuration formats a duration for TTL display.
@@ -119,9 +127,8 @@ func (leaseRow) formatLastSeen(d time.Duration) string {
127 return fmt.Sprintf("%ds", int(d/time.Second))
128 }
129
122 -// isConnected returns true if the lease was seen recently.
123 -func (leaseRow) isConnected(since time.Duration) bool {
124 - return since < 15*time.Second
130 +func isLeaseConnected(since time.Duration) bool {
131 + return since < leaseConnectedWindow
132 }
133
134 // fromLeaseEntry populates the leaseRow from a LeaseEntry with common fields.
@@ -129,7 +136,7 @@ func (r *leaseRow) fromLeaseEntry(entry *portal.LeaseEntry, admin *Admin, portal
136 lease := entry.Lease
137 identityID := lease.ID
138 since := max(time.Since(entry.LastSeen), 0)
132 - connected := r.isConnected(since)
139 + connected := isLeaseConnected(since)
140
141 name := lease.Name
142 if name == "" {
@@ -170,12 +177,23 @@ func (r *leaseRow) fromLeaseEntry(entry *portal.LeaseEntry, admin *Admin, portal
177 r.LastSeenISO = entry.LastSeen.UTC().Format(time.RFC3339)
178 r.FirstSeenISO = entry.FirstSeen.UTC().Format(time.RFC3339)
179 r.TTL = r.formatDuration(time.Until(entry.Expires))
173 - linkLabel := strings.TrimSpace(lease.Name)
174 - if linkLabel == "" {
175 - linkLabel = identityID
180 + linkLabel := identityID
181 + if normalized, ok := types.NormalizeServiceName(lease.Name); ok {
182 + linkLabel = normalized
183 + } else if normalized, ok := types.NormalizeServiceName(identityID); ok {
184 + linkLabel = normalized
185 }
177 - r.Link = fmt.Sprintf("//%s.%s/", linkLabel, types.PortalHostPort(portalURL))
178 - r.StaleRed = !connected && since >= 15*time.Second
186 +
187 + publicHost := types.PortalRootHost(portalURL)
188 + if publicHost == "" {
189 + publicHost = types.PortalHostPort(portalURL)
190 + }
191 + if linkLabel != "" && publicHost != "" {
192 + r.Link = fmt.Sprintf("//%s.%s/", linkLabel, publicHost)
193 + } else {
194 + r.Link = ""
195 + }
196 + r.StaleRed = !connected && since >= leaseConnectedWindow
197 r.Hide = entry.ParsedMetadata != nil && entry.ParsedMetadata.Hide
198 r.Metadata = metadataStr
199 r.BPS = bps
@@ -229,8 +247,8 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin, forAdmin
247 continue
248 }
249 since := max(now.Sub(entry.LastSeen), 0)
232 - connected := (&leaseRow{}).isConnected(since)
233 - if !connected && since >= 3*time.Minute {
250 + connected := isLeaseConnected(since)
251 + if !connected && since >= staleLeaseHideWindow {
252 continue
253 }
254 }
@@ -243,10 +261,41 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin, forAdmin
261 return rows
262 }
263
246 -func writeJSON(w http.ResponseWriter, v any) {
264 +func writeAPIData(w http.ResponseWriter, status int, data any) {
265 + w.Header().Set("Content-Type", "application/json")
266 + w.WriteHeader(status)
267 + if err := json.NewEncoder(w).Encode(types.APIEnvelope{
268 + OK: true,
269 + Data: data,
270 + }); err != nil {
271 + log.Error().Err(err).Msg("[HTTP] Failed to encode API success response")
272 + }
273 +}
274 +
275 +func writeAPIOK(w http.ResponseWriter, status int) {
276 w.Header().Set("Content-Type", "application/json")
248 - if err := json.NewEncoder(w).Encode(v); err != nil {
249 - log.Error().Err(err).Msg("[HTTP] Failed to encode response")
277 + w.WriteHeader(status)
278 + if err := json.NewEncoder(w).Encode(types.APIEnvelope{OK: true}); err != nil {
279 + log.Error().Err(err).Msg("[HTTP] Failed to encode API success response")
280 + }
281 +}
282 +
283 +func writeAPIError(w http.ResponseWriter, status int, code, message string) {
284 + writeAPIErrorWithData(w, status, code, message, nil)
285 +}
286 +
287 +func writeAPIErrorWithData(w http.ResponseWriter, status int, code, message string, data any) {
288 + w.Header().Set("Content-Type", "application/json")
289 + w.WriteHeader(status)
290 + if err := json.NewEncoder(w).Encode(types.APIEnvelope{
291 + OK: false,
292 + Data: data,
293 + Error: &types.APIError{
294 + Code: code,
295 + Message: message,
296 + },
297 + }); err != nil {
298 + log.Error().Err(err).Msg("[HTTP] Failed to encode API error response")
299 }
300 }
301
cmd/relay-server/utils_test.go new
+106
@@ -0,0 +1,106 @@
1 +package main
2 +
3 +import (
4 + "crypto/tls"
5 + "net"
6 + "net/http"
7 + "net/http/httptest"
8 + "testing"
9 +
10 + "gosuda.org/portal/cmd/relay-server/manager"
11 +)
12 +
13 +func mustParseCIDR(t *testing.T, raw string) *net.IPNet {
14 + t.Helper()
15 +
16 + _, network, err := net.ParseCIDR(raw)
17 + if err != nil {
18 + t.Fatalf("parse CIDR %q: %v", raw, err)
19 + }
20 + return network
21 +}
22 +
23 +func TestIsSecureRequest(t *testing.T) {
24 + originalTrustProxyHeaders := flagTrustProxyHeaders
25 + t.Cleanup(func() {
26 + flagTrustProxyHeaders = originalTrustProxyHeaders
27 + manager.SetTrustedProxyCIDRs(nil)
28 + })
29 +
30 + cases := []struct {
31 + headers map[string]string
32 + name string
33 + remote string
34 + allowlist []*net.IPNet
35 + tls bool
36 + trust bool
37 + expected bool
38 + }{
39 + {
40 + name: "tls request is always secure",
41 + tls: true,
42 + trust: false,
43 + remote: "198.51.100.10:443",
44 + expected: true,
45 + },
46 + {
47 + name: "proxy headers ignored when trust flag disabled",
48 + trust: false,
49 + remote: "10.1.2.3:8080",
50 + headers: map[string]string{"X-Forwarded-Proto": "https"},
51 + allowlist: []*net.IPNet{mustParseCIDR(t, "10.0.0.0/8")},
52 + expected: false,
53 + },
54 + {
55 + name: "proxy headers ignored with empty allowlist",
56 + trust: true,
57 + remote: "10.1.2.3:8080",
58 + headers: map[string]string{"X-Forwarded-Proto": "https"},
59 + expected: false,
60 + },
61 + {
62 + name: "trusted proxy with forwarded proto is secure",
63 + trust: true,
64 + remote: "10.1.2.3:8080",
65 + headers: map[string]string{"X-Forwarded-Proto": "https"},
66 + allowlist: []*net.IPNet{mustParseCIDR(t, "10.0.0.0/8")},
67 + expected: true,
68 + },
69 + {
70 + name: "trusted proxy with forwarded ssl on is secure",
71 + trust: true,
72 + remote: "10.1.2.3:8080",
73 + headers: map[string]string{"X-Forwarded-Ssl": "on"},
74 + allowlist: []*net.IPNet{mustParseCIDR(t, "10.0.0.0/8")},
75 + expected: true,
76 + },
77 + {
78 + name: "untrusted proxy headers are rejected",
79 + trust: true,
80 + remote: "198.51.100.99:8080",
81 + headers: map[string]string{"X-Forwarded-Proto": "https"},
82 + allowlist: []*net.IPNet{mustParseCIDR(t, "10.0.0.0/8")},
83 + expected: false,
84 + },
85 + }
86 +
87 + for _, tc := range cases {
88 + t.Run(tc.name, func(t *testing.T) {
89 + flagTrustProxyHeaders = tc.trust
90 + manager.SetTrustedProxyCIDRs(tc.allowlist)
91 +
92 + req := httptest.NewRequest(http.MethodGet, "http://localhost/admin", http.NoBody)
93 + req.RemoteAddr = tc.remote
94 + if tc.tls {
95 + req.TLS = &tls.ConnectionState{}
96 + }
97 + for key, value := range tc.headers {
98 + req.Header.Set(key, value)
99 + }
100 +
101 + if got := isSecureRequest(req); got != tc.expected {
102 + t.Fatalf("isSecureRequest() = %v, want %v", got, tc.expected)
103 + }
104 + })
105 + }
106 +}
docs/adr/0001-raw-tcp-reverse-connect-and-autocert-tls.md new
+42
@@ -0,0 +1,42 @@
1 +# ADR 0001: Raw TCP Reverse Connect and ACME TLS for Portal Root
2 +
3 +- Status: `Accepted`
4 +- Date: `2026-03-03`
5 +- Owners: `Portal maintainers`
6 +
7 +## Context
8 +
9 +Portal must support NAT-friendly inbound connectivity for tenant traffic while keeping root-domain behavior predictable. The legacy approach mixed websocket assumptions into reverse-connect flow and derived TLS hosts inconsistently for non-apex portal domains.
10 +
11 +## Decision
12 +
13 +- Use raw TCP reverse-connect as the canonical transport between relay and tunnel clients.
14 +- Keep SNI routing as the ingress split for tenant subdomains.
15 +- Keep root-domain fallback forwarding from SNI router to the admin/API listener.
16 +- Derive relay `BaseHost` and TLS domain construction from the full portal root host (for example `portal.example.com`), not apex extraction (`example.com`).
17 +- Serve admin/API TLS from ACME-managed certificate material when files are available.
18 +
19 +## Consequences
20 +
21 +### Benefits
22 +
23 +- Reverse-connect transport stays NAT-friendly and removes proxy protocol ambiguity.
24 +- Root-domain behavior remains explicit: SNI fallback forwards to admin/API listener.
25 +- SNI route registration, public URL derivation, and SDK TLS domain construction align on the same host derivation.
26 +
27 +### Trade-offs
28 +
29 +- TLS enablement still depends on ACME certificate files being present.
30 +- Non-apex portal host deployments require wildcard coverage on the full portal root host (for example `*.portal.example.com`).
31 +
32 +### Risks and Mitigations
33 +
34 +- Risk: ACME certificate files unavailable at startup.
35 + Mitigation: keep HTTP fallback when ACME/root-host prerequisites are not met and log explicit TLS enablement state.
36 +- Risk: non-apex portal host deployments route to wrong TLS host if derivation drifts.
37 + Mitigation: enforce portal-root-host derivation consistently in relay and SDK.
38 +
39 +## Alternatives Considered
40 +
41 +- Keep websocket reverse-connect transport for compatibility: rejected due to complexity and policy drift.
42 +- Derive SNI routes from apex/base domain only: rejected due to `portal.example.com` mismatch failures.
docs/adr/0002-remove-websocket-and-legacy-compatibility.md new
+40
@@ -0,0 +1,40 @@
1 +# ADR 0002: Remove WebSocket and Legacy Compatibility Paths
2 +
3 +- Status: `Accepted`
4 +- Date: `2026-03-03`
5 +- Owners: `Portal maintainers`
6 +
7 +## Context
8 +
9 +Portal transport and registration flows previously carried compatibility behavior for websocket-era clients. This increased code-path count, made failure handling inconsistent, and obscured the canonical tunnel behavior.
10 +
11 +## Decision
12 +
13 +- Treat raw TCP reverse-connect as the only supported data-plane transport.
14 +- Remove websocket compatibility expectations from architecture guidance and operational assumptions.
15 +- Keep SDK registration APIs aligned with current raw transport behavior only.
16 +
17 +## Consequences
18 +
19 +### Benefits
20 +
21 +- Fewer protocol paths to secure, test, and debug.
22 +- Clearer invariants for lease registration, route updates, and reverse-hub acquisition.
23 +- Lower maintenance cost by removing compatibility-only logic from design decisions.
24 +
25 +### Trade-offs
26 +
27 +- Older websocket-based clients are intentionally unsupported.
28 +- Migration burden moves to client operators that still depend on websocket semantics.
29 +
30 +### Risks and Mitigations
31 +
32 +- Risk: clients attempt deprecated websocket workflows and fail unexpectedly.
33 + Mitigation: keep docs explicit that raw TCP is the single supported transport and reject unsupported paths clearly.
34 +- Risk: hidden compatibility assumptions in future changes.
35 + Mitigation: use this ADR as a gate in design/review to avoid reintroducing websocket dependencies.
36 +
37 +## Alternatives Considered
38 +
39 +- Keep dual-stack (raw TCP + websocket): rejected due to complexity and security surface growth.
40 +- Keep websocket as fallback only: rejected because fallback behavior still multiplies test and incident scenarios.
docs/adr/0003-security-and-anti-abuse-hardening.md new
+43
@@ -0,0 +1,43 @@
1 +# ADR 0003: Security and Anti-Abuse Hardening
2 +
3 +- Status: `Accepted`
4 +- Date: `2026-03-03`
5 +- Owners: `Portal maintainers`
6 +
7 +## Context
8 +
9 +Portal accepts unauthenticated internet traffic on relay/admin edges while managing long-lived reverse tunnel sessions. Abuse controls and security boundaries must be first-class or operational risk rises quickly.
10 +
11 +## Decision
12 +
13 +- Treat admin-authenticated controls (approval, settings, bans) as authoritative for runtime policy.
14 +- Wire IP ban checks into SDK registration and reverse-connection acceptance paths.
15 +- Enforce lease-token validation before bridging reverse connections.
16 +- Keep root-domain and tenant-subdomain traffic split through SNI routing rules to prevent accidental cross-path handling.
17 +
18 +## Consequences
19 +
20 +### Benefits
21 +
22 +- Faster blocking response to abusive sources with centralized IP policy.
23 +- Stronger boundary between control-plane actions and data-plane forwarding.
24 +- Reduced chance of unauthorized reverse-connection use.
25 +
26 +### Trade-offs
27 +
28 +- Extra checks in critical paths may increase operational complexity during debugging.
29 +- Incorrect ban-list management can block legitimate clients if policy operations are misused.
30 +
31 +### Risks and Mitigations
32 +
33 +- Risk: policy drift between admin state and runtime enforcement.
34 + Mitigation: initialize runtime components from admin-managed settings and keep a single IP manager source.
35 +- Risk: abuse pressure shifts from one endpoint to another.
36 + Mitigation: enforce checks at multiple ingress points (SDK registration and reverse-hub admission).
37 +- Risk: accidental weakening during refactors.
38 + Mitigation: require explicit ADR-aware review for security-sensitive path changes.
39 +
40 +## Alternatives Considered
41 +
42 +- Endpoint-local ad hoc checks only: rejected because policy diverges and creates inconsistent enforcement.
43 +- Rely solely on external perimeter controls: rejected because application-level lease/auth context is required for accurate decisions.
docs/adr/README.md new
+22
@@ -0,0 +1,22 @@
1 +# Architecture Decision Records (ADR)
2 +
3 +This directory is the source of truth for major Portal architecture decisions.
4 +
5 +## Status Values
6 +
7 +- `Accepted`: active and expected in current code paths
8 +- `Superseded`: replaced by a newer ADR
9 +- `Deprecated`: still present but planned for removal
10 +- `Proposed`: under review, not yet implemented
11 +
12 +## ADR Index
13 +
14 +- [0001 - Raw TCP Reverse Connect and ACME TLS for Portal Root](./0001-raw-tcp-reverse-connect-and-autocert-tls.md) (`Accepted`)
15 +- [0002 - Remove WebSocket and Legacy Compatibility Paths](./0002-remove-websocket-and-legacy-compatibility.md) (`Accepted`)
16 +- [0003 - Security and Anti-Abuse Hardening](./0003-security-and-anti-abuse-hardening.md) (`Accepted`)
17 +
18 +## Authoring Notes
19 +
20 +- Use [template.md](./template.md) for new ADRs.
21 +- Keep each ADR focused on one decision and its consequences.
22 +- Update this index whenever adding, superseding, or deprecating an ADR.
docs/adr/template.md new
+34
@@ -0,0 +1,34 @@
1 +# ADR XXXX: <Title>
2 +
3 +- Status: `Proposed|Accepted|Superseded|Deprecated`
4 +- Date: `YYYY-MM-DD`
5 +- Owners: `<team or maintainer>`
6 +
7 +## Context
8 +
9 +Describe the problem, constraints, and why a decision is required.
10 +
11 +## Decision
12 +
13 +State the final decision in concrete terms.
14 +
15 +## Consequences
16 +
17 +### Benefits
18 +
19 +- <benefit 1>
20 +- <benefit 2>
21 +
22 +### Trade-offs
23 +
24 +- <trade-off 1>
25 +- <trade-off 2>
26 +
27 +### Risks and Mitigations
28 +
29 +- Risk: <risk>
30 + Mitigation: <mitigation>
31 +
32 +## Alternatives Considered
33 +
34 +- `<alternative>`: reason rejected
docs/architecture.md
+8 -1
@@ -72,10 +72,11 @@ Result: simple HTTP proxy path for development or non-TLS services.
72 - `tls`
73 - `reverse_token`
74 - Relay stores lease and (TLS only) registers SNI route.
75 +- Route hostnames are generated from normalized lease + normalized `PORTAL_URL` host (`scheme/path/port` removed).
76
77 ### 2. Reverse Connect
78
78 -- Backend opens websocket to `GET /sdk/connect?lease_id=...`
79 +- Backend opens raw TCP reverse channel to `GET /sdk/connect?lease_id=...` and upgrades into a long-lived stream
80 - `X-Portal-Reverse-Token` is validated server-side.
81 - Connection is pooled in `ReverseHub`.
82
@@ -98,6 +99,7 @@ Result: simple HTTP proxy path for development or non-TLS services.
99 3. No-route handler (used for portal root-domain fallback)
100
101 Note: wildcard does not match apex domain (`example.com`).
102 +For non-apex `PORTAL_URL` values such as `https://portal.example.com:8443/admin`, SNI/public hostnames are normalized to `<lease>.portal.example.com`.
103
104 ## Keyless and Certificates
105
@@ -112,4 +114,9 @@ Note: wildcard does not match apex domain (`example.com`).
114 - Reverse-only backend connectivity (no inbound port on app host required)
115 - Per-lease reverse token authorization
116 - Separation of control plane (`/sdk/*`) and data plane (SNI/HTTP forwarding)
117 +- Single transport policy: raw TCP reverse-connect only (no websocket/legacy compatibility mode)
118 - Unified lease abstraction for routing, metadata, and lifecycle
119 +
120 +## ADRs
121 +
122 +- Decision records: [docs/adr/README.md](./adr/README.md)
docs/deployment.md
+9
@@ -42,6 +42,13 @@ Expected:
42 - `example.com -> <server-ip>`
43 - `*.example.com -> <server-ip>`
44
45 +If you run Portal on a non-apex host (for example, `PORTAL_URL=https://portal.example.com:8443`), use host-specific records instead:
46 +
47 +- `portal.example.com -> <server-ip>`
48 +- `*.portal.example.com -> <server-ip>`
49 +
50 +Portal normalizes `PORTAL_URL` to its host for routing, so service SNI/public hosts become `<lease>.portal.example.com`.
51 +
52 ### 2.3 Create Cloudflare API Token
53
54 Cloudflare Dashboard -> `My Profile` -> `API Tokens` -> `Create Token`.
@@ -70,6 +77,8 @@ KEYLESS_DIR=/etc/portal/keyless
77 CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
78 ```
79
80 +For non-apex deployments, set `PORTAL_URL` and `BOOTSTRAP_URIS` to the same non-apex host value (for example, `https://portal.example.com:8443`).
81 +
82 ### 3-2. Start Relay
83
84 ```bash
docs/glossary.md
+2 -1
@@ -58,7 +58,8 @@ The certificate issuance/renewal method used with Cloudflare DNS API token when
58
59 ## Base Domain
60
61 -The root domain derived from `PORTAL_URL` (for example, `example.com`) used to build service subdomains.
61 +The normalized host derived from `PORTAL_URL` and used to build service subdomains.
62 +For non-apex values such as `https://portal.example.com:8443/admin`, the base domain is `portal.example.com` (scheme, path, and port are removed).
63
64 ## Admin/API Server
65
go.mod
+1 -1
@@ -6,7 +6,6 @@ require (
6 github.com/go-acme/lego/v4 v4.32.0
7 github.com/gosuda/keyless_tls v0.0.1-0.20260227054723-d699441f3834
8 github.com/rs/zerolog v1.34.0
9 - golang.org/x/net v0.51.0
9 )
10
11 require (
@@ -17,6 +16,7 @@ require (
16 github.com/miekg/dns v1.1.72 // indirect
17 golang.org/x/crypto v0.48.0 // indirect
18 golang.org/x/mod v0.33.0 // indirect
19 + golang.org/x/net v0.51.0 // indirect
20 golang.org/x/sync v0.19.0 // indirect
21 golang.org/x/sys v0.41.0 // indirect
22 golang.org/x/text v0.34.0 // indirect
portal/acme/acme.go
+21 -21
@@ -45,10 +45,10 @@ type provisionConfig struct {
45 KeyFile string
46 CertFile string
47 Email string
48 - Domains []string
48 AccountKeyFile string
49 RegistrationFile string
50 CloudflareToken string
51 + Domains []string
52 }
53
54 type Config struct {
@@ -57,17 +57,17 @@ type Config struct {
57 CloudflareToken string
58 }
59
60 -type AcmeManager struct {
61 - cfg Config
62 - mu sync.RWMutex
60 +type Manager struct {
61 stopCh chan struct{}
62 + cfg Config
63 waitGroup sync.WaitGroup
64 + mu sync.RWMutex
65 startOnce sync.Once
66 stopOnce sync.Once
67 }
68
69 -func NewManager(cfg Config) *AcmeManager {
70 - return &AcmeManager{
69 +func NewManager(cfg Config) *Manager {
70 + return &Manager{
71 cfg: Config{
72 BaseDomain: cfg.BaseDomain,
73 KeyDir: cfg.KeyDir,
@@ -77,7 +77,7 @@ func NewManager(cfg Config) *AcmeManager {
77 }
78 }
79
80 -func (m *AcmeManager) keyDir() string {
80 +func (m *Manager) keyDir() string {
81 if m == nil {
82 return ""
83 }
@@ -85,7 +85,7 @@ func (m *AcmeManager) keyDir() string {
85 }
86
87 // SigningKeyFile returns the unified signer key path under configured key directory.
88 -func (m *AcmeManager) SigningKeyFile() string {
88 +func (m *Manager) SigningKeyFile() string {
89 if m == nil {
90 return ""
91 }
@@ -97,9 +97,9 @@ func (m *AcmeManager) SigningKeyFile() string {
97 }
98
99 type acmeUser struct {
100 - Email string
101 - Registration *registration.Resource
100 Key crypto.PrivateKey
101 + Registration *registration.Resource
102 + Email string
103 }
104
105 func (u *acmeUser) GetEmail() string {
@@ -124,7 +124,7 @@ func (u *acmeUser) GetPrivateKey() crypto.PrivateKey {
124 }
125
126 // EnsureSigningKey provisions a keyless signing key via ACME DNS-01 when missing.
127 -func (m *AcmeManager) EnsureSigningKey(ctx context.Context) (string, error) {
127 +func (m *Manager) EnsureSigningKey(ctx context.Context) (string, error) {
128 if m == nil {
129 return "", errors.New("acme manager is nil")
130 }
@@ -138,7 +138,7 @@ func (m *AcmeManager) EnsureSigningKey(ctx context.Context) (string, error) {
138
139 baseDomain := m.cfg.BaseDomain
140 if baseDomain == "" {
141 - return "", fmt.Errorf("base domain is required for ACME provisioning")
141 + return "", errors.New("base domain is required for ACME provisioning")
142 }
143
144 targets, err := buildCertTargets(baseDomain, configuredKeyDir)
@@ -213,7 +213,7 @@ func (m *AcmeManager) EnsureSigningKey(ctx context.Context) (string, error) {
213 }
214
215 // TLSFiles returns the unified fullchain and private key file paths when both exist.
216 -func (m *AcmeManager) TLSFiles() (string, string) {
216 +func (m *Manager) TLSFiles() (string, string) {
217 if m == nil {
218 return "", ""
219 }
@@ -311,7 +311,7 @@ func certCoversDomains(certFile string, domains []string) (bool, error) {
311 return true, nil
312 }
313
314 -func (m *AcmeManager) provisionCertificate(cfg provisionConfig) error {
314 +func (m *Manager) provisionCertificate(cfg provisionConfig) error {
315 for _, path := range []string{cfg.KeyFile, cfg.CertFile, cfg.AccountKeyFile, cfg.RegistrationFile} {
316 if err := ensureParentDir(path); err != nil {
317 return err
@@ -348,20 +348,21 @@ func (m *AcmeManager) provisionCertificate(cfg provisionConfig) error {
348 if err != nil {
349 return fmt.Errorf("create Cloudflare DNS provider: %w", err)
350 }
351 - if err := client.Challenge.SetDNS01Provider(provider); err != nil {
351 + err = client.Challenge.SetDNS01Provider(provider)
352 + if err != nil {
353 return fmt.Errorf("set DNS-01 challenge provider: %w", err)
354 }
355
356 if user.Registration == nil {
356 - reg, err := client.Registration.Register(registration.RegisterOptions{
357 + reg, regErr := client.Registration.Register(registration.RegisterOptions{
358 TermsOfServiceAgreed: true,
359 })
359 - if err != nil {
360 - return fmt.Errorf("register ACME account: %w", err)
360 + if regErr != nil {
361 + return fmt.Errorf("register ACME account: %w", regErr)
362 }
363 user.Registration = reg
363 - if err := saveRegistration(cfg.RegistrationFile, reg); err != nil {
364 - return fmt.Errorf("persist ACME registration: %w", err)
364 + if saveErr := saveRegistration(cfg.RegistrationFile, reg); saveErr != nil {
365 + return fmt.Errorf("persist ACME registration: %w", saveErr)
366 }
367 }
368
@@ -519,7 +520,6 @@ func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
520 if err := tmp.Close(); err != nil {
521 return err
522 }
522 - _ = os.Remove(path)
523 if err := os.Rename(tmpName, path); err != nil {
524 return err
525 }
portal/acme/renew.go
+12 -9
@@ -29,7 +29,7 @@ const (
29
30 // Start begins the certificate renewal loop. It checks periodically if the
31 // certificate needs renewal and renews it automatically.
32 -func (m *AcmeManager) Start(ctx context.Context) {
32 +func (m *Manager) Start(ctx context.Context) {
33 if m == nil || m.cfg.KeyDir == "" || !hasCloudflareToken(m.cfg.CloudflareToken) {
34 return
35 }
@@ -41,7 +41,7 @@ func (m *AcmeManager) Start(ctx context.Context) {
41 }
42
43 // Stop stops the renewal loop.
44 -func (m *AcmeManager) Stop() {
44 +func (m *Manager) Stop() {
45 if m == nil {
46 return
47 }
@@ -51,7 +51,7 @@ func (m *AcmeManager) Stop() {
51 m.waitGroup.Wait()
52 }
53
54 -func (m *AcmeManager) renewalLoop(ctx context.Context) {
54 +func (m *Manager) renewalLoop(ctx context.Context) {
55 defer m.waitGroup.Done()
56
57 ticker := time.NewTicker(RenewalCheckInterval)
@@ -77,7 +77,7 @@ func (m *AcmeManager) renewalLoop(ctx context.Context) {
77 }
78
79 // shouldRenew checks if the certificate needs renewal.
80 -func (m *AcmeManager) shouldRenew() bool {
80 +func (m *Manager) shouldRenew() bool {
81 m.mu.RLock()
82 defer m.mu.RUnlock()
83
@@ -147,7 +147,7 @@ func certNeedsRenewal(certFile string, domains []string) (bool, error) {
147 }
148
149 // renewCertificate renews the certificate via ACME.
150 -func (m *AcmeManager) renewCertificate(ctx context.Context) error {
150 +func (m *Manager) renewCertificate(ctx context.Context) error {
151 m.mu.Lock()
152 defer m.mu.Unlock()
153
@@ -196,7 +196,7 @@ func (m *AcmeManager) renewCertificate(ctx context.Context) error {
196 return nil
197 }
198
199 -func (m *AcmeManager) doRenew(cfg provisionConfig) error {
199 +func (m *Manager) doRenew(cfg provisionConfig) error {
200 accountKey, err := loadOrCreateAccountKey(cfg.AccountKeyFile)
201 if err != nil {
202 return fmt.Errorf("load ACME account key: %w", err)
@@ -227,7 +227,8 @@ func (m *AcmeManager) doRenew(cfg provisionConfig) error {
227 if err != nil {
228 return fmt.Errorf("create Cloudflare DNS provider: %w", err)
229 }
230 - if err := client.Challenge.SetDNS01Provider(provider); err != nil {
230 + err = client.Challenge.SetDNS01Provider(provider)
231 + if err != nil {
232 return fmt.Errorf("set DNS-01 challenge provider: %w", err)
233 }
234
@@ -242,11 +243,13 @@ func (m *AcmeManager) doRenew(cfg provisionConfig) error {
243 return fmt.Errorf("read private key for renewal: %w", err)
244 }
245
245 - renewed, err := client.Certificate.Renew(certificate.Resource{
246 + renewed, err := client.Certificate.RenewWithOptions(certificate.Resource{
247 Domain: cfg.Domains[0],
248 Certificate: certPEM,
249 PrivateKey: keyPEM,
249 - }, true, false, "")
250 + }, &certificate.RenewOptions{
251 + Bundle: true,
252 + })
253 if err != nil {
254 return fmt.Errorf("ACME renew: %w", err)
255 }
portal/keyless/client.go
+12 -11
@@ -5,6 +5,7 @@ import (
5 "crypto/tls"
6 "crypto/x509"
7 "encoding/pem"
8 + "errors"
9 "fmt"
10 "net"
11 "net/url"
@@ -20,10 +21,10 @@ import (
21 // It returns the TLS config and a close callback for signer resources.
22 func BuildClientTLSConfig(relayAddr, keylessServerName, domain string) (*tls.Config, func(), error) {
23 if keylessServerName == "" {
23 - return nil, nil, fmt.Errorf("keyless server name is required")
24 + return nil, nil, errors.New("keyless server name is required")
25 }
26 if domain == "" {
26 - return nil, nil, fmt.Errorf("tls domain is required")
27 + return nil, nil, errors.New("tls domain is required")
28 }
29 certPEM, rootCAPEM, err := ResolveMaterials(
30 context.Background(),
@@ -36,8 +37,8 @@ func BuildClientTLSConfig(relayAddr, keylessServerName, domain string) (*tls.Con
37 return nil, nil, fmt.Errorf("prepare keyless materials: %w", err)
38 }
39
39 - if err := VerifyCertificateHostname(certPEM, domain); err != nil {
40 - return nil, nil, fmt.Errorf("keyless certificate does not cover %s: %w", domain, err)
40 + if verifyErr := VerifyCertificateHostname(certPEM, domain); verifyErr != nil {
41 + return nil, nil, fmt.Errorf("keyless certificate does not cover %s: %w", domain, verifyErr)
42 }
43
44 remoteSigner, err := keylesstls.NewRemoteSigner(keylesstls.RemoteSignerConfig{
@@ -91,7 +92,7 @@ func ResolveMaterials(
92 certPEM = chainFromEndpoint
93 }
94 if len(certPEM) == 0 {
94 - return nil, nil, fmt.Errorf("keyless certificate chain is required")
95 + return nil, nil, errors.New("keyless certificate chain is required")
96 }
97
98 if len(rootCAPEM) == 0 && len(chainFromEndpoint) > 0 {
@@ -116,7 +117,7 @@ func VerifyCertificateHostname(certPEM []byte, hostname string) error {
117 // ParseCertificateChainPEM parses PEM cert chain and returns DER chain + leaf.
118 func ParseCertificateChainPEM(certPEM []byte) ([][]byte, *x509.Certificate, error) {
119 if len(certPEM) == 0 {
119 - return nil, nil, fmt.Errorf("certificate PEM is empty")
120 + return nil, nil, errors.New("certificate PEM is empty")
121 }
122
123 var chain [][]byte
@@ -132,7 +133,7 @@ func ParseCertificateChainPEM(certPEM []byte) ([][]byte, *x509.Certificate, erro
133 rest = next
134 }
135 if len(chain) == 0 {
135 - return nil, nil, fmt.Errorf("no certificate blocks found")
136 + return nil, nil, errors.New("no certificate blocks found")
137 }
138
139 leaf, err := x509.ParseCertificate(chain[0])
@@ -147,7 +148,7 @@ func ParseCertificateChainPEM(certPEM []byte) ([][]byte, *x509.Certificate, erro
148 func FetchEndpointCertificateChain(ctx context.Context, endpoint string, serverName string) ([]byte, error) {
149 raw := endpoint
150 if raw == "" {
150 - return nil, fmt.Errorf("endpoint is required")
151 + return nil, errors.New("endpoint is required")
152 }
153 if !strings.Contains(raw, "://") {
154 raw = "https://" + raw
@@ -158,12 +159,12 @@ func FetchEndpointCertificateChain(ctx context.Context, endpoint string, serverN
159 return nil, fmt.Errorf("parse endpoint URL: %w", err)
160 }
161 if u.Scheme == "http" {
161 - return nil, fmt.Errorf("http signer endpoint does not expose TLS certificate chain (use https endpoint)")
162 + return nil, errors.New("http signer endpoint does not expose TLS certificate chain (use https endpoint)")
163 }
164
165 host := u.Hostname()
166 if host == "" {
166 - return nil, fmt.Errorf("endpoint hostname is empty")
167 + return nil, errors.New("endpoint hostname is empty")
168 }
169 port := u.Port()
170 if port == "" {
@@ -190,7 +191,7 @@ func FetchEndpointCertificateChain(ctx context.Context, endpoint string, serverN
191
192 peerCerts := tlsConn.ConnectionState().PeerCertificates
193 if len(peerCerts) == 0 {
193 - return nil, fmt.Errorf("no peer certificates from signer endpoint")
194 + return nil, errors.New("no peer certificates from signer endpoint")
195 }
196
197 var chainPEM []byte
portal/lease.go
+21 -17
@@ -12,12 +12,12 @@ import (
12
13 // Lease represents a registered service.
14 type Lease struct {
15 + Expires time.Time `json:"expires"`
16 ID string `json:"id"`
17 Name string `json:"name"`
18 + ReverseToken string `json:"-"`
19 Metadata types.Metadata `json:"metadata"`
18 - Expires time.Time `json:"expires"`
20 TLS bool `json:"tls"`
20 - ReverseToken string `json:"-"` // shared secret for reverse connect authentication
21 }
22
23 // LeaseEntry represents a registered lease with expiration tracking.
@@ -30,17 +30,17 @@ type LeaseEntry struct {
30 }
31
32 type LeaseManager struct {
33 - leases map[string]*LeaseEntry // Key: lease ID
34 - leasesLock sync.RWMutex
35 - stopCh chan struct{}
36 - ttlInterval time.Duration
37 -
38 - // policy controls
33 + leases map[string]*LeaseEntry
34 + stopCh chan struct{}
35 bannedLeases map[string]struct{}
36 namePattern *regexp.Regexp
41 - minTTL time.Duration // 0 = no bound
42 - maxTTL time.Duration // 0 = no bound
37 onLeaseDeleted func(string)
38 + ttlInterval time.Duration
39 + minTTL time.Duration
40 + maxTTL time.Duration
41 + leasesLock sync.RWMutex
42 + startOnce sync.Once
43 + stopOnce sync.Once
44 }
45
46 func NewLeaseManager(ttlInterval time.Duration) *LeaseManager {
@@ -53,11 +53,15 @@ func NewLeaseManager(ttlInterval time.Duration) *LeaseManager {
53 }
54
55 func (lm *LeaseManager) Start() {
56 - go lm.ttlWorker()
56 + lm.startOnce.Do(func() {
57 + go lm.ttlWorker()
58 + })
59 }
60
61 func (lm *LeaseManager) Stop() {
60 - close(lm.stopCh)
62 + lm.stopOnce.Do(func() {
63 + close(lm.stopCh)
64 + })
65 }
66
67 func (lm *LeaseManager) ttlWorker() {
@@ -250,7 +254,7 @@ func (lm *LeaseManager) GetAllLeases() []*Lease {
254 return validLeases
255 }
256
253 -// GetAllLeaseEntries returns all lease entries from the lease manager
257 +// GetAllLeaseEntries returns all lease entries from the lease manager.
258 func (lm *LeaseManager) GetAllLeaseEntries() []*LeaseEntry {
259 lm.leasesLock.RLock()
260 defer lm.leasesLock.RUnlock()
@@ -267,7 +271,7 @@ func (lm *LeaseManager) GetAllLeaseEntries() []*LeaseEntry {
271 return entries
272 }
273
270 -// Lease policy configuration helpers
274 +// BanLease adds a lease ID to the denylist.
275 func (lm *LeaseManager) BanLease(leaseID string) {
276 lm.leasesLock.Lock()
277 lm.bannedLeases[leaseID] = struct{}{}
@@ -305,9 +309,9 @@ func (lm *LeaseManager) SetNamePattern(pattern string) error {
309 return nil
310 }
311
308 -func (lm *LeaseManager) SetTTLBounds(min, max time.Duration) {
312 +func (lm *LeaseManager) SetTTLBounds(minTTL, maxTTL time.Duration) {
313 lm.leasesLock.Lock()
310 - lm.minTTL = min
311 - lm.maxTTL = max
314 + lm.minTTL = minTTL
315 + lm.maxTTL = maxTTL
316 lm.leasesLock.Unlock()
317 }
portal/lease_test.go
+8
@@ -68,3 +68,11 @@ func TestLeaseManagerCleanupExpiredLeasesInvokesCallback(t *testing.T) {
68 t.Fatal("expected active-1 to remain")
69 }
70 }
71 +
72 +func TestLeaseManagerStopIsIdempotent(_ *testing.T) {
73 + lm := NewLeaseManager(10 * time.Millisecond)
74 +
75 + lm.Start()
76 + lm.Stop()
77 + lm.Stop()
78 +}
portal/relay.go
+94 -51
@@ -17,17 +17,14 @@ import (
17 )
18
19 type RelayServer struct {
20 - address []string
21 - BaseHost string
22 -
20 leaseManager *LeaseManager
21 reverseHub *ReverseHub
22 sniRouter *sni.Router
26 - acmeManager *acme.AcmeManager
23 + acmeManager *acme.Manager
24 keylessSigner *keyless.Signer
28 -
29 - stopch chan struct{}
30 - waitgroup sync.WaitGroup
25 + BaseHost string
26 + address []string
27 + stopOnce sync.Once
28 }
29
30 // NewRelayServer creates a new relay server.
@@ -45,7 +42,6 @@ func NewRelayServer(
42 leaseManager: NewLeaseManager(30 * time.Second),
43 reverseHub: NewReverseHub(),
44 sniRouter: sni.NewRouter(sniPort),
48 - stopch: make(chan struct{}),
45 }
46
47 keyFile := ""
@@ -86,21 +82,58 @@ func NewRelayServer(
82 Msg("[signer] keyless signer enabled at /v1/sign")
83 }
84
89 - server.leaseManager.SetOnLeaseDeleted(server.reverseHub.DropLease)
90 - server.reverseHub.SetAuthorizer(func(leaseID, token string) bool {
91 - entry, ok := server.leaseManager.GetLeaseByID(leaseID)
92 - if !ok || entry == nil || entry.Lease == nil {
93 - return false
94 - }
95 - expected := entry.Lease.ReverseToken
96 - if expected == "" {
97 - return false
98 - }
99 - return subtle.ConstantTimeCompare([]byte(expected), []byte(token)) == 1
100 - })
85 + server.bindLeaseLifecycleHooks()
86 + server.bindReverseConnectAuthorizer()
87 return server, nil
88 }
89
90 +func (g *RelayServer) bindLeaseLifecycleHooks() {
91 + if g == nil || g.leaseManager == nil {
92 + return
93 + }
94 + g.leaseManager.SetOnLeaseDeleted(g.handleLeaseDeleted)
95 +}
96 +
97 +func (g *RelayServer) handleLeaseDeleted(leaseID string) {
98 + leaseID = strings.TrimSpace(leaseID)
99 + if leaseID == "" {
100 + return
101 + }
102 +
103 + if g.reverseHub != nil {
104 + g.reverseHub.DropLease(leaseID)
105 + }
106 + if g.sniRouter != nil {
107 + g.sniRouter.UnregisterRouteByLeaseID(leaseID)
108 + }
109 +}
110 +
111 +func (g *RelayServer) bindReverseConnectAuthorizer() {
112 + if g == nil || g.reverseHub == nil {
113 + return
114 + }
115 + g.reverseHub.SetAuthorizer(g.authorizeReverseConnect)
116 +}
117 +
118 +func (g *RelayServer) authorizeReverseConnect(leaseID, token string) bool {
119 + if g == nil || g.leaseManager == nil {
120 + return false
121 + }
122 +
123 + entry, ok := g.leaseManager.GetLeaseByID(leaseID)
124 + if !ok || entry == nil || entry.Lease == nil {
125 + return false
126 + }
127 +
128 + expected := strings.TrimSpace(entry.Lease.ReverseToken)
129 + provided := strings.TrimSpace(token)
130 + if expected == "" || provided == "" {
131 + return false
132 + }
133 +
134 + return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1
135 +}
136 +
137 // GetLeaseManager returns the lease manager instance.
138 func (g *RelayServer) GetLeaseManager() *LeaseManager {
139 return g.leaseManager
@@ -122,7 +155,7 @@ func (g *RelayServer) GetKeylessSigner() *keyless.Signer {
155 }
156
157 // GetACMEManager returns relay ACME manager.
125 -func (g *RelayServer) GetACMEManager() *acme.AcmeManager {
158 +func (g *RelayServer) GetACMEManager() *acme.Manager {
159 return g.acmeManager
160 }
161
@@ -132,10 +165,12 @@ func (g *RelayServer) ConfigurePortalRootFallback(rootSNI, upstreamAddr string)
165 return
166 }
167
168 + rootSNI = strings.TrimSpace(rootSNI)
169 if rootSNI == "" {
170 return
171 }
172
173 + upstreamAddr = strings.TrimSpace(upstreamAddr)
174 if upstreamAddr == "" {
175 log.Warn().
176 Msg("[RelayServer] root-domain SNI fallback upstream is empty; fallback disabled")
@@ -143,31 +178,35 @@ func (g *RelayServer) ConfigurePortalRootFallback(rootSNI, upstreamAddr string)
178 }
179
180 g.sniRouter.SetNoRouteHandler(func(clientConn net.Conn, serverName string) bool {
146 - if !strings.EqualFold(serverName, rootSNI) {
147 - return false
148 - }
181 + return g.handleRootFallback(clientConn, serverName, rootSNI, upstreamAddr)
182 + })
183 +}
184
150 - dialer := &net.Dialer{Timeout: 5 * time.Second}
151 - upstreamConn, err := dialer.DialContext(context.Background(), "tcp", upstreamAddr)
152 - if err != nil {
153 - log.Warn().
154 - Err(err).
155 - Str("sni", serverName).
156 - Str("upstream", upstreamAddr).
157 - Msg("[SNI] failed to forward root domain to admin/API listener")
158 - if closeErr := clientConn.Close(); closeErr != nil {
159 - log.Debug().Err(closeErr).Str("sni", serverName).Msg("[SNI] failed to close client connection")
160 - }
161 - return true
162 - }
185 +func (g *RelayServer) handleRootFallback(clientConn net.Conn, serverName, rootSNI, upstreamAddr string) bool {
186 + if !strings.EqualFold(strings.TrimSpace(serverName), rootSNI) {
187 + return false
188 + }
189
164 - log.Debug().
190 + dialer := &net.Dialer{Timeout: 5 * time.Second}
191 + upstreamConn, err := dialer.DialContext(context.Background(), "tcp", upstreamAddr)
192 + if err != nil {
193 + log.Warn().
194 + Err(err).
195 Str("sni", serverName).
196 Str("upstream", upstreamAddr).
167 - Msg("[SNI] forwarding root domain to admin/API listener")
168 - sni.BridgeConnections(clientConn, upstreamConn)
197 + Msg("[SNI] failed to forward root domain to admin/API listener")
198 + if closeErr := clientConn.Close(); closeErr != nil {
199 + log.Debug().Err(closeErr).Str("sni", serverName).Msg("[SNI] failed to close client connection")
200 + }
201 return true
170 - })
202 + }
203 +
204 + log.Debug().
205 + Str("sni", serverName).
206 + Str("upstream", upstreamAddr).
207 + Msg("[SNI] forwarding root domain to admin/API listener")
208 + sni.BridgeConnections(clientConn, upstreamConn)
209 + return true
210 }
211
212 // Start starts the relay server.
@@ -191,14 +230,18 @@ func (g *RelayServer) Start() error {
230
231 // Stop stops the relay server.
232 func (g *RelayServer) Stop() {
194 - close(g.stopch)
195 - g.leaseManager.Stop()
196 - if err := g.sniRouter.Stop(); err != nil {
197 - log.Warn().Err(err).Msg("[RelayServer] Failed to stop SNI router")
198 - }
199 - if g.acmeManager != nil {
200 - g.acmeManager.Stop()
201 - }
202 - g.waitgroup.Wait()
203 - log.Info().Msg("[RelayServer] Stopped")
233 + g.stopOnce.Do(func() {
234 + if g.leaseManager != nil {
235 + g.leaseManager.Stop()
236 + }
237 + if g.sniRouter != nil {
238 + if err := g.sniRouter.Stop(); err != nil {
239 + log.Warn().Err(err).Msg("[RelayServer] Failed to stop SNI router")
240 + }
241 + }
242 + if g.acmeManager != nil {
243 + g.acmeManager.Stop()
244 + }
245 + log.Info().Msg("[RelayServer] Stopped")
246 + })
247 }
portal/relay_test.go new
+126
@@ -0,0 +1,126 @@
1 +package portal
2 +
3 +import (
4 + "context"
5 + "net"
6 + "strings"
7 + "testing"
8 + "time"
9 +)
10 +
11 +func TestRelayServerReverseHubAuthorizerTrimsToken(t *testing.T) {
12 + serv, err := NewRelayServer(context.Background(), nil, ":0", "example.com", "", "")
13 + if err != nil {
14 + t.Fatalf("create relay server: %v", err)
15 + }
16 +
17 + lease := &Lease{
18 + ID: "lease-authorizer-trim",
19 + Name: "tenant",
20 + TLS: true,
21 + ReverseToken: " reverse-token ",
22 + Expires: time.Now().Add(time.Minute),
23 + }
24 + if ok := serv.GetLeaseManager().UpdateLease(lease); !ok {
25 + t.Fatal("failed to register lease in lease manager")
26 + }
27 +
28 + hub := serv.GetReverseHub()
29 + if !hub.isAuthorized(lease.ID, "reverse-token") {
30 + t.Fatal("expected authorizer to accept trimmed reverse token")
31 + }
32 + if !hub.isAuthorized(lease.ID, " reverse-token ") {
33 + t.Fatal("expected authorizer to accept token with surrounding whitespace")
34 + }
35 + if hub.isAuthorized(lease.ID, "wrong-token") {
36 + t.Fatal("expected authorizer to reject wrong token")
37 + }
38 +}
39 +
40 +func TestRelayServerHandleLeaseDeletedDropsRouteAndPool(t *testing.T) {
41 + serv, err := NewRelayServer(context.Background(), nil, ":0", "example.com", "", "")
42 + if err != nil {
43 + t.Fatalf("create relay server: %v", err)
44 + }
45 +
46 + const (
47 + leaseID = "lease-delete-hook"
48 + leaseSNI = "tenant.example.com"
49 + )
50 +
51 + if err := serv.GetSNIRouter().RegisterRoute(leaseSNI, leaseID, "tenant"); err != nil {
52 + t.Fatalf("register route: %v", err)
53 + }
54 +
55 + local, peer := net.Pipe()
56 + defer func() { _ = peer.Close() }()
57 + conn := NewReverseConn(local)
58 + defer conn.Close()
59 +
60 + if ok := serv.GetReverseHub().Offer(leaseID, conn); !ok {
61 + t.Fatal("offer failed")
62 + }
63 +
64 + serv.handleLeaseDeleted(" " + leaseID + " ")
65 +
66 + if _, ok := serv.GetSNIRouter().GetRoute(leaseSNI); ok {
67 + t.Fatal("expected route to be removed when lease is deleted")
68 + }
69 +
70 + _, acquireErr := serv.GetReverseHub().AcquireForTLS(leaseID, 100*time.Millisecond)
71 + if acquireErr == nil {
72 + t.Fatal("expected reverse pool to be dropped when lease is deleted")
73 + }
74 + if !strings.Contains(acquireErr.Error(), "no tunnel available") {
75 + t.Fatalf("unexpected acquire error: %v", acquireErr)
76 + }
77 +}
78 +
79 +func TestRelayServerHandleRootFallbackRequiresRootHostMatch(t *testing.T) {
80 + serv, err := NewRelayServer(context.Background(), nil, ":0", "example.com", "", "")
81 + if err != nil {
82 + t.Fatalf("create relay server: %v", err)
83 + }
84 +
85 + client, peer := net.Pipe()
86 + defer func() {
87 + _ = client.Close()
88 + _ = peer.Close()
89 + }()
90 +
91 + handled := serv.handleRootFallback(client, "tenant.example.com", "portal.example.com", "127.0.0.1:4017")
92 + if handled {
93 + t.Fatal("expected non-root SNI to bypass fallback handler")
94 + }
95 +}
96 +
97 +func TestRelayServerHandleRootFallbackClosesClientOnDialFailure(t *testing.T) {
98 + serv, err := NewRelayServer(context.Background(), nil, ":0", "example.com", "", "")
99 + if err != nil {
100 + t.Fatalf("create relay server: %v", err)
101 + }
102 +
103 + client, peer := net.Pipe()
104 + defer func() { _ = peer.Close() }()
105 +
106 + handled := serv.handleRootFallback(client, "portal.example.com", "portal.example.com", "invalid-upstream")
107 + if !handled {
108 + t.Fatal("expected root SNI to be handled by fallback path")
109 + }
110 +
111 + _ = peer.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
112 + var b [1]byte
113 + if _, err := peer.Read(b[:]); err == nil {
114 + t.Fatal("expected client connection to be closed after fallback dial failure")
115 + }
116 +}
117 +
118 +func TestRelayServerStopIsIdempotent(t *testing.T) {
119 + serv, err := NewRelayServer(context.Background(), nil, ":0", "example.com", "", "")
120 + if err != nil {
121 + t.Fatalf("create relay server: %v", err)
122 + }
123 +
124 + serv.Stop()
125 + serv.Stop()
126 +}
portal/reverse_hub.go
+77 -56
@@ -3,27 +3,20 @@ package portal
3 import (
4 "fmt"
5 "net"
6 - "net/http"
7 - "strings"
6 "sync"
7 "sync/atomic"
8 "time"
9
10 "github.com/rs/zerolog/log"
13 - "golang.org/x/net/websocket"
11 )
12
13 const (
17 - // ReverseKeepaliveMarker keeps idle reverse websocket connections alive
14 + // ReverseKeepaliveMarker keeps idle reverse connections alive
15 // before they are activated for a real client request.
16 ReverseKeepaliveMarker = byte(0x00)
17
21 - // HTTPStartMarker is sent by the relay to activate a reverse connection
22 - // for HTTP proxy mode.
23 - HTTPStartMarker = byte(0x01)
24 -
18 // TLSStartMarker is sent by the relay to activate a reverse connection
26 - // for TLS passthrough mode.
19 + // for TLS reverse-connect mode.
20 TLSStartMarker = byte(0x02)
21
22 // QueueSize is the maximum number of pending reverse connections per lease.
@@ -32,20 +25,20 @@ const (
25 // DefaultAcquireTimeout is the default timeout for acquiring a reverse connection.
26 DefaultAcquireTimeout = 2 * time.Second
27
35 - // HTTPProxyWait is the timeout for HTTP proxy connections (shorter for better UX).
36 - HTTPProxyWait = 1500 * time.Millisecond
37 -
28 // TLSAcquireWait is the timeout for TLS passthrough connections.
29 TLSAcquireWait = 2 * time.Second
30
31 // AuthFailureDelay is the delay before closing unauthorized connections (rate limiting).
32 AuthFailureDelay = 2 * time.Second
33
34 + // controlWriteTimeout bounds control-marker writes on reverse connections.
35 + controlWriteTimeout = 2 * time.Second
36 +
37 // ReverseIdleKeepaliveInterval sends an idle keepalive byte to reduce
45 - // reverse websocket disconnections from intermediate idle timeouts.
38 + // reverse connection disconnections from intermediate idle timeouts.
39 ReverseIdleKeepaliveInterval = 25 * time.Second
40
48 - // ReverseConnectTokenHeader is the websocket handshake header carrying reverse auth token.
41 + // ReverseConnectTokenHeader carries reverse auth token on /sdk/connect requests.
42 ReverseConnectTokenHeader = "X-Portal-Reverse-Token"
43 )
44
@@ -117,10 +110,12 @@ func (c *ReverseConn) WriteControlByte(marker byte, timeout time.Duration) error
110 }
111
112 type ReverseHub struct {
120 - mu sync.RWMutex
121 - pools map[string]chan *ReverseConn
122 - dropped map[string]struct{}
123 - authorizer func(leaseID, token string) bool
113 + pools map[string]chan *ReverseConn
114 + dropped map[string]struct{}
115 + authorizer func(leaseID, token string) bool
116 + ipBanChecker func(ip string) bool
117 + onAccepted func(leaseID, ip string)
118 + mu sync.RWMutex
119 }
120
121 // NewReverseHub creates a new reverse connection hub.
@@ -161,6 +156,20 @@ func (h *ReverseHub) SetAuthorizer(authorizer func(leaseID, token string) bool)
156 h.authorizer = authorizer
157 }
158
159 +// SetIPBanChecker sets optional IP ban check for reverse connections.
160 +func (h *ReverseHub) SetIPBanChecker(checker func(ip string) bool) {
161 + h.mu.Lock()
162 + defer h.mu.Unlock()
163 + h.ipBanChecker = checker
164 +}
165 +
166 +// SetOnAccepted sets optional callback for authorized reverse connections.
167 +func (h *ReverseHub) SetOnAccepted(onAccepted func(leaseID, ip string)) {
168 + h.mu.Lock()
169 + defer h.mu.Unlock()
170 + h.onAccepted = onAccepted
171 +}
172 +
173 func (h *ReverseHub) isAuthorized(leaseID, token string) bool {
174 h.mu.RLock()
175 authorizer := h.authorizer
@@ -171,6 +180,26 @@ func (h *ReverseHub) isAuthorized(leaseID, token string) bool {
180 return authorizer(leaseID, token)
181 }
182
183 +func (h *ReverseHub) isIPBanned(ip string) bool {
184 + h.mu.RLock()
185 + checker := h.ipBanChecker
186 + h.mu.RUnlock()
187 + if checker == nil || ip == "" {
188 + return false
189 + }
190 + return checker(ip)
191 +}
192 +
193 +func (h *ReverseHub) notifyAccepted(leaseID, ip string) {
194 + h.mu.RLock()
195 + onAccepted := h.onAccepted
196 + h.mu.RUnlock()
197 + if onAccepted == nil {
198 + return
199 + }
200 + onAccepted(leaseID, ip)
201 +}
202 +
203 func (h *ReverseHub) Offer(leaseID string, conn *ReverseConn) bool {
204 pool := h.getOrCreatePool(leaseID)
205 if pool == nil {
@@ -198,16 +227,6 @@ func (h *ReverseHub) Offer(leaseID string, conn *ReverseConn) bool {
227 }
228
229 func (h *ReverseHub) AcquireForTLS(leaseID string, timeout time.Duration) (*ReverseConn, error) {
201 - return h.acquireWithStartMarker(leaseID, timeout, TLSStartMarker, "TLS")
202 -}
203 -
204 -// AcquireForHTTP retrieves a connection for HTTP proxy mode.
205 -// A mode-specific start marker is sent before returning the connection.
206 -func (h *ReverseHub) AcquireForHTTP(leaseID string, timeout time.Duration) (*ReverseConn, error) {
207 - return h.acquireWithStartMarker(leaseID, timeout, HTTPStartMarker, "HTTP")
208 -}
209 -
210 -func (h *ReverseHub) acquireWithStartMarker(leaseID string, timeout time.Duration, marker byte, mode string) (*ReverseConn, error) {
230 pool, ok := h.getPool(leaseID)
231 if !ok {
232 return nil, fmt.Errorf("no tunnel available for lease %s", leaseID)
@@ -241,7 +260,7 @@ func (h *ReverseHub) acquireWithStartMarker(leaseID string, timeout time.Duratio
260 }
261 // Stop idle keepalive and signal tunnel worker to release this connection.
262 conn.Activate()
244 - err := conn.WriteControlByte(marker, 2*time.Second)
263 + err := conn.WriteControlByte(TLSStartMarker, controlWriteTimeout)
264 if err == nil {
265 return conn, nil
266 }
@@ -249,8 +268,7 @@ func (h *ReverseHub) acquireWithStartMarker(leaseID string, timeout time.Duratio
268 log.Warn().
269 Err(err).
270 Str("lease_id", leaseID).
252 - Str("mode", mode).
253 - Msg("[ReverseHub] Failed to send start marker; retrying with new connection")
271 + Msg("[ReverseHub] Failed to send TLS start marker; retrying with new connection")
272 conn.Close()
273 continue
274 case <-timer.C:
@@ -293,52 +311,55 @@ func (h *ReverseHub) ClearDropped(leaseID string) {
311 h.mu.Unlock()
312 }
313
296 -func (h *ReverseHub) HandleConnect(ws *websocket.Conn) {
297 - if ws == nil {
314 +func (h *ReverseHub) HandleConnect(conn net.Conn, leaseID, token, remoteIP string) {
315 + if conn == nil {
316 return
317 }
300 - ws.PayloadType = websocket.BinaryFrame
301 -
302 - leaseID, token := parseReverseConnectCredentials(ws.Request())
318
319 if leaseID == "" {
320 log.Warn().Msg("[ReverseHub] Missing lease_id on reverse connect")
306 - time.Sleep(AuthFailureDelay)
307 - if err := ws.Close(); err != nil {
308 - log.Debug().Err(err).Msg("[ReverseHub] failed to close unauthorized websocket")
309 - }
321 + h.rejectConn(conn, "[ReverseHub] failed to close unauthorized reverse connection")
322 + return
323 + }
324 +
325 + if h.isIPBanned(remoteIP) {
326 + log.Warn().
327 + Str("lease_id", leaseID).
328 + Str("ip", remoteIP).
329 + Msg("[ReverseHub] IP banned for reverse connect")
330 + h.rejectConn(conn, "[ReverseHub] failed to close banned reverse connection")
331 return
332 }
333
334 if !h.isAuthorized(leaseID, token) {
335 log.Warn().Str("lease_id", leaseID).Msg("[ReverseHub] Unauthorized reverse connect")
315 - time.Sleep(AuthFailureDelay)
316 - if err := ws.Close(); err != nil {
317 - log.Debug().Err(err).Msg("[ReverseHub] failed to close unauthorized websocket")
318 - }
336 + h.rejectConn(conn, "[ReverseHub] failed to close unauthorized reverse connection")
337 return
338 }
339
322 - conn := NewReverseConn(ws)
323 - if !h.Offer(leaseID, conn) {
340 + h.notifyAccepted(leaseID, remoteIP)
341 + reverseConn := NewReverseConn(conn)
342 + if !h.Offer(leaseID, reverseConn) {
343 log.Warn().Str("lease_id", leaseID).Msg("[ReverseHub] Connection pool full for lease")
325 - conn.Close()
344 + reverseConn.Close()
345 return
346 }
347
329 - h.keepAliveWhileIdle(conn, leaseID)
348 + h.keepAliveWhileIdle(reverseConn, leaseID)
349
350 // Wait until the connection is used and closed
332 - conn.Wait()
351 + reverseConn.Wait()
352 +}
353 +
354 +func (h *ReverseHub) rejectConn(conn net.Conn, debugCloseMessage string) {
355 + time.Sleep(AuthFailureDelay)
356 + h.closeConn(conn, debugCloseMessage)
357 }
358
335 -func parseReverseConnectCredentials(req *http.Request) (leaseID, token string) {
336 - if req == nil || req.URL == nil {
337 - return "", ""
359 +func (h *ReverseHub) closeConn(conn net.Conn, debugCloseMessage string) {
360 + if err := conn.Close(); err != nil {
361 + log.Debug().Err(err).Msg(debugCloseMessage)
362 }
339 - leaseID = strings.TrimSpace(req.URL.Query().Get("lease_id"))
340 - token = strings.TrimSpace(req.Header.Get(ReverseConnectTokenHeader))
341 - return leaseID, token
363 }
364
365 func (h *ReverseHub) keepAliveWhileIdle(conn *ReverseConn, leaseID string) {
@@ -352,7 +373,7 @@ func (h *ReverseHub) keepAliveWhileIdle(conn *ReverseConn, leaseID string) {
373 case <-conn.active:
374 return
375 case <-ticker.C:
355 - if err := conn.WriteControlByte(ReverseKeepaliveMarker, 2*time.Second); err != nil {
376 + if err := conn.WriteControlByte(ReverseKeepaliveMarker, controlWriteTimeout); err != nil {
377 log.Debug().
378 Err(err).
379 Str("lease_id", leaseID).
portal/reverse_hub_test.go
+217 -22
@@ -3,7 +3,7 @@ package portal
3 import (
4 "io"
5 "net"
6 - "net/http/httptest"
6 + "strings"
7 "testing"
8 "time"
9 )
@@ -74,7 +74,7 @@ func TestAcquireForTLSSendsStartMarker(t *testing.T) {
74 }
75 }
76
77 -func TestAcquireForHTTPSendsStartMarker(t *testing.T) {
77 +func TestAcquireForTLSPollLoopSendsStartMarker(t *testing.T) {
78 hub := NewReverseHub()
79 leaseID := "lease-http-marker"
80
@@ -101,19 +101,30 @@ func TestAcquireForHTTPSendsStartMarker(t *testing.T) {
101 markerRead <- b[0]
102 }()
103
104 - got, err := hub.AcquireForHTTP(leaseID, 500*time.Millisecond)
105 - if err != nil {
106 - t.Fatalf("AcquireForHTTP failed: %v", err)
104 + var (
105 + got *ReverseConn
106 + err error
107 + )
108 + deadline := time.Now().Add(500 * time.Millisecond)
109 + for {
110 + got, err = hub.AcquireForTLS(leaseID, 25*time.Millisecond)
111 + if err == nil {
112 + break
113 + }
114 + if time.Now().After(deadline) {
115 + t.Fatalf("AcquireForTLS failed: %v", err)
116 + }
117 + time.Sleep(10 * time.Millisecond)
118 }
119 if got != conn {
109 - t.Fatal("AcquireForHTTP returned unexpected connection")
120 + t.Fatal("AcquireForTLS returned unexpected connection")
121 }
122
123 select {
124 case err := <-readErr:
125 t.Fatalf("failed to read marker: %v", err)
126 case b := <-markerRead:
116 - if b != HTTPStartMarker {
127 + if b != TLSStartMarker {
128 t.Fatalf("unexpected marker: %d", b)
129 }
130 case <-time.After(500 * time.Millisecond):
@@ -121,27 +132,211 @@ func TestAcquireForHTTPSendsStartMarker(t *testing.T) {
132 }
133 }
134
124 -func TestParseReverseConnectCredentials_HeaderTokenPreferred(t *testing.T) {
125 - req := httptest.NewRequest("GET", "/sdk/connect?lease_id=lease-1&token=query-token", nil)
126 - req.Header.Set(ReverseConnectTokenHeader, "header-token")
135 +func TestHandleConnectOffersAuthorizedConn(t *testing.T) {
136 + hub := NewReverseHub()
137 + leaseID := "lease-connect"
138 + token := "reverse-token"
139 + accepted := make(chan struct{}, 1)
140 + hub.SetAuthorizer(func(gotLeaseID, gotToken string) bool {
141 + return gotLeaseID == leaseID && gotToken == token
142 + })
143 + hub.SetOnAccepted(func(gotLeaseID, _ string) {
144 + if gotLeaseID != leaseID {
145 + return
146 + }
147 + select {
148 + case accepted <- struct{}{}:
149 + default:
150 + }
151 + })
152 +
153 + local, peer := net.Pipe()
154 + defer func() {
155 + _ = peer.Close()
156 + }()
157 +
158 + done := make(chan struct{})
159 + go func() {
160 + hub.HandleConnect(local, leaseID, token, "127.0.0.1")
161 + close(done)
162 + }()
163 +
164 + select {
165 + case <-accepted:
166 + case <-time.After(500 * time.Millisecond):
167 + t.Fatal("HandleConnect did not reach accepted state")
168 + }
169 +
170 + markerRead := make(chan byte, 1)
171 + readErr := make(chan error, 1)
172 + go func() {
173 + var b [1]byte
174 + _, err := io.ReadFull(peer, b[:])
175 + if err != nil {
176 + readErr <- err
177 + return
178 + }
179 + markerRead <- b[0]
180 + }()
181 +
182 + got, err := hub.AcquireForTLS(leaseID, 500*time.Millisecond)
183 + if err != nil {
184 + t.Fatalf("AcquireForTLS failed: %v", err)
185 + }
186 + if got == nil {
187 + t.Fatal("AcquireForTLS returned nil connection")
188 + }
189 +
190 + select {
191 + case err := <-readErr:
192 + t.Fatalf("failed to read start marker: %v", err)
193 + case b := <-markerRead:
194 + if b != TLSStartMarker {
195 + t.Fatalf("unexpected marker: %d", b)
196 + }
197 + case <-time.After(500 * time.Millisecond):
198 + t.Fatal("timed out waiting for start marker")
199 + }
200 +
201 + got.Close()
202 +
203 + select {
204 + case <-done:
205 + case <-time.After(500 * time.Millisecond):
206 + t.Fatal("HandleConnect did not return after connection close")
207 + }
208 +}
209 +
210 +func TestHandleConnectUnauthorizedHonorsAuthDelay(t *testing.T) {
211 + hub := NewReverseHub()
212 + leaseID := "lease-auth-delay"
213 + hub.SetAuthorizer(func(gotLeaseID, gotToken string) bool {
214 + return gotLeaseID == leaseID && gotToken == "expected-token"
215 + })
216 +
217 + local, peer := net.Pipe()
218 + defer func() {
219 + _ = peer.Close()
220 + }()
221 +
222 + done := make(chan struct{})
223 + go func() {
224 + hub.HandleConnect(local, leaseID, "wrong-token", "127.0.0.1")
225 + close(done)
226 + }()
227 +
228 + select {
229 + case <-done:
230 + t.Fatal("HandleConnect returned before auth delay elapsed")
231 + case <-time.After(AuthFailureDelay / 2):
232 + }
233 +
234 + select {
235 + case <-done:
236 + case <-time.After(AuthFailureDelay + 500*time.Millisecond):
237 + t.Fatal("HandleConnect did not return after auth delay")
238 + }
239 +
240 + _ = peer.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
241 + var b [1]byte
242 + if _, err := peer.Read(b[:]); err == nil {
243 + t.Fatal("expected connection to be closed after unauthorized connect")
244 + }
245 +}
246 +
247 +func TestHandleConnectBannedIPHonorsAuthDelay(t *testing.T) {
248 + hub := NewReverseHub()
249 + leaseID := "lease-banned-ip"
250 + token := "token-ok"
251 + blockedIP := "203.0.113.50"
252 +
253 + hub.SetAuthorizer(func(gotLeaseID, gotToken string) bool {
254 + return gotLeaseID == leaseID && gotToken == token
255 + })
256 + hub.SetIPBanChecker(func(ip string) bool {
257 + return ip == blockedIP
258 + })
259
128 - leaseID, token := parseReverseConnectCredentials(req)
129 - if leaseID != "lease-1" {
130 - t.Fatalf("unexpected lease_id: %q", leaseID)
260 + local, peer := net.Pipe()
261 + defer func() {
262 + _ = peer.Close()
263 + }()
264 +
265 + done := make(chan struct{})
266 + go func() {
267 + hub.HandleConnect(local, leaseID, token, blockedIP)
268 + close(done)
269 + }()
270 +
271 + select {
272 + case <-done:
273 + t.Fatal("HandleConnect returned before auth delay elapsed for banned IP")
274 + case <-time.After(AuthFailureDelay / 2):
275 + }
276 +
277 + select {
278 + case <-done:
279 + case <-time.After(AuthFailureDelay + 500*time.Millisecond):
280 + t.Fatal("HandleConnect did not return after banned-IP auth delay")
281 + }
282 +
283 + _, err := hub.AcquireForTLS(leaseID, 100*time.Millisecond)
284 + if err == nil {
285 + t.Fatal("expected no reverse tunnel for banned IP")
286 }
132 - if token != "header-token" {
133 - t.Fatalf("expected header token, got %q", token)
287 + if !strings.Contains(err.Error(), "no tunnel available") {
288 + t.Fatalf("unexpected acquire error after banned IP connect: %v", err)
289 + }
290 +
291 + _ = peer.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
292 + var b [1]byte
293 + if _, err := peer.Read(b[:]); err == nil {
294 + t.Fatal("expected banned reverse connection to be closed")
295 }
296 }
297
137 -func TestParseReverseConnectCredentials_QueryTokenIgnored(t *testing.T) {
138 - req := httptest.NewRequest("GET", "/sdk/connect?lease_id=lease-2&token=query-token", nil)
298 +func TestDropLeaseCleansPoolImmediately(t *testing.T) {
299 + hub := NewReverseHub()
300 + leaseID := "lease-drop"
301
140 - leaseID, token := parseReverseConnectCredentials(req)
141 - if leaseID != "lease-2" {
142 - t.Fatalf("unexpected lease_id: %q", leaseID)
302 + local, peer := net.Pipe()
303 + defer func() {
304 + _ = peer.Close()
305 + }()
306 + conn := NewReverseConn(local)
307 + defer conn.Close()
308 +
309 + if ok := hub.Offer(leaseID, conn); !ok {
310 + t.Fatal("offer failed")
311 + }
312 +
313 + start := time.Now()
314 + hub.DropLease(leaseID)
315 +
316 + _, err := hub.AcquireForTLS(leaseID, 2*time.Second)
317 + if err == nil {
318 + t.Fatal("expected acquire to fail after lease drop")
319 }
144 - if token != "" {
145 - t.Fatalf("expected empty token when header is missing, got %q", token)
320 + if !strings.Contains(err.Error(), "no tunnel available") {
321 + t.Fatalf("unexpected error after lease drop: %v", err)
322 + }
323 + if elapsed := time.Since(start); elapsed > 250*time.Millisecond {
324 + t.Fatalf("expected immediate cleanup after drop, acquire took %v", elapsed)
325 + }
326 +
327 + otherLocal, otherPeer := net.Pipe()
328 + defer func() {
329 + _ = otherPeer.Close()
330 + }()
331 + otherConn := NewReverseConn(otherLocal)
332 + defer otherConn.Close()
333 + if ok := hub.Offer(leaseID, otherConn); ok {
334 + t.Fatal("expected dropped lease to reject new offered connections")
335 + }
336 +
337 + _ = peer.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
338 + var b [1]byte
339 + if _, err := peer.Read(b[:]); err == nil {
340 + t.Fatal("expected dropped pooled connection to be closed")
341 }
342 }
portal/sni/parser.go
+6 -6
@@ -11,13 +11,13 @@ import (
11 )
12
13 var (
14 - // ErrInvalidTLSRecord is returned when the TLS record is malformed
14 + // ErrInvalidTLSRecord is returned when the TLS record is malformed.
15 ErrInvalidTLSRecord = errors.New("invalid TLS record")
16 - // ErrNotClientHello is returned when the record is not a ClientHello
16 + // ErrNotClientHello is returned when the record is not a ClientHello.
17 ErrNotClientHello = errors.New("not a ClientHello message")
18 - // ErrNoSNI is returned when the ClientHello doesn't contain SNI
18 + // ErrNoSNI is returned when the ClientHello doesn't contain SNI.
19 ErrNoSNI = errors.New("no SNI found in ClientHello")
20 - // ErrInvalidSNI is returned when the SNI hostname is invalid
20 + // ErrInvalidSNI is returned when the SNI hostname is invalid.
21 ErrInvalidSNI = errors.New("invalid SNI hostname")
22 )
23
@@ -227,14 +227,14 @@ func parseSNIExtension(data []byte) (string, error) {
227 // - Labels must be 1-63 characters
228 // - Labels can contain a-z, A-Z, 0-9, and hyphen
229 // - Labels cannot start or end with hyphen
230 -// - No null bytes or other control characters
230 +// - No null bytes or other control characters.
231 func isValidSNIHostname(hostname string) bool {
232 if len(hostname) == 0 || len(hostname) > 253 {
233 return false
234 }
235
236 // Check for null bytes and other control characters
237 - for i := 0; i < len(hostname); i++ {
237 + for i := range len(hostname) {
238 if hostname[i] < 0x20 || hostname[i] > 0x7E {
239 return false
240 }
portal/sni/router.go
+28 -32
@@ -15,9 +15,9 @@ import (
15 )
16
17 var (
18 - // ErrNoRoute is returned when no route is found for the SNI
18 + // ErrNoRoute is returned when no route is found for the SNI.
19 ErrNoRoute = errors.New("no route found for SNI")
20 - // ErrRouterClosed is returned when the router is closed
20 + // ErrRouterClosed is returned when the router is closed.
21 ErrRouterClosed = errors.New("router is closed")
22 )
23
@@ -27,32 +27,28 @@ const (
27 maxTLSRecordSize = 16*1024 + 2048
28 )
29
30 -// Route represents a registered route
30 +// Route represents a registered route.
31 type Route struct {
32 SNI string
33 LeaseID string
34 LeaseName string
35 }
36
37 -// Router handles SNI-based TCP routing
37 +// Router handles SNI-based TCP routing.
38 type Router struct {
39 - mu sync.RWMutex
40 - routes map[string]*Route // SNI -> Route
41 - leases map[string]*Route // LeaseID -> Route
42 - listener net.Listener
43 - addr string
44 -
45 - // Callback for new connections
39 + listener net.Listener
40 + routes map[string]*Route
41 + leases map[string]*Route
42 onConnection func(conn net.Conn, route *Route)
47 - // Callback for SNI connections that do not match any registered route.
48 - onNoRoute func(conn net.Conn, sni string) bool
49 -
50 - stopCh chan struct{}
51 - stopOnce sync.Once
52 - wg sync.WaitGroup
43 + onNoRoute func(conn net.Conn, sni string) bool
44 + stopCh chan struct{}
45 + addr string
46 + wg sync.WaitGroup
47 + mu sync.RWMutex
48 + stopOnce sync.Once
49 }
50
55 -// NewRouter creates a new SNI router
51 +// NewRouter creates a new SNI router.
52 func NewRouter(addr string) *Router {
53 return &Router{
54 addr: addr,
@@ -67,7 +63,7 @@ func (r *Router) GetAddr() string {
63 return r.addr
64 }
65
70 -// SetConnectionCallback sets the callback for new connections
66 +// SetConnectionCallback sets the callback for new connections.
67 func (r *Router) SetConnectionCallback(cb func(conn net.Conn, route *Route)) {
68 r.mu.Lock()
69 defer r.mu.Unlock()
@@ -82,7 +78,7 @@ func (r *Router) SetNoRouteHandler(cb func(conn net.Conn, sni string) bool) {
78 r.onNoRoute = cb
79 }
80
85 -// RegisterRoute registers a new route for an SNI
81 +// RegisterRoute registers a new route for an SNI.
82 func (r *Router) RegisterRoute(sni, leaseID, leaseName string) error {
83 r.mu.Lock()
84 defer r.mu.Unlock()
@@ -95,7 +91,7 @@ func (r *Router) RegisterRoute(sni, leaseID, leaseName string) error {
91
92 sni = strings.ToLower(strings.TrimSpace(sni))
93 if sni == "" {
98 - return fmt.Errorf("sni is required")
94 + return errors.New("sni is required")
95 }
96
97 // Remove previous SNI entry when a lease is re-registered with a new name.
@@ -130,7 +126,7 @@ func (r *Router) RegisterRoute(sni, leaseID, leaseName string) error {
126 return nil
127 }
128
133 -// UnregisterRoute removes a route for an SNI
129 +// UnregisterRoute removes a route for an SNI.
130 func (r *Router) UnregisterRoute(sni string) {
131 r.mu.Lock()
132 defer r.mu.Unlock()
@@ -147,7 +143,7 @@ func (r *Router) UnregisterRoute(sni string) {
143 }
144 }
145
150 -// UnregisterRouteByLeaseID removes a route by lease ID
146 +// UnregisterRouteByLeaseID removes a route by lease ID.
147 func (r *Router) UnregisterRouteByLeaseID(leaseID string) {
148 r.mu.Lock()
149 defer r.mu.Unlock()
@@ -162,7 +158,7 @@ func (r *Router) UnregisterRouteByLeaseID(leaseID string) {
158 }
159 }
160
165 -// GetRoute returns the route for an SNI
161 +// GetRoute returns the route for an SNI.
162 func (r *Router) GetRoute(sni string) (*Route, bool) {
163 r.mu.RLock()
164 defer r.mu.RUnlock()
@@ -189,7 +185,7 @@ func (r *Router) GetRoute(sni string) (*Route, bool) {
185 return nil, false
186 }
187
192 -// GetRouteByLeaseID returns the route for a lease ID
188 +// GetRouteByLeaseID returns the route for a lease ID.
189 func (r *Router) GetRouteByLeaseID(leaseID string) (*Route, bool) {
190 r.mu.RLock()
191 defer r.mu.RUnlock()
@@ -198,7 +194,7 @@ func (r *Router) GetRouteByLeaseID(leaseID string) (*Route, bool) {
194 return route, ok
195 }
196
201 -// GetAllRoutes returns all registered routes
197 +// GetAllRoutes returns all registered routes.
198 func (r *Router) GetAllRoutes() []*Route {
199 r.mu.RLock()
200 defer r.mu.RUnlock()
@@ -232,7 +228,7 @@ func (r *Router) Start() error {
228 return nil
229 }
230
235 -// Stop stops the SNI router
231 +// Stop stops the SNI router.
232 func (r *Router) Stop() error {
233 r.stopOnce.Do(func() {
234 close(r.stopCh)
@@ -251,7 +247,7 @@ func (r *Router) Stop() error {
247 return nil
248 }
249
254 -// Addr returns the router's listen address
250 +// Addr returns the router's listen address.
251 func (r *Router) Addr() net.Addr {
252 r.mu.RLock()
253 defer r.mu.RUnlock()
@@ -262,7 +258,7 @@ func (r *Router) Addr() net.Addr {
258 return nil
259 }
260
265 -// acceptLoop accepts incoming connections
261 +// acceptLoop accepts incoming connections.
262 func (r *Router) acceptLoop(listener net.Listener) {
263 defer r.wg.Done()
264
@@ -283,7 +279,7 @@ func (r *Router) acceptLoop(listener net.Listener) {
279 }
280 }
281
286 -// handleConnection handles a single connection
282 +// handleConnection handles a single connection.
283 func (r *Router) handleConnection(clientConn net.Conn) {
284 defer r.wg.Done()
285
@@ -361,7 +357,7 @@ func (r *Router) handleConnection(clientConn net.Conn) {
357 }
358 }
359
364 -// BridgeConnections bridges two connections
360 +// BridgeConnections bridges two connections.
361 func BridgeConnections(conn1, conn2 net.Conn) {
362 defer conn1.Close()
363 defer conn2.Close()
@@ -407,7 +403,7 @@ func ExtractSNIFromConnection(conn net.Conn, bufSize int) (string, net.Conn, err
403 return sni, wrappedConn, nil
404 }
405
410 -// peekedConn wraps a net.Conn to include peeked data
406 +// peekedConn wraps a net.Conn to include peeked data.
407 type peekedConn struct {
408 net.Conn
409 reader io.Reader
portal/sni/router_test.go
+6 -6
@@ -72,14 +72,14 @@ func TestRouter_GetRoute_Wildcard(t *testing.T) {
72
73 tests := []struct {
74 sni string
75 - wantOK bool
75 wantName string
76 + wantOK bool
77 }{
78 - {"foo.example.com", true, "*.example.com"}, // should match
79 - {"bar.example.com", true, "*.example.com"}, // should match
80 - {"example.com", false, ""}, // should NOT match (no subdomain)
81 - {"foo.bar.example.com", false, ""}, // should NOT match (TLS wildcard only matches one level)
82 - {"other.com", false, ""}, // should NOT match
78 + {"foo.example.com", "*.example.com", true}, // should match
79 + {"bar.example.com", "*.example.com", true}, // should match
80 + {"example.com", "", false}, // should NOT match (no subdomain)
81 + {"foo.bar.example.com", "", false}, // should NOT match (TLS wildcard only matches one level)
82 + {"other.com", "", false}, // should NOT match
83 }
84
85 for _, tt := range tests {
sdk/client.go
+36 -66
@@ -9,7 +9,6 @@ import (
9 "fmt"
10 "net"
11 "net/url"
12 - "regexp"
12 "sync"
13 "time"
14
@@ -27,7 +26,7 @@ var (
26 ErrListenerExists = errors.New("listener already exists for this credential")
27 ErrRelayExists = errors.New("relay already exists")
28 ErrRelayNotFound = errors.New("relay not found")
30 - ErrInvalidName = errors.New("lease name contains invalid characters (only alphanumeric, hyphen, underscore allowed)")
29 + ErrInvalidName = errors.New("lease name must be a DNS label (letters, digits, hyphen; no dots or underscores)")
30 ErrFailedToCreateClient = errors.New("failed to create relay client")
31 ErrInvalidMetadata = errors.New("invalid metadata")
32 )
@@ -35,8 +34,7 @@ var (
34 // ClientConfig configures the SDK client.
35 type ClientConfig struct {
36 BootstrapServers []string
38 - ReverseDialTimeout time.Duration // Reverse websocket dial timeout (default: 5 seconds)
39 - TLS bool
37 + ReverseDialTimeout time.Duration // Reverse connect dial timeout (default: 5 seconds)
38 }
39
40 // ClientOption configures ClientConfig.
@@ -56,17 +54,10 @@ func WithReverseDialTimeout(timeout time.Duration) ClientOption {
54 }
55 }
56
59 -// WithTLS enables keyless TLS mode using relay-derived defaults.
60 -func WithTLS() ClientOption {
61 - return func(c *ClientConfig) {
62 - c.TLS = true
63 - }
64 -}
65 -
57 // Client is a minimal client for lease registration with the relay.
58 type Client struct {
68 - mu sync.Mutex
59 config *ClientConfig
60 + mu sync.Mutex
61 }
62
63 // NewClient creates a new SDK client.
@@ -74,7 +65,6 @@ func NewClient(opt ...ClientOption) (*Client, error) {
65 config := &ClientConfig{
66 BootstrapServers: []string{},
67 ReverseDialTimeout: 5 * time.Second,
77 - TLS: false,
68 }
69
70 for _, o := range opt {
@@ -84,27 +74,17 @@ func NewClient(opt ...ClientOption) (*Client, error) {
74 return &Client{config: config}, nil
75 }
76
87 -var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
88 -
89 -// isURLSafeName checks if a name contains only URL-safe characters.
90 -func isURLSafeName(name string) bool {
91 - if name == "" {
92 - return true
93 - }
94 - return urlSafeNameRegex.MatchString(name)
95 -}
96 -
77 // Listen creates a listener and registers it with the relay.
98 -// In TLS passthrough mode, this registers the lease and returns a listener
99 -// that accepts connections from the relay.
78 +// In reverse-connect mode (TCP tunnel + TLS SNI routing), this registers the
79 +// lease and returns a listener that accepts relay-proxied connections.
80 func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Listener, error) {
81 c.mu.Lock()
82 defer c.mu.Unlock()
83
84 if name == "" {
105 - return nil, fmt.Errorf("name is required")
85 + return nil, errors.New("name is required")
86 }
107 - if !isURLSafeName(name) {
87 + if !types.IsValidLeaseName(name) {
88 return nil, ErrInvalidName
89 }
90
@@ -119,60 +99,54 @@ func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Liste
99 }
100
101 listeners := make([]net.Listener, 0, len(relayAddrs))
102 + closeActiveListeners := func() {
103 + for _, listener := range listeners {
104 + _ = listener.Close()
105 + }
106 + }
107 +
108 + runCloseFns := func(closeFns []func()) {
109 + for _, closeFn := range closeFns {
110 + if closeFn != nil {
111 + closeFn()
112 + }
113 + }
114 + }
115 +
116 for _, relayAddr := range relayAddrs {
117 tlsConfig, listenerCloseFns, tlsErr := c.buildTLSConfig(relayAddr, name)
118 if tlsErr != nil {
125 - for _, l := range listeners {
126 - _ = l.Close()
127 - }
119 + closeActiveListeners()
120 return nil, tlsErr
121 }
122
123 leaseCopy := *lease
124 listener, listenerErr := NewListener(relayAddr, &leaseCopy, tlsConfig, 0, c.config.ReverseDialTimeout, listenerCloseFns...)
125 if listenerErr != nil {
134 - for _, closeFn := range listenerCloseFns {
135 - if closeFn != nil {
136 - closeFn()
137 - }
138 - }
139 - for _, l := range listeners {
140 - _ = l.Close()
141 - }
126 + runCloseFns(listenerCloseFns)
127 + closeActiveListeners()
128 return nil, fmt.Errorf("create relay listener: %w", listenerErr)
129 }
130
131 if startErr := listener.Start(); startErr != nil {
132 _ = listener.Close()
147 - for _, l := range listeners {
148 - _ = l.Close()
149 - }
133 + closeActiveListeners()
134 return nil, fmt.Errorf("start relay listener: %w", startErr)
135 }
136
137 listeners = append(listeners, listener)
138 }
139
156 - var listener net.Listener
140 + listener := net.Listener(newMultiRelayListener(lease.ID, listeners))
141 if len(listeners) == 1 {
142 listener = listeners[0]
159 - } else {
160 - listener = newMultiRelayListener(lease.ID, listeners)
143 }
144
163 - if c.config.TLS {
164 - log.Info().
165 - Str("lease_id", lease.ID).
166 - Str("name", name).
167 - Bool("tls", true).
168 - Msg("[SDK] Lease registered with TLS")
169 - } else {
170 - log.Info().
171 - Str("lease_id", lease.ID).
172 - Str("name", name).
173 - Bool("tls", false).
174 - Msg("[SDK] Lease registered")
175 - }
145 + log.Info().
146 + Str("lease_id", lease.ID).
147 + Str("name", name).
148 + Bool("tls", true).
149 + Msg("[SDK] Lease registered with TLS")
150
151 return listener, nil
152 }
@@ -196,7 +170,7 @@ func (c *Client) newLease(name string, options ...types.MetadataOption) (*portal
170 lease := &portal.Lease{
171 ID: hex.EncodeToString(idBytes),
172 Name: name,
199 - TLS: c.config.TLS,
173 + TLS: true,
174 ReverseToken: hex.EncodeToString(tokenBytes),
175 Metadata: types.Metadata{
176 Description: metadata.Description,
@@ -211,10 +185,6 @@ func (c *Client) newLease(name string, options ...types.MetadataOption) (*portal
185 }
186
187 func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []func(), error) {
214 - if !c.config.TLS {
215 - return nil, nil, nil
216 - }
217 -
188 parsed, err := url.Parse(relayAddr)
189 if err != nil {
190 return nil, nil, fmt.Errorf("invalid relay address: %s, %w", relayAddr, err)
@@ -223,11 +193,11 @@ func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []fun
193 if keylessServerName == "" {
194 return nil, nil, fmt.Errorf("relay hostname is required: %s", relayAddr)
195 }
226 - baseDomain := types.ExtractBaseDomain(relayAddr)
227 - if baseDomain == "" {
228 - return nil, nil, fmt.Errorf("keyless base domain is required for relay %s", relayAddr)
196 + baseHost := types.PortalRootHost(relayAddr)
197 + if baseHost == "" {
198 + return nil, nil, fmt.Errorf("keyless base host is required for relay %s", relayAddr)
199 }
230 - domain := leaseName + "." + baseDomain
200 + domain := leaseName + "." + baseHost
201
202 tlsConfig, closeFn, err := keyless.BuildClientTLSConfig(relayAddr, keylessServerName, domain)
203 if err != nil {
sdk/listener.go
+291 -85
@@ -1,6 +1,7 @@
1 package sdk
2
3 import (
4 + "bufio"
5 "bytes"
6 "context"
7 "crypto/tls"
@@ -16,58 +17,56 @@ import (
17 "time"
18
19 "github.com/rs/zerolog/log"
19 - "golang.org/x/net/websocket"
20
21 "gosuda.org/portal/portal"
22 "gosuda.org/portal/types"
23 )
24
25 const (
26 - relayKeepaliveInterval = 10 * time.Second
27 - reverseReadTimeout = 1 * time.Second
28 - defaultReverseWorkers = 16
29 - defaultReverseDialTimeout = 5 * time.Second
26 + relayKeepaliveInterval = 10 * time.Second
27 + reverseReadTimeout = 1 * time.Second
28 + defaultReverseWorkers = 16
29 + defaultReverseDialTimeout = 5 * time.Second
30 + defaultTLSHandshakeTimeout = 10 * time.Second
31 )
32
33 // Listener is a net.Listener backed by relay tunnel registration.
34 // The relay connects to this listener after SNI routing resolves the lease.
35 type Listener struct {
35 - relayAddr string
36 - lease *portal.Lease
37 -
38 - httpClient *http.Client
39 -
40 - mu sync.RWMutex
41 - closed bool
36 + tlsConfig *tls.Config
37 + lease *portal.Lease
38 + httpClient *http.Client
39 + stopCh chan struct{}
40 acceptCh chan net.Conn
41 + relayAddr string
42 + closeFns []func()
43 + wg sync.WaitGroup
44 reverseWorkers int
45 reverseDialTimeout time.Duration
45 -
46 - // TLS configuration
47 - tlsConfig *tls.Config
48 - closeFns []func()
49 -
50 - stopCh chan struct{}
51 - closeOnce sync.Once
52 - wg sync.WaitGroup
46 + mu sync.RWMutex
47 + closeOnce sync.Once
48 + closed bool
49 }
50
51 var _ net.Listener = (*Listener)(nil)
52
53 // NewListener creates a relay-backed listener.
58 -// If tlsConfig is provided, the listener will perform TLS handshake on incoming connections.
54 +// If tlsConfig is provided, reverse workers complete TLS handshakes before enqueueing connections.
55 func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, reverseWorkers int, reverseDialTimeout time.Duration, closeFns ...func()) (*Listener, error) {
56 if lease == nil {
61 - return nil, fmt.Errorf("lease is required")
57 + return nil, errors.New("lease is required")
58 }
59 if lease.ID == "" {
64 - return nil, fmt.Errorf("lease ID is required")
60 + return nil, errors.New("lease ID is required")
61 }
62 if lease.Name == "" {
67 - return nil, fmt.Errorf("lease name is required")
63 + return nil, errors.New("lease name is required")
64 }
65 if lease.ReverseToken == "" {
70 - return nil, fmt.Errorf("lease reverse token is required")
66 + return nil, errors.New("lease reverse token is required")
67 + }
68 + if tlsConfig == nil {
69 + return nil, errors.New("tls config is required")
70 }
71
72 apiURL, err := types.NormalizeRelayAPIURL(relayAddr)
@@ -81,6 +80,7 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, r
80 if reverseDialTimeout <= 0 {
81 reverseDialTimeout = defaultReverseDialTimeout
82 }
83 + lease.TLS = true
84
85 return &Listener{
86 relayAddr: apiURL,
@@ -112,7 +112,7 @@ func (l *Listener) Start() error {
112
113 l.wg.Add(1)
114 go l.keepaliveLoop()
115 - for i := 0; i < l.reverseWorkers; i++ {
115 + for i := range l.reverseWorkers {
116 l.wg.Add(1)
117 go l.reverseAcceptWorker(i)
118 }
@@ -128,11 +128,10 @@ func (l *Listener) Start() error {
128 }
129
130 // Accept waits for the next connection from relay.
131 -// If TLS is enabled, it performs TLS handshake before returning the connection.
131 +// Reverse workers deliver ready connections to acceptCh.
132 func (l *Listener) Accept() (net.Conn, error) {
133 l.mu.RLock()
134 closed := l.closed
135 - tlsConfig := l.tlsConfig
135 l.mu.RUnlock()
136 if closed {
137 return nil, net.ErrClosed
@@ -147,19 +146,6 @@ func (l *Listener) Accept() (net.Conn, error) {
146 return nil, net.ErrClosed
147 }
148 }
150 -
151 - // If TLS is enabled, wrap the connection and perform handshake
152 - if tlsConfig != nil {
153 - tlsConn := tls.Server(conn, tlsConfig)
154 - if err := tlsConn.HandshakeContext(context.Background()); err != nil {
155 - if closeErr := conn.Close(); closeErr != nil {
156 - log.Debug().Err(closeErr).Msg("[SDK] failed to close TLS connection after handshake error")
157 - }
158 - return nil, fmt.Errorf("TLS handshake failed: %w", err)
159 - }
160 - return tlsConn, nil
161 - }
162 -
149 return conn, nil
150 }
151
@@ -251,12 +237,8 @@ func (l *Listener) reverseAcceptWorker(workerID int) {
237 continue
238 }
239
254 - expectedMarker := portal.HTTPStartMarker
255 - if l.tlsConfig != nil {
256 - expectedMarker = portal.TLSStartMarker
257 - }
258 -
259 - if err := l.waitForReverseStart(conn, expectedMarker); err != nil {
240 + err = l.waitForReverseStart(conn, portal.TLSStartMarker)
241 + if err != nil {
242 if closeErr := conn.Close(); closeErr != nil {
243 log.Debug().Err(closeErr).Msg("[SDK] failed to close reverse connection")
244 }
@@ -274,6 +256,22 @@ func (l *Listener) reverseAcceptWorker(workerID int) {
256 continue
257 }
258
259 + conn, err = l.prepareAcceptedConnection(conn)
260 + if err != nil {
261 + if errors.Is(err, net.ErrClosed) {
262 + return
263 + }
264 + if errors.Is(err, io.EOF) {
265 + continue
266 + }
267 + log.Debug().
268 + Err(err).
269 + Str("lease_id", l.lease.ID).
270 + Int("worker_id", workerID).
271 + Msg("[SDK] Reverse connection preparation failed")
272 + continue
273 + }
274 +
275 select {
276 case <-l.stopCh:
277 if closeErr := conn.Close(); closeErr != nil {
@@ -286,28 +284,225 @@ func (l *Listener) reverseAcceptWorker(workerID int) {
284 }
285
286 func (l *Listener) openReverseConnection() (net.Conn, error) {
287 + if l.isStopping() {
288 + return nil, net.ErrClosed
289 + }
290 connectURL, err := relayConnectURL(l.relayAddr, l.lease.ID, l.lease.ReverseToken)
291 if err != nil {
292 return nil, err
293 }
294 + u, err := url.Parse(connectURL)
295 + if err != nil {
296 + return nil, fmt.Errorf("parse reverse connect URL: %w", err)
297 + }
298 + address := u.Host
299 + if address == "" {
300 + return nil, errors.New("reverse connect URL missing host")
301 + }
302 + if u.Scheme != "https" {
303 + return nil, errors.New("reverse connect must use https scheme")
304 + }
305 + if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
306 + address = net.JoinHostPort(address, "443")
307 + }
308
294 - cfg, err := websocket.NewConfig(connectURL, l.relayAddr)
309 + timeout := l.reverseSetupTimeout()
310 + ctx, cancel := l.newStopAwareContext(timeout)
311 + defer cancel()
312 + dialer := &net.Dialer{
313 + Timeout: timeout,
314 + }
315 + rawConn, err := dialer.DialContext(ctx, "tcp", address)
316 + if err != nil {
317 + if l.isStopping() || errors.Is(err, context.Canceled) {
318 + return nil, net.ErrClosed
319 + }
320 + return nil, fmt.Errorf("dial reverse tcp: %w", err)
321 + }
322 + stopConnWatch := l.closeConnOnStop(rawConn)
323 + defer stopConnWatch()
324 +
325 + serverName := u.Hostname()
326 + if serverName == "" {
327 + _ = rawConn.Close()
328 + return nil, errors.New("reverse connect URL missing TLS server name")
329 + }
330 + tlsConn := tls.Client(rawConn, &tls.Config{
331 + MinVersion: tls.VersionTLS12,
332 + ServerName: serverName,
333 + })
334 + err = tlsConn.HandshakeContext(ctx)
335 + if err != nil {
336 + _ = rawConn.Close()
337 + if l.isStopping() || errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) {
338 + return nil, net.ErrClosed
339 + }
340 + return nil, fmt.Errorf("reverse TLS handshake: %w", err)
341 + }
342 + conn := net.Conn(tlsConn)
343 +
344 + err = l.writeReverseConnectRequest(conn, u)
345 + if err != nil {
346 + _ = conn.Close()
347 + return nil, err
348 + }
349 + reader, err := l.readReverseConnectResponse(conn)
350 if err != nil {
296 - return nil, fmt.Errorf("new reverse websocket config: %w", err)
351 + _ = conn.Close()
352 + return nil, err
353 }
298 - cfg.Header.Set(portal.ReverseConnectTokenHeader, l.lease.ReverseToken)
299 - cfg.Dialer = &net.Dialer{
300 - Timeout: l.reverseDialTimeout,
354 + if l.isStopping() {
355 + _ = conn.Close()
356 + return nil, net.ErrClosed
357 }
302 - ctx, cancel := context.WithTimeout(context.Background(), l.reverseDialTimeout)
358 +
359 + return &bufferedConn{Conn: conn, reader: reader}, nil
360 +}
361 +
362 +func (l *Listener) prepareAcceptedConnection(conn net.Conn) (net.Conn, error) {
363 + l.mu.RLock()
364 + tlsConfig := l.tlsConfig
365 + l.mu.RUnlock()
366 + if tlsConfig == nil {
367 + return conn, nil
368 + }
369 +
370 + tlsConn := tls.Server(conn, tlsConfig)
371 + handshakeCtx, cancel := l.newStopAwareContext(defaultTLSHandshakeTimeout)
372 defer cancel()
373 + if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
374 + _ = conn.Close()
375 + if l.isStopping() || errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) {
376 + return nil, net.ErrClosed
377 + }
378 + return nil, fmt.Errorf("TLS handshake failed: %w", err)
379 + }
380 + return tlsConn, nil
381 +}
382 +
383 +func buildReverseConnectRequest(u *url.URL, reverseToken string) (*http.Request, error) {
384 + if u == nil {
385 + return nil, errors.New("reverse connect URL is required")
386 + }
387 + if u.Host == "" {
388 + return nil, errors.New("reverse connect URL missing host")
389 + }
390 +
391 + token := strings.TrimSpace(reverseToken)
392 + if token == "" {
393 + return nil, errors.New("reverse token is required")
394 + }
395 +
396 + requestPath := u.EscapedPath()
397 + if requestPath == "" {
398 + requestPath = "/"
399 + }
400
305 - conn, err := cfg.DialContext(ctx)
401 + requestURL := &url.URL{
402 + Path: requestPath,
403 + RawPath: u.RawPath,
404 + RawQuery: u.RawQuery,
405 + }
406 + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, requestURL.String(), nil)
407 if err != nil {
307 - return nil, fmt.Errorf("dial reverse websocket: %w", err)
408 + return nil, fmt.Errorf("build reverse connect request: %w", err)
409 + }
410 + req.Host = u.Host
411 + req.Header.Set(portal.ReverseConnectTokenHeader, token)
412 + req.Header.Set("Connection", "keep-alive")
413 + return req, nil
414 +}
415 +
416 +func (l *Listener) writeReverseConnectRequest(conn net.Conn, u *url.URL) error {
417 + timeout := l.reverseSetupTimeout()
418 + if err := conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
419 + return fmt.Errorf("set reverse connect write deadline: %w", err)
420 + }
421 + defer func() {
422 + _ = conn.SetWriteDeadline(time.Time{})
423 + }()
424 +
425 + req, err := buildReverseConnectRequest(u, l.lease.ReverseToken)
426 + if err != nil {
427 + return err
428 + }
429 + if err := req.Write(conn); err != nil {
430 + if l.isStopping() || errors.Is(err, net.ErrClosed) {
431 + return net.ErrClosed
432 + }
433 + return fmt.Errorf("write reverse connect request: %w", err)
434 + }
435 + return nil
436 +}
437 +
438 +func (l *Listener) readReverseConnectResponse(conn net.Conn) (*bufio.Reader, error) {
439 + timeout := l.reverseSetupTimeout()
440 + if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
441 + return nil, fmt.Errorf("set reverse connect read deadline: %w", err)
442 + }
443 + defer func() {
444 + _ = conn.SetReadDeadline(time.Time{})
445 + }()
446 +
447 + reader := bufio.NewReader(conn)
448 + resp, err := http.ReadResponse(reader, &http.Request{Method: http.MethodGet})
449 + if err != nil {
450 + if l.isStopping() || errors.Is(err, net.ErrClosed) {
451 + return nil, net.ErrClosed
452 + }
453 + return nil, fmt.Errorf("read reverse connect response: %w", err)
454 + }
455 + defer resp.Body.Close()
456 + if resp.StatusCode != http.StatusOK {
457 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
458 + return nil, fmt.Errorf("reverse connect rejected: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
459 + }
460 + return reader, nil
461 +}
462 +
463 +func (l *Listener) reverseSetupTimeout() time.Duration {
464 + if l.reverseDialTimeout <= 0 {
465 + return defaultReverseDialTimeout
466 + }
467 + return l.reverseDialTimeout
468 +}
469 +
470 +func (l *Listener) newStopAwareContext(timeout time.Duration) (context.Context, context.CancelFunc) {
471 + if timeout <= 0 {
472 + timeout = defaultReverseDialTimeout
473 + }
474 + ctx, cancel := context.WithTimeout(context.Background(), timeout)
475 + go func() {
476 + select {
477 + case <-l.stopCh:
478 + cancel()
479 + case <-ctx.Done():
480 + }
481 + }()
482 + return ctx, cancel
483 +}
484 +
485 +func (l *Listener) closeConnOnStop(conn net.Conn) func() {
486 + done := make(chan struct{})
487 + go func() {
488 + select {
489 + case <-l.stopCh:
490 + _ = conn.Close()
491 + case <-done:
492 + }
493 + }()
494 + return func() {
495 + close(done)
496 + }
497 +}
498 +
499 +func (l *Listener) isStopping() bool {
500 + select {
501 + case <-l.stopCh:
502 + return true
503 + default:
504 + return false
505 }
309 - conn.PayloadType = websocket.BinaryFrame
310 - return conn, nil
506 }
507
508 func (l *Listener) waitForReverseStart(conn net.Conn, expectedMarker byte) error {
@@ -391,30 +586,39 @@ func (l *Listener) postJSON(path string, body any) error {
586 }
587 defer resp.Body.Close()
588
394 - if resp.StatusCode != http.StatusOK {
395 - data, _ := io.ReadAll(resp.Body)
396 - return fmt.Errorf("POST %s failed: status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(data)))
397 - }
398 -
589 data, _ := io.ReadAll(resp.Body)
590 if len(data) == 0 {
401 - return nil
591 + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
592 + return nil
593 + }
594 + return fmt.Errorf("POST %s failed: status=%d", path, resp.StatusCode)
595 }
596
404 - var apiResp types.APIResponse
405 - if err := json.Unmarshal(data, &apiResp); err != nil {
406 - // Non-JSON success payloads are treated as successful.
407 - return nil
408 - }
409 - if !apiResp.Success {
410 - msg := strings.TrimSpace(apiResp.Message)
597 + var envelope types.APIRawEnvelope
598 + if err := json.Unmarshal(data, &envelope); err == nil {
599 + if envelope.OK {
600 + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
601 + return nil
602 + }
603 + return fmt.Errorf("POST %s failed: status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(data)))
604 + }
605 +
606 + msg := ""
607 + if envelope.Error != nil {
608 + msg = strings.TrimSpace(envelope.Error.Message)
609 + }
610 if msg == "" {
611 msg = strings.TrimSpace(string(data))
612 }
613 return fmt.Errorf("POST %s rejected: %s", path, msg)
614 }
615
417 - return nil
616 + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
617 + // Non-envelope successful payloads are treated as successful.
618 + return nil
619 + }
620 +
621 + return fmt.Errorf("POST %s failed: status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(data)))
622 }
623
624 func isLeaseNotFoundError(err error) bool {
@@ -426,23 +630,18 @@ func isLeaseNotFoundError(err error) bool {
630
631 func relayConnectURL(relayAddr, leaseID, token string) (string, error) {
632 if strings.TrimSpace(leaseID) == "" {
429 - return "", fmt.Errorf("leaseID is required")
633 + return "", errors.New("leaseID is required")
634 }
635 if strings.TrimSpace(token) == "" {
432 - return "", fmt.Errorf("reverse token is required")
636 + return "", errors.New("reverse token is required")
637 }
638
639 u, err := url.Parse(relayAddr)
640 if err != nil {
641 return "", fmt.Errorf("parse relay URL: %w", err)
642 }
439 - switch u.Scheme {
440 - case "http":
441 - u.Scheme = "ws"
442 - case "https":
443 - u.Scheme = "wss"
444 - default:
445 - return "", fmt.Errorf("unsupported relay URL scheme: %q", u.Scheme)
643 + if u.Scheme != "https" {
644 + return "", fmt.Errorf("unsupported relay URL scheme: %q (use https)", u.Scheme)
645 }
646 u.Path = types.PathSDKConnect
647 q := u.Query()
@@ -452,15 +651,22 @@ func relayConnectURL(relayAddr, leaseID, token string) (string, error) {
651 return u.String(), nil
652 }
653
654 +type bufferedConn struct {
655 + net.Conn
656 + reader *bufio.Reader
657 +}
658 +
659 +func (c *bufferedConn) Read(p []byte) (int, error) {
660 + return c.reader.Read(p)
661 +}
662 +
663 type multiRelayListener struct {
664 + acceptCh chan net.Conn
665 + stopCh chan struct{}
666 leaseID string
667 listeners []net.Listener
458 -
459 - acceptCh chan net.Conn
460 - stopCh chan struct{}
461 -
462 - closeOnce sync.Once
668 wg sync.WaitGroup
669 + closeOnce sync.Once
670 }
671
672 func newMultiRelayListener(leaseID string, listeners []net.Listener) *multiRelayListener {
sdk/listener_test.go
+275 -17
@@ -2,7 +2,10 @@ package sdk
2
3 import (
4 "crypto/tls"
5 + "errors"
6 "net"
7 + "net/http"
8 + "net/url"
9 "strings"
10 "testing"
11 "time"
@@ -11,6 +14,8 @@ import (
14 "gosuda.org/portal/types"
15 )
16
17 +const testNonTLSStartMarker = byte(0x01)
18 +
19 func TestNormalizeRelayAPIURL(t *testing.T) {
20 t.Parallel()
21
@@ -55,18 +60,60 @@ func TestNormalizeRelayAPIURL(t *testing.T) {
60 func TestRelayConnectURL(t *testing.T) {
61 t.Parallel()
62
58 - got, err := relayConnectURL("http://localhost:4017", "lease-1", "token-1")
59 - if err != nil {
60 - t.Fatalf("unexpected error: %v", err)
61 - }
62 - if !strings.HasPrefix(got, "ws://localhost:4017/sdk/connect?") {
63 - t.Fatalf("unexpected URL prefix: %q", got)
64 - }
65 - if !strings.Contains(got, "lease_id=lease-1") {
66 - t.Fatalf("missing lease_id in URL: %q", got)
63 + tests := []struct {
64 + name string
65 + relayAddr string
66 + wantScheme string
67 + wantHost string
68 + wantErr bool
69 + }{
70 + {
71 + name: "http relay URL rejected",
72 + relayAddr: "http://localhost:4017",
73 + wantErr: true,
74 + },
75 + {
76 + name: "https relay URL",
77 + relayAddr: "https://relay.example.com",
78 + wantScheme: "https",
79 + wantHost: "relay.example.com",
80 + },
81 }
68 - if strings.Contains(got, "token=token-1") {
69 - t.Fatalf("token must not be present in URL query: %q", got)
82 + for _, tt := range tests {
83 + t.Run(tt.name, func(t *testing.T) {
84 + t.Parallel()
85 +
86 + got, err := relayConnectURL(tt.relayAddr, "lease-1", "token-1")
87 + if tt.wantErr {
88 + if err == nil {
89 + t.Fatalf("expected error for relay %q", tt.relayAddr)
90 + }
91 + return
92 + }
93 + if err != nil {
94 + t.Fatalf("unexpected error: %v", err)
95 + }
96 +
97 + parsed, err := url.Parse(got)
98 + if err != nil {
99 + t.Fatalf("parse URL: %v", err)
100 + }
101 + if parsed.Scheme != tt.wantScheme {
102 + t.Fatalf("unexpected URL scheme: got %q want %q", parsed.Scheme, tt.wantScheme)
103 + }
104 + if parsed.Host != tt.wantHost {
105 + t.Fatalf("unexpected URL host: got %q want %q", parsed.Host, tt.wantHost)
106 + }
107 + if parsed.Path != types.PathSDKConnect {
108 + t.Fatalf("unexpected URL path: got %q want %q", parsed.Path, types.PathSDKConnect)
109 + }
110 + if parsed.Query().Get("lease_id") != "lease-1" {
111 + t.Fatalf("missing lease_id in URL: %q", got)
112 + }
113 + if parsed.Query().Get("token") != "" {
114 + t.Fatalf("token must not be present in URL query: %q", got)
115 + }
116 + })
117 }
118
119 if _, err := relayConnectURL("http://localhost:4017", "", "token-1"); err == nil {
@@ -75,6 +122,192 @@ func TestRelayConnectURL(t *testing.T) {
122 if _, err := relayConnectURL("http://localhost:4017", "lease-1", ""); err == nil {
123 t.Fatal("expected error for empty token")
124 }
125 + if _, err := relayConnectURL("ws://localhost:4017", "lease-1", "token-1"); err == nil {
126 + t.Fatal("expected error for unsupported ws scheme")
127 + }
128 +}
129 +
130 +func TestBuildReverseConnectRequest(t *testing.T) {
131 + t.Parallel()
132 +
133 + connectURL, err := relayConnectURL("https://relay.example.com", "lease-1", "token-1")
134 + if err != nil {
135 + t.Fatalf("relayConnectURL returned error: %v", err)
136 + }
137 +
138 + u, err := url.Parse(connectURL)
139 + if err != nil {
140 + t.Fatalf("parse connect URL: %v", err)
141 + }
142 +
143 + req, err := buildReverseConnectRequest(u, " token-1 ")
144 + if err != nil {
145 + t.Fatalf("buildReverseConnectRequest returned error: %v", err)
146 + }
147 +
148 + if req.Method != http.MethodGet {
149 + t.Fatalf("unexpected request method: got %q want %q", req.Method, http.MethodGet)
150 + }
151 + if req.Host != "relay.example.com" {
152 + t.Fatalf("unexpected host header: got %q want %q", req.Host, "relay.example.com")
153 + }
154 + if req.URL.Path != types.PathSDKConnect {
155 + t.Fatalf("unexpected request path: got %q want %q", req.URL.Path, types.PathSDKConnect)
156 + }
157 + if req.URL.Query().Get("lease_id") != "lease-1" {
158 + t.Fatalf("unexpected lease_id query: %q", req.URL.Query().Get("lease_id"))
159 + }
160 + if req.URL.Query().Get("token") != "" {
161 + t.Fatalf("token must not be present in query: %q", req.URL.RawQuery)
162 + }
163 + if got := req.Header.Get(portal.ReverseConnectTokenHeader); got != "token-1" {
164 + t.Fatalf("unexpected reverse token header: got %q want %q", got, "token-1")
165 + }
166 +}
167 +
168 +func TestOpenReverseConnection_RejectsNonHTTPSRelay(t *testing.T) {
169 + t.Parallel()
170 +
171 + l := &Listener{
172 + relayAddr: "http://localhost:4017",
173 + lease: &portal.Lease{ID: "lease-1", ReverseToken: "token-1"},
174 + reverseDialTimeout: 2 * time.Second,
175 + stopCh: make(chan struct{}),
176 + }
177 +
178 + conn, err := l.openReverseConnection()
179 + if err != nil {
180 + if !strings.Contains(err.Error(), "https") {
181 + t.Fatalf("expected https scheme error, got: %v", err)
182 + }
183 + return
184 + }
185 + _ = conn.Close()
186 + t.Fatal("expected openReverseConnection to reject non-https relay")
187 +}
188 +
189 +func TestOpenReverseConnection_StopUnblocksTLSHandshake(t *testing.T) {
190 + t.Parallel()
191 +
192 + ln, err := net.Listen("tcp", "127.0.0.1:0")
193 + if err != nil {
194 + t.Fatalf("listen: %v", err)
195 + }
196 + defer ln.Close()
197 +
198 + accepted := make(chan struct{}, 1)
199 + go func() {
200 + conn, acceptErr := ln.Accept()
201 + if acceptErr != nil {
202 + return
203 + }
204 + defer conn.Close()
205 + accepted <- struct{}{}
206 + buf := make([]byte, 1)
207 + _, _ = conn.Read(buf)
208 + }()
209 +
210 + l := &Listener{
211 + relayAddr: "https://" + ln.Addr().String(),
212 + lease: &portal.Lease{ID: "lease-1", ReverseToken: "token-1"},
213 + reverseDialTimeout: 5 * time.Second,
214 + stopCh: make(chan struct{}),
215 + }
216 +
217 + done := make(chan error, 1)
218 + go func() {
219 + _, openErr := l.openReverseConnection()
220 + done <- openErr
221 + }()
222 +
223 + select {
224 + case <-accepted:
225 + case <-time.After(1 * time.Second):
226 + t.Fatal("timed out waiting for reverse dial accept")
227 + }
228 +
229 + close(l.stopCh)
230 +
231 + select {
232 + case openErr := <-done:
233 + if openErr == nil {
234 + t.Fatal("expected stop-aware openReverseConnection error")
235 + }
236 + if !errors.Is(openErr, net.ErrClosed) {
237 + t.Fatalf("expected net.ErrClosed, got: %v", openErr)
238 + }
239 + case <-time.After(1 * time.Second):
240 + t.Fatal("openReverseConnection did not unblock after stop")
241 + }
242 +}
243 +
244 +func TestWriteReverseConnectRequest_RespectsWriteDeadline(t *testing.T) {
245 + t.Parallel()
246 +
247 + local, peer := net.Pipe()
248 + defer local.Close()
249 + defer peer.Close()
250 +
251 + requestURL, err := url.Parse("https://relay.example.com" + types.PathSDKConnect + "?lease_id=lease-1")
252 + if err != nil {
253 + t.Fatalf("parse request URL: %v", err)
254 + }
255 +
256 + l := &Listener{
257 + lease: &portal.Lease{ReverseToken: "token-1"},
258 + reverseDialTimeout: 25 * time.Millisecond,
259 + stopCh: make(chan struct{}),
260 + }
261 +
262 + errCh := make(chan error, 1)
263 + go func() {
264 + errCh <- l.writeReverseConnectRequest(local, requestURL)
265 + }()
266 +
267 + select {
268 + case writeErr := <-errCh:
269 + if writeErr == nil {
270 + t.Fatal("expected write deadline error")
271 + }
272 + var netErr net.Error
273 + if !errors.As(writeErr, &netErr) || !netErr.Timeout() {
274 + t.Fatalf("expected timeout error, got: %v", writeErr)
275 + }
276 + case <-time.After(500 * time.Millisecond):
277 + t.Fatal("timed out waiting for write result")
278 + }
279 +}
280 +
281 +func TestReadReverseConnectResponse_RespectsReadDeadline(t *testing.T) {
282 + t.Parallel()
283 +
284 + local, peer := net.Pipe()
285 + defer local.Close()
286 + defer peer.Close()
287 +
288 + l := &Listener{
289 + reverseDialTimeout: 25 * time.Millisecond,
290 + stopCh: make(chan struct{}),
291 + }
292 +
293 + errCh := make(chan error, 1)
294 + go func() {
295 + _, readErr := l.readReverseConnectResponse(local)
296 + errCh <- readErr
297 + }()
298 +
299 + select {
300 + case readErr := <-errCh:
301 + if readErr == nil {
302 + t.Fatal("expected read deadline error")
303 + }
304 + var netErr net.Error
305 + if !errors.As(readErr, &netErr) || !netErr.Timeout() {
306 + t.Fatalf("expected timeout error, got: %v", readErr)
307 + }
308 + case <-time.After(500 * time.Millisecond):
309 + t.Fatal("timed out waiting for read result")
310 + }
311 }
312
313 func TestWaitForReverseStart_HTTPMode(t *testing.T) {
@@ -87,10 +320,10 @@ func TestWaitForReverseStart_HTTPMode(t *testing.T) {
320
321 done := make(chan error, 1)
322 go func() {
90 - done <- l.waitForReverseStart(local, portal.HTTPStartMarker)
323 + done <- l.waitForReverseStart(local, portal.TLSStartMarker)
324 }()
325
93 - _, err := peer.Write([]byte{portal.HTTPStartMarker})
326 + _, err := peer.Write([]byte{portal.TLSStartMarker})
327 if err != nil {
328 t.Fatalf("write marker: %v", err)
329 }
@@ -146,14 +379,14 @@ func TestWaitForReverseStart_IgnoresKeepaliveMarker(t *testing.T) {
379
380 done := make(chan error, 1)
381 go func() {
149 - done <- l.waitForReverseStart(local, portal.HTTPStartMarker)
382 + done <- l.waitForReverseStart(local, portal.TLSStartMarker)
383 }()
384
385 _, err := peer.Write([]byte{portal.ReverseKeepaliveMarker})
386 if err != nil {
387 t.Fatalf("write keepalive marker: %v", err)
388 }
156 - _, err = peer.Write([]byte{portal.HTTPStartMarker})
389 + _, err = peer.Write([]byte{portal.TLSStartMarker})
390 if err != nil {
391 t.Fatalf("write start marker: %v", err)
392 }
@@ -184,7 +417,7 @@ func TestWaitForReverseStart_TLSRejectsHTTPMarker(t *testing.T) {
417 done <- l.waitForReverseStart(local, portal.TLSStartMarker)
418 }()
419
187 - _, err := peer.Write([]byte{portal.HTTPStartMarker})
420 + _, err := peer.Write([]byte{testNonTLSStartMarker})
421 if err != nil {
422 t.Fatalf("write marker: %v", err)
423 }
@@ -209,7 +442,7 @@ func TestWaitForReverseStart_HTTPRejectsTLSMarker(t *testing.T) {
442
443 done := make(chan error, 1)
444 go func() {
212 - done <- l.waitForReverseStart(local, portal.HTTPStartMarker)
445 + done <- l.waitForReverseStart(local, testNonTLSStartMarker)
446 }()
447
448 _, err := peer.Write([]byte{portal.TLSStartMarker})
@@ -226,3 +459,28 @@ func TestWaitForReverseStart_HTTPRejectsTLSMarker(t *testing.T) {
459 t.Fatal("timed out waiting for marker")
460 }
461 }
462 +
463 +func TestWaitForReverseStart_StopCancelsWait(t *testing.T) {
464 + t.Parallel()
465 +
466 + l := &Listener{stopCh: make(chan struct{})}
467 + local, peer := net.Pipe()
468 + defer local.Close()
469 +
470 + done := make(chan error, 1)
471 + go func() {
472 + done <- l.waitForReverseStart(local, portal.TLSStartMarker)
473 + }()
474 +
475 + close(l.stopCh)
476 + _ = peer.Close()
477 +
478 + select {
479 + case err := <-done:
480 + if !errors.Is(err, net.ErrClosed) {
481 + t.Fatalf("expected net.ErrClosed when listener stops, got: %v", err)
482 + }
483 + case <-time.After(500 * time.Millisecond):
484 + t.Fatal("waitForReverseStart did not stop after cancellation")
485 + }
486 +}
types/api.go
+30 -7
@@ -1,5 +1,8 @@
1 +//revive:disable:var-naming
2 package types
3
4 +import "encoding/json"
5 +
6 // API path constants for Portal relay server.
7
8 // SDK API paths for lease registration and tunnel connections.
@@ -38,21 +41,41 @@ const (
41
42 // Client API types for /sdk/* endpoints.
43
44 +// APIError is the normalized API error payload.
45 +type APIError struct {
46 + Code string `json:"code"`
47 + Message string `json:"message"`
48 +}
49 +
50 +// APIEnvelope is the canonical response wrapper for relay APIs.
51 +type APIEnvelope struct {
52 + Data any `json:"data,omitempty"`
53 + Error *APIError `json:"error,omitempty"`
54 + OK bool `json:"ok"`
55 +}
56 +
57 +// APIRawEnvelope is the decoding-friendly envelope with raw data payload.
58 +type APIRawEnvelope struct {
59 + Error *APIError `json:"error,omitempty"`
60 + Data json.RawMessage `json:"data,omitempty"`
61 + OK bool `json:"ok"`
62 +}
63 +
64 // RegisterRequest is the lease registration request.
65 type RegisterRequest struct {
66 LeaseID string `json:"lease_id"`
67 Name string `json:"name"`
68 + ReverseToken string `json:"reverse_token"`
69 Metadata Metadata `json:"metadata"`
70 TLS bool `json:"tls"`
47 - ReverseToken string `json:"reverse_token"`
71 }
72
73 // RegisterResponse is the lease registration response.
74 type RegisterResponse struct {
52 - Success bool `json:"success"`
75 Message string `json:"message,omitempty"`
76 LeaseID string `json:"lease_id,omitempty"`
77 PublicURL string `json:"public_url,omitempty"`
78 + Success bool `json:"success"`
79 }
80
81 // UnregisterRequest is the lease unregistration request.
@@ -68,15 +91,15 @@ type RenewRequest struct {
91
92 // APIResponse is a generic Client API response.
93 type APIResponse struct {
71 - Success bool `json:"success"`
94 Message string `json:"message,omitempty"`
95 + Success bool `json:"success"`
96 }
97
98 // DomainResponse is the Client domain discovery response.
99 type DomainResponse struct {
77 - Success bool `json:"success"`
100 Message string `json:"message,omitempty"`
101 BaseDomain string `json:"base_domain,omitempty"`
102 + Success bool `json:"success"`
103 }
104
105 // Admin API types for /admin/* endpoints.
@@ -88,10 +111,10 @@ type AdminLoginRequest struct {
111
112 // AdminLoginResponse is the admin login response.
113 type AdminLoginResponse struct {
91 - Success bool `json:"success"`
114 Error string `json:"error,omitempty"`
93 - Locked bool `json:"locked,omitempty"`
115 RemainingSeconds int `json:"remaining_seconds,omitempty"`
116 + Success bool `json:"success"`
117 + Locked bool `json:"locked,omitempty"`
118 }
119
120 // AdminAuthStatusResponse is the admin auth status response.
@@ -124,6 +147,6 @@ type AdminBPSRequest struct {
147
148 // AdminStatsResponse is the admin stats response.
149 type AdminStatsResponse struct {
127 - LeasesCount int `json:"leases_count"`
150 Uptime string `json:"uptime"`
151 + LeasesCount int `json:"leases_count"`
152 }
types/metadata.go
+4 -2
@@ -1,22 +1,24 @@
1 // Package types defines all API request/response types and path constants
2 // for the Portal relay server and SDK.
3 +//
4 +//revive:disable-next-line:var-naming
5 package types
6
7 // Metadata holds service metadata for a lease.
8 type Metadata struct {
9 Description string `json:"description,omitempty"`
8 - Tags []string `json:"tags,omitempty"`
10 Thumbnail string `json:"thumbnail,omitempty"`
11 Owner string `json:"owner,omitempty"`
12 + Tags []string `json:"tags,omitempty"`
13 Hide bool `json:"hide,omitempty"`
14 }
15
16 // ParsedMetadata holds struct-parsed metadata for better access.
17 type ParsedMetadata struct {
18 Description string `json:"description"`
17 - Tags []string `json:"tags"`
19 Thumbnail string `json:"thumbnail"`
20 Owner string `json:"owner"`
21 + Tags []string `json:"tags"`
22 Hide bool `json:"hide"`
23 }
24
types/netutil.go
+117 -82
@@ -1,6 +1,7 @@
1 package types
2
3 import (
4 + "errors"
5 "fmt"
6 "net"
7 "net/url"
@@ -8,30 +9,6 @@ import (
9 "strings"
10 )
11
11 -// ExtractBaseDomain extracts the base domain (e.g., "example.com") from a URL.
12 -// Returns empty string if the URL is invalid or has fewer than 2 domain parts.
13 -func ExtractBaseDomain(rawURL string) string {
14 - trimmed := strings.TrimSpace(rawURL)
15 - if trimmed == "" {
16 - return ""
17 - }
18 - if !strings.Contains(trimmed, "://") {
19 - trimmed = "https://" + trimmed
20 - }
21 -
22 - u, err := url.Parse(trimmed)
23 - if err != nil || u.Hostname() == "" {
24 - return ""
25 - }
26 -
27 - host := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(u.Hostname())), "*.")
28 - parts := strings.Split(host, ".")
29 - if len(parts) < 2 {
30 - return ""
31 - }
32 - return parts[len(parts)-2] + "." + parts[len(parts)-1]
33 -}
34 -
12 // StripScheme removes http:// or https:// prefix from a string.
13 func StripScheme(s string) string {
14 s = strings.TrimSpace(s)
@@ -91,77 +68,119 @@ func IsSubdomain(domain, host string) bool {
68 return strings.HasSuffix(h, "."+d)
69 }
70
94 -// DefaultAppPattern builds a wildcard subdomain pattern from a base portal URL or host.
95 -func DefaultAppPattern(base string) string {
96 - base = strings.TrimSpace(strings.TrimSuffix(base, "/"))
97 - if base == "" {
98 - return "*.localhost:4017"
71 +func parsePortalAddress(raw, fallbackScheme string) (scheme, rootHost, hostPort string, ok bool) {
72 + normalized := strings.TrimSpace(raw)
73 + if normalized == "" {
74 + return "", "", "", false
75 }
100 - host := StripWildcard(StripScheme(base))
101 - if host == "" {
102 - return "*.localhost:4017"
76 +
77 + if fallbackScheme == "" {
78 + fallbackScheme = "https"
79 }
104 - if strings.HasPrefix(host, "*.") {
105 - return host
80 + fallbackScheme = strings.ToLower(strings.TrimSpace(fallbackScheme))
81 +
82 + if !strings.Contains(normalized, "://") {
83 + normalized = fallbackScheme + "://" + normalized
84 }
107 - return "*." + host
108 -}
85
110 -// ServicePublicURL returns a service URL derived from portalURL and service name.
111 -func ServicePublicURL(portalURL, serviceName string) string {
112 - serviceName = strings.TrimSpace(serviceName)
113 - if serviceName == "" {
114 - return ""
86 + parsed, err := url.Parse(normalized)
87 + if err != nil || parsed.Hostname() == "" {
88 + return "", "", "", false
89 }
90
117 - raw := strings.TrimSpace(portalURL)
118 - if raw == "" {
119 - return ""
91 + rootHost = strings.ToLower(strings.TrimSpace(parsed.Hostname()))
92 + rootHost = strings.TrimPrefix(strings.TrimSuffix(rootHost, "."), "*.")
93 + if rootHost == "" {
94 + return "", "", "", false
95 }
121 - if !strings.Contains(raw, "://") {
122 - raw = "http://" + raw
96 +
97 + scheme = strings.ToLower(strings.TrimSpace(parsed.Scheme))
98 + if scheme == "" {
99 + scheme = fallbackScheme
100 }
101
125 - u, err := url.Parse(raw)
126 - if err != nil || strings.TrimSpace(u.Host) == "" {
127 - return ""
102 + if port := strings.TrimSpace(parsed.Port()); port != "" {
103 + hostPort = net.JoinHostPort(rootHost, port)
104 + } else {
105 + hostPort = rootHost
106 }
107
130 - host := strings.TrimSpace(StripWildcard(u.Host))
131 - if host == "" {
132 - return ""
108 + return scheme, rootHost, strings.ToLower(strings.TrimSpace(hostPort)), true
109 +}
110 +
111 +// NormalizeServiceName canonicalizes and validates a service/lease name for DNS usage.
112 +// Valid names are a single DNS label: [a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?
113 +func NormalizeServiceName(name string) (string, bool) {
114 + normalized := strings.ToLower(strings.TrimSpace(name))
115 + normalized = strings.TrimPrefix(normalized, "*.")
116 + normalized = strings.TrimSuffix(normalized, ".")
117 +
118 + if normalized == "" || len(normalized) > 63 {
119 + return "", false
120 + }
121 + if strings.Contains(normalized, ".") || normalized[0] == '-' || normalized[len(normalized)-1] == '-' {
122 + return "", false
123 + }
124 + for _, ch := range normalized {
125 + switch {
126 + case ch >= 'a' && ch <= 'z':
127 + case ch >= '0' && ch <= '9':
128 + case ch == '-':
129 + default:
130 + return "", false
131 + }
132 }
133 + return normalized, true
134 +}
135
135 - scheme := strings.TrimSpace(u.Scheme)
136 - if scheme == "" {
137 - scheme = "http"
136 +// IsValidServiceName reports whether a service name can be used as a DNS label.
137 +func IsValidServiceName(name string) bool {
138 + _, ok := NormalizeServiceName(name)
139 + return ok
140 +}
141 +
142 +// DefaultAppPattern builds a wildcard subdomain pattern from a base portal URL or host.
143 +func DefaultAppPattern(base string) string {
144 + if strings.TrimSpace(base) == "" {
145 + return "*.localhost:4017"
146 }
147 + _, _, hostPort, ok := parsePortalAddress(base, "https")
148 + if !ok || hostPort == "" {
149 + return "*.localhost:4017"
150 + }
151 + return "*." + hostPort
152 +}
153
140 - return fmt.Sprintf("%s://%s.%s", scheme, serviceName, host)
154 +// ServicePublicURL returns a service URL derived from portalURL and service name.
155 +func ServicePublicURL(portalURL, serviceName string) string {
156 + normalizedName, ok := NormalizeServiceName(serviceName)
157 + if !ok {
158 + return ""
159 + }
160 +
161 + scheme, rootHost, _, ok := parsePortalAddress(portalURL, "http")
162 + if !ok || rootHost == "" {
163 + return ""
164 + }
165 + return fmt.Sprintf("%s://%s.%s", scheme, normalizedName, rootHost)
166 }
167
168 // PortalHostPort returns normalized host[:port] from a portal URL-like input.
169 func PortalHostPort(portalURL string) string {
145 - return strings.ToLower(strings.TrimSpace(
146 - StripWildcard(StripScheme(portalURL)),
147 - ))
170 + _, _, hostPort, ok := parsePortalAddress(portalURL, "https")
171 + if !ok {
172 + return ""
173 + }
174 + return hostPort
175 }
176
177 // PortalRootHost extracts the root hostname from a portal URL.
178 func PortalRootHost(portalURL string) string {
152 - raw := strings.TrimSpace(portalURL)
153 - if raw == "" {
154 - return ""
155 - }
156 - if !strings.Contains(raw, "://") {
157 - raw = "https://" + raw
158 - }
159 -
160 - parsed, err := url.Parse(raw)
161 - if err != nil || parsed.Hostname() == "" {
179 + _, rootHost, _, ok := parsePortalAddress(portalURL, "https")
180 + if !ok {
181 return ""
182 }
164 - return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(parsed.Hostname())), "*.")
183 + return rootHost
184 }
185
186 // DefaultBootstrapFrom derives a relay API bootstrap URL from a base portal URL or host.
@@ -213,21 +232,37 @@ func LeaseNameFromHost(host, appURL string) (string, bool) {
232 }
233
234 leaseName := strings.TrimSuffix(normalizedHost, suffix)
216 - if leaseName == "" || strings.Contains(leaseName, ".") {
235 + normalizedLeaseName, ok := NormalizeServiceName(leaseName)
236 + if !ok {
237 return "", false
238 }
239
220 - return leaseName, true
240 + return normalizedLeaseName, true
241 }
242
243 // BuildSNIName constructs the SNI hostname for a lease.
244 func BuildSNIName(leaseName, baseHost string) string {
225 - leaseName = strings.ToLower(strings.TrimSpace(leaseName))
226 - baseHost = strings.TrimSpace(baseHost)
227 - if leaseName == "" || baseHost == "" {
245 + normalizedLeaseName, ok := NormalizeServiceName(leaseName)
246 + if !ok {
247 return ""
248 }
230 - return leaseName + "." + baseHost
249 +
250 + normalizedBaseHost := PortalRootHost(baseHost)
251 + if normalizedBaseHost == "" {
252 + normalizedBaseHost = strings.ToLower(strings.TrimSpace(
253 + strings.TrimPrefix(strings.TrimSuffix(baseHost, "."), "*."),
254 + ))
255 + }
256 + if normalizedBaseHost == "" {
257 + return ""
258 + }
259 +
260 + return normalizedLeaseName + "." + normalizedBaseHost
261 +}
262 +
263 +// IsValidLeaseName reports whether a lease/service name is DNS-label-safe.
264 +func IsValidLeaseName(name string) bool {
265 + return IsValidServiceName(name)
266 }
267
268 // ParseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
@@ -270,7 +305,7 @@ func LoopbackForwardAddr(listenAddr string) string {
305 return ""
306 }
307
273 - port := ""
308 + var port string
309 switch {
310 case strings.HasPrefix(raw, ":"):
311 port = strings.TrimPrefix(raw, ":")
@@ -298,7 +333,7 @@ func LoopbackForwardAddr(listenAddr string) string {
333 func NormalizeTargetAddr(raw string) (string, error) {
334 raw = strings.TrimSpace(raw)
335 if raw == "" {
301 - return "", fmt.Errorf("empty host")
336 + return "", errors.New("empty host")
337 }
338
339 // Treat plain host[:port] as a dial target without URL parsing.
@@ -311,7 +346,7 @@ func NormalizeTargetAddr(raw string) (string, error) {
346 return "", fmt.Errorf("parse target URL: %w", err)
347 }
348 if strings.TrimSpace(u.Host) == "" {
314 - return "", fmt.Errorf("missing host in URL")
349 + return "", errors.New("missing host in URL")
350 }
351 return u.Host, nil
352 }
@@ -322,7 +357,7 @@ func NormalizeTargetAddr(raw string) (string, error) {
357 func NormalizeRelayAPIURL(raw string) (string, error) {
358 raw = strings.TrimSpace(raw)
359 if raw == "" {
325 - return "", fmt.Errorf("empty relay URL")
360 + return "", errors.New("empty relay URL")
361 }
362
363 // Accept host:port input.
@@ -368,7 +403,7 @@ func NormalizeRelayAPIURL(raw string) (string, error) {
403 // Returns an error if no valid URLs remain after normalization.
404 func NormalizeRelayAPIURLs(bootstrapServers []string) ([]string, error) {
405 if len(bootstrapServers) == 0 {
371 - return nil, fmt.Errorf("no available relay")
406 + return nil, errors.New("no available relay")
407 }
408
409 seen := make(map[string]struct{}, len(bootstrapServers))
@@ -386,7 +421,7 @@ func NormalizeRelayAPIURLs(bootstrapServers []string) ([]string, error) {
421 }
422
423 if len(out) == 0 {
389 - return nil, fmt.Errorf("no available relay")
424 + return nil, errors.New("no available relay")
425 }
426 return out, nil
427 }
types/netutil_test.go
+195 -1
@@ -1,6 +1,9 @@
1 package types
2
3 -import "testing"
3 +import (
4 + "strings"
5 + "testing"
6 +)
7
8 func TestNormalizeTargetAddr(t *testing.T) {
9 t.Parallel()
@@ -55,3 +58,194 @@ func TestNormalizeTargetAddr(t *testing.T) {
58 })
59 }
60 }
61 +
62 +func TestNormalizeServiceName(t *testing.T) {
63 + t.Parallel()
64 +
65 + tests := []struct {
66 + name string
67 + in string
68 + want string
69 + ok bool
70 + }{
71 + {name: "simple", in: "my-app", want: "my-app", ok: true},
72 + {name: "trim and lowercase", in: " My-App ", want: "my-app", ok: true},
73 + {name: "strip wildcard and trailing dot", in: "*.Service.", want: "service", ok: true},
74 + {name: "empty", in: " ", ok: false},
75 + {name: "contains dot", in: "api.v1", ok: false},
76 + {name: "contains underscore", in: "api_v1", ok: false},
77 + {name: "leading hyphen", in: "-api", ok: false},
78 + {name: "trailing hyphen", in: "api-", ok: false},
79 + {name: "too long", in: strings.Repeat("a", 64), ok: false},
80 + {name: "contains spaces", in: "api v1", ok: false},
81 + }
82 +
83 + for _, tt := range tests {
84 + t.Run(tt.name, func(t *testing.T) {
85 + t.Parallel()
86 +
87 + got, ok := NormalizeServiceName(tt.in)
88 + if ok != tt.ok {
89 + t.Fatalf("NormalizeServiceName(%q) ok=%v, want %v", tt.in, ok, tt.ok)
90 + }
91 + if got != tt.want {
92 + t.Fatalf("NormalizeServiceName(%q)=%q, want %q", tt.in, got, tt.want)
93 + }
94 + })
95 + }
96 +}
97 +
98 +func TestPortalHostDerivationConsistency(t *testing.T) {
99 + t.Parallel()
100 +
101 + portalURL := "https://relay.edge.example.com:8443/path"
102 +
103 + if got := PortalRootHost(portalURL); got != "relay.edge.example.com" {
104 + t.Fatalf("PortalRootHost(%q)=%q, want %q", portalURL, got, "relay.edge.example.com")
105 + }
106 + if got := PortalHostPort(portalURL); got != "relay.edge.example.com:8443" {
107 + t.Fatalf("PortalHostPort(%q)=%q, want %q", portalURL, got, "relay.edge.example.com:8443")
108 + }
109 + if got := DefaultAppPattern(portalURL); got != "*.relay.edge.example.com:8443" {
110 + t.Fatalf("DefaultAppPattern(%q)=%q, want %q", portalURL, got, "*.relay.edge.example.com:8443")
111 + }
112 + if got := BuildSNIName("Api-Gateway", portalURL); got != "api-gateway.relay.edge.example.com" {
113 + t.Fatalf("BuildSNIName()=%q, want %q", got, "api-gateway.relay.edge.example.com")
114 + }
115 +}
116 +
117 +func TestServicePublicURL(t *testing.T) {
118 + t.Parallel()
119 +
120 + tests := []struct {
121 + name string
122 + portalURL string
123 + service string
124 + want string
125 + }{
126 + {
127 + name: "preserve explicit scheme and root host",
128 + portalURL: "https://portal.example.com:4017/admin",
129 + service: "my-app",
130 + want: "https://my-app.portal.example.com",
131 + },
132 + {
133 + name: "default scheme for host-only portal URL",
134 + portalURL: "portal.example.com",
135 + service: "My-App",
136 + want: "http://my-app.portal.example.com",
137 + },
138 + {
139 + name: "invalid service returns empty",
140 + portalURL: "https://portal.example.com",
141 + service: "my_app",
142 + want: "",
143 + },
144 + {
145 + name: "invalid portal returns empty",
146 + portalURL: "",
147 + service: "my-app",
148 + want: "",
149 + },
150 + }
151 +
152 + for _, tt := range tests {
153 + t.Run(tt.name, func(t *testing.T) {
154 + t.Parallel()
155 +
156 + got := ServicePublicURL(tt.portalURL, tt.service)
157 + if got != tt.want {
158 + t.Fatalf("ServicePublicURL(%q, %q)=%q, want %q", tt.portalURL, tt.service, got, tt.want)
159 + }
160 + })
161 + }
162 +}
163 +
164 +func TestNonApexPortalRoundTrip(t *testing.T) {
165 + t.Parallel()
166 +
167 + const (
168 + portalURL = "https://portal.edge.example.com:8443"
169 + leaseNameInput = "My-App"
170 + expectedLeaseName = "my-app"
171 + expectedHost = "my-app.portal.edge.example.com"
172 + expectedPublicURL = "https://my-app.portal.edge.example.com"
173 + )
174 +
175 + if got := BuildSNIName(leaseNameInput, portalURL); got != expectedHost {
176 + t.Fatalf("BuildSNIName(%q, %q)=%q, want %q", leaseNameInput, portalURL, got, expectedHost)
177 + }
178 + if got := ServicePublicURL(portalURL, leaseNameInput); got != expectedPublicURL {
179 + t.Fatalf("ServicePublicURL(%q, %q)=%q, want %q", portalURL, leaseNameInput, got, expectedPublicURL)
180 + }
181 +
182 + appURLs := []string{
183 + portalURL,
184 + "portal.edge.example.com:8443",
185 + "*.portal.edge.example.com:8443",
186 + }
187 + for _, appURL := range appURLs {
188 + t.Run(appURL, func(t *testing.T) {
189 + t.Parallel()
190 +
191 + gotLeaseName, ok := LeaseNameFromHost(expectedHost, appURL)
192 + if !ok {
193 + t.Fatalf("LeaseNameFromHost(%q, %q) expected success", expectedHost, appURL)
194 + }
195 + if gotLeaseName != expectedLeaseName {
196 + t.Fatalf("LeaseNameFromHost(%q, %q)=%q, want %q", expectedHost, appURL, gotLeaseName, expectedLeaseName)
197 + }
198 + })
199 + }
200 +
201 + if gotLeaseName, ok := LeaseNameFromHost("portal.edge.example.com", portalURL); ok {
202 + t.Fatalf("LeaseNameFromHost() unexpected success for apex host, got %q", gotLeaseName)
203 + }
204 +}
205 +
206 +func TestIsValidLeaseNameUsesServiceValidation(t *testing.T) {
207 + t.Parallel()
208 +
209 + if !IsValidLeaseName("my-app") {
210 + t.Fatalf("expected valid lease name")
211 + }
212 + if IsValidLeaseName("my_app") {
213 + t.Fatalf("expected underscore to be invalid for DNS-safe lease names")
214 + }
215 +}
216 +
217 +func TestParsePortalAddressPreservesFullRootHost(t *testing.T) {
218 + t.Parallel()
219 +
220 + scheme, rootHost, hostPort, ok := parsePortalAddress("https://portal.edge.example.com:8443/path", "http")
221 + if !ok {
222 + t.Fatalf("expected parsePortalAddress success")
223 + }
224 + if scheme != "https" {
225 + t.Fatalf("scheme=%q, want https", scheme)
226 + }
227 + if rootHost != "portal.edge.example.com" {
228 + t.Fatalf("rootHost=%q, want portal.edge.example.com", rootHost)
229 + }
230 + if hostPort != "portal.edge.example.com:8443" {
231 + t.Fatalf("hostPort=%q, want portal.edge.example.com:8443", hostPort)
232 + }
233 +}
234 +
235 +func TestParsePortalAddressHostOnlyUsesFallbackScheme(t *testing.T) {
236 + t.Parallel()
237 +
238 + scheme, rootHost, hostPort, ok := parsePortalAddress("portal.edge.example.com:9443", "http")
239 + if !ok {
240 + t.Fatalf("expected parsePortalAddress success")
241 + }
242 + if scheme != "http" {
243 + t.Fatalf("scheme=%q, want http", scheme)
244 + }
245 + if rootHost != "portal.edge.example.com" {
246 + t.Fatalf("rootHost=%q, want portal.edge.example.com", rootHost)
247 + }
248 + if hostPort != "portal.edge.example.com:9443" {
249 + t.Fatalf("hostPort=%q, want portal.edge.example.com:9443", hostPort)
250 + }
251 +}