diff --git a/apps/api/src/box/services/tunnel.service.spec.ts b/apps/api/src/box/services/tunnel.service.spec.ts index 8895205a2..6bc486a72 100644 --- a/apps/api/src/box/services/tunnel.service.spec.ts +++ b/apps/api/src/box/services/tunnel.service.spec.ts @@ -17,7 +17,12 @@ function makeService() { query: jest.fn().mockResolvedValue([{ id: 'tunnel-1' }]), createQueryBuilder: jest.fn().mockReturnValue(builder), } - return { service: new TunnelService(repository as never), repository, builder } + const redis = { + get: jest.fn().mockResolvedValue(null), + setex: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), + } + return { service: new TunnelService(repository as never, redis as never), repository, builder, redis } } describe('TunnelService', () => { @@ -54,4 +59,33 @@ describe('TunnelService', () => { expect(builder.andWhere).toHaveBeenCalledWith('box.public = true') }) + it('caches both access verdicts briefly', async () => { + const { service, builder, redis } = makeService() + builder.getExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + await expect(service.isPublicAccessAllowed('AbCdEf123456', 3000)).resolves.toBe(true) + await expect(service.isPublicAccessAllowed('AbCdEf123456', 4000)).resolves.toBe(false) + + expect(redis.setex).toHaveBeenCalledWith('preview:tunnel:AbCdEf123456:3000', 3, '1') + expect(redis.setex).toHaveBeenCalledWith('preview:tunnel:AbCdEf123456:4000', 3, '0') + }) + + it('answers from the cache without querying the database', async () => { + const { service, repository, redis } = makeService() + redis.get.mockResolvedValueOnce('1').mockResolvedValueOnce('0') + + await expect(service.isPublicAccessAllowed('AbCdEf123456', 3000)).resolves.toBe(true) + await expect(service.isPublicAccessAllowed('AbCdEf123456', 3000)).resolves.toBe(false) + + expect(redis.get).toHaveBeenCalledWith('preview:tunnel:AbCdEf123456:3000') + expect(repository.createQueryBuilder).not.toHaveBeenCalled() + }) + + it('drops a cached refusal when the port is declared', async () => { + const { service, redis } = makeService() + + await service.declarePublic('AbCdEf123456', 3000) + + expect(redis.del).toHaveBeenCalledWith('preview:tunnel:AbCdEf123456:3000') + }) }) diff --git a/apps/api/src/box/services/tunnel.service.ts b/apps/api/src/box/services/tunnel.service.ts index e51692b02..fb5b89136 100644 --- a/apps/api/src/box/services/tunnel.service.ts +++ b/apps/api/src/box/services/tunnel.service.ts @@ -5,15 +5,23 @@ import { ConflictException, Injectable } from '@nestjs/common' import { InjectRepository } from '@nestjs/typeorm' +import { InjectRedis } from '@nestjs-modules/ioredis' +import Redis from 'ioredis' import { Repository } from 'typeorm' import { BadRequestError } from '../../exceptions/bad-request.exception' import { Tunnel } from '../entities/tunnel.entity' const TERMINAL_PORT = 22222 +// Same window as the other preview checks (preview:public, preview:token): the proxy +// asks on every request, and a revoked or unpublished tunnel stays open this long. +const ACCESS_CACHE_TTL_SECONDS = 3 @Injectable() export class TunnelService { - constructor(@InjectRepository(Tunnel) private readonly tunnels: Repository) {} + constructor( + @InjectRepository(Tunnel) private readonly tunnels: Repository, + @InjectRedis() private readonly redis: Redis, + ) {} async declarePublic(boxId: string, port: number): Promise { this.assertPort(port) @@ -26,11 +34,17 @@ export class TunnelService { if (rows.length === 0) { throw new ConflictException('Port already has a non-public tunnel') } + await this.redis.del(this.accessCacheKey(boxId, port)) } async isPublicAccessAllowed(boxId: string, port: number): Promise { this.assertPort(port) - return this.tunnels + const cacheKey = this.accessCacheKey(boxId, port) + const cached = await this.redis.get(cacheKey) + if (cached) { + return cached === '1' + } + const allowed = await this.tunnels .createQueryBuilder('tunnel') .innerJoin('tunnel.box', 'box') .where('tunnel.box_id = :boxId', { boxId }) @@ -40,6 +54,12 @@ export class TunnelService { .andWhere('box.public = true') .andWhere('box.state NOT IN (:...excluded)', { excluded: ['destroyed', 'destroying', 'archived', 'archiving'] }) .getExists() + await this.redis.setex(cacheKey, ACCESS_CACHE_TTL_SECONDS, allowed ? '1' : '0') + return allowed + } + + private accessCacheKey(boxId: string, port: number): string { + return `preview:tunnel:${boxId}:${port}` } private assertPort(port: number): void { diff --git a/apps/proxy/README.md b/apps/proxy/README.md index 280388e0a..04caeb403 100644 --- a/apps/proxy/README.md +++ b/apps/proxy/README.md @@ -52,9 +52,9 @@ flowchart TB class runner_process scope_execution ``` -The proxy asks the API whether the box is public, whether the caller may reach it, which runner -hosts it, and records activity. It then opens a tunnel through that runner to the guest port. -Browsers opening a private box log in through the OIDC provider first. +The proxy asks the API whether the box is public and whether the requested guest port has an +active public tunnel. It resolves the box's runner and records activity before forwarding. +The web terminal uses the runner's terminal endpoint and always requires authentication. ## Preview hosts @@ -71,11 +71,11 @@ A host without a `-` label serves only the utility routes listed in ## Request paths -| Request | Upstream | Authentication | -| ------------------------------------------ | --------------------------------------------------------------- | --------------------------------- | -| HTTP or WebSocket to any port except 22222 | Reverse proxy over a runner CONNECT tunnel to the guest port | Private boxes only | -| Port 22222 | The runner's web terminal at `/boxes//toolbox/proxy/22222` | Always | -| `CONNECT` | Raw TCP tunnel through the runner | Public boxes only; others get 403 | +| Request | Upstream | Access rule | +| -------------------------------- | ------------------------------------------------------------- | ---------------------------------------- | +| HTTP or WebSocket to a guest port | Reverse proxy over a runner CONNECT tunnel to the guest port | Public box and active tunnel declaration | +| Port 22222 | Runner's `/boxes//toolbox/proxy/22222` terminal endpoint | Authenticated; no tunnel declaration | +| Raw `CONNECT` to a guest port | TCP tunnel through the runner | Public box and active tunnel declaration | The HTTP path in code: @@ -83,13 +83,14 @@ The HTTP path in code: StartProxy (— · apps/proxy/pkg/proxy/proxy.go:78) — registers the catch-all route for preview hosts └─ NewProxyRequestHandler (— · apps/libs/common-go/pkg/proxy/proxy.go:113) — reverse proxy for one request ├─ GetProxyTarget (Proxy · apps/proxy/pkg/proxy/get_box_target.go:49) — choose the upstream - ├─ parseHost (Proxy · apps/proxy/pkg/proxy/get_box_target.go:357) — port plus box ID or signed token - ├─ getBoxPublic (Proxy · apps/proxy/pkg/proxy/get_box_target.go:250) — ask the API, cached 3 s - ├─ Authenticate (Proxy · apps/proxy/pkg/proxy/auth.go:18) — private box or terminal port only - └─ updateLastActivity (Proxy · apps/proxy/pkg/proxy/get_box_target.go:430) — renew activity every 50 s - └─ dialGuestPort (Proxy · apps/proxy/pkg/proxy/get_box_target.go:165) — dial each new upstream connection - ├─ getBoxRunnerInfo (Proxy · apps/proxy/pkg/proxy/get_box_target.go:211) — runner URL and key, cached 2 min - └─ dialRunnerTunnel (— · apps/proxy/pkg/proxy/tunnel.go:117) — CONNECT through the runner to the guest port + ├─ parseHost (Proxy · apps/proxy/pkg/proxy/get_box_target.go:354) — canonical port plus box ID or signed token + ├─ getBoxPublic (Proxy · apps/proxy/pkg/proxy/get_box_target.go:245) — ask the API, cached 3 s + ├─ Authenticate (Proxy · apps/proxy/pkg/proxy/auth.go:18) — resolve signed hosts or authorize private/terminal access + ├─ hasPublicTunnelAccess (Proxy · apps/proxy/pkg/proxy/tunnel_access.go:15) — check every guest service port + └─ updateLastActivity (Proxy · apps/proxy/pkg/proxy/get_box_target.go:425) — renew activity every 50 s + └─ dialGuestPort (Proxy · apps/proxy/pkg/proxy/get_box_target.go:160) — dial each new upstream connection + ├─ getBoxRunnerInfo (Proxy · apps/proxy/pkg/proxy/get_box_target.go:206) — runner URL and key, cached 2 min + └─ dialRunnerTunnel (— · apps/proxy/pkg/proxy/tunnel.go:124) — CONNECT through the runner to the guest port ``` The upstream URL `http://:` is only a routing key. `dialGuestPort` is the transport's @@ -97,9 +98,18 @@ The upstream URL `http://:` is only a routing key. `dialGuestPort` pooling reuses tunnels per box and port. Raw `CONNECT` requests skip this router and go to `handleTunnelConnect` in [`tunnel.go`](pkg/proxy/tunnel.go). +Every new HTTP/WebSocket guest service request and raw CONNECT checks the API's public tunnel +endpoint. It requires an unrevoked public declaration, a public box, and a box outside the +destroying/archiving states. The API caches allowed and denied verdicts in Redis for 3 seconds; +the proxy does not cache tunnel verdicts. Declaring a port clears its cached denial. A revoked +tunnel or a box made private can still admit new requests until a cached allowance expires; +existing connections continue until they close. If the tunnel check fails, both paths return +502. Ports are parsed as numbers, so `022222` is still the terminal port and cannot be used +for raw CONNECT. + ## Authentication -A request to a private box, or to port 22222, takes the first credential that works +A signed preview host, a private box, or port 22222 takes the first credential that works ([`auth.go`](pkg/proxy/auth.go)): 1. `Authorization: Bearer `: the API checks box access with the caller's own token. @@ -120,7 +130,9 @@ re-checks it with the API. | Situation | HTTP or WebSocket | `CONNECT` | | ---------------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------- | -| The box is private | `307` to the OIDC login unless a credential works, for API clients too | `403`, whatever the credential | +| The box is private and the port is not 22222 | `404` after credential resolution; private service previews are unavailable | `403`, whatever the credential | +| The public box has no active declaration for the port | `404`, including for a signed preview URL | `404` | +| The tunnel access API is unavailable | `502` | `502` | | The host has no `-` label | `404`, except the utility routes | `400` | | The API still fails the visibility check after its retries | `400` | `502` | | The runner or the guest port is unreachable | `502` | `502` | @@ -168,7 +180,7 @@ is internal and changes together with the API and the runner. | Service | Call | Credential | | ------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | API | `GET /api/config` at startup, for unset OIDC settings | `PROXY_API_KEY` | -| API | `GET /api/preview/{boxId}/public`, `/validate/{token}`; `GET /api/preview/{token}/{port}/box-id` | `PROXY_API_KEY` | +| API | `GET /api/preview/{boxId}/public`, `/validate/{token}`, `/tunnels/{port}`; `GET /api/preview/{token}/{port}/box-id` | `PROXY_API_KEY` | | API | `GET /api/preview/{boxId}/access` | The caller's bearer token | | API | `GET /api/runners/by-box/{boxId}`, `POST /api/box/{boxId}/last-activity` | `PROXY_API_KEY` | | Runner | `CONNECT /v1/boxes/{boxId}/network/tunnel?port={port}`; `/boxes/{boxId}/toolbox/proxy/22222/...` | The runner's key from `by-box`, in `X-BoxLite-Authorization` | diff --git a/apps/proxy/pkg/proxy/get_box_target.go b/apps/proxy/pkg/proxy/get_box_target.go index ed490235e..aa79f3fe3 100644 --- a/apps/proxy/pkg/proxy/get_box_target.go +++ b/apps/proxy/pkg/proxy/get_box_target.go @@ -47,22 +47,14 @@ const ( ) func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, error) { - var targetPort, targetPath, boxIdOrSignedToken string - // Extract port and box ID from the host header. // Expected format: 1234-.proxy.domain - var err error - targetPort, boxIdOrSignedToken, _, err = p.parseHost(ctx.Request.Host) + targetPort, boxIdOrSignedToken, _, err := p.parseHost(ctx.Request.Host) if err != nil { ctx.Error(common_errors.NewBadRequestError(err)) return nil, err } - targetPath = requestEscapedPath(ctx.Request.URL, ctx.Param("path")) - - if targetPort == "" { - ctx.Error(common_errors.NewBadRequestError(errors.New("target port is required"))) - return nil, errors.New("target port is required") - } + targetPath := requestEscapedPath(ctx.Request.URL, ctx.Param("path")) if boxIdOrSignedToken == "" { ctx.Error(common_errors.NewBadRequestError(errors.New("box ID or signed token is required"))) @@ -85,13 +77,8 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e } if !*isPublic || targetPort == TERMINAL_PORT { - portFloat, err := strconv.ParseFloat(targetPort, 64) - if err != nil { - ctx.Error(common_errors.NewBadRequestError(fmt.Errorf("failed to parse target port: %w", err))) - return nil, fmt.Errorf("failed to parse target port: %w", err) - } var didRedirect bool - boxId, didRedirect, err = p.Authenticate(ctx, boxIdOrSignedToken, float32(portFloat)) + boxId, didRedirect, err = p.Authenticate(ctx, boxIdOrSignedToken, float32(targetPort)) if err != nil { if !didRedirect { ctx.Error(err) @@ -99,6 +86,19 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e return nil, err } } + if targetPort != TERMINAL_PORT { + allowed, err := p.hasPublicTunnelAccess(ctx.Request.Context(), boxId, targetPort) + if err != nil { + wrappedErr := fmt.Errorf("check tunnel access: %w", err) + ctx.Error(common_errors.NewCustomError(http.StatusBadGateway, wrappedErr.Error(), "BAD_GATEWAY")) + return nil, wrappedErr + } + if !allowed { + wrappedErr := errors.New("tunnel not found") + ctx.Error(common_errors.NewNotFoundError(wrappedErr)) + return nil, wrappedErr + } + } // Stamp the API's span vocabulary (boxlite.* — see the API's // ObservabilityContextInterceptor) so one key filters a box across @@ -132,12 +132,7 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e } if targetPort != TERMINAL_PORT { - if _, err := strconv.ParseUint(targetPort, 10, 16); err != nil { - wrappedErr := fmt.Errorf("invalid target port: %w", err) - ctx.Error(common_errors.NewBadRequestError(wrappedErr)) - return nil, wrappedErr - } - target, err := url.Parse("http://" + net.JoinHostPort(boxId, targetPort) + targetPath) + target, err := url.Parse("http://" + net.JoinHostPort(boxId, strconv.Itoa(int(targetPort))) + targetPath) if err != nil { return nil, fmt.Errorf("failed to parse guest target URL: %w", err) } @@ -154,7 +149,7 @@ func (p *Proxy) GetProxyTarget(ctx *gin.Context) (*common_proxy.RequestTarget, e ctx.Error(common_errors.NewBadRequestError(fmt.Errorf("failed to get runner info: %w", err))) return nil, fmt.Errorf("failed to get runner info: %w", err) } - target, err := url.Parse(fmt.Sprintf("%s/boxes/%s/toolbox/proxy/%s%s", strings.TrimRight(runnerInfo.ApiUrl, "/"), boxId, targetPort, targetPath)) + target, err := url.Parse(fmt.Sprintf("%s/boxes/%s/toolbox/proxy/%d%s", strings.TrimRight(runnerInfo.ApiUrl, "/"), boxId, targetPort, targetPath)) if err != nil { return nil, fmt.Errorf("failed to parse terminal target URL: %w", err) } @@ -354,42 +349,42 @@ func (p *Proxy) validateAndCache( return &isValid, nil } -func (p *Proxy) parseHost(host string) (targetPort string, boxIdOrSignedToken string, baseHost string, err error) { +// parseHost returns the target port as a number, so every caller compares one +// canonical form: a label like "022222" is TERMINAL_PORT, not a guest port. +func (p *Proxy) parseHost(host string) (targetPort uint16, boxIdOrSignedToken string, baseHost string, err error) { // Extract port and box ID from the host header // Expected format: 1234-some-id-uuid.proxy.domain if host == "" { - return "", "", "", errors.New("host is required") + return 0, "", "", errors.New("host is required") } // Split the host to extract the port and box ID parts := strings.Split(host, ".") if len(parts) == 0 { - return "", "", "", errors.New("invalid host format") + return 0, "", "", errors.New("invalid host format") } if len(parts) < 2 { - return "", "", "", errors.New("invalid host format: must have subdomain") + return 0, "", "", errors.New("invalid host format: must have subdomain") } // Extract port from the first part (e.g., "1234-some-id-uuid") hostPrefix := parts[0] before, after, ok := strings.Cut(hostPrefix, "-") if !ok { - return "", "", "", errors.New("invalid host format: port and box ID not found") + return 0, "", "", errors.New("invalid host format: port and box ID not found") } - targetPort = before - - // Check that port is numeric - if _, err := strconv.Atoi(targetPort); err != nil { - return "", "", "", fmt.Errorf("invalid port '%s': must be numeric", targetPort) + port, err := strconv.ParseUint(before, 10, 16) + if err != nil || port == 0 { + return 0, "", "", fmt.Errorf("invalid port '%s': must be 1-65535", before) } boxIdOrSignedToken = after // Join remaining parts to form the base domain (e.g., "proxy.domain") baseHost = strings.Join(parts[1:], ".") - return targetPort, boxIdOrSignedToken, baseHost, nil + return uint16(port), boxIdOrSignedToken, baseHost, nil } func decodeDirectPreviewBoxID(value string) (string, bool, error) { diff --git a/apps/proxy/pkg/proxy/parse_host_test.go b/apps/proxy/pkg/proxy/parse_host_test.go new file mode 100644 index 000000000..c5d1e2f7d --- /dev/null +++ b/apps/proxy/pkg/proxy/parse_host_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 BoxLite AI +// SPDX-License-Identifier: AGPL-3.0 + +package proxy + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/boxlite-ai/proxy/cmd/proxy/config" + "github.com/gin-gonic/gin" +) + +func TestParseHostReturnsCanonicalPort(t *testing.T) { + for _, label := range []string{"3000", "03000", "003000"} { + port, _, _, err := (&Proxy{}).parseHost(label + "-AbCdEf123456.proxy.test") + if err != nil || port != 3000 { + t.Fatalf("parseHost(%q) port = %d, err = %v; want 3000", label, port, err) + } + } +} + +func TestParseHostRejectsOutOfRangePort(t *testing.T) { + for _, label := range []string{"0", "+3000", "-1", "65536"} { + if _, _, _, err := (&Proxy{}).parseHost(label + "-AbCdEf123456.proxy.test"); err == nil { + t.Fatalf("parseHost accepted port label %q", label) + } + } +} + +func TestZeroPaddedTerminalPortRoutesToTerminal(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusNotFound) + if err := proxy.boxAuthKeyValidCache.Set(context.Background(), "AbCdEf123456:owner-key", true, time.Minute); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "http://022222-d-416243644566313233343536.proxy.test/", nil) + request.Header.Set(BOX_AUTH_KEY_HEADER, "owner-key") + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if err != nil || target == nil { + t.Fatalf("zero-padded terminal port rejected: target=%v err=%v", target, err) + } + if target.URL.Path != "/boxes/AbCdEf123456/toolbox/proxy/22222/" { + t.Fatalf("zero-padded terminal port reached %s, want the runner terminal", target.URL) + } +} + +func TestZeroPaddedTerminalPortSkipsBrowserWarning(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use((&Proxy{config: &config.Config{}}).browserWarningMiddleware()) + engine.GET("/", func(ctx *gin.Context) { ctx.Status(http.StatusNoContent) }) + request := httptest.NewRequest(http.MethodGet, "http://022222-d-416243644566313233343536.proxy.test/", nil) + request.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36") + response := httptest.NewRecorder() + + engine.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("zero-padded terminal port got status %d, want the terminal to skip the warning page", response.Code) + } +} diff --git a/apps/proxy/pkg/proxy/proxy.go b/apps/proxy/pkg/proxy/proxy.go index b43499801..a151ab58b 100644 --- a/apps/proxy/pkg/proxy/proxy.go +++ b/apps/proxy/pkg/proxy/proxy.go @@ -38,7 +38,7 @@ const BOX_AUTH_KEY_HEADER = "X-BoxLite-Preview-Token" const BOX_AUTH_KEY_QUERY_PARAM = "BOXLITE_BOX_AUTH_KEY" const BOX_AUTH_COOKIE_NAME = "boxlite-box-auth-" const ACTIVITY_POLL_STOP_KEY = "boxlite-activity-poll-stop" -const TERMINAL_PORT = "22222" +const TERMINAL_PORT uint16 = 22222 type activityPollController struct { done chan struct{} diff --git a/apps/proxy/pkg/proxy/tunnel.go b/apps/proxy/pkg/proxy/tunnel.go index ba8824370..512c13a3e 100644 --- a/apps/proxy/pkg/proxy/tunnel.go +++ b/apps/proxy/pkg/proxy/tunnel.go @@ -14,7 +14,6 @@ import ( "net" "net/http" "net/url" - "strconv" "strings" "time" @@ -44,6 +43,15 @@ func (p *Proxy) handleTunnelConnect(writer http.ResponseWriter, request *http.Re http.Error(writer, "box is not public", http.StatusForbidden) return } + allowed, err := p.hasPublicTunnelAccess(request.Context(), boxID, port) + if err != nil { + http.Error(writer, "tunnel access unavailable", http.StatusBadGateway) + return + } + if !allowed { + http.Error(writer, "tunnel not found", http.StatusNotFound) + return + } // A CONNECT tunnel outlives any single HTTP request, so for tunnels the poll // is the renewal rather than a fallback: bind its ticker to the stream, which @@ -88,11 +96,10 @@ func (p *Proxy) tunnelTarget(request *http.Request) (string, uint16, error) { } else if ok { boxID = decoded } - value, err := strconv.ParseUint(port, 10, 16) - if err != nil || value == 0 { - return "", 0, fmt.Errorf("invalid tunnel port") + if port == TERMINAL_PORT { + return "", 0, fmt.Errorf("terminal port is reserved") } - return boxID, uint16(value), nil + return boxID, port, nil } func decodeTunnelBoxID(value string) (string, bool, error) { diff --git a/apps/proxy/pkg/proxy/tunnel_access.go b/apps/proxy/pkg/proxy/tunnel_access.go new file mode 100644 index 000000000..c57bbf3b1 --- /dev/null +++ b/apps/proxy/pkg/proxy/tunnel_access.go @@ -0,0 +1,45 @@ +// Copyright 2026 BoxLite AI +// SPDX-License-Identifier: AGPL-3.0 + +package proxy + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "time" +) + +func (p *Proxy) hasPublicTunnelAccess(ctx context.Context, boxID string, port uint16) (bool, error) { + configuration := p.apiclient.GetConfig() + endpoint, err := url.JoinPath(configuration.Servers[0].URL, "preview", boxID, "tunnels", strconv.Itoa(int(port))) + if err != nil { + return false, fmt.Errorf("build tunnel access endpoint: %w", err) + } + checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(checkCtx, http.MethodGet, endpoint, nil) + if err != nil { + return false, fmt.Errorf("build tunnel access request: %w", err) + } + request.Header.Set("Authorization", configuration.DefaultHeader["Authorization"]) + client := configuration.HTTPClient + if client == nil { + client = http.DefaultClient + } + response, err := client.Do(request) + if err != nil { + return false, fmt.Errorf("check tunnel access for box %s port %d: %w", boxID, port, err) + } + defer response.Body.Close() + switch response.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusNotFound: + return false, nil + default: + return false, fmt.Errorf("check tunnel access for box %s port %d: API returned %d", boxID, port, response.StatusCode) + } +} diff --git a/apps/proxy/pkg/proxy/tunnel_access_test.go b/apps/proxy/pkg/proxy/tunnel_access_test.go new file mode 100644 index 000000000..332901978 --- /dev/null +++ b/apps/proxy/pkg/proxy/tunnel_access_test.go @@ -0,0 +1,240 @@ +// Copyright 2026 BoxLite AI +// SPDX-License-Identifier: AGPL-3.0 + +package proxy + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + apiclient "github.com/boxlite-ai/boxlite/libs/api-client-go" + common_cache "github.com/boxlite-ai/common-go/pkg/cache" + common_errors "github.com/boxlite-ai/common-go/pkg/errors" + "github.com/boxlite-ai/proxy/cmd/proxy/config" + "github.com/gin-gonic/gin" + "github.com/gorilla/securecookie" +) + +func newTunnelProxy(t *testing.T, accessStatus int) *Proxy { + t.Helper() + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/api/preview/AbCdEf123456/tunnels/3000" { + writer.WriteHeader(accessStatus) + return + } + writer.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(api.Close) + clientConfig := apiclient.NewConfiguration() + clientConfig.Servers[0].URL = api.URL + "/api" + clientConfig.AddDefaultHeader("Authorization", "Bearer proxy-key") + ctx := context.Background() + publicCache := common_cache.NewMapCache[bool](ctx) + if err := publicCache.Set(ctx, "AbCdEf123456", true, time.Minute); err != nil { + t.Fatal(err) + } + activityCache := common_cache.NewMapCache[bool](ctx) + if err := activityCache.Set(ctx, "AbCdEf123456", true, time.Minute); err != nil { + t.Fatal(err) + } + runnerCache := common_cache.NewMapCache[RunnerInfo](ctx) + if err := runnerCache.Set(ctx, "AbCdEf123456", RunnerInfo{ApiUrl: "http://127.0.0.1:1", ApiKey: "runner-key"}, time.Minute); err != nil { + t.Fatal(err) + } + proxy := &Proxy{ + apiclient: apiclient.NewAPIClient(clientConfig), + boxPublicCache: publicCache, + boxAuthKeyValidCache: common_cache.NewMapCache[bool](ctx), + boxRunnerCache: runnerCache, + boxLastActivityUpdateCache: activityCache, + } + return proxy +} + +func TestUndeclaredTunnelRejectsHTTP(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusNotFound) + request := httptest.NewRequest(http.MethodGet, "http://3000-d-416243644566313233343536.proxy.test/", nil) + response := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(response) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if err == nil || target != nil { + t.Fatalf("undeclared HTTP port reached proxy target: target=%v err=%v", target, err) + } + if _, started := ctx.Get(ACTIVITY_POLL_STOP_KEY); started { + t.Fatal("undeclared HTTP port started activity polling") + } +} + +func TestUndeclaredTunnelRejectsConnect(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusNotFound) + request := httptest.NewRequest(http.MethodConnect, "http://proxy.test", nil) + request.Host = "3000-d-416243644566313233343536.proxy.test:443" + response := httptest.NewRecorder() + + proxy.handleTunnelConnect(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("undeclared CONNECT port status = %d, want 404", response.Code) + } +} + +func TestUndeclaredTunnelRejectsRawBoxIDHost(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusNotFound) + request := httptest.NewRequest(http.MethodGet, "http://3000-AbCdEf123456.proxy.test/", nil) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if err == nil || target != nil { + t.Fatalf("raw box ID host bypassed declaration: target=%v err=%v", target, err) + } +} + +func TestDeclaredTunnelAllowsHTTP(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusOK) + request := httptest.NewRequest(http.MethodGet, "http://3000-d-416243644566313233343536.proxy.test/", nil) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if err != nil || target == nil { + t.Fatalf("declared HTTP port was rejected: target=%v err=%v", target, err) + } +} + +func TestAuthenticatedPrivateServicePreviewIsDenied(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusNotFound) + ctx := context.Background() + if err := proxy.boxPublicCache.Set(ctx, "AbCdEf123456", false, time.Minute); err != nil { + t.Fatal(err) + } + if err := proxy.boxAuthKeyValidCache.Set(ctx, "AbCdEf123456:owner-key", true, time.Minute); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "http://3000-d-416243644566313233343536.proxy.test/", nil) + request.Header.Set(BOX_AUTH_KEY_HEADER, "owner-key") + ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ginCtx.Request = request + + target, err := proxy.GetProxyTarget(ginCtx) + stopActivityPoll(ginCtx) + if err == nil || target != nil { + t.Fatalf("private service preview reached guest port: target=%v err=%v", target, err) + } +} + +func TestSignedServicePreviewRequiresTunnel(t *testing.T) { + for _, test := range []struct { + name string + accessStatus int + allowed bool + }{ + {name: "undeclared", accessStatus: http.StatusNotFound}, + {name: "declared", accessStatus: http.StatusOK, allowed: true}, + } { + t.Run(test.name, func(t *testing.T) { + proxy := newTunnelProxy(t, test.accessStatus) + proxy.config = &config.Config{} + proxy.secureCookie = securecookie.New([]byte("test-cookie-signing-key-with-32-bytes"), nil) + token := "signedtoken12345" + if err := proxy.boxPublicCache.Set(context.Background(), token, false, time.Minute); err != nil { + t.Fatal(err) + } + cookieValue, err := proxy.secureCookie.Encode(BOX_AUTH_COOKIE_NAME+token, "AbCdEf123456") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "http://3000-"+token+".proxy.test/", nil) + request.AddCookie(&http.Cookie{Name: BOX_AUTH_COOKIE_NAME + token, Value: cookieValue}) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if (err == nil && target != nil) != test.allowed { + t.Fatalf("signed preview allowed = %t, want %t (target=%v, err=%v)", err == nil && target != nil, test.allowed, target, err) + } + }) + } +} + +func TestPrivateTerminalPreviewRemainsAvailable(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusNotFound) + ctx := context.Background() + if err := proxy.boxPublicCache.Set(ctx, "AbCdEf123456", false, time.Minute); err != nil { + t.Fatal(err) + } + if err := proxy.boxAuthKeyValidCache.Set(ctx, "AbCdEf123456:owner-key", true, time.Minute); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "http://22222-AbCdEf123456.proxy.test/", nil) + request.Header.Set(BOX_AUTH_KEY_HEADER, "owner-key") + ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ginCtx.Request = request + + target, err := proxy.GetProxyTarget(ginCtx) + stopActivityPoll(ginCtx) + if err != nil || target == nil { + t.Fatalf("private terminal rejected: target=%v err=%v", target, err) + } +} + +func TestTunnelAccessAPIErrorFailsHTTPWithBadGateway(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusServiceUnavailable) + request := httptest.NewRequest(http.MethodGet, "http://3000-d-416243644566313233343536.proxy.test/", nil) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = request + + target, err := proxy.GetProxyTarget(ctx) + stopActivityPoll(ctx) + if err == nil || target != nil { + t.Fatalf("access API failure reached proxy target: target=%v err=%v", target, err) + } + var customErr *common_errors.CustomError + if len(ctx.Errors) != 1 || !errors.As(ctx.Errors[0].Err, &customErr) || customErr.StatusCode != http.StatusBadGateway { + t.Fatalf("access API failure recorded %v, want 502", ctx.Errors) + } +} + +func TestTunnelAccessAPIErrorFailsConnectWithBadGateway(t *testing.T) { + proxy := newTunnelProxy(t, http.StatusServiceUnavailable) + request := httptest.NewRequest(http.MethodConnect, "http://proxy.test", nil) + request.Host = "3000-d-416243644566313233343536.proxy.test:443" + response := httptest.NewRecorder() + + proxy.handleTunnelConnect(response, request) + if response.Code != http.StatusBadGateway { + t.Fatalf("access API failure status = %d, want 502", response.Code) + } +} + +func TestProxyDoesNotCacheTunnelAccess(t *testing.T) { + var status atomic.Int32 + status.Store(http.StatusOK) + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(int(status.Load())) + })) + defer api.Close() + config := apiclient.NewConfiguration() + config.Servers[0].URL = api.URL + "/api" + proxy := &Proxy{apiclient: apiclient.NewAPIClient(config)} + + allowed, err := proxy.hasPublicTunnelAccess(context.Background(), "AbCdEf123456", 3000) + if err != nil || !allowed { + t.Fatalf("declared port rejected: allowed=%v err=%v", allowed, err) + } + status.Store(http.StatusNotFound) + allowed, err = proxy.hasPublicTunnelAccess(context.Background(), "AbCdEf123456", 3000) + if err != nil || allowed { + t.Fatalf("revoked port remained authorized: allowed=%v err=%v", allowed, err) + } +} diff --git a/apps/proxy/pkg/proxy/tunnel_test.go b/apps/proxy/pkg/proxy/tunnel_test.go index 9dae77873..858414b63 100644 --- a/apps/proxy/pkg/proxy/tunnel_test.go +++ b/apps/proxy/pkg/proxy/tunnel_test.go @@ -97,6 +97,17 @@ func TestTunnelTargetUsesPreviewAuthority(t *testing.T) { } } +func TestTunnelTargetRejectsTerminalPort(t *testing.T) { + for _, port := range []string{"22222", "022222"} { + request := httptest.NewRequest(http.MethodConnect, "http://proxy.test", nil) + request.Host = port + "-d-416243644566313233343536.proxy.test:443" + + if _, _, err := (&Proxy{}).tunnelTarget(request); err == nil { + t.Fatalf("terminal port %s accepted as a public CONNECT tunnel", port) + } + } +} + func TestTunnelConnectRejectsPrivateBoxBeforeRunnerDial(t *testing.T) { ctx := context.Background() publicCache := common_cache.NewMapCache[bool](ctx) @@ -120,6 +131,10 @@ func TestTunnelConnectRenewsBoxActivityBeforeStreaming(t *testing.T) { renewed := make(chan string, 1) api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/preview/"+boxID+"/tunnels/3000" { + writer.WriteHeader(http.StatusOK) + return + } if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/last-activity") { select { case renewed <- request.URL.Path: diff --git a/docs/concepts/networking.md b/docs/concepts/networking.md index c931c3fe8..631d5333c 100644 --- a/docs/concepts/networking.md +++ b/docs/concepts/networking.md @@ -52,6 +52,12 @@ The box network tunnel API is portable across local and REST runtimes, but its transport is backend-specific. `tunnel()` eagerly prepares one local gvproxy or remote service-proxy connection. `uri()` inspects its public URI, while `connect()` or `forward()` consumes that prepared one-shot tunnel into a byte stream or listener. +For a remote public box, preparing the tunnel registers that guest port as public. +The proxy accepts HTTP/WebSocket previews, including signed URLs, and raw CONNECT +only when the box is public and the port has an active declaration. Getting a +preview URL alone does not declare the port. Private boxes cannot expose guest +service ports through previews, even with a credential. The authenticated web +terminal uses reserved port 22222 without a tunnel declaration. Explicit host port publication is a separate local-runtime feature that owns a TCP listener and accepts repeated connections. diff --git a/docs/contributing/investigations/2026-09-26-preview-tunnel-gate.md b/docs/contributing/investigations/2026-09-26-preview-tunnel-gate.md new file mode 100644 index 000000000..123314e4a --- /dev/null +++ b/docs/contributing/investigations/2026-09-26-preview-tunnel-gate.md @@ -0,0 +1,31 @@ +## TL;DR + +Guest service previews require a public box and an active tunnel declaration, regardless of URL form or credential. + +## Problem + +The original proxy forwarded signed preview hosts and authenticated private previews to guest ports without checking a tunnel declaration. A preview URL only identifies a port; it does not open one. The access decision must apply to every guest-port request, while the authenticated web terminal remains available on port 22222. + +## Related work and lessons + +- [`boxlite-proxy.controller.ts`](../../../apps/api/src/boxlite-rest/boxlite-proxy.controller.ts#L242) requires a public box before opening a remote tunnel; [`tunnel.service.ts`](../../../apps/api/src/box/services/tunnel.service.ts#L26) records the per-port declaration. URL issuance in [`box.service.ts`](../../../apps/api/src/box/services/box.service.ts#L784) is separate, so issuing a URL cannot enforce access. +- [`preview.controller.ts`](../../../apps/api/src/box/controllers/preview.controller.ts#L29) exposes the proxy-only tunnel check. [`tunnel.service.ts`](../../../apps/api/src/box/services/tunnel.service.ts#L40) checks the declaration, revocation, box visibility, and lifecycle state. Reusing this endpoint keeps the HTTP and CONNECT decisions aligned. +- [`get_box_target.go`](../../../apps/proxy/pkg/proxy/get_box_target.go#L49) routes HTTP/WebSocket previews; [`tunnel.go`](../../../apps/proxy/pkg/proxy/tunnel.go#L27) handles raw CONNECT separately. Both paths need the same access check, while terminal traffic takes a separate authenticated route. + +## Approach + +- After resolving the box ID, check the API's public tunnel endpoint for every HTTP/WebSocket guest service port and raw CONNECT. Keep port 22222 on its authenticated terminal path and reject raw CONNECT to it. +- Use the same API check on both paths. It confirms an active public declaration and public box state, and denies boxes being destroyed or archived (`apps/api/src/box/services/tunnel.service.ts:40`). +- Resolve signed hosts before checking the declaration. Do not trust the initial public lookup for a signed token as the box's visibility. +- Parse host ports into one numeric form, so `022222` cannot bypass the terminal rule. Return 404 for a missing declaration and 502 when the tunnel check is unavailable. +- Cache both API verdicts for 3 seconds in Redis; clear a denial when declaring a port. Do not cache the tunnel verdict in the proxy. +- Keep the URL APIs compatible; the proxy is the enforcement boundary, including for URLs issued before this change. +- Update proxy and networking documentation and focused tests for direct, signed, private, terminal, and failed API access. + +## Alternatives and trade-offs + +Checking declarations only when issuing URLs would leave old or manually constructed URLs accessible and cannot enforce revocation. Disabling signed preview URL issuance would disrupt legitimate signed previews. Checking the API on each new request adds one control-plane call; its 3-second cache limits database reads but allows a recently revoked declaration to admit new requests until the cached allowance expires. Existing connections remain open. + +## Validation + +Reproduce the old bypass with the focused proxy tests, then rerun them with the fix. Cover direct, signed, private, terminal, canonical-port, and API-failure paths; verify API cache invalidation separately. Run the proxy and API suites through repository Make targets and confirm the final diff matches the documented behavior.