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(®isterReq); 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
+}