| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "flag" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "net/http" |
| 10 | "os" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "text/tabwriter" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/prometheus/client_golang/prometheus/promhttp" |
| 17 | "github.com/rs/zerolog" |
| 18 | "github.com/rs/zerolog/log" |
| 19 | |
| 20 | "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer" |
| 21 | "github.com/gosuda/portal-tunnel/v2/sdk" |
| 22 | "github.com/gosuda/portal-tunnel/v2/types" |
| 23 | "github.com/gosuda/portal-tunnel/v2/utils" |
| 24 | ) |
| 25 | |
| 26 | func main() { |
| 27 | log.Logger = log.Output(zerolog.NewConsoleWriter()) |
| 28 | if err := utils.RunCommands(os.Args[1:], os.Stdout, os.Stderr, printRootUsage, map[string]utils.CommandFunc{ |
| 29 | "expose": runExposeCommand, |
| 30 | "agent": runAgentCommand, |
| 31 | "list": runListCommand, |
| 32 | "update": runUpdateCommand, |
| 33 | "version": func(args []string) error { |
| 34 | fmt.Fprintln(os.Stdout, types.ReleaseVersion) |
| 35 | return nil |
| 36 | }, |
| 37 | "help": utils.MakeHelpCommand(printRootUsage, []utils.HelpTopic{ |
| 38 | {Name: "expose", Usage: printExposeUsage}, |
| 39 | {Name: "agent", Usage: printAgentUsage}, |
| 40 | {Name: "list", Usage: printListUsage}, |
| 41 | {Name: "update", Usage: printUpdateUsage}, |
| 42 | }), |
| 43 | }); err != nil { |
| 44 | log.Error().Err(err).Msg("portal tunnel exited with error") |
| 45 | os.Exit(1) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | type exposeFlags struct { |
| 50 | relayCSV string |
| 51 | multiHopCSV string |
| 52 | discovery bool |
| 53 | banMITM bool |
| 54 | identityPath string |
| 55 | identityJSON string |
| 56 | name string |
| 57 | desc string |
| 58 | tags string |
| 59 | owner string |
| 60 | thumbnail string |
| 61 | hide bool |
| 62 | x402PayTo string |
| 63 | x402Testnet bool |
| 64 | targetAddr string |
| 65 | httpRoutes []string |
| 66 | udp bool |
| 67 | udpAddr string |
| 68 | tcp bool |
| 69 | maxActiveRelays int |
| 70 | multiHopDepth int |
| 71 | metricsAddr string |
| 72 | } |
| 73 | |
| 74 | func runExposeCommand(args []string) error { |
| 75 | installer.StartUpdateCheck(types.ReleaseVersion) |
| 76 | |
| 77 | flags := exposeFlags{} |
| 78 | fs := utils.NewFlagSet("expose", printExposeUsage) |
| 79 | |
| 80 | utils.StringFlag(fs, &flags.relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)") |
| 81 | utils.StringFlagEnv(fs, &flags.multiHopCSV, "multi-hop", "", "Ordered multi-hop relay API URLs, comma-separated", "MULTI_HOP") |
| 82 | utils.BoolFlag(fs, &flags.discovery, "discovery", true, "Include bootstrap relays and discover additional relays") |
| 83 | utils.BoolFlagEnv(fs, &flags.banMITM, "ban-mitm", false, "Ban relay when the MITM self-probe detects TLS termination", "BAN_MITM") |
| 84 | utils.StringFlagEnv(fs, &flags.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH") |
| 85 | utils.StringFlagEnv(fs, &flags.identityJSON, "identity-json", "", "identity json payload; overrides --identity-path contents and is persisted there when both are set", "IDENTITY_JSON") |
| 86 | utils.StringFlag(fs, &flags.name, "name", "", "Public hostname prefix (single DNS label); auto-generated when omitted") |
| 87 | utils.StringFlag(fs, &flags.desc, "description", "", "Service description metadata") |
| 88 | utils.StringFlag(fs, &flags.tags, "tags", "", "Service tags metadata (comma-separated)") |
| 89 | utils.StringFlag(fs, &flags.owner, "owner", "", "Service owner metadata") |
| 90 | utils.StringFlag(fs, &flags.thumbnail, "thumbnail", "", "Service thumbnail URL metadata") |
| 91 | utils.BoolFlag(fs, &flags.hide, "hide", false, "Hide service from relay listing screens") |
| 92 | utils.StringFlag(fs, &flags.x402PayTo, "x402-pay-to", "", "Sui USDC payment recipient address for this tunnel") |
| 93 | utils.BoolFlag(fs, &flags.x402Testnet, "x402-testnet", false, "Use Sui testnet for tunnel x402 payments; default is Sui mainnet") |
| 94 | utils.RepeatedStringFlag(fs, &flags.httpRoutes, "http-route", "HTTP route mapping in PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT] form; repeat to aggregate multiple local HTTP services behind one public URL") |
| 95 | utils.BoolFlagEnv(fs, &flags.udp, "udp", false, "Enable public UDP relay in addition to the default TCP relay", "UDP_ENABLED") |
| 96 | utils.StringFlagEnv(fs, &flags.udpAddr, "udp-addr", "", "Local UDP target address for relayed datagrams (host:port or port only); defaults to the target when --udp is enabled", "UDP_ADDR") |
| 97 | utils.BoolFlagEnv(fs, &flags.tcp, "tcp", false, "Request a dedicated TCP port on the relay for raw TCP services (no TLS; e.g., Minecraft, game servers)", "TCP_ENABLED") |
| 98 | utils.IntFlagEnv(fs, &flags.maxActiveRelays, "max-active-relays", 3, nil, "Maximum number of auto-selected relays to keep connected; explicit --relays are always included", "MAX_ACTIVE_RELAYS") |
| 99 | utils.IntFlagEnv(fs, &flags.multiHopDepth, "multi-hop-depth", 0, nil, "Automatically select one multi-hop route with this hop count; 0 or 1 disables multi-hop", "MULTI_HOP_DEPTH") |
| 100 | utils.StringFlag(fs, &flags.metricsAddr, "metrics-addr", "", "Optional address (host:port) to serve Prometheus /metrics. Empty = disabled.") |
| 101 | |
| 102 | if err := utils.ParseFlagSet(fs, args, printExposeUsage); err != nil { |
| 103 | if errors.Is(err, flag.ErrHelp) { |
| 104 | return nil |
| 105 | } |
| 106 | return err |
| 107 | } |
| 108 | |
| 109 | var err error |
| 110 | flags.targetAddr, err = utils.OptionalSingleArg(fs.Args(), "target") |
| 111 | if err != nil { |
| 112 | printExposeUsage(os.Stderr) |
| 113 | return err |
| 114 | } |
| 115 | httpRouteInputs := append([]string(nil), flags.httpRoutes...) |
| 116 | switch { |
| 117 | case flags.targetAddr == "" && len(httpRouteInputs) == 0: |
| 118 | printExposeUsage(os.Stderr) |
| 119 | return errors.New("target or at least one --http-route is required") |
| 120 | case flags.targetAddr != "" && len(flags.httpRoutes) > 0: |
| 121 | printExposeUsage(os.Stderr) |
| 122 | return errors.New("target cannot be combined with --http-route") |
| 123 | case len(httpRouteInputs) > 0 && flags.udp: |
| 124 | printExposeUsage(os.Stderr) |
| 125 | return errors.New("--udp cannot be combined with --http-route") |
| 126 | } |
| 127 | |
| 128 | httpRoutes := make([]sdk.HTTPRouteConfig, 0, len(httpRouteInputs)) |
| 129 | for _, raw := range httpRouteInputs { |
| 130 | fields := strings.Fields(raw) |
| 131 | if len(fields) == 0 || len(fields) > 2 { |
| 132 | return fmt.Errorf("--http-route %q: expected PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", raw) |
| 133 | } |
| 134 | prefix, upstream, ok := strings.Cut(fields[0], "=") |
| 135 | if !ok { |
| 136 | return fmt.Errorf("--http-route %q: expected PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]", raw) |
| 137 | } |
| 138 | prefix = strings.TrimSpace(prefix) |
| 139 | if prefix == "" { |
| 140 | return fmt.Errorf("--http-route %q: path is required", raw) |
| 141 | } |
| 142 | if !strings.HasPrefix(prefix, "/") { |
| 143 | return fmt.Errorf("--http-route %q: path must start with /", raw) |
| 144 | } |
| 145 | upstream = strings.TrimSpace(upstream) |
| 146 | if upstream == "" { |
| 147 | return fmt.Errorf("--http-route %q: upstream is required", raw) |
| 148 | } |
| 149 | route := sdk.HTTPRouteConfig{ |
| 150 | Prefix: prefix, |
| 151 | Upstream: upstream, |
| 152 | } |
| 153 | if len(fields) == 2 { |
| 154 | methods, amount, err := parseHTTPRoutePayment(fields[1]) |
| 155 | if err != nil { |
| 156 | return fmt.Errorf("--http-route %q: %w", raw, err) |
| 157 | } |
| 158 | if strings.TrimSpace(flags.x402PayTo) == "" { |
| 159 | return fmt.Errorf("--http-route %q: payment amount requires --x402-pay-to", raw) |
| 160 | } |
| 161 | route.Methods = methods |
| 162 | route.Amount = amount |
| 163 | } |
| 164 | httpRoutes = append(httpRoutes, route) |
| 165 | } |
| 166 | |
| 167 | ctx, stop := utils.SignalContext() |
| 168 | defer stop() |
| 169 | |
| 170 | if flags.metricsAddr != "" { |
| 171 | mux := http.NewServeMux() |
| 172 | mux.Handle("/metrics", promhttp.Handler()) |
| 173 | srv := &http.Server{ |
| 174 | Addr: flags.metricsAddr, |
| 175 | Handler: mux, |
| 176 | ReadHeaderTimeout: 5 * time.Second, |
| 177 | } |
| 178 | go func() { |
| 179 | log.Info().Str("metrics_addr", flags.metricsAddr).Msg("metrics server listening") |
| 180 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { |
| 181 | log.Error().Err(err).Msg("metrics server error") |
| 182 | } |
| 183 | }() |
| 184 | } |
| 185 | |
| 186 | exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{ |
| 187 | RelayURLs: utils.SplitCSV(flags.relayCSV), |
| 188 | Discovery: flags.discovery, |
| 189 | Identity: types.Identity{Name: flags.name}, |
| 190 | IdentityPath: flags.identityPath, |
| 191 | IdentityJSON: flags.identityJSON, |
| 192 | TargetAddr: flags.targetAddr, |
| 193 | UDPAddr: flags.udpAddr, |
| 194 | UDPEnabled: flags.udp, |
| 195 | TCPEnabled: flags.tcp, |
| 196 | MultiHop: utils.SplitCSV(flags.multiHopCSV), |
| 197 | MultiHopDepth: flags.multiHopDepth, |
| 198 | BanMITM: flags.banMITM, |
| 199 | MaxActiveRelays: flags.maxActiveRelays, |
| 200 | Metadata: types.LeaseMetadata{ |
| 201 | Description: flags.desc, |
| 202 | Tags: utils.SplitCSV(flags.tags), |
| 203 | Owner: flags.owner, |
| 204 | Thumbnail: flags.thumbnail, |
| 205 | Hide: flags.hide, |
| 206 | }, |
| 207 | X402PayTo: flags.x402PayTo, |
| 208 | X402Testnet: flags.x402Testnet, |
| 209 | }) |
| 210 | if err != nil { |
| 211 | return fmt.Errorf("failed to start relays: %w", err) |
| 212 | } |
| 213 | if len(httpRouteInputs) > 0 { |
| 214 | defer exposure.Close() |
| 215 | return exposure.RunHTTPRoutes(ctx, httpRoutes, "") |
| 216 | } |
| 217 | return sdk.ProxyExposure(ctx, exposure) |
| 218 | } |
| 219 | |
| 220 | func parseHTTPRoutePayment(value string) ([]string, string, error) { |
| 221 | value = strings.TrimSpace(value) |
| 222 | if value == "" { |
| 223 | return nil, "", errors.New("payment amount is required") |
| 224 | } |
| 225 | methodPart, amount, hasMethods := strings.Cut(value, ":") |
| 226 | if !hasMethods { |
| 227 | amount = value |
| 228 | methodPart = "" |
| 229 | } |
| 230 | amount = strings.TrimSpace(amount) |
| 231 | if amount == "" { |
| 232 | return nil, "", errors.New("payment amount is required") |
| 233 | } |
| 234 | methods := []string(nil) |
| 235 | if hasMethods { |
| 236 | for _, rawMethod := range strings.Split(methodPart, ",") { |
| 237 | method := strings.ToUpper(strings.TrimSpace(rawMethod)) |
| 238 | if method == "" { |
| 239 | return nil, "", errors.New("payment method is required") |
| 240 | } |
| 241 | exists := false |
| 242 | for _, existing := range methods { |
| 243 | if existing == method { |
| 244 | exists = true |
| 245 | break |
| 246 | } |
| 247 | } |
| 248 | if !exists { |
| 249 | methods = append(methods, method) |
| 250 | } |
| 251 | } |
| 252 | if len(methods) == 0 { |
| 253 | return nil, "", errors.New("payment methods are required before ':'") |
| 254 | } |
| 255 | } |
| 256 | return methods, amount, nil |
| 257 | } |
| 258 | |
| 259 | func runUpdateCommand(args []string) error { |
| 260 | var version string |
| 261 | fs := utils.NewFlagSet("update", printUpdateUsage) |
| 262 | utils.StringFlag(fs, &version, "version", "", "Release version to install; defaults to latest") |
| 263 | |
| 264 | if err := utils.ParseFlagSet(fs, args, printUpdateUsage); err != nil { |
| 265 | if errors.Is(err, flag.ErrHelp) { |
| 266 | return nil |
| 267 | } |
| 268 | return err |
| 269 | } |
| 270 | if err := utils.RequireNoArgs(fs.Args(), "update"); err != nil { |
| 271 | printUpdateUsage(os.Stderr) |
| 272 | return err |
| 273 | } |
| 274 | |
| 275 | if err := installer.UpdateCurrentBinary(version); err != nil { |
| 276 | return err |
| 277 | } |
| 278 | |
| 279 | fmt.Fprintln(os.Stderr, "Updated portal.") |
| 280 | return nil |
| 281 | } |
| 282 | |
| 283 | type listFlags struct { |
| 284 | relayCSV string |
| 285 | defaultRelays bool |
| 286 | } |
| 287 | |
| 288 | func runListCommand(args []string) error { |
| 289 | installer.StartUpdateCheck(types.ReleaseVersion) |
| 290 | |
| 291 | flags := listFlags{} |
| 292 | fs := utils.NewFlagSet("list", printListUsage) |
| 293 | |
| 294 | utils.StringFlag(fs, &flags.relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)") |
| 295 | utils.BoolFlag(fs, &flags.defaultRelays, "default-relays", true, "Include bootstrap relays") |
| 296 | |
| 297 | if err := utils.ParseFlagSet(fs, args, printListUsage); err != nil { |
| 298 | if errors.Is(err, flag.ErrHelp) { |
| 299 | return nil |
| 300 | } |
| 301 | return err |
| 302 | } |
| 303 | if err := utils.RequireNoArgs(fs.Args(), "list"); err != nil { |
| 304 | printListUsage(os.Stderr) |
| 305 | return err |
| 306 | } |
| 307 | |
| 308 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| 309 | defer cancel() |
| 310 | |
| 311 | relayInputs := utils.SplitCSV(flags.relayCSV) |
| 312 | |
| 313 | relayURLs, err := utils.ResolvePortalRelayURLs(relayInputs, flags.defaultRelays) |
| 314 | if err != nil { |
| 315 | return fmt.Errorf("resolve relay urls: %w", err) |
| 316 | } |
| 317 | if len(relayURLs) == 0 { |
| 318 | return errors.New("no relay URLs configured") |
| 319 | } |
| 320 | |
| 321 | versions := make([]string, len(relayURLs)) |
| 322 | var wg sync.WaitGroup |
| 323 | wg.Add(len(relayURLs)) |
| 324 | for i, u := range relayURLs { |
| 325 | go func(idx int, url string) { |
| 326 | defer wg.Done() |
| 327 | versions[idx] = utils.FetchRelayVersion(ctx, url) |
| 328 | }(i, u) |
| 329 | } |
| 330 | wg.Wait() |
| 331 | |
| 332 | table := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) |
| 333 | fmt.Fprintln(table, "RELAY\tVERSION") |
| 334 | for i, relayURL := range relayURLs { |
| 335 | ver := versions[i] |
| 336 | if ver == "" { |
| 337 | ver = "unknown" |
| 338 | } |
| 339 | fmt.Fprintf(table, "%s\t%s\n", relayURL, ver) |
| 340 | } |
| 341 | return table.Flush() |
| 342 | } |
| 343 | |
| 344 | func printRootUsage(w io.Writer) { |
| 345 | utils.WriteCommandUsage(w, |
| 346 | []string{ |
| 347 | "portal expose [flags] <target>", |
| 348 | "portal expose [flags] --http-route \"PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]\" [...]", |
| 349 | "portal agent run [flags]", |
| 350 | "portal agent dashboard [flags]", |
| 351 | "portal agent stop [flags]", |
| 352 | "portal agent restart [flags]", |
| 353 | "portal list [flags]", |
| 354 | "portal update [flags]", |
| 355 | "portal version", |
| 356 | }, |
| 357 | []string{ |
| 358 | "portal expose 3000", |
| 359 | "portal expose localhost:8080 --name my-app", |
| 360 | "portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app", |
| 361 | "portal expose --http-route \"/paid=http://127.0.0.1:3001 GET:0.01\" --http-route /=http://127.0.0.1:5173 --x402-pay-to 0x...", |
| 362 | "portal agent run", |
| 363 | "portal agent dashboard", |
| 364 | "portal agent stop", |
| 365 | "portal agent restart", |
| 366 | "portal expose 3000 --udp --udp-addr 127.0.0.1:5353", |
| 367 | "portal list", |
| 368 | "portal update", |
| 369 | "portal update --version v2.1.7", |
| 370 | "portal version", |
| 371 | }, |
| 372 | ) |
| 373 | } |
| 374 | |
| 375 | func printExposeUsage(w io.Writer) { |
| 376 | utils.WriteCommandUsage(w, |
| 377 | []string{ |
| 378 | "portal expose [flags] <target>", |
| 379 | "portal expose [flags] --http-route \"PATH=UPSTREAM [METHOD[,METHOD...]:USDC_AMOUNT]\" [...]", |
| 380 | }, |
| 381 | []string{ |
| 382 | "portal expose 3000", |
| 383 | "portal expose localhost:8080 --name my-app", |
| 384 | "portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app", |
| 385 | "portal expose --http-route \"/paid=http://127.0.0.1:3001 GET:0.01\" --http-route /=http://127.0.0.1:5173 --x402-pay-to 0x...", |
| 386 | "portal expose 3000 --udp --udp-addr 127.0.0.1:5353", |
| 387 | "portal expose 3000 --ban-mitm", |
| 388 | "portal expose 3000 --relays https://portal.example.com --discovery=false", |
| 389 | "portal expose 3000 --multi-hop https://entry.example.com,https://transit.example.com,https://exit.example.com", |
| 390 | "portal expose 3000 --multi-hop-depth 3", |
| 391 | }, |
| 392 | ) |
| 393 | } |
| 394 | |
| 395 | func printListUsage(w io.Writer) { |
| 396 | utils.WriteCommandUsage(w, |
| 397 | []string{ |
| 398 | "portal list [flags]", |
| 399 | }, |
| 400 | []string{ |
| 401 | "portal list", |
| 402 | "portal list --relays https://portal.example.com --default-relays=false", |
| 403 | }, |
| 404 | ) |
| 405 | } |
| 406 | |
| 407 | func printUpdateUsage(w io.Writer) { |
| 408 | utils.WriteCommandUsage(w, |
| 409 | []string{ |
| 410 | "portal update [flags]", |
| 411 | }, |
| 412 | []string{ |
| 413 | "portal update", |
| 414 | "portal update --version v2.1.9", |
| 415 | }, |
| 416 | ) |
| 417 | } |