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)