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"