main
go 417 lines 13.7 KB
Raw
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 }