Skip to main content

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:

VariableDefaultMeaning
FREEHOP_GATE_HOST127.0.0.1Address the WebSocket server listens on. Keep it on loopback behind a TLS proxy.
FREEHOP_GATE_PORT8787WebSocket port
FREEHOP_GATE_PATH/freehopWebSocket path. Clients connect to wss://<your host><path>.
FREEHOP_GATE_PUBLIC_HOSTnonePublic host name advertised in the stun: URLs. Needed when STUN binds a wildcard address.
FREEHOP_GATE_STUNnoneSTUN responders to start, for example 0.0.0.0:3478,[::]:3478 (IPv4 and IPv6).
FREEHOP_GATE_TOKEN_SECRETnoneMakes 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_AUDIENCErequired with a secretExact public WebSocket URL, e.g. wss://gate.example.com/freehop. Must match the authority’s URL and token audience.
FREEHOP_GATE_ALLOW_ANONYMOUSoffSet 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_PROXYoff1 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.

/etc/systemd/system/freehop-gate.service
# 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:

Caddyfile
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​

PortWhy
TCP 443Your HTTPS proxy (already open for any website)
UDP 3478The 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 /healthz on the gate's own port answers ok.
  • 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. SIGINT and SIGTERM close 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:

server.mjs
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.