| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "flag" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "os" |
| 10 | "strings" |
| 11 | |
| 12 | "github.com/rs/zerolog" |
| 13 | "github.com/rs/zerolog/log" |
| 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/identity" |
| 18 | "github.com/gosuda/portal-tunnel/v2/portal/overlay" |
| 19 | portalx402 "github.com/gosuda/portal-tunnel/v2/portal/x402" |
| 20 | "github.com/gosuda/portal-tunnel/v2/types" |
| 21 | "github.com/gosuda/portal-tunnel/v2/utils" |
| 22 | ) |
| 23 | |
| 24 | func main() { |
| 25 | log.Logger = log.Output(zerolog.NewConsoleWriter()) |
| 26 | if err := utils.RunCommands(os.Args[1:], os.Stdout, os.Stderr, printRootUsage, map[string]utils.CommandFunc{ |
| 27 | "": runServeCommand, |
| 28 | "serve": runServeCommand, |
| 29 | "help": runHelpCommand, |
| 30 | }); err != nil { |
| 31 | log.Error().Err(err).Msg("execute root command") |
| 32 | os.Exit(1) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | type relayServerConfig struct { |
| 37 | PortalURL string |
| 38 | IdentityPath string |
| 39 | Bootstraps string |
| 40 | DiscoveryEnabled bool |
| 41 | WireGuardPort int |
| 42 | APIPort int |
| 43 | SNIPort int |
| 44 | TrustProxyHeaders bool |
| 45 | TrustedProxyCIDRs string |
| 46 | UDPEnabled bool |
| 47 | TCPEnabled bool |
| 48 | MinPort int |
| 49 | MaxPort int |
| 50 | AdminToken string |
| 51 | PProfEnabled bool |
| 52 | PProfAddr string |
| 53 | X402Enabled bool |
| 54 | X402Testnet bool |
| 55 | X402PayTo string |
| 56 | |
| 57 | ACMEDNSProvider string |
| 58 | ENSGaslessEnabled bool |
| 59 | CloudflareToken string |
| 60 | GCPProjectID string |
| 61 | GCPManagedZone string |
| 62 | HetznerAPIToken string |
| 63 | AWSAccessKeyID string |
| 64 | AWSSecretAccessKey string |
| 65 | AWSSessionToken string |
| 66 | AWSRegion string |
| 67 | AWSHostedZoneID string |
| 68 | AWSDNSSECKMSKeyARN string |
| 69 | VultrAPIKey string |
| 70 | NjallaToken string |
| 71 | } |
| 72 | |
| 73 | func runServeCommand(args []string) error { |
| 74 | cfg := relayServerConfig{} |
| 75 | fs := utils.NewFlagSet("relay-server", printRootUsage) |
| 76 | |
| 77 | utils.StringFlagEnv(fs, &cfg.PortalURL, "portal-url", "https://localhost:4017", "portal base URL", "PORTAL_URL") |
| 78 | utils.StringFlagEnv(fs, &cfg.IdentityPath, "identity-path", "./.portal-certs", "directory path for relay identity, policy state, and keyless materials", "IDENTITY_PATH") |
| 79 | utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "bootstrap relay API URLs; merged with bootstrap relays when discovery is enabled", "BOOTSTRAPS") |
| 80 | utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY") |
| 81 | utils.IntFlagEnv(fs, &cfg.WireGuardPort, "wireguard-port", overlay.DefaultListenPort, utils.ParsePortNumber, "public and listen UDP port for relay overlay", "WIREGUARD_PORT") |
| 82 | |
| 83 | utils.IntFlagEnv(fs, &cfg.APIPort, "api-port", 4017, utils.ParsePortNumber, "Admin/API server port", "API_PORT") |
| 84 | utils.IntFlagEnv(fs, &cfg.SNIPort, "sni-port", 443, utils.ParsePortNumber, "TCP SNI router port number", "SNI_PORT") |
| 85 | utils.BoolFlagEnv(fs, &cfg.TrustProxyHeaders, "trust-proxy-headers", false, "trust X-Forwarded-* and X-Real-IP headers from trusted proxies", "TRUST_PROXY_HEADERS") |
| 86 | 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") |
| 87 | |
| 88 | utils.BoolFlagEnv(fs, &cfg.UDPEnabled, "udp-enabled", false, "enable UDP relay transport; requires a valid --min-port/--max-port range", "UDP_ENABLED") |
| 89 | utils.BoolFlagEnv(fs, &cfg.TCPEnabled, "tcp-enabled", false, "enable raw TCP port transport; requires a valid --min-port/--max-port range", "TCP_ENABLED") |
| 90 | 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") |
| 91 | 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") |
| 92 | |
| 93 | utils.StringFlagEnv(fs, &cfg.AdminToken, "admin-token", "", "admin bearer token for relay admin and policy APIs", "ADMIN_TOKEN") |
| 94 | utils.BoolFlagEnv(fs, &cfg.PProfEnabled, "pprof-enabled", false, "enable pprof diagnostics HTTP server", "PPROF_ENABLED") |
| 95 | utils.StringFlagEnv(fs, &cfg.PProfAddr, "pprof-addr", portal.DefaultPProfListenAddr, "pprof diagnostics listen address when enabled", "PPROF_ADDR") |
| 96 | utils.BoolFlagEnv(fs, &cfg.X402Enabled, "x402-enabled", false, "enable relay-owned Sui x402 facilitator endpoints under /api/x402 for future control-plane payments", "X402_ENABLED") |
| 97 | utils.BoolFlagEnv(fs, &cfg.X402Testnet, "x402-testnet", false, "use Sui testnet for relay-owned x402 facilitator payments", "X402_TESTNET") |
| 98 | utils.StringFlagEnv(fs, &cfg.X402PayTo, "x402-pay-to", "", "Sui payment recipient address for relay-owned control-plane x402 resources", "X402_PAY_TO") |
| 99 | |
| 100 | utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|hetzner|njalla|route53|vultr); leave empty to use manual fullchain.pem/privatekey.pem from IDENTITY_PATH", "ACME_DNS_PROVIDER") |
| 101 | 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") |
| 102 | utils.StringFlagEnv(fs, &cfg.CloudflareToken, "cloudflare-token", "", "Cloudflare DNS API token (required when acme-dns-provider=cloudflare)", "CLOUDFLARE_TOKEN") |
| 103 | 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") |
| 104 | utils.StringFlagEnv(fs, &cfg.GCPManagedZone, "gcp-managed-zone", "", "explicit Google Cloud DNS managed zone name or numeric ID override", "GCP_MANAGED_ZONE", "GCP_ZONE", "GCE_ZONE_ID") |
| 105 | utils.StringFlagEnv(fs, &cfg.HetznerAPIToken, "hetzner-api-token", "", "Hetzner Cloud API token for DNS automation (required when acme-dns-provider=hetzner)", "HETZNER_API_TOKEN", "HCLOUD_TOKEN") |
| 106 | utils.StringFlagEnv(fs, &cfg.AWSAccessKeyID, "aws-access-key-id", "", "AWS access key ID for Route53 static credentials; uses the default AWS credential chain when omitted", "AWS_ACCESS_KEY_ID") |
| 107 | utils.StringFlagEnv(fs, &cfg.AWSSecretAccessKey, "aws-secret-access-key", "", "AWS secret access key for Route53 static credentials", "AWS_SECRET_ACCESS_KEY") |
| 108 | utils.StringFlagEnv(fs, &cfg.AWSSessionToken, "aws-session-token", "", "AWS session token for Route53 temporary credentials", "AWS_SESSION_TOKEN") |
| 109 | utils.StringFlagEnv(fs, &cfg.AWSRegion, "aws-region", "", "AWS region for Route53 and Route53-backed DNS-01; defaults to us-east-1 when unset", "AWS_REGION", "AWS_DEFAULT_REGION") |
| 110 | utils.StringFlagEnv(fs, &cfg.AWSHostedZoneID, "aws-hosted-zone-id", "", "explicit Route53 hosted zone ID override", "AWS_HOSTED_ZONE_ID") |
| 111 | utils.StringFlagEnv(fs, &cfg.AWSDNSSECKMSKeyARN, "aws-dnssec-kms-key-arn", "", "AWS KMS key ARN used to create a Route53 DNSSEC key-signing key when needed", "AWS_DNSSEC_KMS_KEY_ARN") |
| 112 | utils.StringFlagEnv(fs, &cfg.VultrAPIKey, "vultr-api-key", "", "Vultr API key for DNS automation (required when acme-dns-provider=vultr)", "VULTR_API_KEY") |
| 113 | utils.StringFlagEnv(fs, &cfg.NjallaToken, "njalla-token", "", "Njalla API token for DNS automation (required when acme-dns-provider=njalla)", "NJALLA_TOKEN") |
| 114 | |
| 115 | if err := utils.ParseFlagSet(fs, args, printRootUsage); err != nil { |
| 116 | if errors.Is(err, flag.ErrHelp) { |
| 117 | return nil |
| 118 | } |
| 119 | return err |
| 120 | } |
| 121 | if err := utils.RequireNoArgs(fs.Args(), "relay-server"); err != nil { |
| 122 | printRootUsage(os.Stderr) |
| 123 | return err |
| 124 | } |
| 125 | cfg.IdentityPath = identity.ResolveRelayStateDir(cfg.IdentityPath) |
| 126 | |
| 127 | log.Info(). |
| 128 | Str("release_version", types.ReleaseVersion). |
| 129 | Str("portal_url", cfg.PortalURL). |
| 130 | Str("identity_path", cfg.IdentityPath). |
| 131 | Str("bootstraps", cfg.Bootstraps). |
| 132 | Bool("discovery_enabled", cfg.DiscoveryEnabled). |
| 133 | Int("wireguard_port", cfg.WireGuardPort). |
| 134 | Int("api_port", cfg.APIPort). |
| 135 | Int("sni_port", cfg.SNIPort). |
| 136 | Bool("trust_proxy_headers", cfg.TrustProxyHeaders). |
| 137 | Str("trusted_proxy_cidrs", cfg.TrustedProxyCIDRs). |
| 138 | Bool("udp_enabled", cfg.UDPEnabled). |
| 139 | Bool("tcp_enabled", cfg.TCPEnabled). |
| 140 | Int("min_port", cfg.MinPort). |
| 141 | Int("max_port", cfg.MaxPort). |
| 142 | Bool("admin_token_configured", strings.TrimSpace(cfg.AdminToken) != ""). |
| 143 | Bool("pprof_enabled", cfg.PProfEnabled). |
| 144 | Str("pprof_addr", cfg.PProfAddr). |
| 145 | Bool("x402_facilitator_enabled", cfg.X402Enabled). |
| 146 | Bool("x402_testnet", cfg.X402Testnet). |
| 147 | Bool("x402_pay_to_configured", strings.TrimSpace(cfg.X402PayTo) != ""). |
| 148 | Str("acme_dns_provider", cfg.ACMEDNSProvider). |
| 149 | Bool("ens_gasless_enabled", cfg.ENSGaslessEnabled). |
| 150 | Msg("configured relay server") |
| 151 | |
| 152 | ctx, stop := utils.SignalContext() |
| 153 | defer stop() |
| 154 | |
| 155 | return runServer(ctx, cfg) |
| 156 | } |
| 157 | |
| 158 | func runServer(ctx context.Context, cfg relayServerConfig) error { |
| 159 | server, err := portal.NewServer(portal.ServerConfig{ |
| 160 | PortalURL: cfg.PortalURL, |
| 161 | IdentityPath: cfg.IdentityPath, |
| 162 | Bootstraps: utils.SplitCSV(cfg.Bootstraps), |
| 163 | DiscoveryEnabled: cfg.DiscoveryEnabled, |
| 164 | WireGuardPort: cfg.WireGuardPort, |
| 165 | APIPort: cfg.APIPort, |
| 166 | SNIPort: cfg.SNIPort, |
| 167 | TrustProxyHeaders: cfg.TrustProxyHeaders, |
| 168 | TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, |
| 169 | UDPEnabled: cfg.UDPEnabled, |
| 170 | TCPEnabled: cfg.TCPEnabled, |
| 171 | MinPort: cfg.MinPort, |
| 172 | MaxPort: cfg.MaxPort, |
| 173 | PProfEnabled: cfg.PProfEnabled, |
| 174 | PProfListenAddr: cfg.PProfAddr, |
| 175 | X402Enabled: cfg.X402Enabled, |
| 176 | X402Testnet: cfg.X402Testnet, |
| 177 | X402PayTo: cfg.X402PayTo, |
| 178 | ACME: acme.Config{ |
| 179 | KeyDir: cfg.IdentityPath, |
| 180 | DNSProvider: cfg.ACMEDNSProvider, |
| 181 | ENSGaslessEnabled: cfg.ENSGaslessEnabled, |
| 182 | CloudflareToken: cfg.CloudflareToken, |
| 183 | GCPProjectID: cfg.GCPProjectID, |
| 184 | GCPManagedZone: cfg.GCPManagedZone, |
| 185 | HetznerAPIToken: cfg.HetznerAPIToken, |
| 186 | AWSAccessKeyID: cfg.AWSAccessKeyID, |
| 187 | AWSSecretAccessKey: cfg.AWSSecretAccessKey, |
| 188 | AWSSessionToken: cfg.AWSSessionToken, |
| 189 | AWSRegion: cfg.AWSRegion, |
| 190 | AWSHostedZoneID: cfg.AWSHostedZoneID, |
| 191 | AWSKMSKeyARN: cfg.AWSDNSSECKMSKeyARN, |
| 192 | VultrAPIKey: cfg.VultrAPIKey, |
| 193 | NjallaToken: cfg.NjallaToken, |
| 194 | }, |
| 195 | }) |
| 196 | if err != nil { |
| 197 | return fmt.Errorf("create relay server: %w", err) |
| 198 | } |
| 199 | |
| 200 | relayAPI, err := NewRelayAPI(server, cfg.IdentityPath, cfg.AdminToken) |
| 201 | if err != nil { |
| 202 | return fmt.Errorf("create relay api: %w", err) |
| 203 | } |
| 204 | |
| 205 | apiMux := relayAPI.Handler() |
| 206 | if cfg.X402Enabled { |
| 207 | x402Network := portalx402.Network(cfg.X402Testnet) |
| 208 | if err := portalx402.MountFacilitator(apiMux, portalx402.FacilitatorConfig{ |
| 209 | Testnet: cfg.X402Testnet, |
| 210 | }); err != nil { |
| 211 | return fmt.Errorf("mount x402 facilitator: %w", err) |
| 212 | } |
| 213 | log.Info(). |
| 214 | Str("path", types.PathX402Facilitator). |
| 215 | Str("network", x402Network). |
| 216 | Msg("relay-owned x402 facilitator enabled") |
| 217 | } |
| 218 | |
| 219 | if err := server.Start(ctx, apiMux); err != nil { |
| 220 | return fmt.Errorf("start relay server: %w", err) |
| 221 | } |
| 222 | |
| 223 | return server.Wait() |
| 224 | } |
| 225 | |
| 226 | func runHelpCommand(args []string) error { |
| 227 | switch len(args) { |
| 228 | case 0: |
| 229 | printRootUsage(os.Stdout) |
| 230 | return nil |
| 231 | case 1: |
| 232 | switch strings.TrimSpace(args[0]) { |
| 233 | case "", "help", "-h", "--help", "serve": |
| 234 | printRootUsage(os.Stdout) |
| 235 | return nil |
| 236 | default: |
| 237 | printRootUsage(os.Stderr) |
| 238 | return fmt.Errorf("unknown help topic %q", strings.TrimSpace(args[0])) |
| 239 | } |
| 240 | default: |
| 241 | printRootUsage(os.Stderr) |
| 242 | return errors.New("only one help topic is supported") |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | func printRootUsage(w io.Writer) { |
| 247 | utils.WriteCommandUsage(w, |
| 248 | []string{ |
| 249 | "relay-server [flags]", |
| 250 | "relay-server serve [flags]", |
| 251 | "relay-server help", |
| 252 | }, |
| 253 | []string{ |
| 254 | "relay-server", |
| 255 | "relay-server serve", |
| 256 | "relay-server --portal-url https://portal.example.com", |
| 257 | "relay-server --discovery --bootstraps https://bootstrap.example.com", |
| 258 | "relay-server --udp-enabled --min-port 40000 --max-port 40099", |
| 259 | "relay-server help", |
| 260 | }, |
| 261 | ) |
| 262 | } |