diff --git a/packaging/linux/README.md b/packaging/linux/README.md new file mode 100644 index 0000000..5b654e9 --- /dev/null +++ b/packaging/linux/README.md @@ -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), + 8433–8443/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; 8433–8442 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. diff --git a/packaging/linux/colibri-bridge.env.example b/packaging/linux/colibri-bridge.env.example new file mode 100644 index 0000000..7ed3f60 --- /dev/null +++ b/packaging/linux/colibri-bridge.env.example @@ -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 diff --git a/packaging/linux/colibri-bridge.nft b/packaging/linux/colibri-bridge.nft new file mode 100644 index 0000000..6c262c7 --- /dev/null +++ b/packaging/linux/colibri-bridge.nft @@ -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 + } +} diff --git a/packaging/linux/colibri-bridge.service b/packaging/linux/colibri-bridge.service new file mode 100644 index 0000000..1365b22 --- /dev/null +++ b/packaging/linux/colibri-bridge.service @@ -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= 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