feat: add portal tunnel functionality and installer script
rabbitprincess committed
Dec 5, 2025 at 20:43 UTC
157629d873373eb5e5ee2ba5a15742284c9f47f0
7 files changed
+228
-7
.dockerignore
+4
-1
@@ -1,5 +1,4 @@
1
bin/
2
-docker-compose.override.yaml
2
3
# Git
4
.git
@@ -33,3 +32,7 @@ coverage.html
32
*.test
33
*.prof
34
vendor/
35
+
36
+# Frontend deps/build artifacts
37
+node_modules/
38
+**/node_modules/
Dockerfile
+1
-1
@@ -18,7 +18,7 @@ ARG TARGETOS
18
ARG TARGETARCH
19
RUN --mount=type=cache,target=/go/pkg/mod \
20
--mount=type=cache,target=/root/.cache/go-build \
21
- make build-wasm && \
21
+ make build-wasm build-tunnel && \
22
GOOS=${TARGETOS} GOARCH=${TARGETARCH} make build-server
23
24
FROM gcr.io/distroless/static-debian12:nonroot
Makefile
+16
-3
@@ -1,6 +1,6 @@
1
SHELL := /bin/sh
2
3
-.PHONY: help run build build-wasm compress-wasm build-frontend build-server clean
3
+.PHONY: help run build build-wasm compress-wasm build-frontend build-tunnel build-server clean
4
5
.DEFAULT_GOAL := help
6
@@ -18,7 +18,7 @@ run:
18
./bin/relay-server
19
20
# Convenience target
21
-build: build-wasm build-frontend build-server
21
+build: build-wasm build-frontend build-tunnel build-server
22
23
build-protoc:
24
protoc -I . \
@@ -84,7 +84,19 @@ build-frontend:
84
@cd cmd/relay-server/frontend && npm i && npm run build
85
@echo "[frontend] build complete"
86
87
-# Build Go relay server (embeds WASM from cmd/relay-server/static)
87
+# Build portal-tunnel binaries for distribution
88
+build-tunnel:
89
+ @echo "[tunnel] building portal-tunnel binaries..."
90
+ @mkdir -p cmd/relay-server/dist/tunnel
91
+ @for GOOS in linux darwin; do \
92
+ for GOARCH in amd64 arm64; do \
93
+ OUT="cmd/relay-server/dist/tunnel/portal-tunnel-$${GOOS}-$${GOARCH}"; \
94
+ echo " - $${OUT}"; \
95
+ CGO_ENABLED=0 GOOS=$${GOOS} GOARCH=$${GOARCH} go build -trimpath -ldflags "-s -w" -o "$${OUT}" ./cmd/portal-tunnel; \
96
+ done; \
97
+ done
98
+
99
+# Build Go relay server
100
build-server:
101
@echo "[server] building Go portal..."
102
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o bin/relay-server ./cmd/relay-server
@@ -93,3 +105,4 @@ clean:
105
rm -rf bin
106
rm -rf cmd/relay-server/dist/app
107
rm -rf cmd/relay-server/dist/wasm
108
+ rm -rf cmd/relay-server/dist/tunnel
README.md
+7
-1
@@ -55,7 +55,13 @@ BOOTSTRAP_URIS: wss://portal.example.com/relay
55
```
56
57
### Running a Portal App
58
-See [portal-toys](https://github.com/gosuda/portal-toys)
58
+
59
+1. Start your local service
60
+
61
+2. Run the tunnel client to expose
62
+```sh
63
+curl -fsSL http://localhost:4017/tunnel | PORT=3000 NAME=myapp sh
64
+```
65
66
## Architecture
67
cmd/relay-server/tunnel.go
new
+119
@@ -0,0 +1,119 @@
1
+package main
2
+
3
+import (
4
+ "fmt"
5
+ "net/http"
6
+ "strings"
7
+
8
+ "github.com/rs/zerolog/log"
9
+
10
+ "gosuda.org/portal/utils"
11
+)
12
+
13
+const tunnelScriptTemplate = `#!/usr/bin/env sh
14
+set -e
15
+
16
+OS="$(uname -s)"
17
+case "$OS" in
18
+ Linux) TUNNEL_OS="linux" ;;
19
+ Darwin) TUNNEL_OS="darwin" ;;
20
+ *)
21
+ echo "Unsupported OS: $OS" >&2
22
+ exit 1
23
+ ;;
24
+esac
25
+
26
+ARCH="$(uname -m)"
27
+case "$ARCH" in
28
+ x86_64|amd64) TUNNEL_ARCH="amd64" ;;
29
+ arm64|aarch64) TUNNEL_ARCH="arm64" ;;
30
+ *)
31
+ echo "Unsupported architecture: $ARCH" >&2
32
+ exit 1
33
+ ;;
34
+esac
35
+
36
+BASE_URL="${BASE_URL:-%s}"
37
+RELAY_URL="${RELAY_URL:-$BASE_URL}"
38
+BIN_URL="${BIN_URL:-$BASE_URL/tunnel/bin/$TUNNEL_OS-$TUNNEL_ARCH}"
39
+
40
+TMPDIR="${TMPDIR:-/tmp}"
41
+WORKDIR="$(mktemp -d "$TMPDIR/portal-tunnel.XXXXXX" 2>/dev/null || mktemp -d -t portal-tunnel)"
42
+BIN_PATH="$WORKDIR/portal-tunnel"
43
+cleanup() { rm -rf "$WORKDIR"; }
44
+trap cleanup EXIT INT TERM
45
+
46
+echo "Downloading portal-tunnel ($TUNNEL_OS/$TUNNEL_ARCH)..." >&2
47
+curl -fsSL "$BIN_URL" -o "$BIN_PATH"
48
+chmod +x "$BIN_PATH"
49
+
50
+set -- "$BIN_PATH" --relay "$RELAY_URL" --host "${HOST:-localhost}" --port "${PORT:-4018}"
51
+[ -n "${NAME:-}" ] && set -- "$@" --name "$NAME"
52
+[ -n "${DESCRIPTION:-}" ] && set -- "$@" --description "$DESCRIPTION"
53
+[ -n "${TAGS:-}" ] && set -- "$@" --tags "$TAGS"
54
+[ -n "${THUMBNAIL:-}" ] && set -- "$@" --thumbnail "$THUMBNAIL"
55
+[ -n "${OWNER:-}" ] && set -- "$@" --owner "$OWNER"
56
+if [ "${HIDE:-}" = "1" ] || [ "${HIDE:-}" = "true" ]; then
57
+ set -- "$@" --hide
58
+fi
59
+
60
+echo "Starting portal-tunnel..." >&2
61
+exec "$@"
62
+`
63
+
64
+func serveTunnelScript(w http.ResponseWriter, r *http.Request) {
65
+ utils.SetCORSHeaders(w)
66
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
67
+ w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
68
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
69
+ return
70
+ }
71
+
72
+ baseURL := utils.DetectBaseURL(r, flagPortalURL)
73
+ script := fmt.Sprintf(tunnelScriptTemplate, baseURL)
74
+
75
+ w.Header().Set("Content-Type", "text/x-shellscript")
76
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
77
+ w.Header().Set("Content-Disposition", "inline; filename=\"tunnel.sh\"")
78
+ w.WriteHeader(http.StatusOK)
79
+ if r.Method == http.MethodGet {
80
+ w.Write([]byte(script))
81
+ }
82
+}
83
+
84
+func serveTunnelBinary(w http.ResponseWriter, r *http.Request) {
85
+ utils.SetCORSHeaders(w)
86
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
87
+ w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
88
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
89
+ return
90
+ }
91
+
92
+ slug := strings.TrimPrefix(r.URL.Path, "/tunnel/bin/")
93
+ slug = strings.Trim(slug, "/")
94
+ path, ok := map[string]string{
95
+ "linux-amd64": "dist/tunnel/portal-tunnel-linux-amd64",
96
+ "linux-arm64": "dist/tunnel/portal-tunnel-linux-arm64",
97
+ "darwin-amd64": "dist/tunnel/portal-tunnel-darwin-amd64",
98
+ "darwin-arm64": "dist/tunnel/portal-tunnel-darwin-arm64",
99
+ }[slug]
100
+ if !ok {
101
+ http.NotFound(w, r)
102
+ return
103
+ }
104
+
105
+ data, err := distFS.ReadFile(path)
106
+ if err != nil {
107
+ log.Error().Err(err).Str("path", path).Msg("failed to read embedded tunnel binary")
108
+ http.NotFound(w, r)
109
+ return
110
+ }
111
+
112
+ w.Header().Set("Content-Type", "application/octet-stream")
113
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"portal-tunnel-%s\"", slug))
114
+ w.Header().Set("Cache-Control", "public, max-age=600")
115
+ w.WriteHeader(http.StatusOK)
116
+ if r.Method == http.MethodGet {
117
+ w.Write(data)
118
+ }
119
+}
cmd/relay-server/view.go
+16
-1
@@ -82,6 +82,14 @@ func serveHTTP(addr string, serv *portal.RelayServer, bpsManager *BPSManager, no
82
servePortalStaticFile(w, r, p)
83
})
84
85
+ // Tunnel installer script and binaries
86
+ appMux.HandleFunc("/tunnel", func(w http.ResponseWriter, r *http.Request) {
87
+ serveTunnelScript(w, r)
88
+ })
89
+ appMux.HandleFunc("/tunnel/bin/", func(w http.ResponseWriter, r *http.Request) {
90
+ serveTunnelBinary(w, r)
91
+ })
92
+
93
appMux.HandleFunc("/relay", func(w http.ResponseWriter, r *http.Request) {
94
if r.Method != http.MethodGet {
95
w.Header().Set("Allow", http.MethodGet)
@@ -141,6 +149,14 @@ func serveHTTP(addr string, serv *portal.RelayServer, bpsManager *BPSManager, no
149
serveDynamicServiceWorker(w, r)
150
})
151
152
+ // Tunnel script and binaries for portal subdomains as well
153
+ portalMux.HandleFunc("/tunnel", func(w http.ResponseWriter, r *http.Request) {
154
+ serveTunnelScript(w, r)
155
+ })
156
+ portalMux.HandleFunc("/tunnel/bin/", func(w http.ResponseWriter, r *http.Request) {
157
+ serveTunnelBinary(w, r)
158
+ })
159
+
160
// Root and SPA fallback for portal subdomains
161
portalMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
162
utils.SetCORSHeaders(w)
@@ -684,4 +700,3 @@ func loadAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager) {
700
Int("bps_limits_count", len(settings.BPSLimits)).
701
Msg("[Admin] Loaded admin settings")
702
}
687
-
utils/utils.go
+65
@@ -193,6 +193,71 @@ func SetCORSHeaders(w http.ResponseWriter) {
193
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Accept-Encoding")
194
}
195
196
+// ForwardedHost returns the host from X-Forwarded-Host (first value) or falls back to r.Host.
197
+func ForwardedHost(r *http.Request) string {
198
+ if r == nil {
199
+ return ""
200
+ }
201
+ if h := r.Header.Get("X-Forwarded-Host"); h != "" {
202
+ parts := strings.Split(h, ",")
203
+ return strings.TrimSpace(parts[0])
204
+ }
205
+ return r.Host
206
+}
207
+
208
+// IsHTTPS reports whether the request is HTTPS, checking TLS or X-Forwarded-Proto.
209
+func IsHTTPS(r *http.Request) bool {
210
+ if r == nil {
211
+ return false
212
+ }
213
+ if r.TLS != nil {
214
+ return true
215
+ }
216
+ proto := r.Header.Get("X-Forwarded-Proto")
217
+ return strings.EqualFold(proto, "https")
218
+}
219
+
220
+// RequestScheme returns "https" when the request is HTTPS and "http" otherwise.
221
+func RequestScheme(r *http.Request) string {
222
+ if IsHTTPS(r) {
223
+ return "https"
224
+ }
225
+ return "http"
226
+}
227
+
228
+// DetectBaseURL builds a base URL (scheme://host) using request headers with a fallback portal URL.
229
+func DetectBaseURL(r *http.Request, fallbackPortalURL string) string {
230
+ scheme := RequestScheme(r)
231
+ host := ForwardedHost(r)
232
+ if host == "" && fallbackPortalURL != "" {
233
+ if u, err := url.Parse(fallbackPortalURL); err == nil {
234
+ host = u.Host
235
+ if u.Scheme != "" {
236
+ scheme = u.Scheme
237
+ }
238
+ }
239
+ }
240
+ return fmt.Sprintf("%s://%s", scheme, host)
241
+}
242
+
243
+// DetectRelayURL builds a relay WebSocket URL (ws[s]://host/relay) using request headers with a fallback portal URL.
244
+func DetectRelayURL(r *http.Request, fallbackPortalURL string) string {
245
+ wsScheme := "ws"
246
+ if IsHTTPS(r) {
247
+ wsScheme = "wss"
248
+ }
249
+ host := ForwardedHost(r)
250
+ if host == "" && fallbackPortalURL != "" {
251
+ if u, err := url.Parse(fallbackPortalURL); err == nil {
252
+ host = u.Host
253
+ if u.Scheme == "https" {
254
+ wsScheme = "wss"
255
+ }
256
+ }
257
+ }
258
+ return fmt.Sprintf("%s://%s/relay", wsScheme, host)
259
+}
260
+
261
// IsSubdomain reports whether host matches the given domain pattern.
262
// Supports patterns like:
263
// - "*.example.com" (wildcard for any subdomain of example.com)