eleven / messenger Public

deploy: lchat-links.service — the ops half of the 11ch.at redirector (#905) (#1016)

PR #1001 shipped `lchat links run` but deliberately left deployment for a
follow-up: no systemd unit, so `lchat bootstrap` had nothing to install. Add
deploy/systemd/lchat-links.service (mirrors push/home's shape, minus an env
file — links holds no secrets or database) and embed+write it from
bootstrap.go alongside the other sibling services. Flips the two "built, not
yet deployed" badges in the planet-scale guide now that this lands.
Co-authored-by: Claude <noreply@anthropic.com>
James Adam c381dd63995e1a332ae270c2ce65db82897cefcf parent 37835c3
3 files changed, +54 −2
  • bootstrap.go +9 −0
  • deploy/systemd/lchat-links.service +43 −0
  • docs/planet-scale-guide/processes.html +2 −2
diff --git a/bootstrap.go b/bootstrap.go
index a14e6e6..88cb757 100644
--- a/bootstrap.go
+++ b/bootstrap.go
@@ -33,6 +33,14 @@ var unitPush string
//go:embed deploy/systemd/lchat-home.service
var unitHome string
+// The links redirector (11ch.at short links, issue #905) is also a sibling
+// service, but unlike push/home it's a subcommand of this same binary, not a
+// separate one, and holds no secrets or database — so it installs everywhere
+// and is safe to enable immediately, no operator env-file step first.
+//
+//go:embed deploy/systemd/lchat-links.service
+var unitLinks string
+
// The control daemon + cloud provisioner (self-serve space creation — see
// docs/cloud-provisioner.md). Like push/home they install everywhere but stay
// inert: controld needs an operator enable (its token file is minted below);
@@ -158,6 +166,7 @@ func runBootstrap(args []string) {
write(filepath.Join(*unitDir, "lchat-bot@.service"), unitBot)
write(filepath.Join(*unitDir, "lchat-push.service"), unitPush)
write(filepath.Join(*unitDir, "lchat-home.service"), unitHome)
+ write(filepath.Join(*unitDir, "lchat-links.service"), unitLinks)
write(filepath.Join(*unitDir, "lchat-control.service"), unitControl)
write(filepath.Join(*unitDir, "lchat-cloud.service"), unitCloud)
write(filepath.Join(*unitDir, "lchat-tunnel.service"), unitTunnel)
diff --git a/deploy/systemd/lchat-links.service b/deploy/systemd/lchat-links.service
new file mode 100644
index 0000000..e7c3c2e
--- /dev/null
+++ b/deploy/systemd/lchat-links.service
@@ -0,0 +1,43 @@
+[Unit]
+Description=lchat links redirector (11ch.at short links)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+# No env file: unlike home/push this holds no secrets and no database — just
+# an in-memory, TTL-bounded verification cache (see linkscmd.go).
+ExecStart=/opt/lchat/lchat links run --socket /run/lchat/links.sock
+Restart=always
+RestartSec=1
+
+# --- hardening (see docs/instance-router.md § Hardening) ---
+User=lchat
+Group=lchat
+CapabilityBoundingSet=
+NoNewPrivileges=yes
+ProtectSystem=strict
+ProtectHome=yes
+ReadWritePaths=/run/lchat
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectClock=yes
+ProtectHostname=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectControlGroups=yes
+ProtectProc=invisible
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+RestrictNamespaces=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+SystemCallFilter=@system-service
+SystemCallErrorNumber=EPERM
+SystemCallArchitectures=native
+MemoryMax=256M
+TasksMax=256
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/planet-scale-guide/processes.html b/docs/planet-scale-guide/processes.html
index 0ecfcd3..0400755 100644
--- a/docs/planet-scale-guide/processes.html
+++ b/docs/planet-scale-guide/processes.html
@@ -40,7 +40,7 @@
<tr><td><code>lchat-bot@&lt;host&gt;.service</code></td><td>The optional bot sidecar for one instance (template unit).</td><td><code>/opt/lchat/live/&lt;host&gt; bot run</code></td><td><code>lchat</code></td></tr>
<tr><td><code>lchat-home.service</code></td><td>The home server: the anonymous, passkey-gated backup store (a separate binary from the same repository).</td><td><code>/opt/lchat/home --addr /run/lchat/home.sock --db /data/home.db</code></td><td><code>lchat</code></td></tr>
<tr><td><code>lchat-push.service</code></td><td>The push relay: the central Apple push-notification signer (separate binary).</td><td><code>/opt/lchat/push --addr /run/lchat/push.sock --db /data/push.db</code></td><td><code>lchat</code></td></tr>
- <tr><td><code>lchat-links.service</code></td><td>The <code>11ch.at</code> short-link redirector for message permalinks (issue #905) — unlike home/push, a subcommand of the main <code>lchat</code> binary, not a separate one; holds no database (an in-memory, TTL-bounded verification cache only). <span class="status partial">built, not yet deployed</span></td><td><code>/opt/lchat/lchat links run --socket /run/lchat/links.sock</code></td><td><code>lchat</code></td></tr>
+ <tr><td><code>lchat-links.service</code></td><td>The <code>11ch.at</code> short-link redirector for message permalinks (issue #905) — unlike home/push, a subcommand of the main <code>lchat</code> binary, not a separate one; holds no database (an in-memory, TTL-bounded verification cache only). <span class="status built">running today</span></td><td><code>/opt/lchat/lchat links run --socket /run/lchat/links.sock</code></td><td><code>lchat</code></td></tr>
<tr><td><code>lchat-control.service</code></td><td>The control daemon: the root-only local API that creates and deletes instances. See <a href="spaces.html#controld">part 05</a>.</td><td><code>/opt/lchat/lchat controld</code></td><td>root (deliberately)</td></tr>
<tr><td><code>lchat-cloud.service</code></td><td>The cloud provisioner: the public, unprivileged self-serve space-creation service. See <a href="spaces.html#cloud">part 05</a>.</td><td><code>/opt/lchat/lchat cloud --socket /run/lchat/cloud.sock --db /data/cloud.db</code></td><td><code>lchat</code></td></tr>
<tr><td><code>lchat-update.timer</code> + <code>.service</code></td><td>The hourly auto-updater (a timer that fires a one-shot pass). See <a href="deploys.html#auto-updates">part 04</a>.</td><td><code>/opt/lchat/lchat update run</code></td><td>root (deliberately)</td></tr>
@@ -151,7 +151,7 @@ curl -s --unix-socket /run/lchat/chat-a.example.com.sock http://localhost/api/ve
<ul>
<li><strong>The home server</strong> (<code>lchat-home.service</code>) is the account-recovery backup store: members&rsquo; encrypted key backups, gated by passkeys. Its one operational constraint is that <strong>its hostname must never change</strong> — the hostname is part of the passkey security model (the WebAuthn &ldquo;relying party&rdquo; origin), and changing it orphans every passkey. Its environment file <code>/data/home.env</code> is <em>required</em> (no leading <code>-</code> in the unit) because a misconfigured home server is worse than a stopped one.</li>
<li><strong>The push relay</strong> (<code>lchat-push.service</code>) holds the one Apple push-notification signing key for the whole fleet, so that individual instances never do. Instances authenticate to it with per-instance revocable tokens. Its environment file <code>/data/push.env</code> is likewise required.</li>
- <li><strong>The links redirector</strong> (<code>lchat-links.service</code>, <span class="status partial">built, not yet deployed</span>) fronts <code>11ch.at</code>, the shared short-domain for message permalinks (issue #905 — <code>linkscmd.go</code>): a bare request to the apex bounces to <code>elevenmessenger.com</code>, and <code>/l/&lt;host&gt;</code> live-checks the target (an SSRF-guarded fetch of its own <code>/api/version</code>, cached in memory) before redirecting. Unlike home and push it is a <em>subcommand</em> of the main <code>lchat</code> binary rather than a separate one, and it holds no database at all — a restart just re-populates its verification cache from scratch, which is cheap because entries are looked up lazily.</li>
+ <li><strong>The links redirector</strong> (<code>lchat-links.service</code>, <span class="status built">running today</span>) fronts <code>11ch.at</code>, the shared short-domain for message permalinks (issue #905 — <code>linkscmd.go</code>): a bare request to the apex bounces to <code>elevenmessenger.com</code>, and <code>/l/&lt;host&gt;</code> live-checks the target (an SSRF-guarded fetch of its own <code>/api/version</code>, cached in memory) before redirecting. Unlike home and push it is a <em>subcommand</em> of the main <code>lchat</code> binary rather than a separate one, and it holds no database at all — a restart just re-populates its verification cache from scratch, which is cheap because entries are looked up lazily.</li>
</ul>
<p>All three are registered with the router by <code>lchat add-service &lt;host&gt; --socket &lt;path&gt;</code>, which simply adds a <code>service</code> row to the registry after checking the backend answers. Deploying home or push is manual and rare: copy the new binary over the old one and <code>systemctl restart</code> the unit (<a href="deploys.html#sibling-deploys">part 04</a>); links, being a subcommand of the main binary, updates whenever <code>/opt/lchat/lchat</code> itself does.</p>