Skip to content

Commit 2d21afe

Browse files
authored
Merge pull request #154 from gosuda/feature/tunnel-http-route
Feature/tunnel http route
2 parents 19d300c + 3ddcdd0 commit 2d21afe

14 files changed

Lines changed: 482 additions & 31 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ profile.cov
3131
# env file
3232
.env
3333

34+
# oh-my-claudecode state
35+
.omc/
36+
3437
# Editor/IDE
3538
# .idea/
3639
# .vscode/

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Unlike other tunneling services, Portal is self-hosted and permissionless. You c
2121
- **End-to-end tenant TLS**: Relay routes by SNI, while tenant TLS terminates on your side with relay-backed keyless signing
2222
- **Permissionless Hosting**: Anyone can run their own Portal — no approval needed
2323
- **One-Command Setup**: Expose any local app with a single command
24-
- **UDP Relay (Experimental)**: Supports raw UDP relay use cases, but the transport model and operational behavior may still change
24+
- **UDP Relay (Experimental)**: Supports raw UDP relay
2525

2626
## How Portal Provides End-to-End Encryption
2727

@@ -34,7 +34,7 @@ Portal is designed so that tenant TLS terminates on your side rather than at the
3434
5. Session keys are derived entirely on your side. The relay provides certificate signatures only and does not receive tenant traffic secrets.
3535
6. After the handshake, the relay continues forwarding ciphertext without needing tenant TLS plaintext to keep routing traffic.
3636

37-
Portal also checks that the relay is preserving TLS passthrough. The Portal client connects to its own public endpoint and compares TLS exporter values observed on both client-controlled ends. If they differ, Portal logs suspected TLS termination by default. You can switch to strict enforcement with `portal expose --ban-mitm`.
37+
Portal also checks that the relay is preserving TLS passthrough. The Portal client connects to its own public endpoint and compares TLS exporter values observed on both client-controlled ends. If they differ, `portal expose` rejects the relay by default.
3838

3939
## Components
4040

cmd/portal-tunnel/README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,23 @@ portal expose localhost:8080 \
2828
--owner "Portal Operator"
2929
```
3030

31+
Multi-port HTTP aggregation example:
32+
33+
```text
34+
portal expose --name myapp \
35+
--http-route /api=http://127.0.0.1:3001 \
36+
--http-route /=http://127.0.0.1:5173
37+
```
38+
3139
## Commands
3240

3341
### `portal expose [flags] <target>`
3442

3543
- `<target>` accepts a bare port like `3000`, a `host:port`, or an `http(s)://host:port` URL.
3644
- Bare ports resolve to `127.0.0.1:<port>`.
45+
- Instead of `<target>`, you can repeat `--http-route PATH=UPSTREAM` to aggregate multiple local HTTP services behind one public URL.
46+
- Route matching is longest-prefix-first. `/api=http://127.0.0.1:3001` matches `/api/*` and strips the `/api` prefix before proxying to the upstream.
47+
- Routed HTTP mode automatically forwards `X-Forwarded-*`, rewrites upstream `Location` redirects back to the public route path, and strips loopback cookie domains while remapping cookie paths to the mounted route prefix.
3748
- `--name` is optional. When omitted, the CLI generates a name for that run.
3849
- `--relays` sets the relay API URLs for that run.
3950
- `--discovery=false` disables the public registry seed list and the discovery expansion loop for that run.
@@ -51,6 +62,7 @@ Flags:
5162
--thumbnail Service thumbnail URL metadata
5263
--owner Service owner metadata
5364
--hide Hide service from discovery
65+
--http-route HTTP route mapping in PATH=UPSTREAM form; repeat for multiple routes
5466
```
5567

5668
### `portal list [flags]`
@@ -62,7 +74,7 @@ Legacy execution compatibility has been removed:
6274

6375
- Use `portal expose ...` explicitly; bare `portal [flags]` is no longer accepted.
6476
- Runtime `APP_*`, `RELAYS`, and `DEFAULT_RELAYS` environment variable fallbacks are no longer used.
65-
- Pass the local target as the required positional `<target>` argument.
77+
- Pass either the local target as the positional `<target>` argument or repeat `--http-route` for routed HTTP mode.
6678

6779
## Install Behavior
6880

@@ -82,5 +94,6 @@ Legacy execution compatibility has been removed:
8294
- With discovery enabled, the configured relay list starts with `public registry + --relays values` and can expand through relay discovery. With `--discovery=false`, only the explicit relay URLs are used. Published public URLs appear only for relays that have registered successfully.
8395
- SDK callers that do not set `ListenerConfig.RetryCount` use infinite retry semantics for each relay.
8496
- Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
85-
- TLS self-probe mismatches log warnings by default. Use `--ban-mitm` to reject relays that terminate tenant TLS.
97+
- `portal expose` enables MITM strict enforcement by default. Use `--ban-mitm=false` to keep warning-only behavior when the TLS self-probe suspects relay termination.
8698
- When the local service is unreachable, the tunnel returns an HTTP 503 page.
99+
- `--http-route` mode is HTTP-only and cannot be combined with `--udp`.

cmd/portal-tunnel/http_routes.go

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net"
7+
"net/http"
8+
"net/http/httputil"
9+
"net/url"
10+
"sort"
11+
"strings"
12+
13+
"github.com/rs/zerolog/log"
14+
15+
"github.com/gosuda/portal/v2/utils"
16+
)
17+
18+
type httpRoute struct {
19+
prefix string
20+
upstream *url.URL
21+
proxy *httputil.ReverseProxy
22+
}
23+
24+
func newHTTPRouteHandler(rawRoutes []string) (http.Handler, error) {
25+
if len(rawRoutes) == 0 {
26+
return nil, errors.New("at least one --http-route is required")
27+
}
28+
29+
routes := make([]*httpRoute, 0, len(rawRoutes))
30+
seen := make(map[string]struct{}, len(rawRoutes))
31+
for _, raw := range rawRoutes {
32+
route, err := parseHTTPRoute(raw)
33+
if err != nil {
34+
return nil, err
35+
}
36+
if _, ok := seen[route.prefix]; ok {
37+
return nil, fmt.Errorf("duplicate --http-route prefix %q", route.prefix)
38+
}
39+
seen[route.prefix] = struct{}{}
40+
route.proxy = route.newReverseProxy()
41+
routes = append(routes, route)
42+
}
43+
44+
// longest-prefix-first
45+
sort.Slice(routes, func(i, j int) bool {
46+
if len(routes[i].prefix) == len(routes[j].prefix) {
47+
return routes[i].prefix < routes[j].prefix
48+
}
49+
return len(routes[i].prefix) > len(routes[j].prefix)
50+
})
51+
52+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+
p := r.URL.Path
54+
if p == "" {
55+
p = "/"
56+
}
57+
for _, route := range routes {
58+
if route.prefix == "/" || p == route.prefix || strings.HasPrefix(p, route.prefix+"/") {
59+
route.proxy.ServeHTTP(w, r)
60+
return
61+
}
62+
}
63+
http.NotFound(w, r)
64+
}), nil
65+
}
66+
67+
func parseHTTPRoute(raw string) (*httpRoute, error) {
68+
raw = strings.TrimSpace(raw)
69+
if raw == "" {
70+
return nil, errors.New("--http-route: expected PATH=UPSTREAM")
71+
}
72+
73+
prefixRaw, upstreamRaw, ok := strings.Cut(raw, "=")
74+
if !ok {
75+
return nil, fmt.Errorf("--http-route %q: expected PATH=UPSTREAM", raw)
76+
}
77+
78+
prefix := strings.TrimSpace(prefixRaw)
79+
if prefix == "" {
80+
return nil, fmt.Errorf("--http-route %q: prefix is required", raw)
81+
}
82+
if !strings.HasPrefix(prefix, "/") {
83+
return nil, fmt.Errorf("--http-route %q: prefix must start with /", raw)
84+
}
85+
prefix = utils.NormalizeURLPath(prefix)
86+
87+
upstreamInput := strings.TrimSpace(upstreamRaw)
88+
if upstreamInput == "" {
89+
return nil, fmt.Errorf("--http-route %q: upstream is required", raw)
90+
}
91+
if !strings.Contains(upstreamInput, "://") {
92+
target, err := utils.NormalizeLoopbackTarget(upstreamInput)
93+
if err != nil {
94+
return nil, fmt.Errorf("--http-route %q: %w", raw, err)
95+
}
96+
upstreamInput = "http://" + target
97+
}
98+
99+
upstream, err := url.Parse(upstreamInput)
100+
if err != nil {
101+
return nil, fmt.Errorf("--http-route %q: %w", raw, err)
102+
}
103+
if upstream.Host == "" {
104+
return nil, fmt.Errorf("--http-route %q: upstream host is required", raw)
105+
}
106+
if upstream.Scheme != "http" && upstream.Scheme != "https" {
107+
return nil, fmt.Errorf("--http-route %q: scheme must be http or https", raw)
108+
}
109+
upstream.Fragment = ""
110+
upstream.Path = utils.NormalizeURLPath(upstream.Path)
111+
112+
return &httpRoute{prefix: prefix, upstream: upstream}, nil
113+
}
114+
115+
func (r *httpRoute) newReverseProxy() *httputil.ReverseProxy {
116+
return &httputil.ReverseProxy{
117+
Rewrite: r.rewriteRequest,
118+
ModifyResponse: func(resp *http.Response) error {
119+
if resp == nil || resp.Request == nil {
120+
return nil
121+
}
122+
publicHost := resp.Request.Header.Get("X-Forwarded-Host")
123+
publicScheme := resp.Request.Header.Get("X-Forwarded-Proto")
124+
r.rewriteLocation(resp.Header, publicHost, publicScheme)
125+
r.rewriteSetCookies(resp.Header, publicHost)
126+
return nil
127+
},
128+
ErrorHandler: func(w http.ResponseWriter, req *http.Request, err error) {
129+
log.Error().Err(err).
130+
Str("route_prefix", r.prefix).
131+
Str("upstream", r.upstream.String()).
132+
Msg("http route proxy failed")
133+
http.Error(w, "bad gateway", http.StatusBadGateway)
134+
},
135+
}
136+
}
137+
138+
func (r *httpRoute) rewriteRequest(pr *httputil.ProxyRequest) {
139+
// strip route prefix from the path before forwarding
140+
reqPath := utils.NormalizeURLPath(pr.In.URL.Path)
141+
rawPath := pr.In.URL.RawPath
142+
if r.prefix != "/" {
143+
if reqPath == r.prefix {
144+
reqPath = "/"
145+
rawPath = ""
146+
} else {
147+
reqPath = strings.TrimPrefix(reqPath, r.prefix)
148+
if reqPath == "" {
149+
reqPath = "/"
150+
}
151+
if rawPath == r.prefix {
152+
rawPath = "/"
153+
} else if strings.HasPrefix(rawPath, r.prefix+"/") {
154+
rawPath = strings.TrimPrefix(rawPath, r.prefix)
155+
}
156+
}
157+
}
158+
159+
pr.Out.URL.Path = reqPath
160+
pr.Out.URL.RawPath = rawPath
161+
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
162+
pr.SetURL(r.upstream)
163+
pr.SetXForwarded()
164+
165+
// SetXForwarded checks pr.In.TLS, but behind a TLS-terminating proxy
166+
// the inbound X-Forwarded-Proto carries the real client scheme.
167+
if pr.In.TLS == nil {
168+
proto, _, _ := strings.Cut(pr.In.Header.Get("X-Forwarded-Proto"), ",")
169+
if proto = strings.ToLower(strings.TrimSpace(proto)); proto != "" {
170+
pr.Out.Header.Set("X-Forwarded-Proto", proto)
171+
}
172+
}
173+
174+
if r.prefix != "/" {
175+
pr.Out.Header.Set("X-Forwarded-Prefix", r.prefix)
176+
}
177+
}
178+
179+
func (r *httpRoute) rewriteLocation(header http.Header, publicHost, publicScheme string) {
180+
location := header.Get("Location")
181+
if location == "" {
182+
return
183+
}
184+
parsed, err := url.Parse(location)
185+
if err != nil {
186+
return
187+
}
188+
189+
switch {
190+
case parsed.IsAbs():
191+
if !strings.EqualFold(parsed.Scheme, r.upstream.Scheme) || !strings.EqualFold(parsed.Host, r.upstream.Host) {
192+
return
193+
}
194+
parsed.Scheme = publicScheme
195+
parsed.Host = publicHost
196+
case strings.HasPrefix(location, "/") && parsed.Host == "" && (len(location) == 1 || (location[1] != '\\' && location[1] != '/')):
197+
// server-relative redirect
198+
default:
199+
return
200+
}
201+
202+
mapped := r.mapUpstreamPathToPublic(parsed.Path)
203+
if !strings.HasPrefix(mapped, "/") || (len(mapped) > 1 && (mapped[1] == '/' || mapped[1] == '\\')) {
204+
return
205+
}
206+
parsed.Path = mapped
207+
parsed.RawPath = ""
208+
header.Set("Location", parsed.String())
209+
}
210+
211+
func (r *httpRoute) rewriteSetCookies(header http.Header, publicHost string) {
212+
values := header.Values("Set-Cookie")
213+
if len(values) == 0 {
214+
return
215+
}
216+
217+
publicDomain := publicHost
218+
if host, port, err := net.SplitHostPort(publicDomain); err == nil && port != "" {
219+
publicDomain = host
220+
}
221+
publicDomain = strings.ToLower(strings.Trim(publicDomain, "[]"))
222+
upstreamDomain := strings.ToLower(r.upstream.Hostname())
223+
224+
header.Del("Set-Cookie")
225+
for _, value := range values {
226+
cookie, err := http.ParseSetCookie(value)
227+
if err != nil {
228+
header.Add("Set-Cookie", value)
229+
continue
230+
}
231+
232+
changed := false
233+
if cookie.Path != "" {
234+
if rewritten := r.mapUpstreamPathToPublic(cookie.Path); rewritten != cookie.Path {
235+
cookie.Path = rewritten
236+
changed = true
237+
}
238+
}
239+
240+
domain := strings.ToLower(strings.TrimPrefix(cookie.Domain, "."))
241+
if domain != "" && domain != publicDomain &&
242+
(domain == upstreamDomain || utils.IsLocalRelayHost(domain)) {
243+
cookie.Domain = ""
244+
changed = true
245+
}
246+
247+
if changed {
248+
header.Add("Set-Cookie", cookie.String())
249+
} else {
250+
header.Add("Set-Cookie", value)
251+
}
252+
}
253+
}
254+
255+
func (r *httpRoute) mapUpstreamPathToPublic(raw string) string {
256+
raw = utils.NormalizeURLPath(raw)
257+
if r.prefix != "/" && (raw == r.prefix || strings.HasPrefix(raw, r.prefix+"/")) {
258+
return raw
259+
}
260+
261+
base := utils.NormalizeURLPath(r.upstream.Path)
262+
rest := raw
263+
if base != "/" {
264+
if raw == base {
265+
rest = "/"
266+
} else if strings.HasPrefix(raw, base+"/") {
267+
rest = strings.TrimPrefix(raw, base)
268+
}
269+
}
270+
271+
if r.prefix == "/" {
272+
return rest
273+
}
274+
if rest == "/" {
275+
return r.prefix
276+
}
277+
return r.prefix + rest
278+
}

0 commit comments

Comments
 (0)