Skip to content

Remote syslog

A small syslog-ng receiver on control-host keeps the firewall's logs for 90 days.

Why: OPNsense only keeps logs on its own disk, and not for long: 31 days of system/audit logs, and a firewall (filter) log that, at default verbosity, only covered about an hour before it rotated. So "what talked to what last night" couldn't be answered, and when the firewall cold-booted it took its evidence with it. Shipping logs to a server fixes both: the files sit in /mnt/appdata, which the nightly borgmatic run already carries off-host.

A full Loki/Grafana stack would be overkill for one source. syslog-ng in a container plus a one-line pruner is about 300 MB of RAM limit in total.


Compose

compose.yaml
services:
  syslog-ng:
    image: balabit/syslog-ng:4.12.0
    container_name: syslog-ng
    # --no-caps: syslog-ng otherwise tries to keep CAP_SYSLOG/CAP_NET_BIND_SERVICE, which cap_drop removes.
    # The container listens on 5514/5515 (no privileged port needed); the host publishes 514 on one IP only.
    command: ["-F", "--no-caps", "-f", "/etc/syslog-ng/remote.cfg"]
    ports:
      - "<YOUR_SERVER_IP>:514:5514/udp"
      - "<YOUR_SERVER_IP>:514:5514/tcp"
      - "<YOUR_SERVER_IP>:5515:5515/udp"
    volumes:
      - ./syslog-ng.cfg:/etc/syslog-ng/remote.cfg:ro
      - /mnt/appdata/syslog:/var/log/remote
    security_opt: [no-new-privileges:true]
    cap_drop: [ALL]
    mem_limit: 256m
    restart: unless-stopped
    # syslog-ng-ctl talks to the daemon's control socket: fails if syslog-ng is wedged
    healthcheck:
      test: ["CMD", "syslog-ng-ctl", "healthcheck"]
      interval: 60s
      timeout: 10s
      retries: 3

  prune:
    # Deletes day directories older than 90 days, once a day. busybox find/sleep only.
    image: busybox:1.38.0
    container_name: syslog-prune
    command: ["sh", "-c", "while true; do find /var/log/remote -mindepth 2 -maxdepth 2 -type d -mtime +90 -exec rm -rf {} +; sleep 86400; done"]
    volumes:
      - /mnt/appdata/syslog:/var/log/remote
    security_opt: [no-new-privileges:true]
    cap_drop: [ALL]
    mem_limit: 32m
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "test", "-d", "/var/log/remote"]
      interval: 1h

No .env needed.

syslog-ng config

syslog-ng.cfg
@version: 4.12
# Only the firewall's address is accepted; everything else is dropped.
# Files: /var/log/remote/<host>/<YYYY-MM-DD>/<program>.log, pruned after 90 days by the `prune` service.
options { chain_hostnames(no); keep_hostname(yes); use_dns(no); create_dirs(yes); dir_perm(0750); perm(0640); stats(freq(0)); };

source s_net {
  network(transport("udp") port(5514) flags(syslog-protocol));
  network(transport("tcp") port(5514) flags(syslog-protocol) max-connections(10));
  network(transport("udp") port(5515));          # BSD/RFC3164 fallback
};
filter f_opnsense { netmask("192.0.2.1/32"); };  # your firewall's IP
destination d_files { file("/var/log/remote/opnsense/${R_YEAR}-${R_MONTH}-${R_DAY}/${PROGRAM:-unknown}.log"
                           template("${R_ISODATE} ${HOST} ${PROGRAM}[${PID}]: ${MESSAGE}\n")); };
log { source(s_net); filter(f_opnsense); destination(d_files); flags(final); };

Why it's built like this

  • One day directory per day, one file per program. Retention is then just "delete directories older than N days". No logrotate, no signals to the daemon. -mindepth 2 -maxdepth 2 only matches the <host>/<date> level, so it can never delete the host directory itself.
  • Filter by source address (netmask), so only the firewall can write here, and publish the port on one host IP, not 0.0.0.0. Syslog over UDP has no authentication, so the network is the access control. Add a firewall rule so only the router can reach 514 on that host too.
  • High port inside, 514 outside. The container listens on 5514, so it needs no capabilities at all (cap_drop: [ALL] + --no-caps); Docker does the 514 → 5514 mapping.
  • R_ macros name the files by received time (the container's clock, UTC by default), so a firewall with a wrong clock can't scatter logs into odd dates.
  • flags(syslog-protocol) on 5514 parses RFC 5424, which keeps the program name and structured fields intact. The plain 5515 listener is there in case the sender is set to BSD/RFC 3164 format.

OPNsense side

System → Settings → Logging → Remote, add a destination:

Field Value
Transport UDP(4)
Applications all, or pick: audit, system, filter, gateways, DHCP, configd
Levels info and above
Hostname <YOUR_SERVER_IP>
Port 514
RFC5424 ticked

Logs then appear as /mnt/appdata/syslog/opnsense/<date>/filterlog.log, audit.log, configd.py.log and so on, about 30 MB a day here. A program name containing / creates a subdirectory, which is only cosmetic.

Cut the noise at the source

Most of the firewall log was pass rules nobody reads: "log packets matched by the default pass rules" (Firewall → Settings → Advanced) and logging on the DNS-redirect port forwards. Turn those off and keep logging on block rules, which are the part that matters when you investigate. The same 90 days of disk then hold a lot more useful history.

The firewall health poller (see OPNsense maintenance) reads these files too, for example to alert when CrowdSec bans an internal address.

Reading the logs

cd /mnt/appdata/syslog/opnsense/$(date -u +%F)
tail -f filterlog.log
grep -c ',block,' filterlog.log                              # blocked packets today
grep -rl 'admin' /mnt/appdata/syslog/opnsense/*/audit.log   # days with GUI/admin activity

Sources & further reading