🤖 Running the fleet · Part 02 of 10
The router: the front door of every box
Exactly one process on each box listens on the public internet. It terminates TLS, looks each request’s hostname up in a small database, and forwards the request to the right local process over a unix socket. Everything else on the box hides behind it.
You are here: the router is the single public-facing process on a box. The hibernation activator (part 06) and the mesh (part 07) both live inside this same process, switched on by configuration.
What the router is and why it exists
A box hosts many chat space instances, plus a few supporting services. Each of those is its own process, and none of them listens on a public network port — every one of them listens on a unix socket, which is a special file (in this system, under /run/lchat/) that processes on the same machine can connect to like a network address. Unix sockets are invisible to the internet, which is the point: the only way traffic reaches an instance is through the router.
The router is started by systemd as the service lchat-router.service, and it is the same single lchat binary as everything else, started with the router subcommand. Its full start command, exactly as the systemd unit runs it, is:
/opt/lchat/lchat router --registry /data/registry.db --acme-cache /data/acme
The two arguments name the two files the router depends on: the registry (its routing table) and the certificate cache (its TLS material). The router has three jobs, taken in turn below:
- TLS: obtain and present the right certificate for every hostname it serves.
- Routing: map each request’s hostname to the right unix socket and forward the request.
- Hosting the activator and the mesh: when configured, wake sleeping instances (part 06) and cooperate with peer boxes (part 07). This page covers the first two; the other two have their own pages.
Think of the box as an office building. The router is the reception desk: it is the only door to the street, it checks which company (hostname) a visitor is asking for, and it walks the visitor to the right office (unix socket). The offices have no street doors of their own.
The registry: the routing table on disk
The router’s routing table lives in a small SQLite database, by default /data/registry.db, called the registry throughout this guide. It has one table, routes, with one row per hostname the box serves. The columns you will care about as an operator:
| Column | Meaning |
|---|---|
host | The full domain name, for example chat-a.example.com. This is the primary key. |
kind | instance for a messenger space the box lifecycle-manages, or service for a sibling service (the home server, the push relay, the cloud provisioner) that deploys on its own cadence. |
socket | The unix socket the router forwards this host’s traffic to, for example /run/lchat/chat-a.example.com.sock. |
status | creating, ready, or failed. Only ready rows are served; a failed row keeps its error message in last_error. |
version / prev_version | Which build of the software this instance currently runs, and which it ran before — the basis for rollback. |
hibernate | Whether this host is managed by the activator (put to sleep when idle) instead of by systemd. |
next_due | For a hibernating host: the time of its earliest scheduled delivery (for example a reminder), so the activator can wake it on time with no request. See part 06. |
db_path, app_store_url, home_origin, push_relay_url, push_relay_token, home_region, provider | Per-instance configuration recorded at creation time. home_region and provider are written but not yet acted on — they are seams for future geographic and co-operative placement. partially built |
You rarely edit the registry directly — the command-line tools (lchat add-instance, lchat remove, and so on) do it for you, and lchat instances prints it in a readable form. But it is useful to know that the whole routing state of a box is one ordinary SQLite file you can inspect:
sqlite3 "file:/data/registry.db?mode=ro" "SELECT host, kind, status, version, hibernate FROM routes"
chat-a.example.com|instance|ready|1b4bd808|0
chat-b.example.com|instance|ready|1b4bd808|1
home.example.com|service|ready||0
How the router learns about changes
The router keeps the routing table in memory (swapped atomically, so a reload never drops an in-flight connection) and re-reads the registry in two ways:
- On a reload signal.
systemctl reload lchat-routersends the process a HUP signal, and the unit’sExecReloadline is exactly that. The provisioning commands (add-instance,remove,hibernate) trigger this themselves, so you rarely type it. - On a two-second poll. The router also fingerprints the registry file every two seconds and reloads if it changed. This exists because a change made by another process (for example the control daemon) must be picked up even if nobody sends a signal.
TLS certificates: automatic, two ways
The router obtains its certificates from Let’s Encrypt automatically. There are two mechanisms, and they coexist:
Per-host certificates, issued on first contact
By default the router uses the ACME HTTP-01 method: when a browser first connects for a hostname the router has never served, the router asks Let’s Encrypt for a certificate for exactly that hostname, answers the validation request Let’s Encrypt makes to port 80, and receives a certificate within a few seconds. Certificates (and their private keys) are cached on disk in /data/acme/ — one file per host — so this happens once per host, not once per connection.
Crucially, the router will only request a certificate for a hostname that exists in the registry with a serveable status. This rule (the “host policy”) prevents strangers from pointing arbitrary DNS names at your box and burning through Let’s Encrypt’s rate limits on your behalf.
A wildcard certificate for a whole domain
Per-host issuance rides Let’s Encrypt’s rate limit of roughly fifty new certificates per registered domain per week — a hard ceiling on how fast new spaces can be created. So the router can additionally hold a wildcard certificate (*.example.com), obtained with the ACME DNS-01 method, which covers every direct subdomain at once. Enable it by setting two variables in the router’s environment file, /data/router.env, then restarting the router:
LCHAT_WILDCARD_DOMAINS=example.com
LCHAT_DNSIMPLE_TOKEN=<a DNSimple account API token that can edit the example.com zone>
With this set, the router re-checks the wildcard certificate at startup and every twelve hours, renewing when it is missing or within thirty days of expiry. Issuance works by publishing a temporary DNS record (_acme-challenge.example.com) through the DNSimple API, which is why the token is needed. The certificate persists in the same /data/acme/ cache as wildcard-example.com.pem. Hostnames on other domains, and the apex domain itself, keep using the per-host path — and if wildcard issuance ever fails, per-host issuance quietly keeps covering those hosts too, so a DNS-provider outage degrades gracefully instead of taking the box down.
The files in /data/acme/ contain private keys. They are mode 0600 for a reason. Include the directory in backups (losing it only forces re-issuance, but re-issuance is rate-limited), and never copy it anywhere world-readable.
Today each box keeps its own certificate cache on its own disk, which is fine while each hostname is served by exactly one box. The day a pool of boxes must be able to answer for the same hostname, the cache needs to live somewhere they all can see — object storage. The configuration value for this, LCHAT_CERT_CACHE=s3, is already reserved in the code and deliberately refuses to start, so nobody can half-enable an unfinished mode. Until it exists, certificate issuance ties a hostname to one box. See also the mesh’s honest limits.
What the router does on each port
| Port | Behaviour |
|---|---|
| 443 (HTTPS) | Terminates TLS with the certificate for the requested hostname, then routes by hostname (below). WebSocket connections — which the chat clients hold open continuously — pass straight through. |
| 80 (HTTP) | Answers Let’s Encrypt validation requests (/.well-known/acme-challenge/…), and permanently redirects everything else to HTTPS. |
These are the only two public TCP ports on a box. The firewall guidance in part 08 allows exactly 80, 443, and the WireGuard port (51820, UDP) inbound, and nothing else — SSH is not publicly reachable at all; operators reach it only through the WireGuard tunnel, and WireGuard itself does not answer unauthenticated packets, so to a port scanner the box shows nothing but the web. running today
How a request is routed, step by step
- A request arrives for, say,
https://chat-a.example.com/api/version. The router strips any port from the hostname and lookschat-a.example.comup in its in-memory copy of the registry. - If the host is unknown (no
readyrow), the router answers 404 — or, ifLCHAT_FALLBACK_URLis set in/data/router.env, redirects to that URL instead. The fallback pairs well with wildcard DNS: a mistyped or retired subdomain lands on your marketing site rather than an error page. - If the host is a normal (systemd-managed) route, the router opens a connection to the row’s unix socket and forwards the request, preserving the original hostname so the instance knows who it is.
- If the host is flagged for hibernation, the request is handed to the activator inside the router, which may need to wake the instance first — the whole story is part 06.
- If the box is in a mesh and the placement rule says another box owns this host, the request is forwarded to that box instead — part 07.
Why deploys do not drop requests
When an instance restarts (during a deploy), there is a brief moment when the old process has exited and the new one has not yet bound its socket. During that gap, the router’s connection attempts fail — and the router responds by retrying the connection for up to thirteen seconds before giving up. Thirteen seconds comfortably exceeds an instance’s shutdown drain (ten seconds) plus startup, so in practice a request arriving mid-deploy just waits a beat and then succeeds. This retry window is a load-bearing detail: it is what makes “restart the instance” a zero-downtime operation without any blue-green complexity.
The router also quietly strips two internal headers from every request before forwarding it (X-Lchat-Sidecar and X-Lchat-Mesh-Forwarded), so no outside client can forge the markers that bot sidecars and mesh peers use.
Configuring the router
The router reads optional configuration from /data/router.env (a plain KEY=value file; the unit declares it with a leading -, meaning the file may be absent). Everything it understands:
| Variable | Default | Meaning |
|---|---|---|
LCHAT_REGISTRY | /data/registry.db | Path to the registry database (also a --registry flag). |
LCHAT_ACME_CACHE | /data/acme | Certificate cache directory (also --acme-cache). |
LCHAT_HTTPS_ADDR / LCHAT_HTTP_ADDR | :443 / :80 | Listen addresses. |
LCHAT_FALLBACK_URL | unset (404) | Where unknown hostnames are redirected. |
LCHAT_WILDCARD_DOMAINS, LCHAT_DNSIMPLE_TOKEN | unset | Wildcard certificate issuance, described above. |
LCHAT_HIBERNATE_BUCKET, LCHAT_HIBERNATE_REGION, LCHAT_HIBERNATE_PREFIX, LCHAT_HIBERNATE_IDLE | unset | The hibernation activator’s master switch and tuning — explained fully in part 06. Unset means the activator does not exist. |
LCHAT_MESH_SELF, LCHAT_MESH_DOMAINS | unset | The mesh’s master switch — explained fully in part 07. Unset means zero mesh code runs. |
Checking on the router
# Is it running, and since when?
systemctl status lchat-router
# What has it logged recently? (-f follows live)
journalctl -u lchat-router -n 100
# Does it serve a known host end to end? (checks TLS, routing, and the instance)
curl -s https://chat-a.example.com/api/version
{"version":"a41c9f2","build":"1b4bd808"}
Because the activator runs inside the router, a hibernating fleet’s wake/sleep log lines also appear in journalctl -u lchat-router — that journal is the single most informative log on the box. Part 08 catalogues the log lines worth knowing.
The router is deliberately tiny and stateless: it holds no per-request state, its routing table is rebuilt from a file on disk, and systemd restarts it within a second if it crashes. It is a single point of failure for the box, but a much smaller and more stable one than any alternative — and every stateful, complicated thing (the instances) lives outside it in separate processes it merely connects to.