Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion apps/api/src/box/services/tunnel.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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')
})
})
24 changes: 22 additions & 2 deletions apps/api/src/box/services/tunnel.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tunnel>) {}
constructor(
@InjectRepository(Tunnel) private readonly tunnels: Repository<Tunnel>,
@InjectRedis() private readonly redis: Redis,
) {}

async declarePublic(boxId: string, port: number): Promise<void> {
this.assertPort(port)
Expand All @@ -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<boolean> {
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 })
Expand All @@ -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 {
Expand Down
48 changes: 30 additions & 18 deletions apps/proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -71,35 +71,45 @@ A host without a `<port>-` 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/<id>/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/<id>/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:

```text
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://<box ID>:<port>` is only a routing key. `dialGuestPort` is the transport's
`DialContext`: it resolves the box's runner and returns a tunneled connection, so HTTP connection
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 <API key or JWT>`: the API checks box access with the caller's own token.
Expand All @@ -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 `<port>-<id>` 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` |
Expand Down Expand Up @@ -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` |
Expand Down
63 changes: 29 additions & 34 deletions apps/proxy/pkg/proxy/get_box_target.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<boxId | token>.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")))
Expand All @@ -85,20 +77,28 @@ 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)
}
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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading