refact: simplify flags and refact relay state
Kim committed
Apr 9, 2026 at 18:01 UTC
b039147a6830d9287f7107af3f0dab5531da43a9
26 files changed
+722
-538
AGENTS.md
+25
-17
@@ -1,25 +1,33 @@
1
# AGENTS.md
2
3
Keep this file short and behavioral.
4
-Architecture, product behavior, and design rationale belong in `docs/architecture.md`.
4
+Architecture, product behavior, and design rationale belong in `docs/architecture.md` and `docs/adr/README.md`.
5
6
-## Principles
6
+## Development Principles
7
8
-These are mandates, not suggestions.
8
+- Minimizing concepts, duplication, and ceremony.
9
+- Prefer a single stable contract with one real owner.
10
+- Prefer local simplicity over premature or speculative abstraction.
11
+- Add indirection only when it removes real coupling or protects a real boundary.
12
+- Tests should protect stable contracts and invariants, not drive the spec.
13
10
-- Minimize concepts, duplication, and ceremony.
11
-- One real owner per contract. No mirroring, no wrappers unless they remove real coupling.
12
-- Local simplicity over speculative abstraction. Add indirection only when it removes real coupling or protects a real boundary.
13
-- When caller and callee are both local with no real boundary, change both directly.
14
-- Remove dead code, fields, config, and stale state while touching nearby code.
15
-- Reject invalid state at construction. `NewX` functions never return a half-built value; callers never check after the fact.
16
-- Fail fast. Wrap errors with context; surface the root cause.
17
-- Zero external dependencies unless the alternative is re-implementing a non-trivial, correctness-critical algorithm. Justify in the commit message.
18
-- Interfaces express behavior, not taxonomy. One or two methods. If an interface has no consumer, delete it.
14
+## Project Principles
15
20
-## Testing
16
+- When caller and callee are both local and no real boundary exists, change both directly; do not preserve local call shapes.
17
+- If a field, method, wrapper, or abstraction has no clear, current use and does not protect a real boundary, remove it immediately.
18
+- No wrapper functions or helpers unless they remove real coupling or protect a real boundary.
19
+- Prefer direct code over layers, facades, and indirection.
20
+- Prefer flattening and merging nearby responsibilities over splitting by default.
21
+- Remove dead fields, methods, config, and stale state while touching nearby code.
22
+- Do not duplicate normalization, validation, or defaulting logic; keep it in a single real owner.
23
+- Keep shared stateless transforms in `utils/`; keep stateful and domain-shaped logic with the real owner.
24
+- Keep stable shared contracts, constants, and public paths in `types/`, not in runtime or helpers.
25
+- Resolve complexity in the lowest coherent owner and expose only the minimum surface upward.
26
+- Shared runtime logic must live in one real owner and be reused, not mirrored.
27
22
-- A test exists to catch real bugs. If deleting the test would not let a bug reach production, delete the test.
23
-- Do not add tests whose only purpose is generic regression prevention without a concrete bug, contract, or boundary at risk.
24
-- Test contracts and boundaries: protocol compliance, error semantics, security invariants, integration across real I/O.
25
-- Do not test configuration shapes, constructor output fields, or struct assembly; the type system and constructors already guarantee those.
28
+## Verification
29
+
30
+- CI commands: `make vet`, `make lint`, `make test`, `make vuln`.
31
+- `make tidy` is local maintenance, not a CI requirement.
32
+- Run tests only when explicitly requested.
33
+- If verification seems necessary, ask before running it.
\ No newline at end of file
Dockerfile
+1
-5
@@ -40,11 +40,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
40
COPY --from=go-builder /src/bin/relay-server /usr/bin/relay-server
41
42
ENV PORTAL_URL=https://localhost:4017
43
-ENV IDENTITY_PATH=/portal-certs/identity.json
44
-ENV ADMIN_SECRET_KEY=
45
-ENV SNI_PORT=443
46
-ENV KEYLESS_DIR=/portal-certs
47
-ENV CLOUDFLARE_TOKEN=
43
+ENV IDENTITY_PATH=/portal-certs
44
ENV TZ=UTC
45
46
EXPOSE 4017
cmd/relay-server/admin.go
+3
-12
@@ -9,8 +9,6 @@ import (
9
"sync"
10
"time"
11
12
- "github.com/rs/zerolog/log"
13
-
12
"github.com/gosuda/portal-tunnel/v2/portal/policy"
13
"github.com/gosuda/portal-tunnel/v2/types"
14
"github.com/gosuda/portal-tunnel/v2/utils"
@@ -27,23 +25,16 @@ type adminAuth struct {
25
mu sync.RWMutex
26
}
27
30
-func newAdminAuth(secretKey string) *adminAuth {
28
+func newAdminAuth(secretKey string) (*adminAuth, error) {
29
secretKey = strings.TrimSpace(secretKey)
30
if secretKey == "" {
33
- generated, err := utils.RandomHex(16)
34
- if err != nil {
35
- log.Fatal().Err(err).Msg("generate admin secret key")
36
- }
37
- secretKey = generated
38
- log.Warn().
39
- Str("admin_secret_key", secretKey).
40
- Msg("generated random admin secret key because ADMIN_SECRET_KEY was empty")
31
+ return nil, errors.New("admin secret key is required")
32
}
33
34
return &adminAuth{
35
secretKey: secretKey,
36
sessions: make(map[string]time.Time),
46
- }
37
+ }, nil
38
}
39
40
func (a *adminAuth) AuthEnabled() bool {
cmd/relay-server/frontend.go
+11
-2
@@ -43,7 +43,7 @@ type Frontend struct {
43
landingPageEnabled atomic.Bool
44
}
45
46
-func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath string, defaultLandingPageEnabled bool, headlessShellURL string) (*Frontend, error) {
46
+func NewFrontend(server *portal.Server, identityPath string, defaultLandingPageEnabled bool, headlessShellURL string) (*Frontend, error) {
47
if server == nil {
48
return nil, errors.New("frontend requires portal server")
49
}
@@ -51,15 +51,24 @@ func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath st
51
if runtime == nil {
52
return nil, errors.New("frontend requires policy runtime")
53
}
54
+ adminSettingsPath := utils.ResolveRelayAdminSettingsPath(identityPath)
55
+ if adminSettingsPath == "" {
56
+ return nil, errors.New("frontend requires identity path")
57
+ }
58
state, err := loadAdminState(adminSettingsPath, runtime)
59
if err != nil {
60
return nil, err
61
}
62
+ identity := server.RelayIdentity()
63
+ auth, err := newAdminAuth(identity.AdminSecretKey)
64
+ if err != nil {
65
+ return nil, err
66
+ }
67
68
frontend := &Frontend{
69
distFS: embeddedDistFS,
70
server: server,
62
- auth: newAdminAuth(adminSecret),
71
+ auth: auth,
72
adminSettingsPath: strings.TrimSpace(adminSettingsPath),
73
thumbnails: newThumbnailService(headlessShellURL),
74
}
cmd/relay-server/main.go
+68
-81
@@ -14,6 +14,7 @@ import (
14
15
"github.com/gosuda/portal-tunnel/v2/portal"
16
"github.com/gosuda/portal-tunnel/v2/portal/acme"
17
+ "github.com/gosuda/portal-tunnel/v2/portal/wireguard"
18
"github.com/gosuda/portal-tunnel/v2/types"
19
"github.com/gosuda/portal-tunnel/v2/utils"
20
)
@@ -31,39 +32,33 @@ func main() {
32
}
33
34
type relayServerConfig struct {
34
- PortalURL string
35
- APIPort int
36
- SNIPort int
37
- DiscoveryPort int
38
- MinPort int
39
- MaxPort int
40
- UDPEnabled bool
41
- TCPEnabled bool
42
- LandingPageEnabled bool
43
- Bootstraps string
44
- DiscoveryEnabled bool
45
- WireGuardPrivateKey string
46
- WireGuardEndpoint string
47
- OverlayIPv4 string
48
- OverlayCIDRs string
49
- IdentityPath string
50
- AdminSecretKey string
51
- TrustProxyHeaders bool
52
- TrustedProxyCIDRs string
53
- AdminSettingsPath string
54
- KeylessDir string
55
- HeadlessShellURL string
56
- ACMEDNSProvider string
57
- ENSGaslessEnabled bool
58
- CloudflareToken string
59
- GCPProjectID string
60
- GCPManagedZone string
61
- AWSAccessKeyID string
62
- AWSSecretAccessKey string
63
- AWSSessionToken string
64
- AWSRegion string
65
- AWSHostedZoneID string
66
- AWSDNSSECKMSKeyARN string
35
+ PortalURL string
36
+ IdentityPath string
37
+ Bootstraps string
38
+ DiscoveryEnabled bool
39
+ WireGuardPort int
40
+ APIPort int
41
+ SNIPort int
42
+ TrustProxyHeaders bool
43
+ TrustedProxyCIDRs string
44
+ UDPEnabled bool
45
+ TCPEnabled bool
46
+ MinPort int
47
+ MaxPort int
48
+ LandingPageEnabled bool
49
+ HeadlessShellURL string
50
+
51
+ ACMEDNSProvider string
52
+ ENSGaslessEnabled bool
53
+ CloudflareToken string
54
+ GCPProjectID string
55
+ GCPManagedZone string
56
+ AWSAccessKeyID string
57
+ AWSSecretAccessKey string
58
+ AWSSessionToken string
59
+ AWSRegion string
60
+ AWSHostedZoneID string
61
+ AWSDNSSECKMSKeyARN string
62
}
63
64
func runServeCommand(args []string) error {
@@ -71,30 +66,25 @@ func runServeCommand(args []string) error {
66
fs := utils.NewFlagSet("relay-server", printRootUsage)
67
68
utils.StringFlagEnv(fs, &cfg.PortalURL, "portal-url", "https://localhost:4017", "portal base URL", "PORTAL_URL")
74
- utils.IntFlagEnv(fs, &cfg.APIPort, "api-port", 4017, utils.ParsePortNumber, "Admin/API server port", "API_PORT")
75
- utils.IntFlagEnv(fs, &cfg.SNIPort, "sni-port", 443, utils.ParsePortNumber, "TCP SNI router port number", "SNI_PORT")
76
- utils.IntFlagEnv(fs, &cfg.DiscoveryPort, "discovery-port", 0, utils.ParseOptionalPortNumber, "wireguard overlay listen port (0 uses default)", "DISCOVERY_PORT")
77
- utils.IntFlagEnv(fs, &cfg.MinPort, "min-port", 0, utils.ParseOptionalPortNumber, "inclusive minimum lease port shared by UDP and raw TCP transports (0=disabled)", "MIN_PORT")
78
- utils.IntFlagEnv(fs, &cfg.MaxPort, "max-port", 0, utils.ParseOptionalPortNumber, "inclusive maximum lease port shared by UDP and raw TCP transports (0=disabled)", "MAX_PORT")
79
- utils.BoolFlagEnv(fs, &cfg.UDPEnabled, "udp-enabled", false, "enable UDP relay transport; requires a valid --min-port/--max-port range", "UDP_ENABLED")
80
- utils.BoolFlagEnv(fs, &cfg.TCPEnabled, "tcp-enabled", false, "enable raw TCP port transport; requires a valid --min-port/--max-port range", "TCP_ENABLED")
81
- utils.BoolFlagEnv(fs, &cfg.LandingPageEnabled, "landing-page-enabled", false, "enable landing page by default when no admin setting has been saved yet", "LANDING_PAGE_ENABLED")
69
+ utils.StringFlagEnv(fs, &cfg.IdentityPath, "identity-path", "./.portal-certs", "directory path for relay identity, admin state, and keyless materials", "IDENTITY_PATH")
70
utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "additional bootstrap relay API URLs used for discovery expansion", "BOOTSTRAPS")
71
utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY")
84
- utils.StringFlagEnv(fs, &cfg.WireGuardPrivateKey, "wireguard-private-key", "", "wireguard private key for relay overlay", "WIREGUARD_PRIVATE_KEY")
85
- utils.StringFlagEnv(fs, &cfg.WireGuardEndpoint, "wireguard-endpoint", "", "wireguard endpoint (host:port) for relay overlay", "WIREGUARD_ENDPOINT")
86
- utils.StringFlagEnv(fs, &cfg.OverlayIPv4, "overlay-ipv4", "", "explicit overlay IPv4 override (auto-derived from public key when unset)", "OVERLAY_IPV4")
87
- utils.StringFlagEnv(fs, &cfg.OverlayCIDRs, "overlay-cidrs", "", "comma-separated overlay CIDR allowlist advertised to peers", "OVERLAY_CIDRS")
88
- utils.StringFlagEnv(fs, &cfg.IdentityPath, "identity-path", "identity.json", "relay identity json file path", "IDENTITY_PATH")
89
- utils.StringFlagEnv(fs, &cfg.AdminSecretKey, "admin-secret-key", "", "admin auth secret", "ADMIN_SECRET_KEY")
72
+ utils.IntFlagEnv(fs, &cfg.WireGuardPort, "wireguard-port", wireguard.DefaultListenPort, utils.ParsePortNumber, "public and listen UDP port for relay overlay", "WIREGUARD_PORT")
73
+
74
+ utils.IntFlagEnv(fs, &cfg.APIPort, "api-port", 4017, utils.ParsePortNumber, "Admin/API server port", "API_PORT")
75
+ utils.IntFlagEnv(fs, &cfg.SNIPort, "sni-port", 443, utils.ParsePortNumber, "TCP SNI router port number", "SNI_PORT")
76
utils.BoolFlagEnv(fs, &cfg.TrustProxyHeaders, "trust-proxy-headers", false, "trust X-Forwarded-* and X-Real-IP headers from trusted proxies", "TRUST_PROXY_HEADERS")
77
utils.StringFlagEnv(fs, &cfg.TrustedProxyCIDRs, "trusted-proxy-cidrs", "", "trusted proxy CIDR allowlist for forwarded headers, comma-separated; defaults to private/loopback proxy ranges when trust-proxy-headers is enabled", "TRUSTED_PROXY_CIDRS")
78
79
+ utils.BoolFlagEnv(fs, &cfg.UDPEnabled, "udp-enabled", false, "enable UDP relay transport; requires a valid --min-port/--max-port range", "UDP_ENABLED")
80
+ utils.BoolFlagEnv(fs, &cfg.TCPEnabled, "tcp-enabled", false, "enable raw TCP port transport; requires a valid --min-port/--max-port range", "TCP_ENABLED")
81
+ utils.IntFlagEnv(fs, &cfg.MinPort, "min-port", 0, utils.ParseOptionalPortNumber, "inclusive minimum lease port shared by UDP and raw TCP transports (0=disabled)", "MIN_PORT")
82
+ utils.IntFlagEnv(fs, &cfg.MaxPort, "max-port", 0, utils.ParseOptionalPortNumber, "inclusive maximum lease port shared by UDP and raw TCP transports (0=disabled)", "MAX_PORT")
83
+
84
+ utils.BoolFlagEnv(fs, &cfg.LandingPageEnabled, "landing-page-enabled", false, "enable landing page by default when no admin setting has been saved yet", "LANDING_PAGE_ENABLED")
85
utils.StringFlagEnv(fs, &cfg.HeadlessShellURL, "headless-shell-url", "", "headless Chrome CDP WebSocket URL for thumbnail generation (e.g. ws://headless-shell:9222)", "HEADLESS_SHELL_URL")
86
95
- utils.StringFlagEnv(fs, &cfg.KeylessDir, "keyless-dir", "./.portal-certs", "directory path for relay keyless materials", "KEYLESS_DIR")
96
- utils.StringFlagEnv(fs, &cfg.AdminSettingsPath, "admin-settings-path", "admin_settings.json", "admin settings file path", "ADMIN_SETTINGS_PATH")
97
- utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "ACME DNS provider for managed DNS-01/A-record sync and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|route53); leave empty to use manual fullchain.pem/privatekey.pem from KEYLESS_DIR", "ACME_DNS_PROVIDER")
87
+ utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "ACME DNS provider for managed DNS-01/A-record sync and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|route53); leave empty to use manual fullchain.pem/privatekey.pem from IDENTITY_PATH", "ACME_DNS_PROVIDER")
88
utils.BoolFlagEnv(fs, &cfg.ENSGaslessEnabled, "ens-gasless-enabled", false, "enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames", "ENS_GASLESS_ENABLED")
89
utils.StringFlagEnv(fs, &cfg.CloudflareToken, "cloudflare-token", "", "Cloudflare DNS API token (required when acme-dns-provider=cloudflare)", "CLOUDFLARE_TOKEN")
90
utils.StringFlagEnv(fs, &cfg.GCPProjectID, "gcp-project-id", "", "Google Cloud project id for Cloud DNS automation; auto-detected from ADC or GCE metadata when omitted", "GCP_PROJECT_ID", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GCE_PROJECT")
@@ -116,21 +106,27 @@ func runServeCommand(args []string) error {
106
printRootUsage(os.Stderr)
107
return err
108
}
109
+ cfg.IdentityPath = utils.ResolveRelayStateDir(cfg.IdentityPath)
110
111
log.Info().
112
Str("release_version", types.ReleaseVersion).
113
Str("portal_url", cfg.PortalURL).
114
Str("identity_path", cfg.IdentityPath).
124
- Str("admin_settings_path", cfg.AdminSettingsPath).
125
- Int("discovery_port", cfg.DiscoveryPort).
115
+ Str("bootstraps", cfg.Bootstraps).
116
+ Bool("discovery_enabled", cfg.DiscoveryEnabled).
117
+ Int("wireguard_port", cfg.WireGuardPort).
118
+ Int("api_port", cfg.APIPort).
119
+ Int("sni_port", cfg.SNIPort).
120
+ Bool("trust_proxy_headers", cfg.TrustProxyHeaders).
121
+ Str("trusted_proxy_cidrs", cfg.TrustedProxyCIDRs).
122
+ Bool("udp_enabled", cfg.UDPEnabled).
123
+ Bool("tcp_enabled", cfg.TCPEnabled).
124
Int("min_port", cfg.MinPort).
125
Int("max_port", cfg.MaxPort).
126
Bool("landing_page_enabled", cfg.LandingPageEnabled).
129
- Bool("discovery_enabled", cfg.DiscoveryEnabled).
127
+ Bool("headless_shell_enabled", strings.TrimSpace(cfg.HeadlessShellURL) != "").
128
Str("acme_dns_provider", cfg.ACMEDNSProvider).
129
Bool("ens_gasless_enabled", cfg.ENSGaslessEnabled).
132
- Bool("udp_enabled", cfg.UDPEnabled).
133
- Bool("tcp_enabled", cfg.TCPEnabled).
130
Msg("configured relay server")
131
132
ctx, stop := utils.SignalContext()
@@ -140,23 +136,22 @@ func runServeCommand(args []string) error {
136
}
137
138
func runServer(ctx context.Context, cfg relayServerConfig) error {
143
- bootstraps, err := utils.ResolvePortalRelayURLs(ctx, utils.SplitCSV(cfg.Bootstraps), cfg.DiscoveryEnabled)
144
- if err != nil {
145
- return fmt.Errorf("resolve discovery bootstraps: %w", err)
146
- }
147
- overlayCIDRs := utils.SplitCSV(cfg.OverlayCIDRs)
148
-
139
server, err := portal.NewServer(portal.ServerConfig{
150
- PortalURL: cfg.PortalURL,
151
- IdentityPath: cfg.IdentityPath,
152
- Bootstraps: bootstraps,
153
- WireGuardPrivateKey: cfg.WireGuardPrivateKey,
154
- DiscoveryPort: cfg.DiscoveryPort,
155
- WireGuardEndpoint: cfg.WireGuardEndpoint,
156
- OverlayIPv4: cfg.OverlayIPv4,
157
- OverlayCIDRs: overlayCIDRs,
140
+ PortalURL: cfg.PortalURL,
141
+ IdentityPath: cfg.IdentityPath,
142
+ Bootstraps: utils.SplitCSV(cfg.Bootstraps),
143
+ DiscoveryEnabled: cfg.DiscoveryEnabled,
144
+ WireGuardPort: cfg.WireGuardPort,
145
+ APIPort: cfg.APIPort,
146
+ SNIPort: cfg.SNIPort,
147
+ TrustProxyHeaders: cfg.TrustProxyHeaders,
148
+ TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
149
+ UDPEnabled: cfg.UDPEnabled,
150
+ TCPEnabled: cfg.TCPEnabled,
151
+ MinPort: cfg.MinPort,
152
+ MaxPort: cfg.MaxPort,
153
ACME: acme.Config{
159
- KeyDir: cfg.KeylessDir,
154
+ KeyDir: cfg.IdentityPath,
155
DNSProvider: cfg.ACMEDNSProvider,
156
ENSGaslessEnabled: cfg.ENSGaslessEnabled,
157
CloudflareToken: cfg.CloudflareToken,
@@ -169,21 +164,12 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
164
AWSHostedZoneID: cfg.AWSHostedZoneID,
165
AWSKMSKeyARN: cfg.AWSDNSSECKMSKeyARN,
166
},
172
- APIPort: cfg.APIPort,
173
- SNIPort: cfg.SNIPort,
174
- TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
175
- TrustProxyHeaders: cfg.TrustProxyHeaders,
176
- DiscoveryEnabled: cfg.DiscoveryEnabled,
177
- MinPort: cfg.MinPort,
178
- MaxPort: cfg.MaxPort,
179
- UDPEnabled: cfg.UDPEnabled,
180
- TCPEnabled: cfg.TCPEnabled,
167
})
168
if err != nil {
169
return fmt.Errorf("create relay server: %w", err)
170
}
171
186
- frontend, err := NewFrontend(server, cfg.AdminSecretKey, cfg.AdminSettingsPath, cfg.LandingPageEnabled, cfg.HeadlessShellURL)
172
+ frontend, err := NewFrontend(server, cfg.IdentityPath, cfg.LandingPageEnabled, cfg.HeadlessShellURL)
173
if err != nil {
174
return fmt.Errorf("create frontend: %w", err)
175
}
@@ -227,7 +213,8 @@ func printRootUsage(w io.Writer) {
213
"relay-server",
214
"relay-server serve",
215
"relay-server --portal-url https://portal.example.com",
230
- "relay-server --discovery --udp-enabled --min-port 40000 --max-port 40099",
216
+ "relay-server --discovery --bootstraps https://bootstrap.example.com",
217
+ "relay-server --udp-enabled --min-port 40000 --max-port 40099",
218
"relay-server --landing-page-enabled",
219
"relay-server help",
220
},
docker-compose.yml
+5
-5
@@ -16,6 +16,7 @@ services:
16
ports:
17
- "${API_PORT:-4017}:${API_PORT:-4017}"
18
- "${SNI_PORT:-443}:${SNI_PORT:-443}"
19
+ - "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
20
# Uncomment for UDP backhaul, public UDP lease ports, and raw TCP lease ports as needed.
21
# - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
22
# - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
@@ -25,11 +26,12 @@ services:
26
PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
27
BOOTSTRAPS: ${BOOTSTRAPS:-}
28
DISCOVERY: ${DISCOVERY:-true}
28
- IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs/identity.json}
29
+ IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs}
30
31
# Listener ports (published to the host below)
32
API_PORT: ${API_PORT:-4017}
33
SNI_PORT: ${SNI_PORT:-443}
34
+ WIREGUARD_PORT: ${WIREGUARD_PORT:-51820}
35
36
# Shared lease port range.
37
MIN_PORT: ${MIN_PORT:-40000}
@@ -38,7 +40,6 @@ services:
40
TCP_ENABLED: ${TCP_ENABLED:-false}
41
42
# Admin/auth configuration
41
- ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
43
LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
44
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
45
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
@@ -46,8 +47,7 @@ services:
47
# Optional: auto-generated thumbnails (requires headless-shell sidecar above)
48
# HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
49
49
- # TLS/ACME and keyless materials
50
- KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
50
+ # TLS/ACME materials
51
ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
52
ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
53
CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
@@ -62,7 +62,7 @@ services:
62
AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
63
AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
64
volumes:
65
- - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
65
+ - ./.portal-certs:${IDENTITY_PATH:-/portal-certs}
66
# Uncomment when using a Google Cloud service account file for gcloud automation.
67
# - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
68
restart: unless-stopped
docs/architecture.md
+3
-3
@@ -70,12 +70,12 @@ UDP client
70
71
### Operational Constraints
72
73
-- For non-localhost deployments, relay TLS can run from manual certificate files in `KEYLESS_DIR` or from managed ACME.
73
+- For non-localhost deployments, relay TLS can run from manual certificate files in the relay `IDENTITY_PATH` directory or from managed ACME.
74
- When managed ACME is enabled, supported DNS providers are `cloudflare`, `gcloud`, and `route53`.
75
- ENS gasless automation reuses `ACME_DNS_PROVIDER` for DNSSEC and ENS TXT sync.
76
-- Relay, tunnel, and demo-app identities are persisted as JSON at `IDENTITY_PATH` / `--identity-path`. Missing files are generated automatically and stored with `name`, `address`, `public_key`, and `private_key`.
76
+- Relay stores its state under `IDENTITY_PATH`, including `identity.json`, `admin_settings.json`, and certificate material. Tunnel and demo-app identities still use `IDENTITY_PATH` / `--identity-path` as a direct JSON file path.
77
- Managed non-localhost ACME keeps both root and wildcard DNS A records in sync.
78
-- Relay certificate material lives under `KEYLESS_DIR` as `fullchain.pem` and `privatekey.pem`.
78
+- Relay certificate material lives under `IDENTITY_PATH` as `fullchain.pem` and `privatekey.pem`.
79
- Localhost uses the development certificate path instead of public managed/manual certificate setup.
80
81
## Connection Model
docs/deployment.md
+33
-23
@@ -25,10 +25,10 @@ Choose one of these modes:
25
26
- Manual certificate mode
27
- Leave `ACME_DNS_PROVIDER` empty.
28
- - Place `fullchain.pem` and `privatekey.pem` in `KEYLESS_DIR`.
28
+ - Place `fullchain.pem` and `privatekey.pem` in `IDENTITY_PATH`.
29
- Portal uses the files as-is and does not modify DNS or renew the certificate.
30
- Manual certificate + gasless mode
31
- - Place `fullchain.pem` and `privatekey.pem` in `KEYLESS_DIR`.
31
+ - Place `fullchain.pem` and `privatekey.pem` in `IDENTITY_PATH`.
32
- Set `ACME_DNS_PROVIDER`.
33
- Portal keeps the manual certificate files, skips ACME certificate issuance, and still uses the provider for DNSSEC + ENS TXT automation.
34
- Managed ACME mode
@@ -158,7 +158,7 @@ Portal can optionally enable ENS gasless DNS import for the base domain and leas
158
- Enable it only when you specifically need ENS gasless DNS import.
159
- ENS gasless automation requires `ACME_DNS_PROVIDER`.
160
- Portal uses that provider for both DNSSEC automation and ENS TXT create/delete.
161
-- If valid manual certificate files already exist in `KEYLESS_DIR`, Portal keeps using them and does not force ACME certificate issuance just because `ACME_DNS_PROVIDER` is set.
161
+- If valid manual certificate files already exist in `IDENTITY_PATH`, Portal keeps using them and does not force ACME certificate issuance just because `ACME_DNS_PROVIDER` is set.
162
- Cloudflare can enable zone signing directly, but some registrars still require publishing the returned DS record.
163
- Google Cloud DNS can enable zone signing directly, but the registrar may still require publishing the returned DS record.
164
- Route53 requires a compatible KMS key ARN when no active KSK already exists, and the registrar may still require the DS record.
@@ -201,17 +201,16 @@ Manual certificate example:
201
202
```bash
203
PORTAL_URL=https://example.com
204
-BOOTSTRAPS=
204
+BOOTSTRAPS=https://bootstrap.example.com
205
DISCOVERY=true
206
-IDENTITY_PATH=/portal-certs/identity.json
206
+WIREGUARD_PORT=51820
207
+IDENTITY_PATH=/portal-certs
208
SNI_PORT=443
208
-ADMIN_SECRET_KEY=your-admin-secret
209
-KEYLESS_DIR=/portal-certs
209
ACME_DNS_PROVIDER=
210
ENS_GASLESS_ENABLED=false
211
```
212
214
-Place these files in `KEYLESS_DIR` before startup:
213
+Place these files in `IDENTITY_PATH` before startup:
214
215
```text
216
/portal-certs/fullchain.pem
@@ -222,12 +221,11 @@ Manual certificate + gasless example:
221
222
```bash
223
PORTAL_URL=https://example.com
225
-BOOTSTRAPS=
224
+BOOTSTRAPS=https://bootstrap.example.com
225
DISCOVERY=true
227
-IDENTITY_PATH=/portal-certs/identity.json
226
+WIREGUARD_PORT=51820
227
+IDENTITY_PATH=/portal-certs
228
SNI_PORT=443
229
-ADMIN_SECRET_KEY=your-admin-secret
230
-KEYLESS_DIR=/portal-certs
229
ACME_DNS_PROVIDER=cloudflare
230
CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
231
ENS_GASLESS_ENABLED=true
@@ -239,12 +237,11 @@ Managed Cloudflare example:
237
238
```bash
239
PORTAL_URL=https://example.com
242
-BOOTSTRAPS=
240
+BOOTSTRAPS=https://bootstrap.example.com
241
DISCOVERY=true
244
-IDENTITY_PATH=/portal-certs/identity.json
242
+WIREGUARD_PORT=51820
243
+IDENTITY_PATH=/portal-certs
244
SNI_PORT=443
246
-ADMIN_SECRET_KEY=your-admin-secret
247
-KEYLESS_DIR=/portal-certs
245
ACME_DNS_PROVIDER=cloudflare
246
CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
247
ENS_GASLESS_ENABLED=false
@@ -253,8 +250,7 @@ ENS_GASLESS_ENABLED=false
250
Route53 example:
251
252
```bash
256
-IDENTITY_PATH=/portal-certs/identity.json
257
-KEYLESS_DIR=/portal-certs
253
+IDENTITY_PATH=/portal-certs
254
ACME_DNS_PROVIDER=route53
255
AWS_ACCESS_KEY_ID=AKIA...
256
AWS_SECRET_ACCESS_KEY=...
@@ -270,8 +266,7 @@ ENS_GASLESS_ENABLED=false
266
Google Cloud DNS example:
267
268
```bash
273
-IDENTITY_PATH=/portal-certs/identity.json
274
-KEYLESS_DIR=/portal-certs
269
+IDENTITY_PATH=/portal-certs
270
ACME_DNS_PROVIDER=gcloud
271
# Optional when ADC does not expose the project id directly.
272
GCP_PROJECT_ID=my-gcp-project
@@ -286,9 +281,23 @@ Notes:
281
282
- For non-apex deployments, set `PORTAL_URL` to the non-apex host value, for example `https://portal.example.com:8443`
283
- Portal uses the `PORTAL_URL` host for public lease hostnames
289
-- `IDENTITY_PATH` stores the relay identity JSON inside the container
290
-- `KEYLESS_DIR` stores relay certificate material inside the container
291
-- The Docker Compose stack stores relay identity JSON and certificate state under `./.portal-certs` on the host
284
+- `IDENTITY_PATH` stores the relay state directory inside the container
285
+- Portal stores `identity.json`, `admin_settings.json`, `fullchain.pem`, and `privatekey.pem` under `IDENTITY_PATH`
286
+- The Docker Compose stack stores relay state under `./.portal-certs` on the host
287
+
288
+Discovery settings:
289
+
290
+```bash
291
+DISCOVERY=true
292
+BOOTSTRAPS=https://bootstrap.example.com
293
+WIREGUARD_PORT=51820
294
+```
295
+
296
+- Open `WIREGUARD_PORT/udp` on the host or VM when discovery is enabled.
297
+- The relay always advertises the `PORTAL_URL` host for WireGuard discovery.
298
+- The relay stores its admin secret key in `IDENTITY_PATH/identity.json` and generates one automatically on first startup if the file does not already contain it.
299
+- The relay stores its WireGuard keypair in `IDENTITY_PATH/identity.json`. If that file has no WireGuard key yet, Portal generates one on first discovery startup and saves it back to that file.
300
+- `BOOTSTRAPS` should point at at least one existing relay when you want discovery to join a multi-relay mesh.
301
302
If the relay sits behind a reverse proxy or ingress and you want admin/auth and lease IP tracking to use the original client IP, set:
303
@@ -336,6 +345,7 @@ UDP transport and raw TCP port transport are disabled by default.
345
346
Open these ports in your cloud security group or firewall:
347
348
+- `WIREGUARD_PORT/udp` when discovery is enabled
349
- `SNI_PORT/udp`
350
- `MIN_PORT-MAX_PORT/udp` when UDP transport is enabled
351
- `MIN_PORT-MAX_PORT/tcp` when raw TCP port transport is enabled
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
+4
-4
@@ -71,6 +71,7 @@ services:
71
ports:
72
- "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
73
- "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
74
+ - "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
75
# Uncomment below when enabling UDP transport (host SNI_PORT/udp is free — nginx only uses 443/tcp):
76
# - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/udp"
77
# - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
@@ -81,19 +82,18 @@ services:
82
PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
83
BOOTSTRAPS: ${BOOTSTRAPS:-}
84
DISCOVERY: ${DISCOVERY:-true}
85
+ WIREGUARD_PORT: ${WIREGUARD_PORT:-51820}
86
API_PORT: ${API_PORT:-4017}
87
SNI_PORT: ${SNI_PORT:-4443}
86
- IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs/identity.json}
88
+ IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs}
89
MIN_PORT: ${MIN_PORT:-0}
90
MAX_PORT: ${MAX_PORT:-0}
91
UDP_ENABLED: ${UDP_ENABLED:-false}
92
TCP_ENABLED: ${TCP_ENABLED:-false}
91
- ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
93
LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
94
# HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
95
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
96
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
96
- KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
97
ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
98
ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
99
CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
@@ -108,7 +108,7 @@ services:
108
AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
109
AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
110
volumes:
111
- - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
111
+ - ./.portal-certs:${IDENTITY_PATH:-/portal-certs}
112
# Uncomment when using a Google Cloud service account file for gcloud automation.
113
# - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
114
restart: unless-stopped
docs/examples/nginx-proxy/docker-compose.yaml
+4
-4
@@ -64,6 +64,7 @@ services:
64
ports:
65
- "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
66
- "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
67
+ - "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
68
# Uncomment below when enabling UDP transport (host SNI_PORT/udp is free — nginx only uses 443/tcp):
69
# - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/udp"
70
# - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
@@ -74,25 +75,24 @@ services:
75
PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
76
BOOTSTRAPS: ${BOOTSTRAPS:-}
77
DISCOVERY: ${DISCOVERY:-true}
78
+ WIREGUARD_PORT: ${WIREGUARD_PORT:-51820}
79
80
# Listener ports (bound directly on host via host networking).
81
API_PORT: ${API_PORT:-4017}
82
# Use a non-443 port to avoid conflict with nginx on the host.
83
SNI_PORT: ${SNI_PORT:-4443}
82
- IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs/identity.json}
84
+ IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs}
85
86
MIN_PORT: ${MIN_PORT:-0}
87
MAX_PORT: ${MAX_PORT:-0}
88
UDP_ENABLED: ${UDP_ENABLED:-false}
89
TCP_ENABLED: ${TCP_ENABLED:-false}
90
89
- ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
91
LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
92
# HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
93
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
94
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
95
95
- KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
96
ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
97
ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
98
CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
@@ -107,7 +107,7 @@ services:
107
AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
108
AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
109
volumes:
110
- - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
110
+ - ./.portal-certs:${IDENTITY_PATH:-/portal-certs}
111
# Uncomment when using a Google Cloud service account file for gcloud automation.
112
# - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
113
restart: unless-stopped
frontend/src/pages/AdminLogin.tsx
+2
-4
@@ -29,9 +29,7 @@ export function AdminLogin() {
29
// Show auth not enabled message
30
useEffect(() => {
31
if (!isLoading && !authEnabled) {
32
- setError(
33
- "Admin authentication is not configured. Set ADMIN_SECRET_KEY in your environment."
34
- );
32
+ setError("Admin authentication is unavailable on this relay.");
33
}
34
}, [isLoading, authEnabled]);
35
@@ -120,7 +118,7 @@ export function AdminLogin() {
118
className="text-sm font-medium text-muted-foreground"
119
htmlFor="admin-key"
120
>
123
- ADMIN_SECRET_KEY
121
+ Admin Secret Key
122
</label>
123
<div className="relative">
124
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
portal/api_server.go
+21
-28
@@ -19,7 +19,6 @@ import (
19
"github.com/gosuda/portal-tunnel/v2/portal/auth"
20
"github.com/gosuda/portal-tunnel/v2/portal/discovery"
21
"github.com/gosuda/portal-tunnel/v2/portal/keyless"
22
- "github.com/gosuda/portal-tunnel/v2/portal/policy"
22
"github.com/gosuda/portal-tunnel/v2/portal/transport"
23
"github.com/gosuda/portal-tunnel/v2/types"
24
"github.com/gosuda/portal-tunnel/v2/utils"
@@ -144,7 +143,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
143
}
144
145
func (s *Server) extractAllowedClientIP(w http.ResponseWriter, r *http.Request) (string, bool) {
147
- clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.trustedProxyCIDRs)
146
+ clientIP := s.registry.policy.ExtractClientIP(r)
147
if !s.registry.policy.IPFilter().IsIPBanned(clientIP) {
148
return clientIP, true
149
}
@@ -163,8 +162,18 @@ func (s *Server) handleRelayDiscovery(w http.ResponseWriter, r *http.Request) {
162
ingressAddr = fmt.Sprintf("%s:%d", ingressAddr, s.cfg.SNIPort)
163
}
164
165
+ var wireGuardPublicKey, wireGuardEndpoint, overlayIPv4 string
166
+ var overlayCIDRs []string
167
+ if s.overlay != nil {
168
+ cfg := s.overlay.Config()
169
+ wireGuardPublicKey = cfg.PublicKey
170
+ wireGuardEndpoint = cfg.Endpoint
171
+ overlayIPv4 = cfg.OverlayIPv4
172
+ overlayCIDRs = append([]string(nil), cfg.OverlayCIDRs...)
173
+ }
174
+
175
self, err := discovery.NormalizeDescriptor(types.RelayDescriptor{
167
- Identity: s.identity.Copy(),
176
+ Identity: s.identity.Base(),
177
RelayID: s.cfg.PortalURL,
178
OwnerAddress: s.identity.Address,
179
SignerPublicKey: s.identity.PublicKey,
@@ -174,14 +183,14 @@ func (s *Server) handleRelayDiscovery(w http.ResponseWriter, r *http.Request) {
183
ExpiresAt: now.Add(2 * discovery.DiscoveryPollInterval),
184
APIHTTPSAddr: s.cfg.PortalURL,
185
IngressTLSAddr: ingressAddr,
177
- WireGuardPublicKey: wireGuardField(s.wireGuardOverlayEnabled(), s.cfg.WireGuardPublicKey),
178
- WireGuardEndpoint: wireGuardField(s.wireGuardOverlayEnabled(), s.cfg.WireGuardEndpoint),
179
- OverlayIPv4: wireGuardField(s.wireGuardOverlayEnabled(), s.cfg.OverlayIPv4),
180
- OverlayCIDRs: overlayCIDRsField(s.wireGuardOverlayEnabled(), s.cfg.OverlayCIDRs),
186
+ WireGuardPublicKey: wireGuardPublicKey,
187
+ WireGuardEndpoint: wireGuardEndpoint,
188
+ OverlayIPv4: overlayIPv4,
189
+ OverlayCIDRs: overlayCIDRs,
190
SupportsUDP: s.cfg.UDPEnabled && s.quicTunnel != nil,
191
SupportsTCP: s.cfg.TCPEnabled,
183
- SupportsOverlayPeer: s.wireGuardOverlayEnabled(),
184
- Load: float64(s.loadMgr.ActiveConns()),
192
+ SupportsOverlayPeer: s.overlay != nil,
193
+ Load: float64(s.activeConns.Load()),
194
})
195
if err != nil {
196
utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
@@ -376,22 +385,6 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
385
utils.WriteAPIData(w, http.StatusOK, map[string]any{})
386
}
387
379
-func wireGuardField(enabled bool, value string) string {
380
- if !enabled {
381
- return ""
382
- }
383
- return value
384
-}
385
-
386
-func overlayCIDRsField(enabled bool, cidrs []string) []string {
387
- if !enabled || len(cidrs) == 0 {
388
- return nil
389
- }
390
- out := make([]string, len(cidrs))
391
- copy(out, cidrs)
392
- return out
393
-}
394
-
388
func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
389
if !utils.RequireMethod(w, r, http.MethodGet) {
390
return
@@ -591,15 +584,15 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
584
stream: stream,
585
}
586
if req.UDPEnabled {
594
- if s.ports == nil {
587
+ if s.udpPorts == nil {
588
return types.RegisterResponse{}, errors.New("udp port allocation not available")
589
}
597
- port, err := s.ports.Allocate(identity.Name)
590
+ port, err := s.udpPorts.Allocate(identity.Name)
591
if err != nil {
592
return types.RegisterResponse{}, err
593
}
594
record.datagram = transport.NewRelayDatagram(identityKey, port)
602
- record.ports = s.ports
595
+ record.udpPorts = s.udpPorts
596
}
597
if req.TCPEnabled {
598
if s.tcpPorts == nil {
portal/discovery/relayset.go
+9
-2
@@ -57,12 +57,19 @@ type relayDescriptorProjection struct {
57
bootstrap bool
58
}
59
60
-func NewRelaySet() *RelaySet {
61
- return &RelaySet{
60
+func NewRelaySet(identity types.Identity, relayURL string, bootstrapRelayURLs []string) (*RelaySet, error) {
61
+ set := &RelaySet{
62
relayKeysByURL: make(map[string]string),
63
relays: make(map[string]RelayState),
64
localByURL: make(map[string]RelayState),
65
}
66
+ if err := set.SetSelfRelay(identity, relayURL); err != nil {
67
+ return nil, err
68
+ }
69
+ if err := set.SetBootstrapRelayURLs(bootstrapRelayURLs); err != nil {
70
+ return nil, err
71
+ }
72
+ return set, nil
73
}
74
75
func relayExpiredAt(state RelayState, now time.Time) bool {
portal/lease.go
+8
-7
@@ -25,9 +25,10 @@ type leaseRegistry struct {
25
mu sync.RWMutex
26
}
27
28
-func newLeaseRegistry(runtime *policy.Runtime) *leaseRegistry {
29
- if runtime == nil {
30
- runtime = policy.NewRuntime()
28
+func newLeaseRegistry(udpEnabled, tcpPortEnabled bool, trustProxyHeaders bool, rawTrustedProxyCIDRs string) (*leaseRegistry, error) {
29
+ runtime, err := policy.NewRuntime(udpEnabled, tcpPortEnabled, trustProxyHeaders, rawTrustedProxyCIDRs)
30
+ if err != nil {
31
+ return nil, err
32
}
33
34
return &leaseRegistry{
@@ -35,7 +36,7 @@ func newLeaseRegistry(runtime *policy.Runtime) *leaseRegistry {
36
leasesByKey: make(map[string]*leaseRecord),
37
registerChallenges: make(map[string]*auth.RegisterChallenge),
38
policy: runtime,
38
- }
39
+ }, nil
40
}
41
42
func (r *leaseRegistry) CloseAll() []*leaseRecord {
@@ -323,7 +324,7 @@ type leaseRecord struct {
324
TCPEnabled bool
325
Metadata types.LeaseMetadata
326
datagram *transport.RelayDatagram
326
- ports *transport.PortAllocator
327
+ udpPorts *transport.PortAllocator
328
tcpPort *transport.RelayTCPPort
329
tcpPorts *transport.PortAllocator
330
stream *transport.RelayStream
@@ -373,8 +374,8 @@ func (r *leaseRecord) Close() {
374
if r.datagram != nil {
375
port := r.datagram.UDPPort()
376
r.datagram.Close()
376
- if port > 0 && r.ports != nil {
377
- r.ports.Release(port)
377
+ if port > 0 && r.udpPorts != nil {
378
+ r.udpPorts.Release(port)
379
}
380
}
381
if r.tcpPort != nil {
portal/lease_test.go
+16
-8
@@ -12,11 +12,20 @@ import (
12
"github.com/gosuda/portal-tunnel/v2/types"
13
)
14
15
+func newTestRegistry(t *testing.T) *leaseRegistry {
16
+ t.Helper()
17
+ registry, err := newLeaseRegistry(false, false, false, "")
18
+ if err != nil {
19
+ t.Fatalf("newLeaseRegistry() error = %v", err)
20
+ }
21
+ return registry
22
+}
23
+
24
func TestLeaseRegistryLifecycle(t *testing.T) {
25
t.Parallel()
26
18
- runtime := policy.NewRuntime()
19
- registry := newLeaseRegistry(runtime)
27
+ registry := newTestRegistry(t)
28
+ runtime := registry.policy
29
record := &leaseRecord{
30
Identity: types.Identity{
31
Name: "demo",
@@ -66,7 +75,7 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
75
func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
76
t.Parallel()
77
69
- registry := newLeaseRegistry(policy.NewRuntime())
78
+ registry := newTestRegistry(t)
79
wildcardLease := &leaseRecord{
80
Identity: types.Identity{
81
Name: "wildcard",
@@ -105,12 +114,11 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
114
func TestLeaseRegistrySnapshotAndRoutableUsePolicy(t *testing.T) {
115
t.Parallel()
116
108
- runtime := policy.NewRuntime()
117
+ registry := newTestRegistry(t)
118
+ runtime := registry.policy
119
if err := runtime.Approver().SetMode(policy.ModeManual); err != nil {
120
t.Fatalf("SetMode() error = %v", err)
121
}
112
-
113
- registry := newLeaseRegistry(runtime)
122
record := &leaseRecord{
123
Identity: types.Identity{
124
Name: "demo",
@@ -151,7 +159,7 @@ func TestLeaseRegistrySnapshotAndRoutableUsePolicy(t *testing.T) {
159
func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
160
t.Parallel()
161
154
- registry := newLeaseRegistry(policy.NewRuntime())
162
+ registry := newTestRegistry(t)
163
record := &leaseRecord{
164
Identity: types.Identity{
165
Name: "expired",
@@ -180,7 +188,7 @@ func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
188
func TestServerRunLeaseJanitorRejectsNonPositiveInterval(t *testing.T) {
189
t.Parallel()
190
183
- server := &Server{registry: newLeaseRegistry(policy.NewRuntime())}
191
+ server := &Server{registry: newTestRegistry(t)}
192
err := server.runLeaseJanitor(context.Background(), 0)
193
if err == nil {
194
t.Fatal("runLeaseJanitor() error = nil, want validation error")
portal/policy/load_manager.go
deleted
-49
@@ -1,49 +0,0 @@
1
-package policy
2
-
3
-import "sync/atomic"
4
-
5
-// LoadManager tracks coarse connection counters for relay diagnostics.
6
-type LoadManager struct {
7
- active int64
8
- bytesIn int64
9
- bytesOut int64
10
-}
11
-
12
-func NewLoadManager() *LoadManager {
13
- return &LoadManager{}
14
-}
15
-
16
-func (m *LoadManager) ActiveConns() int64 {
17
- if m == nil {
18
- return 0
19
- }
20
- return atomic.LoadInt64(&m.active)
21
-}
22
-
23
-func (m *LoadManager) RecordConnStart() {
24
- if m == nil {
25
- return
26
- }
27
- atomic.AddInt64(&m.active, 1)
28
-}
29
-
30
-func (m *LoadManager) RecordConnEnd() {
31
- if m == nil {
32
- return
33
- }
34
- atomic.AddInt64(&m.active, -1)
35
-}
36
-
37
-func (m *LoadManager) RecordBytesIn(n int64) {
38
- if m == nil || n <= 0 {
39
- return
40
- }
41
- atomic.AddInt64(&m.bytesIn, n)
42
-}
43
-
44
-func (m *LoadManager) RecordBytesOut(n int64) {
45
- if m == nil || n <= 0 {
46
- return
47
- }
48
- atomic.AddInt64(&m.bytesOut, n)
49
-}
portal/policy/proxy_trust.go
+15
-7
@@ -18,7 +18,7 @@ var defaultTrustedProxyCIDRs = mustParseTrustedProxyCIDRs(
18
"fe80::/10",
19
)
20
21
-func IsTrustedProxyRemoteAddr(remoteAddr string, trustedProxyCIDRs []*net.IPNet) bool {
21
+func isTrustedProxyRemoteAddr(remoteAddr string, trustedProxyCIDRs []*net.IPNet) bool {
22
remoteIP := parseRemoteAddrIP(remoteAddr)
23
if remoteIP == nil {
24
return false
@@ -36,13 +36,21 @@ func IsTrustedProxyRemoteAddr(remoteAddr string, trustedProxyCIDRs []*net.IPNet)
36
return false
37
}
38
39
-func ExtractClientIP(r *http.Request, trustProxyHeaders bool, trustedProxyCIDRs []*net.IPNet) string {
39
+func (r *Runtime) ExtractClientIP(req *http.Request) string {
40
if r == nil {
41
return ""
42
}
43
+ if req == nil {
44
+ return ""
45
+ }
46
+
47
+ r.mu.RLock()
48
+ trustProxyHeaders := r.trustProxyHeaders
49
+ trustedProxyCIDRs := append([]*net.IPNet(nil), r.trustedProxyCIDRs...)
50
+ r.mu.RUnlock()
51
44
- if trustProxyHeaders && IsTrustedProxyRemoteAddr(r.RemoteAddr, trustedProxyCIDRs) {
45
- if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
52
+ if trustProxyHeaders && isTrustedProxyRemoteAddr(req.RemoteAddr, trustedProxyCIDRs) {
53
+ if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
54
if before, _, ok := strings.Cut(xff, ","); ok {
55
if ip := normalizeClientIPCandidate(before); ip != "" {
56
return ip
@@ -51,16 +59,16 @@ func ExtractClientIP(r *http.Request, trustProxyHeaders bool, trustedProxyCIDRs
59
return ip
60
}
61
}
54
- if xri := r.Header.Get("X-Real-IP"); xri != "" {
62
+ if xri := req.Header.Get("X-Real-IP"); xri != "" {
63
if ip := normalizeClientIPCandidate(xri); ip != "" {
64
return ip
65
}
66
}
67
}
68
61
- host, _, err := net.SplitHostPort(r.RemoteAddr)
69
+ host, _, err := net.SplitHostPort(req.RemoteAddr)
70
if err != nil {
63
- return strings.TrimSpace(r.RemoteAddr)
71
+ return strings.TrimSpace(req.RemoteAddr)
72
}
73
if normalized := normalizeClientIPCandidate(host); normalized != "" {
74
return normalized
portal/policy/runtime.go
+30
-2
@@ -1,7 +1,11 @@
1
package policy
2
3
import (
4
+ "fmt"
5
+ "net"
6
"sync"
7
+
8
+ "github.com/gosuda/portal-tunnel/v2/utils"
9
)
10
11
type PortPolicy struct {
@@ -24,16 +28,24 @@ type Runtime struct {
28
bannedIdentityKeys map[string]struct{}
29
udp PortPolicy
30
tcpPort PortPolicy
31
+ trustProxyHeaders bool
32
+ trustedProxyCIDRs []*net.IPNet
33
mu sync.RWMutex
34
}
35
30
-func NewRuntime() *Runtime {
31
- return &Runtime{
36
+func NewRuntime(udpEnabled, tcpPortEnabled bool, trustProxyHeaders bool, rawTrustedProxyCIDRs string) (*Runtime, error) {
37
+ runtime := &Runtime{
38
approver: NewApprover(),
39
bpsManager: NewBPSManager(),
40
ipFilter: NewIPFilter(),
41
bannedIdentityKeys: make(map[string]struct{}),
42
}
43
+ runtime.udp.Set(udpEnabled, 0)
44
+ runtime.tcpPort.Set(tcpPortEnabled, 0)
45
+ if err := runtime.SetProxyTrust(trustProxyHeaders, rawTrustedProxyCIDRs); err != nil {
46
+ return nil, err
47
+ }
48
+ return runtime, nil
49
}
50
51
func (r *Runtime) Approver() *Approver {
@@ -148,6 +160,22 @@ func (r *Runtime) SetTCPPortPolicy(enabled bool, maxLeases int) {
160
r.mu.Unlock()
161
}
162
163
+func (r *Runtime) SetProxyTrust(trustProxyHeaders bool, rawTrustedProxyCIDRs string) error {
164
+ if r == nil {
165
+ return nil
166
+ }
167
+ trustedProxyCIDRs, err := utils.ParseCIDRs(rawTrustedProxyCIDRs)
168
+ if err != nil {
169
+ return fmt.Errorf("parse trusted proxy cidrs: %w", err)
170
+ }
171
+ copied := append([]*net.IPNet(nil), trustedProxyCIDRs...)
172
+ r.mu.Lock()
173
+ r.trustProxyHeaders = trustProxyHeaders
174
+ r.trustedProxyCIDRs = copied
175
+ r.mu.Unlock()
176
+ return nil
177
+}
178
+
179
func (r *Runtime) IsTCPPortEnabled() bool {
180
r.mu.RLock()
181
defer r.mu.RUnlock()
portal/server.go
+166
-225
@@ -10,6 +10,7 @@ import (
10
"net/http"
11
"strings"
12
"sync"
13
+ "sync/atomic"
14
"time"
15
16
"github.com/gosuda/keyless_tls/relay/l4"
@@ -37,190 +38,124 @@ const (
38
)
39
40
type ServerConfig struct {
40
- PortalURL string
41
- IdentityPath string
42
- Bootstraps []string
43
- WireGuardPrivateKey string
44
- DiscoveryPort int
45
- WireGuardEndpoint string
46
- WireGuardPublicKey string
47
- OverlayIPv4 string
48
- OverlayCIDRs []string
49
- ACME acme.Config
50
- APIPort int
51
- SNIPort int
52
- APIListenAddr string
53
- SNIListenAddr string
54
- TrustedProxyCIDRs string
55
- TrustProxyHeaders bool
56
- DiscoveryEnabled bool
57
- MinPort int
58
- MaxPort int
59
- UDPEnabled bool
60
- TCPEnabled bool
41
+ PortalURL string
42
+ IdentityPath string
43
+ Bootstraps []string
44
+ DiscoveryEnabled bool
45
+ WireGuardPort int
46
+ APIPort int
47
+ SNIPort int
48
+ APIListenAddr string
49
+ SNIListenAddr string
50
+ TrustProxyHeaders bool
51
+ TrustedProxyCIDRs string
52
+ UDPEnabled bool
53
+ TCPEnabled bool
54
+ MinPort int
55
+ MaxPort int
56
+ ACME acme.Config
57
}
58
63
-type Server struct {
64
- sniListener net.Listener
65
- apiListener net.Listener
66
- apiServer *http.Server
67
- apiTLSClose io.Closer
68
- acmeManager *acme.Manager
69
- quicTunnel *quic.Listener
70
- overlay *wireguard.Overlay
71
- cancel context.CancelFunc
72
- group *errgroup.Group
73
- registry *leaseRegistry
74
- ports *transport.PortAllocator
75
- tcpPorts *transport.PortAllocator
76
- loadMgr *policy.LoadManager
77
- identity types.Identity
78
- cfg ServerConfig
79
- trustedProxyCIDRs []*net.IPNet
80
- relaySet *discovery.RelaySet
81
- shutdownOnce sync.Once
82
-}
83
-
84
-func NewServer(cfg ServerConfig) (*Server, error) {
59
+func normalizeServerConfig(cfg ServerConfig) (ServerConfig, error) {
60
cfg.PortalURL = strings.TrimSuffix(strings.TrimSpace(cfg.PortalURL), "/")
86
- cfg.APIPort = utils.IntOrDefault(cfg.APIPort, 4017)
87
- cfg.SNIPort = utils.IntOrDefault(cfg.SNIPort, 443)
88
- cfg.APIListenAddr = utils.StringOrDefault(cfg.APIListenAddr, fmt.Sprintf(":%d", cfg.APIPort))
89
- cfg.SNIListenAddr = utils.StringOrDefault(cfg.SNIListenAddr, fmt.Sprintf(":%d", cfg.SNIPort))
90
- rootHost := utils.PortalRootHost(cfg.PortalURL)
91
- if rootHost == "" {
92
- return nil, errors.New("root host is required")
61
+ cfg.IdentityPath = utils.ResolveRelayStateDir(cfg.IdentityPath)
62
+ if cfg.IdentityPath == "" {
63
+ return ServerConfig{}, errors.New("identity path is required")
64
}
94
- trustedProxyCIDRs, err := utils.ParseCIDRs(cfg.TrustedProxyCIDRs)
65
+
66
+ selfRelayURL, err := utils.NormalizeRelayURL(cfg.PortalURL)
67
if err != nil {
96
- return nil, fmt.Errorf("parse trusted proxy cidrs: %w", err)
68
+ return ServerConfig{}, fmt.Errorf("normalize portal url: %w", err)
69
+ }
70
+ if utils.PortalRootHost(selfRelayURL) == "" {
71
+ return ServerConfig{}, errors.New("root host is required")
72
}
73
+
74
bootstraps, err := utils.NormalizeRelayURLs(cfg.Bootstraps...)
75
if err != nil {
100
- return nil, fmt.Errorf("normalize bootstraps: %w", err)
101
- }
102
- selfRelayURL := ""
103
- if trimmedPortalURL := strings.TrimSpace(cfg.PortalURL); trimmedPortalURL != "" {
104
- normalizedPortalURL, err := utils.NormalizeRelayURL(trimmedPortalURL)
105
- if err != nil {
106
- return nil, fmt.Errorf("normalize portal url: %w", err)
107
- }
108
- selfRelayURL = normalizedPortalURL
109
- }
110
- if len(bootstraps) > 0 {
111
- filtered := bootstraps[:0]
112
- for _, relayURL := range bootstraps {
113
- if selfRelayURL != "" && relayURL == selfRelayURL {
114
- continue
115
- }
116
- filtered = append(filtered, relayURL)
117
- }
118
- bootstraps = filtered
76
+ return ServerConfig{}, fmt.Errorf("normalize bootstraps: %w", err)
77
}
78
+ cfg.PortalURL = selfRelayURL
79
cfg.Bootstraps = bootstraps
121
- generatedWireGuardPrivateKey := ""
122
- if cfg.DiscoveryEnabled && strings.TrimSpace(cfg.WireGuardPrivateKey) == "" {
123
- generatedWireGuardPrivateKey, err = utils.GenerateWireGuardPrivateKey()
124
- if err != nil {
125
- return nil, err
126
- }
127
- cfg.WireGuardPrivateKey = generatedWireGuardPrivateKey
128
- }
129
- wgConfig, err := wireguard.NormalizeConfig(rootHost, wireguard.Config{
130
- PrivateKey: cfg.WireGuardPrivateKey,
131
- PublicKey: cfg.WireGuardPublicKey,
132
- Endpoint: cfg.WireGuardEndpoint,
133
- OverlayIPv4: cfg.OverlayIPv4,
134
- OverlayCIDRs: cfg.OverlayCIDRs,
135
- ListenPort: cfg.DiscoveryPort,
136
- })
137
- if err != nil {
138
- return nil, err
139
- }
140
- if generatedWireGuardPrivateKey != "" {
141
- log.Warn().
142
- Str("wireguard_public_key", wgConfig.PublicKey).
143
- Str("wireguard_private_key", generatedWireGuardPrivateKey).
144
- Msg("generated wireguard private key; set WIREGUARD_PRIVATE_KEY to preserve relay identity")
145
- }
146
- cfg.WireGuardPrivateKey = wgConfig.PrivateKey
147
- cfg.WireGuardPublicKey = wgConfig.PublicKey
148
- cfg.WireGuardEndpoint = wgConfig.Endpoint
149
- cfg.OverlayIPv4 = wgConfig.OverlayIPv4
150
- cfg.OverlayCIDRs = append([]string(nil), wgConfig.OverlayCIDRs...)
151
- transportEnabled := cfg.UDPEnabled || cfg.TCPEnabled
80
+ cfg.Bootstraps = utils.RemoveRelayURL(cfg.Bootstraps, selfRelayURL)
81
+
82
+ cfg.APIPort = utils.IntOrDefault(cfg.APIPort, 4017)
83
+ cfg.SNIPort = utils.IntOrDefault(cfg.SNIPort, 443)
84
+ cfg.APIListenAddr = utils.StringOrDefault(cfg.APIListenAddr, fmt.Sprintf(":%d", cfg.APIPort))
85
+ cfg.SNIListenAddr = utils.StringOrDefault(cfg.SNIListenAddr, fmt.Sprintf(":%d", cfg.SNIPort))
86
+
87
hasPortRange := cfg.MinPort > 0 && cfg.MaxPort > 0
153
- if transportEnabled {
88
+ if cfg.UDPEnabled || cfg.TCPEnabled {
89
switch {
90
case !hasPortRange:
156
- return nil, errors.New("udp and tcp relay transport require a valid min port and max port range")
91
+ return ServerConfig{}, errors.New("udp and tcp relay transport require a valid min port and max port range")
92
case cfg.MinPort > 65535 || cfg.MaxPort > 65535:
158
- return nil, errors.New("min port and max port must be between 1 and 65535")
93
+ return ServerConfig{}, errors.New("min port and max port must be between 1 and 65535")
94
case cfg.MinPort > cfg.MaxPort:
160
- return nil, errors.New("min port must be less than or equal to max port")
95
+ return ServerConfig{}, errors.New("min port must be less than or equal to max port")
96
}
97
}
98
99
cfg.UDPEnabled = cfg.UDPEnabled && hasPortRange
100
cfg.TCPEnabled = cfg.TCPEnabled && hasPortRange
101
+ return cfg, nil
102
+}
103
167
- portMin, portMax := 0, 0
168
- if cfg.UDPEnabled {
169
- portMin = cfg.MinPort
170
- portMax = cfg.MaxPort
104
+type Server struct {
105
+ cancel context.CancelFunc
106
+ group *errgroup.Group
107
+ shutdownOnce sync.Once
108
+
109
+ cfg ServerConfig
110
+ identity types.RelayIdentity
111
+ acmeManager *acme.Manager
112
+ activeConns atomic.Int64
113
+
114
+ apiListener net.Listener
115
+ sniListener net.Listener
116
+ apiServer *http.Server
117
+ apiTLSClose io.Closer
118
+ quicTunnel *quic.Listener
119
+
120
+ overlay *wireguard.Overlay
121
+ relaySet *discovery.RelaySet
122
+ registry *leaseRegistry
123
+ udpPorts *transport.PortAllocator
124
+ tcpPorts *transport.PortAllocator
125
+}
126
+
127
+func NewServer(cfg ServerConfig) (*Server, error) {
128
+ cfg, err := normalizeServerConfig(cfg)
129
+ if err != nil {
130
+ return nil, err
131
}
132
173
- identity, created, err := utils.LoadOrCreateIdentity(cfg.IdentityPath, types.Identity{Name: rootHost})
133
+ identity, err := utils.LoadOrCreateRelayIdentity(cfg.IdentityPath, utils.PortalRootHost(cfg.PortalURL), cfg.DiscoveryEnabled)
134
if err != nil {
135
return nil, fmt.Errorf("load relay identity: %w", err)
136
}
177
- if created {
178
- log.Warn().
179
- Str("identity_path", cfg.IdentityPath).
180
- Str("address", identity.Address).
181
- Msg("generated relay identity and saved it to disk")
182
- } else {
183
- log.Info().
184
- Str("identity_path", cfg.IdentityPath).
185
- Str("address", identity.Address).
186
- Msg("loaded relay identity from disk")
187
- }
188
-
189
- tcpPortMin, tcpPortMax := 0, 0
190
- if cfg.TCPEnabled {
191
- tcpPortMin = cfg.MinPort
192
- tcpPortMax = cfg.MaxPort
193
- }
194
-
195
- runtimePolicy := policy.NewRuntime()
196
- runtimePolicy.SetUDPPolicy(cfg.UDPEnabled, 0)
197
- runtimePolicy.SetTCPPortPolicy(cfg.TCPEnabled, 0)
198
- registry := newLeaseRegistry(runtimePolicy)
199
- ports := transport.NewPortAllocator(portMin, portMax, 5*time.Minute)
200
- tcpPorts := transport.NewPortAllocator(tcpPortMin, tcpPortMax, 5*time.Minute)
201
-
202
- s := &Server{
203
- cfg: cfg,
204
- registry: registry,
205
- ports: ports,
206
- tcpPorts: tcpPorts,
207
- loadMgr: policy.NewLoadManager(),
208
- identity: identity,
209
- trustedProxyCIDRs: trustedProxyCIDRs,
137
+ registry, err := newLeaseRegistry(cfg.UDPEnabled, cfg.TCPEnabled, cfg.TrustProxyHeaders, cfg.TrustedProxyCIDRs)
138
+ if err != nil {
139
+ return nil, err
140
}
141
+ var relaySet *discovery.RelaySet
142
if cfg.DiscoveryEnabled {
212
- set := discovery.NewRelaySet()
213
- if err := set.SetSelfRelay(identity, selfRelayURL); err != nil {
214
- return nil, fmt.Errorf("set self relay: %w", err)
215
- }
216
- if err := set.SetBootstrapRelayURLs(cfg.Bootstraps); err != nil {
143
+ relaySet, err = discovery.NewRelaySet(identity.Base(), cfg.PortalURL, cfg.Bootstraps)
144
+ if err != nil {
145
return nil, err
146
}
219
- s.relaySet = set
147
}
148
222
- return s, nil
149
+ return &Server{
150
+ cfg: cfg,
151
+ identity: identity,
152
+ registry: registry,
153
+ relaySet: relaySet,
154
+ udpPorts: transport.NewPortAllocator(cfg.MinPort, cfg.MaxPort, 5*time.Minute),
155
+ tcpPorts: transport.NewPortAllocator(cfg.MinPort, cfg.MaxPort, 5*time.Minute),
156
+ }, nil
157
}
158
+
159
func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
160
if s.group != nil {
161
return errors.New("server already started")
@@ -231,32 +166,66 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
166
}
167
168
serverCtx, cancel := context.WithCancel(ctx)
169
+ started := false
170
+ var apiListener net.Listener
171
+ var sniListener net.Listener
172
+ var apiServer *http.Server
173
+ var apiCloser io.Closer
174
+ var overlay *wireguard.Overlay
175
+ var quicTunnel *quic.Listener
176
+ defer func() {
177
+ if started {
178
+ return
179
+ }
180
+ acmeManager.Stop()
181
+ if overlay != nil {
182
+ _ = overlay.Shutdown(context.Background())
183
+ }
184
+ if apiServer != nil {
185
+ _ = apiServer.Close()
186
+ }
187
+ if apiCloser != nil {
188
+ _ = apiCloser.Close()
189
+ }
190
+ if sniListener != nil {
191
+ _ = sniListener.Close()
192
+ }
193
+ if apiListener != nil {
194
+ _ = apiListener.Close()
195
+ }
196
+ cancel()
197
+ }()
198
var listenConfig net.ListenConfig
199
236
- apiListener, err := listenConfig.Listen(serverCtx, "tcp", s.cfg.APIListenAddr)
200
+ apiListener, err = listenConfig.Listen(serverCtx, "tcp", s.cfg.APIListenAddr)
201
if err != nil {
238
- acmeManager.Stop()
239
- cancel()
202
return fmt.Errorf("listen api: %w", err)
203
}
242
- sniListener, err := listenConfig.Listen(serverCtx, "tcp", s.cfg.SNIListenAddr)
204
+ sniListener, err = listenConfig.Listen(serverCtx, "tcp", s.cfg.SNIListenAddr)
205
if err != nil {
244
- acmeManager.Stop()
245
- _ = apiListener.Close()
246
- cancel()
206
return fmt.Errorf("listen sni: %w", err)
207
}
208
209
group, groupCtx := errgroup.WithContext(serverCtx)
210
wrappedAPIListener, apiServer, apiCloser, err := s.newAPIServer(apiListener, apiMux, apiTLS)
211
if err != nil {
253
- acmeManager.Stop()
254
- _ = apiListener.Close()
255
- _ = sniListener.Close()
256
- cancel()
212
return err
213
}
214
215
+ if s.relaySet != nil && strings.TrimSpace(s.identity.WireGuardPrivateKey) != "" {
216
+ overlay, err = s.startOverlay()
217
+ if err != nil {
218
+ return err
219
+ }
220
+ }
221
+ if s.cfg.UDPEnabled {
222
+ quicTunnel, err = s.newQUICTunnelListener(apiTLS)
223
+ if err != nil {
224
+ log.Warn().Err(err).Msg("quic tunnel listener disabled")
225
+ quicTunnel = nil
226
+ }
227
+ }
228
+
229
s.apiListener = wrappedAPIListener
230
s.sniListener = sniListener
231
s.apiServer = apiServer
@@ -264,34 +233,23 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
233
s.acmeManager = acmeManager
234
s.cancel = cancel
235
s.group = group
267
-
268
- if s.relaySet != nil && strings.TrimSpace(s.cfg.WireGuardPrivateKey) != "" {
269
- if err := s.startOverlay(); err != nil {
270
- acmeManager.Stop()
271
- _ = apiServer.Close()
272
- _ = apiCloser.Close()
273
- _ = sniListener.Close()
274
- cancel()
275
- return err
276
- }
277
- }
236
+ s.overlay = overlay
237
+ s.quicTunnel = quicTunnel
238
+ started = true
239
240
group.Go(s.runAPIServer)
280
- if s.overlay != nil {
281
- group.Go(s.overlay.Serve)
282
- }
241
group.Go(func() error { return s.runSNIListener(groupCtx) })
242
group.Go(func() error { return s.runLeaseJanitor(groupCtx, 5*time.Second) })
243
if s.cfg.DiscoveryEnabled {
244
group.Go(func() error { return s.runRelayDiscoveryLoop(groupCtx) })
245
}
288
- s.acmeManager.Start(serverCtx)
289
-
290
- if s.cfg.UDPEnabled {
291
- if err := s.startQUICTunnelListener(apiTLS); err != nil {
292
- log.Warn().Err(err).Msg("quic tunnel listener disabled")
293
- }
246
+ if s.overlay != nil {
247
+ group.Go(s.overlay.Serve)
248
+ }
249
+ if s.quicTunnel != nil {
250
+ group.Go(s.runQUICTunnelListener)
251
}
252
+ s.acmeManager.Start(serverCtx)
253
group.Go(func() error {
254
<-groupCtx.Done()
255
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -307,8 +265,8 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
265
Int("min_port", s.cfg.MinPort).
266
Int("max_port", s.cfg.MaxPort).
267
Bool("discovery_enabled", s.cfg.DiscoveryEnabled).
310
- Bool("wireguard_enabled", s.wireGuardOverlayEnabled()).
311
- Bool("udp_enabled", s.cfg.UDPEnabled).
268
+ Bool("wireguard_enabled", s.overlay != nil).
269
+ Bool("udp_enabled", s.quicTunnel != nil).
270
Bool("tcp_enabled", s.cfg.TCPEnabled)
271
if s.quicTunnel != nil {
272
logEvent = logEvent.Str("internal_quic_tunnel_addr", s.quicTunnel.Addr().String())
@@ -329,9 +287,9 @@ func (s *Server) Wait() error {
287
return err
288
}
289
332
-func (s *Server) Identity() types.Identity {
290
+func (s *Server) RelayIdentity() types.RelayIdentity {
291
if s == nil {
334
- return types.Identity{}
292
+ return types.RelayIdentity{}
293
}
294
return s.identity.Copy()
295
}
@@ -402,13 +360,6 @@ func (s *Server) PortalURL() string {
360
return s.cfg.PortalURL
361
}
362
405
-func (s *Server) wireGuardOverlayEnabled() bool {
406
- if s == nil {
407
- return false
408
- }
409
- return strings.TrimSpace(s.cfg.WireGuardPrivateKey) != ""
410
-}
411
-
363
func (s *Server) LeaseSnapshots() []types.Lease {
364
s.registry.mu.RLock()
365
defer s.registry.mu.RUnlock()
@@ -602,13 +553,13 @@ func (s *Server) runLeaseJanitor(ctx context.Context, interval time.Duration) er
553
}
554
}
555
605
-func (s *Server) startQUICTunnelListener(apiTLS keyless.TLSMaterialConfig) error {
556
+func (s *Server) newQUICTunnelListener(apiTLS keyless.TLSMaterialConfig) (*quic.Listener, error) {
557
if len(apiTLS.KeyPEM) == 0 {
607
- return fmt.Errorf("quic tunnel requires api tls key")
558
+ return nil, fmt.Errorf("quic tunnel requires api tls key")
559
}
560
tlsCert, err := tls.X509KeyPair(apiTLS.CertPEM, apiTLS.KeyPEM)
561
if err != nil {
611
- return fmt.Errorf("parse quic tls keypair: %w", err)
562
+ return nil, fmt.Errorf("parse quic tls keypair: %w", err)
563
}
564
565
tlsConf := &tls.Config{
@@ -625,21 +576,17 @@ func (s *Server) startQUICTunnelListener(apiTLS keyless.TLSMaterialConfig) error
576
577
listener, err := quic.ListenAddr(s.cfg.SNIListenAddr, tlsConf, quicConf)
578
if err != nil {
628
- return fmt.Errorf("listen quic: %w", err)
579
+ return nil, fmt.Errorf("listen quic: %w", err)
580
}
630
-
631
- s.quicTunnel = listener
632
- s.group.Go(func() error { return s.runQUICTunnelListener(listener) })
633
-
634
- log.Info().
635
- Str("internal_quic_tunnel_addr", listener.Addr().String()).
636
- Msg("internal quic tunnel listener started")
637
- return nil
581
+ return listener, nil
582
}
583
640
-func (s *Server) runQUICTunnelListener(listener *quic.Listener) error {
584
+func (s *Server) runQUICTunnelListener() error {
585
+ if s.quicTunnel == nil {
586
+ return nil
587
+ }
588
for {
642
- conn, err := listener.Accept(context.Background())
589
+ conn, err := s.quicTunnel.Accept(context.Background())
590
if err != nil {
591
if errors.Is(err, quic.ErrServerClosed) || errors.Is(err, net.ErrClosed) {
592
return nil
@@ -650,7 +597,7 @@ func (s *Server) runQUICTunnelListener(listener *quic.Listener) error {
597
}
598
}
599
653
-func (s *Server) startOverlay() error {
600
+func (s *Server) startOverlay() (*wireguard.Overlay, error) {
601
peerMux := http.NewServeMux()
602
peerMux.HandleFunc(types.PathRoot, s.handleRoot)
603
peerMux.HandleFunc(types.PathHealthz, s.handleHealthz)
@@ -658,25 +605,21 @@ func (s *Server) startOverlay() error {
605
peerMux.HandleFunc(types.PathDiscovery, s.handleRelayDiscovery)
606
}
607
661
- overlay, err := wireguard.NewOverlay(wireguard.Config{
662
- PrivateKey: s.cfg.WireGuardPrivateKey,
663
- PublicKey: s.cfg.WireGuardPublicKey,
664
- Endpoint: s.cfg.WireGuardEndpoint,
665
- OverlayIPv4: s.cfg.OverlayIPv4,
666
- OverlayCIDRs: s.cfg.OverlayCIDRs,
667
- ListenPort: s.cfg.DiscoveryPort,
608
+ overlay, err := wireguard.NewOverlay(s.identity.Name, wireguard.Config{
609
+ PrivateKey: s.identity.WireGuardPrivateKey,
610
+ PublicKey: s.identity.WireGuardPublicKey,
611
+ Endpoint: net.JoinHostPort(s.identity.Name, fmt.Sprintf("%d", utils.IntOrDefault(s.cfg.WireGuardPort, wireguard.DefaultListenPort))),
612
}, peerMux)
613
if err != nil {
670
- return fmt.Errorf("start wireguard overlay: %w", err)
614
+ return nil, fmt.Errorf("start wireguard overlay: %w", err)
615
}
616
617
if err := overlay.Sync(s.relaySet.View()); err != nil {
618
_ = overlay.Shutdown(context.Background())
675
- return fmt.Errorf("sync wireguard peers: %w", err)
619
+ return nil, fmt.Errorf("sync wireguard peers: %w", err)
620
}
621
678
- s.overlay = overlay
679
- return nil
622
+ return overlay, nil
623
}
624
625
func (s *Server) runRelayDiscoveryLoop(ctx context.Context) error {
@@ -711,22 +654,20 @@ func (s *Server) runRelayDiscoveryLoop(ctx context.Context) error {
654
}
655
656
func (s *Server) BridgeConns(left, right net.Conn) {
714
- s.loadMgr.RecordConnStart()
715
- defer s.loadMgr.RecordConnEnd()
657
+ s.activeConns.Add(1)
658
+ defer s.activeConns.Add(-1)
659
660
defer left.Close()
661
defer right.Close()
662
663
var group errgroup.Group
664
group.Go(func() error {
722
- n, err := io.Copy(right, left)
723
- s.loadMgr.RecordBytesIn(n)
665
+ _, err := io.Copy(right, left)
666
closeWrite(right)
667
return err
668
})
669
group.Go(func() error {
728
- n, err := io.Copy(left, right)
729
- s.loadMgr.RecordBytesOut(n)
670
+ _, err := io.Copy(left, right)
671
closeWrite(left)
672
return err
673
})
portal/server_test.go
+2
-2
@@ -60,7 +60,7 @@ func applyRelay(t *testing.T, set *discovery.RelaySet, identity types.Identity,
60
61
func tempIdentityPath(t *testing.T) string {
62
t.Helper()
63
- return filepath.Join(t.TempDir(), "relay_identity.json")
63
+ return t.TempDir()
64
}
65
66
func newTestClient(t *testing.T, cancel context.CancelFunc, server *Server) *http.Client {
@@ -541,7 +541,7 @@ func TestServerDiscoverySkipsSelfRelayHint(t *testing.T) {
541
now := time.Now().UTC()
542
bootstrapDesc := mustRelayDescriptor(t, "https://bootstrap.example.com")
543
selfHint, err := discovery.NormalizeDescriptor(types.RelayDescriptor{
544
- Identity: server.identity.Copy(),
544
+ Identity: server.identity.Base(),
545
RelayID: "https://self-mirror.example.com",
546
Sequence: uint64(now.UnixMilli()),
547
Version: 1,
portal/wireguard/overlay.go
+34
-13
@@ -28,7 +28,16 @@ type Config struct {
28
Endpoint string
29
OverlayIPv4 string
30
OverlayCIDRs []string
31
- ListenPort int
31
+}
32
+
33
+func (c Config) Copy() Config {
34
+ return Config{
35
+ PrivateKey: c.PrivateKey,
36
+ PublicKey: c.PublicKey,
37
+ Endpoint: c.Endpoint,
38
+ OverlayIPv4: c.OverlayIPv4,
39
+ OverlayCIDRs: append([]string(nil), c.OverlayCIDRs...),
40
+ }
41
}
42
43
func NormalizeConfig(rootHost string, cfg Config) (Config, error) {
@@ -59,7 +68,6 @@ func NormalizeConfig(rootHost string, cfg Config) (Config, error) {
68
69
cfg.PrivateKey = privateKey
70
cfg.PublicKey = publicKey
62
- cfg.ListenPort = utils.IntOrDefault(cfg.ListenPort, DefaultListenPort)
71
if len(cfg.OverlayCIDRs) > 0 {
72
cfg.OverlayCIDRs, err = utils.NormalizeOverlayCIDRs(cfg.OverlayCIDRs)
73
if err != nil {
@@ -67,7 +75,7 @@ func NormalizeConfig(rootHost string, cfg Config) (Config, error) {
75
}
76
}
77
if strings.TrimSpace(cfg.Endpoint) == "" {
70
- cfg.Endpoint = net.JoinHostPort(rootHost, fmt.Sprintf("%d", cfg.ListenPort))
78
+ cfg.Endpoint = net.JoinHostPort(rootHost, fmt.Sprintf("%d", DefaultListenPort))
79
}
80
if strings.TrimSpace(cfg.OverlayIPv4) == "" {
81
cfg.OverlayIPv4, err = utils.DeriveWireGuardOverlayIPv4(cfg.PublicKey)
@@ -85,13 +93,17 @@ func NormalizeConfig(rootHost string, cfg Config) (Config, error) {
93
}
94
95
type Overlay struct {
88
- publicKey string
89
- stack *stack
90
- listener net.Listener
91
- server *http.Server
96
+ cfg Config
97
+ stack *stack
98
+ listener net.Listener
99
+ server *http.Server
100
}
101
94
-func NewOverlay(cfg Config, handler http.Handler) (*Overlay, error) {
102
+func NewOverlay(rootHost string, cfg Config, handler http.Handler) (*Overlay, error) {
103
+ cfg, err := NormalizeConfig(rootHost, cfg)
104
+ if err != nil {
105
+ return nil, err
106
+ }
107
publicKey := strings.TrimSpace(cfg.PublicKey)
108
if publicKey == "" {
109
return nil, errors.New("wireguard public key is required")
@@ -113,14 +125,23 @@ func NewOverlay(cfg Config, handler http.Handler) (*Overlay, error) {
125
ReadHeaderTimeout: 10 * time.Second,
126
}
127
128
+ publicCfg := cfg.Copy()
129
+ publicCfg.PrivateKey = ""
130
return &Overlay{
117
- publicKey: publicKey,
118
- stack: stack,
119
- listener: listener,
120
- server: server,
131
+ cfg: publicCfg,
132
+ stack: stack,
133
+ listener: listener,
134
+ server: server,
135
}, nil
136
}
137
138
+func (o *Overlay) Config() Config {
139
+ if o == nil {
140
+ return Config{}
141
+ }
142
+ return o.cfg.Copy()
143
+}
144
+
145
func (o *Overlay) Serve() error {
146
if o == nil || o.server == nil || o.listener == nil {
147
return nil
@@ -192,7 +213,7 @@ func (o *Overlay) Sync(view map[string]discovery.RelayState) error {
213
if o == nil || o.stack == nil {
214
return nil
215
}
195
- return o.stack.ApplyPeers(peersForView(o.publicKey, view))
216
+ return o.stack.ApplyPeers(peersForView(o.cfg.PublicKey, view))
217
}
218
219
func peersForView(publicKey string, view map[string]discovery.RelayState) []desiredPeer {
sdk/expose.go
+5
-5
@@ -93,6 +93,10 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
93
return nil, fmt.Errorf("invalid --udp-addr value %q: %w", cfg.UDPAddr, err)
94
}
95
}
96
+ relaySet, err := discovery.NewRelaySet(types.Identity{}, "", relayURLs)
97
+ if err != nil {
98
+ return nil, err
99
+ }
100
101
exposureCtx, cancel := context.WithCancel(ctx)
102
exposure := &Exposure{
@@ -108,15 +112,11 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
112
rootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
113
accepted: make(chan net.Conn, max(len(relayURLs)*defaultReadyTarget*2, 1)),
114
datagrams: make(chan types.DatagramFrame, max(len(relayURLs)*32, 1)),
111
- relaySet: discovery.NewRelaySet(),
115
+ relaySet: relaySet,
116
relayListeners: make(map[string]*Listener, len(relayURLs)),
117
}
118
119
if len(relayURLs) > 0 {
116
- if err := exposure.relaySet.SetBootstrapRelayURLs(relayURLs); err != nil {
117
- _ = exposure.Close()
118
- return nil, err
119
- }
120
if err := exposure.reconcileRelayListeners(true); err != nil {
121
_ = exposure.Close()
122
return nil, err
sdk/expose_test.go
+14
-10
@@ -9,6 +9,16 @@ import (
9
"github.com/gosuda/portal-tunnel/v2/types"
10
)
11
12
+func mustRelaySet(t *testing.T, relayURLs ...string) *discovery.RelaySet {
13
+ t.Helper()
14
+
15
+ set, err := discovery.NewRelaySet(types.Identity{}, "", relayURLs)
16
+ if err != nil {
17
+ t.Fatalf("NewRelaySet() error = %v", err)
18
+ }
19
+ return set
20
+}
21
+
22
func mustRelayDescriptor(t *testing.T, relayName, relayURL string) types.RelayDescriptor {
23
t.Helper()
24
@@ -55,12 +65,9 @@ func TestExposureBanRelayURLMovesRelay(t *testing.T) {
65
}
66
67
exposure := &Exposure{
58
- relaySet: discovery.NewRelaySet(),
68
+ relaySet: mustRelaySet(t, relayA, relayB),
69
relayListeners: make(map[string]*Listener, 2),
70
}
61
- if err := exposure.relaySet.SetBootstrapRelayURLs([]string{relayA, relayB}); err != nil {
62
- t.Fatalf("SetBootstrapRelayURLs() error = %v", err)
63
- }
71
exposure.relayListeners = map[string]*Listener{
72
relayA: listener,
73
relayB: {},
@@ -94,7 +101,7 @@ func TestExposureSetRelayURLsSkipsBannedRelay(t *testing.T) {
101
)
102
103
exposure := &Exposure{
97
- relaySet: discovery.NewRelaySet(),
104
+ relaySet: mustRelaySet(t),
105
relayListeners: make(map[string]*Listener, 1),
106
}
107
exposure.relaySet.BanRelayURL(relayB)
@@ -134,12 +141,9 @@ func TestExposureSetRelayURLsRemovesStaleListener(t *testing.T) {
141
142
relayAClosed := make(chan struct{})
143
exposure := &Exposure{
137
- relaySet: discovery.NewRelaySet(),
144
+ relaySet: mustRelaySet(t, relayA, relayB),
145
relayListeners: make(map[string]*Listener, 2),
146
}
140
- if err := exposure.relaySet.SetBootstrapRelayURLs([]string{relayA, relayB}); err != nil {
141
- t.Fatalf("SetBootstrapRelayURLs() error = %v", err)
142
- }
147
exposure.relayListeners = map[string]*Listener{
148
relayA: {
149
api: &apiClient{baseURL: relayAURL},
@@ -181,7 +185,7 @@ func TestExposureSetRelayURLsRemovesStaleListener(t *testing.T) {
185
}
186
187
func TestExposurePinDiscoveredDescriptorAllowsURLChangeForSameIdentity(t *testing.T) {
184
- exposure := &Exposure{relaySet: discovery.NewRelaySet()}
188
+ exposure := &Exposure{relaySet: mustRelaySet(t)}
189
desc := mustRelayDescriptor(t, "relay-a", "https://relay-a.example")
190
191
if err := applyRelayDiscovery(t, exposure.relaySet, desc.Identity, desc.APIHTTPSAddr, types.DiscoveryResponse{ProtocolVersion: types.ProtocolVersion, Self: desc}, time.Now().UTC()); err != nil {
sdk/mitm_test.go
+2
-9
@@ -18,7 +18,6 @@ import (
18
"testing"
19
"time"
20
21
- "github.com/gosuda/portal-tunnel/v2/portal/discovery"
21
"github.com/gosuda/portal-tunnel/v2/types"
22
)
23
@@ -209,7 +208,7 @@ func TestMITMProbeDetectionBansListener(t *testing.T) {
208
209
listener := &Listener{
210
api: &apiClient{baseURL: relayURL},
212
- relaySet: discovery.NewRelaySet(),
211
+ relaySet: mustRelaySet(t, relayURL.String()),
212
cancel: func() {
213
select {
214
case <-doneCh:
@@ -221,9 +220,6 @@ func TestMITMProbeDetectionBansListener(t *testing.T) {
220
registered: make(chan struct{}),
221
banMITM: true,
222
}
224
- if err := listener.relaySet.SetBootstrapRelayURLs([]string{relayURL.String()}); err != nil {
225
- t.Fatalf("SetBootstrapRelayURLs() error = %v", err)
226
- }
223
listener.mitmManager = newMITMManager(context.Background(), listener)
224
225
listener.mitmManager.logResult(MITMProbeReport{
@@ -251,14 +247,11 @@ func TestMITMProbeDetectionWarnsWithoutBanningListener(t *testing.T) {
247
248
listener := &Listener{
249
api: &apiClient{baseURL: relayURL},
254
- relaySet: discovery.NewRelaySet(),
250
+ relaySet: mustRelaySet(t, relayURL.String()),
251
doneCh: doneCh,
252
registered: make(chan struct{}),
253
banMITM: false,
254
}
259
- if err := listener.relaySet.SetBootstrapRelayURLs([]string{relayURL.String()}); err != nil {
260
- t.Fatalf("SetBootstrapRelayURLs() error = %v", err)
261
- }
255
listener.mitmManager = newMITMManager(context.Background(), listener)
256
257
listener.mitmManager.logResult(MITMProbeReport{
types/identity.go
+25
-1
@@ -5,6 +5,12 @@ import (
5
"time"
6
)
7
8
+const (
9
+ IdentityKeySeparator = ":"
10
+ RelayIdentityFilename = "identity.json"
11
+ RelayAdminSettingsFilename = "admin_settings.json"
12
+)
13
+
14
type Identity struct {
15
Name string `json:"name,omitempty"`
16
Address string `json:"address,omitempty"`
@@ -21,7 +27,25 @@ func (i Identity) Copy() Identity {
27
}
28
}
29
24
-const IdentityKeySeparator = ":"
30
+type RelayIdentity struct {
31
+ Identity
32
+ AdminSecretKey string `json:"-"`
33
+ WireGuardPublicKey string `json:"-"`
34
+ WireGuardPrivateKey string `json:"-"`
35
+}
36
+
37
+func (i RelayIdentity) Copy() RelayIdentity {
38
+ return RelayIdentity{
39
+ Identity: i.Identity.Copy(),
40
+ AdminSecretKey: i.AdminSecretKey,
41
+ WireGuardPublicKey: i.WireGuardPublicKey,
42
+ WireGuardPrivateKey: i.WireGuardPrivateKey,
43
+ }
44
+}
45
+
46
+func (i RelayIdentity) Base() Identity {
47
+ return i.Identity.Copy()
48
+}
49
50
func (i Identity) Key() string {
51
name := strings.TrimSpace(strings.ToLower(i.Name))
utils/identity.go
+216
-10
@@ -7,6 +7,7 @@ import (
7
"net"
8
"net/url"
9
"os"
10
+ "path/filepath"
11
"strings"
12
13
"github.com/gosuda/portal-tunnel/v2/types"
@@ -29,6 +30,35 @@ func NormalizeIdentity(identity types.Identity) (types.Identity, error) {
30
return normalized, nil
31
}
32
33
+func ResolveRelayStateDir(path string) string {
34
+ trimmed := strings.TrimSpace(path)
35
+ if trimmed == "" {
36
+ return ""
37
+ }
38
+ switch strings.ToLower(filepath.Base(trimmed)) {
39
+ case types.RelayIdentityFilename, types.RelayAdminSettingsFilename:
40
+ return filepath.Dir(trimmed)
41
+ default:
42
+ return trimmed
43
+ }
44
+}
45
+
46
+func ResolveRelayIdentityPath(path string) string {
47
+ stateDir := ResolveRelayStateDir(path)
48
+ if stateDir == "" {
49
+ return ""
50
+ }
51
+ return filepath.Join(stateDir, types.RelayIdentityFilename)
52
+}
53
+
54
+func ResolveRelayAdminSettingsPath(path string) string {
55
+ stateDir := ResolveRelayStateDir(path)
56
+ if stateDir == "" {
57
+ return ""
58
+ }
59
+ return filepath.Join(stateDir, types.RelayAdminSettingsFilename)
60
+}
61
+
62
func NormalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
63
normalized := identity.Copy()
64
normalized.Name = strings.TrimSpace(normalized.Name)
@@ -36,7 +66,8 @@ func NormalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
66
normalized.PublicKey = strings.TrimSpace(normalized.PublicKey)
67
normalized.PrivateKey = strings.TrimSpace(normalized.PrivateKey)
68
39
- if normalized.PrivateKey != "" {
69
+ switch {
70
+ case normalized.PrivateKey != "":
71
resolved, err := ResolveSecp256k1Identity(normalized.PrivateKey)
72
if err != nil {
73
return types.Identity{}, err
@@ -50,10 +81,7 @@ func NormalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
81
normalized.Address = resolved.Address
82
normalized.PublicKey = resolved.PublicKey
83
normalized.PrivateKey = resolved.PrivateKey
53
- return normalized, nil
54
- }
55
-
56
- if normalized.PublicKey != "" {
84
+ case normalized.PublicKey != "":
85
address, err := AddressFromCompressedPublicKeyHex(normalized.PublicKey)
86
if err != nil {
87
return types.Identity{}, err
@@ -61,16 +89,13 @@ func NormalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
89
normalized.PublicKey = strings.ToLower(TrimHexPrefix(normalized.PublicKey))
90
if normalized.Address == "" {
91
normalized.Address = address
64
- return normalized, nil
92
+ break
93
}
94
if !strings.EqualFold(normalized.Address, address) {
95
return types.Identity{}, errors.New("identity address does not match public key")
96
}
97
normalized.Address = address
70
- return normalized, nil
71
- }
72
-
73
- if normalized.Address != "" {
98
+ case normalized.Address != "":
99
address, err := NormalizeEVMAddress(normalized.Address)
100
if err != nil {
101
return types.Identity{}, err
@@ -80,6 +105,45 @@ func NormalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
105
return normalized, nil
106
}
107
108
+func NormalizeStoredRelayIdentity(identity types.RelayIdentity) (types.RelayIdentity, error) {
109
+ normalized := identity.Copy()
110
+ baseIdentity, err := NormalizeStoredIdentity(normalized.Identity)
111
+ if err != nil {
112
+ return types.RelayIdentity{}, err
113
+ }
114
+ normalized.Identity = baseIdentity
115
+ normalized.AdminSecretKey = strings.TrimSpace(normalized.AdminSecretKey)
116
+ normalized.WireGuardPublicKey = strings.TrimSpace(normalized.WireGuardPublicKey)
117
+ normalized.WireGuardPrivateKey = strings.TrimSpace(normalized.WireGuardPrivateKey)
118
+
119
+ switch {
120
+ case normalized.WireGuardPrivateKey != "":
121
+ privateKey, err := NormalizeWireGuardPrivateKey(normalized.WireGuardPrivateKey)
122
+ if err != nil {
123
+ return types.RelayIdentity{}, fmt.Errorf("normalize wireguard private key: %w", err)
124
+ }
125
+ publicKey, err := WireGuardPublicKeyFromPrivate(privateKey)
126
+ if err != nil {
127
+ return types.RelayIdentity{}, fmt.Errorf("derive wireguard public key: %w", err)
128
+ }
129
+ if configuredPublicKey := strings.TrimSpace(normalized.WireGuardPublicKey); configuredPublicKey != "" {
130
+ if err := ValidateWireGuardPublicKey(configuredPublicKey); err != nil {
131
+ return types.RelayIdentity{}, err
132
+ }
133
+ if configuredPublicKey != publicKey {
134
+ return types.RelayIdentity{}, errors.New("identity wireguard public key does not match private key")
135
+ }
136
+ }
137
+ normalized.WireGuardPrivateKey = privateKey
138
+ normalized.WireGuardPublicKey = publicKey
139
+ case normalized.WireGuardPublicKey != "":
140
+ if err := ValidateWireGuardPublicKey(normalized.WireGuardPublicKey); err != nil {
141
+ return types.RelayIdentity{}, err
142
+ }
143
+ }
144
+ return normalized, nil
145
+}
146
+
147
type storedIdentity struct {
148
Name string `json:"name,omitempty"`
149
Address string `json:"address,omitempty"`
@@ -87,6 +151,13 @@ type storedIdentity struct {
151
PrivateKey string `json:"private_key,omitempty"`
152
}
153
154
+type storedRelayIdentity struct {
155
+ storedIdentity
156
+ AdminSecretKey string `json:"admin_secret_key,omitempty"`
157
+ WireGuardPublicKey string `json:"wireguard_public_key,omitempty"`
158
+ WireGuardPrivateKey string `json:"wireguard_private_key,omitempty"`
159
+}
160
+
161
func SaveIdentity(path string, identity types.Identity) error {
162
path = strings.TrimSpace(path)
163
if path == "" {
@@ -107,6 +178,31 @@ func SaveIdentity(path string, identity types.Identity) error {
178
return nil
179
}
180
181
+func SaveRelayIdentity(path string, identity types.RelayIdentity) error {
182
+ path = ResolveRelayIdentityPath(path)
183
+ if path == "" {
184
+ return errors.New("identity path is required")
185
+ }
186
+ normalized, err := NormalizeStoredRelayIdentity(identity)
187
+ if err != nil {
188
+ return err
189
+ }
190
+ if err := WriteJSONFile(path, storedRelayIdentity{
191
+ storedIdentity: storedIdentity{
192
+ Name: normalized.Name,
193
+ Address: normalized.Address,
194
+ PublicKey: normalized.PublicKey,
195
+ PrivateKey: normalized.PrivateKey,
196
+ },
197
+ AdminSecretKey: normalized.AdminSecretKey,
198
+ WireGuardPublicKey: normalized.WireGuardPublicKey,
199
+ WireGuardPrivateKey: normalized.WireGuardPrivateKey,
200
+ }, 0o600); err != nil {
201
+ return fmt.Errorf("write identity file: %w", err)
202
+ }
203
+ return nil
204
+}
205
+
206
func LoadIdentity(path string) (types.Identity, error) {
207
path = strings.TrimSpace(path)
208
if path == "" {
@@ -124,6 +220,28 @@ func LoadIdentity(path string) (types.Identity, error) {
220
})
221
}
222
223
+func LoadRelayIdentity(path string) (types.RelayIdentity, error) {
224
+ path = ResolveRelayIdentityPath(path)
225
+ if path == "" {
226
+ return types.RelayIdentity{}, errors.New("identity path is required")
227
+ }
228
+ var payload storedRelayIdentity
229
+ if err := ReadJSONFile(path, &payload); err != nil {
230
+ return types.RelayIdentity{}, fmt.Errorf("read identity file: %w", err)
231
+ }
232
+ return NormalizeStoredRelayIdentity(types.RelayIdentity{
233
+ Identity: types.Identity{
234
+ Name: payload.Name,
235
+ Address: payload.Address,
236
+ PublicKey: payload.PublicKey,
237
+ PrivateKey: payload.PrivateKey,
238
+ },
239
+ AdminSecretKey: payload.AdminSecretKey,
240
+ WireGuardPublicKey: payload.WireGuardPublicKey,
241
+ WireGuardPrivateKey: payload.WireGuardPrivateKey,
242
+ })
243
+}
244
+
245
func ParseIdentityJSON(raw string) (types.Identity, error) {
246
raw = strings.TrimSpace(raw)
247
if raw == "" {
@@ -200,6 +318,94 @@ func LoadOrCreateIdentity(path string, identity types.Identity) (types.Identity,
318
return loaded, true, nil
319
}
320
321
+func LoadOrCreateRelayIdentity(path, rootHost string, discoveryEnabled bool) (types.RelayIdentity, error) {
322
+ path = ResolveRelayIdentityPath(path)
323
+ if path == "" {
324
+ return types.RelayIdentity{}, errors.New("identity path is required")
325
+ }
326
+ rootHost = strings.TrimSpace(rootHost)
327
+ if normalizedRootHost := PortalRootHost(rootHost); normalizedRootHost != "" {
328
+ rootHost = normalizedRootHost
329
+ } else {
330
+ rootHost = NormalizeHostname(rootHost)
331
+ }
332
+
333
+ stored, err := LoadRelayIdentity(path)
334
+ switch {
335
+ case err == nil:
336
+ if rootHost != "" {
337
+ stored.Name = rootHost
338
+ }
339
+
340
+ if err := populateRelayIdentity(&stored, discoveryEnabled); err != nil {
341
+ return types.RelayIdentity{}, err
342
+ }
343
+ if err := SaveRelayIdentity(path, stored); err != nil {
344
+ return types.RelayIdentity{}, fmt.Errorf("persist identity: %w", err)
345
+ }
346
+ loaded, err := LoadRelayIdentity(path)
347
+ if err != nil {
348
+ return types.RelayIdentity{}, fmt.Errorf("load identity: %w", err)
349
+ }
350
+ return loaded, nil
351
+ case !errors.Is(err, os.ErrNotExist):
352
+ return types.RelayIdentity{}, fmt.Errorf("load identity: %w", err)
353
+ }
354
+
355
+ created := types.RelayIdentity{
356
+ Identity: types.Identity{Name: rootHost},
357
+ }
358
+ generated, err := ResolveSecp256k1Identity(created.PrivateKey)
359
+ if err != nil {
360
+ return types.RelayIdentity{}, fmt.Errorf("generate identity: %w", err)
361
+ }
362
+ if strings.TrimSpace(created.Address) == "" {
363
+ created.Address = generated.Address
364
+ }
365
+ if strings.TrimSpace(created.PublicKey) == "" {
366
+ created.PublicKey = generated.PublicKey
367
+ }
368
+ created.PrivateKey = generated.PrivateKey
369
+
370
+ if err := populateRelayIdentity(&created, discoveryEnabled); err != nil {
371
+ return types.RelayIdentity{}, err
372
+ }
373
+ if err := SaveRelayIdentity(path, created); err != nil {
374
+ return types.RelayIdentity{}, fmt.Errorf("persist identity: %w", err)
375
+ }
376
+ loaded, err := LoadRelayIdentity(path)
377
+ if err != nil {
378
+ return types.RelayIdentity{}, fmt.Errorf("load identity: %w", err)
379
+ }
380
+ return loaded, nil
381
+}
382
+
383
+func populateRelayIdentity(identity *types.RelayIdentity, discoveryEnabled bool) error {
384
+ if identity == nil {
385
+ return errors.New("relay identity is required")
386
+ }
387
+
388
+ if strings.TrimSpace(identity.AdminSecretKey) == "" {
389
+ var err error
390
+ adminSecretKey, err := RandomHex(16)
391
+ if err != nil {
392
+ return fmt.Errorf("generate relay admin secret key: %w", err)
393
+ }
394
+ identity.AdminSecretKey = adminSecretKey
395
+ }
396
+
397
+ if discoveryEnabled && strings.TrimSpace(identity.WireGuardPrivateKey) == "" {
398
+ var err error
399
+ wireGuardPrivateKey, err := GenerateWireGuardPrivateKey()
400
+ if err != nil {
401
+ return fmt.Errorf("generate relay wireguard private key: %w", err)
402
+ }
403
+ identity.WireGuardPrivateKey = wireGuardPrivateKey
404
+ }
405
+
406
+ return nil
407
+}
408
+
409
func ResolveListenerIdentity(identity types.Identity, target, identityPath, identityJSON string) (types.Identity, bool, error) {
410
identityPath = strings.TrimSpace(identityPath)
411
identityJSON = strings.TrimSpace(identityJSON)