Skip to content
Merged
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
315 changes: 199 additions & 116 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,64 +1,105 @@
# PocketStation Relay

PocketStation Relay carries source-aware audio over WebRTC. It forwards
independent application, microphone, caller, and generated-audio buses to
native or browser receivers without taking ownership of capture, recording,
models, or durable Session state.
Stream independently named application, microphone, caller, or generated-audio
buses to native and browser receivers over WebRTC.

```text
authenticated source attachment
one RelaySession
named AudioBus + generation
BusSubscription fan-out with per-subscriber queue limits
RTP continuity, pacing, repair, and observations
authenticated publisher
├─ application AudioBus ── selected receivers
├─ microphone AudioBus ─── selected receivers
└─ assistant AudioBus ──── selected receivers
```

An `AudioBus` keeps a stable semantic identity while a transient publisher
attachment, SSRC, and source generation may change. A subscriber selects one
bus or the declared `mix` output.
Relay forwards live media. It does not capture desktop audio, run models, write
recordings, or store durable application state. PocketStation Core captures and
routes audio. The Control Plane creates RelaySessions and receiver invitations
when a deployment uses control-plane mode.

## Choose who issues credentials
An `AudioBus` is a name such as `application`, `microphone`, or `assistant`.
The name stays stable when a publisher reconnects; `source_generation`
identifies the new attachment. A receiver is authorized for one bus or for a
declared `mix` output.

A self-hosted deployment normally lets the control plane issue credentials:
## Start Relay locally

```text
control plane creates the Session and capabilities
Relay validates those capabilities and owns live attachments
Relay sends authenticated, revisioned full-state snapshots back
You need Go 1.26 or newer and UDP access on the host.

Generate development credentials and start standalone mode:

```bash
export POCKETSTATION_JWT_SECRET="$(openssl rand -hex 32)"
export RELAY_INVITATION_SECRET="$(openssl rand -hex 32)"
export RELAY_AUTHORITY_MODE="standalone"
go run ./cmd/relay-server
```

Set:
Relay listens on `http://127.0.0.1:4800` by default. In another terminal:

```text
RELAY_AUTHORITY_MODE=control-plane
RELAY_API_SERVER_URL=https://control.example.com
POCKETSTATION_JWT_SECRET=<shared capability verification secret>
POCKETSTATION_INTERNAL_SECRET=<shared state-synchronization secret>
```bash
curl --fail http://127.0.0.1:4800/healthz
```

The response is `ok`. This confirms that the HTTP server is accepting work; it
does not confirm ICE connectivity or media delivery.

Publish three seconds of synthetic Opus with the repository test source:

```bash
go run ./cmd/relay-test-source -- \
--relay http://127.0.0.1:4800 \
--duration 3s
```

The command creates a temporary RelaySession and prints the session, bus, and
credentials. The fixture checks transport behavior. It is not desktop capture
or physical-device evidence.

Stop Relay with `Ctrl-C`. The default process shutdown deadline is 30 seconds;
HTTP work receives a five-second drain interval inside that deadline.

Continue with the [local setup guide](docs/getting-started/run-relay.md) when a
receiver or Control Plane will join the test.

## Choose how RelaySessions are created

Relay supports two explicit operating modes.

### Control-plane mode

Use this mode when an application service owns RelaySession creation,
required-bus readiness, receiver invitations, and scoped credentials.

```bash
export RELAY_AUTHORITY_MODE="control-plane"
export RELAY_API_SERVER_URL="https://control.example.com"
export POCKETSTATION_JWT_SECRET="<shared capability secret>"
export POCKETSTATION_INTERNAL_SECRET="<state synchronization secret>"
go run ./cmd/relay-server
```

Deploy the control plane and Relay under endpoints you own. PocketStation's
Fly endpoints are a small, rate-limited demonstration environment used by the
installed Python example. They are not a hosted service, an SLA, or a
default for Relay itself, and may return `429 Too Many Requests` when the demo
capacity is in use.
The Control Plane signs source and receiver capabilities. Relay verifies their
issuer, audience, token type, role, expiry, RelaySession ID, and AudioBus scope.
Relay sends complete attachment-state snapshots back to the Control Plane after
changes and at the configured reconciliation interval.

In this mode Relay rejects its local Session and invitation mutation routes.
Subscriber capabilities are signed by the control plane and validated by Relay
with the same strict issuer, audience, token-type, role, and bus-scope profile.
Relay rejects local RelaySession and invitation creation endpoints in this
mode.

`RELAY_AUTHORITY_MODE=standalone` is an explicit self-hosted mode. Relay then
creates its own Sessions and single-use invitations. It uses the independent
`RELAY_INVITATION_SECRET` for subscriber capabilities. Credentials issued in
one mode are not accepted in the other.
### Standalone mode

Use standalone mode for a self-contained deployment or protocol development.
Relay creates temporary RelaySessions and single-use invitations itself. It
uses `RELAY_INVITATION_SECRET` to sign receiver capabilities.

Credentials from one mode are not accepted in the other. Choose one mode for a
deployment; do not configure fallback verification with the other issuer or
secret.

## Publish named buses

A source capability lists every bus the publisher may attach. One signaling
connection can declare multiple independent tracks:
A source capability lists the buses a publisher may attach. One signaling
connection can publish several WebRTC tracks:

```json
{
Expand All @@ -72,29 +113,37 @@ connection can declare multiple independent tracks:
}
```

Every declared bus must be inside the token scope. Stream IDs and bus IDs must
be unique. Relay rejects ambiguous, oversized, malformed, or out-of-scope
declarations before media attachment.
Every bus must be included in the capability. Stream IDs and bus IDs must be
unique. Relay rejects missing credentials, ambiguous declarations, oversized
messages, malformed SDP, and buses outside the token scope before attaching
media.

For one track, send an explicit `bus_id` instead.
Rust applications can use the
[`pocketstation-relay` Connector](https://github.com/pocketstation-io/connectors/tree/main/relay)
instead of implementing signaling, Opus, RTP, and WebRTC publication.

## Receive a bus
## Receive one selected bus

A subscriber capability contains exactly one `bus_id`. Use it with WebSocket
signaling or WHEP:
A receiver capability identifies exactly one RelaySession and AudioBus. Use it
with WebSocket signaling or WHEP:

```http
POST /v1/sessions/{session_id}/whep?bus=application
Authorization: Bearer <subscriber capability>
Authorization: Bearer <receiver capability>
Content-Type: application/sdp
```

The URL bus cannot exceed the token scope. `mix` is a declared virtual output;
it is not an unrestricted wildcard.
The bus in the URL must match the capability. `mix` is an explicitly declared
output, not permission to subscribe to every source.

## Control-state reconciliation
For all HTTP endpoints and authentication requirements, read
[HTTP and WebRTC](docs/reference/http-and-webrtc.md). For every signaling
message, field limit, and error code, read
[WebSocket signaling](docs/reference/signaling.md).

Relay sends one complete state document for every accepted attachment change:
## Keep the Control Plane synchronized

Relay sends one complete state document after an accepted attachment change:

```json
{
Expand All @@ -112,79 +161,112 @@ Relay sends one complete state document for every accepted attachment change:
}
```

The callback is authenticated, has a maximum payload size, and finishes within
an HTTP deadline. A full snapshot replaces all Relay-owned state, so duplicate
delivery is safe and callback loss is repaired
by periodic reconciliation. Reconciliation resends the current revision; it
does not manufacture a new transition.
A later complete snapshot replaces earlier Relay-owned state. Duplicate or
reordered revisions are safe to acknowledge without replaying mutations. A new
`relay_epoch` identifies a Relay process restart.

State-change notification uses an atomic, nonblocking handoff. It does not add
a lock, allocation, network call, or log operation to RTP forwarding.
State-change notification enters a fixed-capacity mailbox without a lock,
allocation, network request, or log call in RTP forwarding. If a notification
is missed, periodic reconciliation sends the latest complete state again.

## Run locally
## Configure capacity before accepting traffic

Control-plane mode:
The server defaults are development values, not sizing recommendations:

```bash
POCKETSTATION_JWT_SECRET=development-source-secret-32-bytes \
POCKETSTATION_INTERNAL_SECRET=development-internal-secret-32-bytes \
RELAY_AUTHORITY_MODE=control-plane \
RELAY_API_SERVER_URL=http://127.0.0.1:4801 \
go run ./cmd/relay-server
```
| Resource | Environment variable | Default |
|---|---|---:|
| active RelaySessions | `RELAY_MAX_ROOMS` | 100 |
| subscribers per RelaySession | `RELAY_MAX_SUBSCRIBERS_PER_SESSION` | 50 |
| AudioBuses per RelaySession | `RELAY_MAX_BUSES_PER_SESSION` | 16 |
| concurrent signaling and WHIP/WHEP handshakes | `RELAY_MAX_CONCURRENT_HANDSHAKES` | 128 |
| standalone RelaySession creation per client IP per minute | `MAX_ROOMS_PER_IP_PER_MINUTE` | 10 |
| inactive RelaySession lifetime | `ROOM_EXPIRY_MINUTES` | 30 minutes |
| publisher reconnect window | `SOURCE_RECONNECT_WINDOW_SEC` | 60 seconds |

Standalone mode:
Each receiver pacer holds 32 RTP packets and discards packets older than 120
milliseconds. Relay counts queue-full and age-related drops. When an admission
limit is reached, it rejects new work with an HTTP or signaling error instead
of growing resource use without a limit.

```bash
POCKETSTATION_JWT_SECRET=development-source-secret-32-bytes \
RELAY_INVITATION_SECRET=development-receiver-secret-32-bytes \
RELAY_AUTHORITY_MODE=standalone \
go run ./cmd/relay-server
```
Set these values from the expected audience, host memory, CPU, bandwidth, and
TURN allocation budget. Read
[capacity and recovery](docs/operations/capacity-and-recovery.md) for metrics,
reconnect behavior, shutdown, and the measurements required for a network
claim.

The deterministic `relay-test-source` fixture can publish one named test bus:
## Secure a public deployment

```bash
go run ./cmd/relay-test-source -- \
--relay http://127.0.0.1:4800 \
--session <session_id> \
--bus application \
--token <source_capability> \
--duration 3s
- Store JWT, internal synchronization, TURN, and webhook secrets in the
deployment secret manager.
- Serve public HTTP and signaling endpoints over TLS.
- Set `ALLOWED_ORIGINS` to the exact HTTPS origins hosting browser receivers.
- Restrict `/metrics` and RelaySession diagnostic endpoints with a private
network, proxy, or ingress policy; Relay does not add administrator auth to
them.
- Treat single-use invitation codes as credentials until redeemed or expired.
- Plan coordinated secret rotation around the remaining capability lifetime.

WebRTC uses DTLS-SRTP. Optional SFrame messages provide separate end-to-end
media encryption only when both clients implement the same key exchange.

Read [security](docs/operations/security.md) before exposing Relay publicly.

## Configure ICE and TURN

Set `ICE_UDP_PORT` and expose that UDP port for a public Relay. Use
`RELAY_PUBLIC_IPS` when the server must advertise a public address. Optional
ICE-TCP uses `ICE_TCP_PORT`.

Set `TURN_PUBLIC_IP` and a 32-byte-or-longer `TURN_SHARED_SECRET` to enable the
embedded TURN server. Expose the selected TURN listeners and the complete UDP
allocation port range. Authentication can succeed while media still fails if
firewalls block allocation ports.

The complete environment-variable reference is in
[Relay configuration](docs/reference/configuration.md).

## Observe a RelaySession

`GET /metrics` exposes Prometheus metrics for active RelaySessions,
subscribers, forwarded and dropped RTP packets, handshakes, ICE restarts,
callbacks, webhooks, and key exchange.

Inspect one affected RelaySession with:

```text
GET /v1/sessions/{id}/health
GET /v1/sessions/{id}/latency
GET /v1/sessions/{id}/media-debug
GET /v1/sessions/{id}/packet-log?bus={bus_id}&limit=100
```

It emits valid synthetic Opus for transport verification. It is not physical
capture evidence.
The packet-log endpoint returns at most 1,000 records. Restrict these endpoints
before public deployment.

For a complete local setup, public-network requirements, configuration, and
operating guidance, start with the [Relay documentation](docs/README.md).
These observations describe Relay and WebRTC behavior. They do not prove the
exact sample a loudspeaker played or what a person heard.

## Set capacity limits
## Published limits

Relay bounds:
Repository and same-host tests establish component and local integration
behavior. They do not establish every NAT topology, cross-network latency,
multi-region behavior, or production load.

- RelaySessions and AudioBuses per Session;
- subscriptions per Session;
- concurrent signaling and WHIP/WHEP handshakes;
- pending invitations in standalone mode;
- control-state notifications;
- callback duration and response size;
- packet queues, repair caches, and packet age.
PocketStation's Fly deployment is a small, rate-limited environment used by
the installed Python example. It is not Relay's default configuration, a
hosted product, or an SLA. Operate Relay and the Control Plane under endpoints
and limits you control for an application deployment.

When capacity is unavailable, Relay rejects new work before allocating media
resources. It returns an explicit capacity response instead of allowing retry
or callback queues to grow without a limit. Operators should set limits for
their own budget and expected audience; the repository's `fly.toml`
intentionally describes only a small demonstration deployment.
## Continue from the task you have

The checked-in Fly configuration keeps one 512 MB Relay machine running and
lets the 256 MB Control Plane stop when idle. At current `iad` shared-CPU
pricing, keeping both machines running for an entire month would exceed USD 5
before network costs. The low-traffic demonstration can remain below that
target only when the Control Plane is stopped for enough idle time. Network
transfer, public IPs, the browser receiver, taxes, and future provider pricing
are separate. Fly does not currently provide a built-in billing alert, so this
configuration is not a hard billing ceiling.
| Task | Guide |
|---|---|
| Start a local Relay | [Run Relay locally](docs/getting-started/run-relay.md) |
| Configure every environment variable | [Configuration](docs/reference/configuration.md) |
| Secure credentials and browser access | [Security](docs/operations/security.md) |
| Set capacity and recover from failures | [Capacity and recovery](docs/operations/capacity-and-recovery.md) |
| Implement a client | [WebSocket signaling](docs/reference/signaling.md) |
| Inspect HTTP, WHIP, WHEP, and diagnostics | [HTTP and WebRTC](docs/reference/http-and-webrtc.md) |

## Verify a change

Expand All @@ -194,9 +276,10 @@ go test -race -short ./...
go test -race ./internal/server ./test/integration
```

CI must pass before Fly deploys. Deployment checks out the exact successful CI
revision and records it in the OCI image. A successful local or same-host test
does not establish WAN/TURN or multi-region performance.
CI must pass before deployment. The deploy workflow checks out the successful
revision and records it in the OCI image. Local or same-host tests do not
establish WAN/TURN or multi-region performance.

## License

See [the signaling protocol](docs/reference/signaling.md) for message
formats and failure behavior.
PocketStation Relay is available under the MIT license.
Loading