Linux/systemd colibri-bridge packaging + domedog network facts #203

Merged
clawdie merged 2 commits from feat/colibri-bridge-linux-packaging into main 2026-06-26 01:35:10 +02:00
4 changed files with 208 additions and 0 deletions

96
packaging/linux/README.md Normal file
View file

@ -0,0 +1,96 @@
# Colibri bridge — Linux (systemd)
Linux/systemd peer of `packaging/freebsd/colibri_bridge.in`. Exposes the
colibri-daemon control-plane Unix socket as a TCP port **on the Tailscale
interface only**, so other mesh hosts can drive the control plane.
The FreeBSD bridge already runs on hermes (binds hermes's own tailnet address);
this is the matching domedog side. The network gate (ufw) is **already applied
on domedog** (see "domedog host facts"); the systemd unit here is the proposed
service — review the open questions with hermes before `enable --now`.
> Real `100.x` Tailscale addresses are never committed to git. Each host's
> address is supplied at deploy time (`tailscale ip -4`) via
> `/etc/colibri/bridge.env` — see `COLIBRI_BRIDGE_LISTEN_ADDR=TAILSCALE_IP_REQUIRED`.
## Files
| File | Purpose |
|---|---|
| `colibri-bridge.service` | systemd unit running socat (peer of the rc.d script) |
| `colibri-bridge.env.example` | tunables — install to `/etc/colibri/bridge.env` |
| `colibri-bridge.nft` | nftables ruleset **for hosts without ufw** (see facts below) |
## Install (one-time, as root)
```sh
install -D -m 0644 packaging/linux/colibri-bridge.service \
/etc/systemd/system/colibri-bridge.service
install -D -m 0644 packaging/linux/colibri-bridge.env.example \
/etc/colibri/bridge.env # then edit for this host
# Firewall: scope port 9190 to tailscale0 (peer of the hermes pf rule).
# domedog runs ufw (active, default-deny input) — the allow goes in ufw, and
# ufw's default-deny already blocks 9190 on every other interface:
ufw allow in on tailscale0 to any port 9190 proto tcp comment 'colibri-bridge'
# On a host WITHOUT ufw / with a default-accept input, use the nft table instead:
# install -D -m 0644 packaging/linux/colibri-bridge.nft /etc/colibri/colibri-bridge.nft
# nft -f /etc/colibri/colibri-bridge.nft
systemctl daemon-reload
systemctl enable --now colibri-bridge.service
systemctl status colibri-bridge.service
```
Quick check from another tailnet host:
```sh
printf '{"cmd":"status"}\n' | nc -w2 "$(tailscale ip -4)" 9190 # run on/against this host
```
## domedog host facts (verified 26.jun.2026)
The networking reality this packaging assumes, recorded so the next person
doesn't have to re-derive it:
- **Tailnet IPv4:** each host has a distinct `100.x` address — get it with
`tailscale ip -4` (not committed to git per policy).
- **Init / service mgr:** systemd (Ubuntu 24.04). FreeBSD/hermes uses rc.d.
- **Daemon user:** `clawdija` (hermes: `clawdie`). No dedicated `colibri`
group exists on domedog; the bridge runs as the daemon's own user so it can
reach the 0770 socket.
- **Firewall:** **ufw active + enabled**, default **deny (incoming)** /
allow (outgoing), backed by nftables, with fail2ban on top. Live packet
counters confirm it's enforcing, not just configured.
- **Currently-allowed inbound ports:** 22/tcp (ssh), 80/tcp, 443 (tcp+udp),
84338443/tcp, and now **9190/tcp scoped to `tailscale0`** (the bridge, added
for this work).
- **Port 8443 = CloudPanel** server control panel (nginx vhost
`cloudpanel.conf`, run by the `clp` user from `/home/clp/services/nginx/`).
Only 8443 actually listens; 84338442 are allowed but unused (range wider
than needed). ⚠️ CloudPanel's admin login is exposed to **Anywhere** (public),
not tailnet-scoped — the opposite posture from this bridge; flagged for the
host-exposure review.
## Open questions for the hermes discussion
1. **The socket has no auth — the tailnet boundary is the auth.** With both
hosts bridging the control plane, any tailnet peer that can reach either host
can issue full control-plane commands (`spawn-agent`, `kill-agent`,
`terminal-*`). Consider a Tailscale ACL scoping `:9190` to specific peers.
2. **Socket path parity.** Both sides assume `/run/colibri/colibri.sock`
(FreeBSD: `/var/run/colibri/colibri.sock`). domedog's daemon must be started
with a matching `COLIBRI_DAEMON_SOCKET`; the daemon's default is under
`$XDG_DATA_HOME`, which the bridge can't see under `ProtectHome=yes`.
3. **CloudPanel public exposure** (8443) — decide whether to keep it public or
move it behind the tailnet like the bridge.
## Notes
- The FreeBSD `colibri_bridge.in` `health`/`status` block bug (scrambled
function definitions) was **fixed and pushed** on the hermes side during this
work.
- `colibri-bridge.nft` is retained for hosts with a default-accept input chain;
on a ufw host its `accept` is not reliable (ufw's `drop` is terminal across
base chains) and its `drop` is redundant (ufw already default-denies). Kept as
documentation of intent and for the no-ufw case.

View file

@ -0,0 +1,18 @@
# Colibri bridge configuration — systemd EnvironmentFile.
# Install to /etc/colibri/bridge.env (root:root, 0644).
# This is the systemd parallel to the sysrc vars in the FreeBSD rc.d script
# (packaging/freebsd/colibri_bridge.in).
# Tailscale IPv4 of THIS host. socat binds here — never 0.0.0.0 — so the control
# plane is not exposed on any other interface even if the firewall rule is
# removed. Fill in with this host's own address from `tailscale ip -4`.
# (Real 100.x addresses are never committed to git — set it at deploy time.)
COLIBRI_BRIDGE_LISTEN_ADDR=TAILSCALE_IP_REQUIRED
# TCP port. MUST match the firewall allow-rule that scopes 9190 to tailscale0.
COLIBRI_BRIDGE_LISTEN_PORT=9190
# colibri-daemon Unix socket. MUST equal the daemon's COLIBRI_DAEMON_SOCKET.
# The daemon default is under $XDG_DATA_HOME; for a system bridge point both at
# a stable /run path (and keep it off /home so ProtectHome=yes can stay on).
COLIBRI_BRIDGE_SOCKET=/run/colibri/colibri.sock

View file

@ -0,0 +1,28 @@
#!/usr/sbin/nft -f
#
# Colibri bridge firewall — restrict the control-plane TCP port to the Tailscale
# interface only. Linux/nftables peer of the hermes pf rule:
#
# pass in quick on tailscale0 proto tcp to port 9190 flags S/SA keep state
#
# Load standalone: sudo nft -f packaging/linux/colibri-bridge.nft
# Verify: sudo nft list table inet colibri
# Persist (Debian/Ubuntu): `include "/etc/colibri/colibri-bridge.nft"` from
# /etc/nftables.conf, then `systemctl enable --now nftables`.
table inet colibri {
chain input {
# Base-chain declaration — REQUIRED. Without `type … hook input …` the
# chain is inert and filters nothing. policy accept keeps this table
# surgical: it governs ONLY port 9190; all other traffic falls through
# to the host's existing rules.
type filter hook input priority 0; policy accept;
# Reach the bridge only over the tailnet…
iifname "tailscale0" tcp dport 9190 accept
# …and drop it on every other interface. `drop` is a terminal verdict,
# so this reliably blocks 9190 from the public side even if another
# table would otherwise accept it.
tcp dport 9190 drop
}
}

View file

@ -0,0 +1,66 @@
[Unit]
# Colibri control-plane TCP bridge — Linux/systemd peer of the hermes rc.d
# service in packaging/freebsd/colibri_bridge.in.
#
# Bridges the colibri-daemon Unix socket to a TCP port on the Tailscale
# interface so other mesh hosts can reach the Colibri control plane. socat runs
# in the foreground; systemd supervises it (restart on crash, journald logs) —
# the systemd equivalent of FreeBSD running socat under daemon(8).
Description=Colibri control-plane TCP bridge (Tailscale -> Unix socket)
Documentation=https://code.smilepowered.org/clawdie/colibri
After=network-online.target tailscaled.service colibri-daemon.service
Wants=network-online.target
# Ordering + lifecycle: the bridge is useless without the daemon, and the
# socket vanishes if the daemon stops — so bind our lifecycle to it.
Requires=colibri-daemon.service
BindsTo=colibri-daemon.service
# Keep retrying while we wait for tailscaled to assign the address at boot
# (paired with freebind below, this race is largely moot, but be forgiving).
StartLimitIntervalSec=0
[Service]
Type=exec
# Must be a user in the colibri-daemon socket's group (the daemon chmods its
# socket 0770 owner+group). On domedog that is the daemon's own user.
User=clawdija
Group=clawdija
# Tunables live here (systemd parallel to the rc.d sysrc vars). See
# colibri-bridge.env.example.
EnvironmentFile=/etc/colibri/bridge.env
# Refuse to start until the daemon socket actually exists, mirroring the rc.d
# prestart check (clearer failure than a socat connect error).
ExecStartPre=/usr/bin/test -S ${COLIBRI_BRIDGE_SOCKET}
# bind=<tailscale-ip> keeps us off every other interface even if the firewall
# is flushed. freebind (Linux IP_FREEBIND, no privilege needed) lets socat bind
# the tailnet address before tailscaled has finished bringing it up, avoiding a
# boot-order race — the one place this unit improves on the FreeBSD version.
ExecStart=/usr/bin/socat -d \
TCP-LISTEN:${COLIBRI_BRIDGE_LISTEN_PORT},bind=${COLIBRI_BRIDGE_LISTEN_ADDR},freebind,fork,reuseaddr \
UNIX-CONNECT:${COLIBRI_BRIDGE_SOCKET}
Restart=on-failure
RestartSec=2
# --- hardening: socat needs only TCP + Unix sockets and no privileges ---
NoNewPrivileges=yes
ProtectSystem=strict
# ProtectHome=yes is safe ONLY while the socket lives outside /home (it does, by
# default, under /run). If you repoint COLIBRI_BRIDGE_SOCKET into a home dir,
# relax this to read-only or the connect will fail.
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectControlGroups=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
RestrictAddressFamilies=AF_INET AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target