SpadeIDE

Hosting your own rendezvous

The rendezvous is an introducer: it helps a client and an agent find each other, then gets out of the way. When the network allows a direct peer-to-peer tunnel, it carries nothing at all; when it does not, it relays bytes it cannot read. One static Go binary, no database.

You almost certainly do not need this. Remote access works against the hosted introducer with no setup. Run your own if you would rather not depend on ours, or if policy requires the introduction to happen on your own infrastructure.

What it can and cannot see

Worth understanding before you run one, because it is what makes the design safe:

Requirements

UDP 443 matters. It carries the reflexive-address echo that lets two peers punch through NAT. If only TCP is open, every session silently falls back to the relay — more latency for your users and more bandwidth for you, with no error anywhere to tell you why.

Install on Ubuntu Server

1 · Create an unprivileged user

sudo useradd --system --no-create-home --shell /usr/sbin/nologin spade-rv

2 · Install the binary

# unpack the tarball from the download page
tar xzf spade-rendezvous-linux-amd64.tar.gz

sudo install -o root -g root -m 0755 \
  spade-rendezvous /usr/local/bin/spade-rendezvous

spade-rendezvous --version

3 · Create directories

sudo mkdir -p /var/lib/spade-rendezvous/acme /etc/spade-rendezvous
sudo chown -R spade-rv:spade-rv /var/lib/spade-rendezvous
sudo chmod 700 /var/lib/spade-rendezvous/acme

4 · Write the environment file

sudo tee /etc/spade-rendezvous/env >/dev/null <<'EOF'
SPADE_RV_DOMAIN=rendezvous.example.com
SPADE_RV_ACME_EMAIL=ops@example.com
SPADE_RV_LISTEN=:443
SPADE_RV_UDP_LISTEN=:443
SPADE_RV_METRICS_LISTEN=127.0.0.1:9090
SPADE_RV_SESSION_MAX_BYTES=536870912
SPADE_RV_SESSION_MAX_SECONDS=28800
SPADE_RV_AGENT_TTL=300s
SPADE_RV_MAX_AGENTS_PER_IP=20
SPADE_RV_RATE_LOOKUPS_PER_MIN=60
SPADE_RV_NOTICE_FILE=/etc/spade-rendezvous/notice.json
EOF

sudo chmod 640 /etc/spade-rendezvous/env
sudo chown root:spade-rv /etc/spade-rendezvous/env

The tarball install.sh also writes /etc/spade-rendezvous/notice.json (silent until you enable it) — see operator notice.

5 · Install the systemd unit

Save as /etc/systemd/system/spade-rendezvous.service. The hardening directives are requirements, not decoration — this process is exposed to the whole internet.

[Unit]
Description=SpadeIDE Rendezvous (introducer + blind relay)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=spade-rv
Group=spade-rv
EnvironmentFile=/etc/spade-rendezvous/env
ExecStart=/usr/local/bin/spade-rendezvous serve
Restart=on-failure
RestartSec=3

# Bind 443 without being root
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

# Only the ACME cache is writable
StateDirectory=spade-rendezvous
ReadWritePaths=/var/lib/spade-rendezvous

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @obsolete

LimitNOFILE=65535

[Install]
WantedBy=multi-user.target
LimitNOFILE is not optional. Every online agent holds one open connection, so the default of 1024 caps you at roughly a thousand agents.

6 · Start it

sudo systemctl daemon-reload
sudo systemctl enable --now spade-rendezvous
sudo systemctl status spade-rendezvous

7 · Open the firewall

sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status

8 · Verify

# Certificate issued, TLS 1.3 negotiated
openssl s_client -connect rendezvous.example.com:443 -tls1_3 </dev/null 2>/dev/null \
  | grep -E "Protocol|Verify return code"

# Health endpoint
curl -fsS https://rendezvous.example.com/healthz && echo OK

# Metrics must NOT answer from outside — this command must FAIL
curl -m 5 http://rendezvous.example.com:9090/metrics && echo "LEAK — fix SPADE_RV_METRICS_LISTEN"

# Logs must contain no handles, agent ids or client addresses
sudo journalctl -u spade-rendezvous -n 100 --no-pager

First-start certificate issuance takes a few seconds. If it fails, the cause is DNS or a blocked port 443 nine times out of ten.

9 · Point your agent and iPad at it

# on the host
spadeide remote on --server wss://rendezvous.example.com
spadeide remote status

On the iPad: Settings → Rendezvous URL, enter the same wss:// address. Leave it empty to go back to the hosted default.

Configuration reference

Everything is read from the environment. No secrets are written to disk.

VariableDefaultMeaning
SPADE_RV_DOMAINrequired Public hostname, and the subject of the ACME certificate
SPADE_RV_LISTEN:443 TCP listener — control connections and the relay
SPADE_RV_UDP_LISTEN:443 UDP listener — reflexive-address echo for NAT traversal
SPADE_RV_ACME_CACHE/var/lib/spade-rendezvous/acme Where the TLS certificate is cached. Keep it 0700
SPADE_RV_ACME_EMAILempty Optional contact address for Let's Encrypt
SPADE_RV_METRICS_LISTEN127.0.0.1:9090 Aggregate counters. Must be loopback — the server refuses to start otherwise
SPADE_RV_AGENT_TTL300s How long a registration survives without a heartbeat
SPADE_RV_SESSION_MAX_BYTES536870912 (512 MiB) Per-relay-session byte cap. 0 means unlimited — not advisable on a public host
SPADE_RV_SESSION_MAX_SECONDS28800 (8 h) Per-relay-session lifetime cap
SPADE_RV_MAX_AGENTS_PER_IP20 Registration cap per source address
SPADE_RV_RATE_LOOKUPS_PER_MIN60 Client lookup rate per source address
SPADE_RV_LOG_LEVELinfo Verbosity. Even at debug, no handles or addresses are logged
SPADE_RV_NOTICE_FILE/etc/spade-rendezvous/notice.json Operator notice file (always installed). Set "enabled": true inside to broadcast; hot-reloaded. Also GET /v1/notice

A malformed value is a startup error, not a silent fallback — a typo in a limit will stop the service rather than quietly running with a different number.

Operator notice

Broadcast a short message to iPads that use this rendezvous for remote access — security fixes, required updates, or anything you need everyone to see. The file is always on disk; whether it is shown is controlled inside the JSON, not by deleting the file.

Where it lives

install.sh / reinstall.sh create /etc/spade-rendezvous/notice.json if it is missing (default "enabled": false). An existing file is never overwritten on upgrade. Path override: SPADE_RV_NOTICE_FILE in the env file.

How clients get it

The notice is piggybacked on the next remote lookup (WSS /v1/client — when the iPad cannot reach the host on the LAN and talks to rendezvous). It is cached on the tablet and shown as a dismissible banner on the home screen. Editing the JSON is hot-reloaded (mtime) — no systemctl restart.

Turn it on

sudo nano /etc/spade-rendezvous/notice.json
# set "enabled": true, fill title/body, save

curl -sS https://rendezvous.example.com/v1/notice
# 200 + JSON while enabled; 204 while disabled

File format

{
  "enabled": true,
  "id": "sec-2026-04",
  "title": "Please update SpadeIDE",
  "body": "A security fix is out. Update the iPad app and the host agent.",
  "severity": "warning",
  "belowAppVersion": "1.2.0",
  "belowAgentVersion": "0.2.0",
  "agentUrl": "https://spadeide.com/download",
  "appUrl": "https://apps.apple.com/app/spadeide"
}
FieldMeaning
enabled false (install default) = silent; true = attach to lookups
id Stable label for this announcement. The iPad dismisses once per id — change it to show again after dismiss
title / body Banner text. When enabled you need id and at least one of these
severity info (default), warning, or critical — tint on the banner
belowAppVersion Show only if the iPad app version is strictly below this. Empty string = ignore
belowAgentVersion Show only if any paired host’s last-known agent version is below this. Empty = ignore
agentUrl Optional — “Download agent” on the banner (host agent download page)
appUrl Optional — “Get the app” on the banner (App Store / product page)

If both version fields are empty, every client that looks up sees the notice. If either is set, the banner appears when the app gate or the agent gate matches (strictly below). Agent versions are remembered from the last successful hello on each paired host.

This is informational only — it does not block pairing or connect. Clients that never use remote access (LAN only) will not fetch a notice until they look up.

Ports

PortExposurePurpose
443/tcpPublicAgent registration, client lookup, relay
443/udpPublicReflexive echo for direct connections
9090/tcpLoopback onlyMetrics. Scrape it over an SSH tunnel

Port 80 is never needed: certificates are issued over TLS-ALPN on 443 itself, so there is no HTTP listener and no redirect to get wrong.

Why 443 and not an obscure port? Because reachability is the entire point. Restrictive networks — hotel, corporate, some mobile carriers — allow outbound 443 and little else. Moving the port gets you blocked exactly where remote access matters most, and buys nothing: a full port scan of one host takes seconds. You can change it with SPADE_RV_LISTEN if your situation calls for it.

Operating it

Upgrades

tar xzf spade-rendezvous-linux-amd64.tar.gz
cd spade-rendezvous-linux-amd64
sudo ./reinstall.sh

reinstall.sh stops the unit, replaces the binary, and starts it again. /etc/spade-rendezvous/env, the ACME cache, and notice.json stay put (a missing notice file is created with enabled: false). Agents reconnect on their own within their retry window. Relayed sessions see a brief reconnect. Session state does not need a backup — that is the in-memory design. Keep copies of env and notice.json if you care about operator config.

Sizing

The workload is idle connections, so it is memory-bound rather than CPU-bound: budget a few tens of kilobytes per online agent. A 1–2 GB VM holds thousands. Relay bandwidth, not CPU, is the thing that costs money — and only for sessions that could not go direct.

Scaling past one node

Out of scope by design. State is in memory, so an agent and its client must reach the same instance. When you outgrow one, the answer is session affinity by handle — consistent hashing at a TCP load balancer, or regional hostnames — not a shared database, which would break the "stores nothing" property.

Troubleshooting

SymptomLikely cause
Service restarts in a loop at boot SPADE_RV_DOMAIN unset or invalid — check journalctl -u spade-rendezvous
TLS handshake fails, no certificate DNS not pointing here yet, or 443/tcp blocked upstream — the cloud security group, not ufw
Agents register but clients get "not found" Clock skew between host and tablet — handles rotate hourly. Check timedatectl
"too many open files" under load LimitNOFILE not applied — systemctl show -p LimitNOFILE spade-rendezvous
Everything works but always relays, never direct 443/udp is closed. TCP alone degrades every session to relay, silently