Gate service and deployment
The gate is the signalling service you run alongside your application backend, and it is small: a WebSocket service that passes sealed envelopes, plus an optional STUN responder. It never carries media, so it needs no TURN ports, no relay port ranges and no media bandwidth. Gates explains what it sees.
Run it
node bin/freehop-gate.mjs # from a checkout of the repository
./node_modules/.bin/freehop-gate # in a project that depends on freehop
Use the installed local binary above. An unpinned npx freehop-gate can resolve a different package from the registry; from a checkout, run node bin/freehop-gate.mjs instead.
It prints one line when it is ready. With STUN configured, for example:
{"event":"listening","url":"ws://127.0.0.1:8787/freehop","stun":["stun:gate.example.com:3478"]}
Without a token secret it also prints one warning line, because any client can then use it.
Configuration
The service is configured with environment variables:
| Variable | Default | Meaning |
|---|---|---|
FREEHOP_GATE_HOST | 127.0.0.1 | Address the WebSocket server listens on. Keep it on loopback behind a TLS proxy. |
FREEHOP_GATE_PORT | 8787 | WebSocket port |
FREEHOP_GATE_PATH | /freehop | WebSocket path. Clients connect to wss://<your host><path>. |
FREEHOP_GATE_PUBLIC_HOST | none | Public host name advertised in the stun: URLs. Needed when STUN binds a wildcard address. |
FREEHOP_GATE_STUN | none | STUN responders to start, for example 0.0.0.0:3478,[::]:3478 (IPv4 and IPv6). |
FREEHOP_GATE_TOKEN_SECRET | none | Makes the gate token-gated: only clients with a token minted with this secret get in. Use the same value in the authority's gateTokenSecrets map for this gate URL. At least 32 characters, for example from openssl rand -base64 32. |
FREEHOP_GATE_TOKEN_AUDIENCE | required with a secret | Exact public WebSocket URL, e.g. wss://gate.example.com/freehop. Must match the authority’s URL and token audience. |
FREEHOP_GATE_ALLOW_ANONYMOUS | off | Set 1 explicitly to run a public community gate without admission tokens. A gate reachable beyond loopback (a FREEHOP_GATE_HOST that is not loopback, FREEHOP_GATE_TRUST_PROXY=1 or FREEHOP_GATE_PUBLIC_HOST) refuses to start without a token secret or this setting. |
FREEHOP_GATE_TRUST_PROXY | off | 1 behind a local reverse proxy: per-address limits then use the last X-Forwarded-For hop. |
Deploy with systemd and Caddy
A gate fits next to anything else on a small server. These steps install it under /opt/freehop and run it as an unprivileged user, on loopback, behind Caddy. The service user installs the locked dependencies itself, with install scripts turned off.
sudo useradd --system --home-dir /opt/freehop --shell /usr/sbin/nologin freehop
sudo install -d -o freehop -g freehop /opt/freehop
sudo -u freehop git clone https://github.com/jolynstudios/freehop /opt/freehop
cd /opt/freehop && sudo -u freehop -H npm ci --omit=dev --ignore-scripts
sudo install -d -m 700 /etc/freehop
echo "FREEHOP_GATE_TOKEN_SECRET=$(openssl rand -base64 32)" | sudo tee /etc/freehop/gate.env > /dev/null
Add FREEHOP_GATE_TOKEN_AUDIENCE=wss://gate.example.com/freehop to the environment file, matching your public host and path. Public listeners and reverse-proxy configurations require a secret or explicit anonymous mode. Configure STUN URLs in the application’s authority too; clients do not trust gate-supplied STUN hints.
Keep the token secret out of the unit file: the unit reads it from /etc/freehop/gate.env, and your backend needs the same value.
# SPDX-License-Identifier: Apache-2.0
# Freehop gate: signalling-only rendezvous. It never carries media; its traffic is a few
# KB per call setup. Runs unprivileged, loopback-only behind Caddy, plus a public STUN port.
[Unit]
Description=Freehop gate (signalling only)
After=network-online.target
Wants=network-online.target
[Service]
User=freehop
Group=freehop
WorkingDirectory=/opt/freehop
Environment=FREEHOP_GATE_HOST=127.0.0.1
Environment=FREEHOP_GATE_PORT=8787
Environment=FREEHOP_GATE_PATH=/freehop
Environment=FREEHOP_GATE_PUBLIC_HOST=gate.example.com
Environment=FREEHOP_GATE_STUN=0.0.0.0:3478,[::]:3478
Environment=FREEHOP_GATE_TRUST_PROXY=1
Environment=FREEHOP_GATE_TOKEN_AUDIENCE=wss://gate.example.com/freehop
EnvironmentFile=-/etc/freehop/gate.env
ExecStart=/usr/bin/node /opt/freehop/bin/freehop-gate.mjs
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
CapabilityBoundingSet=
UMask=0077
# V8 uses JIT executable memory; do not enable MemoryDenyWriteExecute here.
# The gate's default limits need about 225 MiB at worst, plus garbage from bursts (src/gate/gate.mjs).
MemoryMax=512M
LimitNOFILE=8192
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now freehop-gate
Caddy terminates TLS and forwards the WebSocket path. The gate can have its own host name, or share your application's origin by adding the handle block to the existing site:
gate.example.com {
handle /freehop {
reverse_proxy 127.0.0.1:8787
}
}
Clients, and the authority's gates list, then use wss://gate.example.com/freehop.
With FREEHOP_GATE_TRUST_PROXY=1 the proxy must overwrite X-Forwarded-For, or clients can claim any address. Caddy does this by default. With nginx, add proxy_set_header X-Forwarded-For $remote_addr;.
Firewall
| Port | Why |
|---|---|
| TCP 443 | Your HTTPS proxy (already open for any website) |
| UDP 3478 | The gate's STUN responder, if you enabled it |
Nothing else. There are no TURN or relay ports to open on a gate host, because a gate never relays media.
Operate it
- Health check.
GET /healthzon the gate's own port answersok. - Statistics. Every five minutes the service prints a JSON line with
"event":"stats": open sockets, sockets still being admitted, rooms, joins, frames and bytes in and out, buffered output, envelopes forwarded, refusals, rate limits, slow receivers closed, and STUN counters. - Events. Notable events go to standard error as JSON lines with a timestamp.
- Shutdown.
SIGINTandSIGTERMclose every socket and the STUN responders cleanly.
Expect tiny numbers: about 15 to 35 KB per pair of peers at call setup, and almost nothing once the pair is connected. The gate's bounds (byte budgets, message rates, sockets per address) make it impractical to tunnel media through it.
Open or token-gated
With explicit FREEHOP_GATE_ALLOW_ANONYMOUS=1 a public gate is open: any client that speaks the protocol can use it, though it still only ever sees sealed envelopes. With a token secret, clients must present a token minted with that secret. Configure the authority’s gateTokenSecrets map for this URL, and every ticket carries a token bound to its room and this gate’s audience, so your gate serves only your rooms.
More than one gate
List several gates in the authority's gates option (up to eight). Clients connect to all of them, peers on different gates still meet through the mesh, and established calls keep running if every gate goes down. Gates can be yours, a community's, or public WebTorrent trackers:
createAuthority({
app: 'my-app',
gates: ['wss://gate.example.com/freehop', 'bt+wss://tracker.openwebtorrent.com'],
gateTokenSecrets: {
'wss://gate.example.com/freehop': process.env.FREEHOP_GATE_TOKEN_SECRET
},
stun: ['stun:gate.example.com:3478']
});
A room whose tickets list only tracker gates runs without any server of yours. Host nodes, however, only use Freehop gates, so keep at least one wss:// gate in tickets for hosted rooms.
Embed the gate
The service is a thin wrapper around createGate() from freehop/gate, which you can embed in an existing Node.js server:
import { createGate } from 'freehop/gate';
const gate = await createGate({
server, // an existing http(s).Server, or host + port
path: '/freehop',
stun: [{ host: '0.0.0.0', port: 3478 }, { host: '::', port: 3478 }],
publicHost: 'gate.example.com',
tokenSecret: process.env.FREEHOP_GATE_TOKEN_SECRET,
tokenAudience: 'wss://gate.example.com/freehop',
trustProxy: true
});
console.log(gate.url(), gate.stats());
Other options: stunUrls (advertise your own STUN URLs), authorize(hello, request) (your own admission hook, used instead of tokens), limits and log. The reference example runs the gate on the same server as its backend this way.
Next
The protocol specification defines every frame the gate speaks.