From 75d10385d7030f4c4962eb86ada6c93be37b60ee Mon Sep 17 00:00:00 2001 From: Damien Date: Fri, 31 Jul 2026 17:52:47 +0200 Subject: [PATCH 1/2] feat(proxy): add generic rsyslog receiver for exposed services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fail2ban jail running inside a service's own LXC only ever sees this proxy's tailnet IP as the connection source, so it would end up banning the proxy itself. Detection needs to stay at the service's application log; banning needs to happen here, at the edge where public connections terminate. Adds a generic imtcp listener (port RSYSLOG_PORT, default 5514) that routes anything unclaimed by a later 50-.conf into /var/log/remote/.log, plus logrotate with copytruncate so fail2ban never loses its file descriptor. No service-specific routing yet — that's one 50-.conf per service, documented here for the next issue to follow. Refs #20 --- proxy/README.md | 39 +++++++++++++++++++++++++++++++++++++++ proxy/install.sh | 43 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/proxy/README.md b/proxy/README.md index 946c749..fa80a9d 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -60,3 +60,42 @@ sudo ufw delete allow 22/tcp - Access NPM admin: `https://proxy..ts.net` - Default credentials: `admin@example.com` / `changeme` - Optionally approve exit-node in Tailscale admin console + +## Centralized log reception (rsyslog) + +A fail2ban jail running inside an exposed service's own LXC only ever sees +this proxy's tailnet IP as the connection source, so it would end up +banning the proxy itself instead of the actual client. Detection has to +stay where the signal is (the service's application log); banning has to +happen here, where public connections terminate. Services forward their +logs to this proxy over TCP so a jail here can act on them. + +| File | Purpose | +|------|---------| +| `/etc/rsyslog.d/10-remote-receiver.conf` | Generic `imtcp` listener, port `RSYSLOG_PORT` (default `5514`). Anything not claimed by a more specific routing file lands in `/var/log/remote/.log`. | +| `/etc/rsyslog.d/50-.conf` | One per exposed service. Routes by tag/programname into that service's own logfile for its dedicated fail2ban jail. | +| `/etc/logrotate.d/remote-logs` | Rotation for everything under `/var/log/remote/` (`copytruncate`, so fail2ban never loses its file descriptor across a rotation). | + +Adding a new exposed service is a matter of dropping its `50-.conf` +here — nothing else in this list needs to change. A minimal example that +routes messages tagged `myservice` into their own file, in addition to the +generic catch-all: + +``` +$RuleSet remoteLogs +if $programname == 'myservice' then { + action(type="omfile" file="/var/log/myservice/myservice.log") +} +$RuleSet RSYSLOG_DefaultRuleset +``` + +`RSYSLOG_PORT` and `RSYSLOG_BIND_ADDR` (default `0.0.0.0`) are overridable +via environment. The default bind is safe as-is: UFW's default-deny only +opens `80/tcp` and `443/tcp` publicly, so port `5514` is reachable +exclusively over `tailscale0` regardless of the bind address. Binding to +the tailnet IP directly was considered and rejected — it would require +`tailscale up` to have already succeeded before rsyslog is configured, +which complicates the script's flow (a missing `TS_AUTHKEY` is tolerated +today). The residual risk is log injection from anything that reaches the +port (which can trigger a false fail2ban ban); narrow `RSYSLOG_BIND_ADDR` +to a specific tailnet IP if that risk becomes a concern. diff --git a/proxy/install.sh b/proxy/install.sh index cfca993..b2b498a 100644 --- a/proxy/install.sh +++ b/proxy/install.sh @@ -44,6 +44,13 @@ ACME_EMAIL="${ACME_EMAIL:-}" # Generate at https://login.tailscale.com/admin/settings/keys TS_AUTHKEY="${TS_AUTHKEY:-}" +# rsyslog receiver for exposed services' logs (see "Configuring rsyslog" below). +RSYSLOG_PORT="${RSYSLOG_PORT:-5514}" +# Default 0.0.0.0 is fine: UFW's default-deny only opens 80/443 publicly, so +# this port is reachable exclusively over tailscale0 either way. Override to +# a specific tailnet IP to shrink the blast radius of log injection instead. +RSYSLOG_BIND_ADDR="${RSYSLOG_BIND_ADDR:-0.0.0.0}" + main() { log_info "=== Proxy Server Deployment (Traefik v3) ===" @@ -75,7 +82,7 @@ main() { log_info "Installing base packages..." sudo apt update -qq - sudo apt install -y -qq vim ca-certificates curl gnupg lsb-release fail2ban unattended-upgrades ufw ethtool networkd-dispatcher > /dev/null + sudo apt install -y -qq vim ca-certificates curl gnupg lsb-release fail2ban unattended-upgrades ufw ethtool networkd-dispatcher rsyslog > /dev/null log_info "Installing Tailscale..." curl -fsSL https://tailscale.com/install.sh | sh @@ -174,6 +181,40 @@ EOF sudo systemctl restart fail2ban + log_info "Configuring rsyslog receiver for exposed services..." + # A fail2ban jail running inside a service's own LXC only ever sees this + # proxy's tailnet IP as the source of connections, so it would end up + # banning the proxy itself. Detection has to stay where the signal is + # (the service's application log); banning has to happen here, at the + # edge where public connections actually terminate. Services forward + # their logs to this receiver over TCP; adding a new one is a matter of + # dropping a 50-.conf here (see proxy/README.md) — nothing else + # to touch. + sudo mkdir -p /var/log/remote + sudo tee /etc/rsyslog.d/10-remote-receiver.conf > /dev/null << EOF +module(load="imtcp") + +\$RuleSet remoteLogs +\$template RemoteLogPath,"/var/log/remote/%HOSTNAME%.log" +*.* ?RemoteLogPath +\$RuleSet RSYSLOG_DefaultRuleset + +input(type="imtcp" port="${RSYSLOG_PORT}" address="${RSYSLOG_BIND_ADDR}" ruleset="remoteLogs") +EOF + + sudo tee /etc/logrotate.d/remote-logs > /dev/null << 'EOF' +/var/log/remote/*.log { + daily + rotate 7 + compress + missingok + notifempty + copytruncate +} +EOF + + sudo systemctl restart rsyslog + log_info "Creating Traefik stack under $TRAEFIK_DIR..." mkdir -p "$TRAEFIK_DIR/conf.d" -- 2.55.0 From 4744de1a43341d2202d041e3ef4343eef4d2076a Mon Sep 17 00:00:00 2001 From: Damien Date: Sat, 1 Aug 2026 18:10:21 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(proxy):=20s=C3=A9parer=20le=20listener?= =?UTF-8?q?=20rsyslog=20du=20catch-all=20g=C3=A9n=C3=A9rique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le catch-all du récepteur générique vivait dans 10-remote-receiver.conf, chargé avant tout 50-.conf (rsyslog charge /etc/rsyslog.d/*.conf par ordre alphabétique, et les règles d'un ruleset s'exécutent dans l'ordre de chargement). Chaque message tagué finissait donc écrit deux fois : une fois par le catch-all générique, une fois par sa règle dédiée. 10-remote-receiver.conf ne contient plus que module()/input(). Le catch-all part dans 90-remote-fallback.conf, chargé après tout 50-.conf, dont le `stop` empêche alors effectivement la double écriture. Gardé sur la syntaxe legacy $RuleSet plutôt que l'objet moderne ruleset(name=...){...} suggéré en review : ce dernier refuse d'être déclaré une seconde fois pour le même nom ("ruleset ... specified more than once"), ce qui casserait dès qu'un 50-.conf ajoute ses propres règles au même ruleset remoteLogs — précisément le mécanisme que ce découpage doit permettre. $RuleSet est un sélecteur de contexte, pas une déclaration unique ; n'importe quel nombre de fichiers peut le rouvrir pour y ajouter des règles. Détail dans le commentaire du script et dans le README. README : table mise à jour (3 fichiers au lieu de 2), exemple avec `stop`, et la phrase sur ce qui atterrit dans /var/log/remote/ corrigée (uniquement ce qui n'est pas réclamé, pas "tout"). --- proxy/README.md | 24 +++++++++++++++++++++--- proxy/install.sh | 30 +++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/proxy/README.md b/proxy/README.md index fa80a9d..9a1e46d 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -72,23 +72,41 @@ logs to this proxy over TCP so a jail here can act on them. | File | Purpose | |------|---------| -| `/etc/rsyslog.d/10-remote-receiver.conf` | Generic `imtcp` listener, port `RSYSLOG_PORT` (default `5514`). Anything not claimed by a more specific routing file lands in `/var/log/remote/.log`. | -| `/etc/rsyslog.d/50-.conf` | One per exposed service. Routes by tag/programname into that service's own logfile for its dedicated fail2ban jail. | +| `/etc/rsyslog.d/10-remote-receiver.conf` | Generic `imtcp` listener only (`module`/`input`), port `RSYSLOG_PORT` (default `5514`). No rules here — see why below. | +| `/etc/rsyslog.d/50-.conf` | One per exposed service. Routes by tag/programname into that service's own logfile for its dedicated fail2ban jail, then `stop`s so the message doesn't also fall through to the catch-all. | +| `/etc/rsyslog.d/90-remote-fallback.conf` | Catch-all: anything a `50-.conf` didn't claim (or before one exists yet) lands in `/var/log/remote/.log`. | | `/etc/logrotate.d/remote-logs` | Rotation for everything under `/var/log/remote/` (`copytruncate`, so fail2ban never loses its file descriptor across a rotation). | +rsyslog loads `/etc/rsyslog.d/*.conf` in filename order, and rules within a +ruleset run in the order they were loaded — a catch-all in `10-` would fire +on *every* message before a `50-.conf` ever got a look, doubling +every claimed message into both files. Keeping the listener in `10-`, routing +in `50-`, and the catch-all in `90-` puts them in the right order without +depending on load-order accidents. + Adding a new exposed service is a matter of dropping its `50-.conf` here — nothing else in this list needs to change. A minimal example that -routes messages tagged `myservice` into their own file, in addition to the +routes messages tagged `myservice` into their own file instead of the generic catch-all: ``` $RuleSet remoteLogs if $programname == 'myservice' then { action(type="omfile" file="/var/log/myservice/myservice.log") + stop } $RuleSet RSYSLOG_DefaultRuleset ``` +Deliberately on the legacy `$RuleSet ` directive rather than the +modern `ruleset(name="...") { ... }` object syntax: rsyslog rejects a named +ruleset declared with that object syntax more than once ("ruleset ... +specified more than once"), which breaks the moment a second +`50-.conf` (or `90-remote-fallback.conf`) tries to add its own +rules to the same `remoteLogs` ruleset. `$RuleSet ` is a context +selector, not a one-shot declaration — any number of files can reopen it to +append rules, which is the entire point of this pattern. + `RSYSLOG_PORT` and `RSYSLOG_BIND_ADDR` (default `0.0.0.0`) are overridable via environment. The default bind is safe as-is: UFW's default-deny only opens `80/tcp` and `443/tcp` publicly, so port `5514` is reachable diff --git a/proxy/install.sh b/proxy/install.sh index b2b498a..b5c73a3 100644 --- a/proxy/install.sh +++ b/proxy/install.sh @@ -190,16 +190,36 @@ EOF # their logs to this receiver over TCP; adding a new one is a matter of # dropping a 50-.conf here (see proxy/README.md) — nothing else # to touch. + # + # 10- carries only the listener (module/input): rsyslog loads + # /etc/rsyslog.d/*.conf in filename order, and rules within a ruleset + # execute in the order they were loaded. A catch-all defined here would + # run *before* any 50-.conf's rules ever get a chance — every + # message would double up into both the generic file and the + # service-specific one. The catch-all instead lives in + # 90-remote-fallback.conf (below, written after this block) so it loads + # last, and a service's `stop` actually prevents fallthrough into it. sudo mkdir -p /var/log/remote sudo tee /etc/rsyslog.d/10-remote-receiver.conf > /dev/null << EOF module(load="imtcp") -\$RuleSet remoteLogs -\$template RemoteLogPath,"/var/log/remote/%HOSTNAME%.log" -*.* ?RemoteLogPath -\$RuleSet RSYSLOG_DefaultRuleset - input(type="imtcp" port="${RSYSLOG_PORT}" address="${RSYSLOG_BIND_ADDR}" ruleset="remoteLogs") +EOF + + # Catch-all for anything a 50-.conf doesn't claim (or before one + # exists yet). Kept on the legacy $RuleSet/$template directives rather + # than the modern ruleset(name=...){...} object: rsyslog rejects a named + # ruleset declared via that object syntax more than once ("ruleset ... + # specified more than once"), which would break the moment a + # 50-.conf tries to add its own rules to the same "remoteLogs" + # ruleset — the entire point of this split. $RuleSet is a context + # selector, not a one-shot declaration, so any number of files can + # reopen it to append rules. + sudo tee /etc/rsyslog.d/90-remote-fallback.conf > /dev/null << 'EOF' +$RuleSet remoteLogs +$template RemoteLogPath,"/var/log/remote/%HOSTNAME%.log" +*.* ?RemoteLogPath +$RuleSet RSYSLOG_DefaultRuleset EOF sudo tee /etc/logrotate.d/remote-logs > /dev/null << 'EOF' -- 2.55.0