fix tunnel script

rabbitprincess committed Mar 13, 2026 at 00:03 UTC 5b5327e6a42e733e1f30eceab68db7935db12453
16 files changed +775 -285
AGENTS.md
+4 -4
@@ -57,7 +57,7 @@ Descriptive docs under `docs/` should match current code paths.
57
58 1. **All JSON control-plane responses use the `APIEnvelope` wrapper:** `{ ok: bool, data?: any, error?: { code, message } }` (defined in `types/api.go`).
59 - Write responses through `writeAPIData()`, `writeAPIOK()`, or `writeAPIError()`.
60 - - Admin HTML pages, tunnel script/binary responses, and other non-JSON endpoints are exceptions.
60 + - Admin HTML pages, install script/binary responses, and other non-JSON endpoints are exceptions.
61
62 ## Shared Types Package
63
@@ -66,7 +66,7 @@ Descriptive docs under `docs/` should match current code paths.
66 - Not allowed: relay runtime state, broker/session state, server config, SDK lifecycle state, generic helpers.
67
68 2. **Shared control-plane and public route constants that cross package boundaries belong in `types/paths.go`.**
69 - - Examples: `/sdk/*`, `/v1/sign`, `/healthz`, `/admin`, `/admin/leases`, `/tunnel`.
69 + - Examples: `/sdk/*`, `/v1/sign`, `/healthz`, `/admin`, `/admin/leases`, `/install.sh`, `/install.ps1`, `/install/bin/*`.
70
71 3. **Relay-local frontend asset paths stay local to `cmd/relay-server`.**
72 - Why: filenames like `favicon.svg` or `portal.jpg` are frontend serving details, not cross-package API contract.
@@ -79,8 +79,8 @@ Descriptive docs under `docs/` should match current code paths.
79 1. **CI verification commands:** `make vet`, `make lint`, `make test`, `make vuln`.
80 - `make tidy` is a local maintenance step, not part of CI.
81
82 -2. **`make build-server` does not build the frontend first.**
83 - - Why: `cmd/relay-server/dist/*` is embed input; build the frontend explicitly before packaging the relay binary.
82 +2. **`make build-server` does not build the frontend or installer binaries first.**
83 + - Why: `cmd/relay-server/dist/*` is embed input; build the frontend and CLI artifacts explicitly before packaging the relay binary.
84
85 3. **ACME management supports only `cloudflare` and `route53`, and keeps both root and wildcard DNS A records in sync for non-localhost deployments.**
86 - Certificates and keys live under `KEYLESS_DIR` as `fullchain.pem` and `privatekey.pem`.
Makefile
+2 -2
@@ -66,7 +66,7 @@ build-frontend:
66 @cd frontend && npm i && npm run build
67 @echo "[frontend] build complete"
68
69 -# Build portal-tunnel binaries for distribution
69 +# Build portal-tunnel binaries for installer distribution
70 build-tunnel:
71 @echo "[tunnel] building portal-tunnel binaries..."
72 @mkdir -p cmd/relay-server/dist/tunnel
@@ -74,7 +74,7 @@ build-tunnel:
74 for GOARCH in amd64 arm64; do \
75 EXT=""; \
76 if [ "$${GOOS}" = "windows" ]; then EXT=".exe"; fi; \
77 - OUT="cmd/relay-server/dist/tunnel/portal-tunnel-$${GOOS}-$${GOARCH}$${EXT}"; \
77 + OUT="cmd/relay-server/dist/tunnel/portal-$${GOOS}-$${GOARCH}$${EXT}"; \
78 echo " - $${OUT}"; \
79 CGO_ENABLED=0 GOOS=$${GOOS} GOARCH=$${GOARCH} go build -trimpath -ldflags "-s -w" -o "$${OUT}" ./cmd/portal-tunnel; \
80 done; \
cmd/portal-tunnel/README.md
+60 -20
@@ -1,44 +1,84 @@
1 -# Portal-tunnel
1 +# Portal CLI
2
3 -Portal-tunnel connects a local service to a Portal relay with the legacy CLI shape restored on top of the new core.
3 +`cmd/portal-tunnel` builds the `portal` tunnel CLI. It connects a local service to one or more Portal relays.
4
5 ## Usage
6
7 ```bash
8 -./portal-tunnel --host localhost:8080 \
8 +curl -sSL https://portal.example.com/install.sh | bash
9 +portal expose 3000
10 +portal list
11 +```
12 +
13 +```powershell
14 +irm https://portal.example.com/install.ps1 | iex
15 +portal expose 3000
16 +portal list
17 +```
18 +
19 +Custom relay and metadata example:
20 +
21 +```text
22 +portal expose --name myapp \
23 --relays https://portal.example.com \
10 - --name myapp \
24 --description "Service description" \
25 --tags tag1,tag2 \
26 --thumbnail https://example.com/thumb.png \
14 - --owner "Portal Operator"
27 + --owner "Portal Operator" \
28 + localhost:8080
29 ```
30
17 -## Flags
31 +## Commands
32 +
33 +### `portal expose [flags] <target>`
34 +
35 +- `<target>` accepts a bare port like `3000`, a `host:port`, or an `http(s)://host:port` URL.
36 +- Bare ports resolve to `127.0.0.1:<port>`.
37 +- `--name` is optional. When omitted, the CLI generates a stable local name suffix from its config.
38 +- `--relays` overrides installed default relays for that run.
39 +- `--default-relays=false` disables the public registry list for that run.
40 +
41 +Flags:
42
43 ```text
20 ---relays Additional Portal relay server API URLs (comma-separated, https only; appended to registry.json defaults unless --default-relays=false is set) [env: RELAYS]
21 ---default-relays
22 - Include repository registry.json default relays [env: DEFAULT_RELAYS]
23 ---host Target host to proxy to (host:port or URL) [env: APP_HOST]
24 ---name Public hostname prefix (single DNS label) [env: APP_NAME]
25 ---description Service description metadata [env: APP_DESCRIPTION]
26 ---tags Service tags metadata (comma-separated) [env: APP_TAGS]
27 ---thumbnail Service thumbnail URL metadata [env: APP_THUMBNAIL]
28 ---owner Service owner metadata [env: APP_OWNER]
29 ---hide Hide service from discovery [env: APP_HIDE]
44 +--relays Portal relay API URLs (comma-separated, https only)
45 +--default-relays Include repository registry.json public relays
46 +--name Public hostname prefix (single DNS label); auto-generated when omitted
47 +--description Service description metadata
48 +--tags Service tags metadata (comma-separated)
49 +--thumbnail Service thumbnail URL metadata
50 +--owner Service owner metadata
51 +--hide Hide service from discovery
52 ```
53
54 +### `portal list [flags]`
55 +
56 +- Prints the relay URLs that the CLI will use with the current installed config plus any runtime overrides.
57 +- `--relays` and `--default-relays=false` follow the same semantics as `portal expose`.
58 +
59 +Legacy execution compatibility has been removed:
60 +
61 +- Use `portal expose ...` explicitly; bare `portal [flags]` is no longer accepted.
62 +- Runtime `APP_*`, `RELAYS`, and `DEFAULT_RELAYS` environment variable fallbacks are no longer used.
63 +- Pass the local target as the required positional `<target>` argument.
64 +
65 +## Install Behavior
66 +
67 +- `install.sh` installs the downloaded binary as `portal`.
68 +- `install.ps1` installs `portal.exe` for the current Windows user and updates the user `PATH`.
69 +- The installer writes relay defaults to the user config file:
70 + - Linux/macOS: `${XDG_CONFIG_HOME:-$HOME/.config}/portal/config.json`
71 + - Windows: `%APPDATA%\portal\config.json`
72 +- Installed defaults currently include the relay that served the installer plus the public registry list.
73 +
74 ## Notes
75
76 - Multiple relay URLs are registered independently. Each relay gets its own lease ID and public URLs.
35 -- The tunnel always starts from the repository-root `registry.json` relay list. `--relays` and `RELAYS` append extra relay URLs on top of those defaults.
36 -- `--default-relays=false` disables the registry defaults and uses only explicit `--relays` or `RELAYS` input.
77 - Relay publishes each service at `<name>.<portal root host>`.
38 -- Portal-tunnel now consumes one aggregate SDK listener, so the CLI no longer manages per-relay listener loops itself.
78 +- The tunnel consumes one aggregate SDK listener, so the CLI no longer manages per-relay listener loops itself.
79 - Relay startup and reconnect failures are retried independently in the background. A relay that is down does not stop healthy relays from continuing to serve traffic.
80 - The tunnel starts once relay URLs pass local validation. Remote compatibility checks, lease registration, and reconnects continue in the background until each relay becomes ready.
41 -- The configured relay list is either `registry.json + explicit relay URLs` or, with `--default-relays=false`, just the explicit relay URLs. Published public URLs appear only for relays that have registered successfully.
81 +- The configured relay list is either `registry.json + installed/configured relay URLs` or, with `--default-relays=false`, just the explicit relay URLs. Published public URLs appear only for relays that have registered successfully.
82 - SDK callers that do not set `ListenerConfig.RetryCount` use infinite retry semantics for each relay.
83 - Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
84 - When the local service is unreachable, the tunnel returns an HTTP 503 page.
cmd/portal-tunnel/config.go new
+85
@@ -0,0 +1,85 @@
1 +package main
2 +
3 +import (
4 + "encoding/json"
5 + "errors"
6 + "os"
7 + "path/filepath"
8 + "strings"
9 +
10 + "github.com/gosuda/portal/v2/utils"
11 +)
12 +
13 +const (
14 + cliConfigDirName = "portal"
15 + cliConfigFileName = "config.json"
16 +)
17 +
18 +type cliConfig struct {
19 + ClientID string `json:"client_id,omitempty"`
20 + Relays []string `json:"relays,omitempty"`
21 +}
22 +
23 +func loadCLIConfig() (cliConfig, string, error) {
24 + path, err := cliConfigPath()
25 + if err != nil {
26 + return cliConfig{}, "", err
27 + }
28 +
29 + cfg := cliConfig{}
30 + data, err := os.ReadFile(path)
31 + if err != nil {
32 + if errors.Is(err, os.ErrNotExist) {
33 + return cfg, path, nil
34 + }
35 + return cfg, path, err
36 + }
37 + if len(data) == 0 {
38 + return cfg, path, nil
39 + }
40 + if err := json.Unmarshal(data, &cfg); err != nil {
41 + return cliConfig{}, path, err
42 + }
43 + if len(cfg.Relays) > 0 {
44 + cfg.Relays, err = utils.NormalizeRelayURLs(cfg.Relays)
45 + if err != nil {
46 + return cliConfig{}, path, err
47 + }
48 + }
49 + return cfg, path, nil
50 +}
51 +
52 +func saveCLIConfig(path string, cfg cliConfig) error {
53 + if strings.TrimSpace(path) == "" {
54 + var err error
55 + path, err = cliConfigPath()
56 + if err != nil {
57 + return err
58 + }
59 + }
60 +
61 + if len(cfg.Relays) > 0 {
62 + normalizedRelays, err := utils.NormalizeRelayURLs(cfg.Relays)
63 + if err != nil {
64 + return err
65 + }
66 + cfg.Relays = normalizedRelays
67 + }
68 + data, err := json.MarshalIndent(cfg, "", " ")
69 + if err != nil {
70 + return err
71 + }
72 +
73 + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
74 + return err
75 + }
76 + return os.WriteFile(path, append(data, '\n'), 0o600)
77 +}
78 +
79 +func cliConfigPath() (string, error) {
80 + baseDir, err := os.UserConfigDir()
81 + if err != nil {
82 + return "", err
83 + }
84 + return filepath.Join(baseDir, cliConfigDirName, cliConfigFileName), nil
85 +}
cmd/portal-tunnel/main.go
+277 -44
@@ -5,8 +5,12 @@ import (
5 "errors"
6 "flag"
7 "fmt"
8 + "io"
9 + "net"
10 "os"
11 "os/signal"
12 + "strconv"
13 + "strings"
14 "sync"
15 "sync/atomic"
16 "syscall"
@@ -20,66 +24,198 @@ import (
24 "github.com/gosuda/portal/v2/utils"
25 )
26
23 -var (
24 - flagRelayURLs string
25 - flagHost string
26 - flagName string
27 - flagDesc string
28 - flagTags string
29 - flagThumbnail string
30 - flagOwner string
31 - flagHide bool
32 - flagDefaultRelays bool
33 -)
27 +const shortClientIDSuffixLen = 6
28
29 func main() {
30 zerolog.TimeFieldFormat = time.RFC3339
31 zerolog.SetGlobalLevel(zerolog.InfoLevel)
32 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
39 - logger := log.With().Str("component", "portal-tunnel").Logger()
40 -
41 - defaultRelayURLs := os.Getenv("RELAYS")
42 - flag.StringVar(&flagRelayURLs, "relays", defaultRelayURLs, "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https; appended to registry.json defaults unless --default-relays=false is set) [env: RELAYS]")
43 - flag.BoolVar(&flagDefaultRelays, "default-relays", utils.ParseBoolEnv("DEFAULT_RELAYS", true), "Include repository registry.json default relays [env: DEFAULT_RELAYS]")
44 - flag.StringVar(&flagHost, "host", os.Getenv("APP_HOST"), "Target host to proxy to (host:port or URL) [env: APP_HOST]")
45 - flag.StringVar(&flagName, "name", os.Getenv("APP_NAME"), "Public hostname prefix (single DNS label) [env: APP_NAME]")
46 - flag.StringVar(&flagDesc, "description", os.Getenv("APP_DESCRIPTION"), "Service description metadata [env: APP_DESCRIPTION]")
47 - flag.StringVar(&flagTags, "tags", os.Getenv("APP_TAGS"), "Service tags metadata (comma-separated) [env: APP_TAGS]")
48 - flag.StringVar(&flagThumbnail, "thumbnail", os.Getenv("APP_THUMBNAIL"), "Service thumbnail URL metadata [env: APP_THUMBNAIL]")
49 - flag.StringVar(&flagOwner, "owner", os.Getenv("APP_OWNER"), "Service owner metadata [env: APP_OWNER]")
50 - flag.BoolVar(&flagHide, "hide", utils.ParseBoolEnv("APP_HIDE", false), "Hide service from discovery (metadata) [env: APP_HIDE]")
51 - flag.Parse()
52 -
53 - if err := runTunnel(); err != nil {
54 - logger.Error().Err(err).Msg("portal tunnel exited with error")
33 +
34 + if err := run(os.Args[1:]); err != nil {
35 + log.Error().Err(err).Msg("portal tunnel exited with error")
36 os.Exit(1)
37 }
38 }
39
59 -func runTunnel() error {
60 - logger := log.With().Str("component", "portal-tunnel").Logger()
40 +func run(args []string) error {
41 + if len(args) == 0 {
42 + printRootUsage(os.Stdout)
43 + return nil
44 + }
45 +
46 + command := strings.TrimSpace(args[0])
47 + switch command {
48 + case "help", "-h", "--help":
49 + printRootUsage(os.Stdout)
50 + return nil
51 + case "expose":
52 + return runExposeCommand(args[1:])
53 + case "list":
54 + return runListCommand(args[1:])
55 + default:
56 + printRootUsage(os.Stderr)
57 + return fmt.Errorf("unknown command %q", command)
58 + }
59 +}
60 +
61 +func runExposeCommand(args []string) error {
62 + cfg, cfgPath, err := loadCLIConfig()
63 + if err != nil {
64 + return fmt.Errorf("load portal config: %w", err)
65 + }
66 +
67 + defaultRelays := true
68 +
69 + fs := flag.NewFlagSet("expose", flag.ContinueOnError)
70 + fs.SetOutput(io.Discard)
71 +
72 + var relayCSV string
73 + var target string
74 + var name string
75 + var desc string
76 + var tags string
77 + var thumbnail string
78 + var owner string
79 + var hide bool
80 +
81 + fs.StringVar(&relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
82 + fs.BoolVar(&defaultRelays, "default-relays", defaultRelays, "Include public registry relays")
83 + fs.StringVar(&name, "name", "", "Public hostname prefix (single DNS label); auto-generated when omitted")
84 + fs.StringVar(&desc, "description", "", "Service description metadata")
85 + fs.StringVar(&tags, "tags", "", "Service tags metadata (comma-separated)")
86 + fs.StringVar(&thumbnail, "thumbnail", "", "Service thumbnail URL metadata")
87 + fs.StringVar(&owner, "owner", "", "Service owner metadata")
88 + fs.BoolVar(&hide, "hide", false, "Hide service from discovery")
89 + fs.Usage = func() {
90 + printExposeUsage(fs.Output())
91 + }
92 +
93 + parseArgs := moveFirstPositionalToEnd(args)
94 + if err := fs.Parse(parseArgs); err != nil {
95 + printExposeUsage(os.Stderr)
96 + return err
97 + }
98 +
99 + if positionals := fs.Args(); len(positionals) > 0 {
100 + if len(positionals) > 1 {
101 + return errors.New("only one target is supported")
102 + }
103 + target = positionals[0]
104 + }
105 +
106 + target, err = normalizeExposeTarget(target)
107 + if err != nil {
108 + printExposeUsage(os.Stderr)
109 + return err
110 + }
111 +
112 + if strings.TrimSpace(name) == "" {
113 + if strings.TrimSpace(cfg.ClientID) == "" {
114 + cfg.ClientID = utils.RandomID("cli_")
115 + }
116 + name, err = defaultExposeName(target, cfg.ClientID)
117 + if err != nil {
118 + return fmt.Errorf("derive service name: %w", err)
119 + }
120 + if saveErr := saveCLIConfig(cfgPath, cfg); saveErr != nil {
121 + return fmt.Errorf("persist portal config: %w", saveErr)
122 + }
123 + }
124
125 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
126 defer stop()
127
65 - relayURLs := utils.SplitCSV(flagRelayURLs)
66 - if flagDefaultRelays {
67 - relayURLs = sdk.WithDefaultRelayURLs(ctx, "", relayURLs...)
128 + relayInputs := append([]string(nil), cfg.Relays...)
129 + if explicitRelays := strings.TrimSpace(relayCSV); explicitRelays != "" {
130 + relayInputs = []string{explicitRelays}
131 + }
132 +
133 + relayURLs, err := resolveRelayURLs(ctx, "", relayInputs, defaultRelays)
134 + if err != nil {
135 + return fmt.Errorf("resolve relay urls: %w", err)
136 + }
137 + if len(relayURLs) == 0 {
138 + return errors.New("no relay URLs configured; run the installer first or pass --relays")
139 + }
140 +
141 + return runTunnel(
142 + ctx,
143 + stop,
144 + relayURLs,
145 + target,
146 + name,
147 + types.LeaseMetadata{
148 + Description: desc,
149 + Tags: utils.SplitCSV(tags),
150 + Owner: owner,
151 + Thumbnail: thumbnail,
152 + Hide: hide,
153 + },
154 + )
155 +}
156 +
157 +func runListCommand(args []string) error {
158 + cfg, _, err := loadCLIConfig()
159 + if err != nil {
160 + return fmt.Errorf("load portal config: %w", err)
161 + }
162 +
163 + defaultRelays := true
164 +
165 + fs := flag.NewFlagSet("list", flag.ContinueOnError)
166 + fs.SetOutput(io.Discard)
167 +
168 + var relayCSV string
169 + fs.StringVar(&relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
170 + fs.BoolVar(&defaultRelays, "default-relays", defaultRelays, "Include public registry relays")
171 + fs.Usage = func() {
172 + printListUsage(fs.Output())
173 + }
174 +
175 + if err := fs.Parse(args); err != nil {
176 + printListUsage(os.Stderr)
177 + return err
178 + }
179 + if len(fs.Args()) > 0 {
180 + printListUsage(os.Stderr)
181 + return errors.New("list does not accept positional arguments")
182 + }
183 +
184 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
185 + defer cancel()
186 +
187 + relayInputs := append([]string(nil), cfg.Relays...)
188 + if explicitRelays := strings.TrimSpace(relayCSV); explicitRelays != "" {
189 + relayInputs = []string{explicitRelays}
190 }
69 - relayURLs, err := utils.NormalizeRelayURLs(relayURLs)
191 +
192 + relayURLs, err := resolveRelayURLs(ctx, "", relayInputs, defaultRelays)
193 if err != nil {
194 return fmt.Errorf("resolve relay urls: %w", err)
195 }
196 + if len(relayURLs) == 0 {
197 + return errors.New("no relay URLs configured")
198 + }
199
74 - exposure, err := sdk.Expose(ctx, relayURLs, flagName, types.LeaseMetadata{
75 - Description: flagDesc,
76 - Tags: utils.SplitCSV(flagTags),
77 - Owner: flagOwner,
78 - Thumbnail: flagThumbnail,
79 - Hide: flagHide,
80 - })
200 + for _, relayURL := range relayURLs {
201 + fmt.Println(relayURL)
202 + }
203 + return nil
204 +}
205 +
206 +func runTunnel(
207 + ctx context.Context,
208 + stop func(),
209 + relayURLs []string,
210 + target string,
211 + name string,
212 + metadata types.LeaseMetadata,
213 +) error {
214 + logger := log.With().Str("component", "portal").Logger()
215 +
216 + exposure, err := sdk.Expose(ctx, relayURLs, name, metadata)
217 if err != nil {
82 - return fmt.Errorf("service %s: failed to start relays: %w", flagName, err)
218 + return fmt.Errorf("service %s: failed to start relays: %w", name, err)
219 }
220 if exposure == nil {
221 return errors.New("no relay URLs provided")
@@ -88,7 +224,9 @@ func runTunnel() error {
224
225 logger.Info().
226 Str("release_version", types.ReleaseVersion).
91 - Str("local", flagHost).
227 + Str("local", target).
228 + Str("service_name", name).
229 + Strs("relays", exposure.RelayURLs()).
230 Msg("starting portal tunnel")
231
232 var connWG sync.WaitGroup
@@ -99,8 +237,8 @@ func runTunnel() error {
237 _ = exposure.Close()
238 }()
239
102 - waitErr := proxyRelayConnections(ctx, exposure, flagHost, &connWG, &connCount)
103 - if waitErr != nil {
240 + waitErr := proxyRelayConnections(ctx, exposure, target, &connWG, &connCount)
241 + if waitErr != nil && stop != nil {
242 stop()
243 }
244 closeErr := exposure.Close()
@@ -130,3 +268,98 @@ func runTunnel() error {
268 logger.Info().Msg("tunnel shutdown complete")
269 return errors.Join(waitErr, closeErr)
270 }
271 +
272 +func resolveRelayURLs(ctx context.Context, registryURL string, inputs []string, includeDefaultRelays bool) ([]string, error) {
273 + if includeDefaultRelays {
274 + relayURLs := sdk.WithDefaultRelayURLs(ctx, registryURL, inputs...)
275 + if len(relayURLs) == 0 {
276 + return nil, nil
277 + }
278 + return relayURLs, nil
279 + }
280 + return utils.NormalizeRelayURLs(inputs)
281 +}
282 +
283 +func moveFirstPositionalToEnd(args []string) []string {
284 + if len(args) == 0 {
285 + return nil
286 + }
287 + first := strings.TrimSpace(args[0])
288 + if first == "" || strings.HasPrefix(first, "-") {
289 + return append([]string(nil), args...)
290 + }
291 + normalized := append([]string(nil), args[1:]...)
292 + return append(normalized, first)
293 +}
294 +
295 +func normalizeExposeTarget(raw string) (string, error) {
296 + raw = strings.TrimSpace(raw)
297 + if raw == "" {
298 + return "", errors.New("target is required")
299 + }
300 +
301 + if _, err := strconv.Atoi(raw); err == nil {
302 + return net.JoinHostPort("127.0.0.1", raw), nil
303 + }
304 +
305 + targetAddr, err := utils.NormalizeTargetAddr(raw)
306 + if err != nil {
307 + return "", fmt.Errorf("invalid target %q: %w", raw, err)
308 + }
309 + return targetAddr, nil
310 +}
311 +
312 +func defaultExposeName(target, clientID string) (string, error) {
313 + trimmed := strings.TrimSpace(clientID)
314 + if trimmed == "" {
315 + trimmed = "relay"
316 + }
317 + if cut, ok := strings.CutPrefix(trimmed, "cli_"); ok {
318 + trimmed = cut
319 + }
320 + if len(trimmed) > shortClientIDSuffixLen {
321 + trimmed = trimmed[:shortClientIDSuffixLen]
322 + }
323 + if trimmed == "" {
324 + trimmed = "relay"
325 + }
326 +
327 + port := "app"
328 + if _, rawPort, err := net.SplitHostPort(target); err == nil {
329 + if rawPort != "" {
330 + port = rawPort
331 + }
332 + }
333 +
334 + return utils.NormalizeDNSLabel("app-" + port + "-" + trimmed)
335 +}
336 +
337 +func printRootUsage(w io.Writer) {
338 + fmt.Fprintln(w, "Usage:")
339 + fmt.Fprintln(w, " portal expose [flags] <target>")
340 + fmt.Fprintln(w, " portal list [flags]")
341 + fmt.Fprintln(w)
342 + fmt.Fprintln(w, "Examples:")
343 + fmt.Fprintln(w, " portal expose 3000")
344 + fmt.Fprintln(w, " portal expose --name my-app localhost:8080")
345 + fmt.Fprintln(w, " portal list")
346 +}
347 +
348 +func printExposeUsage(w io.Writer) {
349 + fmt.Fprintln(w, "Usage:")
350 + fmt.Fprintln(w, " portal expose [flags] <target>")
351 + fmt.Fprintln(w)
352 + fmt.Fprintln(w, "Examples:")
353 + fmt.Fprintln(w, " portal expose 3000")
354 + fmt.Fprintln(w, " portal expose --name my-app localhost:8080")
355 + fmt.Fprintln(w, " portal expose --relays https://portal.example.com --default-relays=false 3000")
356 +}
357 +
358 +func printListUsage(w io.Writer) {
359 + fmt.Fprintln(w, "Usage:")
360 + fmt.Fprintln(w, " portal list [flags]")
361 + fmt.Fprintln(w)
362 + fmt.Fprintln(w, "Examples:")
363 + fmt.Fprintln(w, " portal list")
364 + fmt.Fprintln(w, " portal list --relays https://portal.example.com --default-relays=false")
365 +}
cmd/portal-tunnel/relays.go
+1 -1
@@ -60,7 +60,7 @@ func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn)
60
61 targetAddr, err := utils.NormalizeTargetAddr(localAddr)
62 if err != nil {
63 - return fmt.Errorf("invalid --host value %q: %w", localAddr, err)
63 + return fmt.Errorf("invalid target %q: %w", localAddr, err)
64 }
65
66 dialer := &net.Dialer{Timeout: 5 * time.Second}
cmd/relay-server/serve.go
+6 -3
@@ -99,10 +99,13 @@ func newAPIMux(frontend *Frontend, adminHandler *admin.Handler, cfg relayServerC
99
100 mux.HandleFunc(types.PathAdmin, adminHandler.HandleRequest)
101 mux.HandleFunc(types.PathAdminPrefix, adminHandler.HandleRequest)
102 - mux.HandleFunc(types.PathTunnel, func(w http.ResponseWriter, r *http.Request) {
103 - serveTunnelScript(w, r, cfg.PortalURL)
102 + mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) {
103 + serveInstallScript(w, r, cfg.PortalURL, false)
104 })
105 - mux.HandleFunc(types.PathTunnelBinPrefix, serveTunnelBinary)
105 + mux.HandleFunc(types.PathInstallPowerShell, func(w http.ResponseWriter, r *http.Request) {
106 + serveInstallScript(w, r, cfg.PortalURL, true)
107 + })
108 + mux.HandleFunc(types.PathInstallBinPrefix, serveInstallBinary)
109
110 return mux
111 }
cmd/relay-server/tunnel.go
+205 -126
@@ -3,18 +3,21 @@ package main
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 + "encoding/json"
7 "fmt"
8 "net/http"
9 "strings"
10 +
11 + "github.com/gosuda/portal/v2/types"
12 )
13
11 -const tunnelScriptTemplate = `#!/usr/bin/env sh
12 -set -e
14 +const installShellScriptTemplate = `#!/usr/bin/env sh
15 +set -eu
16
17 OS="$(uname -s)"
18 case "$OS" in
16 - Linux) TUNNEL_OS="linux" ;;
17 - Darwin) TUNNEL_OS="darwin" ;;
19 + Linux) PORTAL_OS="linux" ;;
20 + Darwin) PORTAL_OS="darwin" ;;
21 *)
22 echo "Unsupported OS: $OS" >&2
23 exit 1
@@ -23,8 +26,8 @@ esac
26
27 ARCH="$(uname -m)"
28 case "$ARCH" in
26 - x86_64|amd64) TUNNEL_ARCH="amd64" ;;
27 - arm64|aarch64) TUNNEL_ARCH="arm64" ;;
29 + x86_64|amd64) PORTAL_ARCH="amd64" ;;
30 + arm64|aarch64) PORTAL_ARCH="arm64" ;;
31 *)
32 echo "Unsupported architecture: $ARCH" >&2
33 exit 1
@@ -32,9 +35,7 @@ case "$ARCH" in
35 esac
36
37 BASE_URL="${BASE_URL:-%s}"
35 -RELAYS="${RELAYS:-$BASE_URL}"
36 -DEFAULT_RELAYS="${DEFAULT_RELAYS:-true}"
37 -BIN_URL="${BIN_URL:-$BASE_URL/tunnel/bin/$TUNNEL_OS-$TUNNEL_ARCH}"
38 +BIN_URL="${BIN_URL:-$BASE_URL/install/bin/$PORTAL_OS-$PORTAL_ARCH}"
39 CHECKSUM_URL="${BIN_URL}.sha256"
40 CURL_INSECURE_FLAG=""
41
@@ -45,12 +46,12 @@ case "$BASE_URL" in
46 esac
47
48 TMPDIR="${TMPDIR:-/tmp}"
48 -WORKDIR="$(mktemp -d "$TMPDIR/portal-tunnel.XXXXXX" 2>/dev/null || mktemp -d -t portal-tunnel)"
49 -BIN_PATH="$WORKDIR/portal-tunnel"
49 +WORKDIR="$(mktemp -d "$TMPDIR/portal-install.XXXXXX" 2>/dev/null || mktemp -d -t portal-install)"
50 +BIN_PATH="$WORKDIR/portal"
51 cleanup() { rm -rf "$WORKDIR"; }
52 trap cleanup EXIT INT TERM
53
53 -echo "Downloading portal-tunnel ($TUNNEL_OS/$TUNNEL_ARCH)..." >&2
54 +echo "Downloading portal ($PORTAL_OS/$PORTAL_ARCH)..." >&2
55 curl $CURL_INSECURE_FLAG -fsSL "$BIN_URL" -o "$BIN_PATH"
56
57 echo "Verifying SHA256 checksum..." >&2
@@ -67,155 +68,194 @@ if ! printf '%%s\n' "$EXPECTED_SHA" | grep -Eq '^[0-9a-f]{64}$'; then
68 exit 1
69 fi
70
70 -ACTUAL_SHA="$(sha256sum "$BIN_PATH" | awk '{print $1}')"
71 +if command -v sha256sum >/dev/null 2>&1; then
72 + ACTUAL_SHA="$(sha256sum "$BIN_PATH" | awk '{print $1}')"
73 +elif command -v shasum >/dev/null 2>&1; then
74 + ACTUAL_SHA="$(shasum -a 256 "$BIN_PATH" | awk '{print $1}')"
75 +else
76 + echo "No SHA256 checksum tool found (need sha256sum or shasum)." >&2
77 + exit 1
78 +fi
79 if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
72 - echo "Checksum mismatch for portal-tunnel binary. Aborting (fail-closed)." >&2
80 + echo "Checksum mismatch for portal binary. Aborting (fail-closed)." >&2
81 echo "Hint: relay artifact and checksum may be out of sync or cached stale." >&2
82 exit 1
83 fi
84
77 -chmod +x "$BIN_PATH"
85 +pick_install_path() {
86 + EXISTING="$(command -v portal 2>/dev/null || true)"
87 + if [ -n "$EXISTING" ]; then
88 + EXISTING_DIR="$(dirname "$EXISTING")"
89 + if [ -d "$EXISTING_DIR" ] && [ -w "$EXISTING_DIR" ]; then
90 + printf '%%s\n' "$EXISTING"
91 + return 0
92 + fi
93 + fi
94 +
95 + OLD_IFS="$IFS"
96 + IFS=':'
97 + for DIR in $PATH; do
98 + [ -n "$DIR" ] || continue
99 + if [ -d "$DIR" ] && [ -w "$DIR" ]; then
100 + IFS="$OLD_IFS"
101 + printf '%%s\n' "$DIR/portal"
102 + return 0
103 + fi
104 + done
105 + IFS="$OLD_IFS"
106 +
107 + if [ -n "${HOME:-}" ]; then
108 + for DIR in "$HOME/.local/bin" "$HOME/bin"; do
109 + mkdir -p "$DIR" 2>/dev/null || true
110 + if [ -d "$DIR" ] && [ -w "$DIR" ]; then
111 + printf '%%s\n' "$DIR/portal"
112 + return 0
113 + fi
114 + done
115 + fi
116 +
117 + return 1
118 +}
119
79 -set -- "$BIN_PATH" --relays "$RELAYS" --host "${APP_HOST:-localhost:3000}"
80 -if [ "$DEFAULT_RELAYS" = "0" ] || [ "$DEFAULT_RELAYS" = "false" ]; then
81 - set -- "$@" --default-relays=false
82 -fi
83 -[ -n "${APP_NAME:-}" ] && set -- "$@" --name "$APP_NAME"
84 -[ -n "${APP_DESCRIPTION:-}" ] && set -- "$@" --description "$APP_DESCRIPTION"
85 -[ -n "${APP_TAGS:-}" ] && set -- "$@" --tags "$APP_TAGS"
86 -[ -n "${APP_THUMBNAIL:-}" ] && set -- "$@" --thumbnail "$APP_THUMBNAIL"
87 -[ -n "${APP_OWNER:-}" ] && set -- "$@" --owner "$APP_OWNER"
88 -if [ "${APP_HIDE:-}" = "1" ] || [ "${APP_HIDE:-}" = "true" ]; then
89 - set -- "$@" --hide
90 -fi
120 +write_config() {
121 + CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
122 + CONFIG_DIR="$CONFIG_HOME/portal"
123 + CONFIG_PATH="$CONFIG_DIR/config.json"
124 + mkdir -p "$CONFIG_DIR"
125 + cat > "$CONFIG_PATH" <<'EOF'
126 +%s
127 +EOF
128 + printf '%%s\n' "$CONFIG_PATH"
129 +}
130
92 -echo "Starting portal-tunnel..." >&2
93 -exec "$@"
94 -`
131 +INSTALL_PATH="$(pick_install_path)" || {
132 + echo "No writable install directory found. Create a writable PATH entry or install manually." >&2
133 + exit 1
134 +}
135 +
136 +cp "$BIN_PATH" "$INSTALL_PATH"
137 +chmod +x "$INSTALL_PATH"
138 +CONFIG_PATH="$(write_config)"
139
96 -const tunnelPowerShellScriptTemplate = `$ErrorActionPreference = "Stop"
140 +echo "Installed portal to $INSTALL_PATH" >&2
141 +echo "Saved default relay config to $CONFIG_PATH" >&2
142
143 +INSTALL_DIR="$(dirname "$INSTALL_PATH")"
144 +case ":$PATH:" in
145 + *":$INSTALL_DIR:"*) ;;
146 + *)
147 + echo "Warning: $INSTALL_DIR is not on PATH. Add it before running 'portal expose 3000'." >&2
148 + ;;
149 +esac
150 +
151 +echo "Next step:" >&2
152 +echo " portal expose 3000" >&2
153 +`
154 +
155 +const installPowerShellTemplatePrefix = `$ErrorActionPreference = "Stop"
156 $BaseUrl = if ($env:BASE_URL) { $env:BASE_URL } else { "%s" }
99 -$RelayUrls = if ($env:RELAYS) { $env:RELAYS } else { $BaseUrl }
100 -$DefaultRelays = if ($env:DEFAULT_RELAYS) { $env:DEFAULT_RELAYS } else { "true" }
157 $OriginalSecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol
158 [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
159 +$WorkDir = $null
160 +`
161
104 -$Arch = $env:PROCESSOR_ARCHITECTURE
105 -if ($Arch -eq "AMD64") {
106 - $TunnelArch = "amd64"
107 -} elseif ($Arch -eq "ARM64") {
108 - $TunnelArch = "arm64"
109 -} else {
110 - Write-Error "Unsupported architecture: $Arch"
111 - exit 1
112 -}
113 -
114 -$BinUrl = if ($env:BIN_URL) { $env:BIN_URL } else { "$BaseUrl/tunnel/bin/windows-$TunnelArch" }
115 -$ChecksumUrl = "$BinUrl.sha256"
162 +const installPowerShellTemplateSuffix = `
163 +try {
164 + $Arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
165 + if ($Arch -eq "ARM64") {
166 + $PortalArch = "arm64"
167 + } elseif ($Arch -eq "AMD64" -or $Arch -eq "x86_64") {
168 + $PortalArch = "amd64"
169 + } else {
170 + throw "Unsupported architecture: $Arch"
171 + }
172
117 -$WorkDir = Join-Path $env:TEMP ("portal-tunnel-" + [Guid]::NewGuid().ToString())
118 -New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
119 -$BinPath = Join-Path $WorkDir "portal-tunnel.exe"
173 + $BinUrl = if ($env:BIN_URL) { $env:BIN_URL } else { "$BaseUrl/install/bin/windows-$PortalArch" }
174 + $ChecksumUrl = "$BinUrl.sha256"
175 + $WorkDir = Join-Path $env:TEMP ("portal-install-" + [Guid]::NewGuid().ToString())
176 + New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
177 + $BinPath = Join-Path $WorkDir "portal.exe"
178
121 -try {
122 - Write-Host "Downloading portal-tunnel (windows/$TunnelArch)..."
123 - Invoke-WebRequest -Uri $BinUrl -OutFile $BinPath
124 -} catch {
125 - Write-Error "Failed to download portal-tunnel: $_"
126 - Remove-Item -Recurse -Force $WorkDir
127 - exit 1
128 -}
179 + Write-Host "Downloading portal (windows/$PortalArch)..."
180 + Invoke-WebRequest -UseBasicParsing -Uri $BinUrl -OutFile $BinPath
181
130 -try {
182 Write-Host "Verifying SHA256 checksum..."
132 - $ChecksumPayload = (Invoke-WebRequest -Uri $ChecksumUrl).Content
133 -} catch {
134 - Write-Error "Failed to download checksum from $ChecksumUrl. Aborting (fail-closed)."
135 - Write-Error "Hint: verify relay artifact publishing or CDN cache freshness."
136 - Remove-Item -Recurse -Force $WorkDir
137 - exit 1
138 -}
183 + $ChecksumPayload = (Invoke-WebRequest -UseBasicParsing -Uri $ChecksumUrl).Content
184 + $ChecksumMatch = [regex]::Match($ChecksumPayload, '([A-Fa-f0-9]{64})')
185 + if (-not $ChecksumMatch.Success) {
186 + throw "Invalid checksum payload from $ChecksumUrl. Expected '<sha256> <filename>'."
187 + }
188
140 -$ChecksumMatch = [regex]::Match($ChecksumPayload, '([A-Fa-f0-9]{64})')
141 -if (-not $ChecksumMatch.Success) {
142 - Write-Error "Invalid checksum payload from $ChecksumUrl. Aborting (fail-closed)."
143 - Write-Error "Hint: expected SHA256 sidecar format '<sha256> <filename>'."
144 - Remove-Item -Recurse -Force $WorkDir
145 - exit 1
146 -}
189 + $ExpectedHash = $ChecksumMatch.Groups[1].Value.ToLowerInvariant()
190 + $ActualHash = (Get-FileHash -Algorithm SHA256 -Path $BinPath).Hash.ToLowerInvariant()
191 + if ($ActualHash -ne $ExpectedHash) {
192 + throw "Checksum mismatch for portal binary."
193 + }
194
148 -$ExpectedHash = $ChecksumMatch.Groups[1].Value.ToLowerInvariant()
149 -$ActualHash = (Get-FileHash -Algorithm SHA256 -Path $BinPath).Hash.ToLowerInvariant()
150 -if ($ActualHash -ne $ExpectedHash) {
151 - Write-Error "Checksum mismatch for portal-tunnel binary. Aborting (fail-closed)."
152 - Write-Error "Hint: relay artifact and checksum may be out of sync or cached stale."
153 - Remove-Item -Recurse -Force $WorkDir
154 - exit 1
155 -}
195 + $InstallDir = Join-Path $env:LOCALAPPDATA "portal\bin"
196 + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
197 + $InstallPath = Join-Path $InstallDir "portal.exe"
198 + Copy-Item -Force $BinPath $InstallPath
199
157 -$ArgsList = @("--relays", $RelayUrls)
158 -if ($DefaultRelays.ToLowerInvariant() -eq "0" -or $DefaultRelays.ToLowerInvariant() -eq "false") {
159 - $ArgsList += "--default-relays=false"
160 -}
200 + $ConfigRoot = [Environment]::GetFolderPath("ApplicationData")
201 + if ([string]::IsNullOrWhiteSpace($ConfigRoot)) {
202 + throw "Failed to resolve ApplicationData directory."
203 + }
204
162 -if ($env:APP_HOST) { $ArgsList += "--host", $env:APP_HOST } else { $ArgsList += "--host", "localhost:3000" }
163 -if ($env:APP_NAME) { $ArgsList += "--name", $env:APP_NAME }
164 -if ($env:APP_DESCRIPTION) { $ArgsList += "--description", $env:APP_DESCRIPTION }
165 -if ($env:APP_TAGS) { $ArgsList += "--tags", $env:APP_TAGS }
166 -if ($env:APP_THUMBNAIL) { $ArgsList += "--thumbnail", $env:APP_THUMBNAIL }
167 -if ($env:APP_OWNER) { $ArgsList += "--owner", $env:APP_OWNER }
168 -if ($env:APP_HIDE -eq "1" -or $env:APP_HIDE -eq "true") { $ArgsList += "--hide" }
205 + $ConfigDir = Join-Path $ConfigRoot "portal"
206 + New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
207 + $ConfigPath = Join-Path $ConfigDir "config.json"
208 + $ConfigPayload = @'
209 +%s
210 +'@
211 + $Utf8NoBom = New-Object System.Text.UTF8Encoding $false
212 + [System.IO.File]::WriteAllText($ConfigPath, $ConfigPayload, $Utf8NoBom)
213 +
214 + $UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
215 + $UserEntries = @()
216 + if (-not [string]::IsNullOrWhiteSpace($UserPath)) {
217 + $UserEntries = @($UserPath -split ';' | Where-Object { $_ -ne "" })
218 + }
219 + if (-not ($UserEntries -contains $InstallDir)) {
220 + $NewUserPath = if ([string]::IsNullOrWhiteSpace($UserPath)) {
221 + $InstallDir
222 + } else {
223 + "$InstallDir;$UserPath"
224 + }
225 + [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User")
226 + }
227
170 -Write-Host "Starting portal-tunnel..."
171 -try {
172 - & $BinPath $ArgsList
228 + $SessionEntries = @($env:Path -split ';' | Where-Object { $_ -ne "" })
229 + if (-not ($SessionEntries -contains $InstallDir)) {
230 + $env:Path = "$InstallDir;$env:Path"
231 + }
232 +
233 + Write-Host "Installed portal to $InstallPath"
234 + Write-Host "Saved default relay config to $ConfigPath"
235 + Write-Host "Next step:"
236 + Write-Host " portal expose 3000"
237 } finally {
238 [System.Net.ServicePointManager]::SecurityProtocol = $OriginalSecurityProtocol
175 - if (Test-Path $WorkDir) {
239 + if ($WorkDir -and (Test-Path $WorkDir)) {
240 Remove-Item -Recurse -Force $WorkDir
241 }
242 }
243 `
244
181 -func serveTunnelScript(w http.ResponseWriter, r *http.Request, portalURL string) {
182 - if r.Method != http.MethodGet && r.Method != http.MethodHead {
183 - w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
184 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
185 - return
186 - }
187 -
188 - isWindows := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("os")), "windows")
189 - script := fmt.Sprintf(tunnelScriptTemplate, portalURL)
190 - contentType := "text/x-shellscript"
191 - filename := "tunnel.sh"
192 - if isWindows {
193 - script = fmt.Sprintf(tunnelPowerShellScriptTemplate, portalURL)
194 - contentType = "text/plain; charset=utf-8"
195 - filename = "tunnel.ps1"
196 - }
197 -
198 - w.Header().Set("Content-Type", contentType)
199 - w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename))
200 - if r.Method == http.MethodGet {
201 - _, _ = w.Write([]byte(script))
202 - }
203 -}
204 -
205 -func serveTunnelBinary(w http.ResponseWriter, r *http.Request) {
245 +func serveInstallBinary(w http.ResponseWriter, r *http.Request) {
246 if r.Method != http.MethodGet && r.Method != http.MethodHead {
247 w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
248 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
249 return
250 }
251
212 - slug := strings.Trim(strings.TrimPrefix(r.URL.Path, "/tunnel/bin/"), "/")
252 + slug := strings.Trim(strings.TrimPrefix(r.URL.Path, types.PathInstallBinPrefix), "/")
253 checksumRequest := strings.HasSuffix(slug, ".sha256")
254 if checksumRequest {
255 slug = strings.TrimSuffix(slug, ".sha256")
256 }
257
218 - data, filename, ok := tunnelBinaryBySlug(slug)
258 + data, filename, ok := installBinaryBySlug(slug)
259 if !ok {
260 http.NotFound(w, r)
261 return
@@ -239,17 +279,56 @@ func serveTunnelBinary(w http.ResponseWriter, r *http.Request) {
279 }
280 }
281
242 -func tunnelBinaryBySlug(slug string) ([]byte, string, bool) {
243 - filename := tunnelBinaryName(slug)
282 +func installBinaryBySlug(slug string) ([]byte, string, bool) {
283 + filename := installBinaryName(slug)
284 if data, err := embeddedDistFS.ReadFile("dist/tunnel/" + filename); err == nil {
285 return data, filename, true
286 }
287 return nil, "", false
288 }
289
250 -func tunnelBinaryName(slug string) string {
290 +func installBinaryName(slug string) string {
291 if strings.HasPrefix(slug, "windows-") {
252 - return "portal-tunnel-" + slug + ".exe"
292 + return "portal-" + slug + ".exe"
293 + }
294 + return "portal-" + slug
295 +}
296 +
297 +type installerConfig struct {
298 + Relays []string `json:"relays"`
299 +}
300 +
301 +func serveInstallScript(w http.ResponseWriter, r *http.Request, portalURL string, isWindows bool) {
302 + if r.Method != http.MethodGet && r.Method != http.MethodHead {
303 + w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
304 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
305 + return
306 + }
307 +
308 + configPayload, err := json.Marshal(installerConfig{
309 + Relays: []string{strings.TrimSpace(portalURL)},
310 + })
311 + if err != nil {
312 + http.Error(w, "failed to build installer config", http.StatusInternalServerError)
313 + return
314 + }
315 +
316 + script := fmt.Sprintf(installShellScriptTemplate, portalURL, string(configPayload))
317 + contentType := "text/x-shellscript"
318 + filename := "install.sh"
319 + if isWindows {
320 + script = fmt.Sprintf(
321 + installPowerShellTemplatePrefix+installPowerShellTemplateSuffix,
322 + portalURL,
323 + string(configPayload),
324 + )
325 + contentType = "text/plain; charset=utf-8"
326 + filename = "install.ps1"
327 + }
328 +
329 + w.Header().Set("Content-Type", contentType)
330 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
331 + if r.Method == http.MethodGet {
332 + _, _ = w.Write([]byte(script))
333 }
254 - return "portal-tunnel-" + slug
334 }
docs/architecture.md
+7 -5
@@ -40,7 +40,7 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
40 - SNI listener on `--sni-port` (default `:443`)
41 - Public frontend routes under `/`, `/app`, `/assets/*`
42 - Minimal admin surface at `/admin` and `/admin/leases`
43 -- Tunnel bootstrap routes at `/tunnel` and `/tunnel/bin/*`
43 +- Tunnel bootstrap routes at `/install.sh`, `/install.ps1`, and `/install/bin/*`
44 - Keyless signer endpoint at `/v1/sign`
45
46 ### Relay Core (`portal/`)
@@ -68,9 +68,10 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
68
69 ### Tunnel (`cmd/portal-tunnel`)
70
71 +- Builds the `portal` CLI and exposes subcommands such as `portal expose` and `portal list`
72 - Creates one SDK listener per relay through the SDK and consumes one aggregate listener
73 - Accepts claimed tenant connections from the relay
73 -- Proxies raw TCP to a local `--host`
74 +- Proxies raw TCP to a local target passed to `portal expose`
75 - Returns an HTTP 503 response when the local target is unavailable
76
77 ## Transport Model
@@ -149,8 +150,9 @@ Current relay-served public routes:
150 - `/assets/*`
151 - `/admin`
152 - `/admin/leases`
152 -- `/tunnel`
153 -- `/tunnel/bin/*`
153 +- `/install.sh`
154 +- `/install.ps1`
155 +- `/install/bin/*`
156 - `/healthz`
157 - `/v1/sign`
158 - `/sdk/*`
@@ -167,7 +169,7 @@ Cross-package public contract lives in:
169 - lease metadata
170 - reverse marker/header constants
171 - `types/paths.go`
170 - - shared `/sdk/*`, admin, health, tunnel, and signer paths
172 + - shared `/sdk/*`, admin, health, install, and signer paths
173
174 Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
175
docs/glossary.md
+1 -1
@@ -9,7 +9,7 @@ It does not terminate tenant TLS.
9
10 ## App (Service Publisher)
11
12 -A backend service connected to Portal through `portal-tunnel` or the native Go SDK.
12 +A backend service connected to Portal through the `portal` CLI (`cmd/portal-tunnel`) or the native Go SDK.
13 An app publishes one or more leases and serves traffic from a local process.
14
15 ## Client (Service Consumer)
extensions/vscode/src/extension.ts
+38 -9
@@ -123,20 +123,34 @@ interface TunnelCommandOptions {
123
124 function buildCommand(opts: TunnelCommandOptions): string {
125 const { host, name, relayList, relayUrl, thumbnail, isLocal } = opts;
126 - const tunnelScript = `${relayUrl}/tunnel`;
127 - const thumbEnv = thumbnail ? ` APP_THUMBNAIL=${thumbnail}` : "";
126 + const target = os.platform() === "win32" ? "windows" : "unix";
127 + const installShellUrl = `${relayUrl}/install.sh`;
128 + const installPowerShellUrl = `${relayUrl}/install.ps1`;
129 + const exposeArgs: string[] = [];
130 +
131 + if (name.trim()) {
132 + exposeArgs.push(`--name ${formatToken(name.trim(), target)}`);
133 + }
134 + exposeArgs.push(`--relays ${formatToken(relayList, target)}`);
135 + if (thumbnail.trim()) {
136 + exposeArgs.push(`--thumbnail ${formatToken(thumbnail.trim(), target)}`);
137 + }
138 +
139 + const exposeCommand = `portal expose ${[...exposeArgs, formatToken(host, target)].join(" ")}`;
140
141 if (os.platform() === "win32") {
130 - const thumbEnvWin = thumbnail ? ` $env:APP_THUMBNAIL="${thumbnail}";` : "";
131 - return (
132 - `$ProgressPreference = 'SilentlyContinue'; ` +
133 - `$env:APP_HOST="${host}"; $env:APP_NAME="${name}"; $env:RELAYS="${relayList}";${thumbEnvWin} ` +
134 - `irm ${tunnelScript}?os=windows | iex`
135 - );
142 + return [
143 + `$ProgressPreference = 'SilentlyContinue'`,
144 + `irm ${formatToken(installPowerShellUrl, target)} | iex`,
145 + exposeCommand,
146 + ].join("\n");
147 }
148
149 const curlFlags = isLocal ? "-kfsSL" : "-fsSL";
139 - return `curl ${curlFlags} ${tunnelScript} | APP_HOST=${host} APP_NAME=${name}${thumbEnv} RELAYS="${relayList}" sh`;
150 + return [
151 + `curl ${curlFlags} ${formatToken(installShellUrl, target)} | bash`,
152 + exposeCommand,
153 + ].join("\n");
154 }
155
156 function createTunnelTerminal(): vscode.Terminal {
@@ -159,3 +173,18 @@ function isLocalhost(url: string): boolean {
173 return false;
174 }
175 }
176 +
177 +function quoteShellValue(value: string): string {
178 + return "'" + value.replace(/'/g, `'\"'\"'`) + "'";
179 +}
180 +
181 +function quotePowerShellValue(value: string): string {
182 + return `'${value.replace(/'/g, "''")}'`;
183 +}
184 +
185 +function formatToken(value: string, target: "unix" | "windows"): string {
186 + if (/^[A-Za-z0-9:/.=_,-]+$/.test(value)) {
187 + return value;
188 + }
189 + return target === "windows" ? quotePowerShellValue(value) : quoteShellValue(value);
190 +}
frontend/README.md
+2
@@ -122,6 +122,8 @@ Relay server exposes:
122
123 - `/` - React frontend with SSR bootstrap payload
124 - `/app/` - Static frontend assets
125 +- `/install.sh` - Unix installer for the `portal` CLI
126 +- `/install.ps1` - PowerShell installer for the `portal` CLI
127 - `/healthz` - Health endpoint
128 - `/admin/*` - Admin API/control endpoints used by server management UI
129 - `/sdk/*` - SDK/control endpoints (`/sdk/connect` opens the raw TCP reverse channel used by the relay)
frontend/src/components/TunnelCommandModal.tsx
+75 -65
@@ -18,7 +18,6 @@ interface TunnelCommandModalProps {
18
19 export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
20 const defaultHost = "localhost:3000";
21 - const defaultName = "your-app-name";
21
22 // Get current host URL dynamically
23 const currentOrigin = useMemo(() => {
@@ -28,8 +27,8 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
27 return "https://localhost:4017";
28 }, []);
29
31 - const [host, setHost] = useState(defaultHost);
32 - const [name, setName] = useState(defaultName);
30 + const [target, setTarget] = useState(defaultHost);
31 + const [name, setName] = useState("");
32 const [relayUrls, setRelayUrls] = useState<string[]>([currentOrigin]);
33 const [defaultRelays, setDefaultRelays] = useState(true);
34 const [urlInput, setUrlInput] = useState("");
@@ -83,53 +82,61 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
82
83 // Generate the tunnel command
84 const command = useMemo(() => {
86 - const hostVal = host.trim() === "" ? defaultHost : host.trim();
87 - const nameVal = name.trim() === "" ? defaultName : name.trim();
85 + const targetVal = target.trim() === "" ? defaultHost : target.trim();
86 + const nameVal = name.trim();
87 const relayUrlVal =
88 relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
90 - const tunnelScriptURL = new URL(API_PATHS.tunnel, currentOrigin).toString();
89 + const installScriptURL = new URL(
90 + API_PATHS.install.shell,
91 + currentOrigin
92 + ).toString();
93 + const installPowerShellURL = new URL(
94 + API_PATHS.install.powershell,
95 + currentOrigin
96 + ).toString();
97 const localhostRelay = isLocalRelayOrigin(currentOrigin);
98 + const installerDefaultsMatch =
99 + defaultRelays &&
100 + relayUrls.length === 1 &&
101 + relayUrls[0] === currentOrigin;
102
93 - if (os === "windows") {
94 - const windowsScriptURL = new URL(tunnelScriptURL);
95 - windowsScriptURL.searchParams.set("os", "windows");
96 - const envAssignments = [
97 - "$ProgressPreference = 'SilentlyContinue'",
98 - `$env:APP_HOST=${quotePowerShellValue(hostVal)}`,
99 - `$env:APP_NAME=${quotePowerShellValue(nameVal)}`,
100 - `$env:RELAYS=${quotePowerShellValue(relayUrlVal)}`,
101 - ];
102 - if (!defaultRelays) {
103 - envAssignments.push("$env:DEFAULT_RELAYS='false'");
104 - }
105 - if (normalizedThumbnailURL) {
106 - envAssignments.push(
107 - `$env:APP_THUMBNAIL=${quotePowerShellValue(normalizedThumbnailURL)}`
108 - );
109 - }
110 - return `${envAssignments.join("; ")}; irm ${quotePowerShellValue(
111 - windowsScriptURL.toString()
112 - )} | iex`;
113 - }
103 + const exposeArgs: string[] = [];
104
115 - const curlFlags = localhostRelay ? "-kfsSL" : "-fsSL";
116 - const envAssignments = [
117 - `APP_HOST=${quoteShellValue(hostVal)}`,
118 - `APP_NAME=${quoteShellValue(nameVal)}`,
119 - `RELAYS=${quoteShellValue(relayUrlVal)}`,
120 - ];
105 + if (nameVal !== "") {
106 + exposeArgs.push(`--name ${formatToken(nameVal, os)}`);
107 + }
108 + if (!installerDefaultsMatch) {
109 + exposeArgs.push(`--relays ${formatToken(relayUrlVal, os)}`);
110 + }
111 if (!defaultRelays) {
122 - envAssignments.push(`DEFAULT_RELAYS=${quoteShellValue("false")}`);
112 + exposeArgs.push("--default-relays=false");
113 }
114 if (normalizedThumbnailURL) {
125 - envAssignments.push(
126 - `APP_THUMBNAIL=${quoteShellValue(normalizedThumbnailURL)}`
127 - );
115 + exposeArgs.push(`--thumbnail ${formatToken(normalizedThumbnailURL, os)}`);
116 + }
117 +
118 + if (os === "windows") {
119 + return [
120 + `$ProgressPreference = 'SilentlyContinue'`,
121 + `irm ${formatToken(installPowerShellURL, os)} | iex`,
122 + `portal expose ${[...exposeArgs, formatToken(targetVal, os)].join(" ")}`,
123 + ].join("\n");
124 }
129 - return `curl ${curlFlags} ${quoteShellValue(
130 - tunnelScriptURL
131 - )} | ${envAssignments.join(" ")} sh`;
132 - }, [currentOrigin, defaultRelays, host, name, normalizedThumbnailURL, relayUrls, os]);
125 +
126 + const curlFlags = localhostRelay ? "-ksSL" : "-sSL";
127 + return [
128 + `curl ${curlFlags} ${formatToken(installScriptURL, os)} | bash`,
129 + `portal expose ${[...exposeArgs, formatToken(targetVal, os)].join(" ")}`,
130 + ].join("\n");
131 + }, [
132 + currentOrigin,
133 + defaultRelays,
134 + name,
135 + normalizedThumbnailURL,
136 + os,
137 + relayUrls,
138 + target,
139 + ]);
140
141 const handleCopy = async () => {
142 try {
@@ -150,7 +157,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
157 </Button>
158 )}
159 </DialogTrigger>
153 - <DialogContent className="sm:max-w-[500px] rounded-sm max-h-[85vh] overflow-y-auto">
160 + <DialogContent className="sm:max-w-[550px] rounded-sm max-h-[85vh] overflow-y-auto">
161 <DialogHeader>
162 <DialogTitle className="flex items-center gap-2">
163 <Terminal className="w-5 h-5" />
@@ -162,22 +169,18 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
169 {/* Host Input */}
170 <div className="space-y-2">
171 <label
165 - htmlFor="host"
172 + htmlFor="target"
173 className="text-sm font-medium text-foreground"
174 >
175 Host
176 </label>
170 - <div className="flex items-center rounded-md bg-border">
171 - <span className="px-3 text-sm text-text-muted">APP_HOST=</span>
172 - <Input
173 - id="host"
174 - type="text"
175 - value={host}
176 - onChange={(e) => setHost(e.target.value)}
177 - placeholder={defaultHost}
178 - className="rounded-l-none"
179 - />
180 - </div>
177 + <Input
178 + id="target"
179 + type="text"
180 + value={target}
181 + onChange={(e) => setTarget(e.target.value)}
182 + placeholder={defaultHost}
183 + />
184 </div>
185
186 {/* Name Input */}
@@ -188,17 +191,13 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
191 >
192 Service Name
193 </label>
191 - <div className="flex items-center rounded-md bg-border">
192 - <span className="px-3 text-sm text-text-muted">APP_NAME=</span>
193 - <Input
194 - id="name"
195 - type="text"
196 - value={name}
197 - onChange={(e) => setName(e.target.value)}
198 - placeholder={defaultName}
199 - className="rounded-l-none"
200 - />
201 - </div>
194 + <Input
195 + id="name"
196 + type="text"
197 + value={name}
198 + onChange={(e) => setName(e.target.value)}
199 + placeholder="auto-generated when empty"
200 + />
201 </div>
202
203 {/* Relay URLs Input */}
@@ -325,6 +324,10 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
324 )}
325 </button>
326 </div>
327 + <p className="text-xs text-muted-foreground">
328 + After installation, run <code>portal list</code> to inspect the
329 + configured public relays.
330 + </p>
331 </div>
332 </div>
333 </DialogContent>
@@ -355,6 +358,13 @@ function quotePowerShellValue(value: string): string {
358 return `'${value.replace(/'/g, "''")}'`;
359 }
360
361 +function formatToken(value: string, os: "unix" | "windows"): string {
362 + if (/^[A-Za-z0-9:/.=_-]+$/.test(value)) {
363 + return value;
364 + }
365 + return os === "windows" ? quotePowerShellValue(value) : quoteShellValue(value);
366 +}
367 +
368 function normalizeAbsoluteHTTPURL(raw: string): string {
369 const trimmed = raw.trim();
370 if (trimmed === "") {
frontend/src/lib/apiPaths.test.ts
+5 -2
@@ -30,7 +30,10 @@ describe("API_PATHS contract alignment", () => {
30 );
31 });
32
33 - it("keeps tunnel installer endpoint aligned", () => {
34 - expect(API_PATHS.tunnel).toBe("/tunnel");
33 + it("keeps install script endpoints aligned", () => {
34 + expect(API_PATHS.install).toEqual({
35 + shell: "/install.sh",
36 + powershell: "/install.ps1",
37 + });
38 });
39 });
frontend/src/lib/apiPaths.ts
+4 -1
@@ -19,8 +19,11 @@ export const API_PATHS = {
19 connect: "/sdk/connect",
20 },
21 healthz: "/healthz",
22 + install: {
23 + shell: "/install.sh",
24 + powershell: "/install.ps1",
25 + },
26 appPrefix: "/app/",
23 - tunnel: "/tunnel",
27 } as const;
28
29 export const ROUTE_PATHS = {
types/paths.go
+3 -2
@@ -18,8 +18,9 @@ const (
18 PathAdminSettings = "/admin/settings"
19 PathAdminApproval = "/admin/settings/approval-mode"
20 PathAdminIPsPrefix = "/admin/ips/"
21 - PathTunnel = "/tunnel"
22 - PathTunnelBinPrefix = "/tunnel/bin/"
21 + PathInstallShell = "/install.sh"
22 + PathInstallPowerShell = "/install.ps1"
23 + PathInstallBinPrefix = "/install/bin/"
24
25 PathSDKPrefix = "/sdk/"
26 PathSDKDomain = "/sdk/domain"