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)