Metadata-Version: 2.4
Name: projectaria-webrtc-signaling-server
Version: 0.1.2
Summary: WebRTC signaling server for Project Aria Gen2 streaming
Home-page: https://www.projectaria.com
Author: Meta Reality Labs Research
License: Apache-2.0
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: http2
Requires-Dist: hypercorn>=0.16; extra == "http2"
Dynamic: author
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: license
Dynamic: license-file
Dynamic: provides-extra
Dynamic: requires-python
Dynamic: summary

# projectaria-webrtc-signaling-server

WebRTC signaling server for Project Aria Gen2 streaming.

A **stateless rendezvous point**: two peers connect to it, it matches them up in a room,
authenticates them, and passes SDP offers/answers and ICE candidates between them until
they have negotiated a connection. Run it on any host both peers can reach.

**Audio and video never pass through this server.** That is a property of the signaling
server specifically, not of the deployment as a whole: once the peers are introduced,
media flows directly between them where the network allows it, and **through a TURN
server when it does not**. If you run TURN — which most cross-NAT deployments need —
then your media does flow through that relay, and it should be sized and placed
accordingly. TURN is a separate process from this one, even when both run on the same
host.

You need this only for **cross-network** streaming. If the glasses and the viewer are on
the same network, the viewer's `--listen` mode connects them directly and no server is
involved.

Signaling is not the same job as STUN/TURN, and you generally need both across NATs:
this server introduces the peers to each other, STUN lets them discover their own public
addresses, and TURN carries the media when the two cannot reach each other directly.
This package is only the first of those; you install and run STUN/TURN separately.

## Install

```bash
pip install projectaria-webrtc-signaling-server
```

The server is **pure Python standard library** — no third-party dependencies on any
platform. Runs on Linux, macOS and Windows, Python 3.9+.

The optional HTTP/2 listener (for peers behind proxies or firewalls that only allow
HTTPS) needs one extra:

```bash
pip install "projectaria-webrtc-signaling-server[http2]"
```

**On a Linux server, give it its own virtualenv.** Debian 12, Ubuntu 23.04, Fedora 38 and
their successors mark the system Python as externally managed (PEP 668), so a system-wide
`pip install` there fails with `error: externally-managed-environment`:

```bash
sudo apt-get install -y python3-venv   # or your distribution's equivalent
sudo python3 -m venv /opt/aria-signaling
sudo /opt/aria-signaling/bin/pip install projectaria-webrtc-signaling-server

# In each shell you run the server from. Every example below then works as written.
source /opt/aria-signaling/bin/activate
```

Activation does not survive `sudo`, which replaces `PATH` with its own `secure_path` on
most distributions, so the `sudo` commands below name the full path instead. That is also
what you want for `--install-service`: the unit's `ExecStart` is built from the copy of
the server you ran the installation with, so invoking the venv's copy is what pins the
service to that interpreter.

`pip install --user` is not an alternative to the virtualenv — it puts the console script
in the invoking user's `~/.local/bin`, which a service running as another account cannot
reach.

## Two ways to run it

**1. Run it directly — signaling only.** Nothing else is installed, and it stops when you
stop it. Use this when you already have STUN/TURN, or when the peers can reach each other
directly and only need introducing:

```bash
# Generate a shared secret once, and give the same value to both peers. The `install`
# line creates the file empty, mode 0600 and owned by you, before anything is written
# to it: that is what lets the unprivileged redirect below write into root-owned
# /etc/aria, and it means the secret is never briefly readable by other accounts.
sudo mkdir -p /etc/aria
sudo install -m 600 -o "$USER" /dev/null /etc/aria/auth.secret
openssl rand -hex 32 > /etc/aria/auth.secret

projectaria_webrtc_signaling_server --port 8443 --auth-token-file /etc/aria/auth.secret
```

The token may also come from the `ARIA_SIGNALING_AUTH_TOKEN` environment variable. It is
deliberately **not** accepted as a command-line argument — argv leaks into `ps` output
and `/proc/<pid>/cmdline`.

Run `projectaria_webrtc_signaling_server --help` for the full flag list.

**2. Install it as a systemd service with `--install-service` — survives reboots.** This
is the recommended path for a VPS. Pass the flags you want the service to run with, plus
`--install-service`, and the server writes a unit instead of starting:

```bash
sudo /opt/aria-signaling/bin/projectaria_webrtc_signaling_server \
  --install-service \
  --port 8443 \
  --auth-token-file /etc/aria/auth.secret
```

That writes `/etc/systemd/system/aria-signaling.service`, enables it and starts it. The
unit's `ExecStart` is rebuilt from the very flags you passed, so the running service and
the command you typed cannot drift apart, and a flag added to a later release is carried
across without editing anything by hand.

| Flag | Effect |
|---|---|
| `--install-service` | write and activate the unit instead of running the server |
| `--service-name <name>` | unit name, default `aria-signaling` |
| `--service-user <name>` | account the service runs as. Defaults to the user behind `sudo`, then root |

Details worth knowing:

- **The token must come from a file.** Installation is refused when the token comes from
  `ARIA_SIGNALING_AUTH_TOKEN`, because that would inline the secret into a unit file.
  The unit is written mode `0600` regardless, since its `ExecStart` names the token path.
- **Relative paths are absolutized.** systemd runs units from `/`, so a path that worked
  in your shell would otherwise resolve somewhere else.
- **It is Linux-only** and exits with a clear message elsewhere. The server itself runs
  on macOS and Windows; wrap it in launchd or a Windows service if you need it at boot.
- **Failed activation rolls back.** If `systemctl` cannot enable or start the unit, the
  unit file is removed rather than left half-installed.

Check it and read its logs the usual way:

```bash
systemctl status aria-signaling
journalctl -u aria-signaling -f
```

**Deploying STUN/TURN as well.** This package installs only the signaling server. Across
NATs you will usually also want coturn, which is a separate daemon with its own
lifecycle: install and configure it with your own package manager and service manager,
then point the peers at it with `--stun` / `--turn`, or configure it once on the server
with `--ice-servers`.

## Connecting the peers

Point both ends at the server. Any shared `--room` id pairs them; add a room password so
that knowing the room id is not by itself enough to join.

Both peers also need STUN/TURN in most real deployments — anything crossing NATs — so
they are shown here rather than as an afterthought. If coturn runs on the same host,
these are that host and the TURN credentials you configured it with.

```bash
# Glasses (over USB). --turn takes one comma-separated url,username,credential.
aria_gen2 streaming webrtc start \
  --signaling-url tcp://<server-host>:8443 \
  --room my-room --room-password "$ROOM_PASSWORD" --auth-token "$AUTH_TOKEN" \
  --stun stun:<server-host>:3478 \
  --turn turn:<server-host>:3478,aria,"$TURN_PASSWORD" \
  --interface wifi_sta

# Receiver computer. Note --turn-username / --turn-password are separate flags here.
aria_streaming_viewer --transport webrtc \
  --signaling-host <server-host> --signaling-port 8443 \
  --room my-room --room-password "$ROOM_PASSWORD" --auth-token "$AUTH_TOKEN" \
  --stun stun:<server-host>:3478 \
  --turn turn:<server-host>:3478 \
  --turn-username aria --turn-password "$TURN_PASSWORD"
```

You can drop the STUN/TURN flags only when the two peers can already reach each other
directly — same LAN, or both on public addresses.

If the glasses fail to pair and the server logs `auth_rejected` with a timestamp skew,
their clock has drifted outside the freshness window: add `--ntp-sync` to the device
command so it syncs before the session starts.

As an alternative to passing them per peer, configure them once on the server with
`--ice-servers <file>` and it will push them to both peers in the `paired` envelope. The
two sources are additive, not exclusive: server-pushed entries are placed first so the
operator controls the primary set, and any peer-supplied `--stun` / `--turn` entries
follow as fallback.

Note what that trades away. Any TURN username and credential in the file is handed to
every peer that authenticates and pairs, and it crosses the plain TCP signaling channel
to get there. Configured per peer, the auth token and the TURN credential are independent
secrets; served from here, the auth token alone grants relay access as well. Convenient
when the same operator holds both, worth avoiding when peers should not be able to use
your TURN server for their own traffic. The file is sent verbatim — the server does not
mint short-lived TURN REST credentials, so rotation means editing the TURN server config.

## Signaling over TLS

Everything above puts signaling on the plain TCP listener, in the clear. The server can
bind a **second** listener that speaks the same protocol over HTTP/2 inside TLS, which a
peer reaches with an `https://` signaling URL. Both listeners share one relay, so peers
pair with each other whichever one they arrive on and you can move one end at a time.

Peers on the TLS listener authenticate with a bearer token presented inside the session
rather than the HMAC handshake, so it takes a secret of its own.

```bash
# 1. The TLS listener needs the one optional dependency. Into a root-owned virtualenv,
#    that is `sudo /opt/aria-signaling/bin/pip install "...[http2]"`.
pip install "projectaria-webrtc-signaling-server[http2]"

# 2. The host peers will dial. Use your DNS name if you have one; otherwise ask the
#    internet what this host looks like from outside, which is not necessarily what
#    `hostname -I` says on a cloud VM behind NAT. Note the value down — the receiver
#    needs the same one.
export SERVER_HOST=$(curl -s https://api.ipify.org)
echo "$SERVER_HOST"

# 3. A self-signed certificate for it, generated here so the private key never travels.
#    The SAN is not strictly required — peers carry your anchor and skip the hostname
#    check — but `openssl` and `curl` do check it when you verify by hand, so it is worth
#    the line. Its type has to match what SERVER_HOST holds: openssl rejects a name that
#    arrives as `IP:`.
export SAN="IP:$SERVER_HOST"          # for a DNS name: export SAN="DNS:$SERVER_HOST"
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \
  -subj "/CN=aria-signaling" -addext "subjectAltName=$SAN" \
  -keyout server.key -out server.pem
sudo install -m 600 -o "$USER" server.key /etc/aria/server.key
sudo install -m 644 -o "$USER" server.pem /etc/aria/server.pem

# 4. A bearer token for this listener, separate from the TCP one. Same pre-create
#    idiom as auth.secret above. Both peers need this value.
sudo install -m 600 -o "$USER" /dev/null /etc/aria/http2.secret
openssl rand -hex 32 > /etc/aria/http2.secret
export HTTP2_TOKEN="$(cat /etc/aria/http2.secret)"

# 5. Run both listeners from one process. To write the unit with these flags instead of
#    starting, add --install-service — and invoke it as
#    `sudo /opt/aria-signaling/bin/projectaria_webrtc_signaling_server`, since sudo does
#    not find the bare name and ExecStart is pinned to whichever copy you ran.
projectaria_webrtc_signaling_server \
  --port 8443 --auth-token-file /etc/aria/auth.secret \
  --http2-port 9443 \
  --http2-tls-cert /etc/aria/server.pem \
  --http2-tls-key /etc/aria/server.key \
  --http2-auth-token-file /etc/aria/http2.secret
```

Open 9443 in both firewalls, the host's and your provider's.

If you added the TLS flags to an existing service with `--install-service`, restart it
explicitly: the installer rewrites the unit and then *starts* it, which is a no-op on an
already-running service, so the old process stays up on the old ports.
`sudo ss -lntp | grep -E ':8443|:9443'` shows which are actually bound.

### Point the receiver at it

For a self-signed or private-CA deployment there is nothing to install into a system
store — copy the certificate across and name it as the trust anchor. Only the
certificate travels; the key stays on the server.

```bash
# On the receiver computer.
export SERVER_HOST=<the value step 2 printed>
export HTTP2_TOKEN=<the contents of /etc/aria/http2.secret>
export ROOM_PASSWORD=<the room password you chose>
export TURN_PASSWORD=<the credential you configured coturn with>
scp "$SERVER_HOST":/etc/aria/server.pem ~/aria-signaling-ca.pem

# Prove the chain and the route before involving a peer. `-CAfile` is not optional on a
# self-signed deployment: without it openssl checks the system store and reports
# `Verify return code: 18` for a perfectly good server.
openssl s_client -connect "$SERVER_HOST:9443" -alpn h2 -CAfile ~/aria-signaling-ca.pem \
  </dev/null 2>&1 | grep -E "Verify return code|ALPN protocol"
```

Expect `Verify return code: 0 (ok)` and `ALPN protocol: h2`. `ALPN protocol: none` means
peers will fail with an opaque connect error, and is the one to act on. Then start the
receiver:

```bash
aria_streaming_viewer --transport webrtc \
  --signaling-url "https://$SERVER_HOST:9443" \
  --ca-root ~/aria-signaling-ca.pem \
  --room my-room --room-password "$ROOM_PASSWORD" \
  --auth-token "$HTTP2_TOKEN" \
  --stun "stun:$SERVER_HOST:3478" \
  --turn "turn:$SERVER_HOST:3478" \
  --turn-username aria --turn-password "$TURN_PASSWORD"
```

`--ca-root` names the anchor to verify the server against, so pass the path you just
copied; it is not the default and the connection fails without it. A peer carrying your
anchor skips the hostname check — the anchor *is* the identity, because you issued
exactly one certificate with it — which is what lets the deployment stay on a bare IP
with no DNS name. Keep such a CA single-purpose: anything else it ever signs is accepted
for your signaling server too.

### Install the anchor on the glasses

The device takes its anchor from a certificate set installed on it, not from a flag. The
set also carries a publisher certificate and key, which signaling does not use but the
installer requires, so generate a throwaway pair for them:

```bash
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
  -keyout node.key -out node.pem -subj "/CN=aria-node-dummy"

aria_gen2 streaming install-certs user-defined-certs \
  --ca-root   ~/aria-signaling-ca.pem \
  --cert      node.pem \
  --key       node.key \
  --cert-name default
```

Two things worth knowing before you debug the wrong thing:

- **`--cert-name` must be `default`.** The device consults
  `/data/misc/streaming/certs/default/root_ca.pem` and nothing else. Any other name
  installs successfully and is then silently ignored.
- **`--cert` and `--key` are required** even though only the root matters here. One
  throwaway pair, reused, is the least confusing way to satisfy that.

With `adb root`, `adb shell ls -lZ /data/misc/streaming/certs/default/` should list
`root_ca.pem`, `cert.pem` and `key.pem` as `0600 system:system`, labelled
`u:object_r:streaming_cert_file:s0`. The trust path itself is worth proving from the
device before streaming anything:

```bash
adb shell 'curl -sS -o /dev/null -w "code=%{http_code} verify=%{ssl_verify_result}\n" \
  --cacert /data/misc/streaming/certs/default/root_ca.pem \
  https://'"$SERVER_HOST"':9443/v2/signaling'
```

`verify=0` is the chain validating; `code=405` is the endpoint refusing a GET, which
means you reached it through TLS. Then start the device against the TLS listener:

```bash
aria_gen2 streaming webrtc start \
  --signaling-url "https://$SERVER_HOST:9443" \
  --room my-room --room-password "$ROOM_PASSWORD" --auth-token "$HTTP2_TOKEN" \
  --interface wifi_sta
```

Without an installed anchor the device refuses to start an `https://` session rather than
falling back to a certificate it cannot authenticate, and names the path it looked in.
Installing the anchor is therefore a required step for TLS signaling, not an optional
hardening one.

## Checking a deployment

The `smoke-test` subcommand connects two synthetic peers, authenticates them, pairs them
and passes an envelope between them — the same handshake the glasses perform, with no
hardware involved:

```bash
# If you just backgrounded the server, wait for the listener first.
until nc -z signaling.example.com 8443 2>/dev/null; do sleep 0.2; done

projectaria_webrtc_signaling_server smoke-test \
  --host signaling.example.com --port 8443 \
  --auth-token-file ./auth.secret --room-password "$ROOM_PASSWORD"
```

Run this before involving the glasses. It separates "server, firewall or token is wrong"
from "glasses or TURN is wrong", which are hard to tell apart from the viewer. A pass
means signaling is healthy; it says nothing about whether media flows, since that depends
on STUN/TURN and no media passes through this server.

## Ports and firewall

| Port | Protocol | Purpose | Needed when |
|---|---|---|---|
| 8443 | TCP | Signaling | always |
| 9443 | TCP | [Signaling over TLS](#signaling-over-tls) | only with `--http2-port` |
| 3478 | TCP + UDP | STUN/TURN control | only with a STUN/TURN server |
| 49152–49200 | UDP | TURN media | only with a STUN/TURN server |

Running signaling without STUN/TURN needs only the signaling port.

Open these in the **host** firewall (`ufw`, `firewalld` or equivalent). Take care
enabling an inactive `ufw` on a remote host: add the `allow OpenSSH` rule before enabling
it, or you will lock yourself out of the VPS.

**Your hosting provider almost certainly has a second firewall in front of the host** — a
cloud security group, a hosting-panel firewall, an upstream appliance. The host firewall
above does not touch it, and it cannot be configured from inside the VM. Open the same
ports there, using whatever console or CLI your provider gives you.

If you started the server directly rather than using the script, nothing was opened for
you. Verify reachability from your receiver computer with `nc -vz <host> 8443` before
glasses.

## Security model

- **Put signaling behind [TLS](#signaling-over-tls) on any untrusted network.** The plain
  TCP listener sends everything the peers exchange to find each other in the clear: the
  room id and room password, the auth handshake, session ids, and the SDP — including the
  ICE candidates, which carry both peers' addresses.

  Media is encrypted either way, and that is the easiest thing here to be wrong about. The
  SDP carries the DTLS fingerprints with which the peers authenticate each other's media
  keys, so an attacker who can *modify* the signaling stream substitutes their own, and
  the media negotiates against them instead — encrypted, to the wrong party. One who can
  only *read* it still comes away with the room password to join on, a captured handshake
  to replay while it is fresh, and both peers' addresses.

  Both listeners share one relay and peers pair across them, so you can turn TLS on for
  one end at a time and decide separately whether to keep the plain listener open.
- **Auth on the plain TCP listener** is HMAC-SHA256 over a client-supplied timestamp,
  keyed by the shared token. The timestamp is also range-checked against the server clock
  — `--auth-freshness-window` seconds, 60 by default — so a captured handshake stops
  replaying once it ages out.

  This assumes the device's clock is roughly right. If it is not, the peer is rejected
  with `auth_rejected` and the server logs the skew. Have the glasses sync their clock
  first by passing `--ntp-sync` to `aria_gen2 streaming webrtc start`; the device runs an
  SNTP sync before the session begins. Note that this makes NTP reachability a hard
  precondition for starting the session.

  For a fleet that genuinely cannot sync, `--auth-freshness-window 0` accepts any
  timestamp and the server warns at startup. That leaves a captured handshake replayable
  indefinitely, so set a room password, restrict who can reach the signaling port at all,
  and prefer [signaling over TLS](#signaling-over-tls) — peers there authenticate with a
  bearer token and send no replayable handshake.
- **The TLS listener authenticates separately**, with that bearer token presented inside
  the TLS session (`--http2-auth-token-file`) instead of the HMAC handshake. Each
  listener needs its own token, and the server exits rather than binding one it cannot
  authenticate.
- **Room passwords** are established by the first peer to register in a room; every later
  peer, including one resuming a dropped session, must present the same value or is
  rejected with `error{code:"room_password_mismatch"}`. Rooms where no peer sets a
  password are unaffected.
- **This server never sees media.** Only SDP and ICE candidates pass through it. Media
  goes directly between the peers, or through your TURN server when a direct path cannot
  be negotiated — so TURN, not this process, is what carries media on a NAT-ed
  deployment.
- **`--ice-servers` widens what the auth token is worth.** The list, including any TURN
  credential in it, is sent to both peers in the `paired` envelope, so a leaked auth token
  yields relay access too. Rotate the two together, or configure STUN/TURN per peer
  instead.
- **The plain TCP listener has no TLS of its own**, and fronting it with a terminating
  proxy does not change that: peers dial that substrate in the clear and have no way to
  negotiate TLS with the proxy. Encrypting signaling means the HTTP/2 listener, either
  terminating TLS itself or behind an edge proxy with `--http2-trust-proxy-tls`.

## Protocol

The wire protocol (v2, JSON-lines) is specified in full in the module docstring of
`projectaria_webrtc_signaling_server/signaling_server.py` — envelope shapes, error codes,
resume semantics, and the forward-compatibility rule that lets clients add new envelope
types without a server roll.

Peers must agree with the server on these values. Changing one of them on the server
alone breaks streaming:

| Value | Setting |
|---|---|
| Wire version | 2 |
| Max envelope line | 64 KiB |
| Handshake timeout | 5 s |
| Resume grace window | 5 s |
| HTTP/2 signaling path | `/v2/signaling` |
| Default port | 8443 |
| Max role / room / session-id / room-password length | 32 / 128 / 128 / 128 |
| Auth digest | HMAC-SHA256 hex over the decimal-ns timestamp string |

## Running without installing

`signaling_server.py` runs directly as a single file
(`python3 projectaria_webrtc_signaling_server/signaling_server.py --help`) if you would
rather not install anything. It imports only the standard library.

## License

Apache 2.0. See [LICENSE](LICENSE).
