AZiT

Firewall logs into the SIEM

syslog over TLS from the firewall to Vector, parsed, named and enriched.

Why Logging pipeline, on the tour

The firewall sends its logs over TLS to Vector in the cluster, which checks nothing is lost in transit, parses pf's lines and SSH logins, adds device names, and writes everything to OpenSearch.

1. Vector#

apps/logging/vector-certs.yaml repositoryDownload
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: vector-syslog
  namespace: logging
spec:
  secretName: vector-syslog-tls
  commonName: siem.internal
  dnsNames:
    - siem.internal
  ipAddresses:
    - 192.168.5.200
  privateKey:
    algorithm: ECDSA
    size: 256
    encoding: PKCS8
    rotationPolicy: Always
  usages:
    - server auth
    - digital signature
  issuerRef:
    kind: ClusterIssuer
    name: homelab-ca
  • The certificate the firewall checks: siem.internal, on the fixed address 192.168.5.200.
apps/logging/hosts.csv repositoryDownload
ip,hostname
192.168.1.1,fw
192.168.2.1,fw
192.168.3.1,fw
192.168.4.1,fw
192.168.5.1,fw
192.168.10.1,fw
192.168.20.1,fw
192.168.30.1,fw
192.168.1.10,laptop
192.168.10.10,laptop-wifi
192.168.10.11,phone
192.168.10.12,watch
192.168.2.2,unifi-ap
192.168.3.10,ps5
192.168.4.10,kodi
192.168.5.10,k8s-cp1
192.168.5.20,k8s-cp2
192.168.5.30,k8s-cp3
192.168.5.40,k8s-worker1
192.168.5.50,k8s-worker2
192.168.5.60,unifi
192.168.5.100,k8s-api
192.168.5.140,homeassistant
192.168.5.200,siem
192.168.5.201,ingress
192.168.20.10,vacuum
  • Address to name, for every device you want named in the logs. Use your own; anything else on the home networks is shown as unknown.
apps/logging/vector.yaml repositoryDownload
Show the whole file, 198 lines
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: vector
  namespace: logging
spec:
  interval: 24h
  url: https://helm.vector.dev
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: vector
  namespace: logging
spec:
  interval: 30m
  dependsOn:
    - name: opensearch
  chart:
    spec:
      chart: vector
      version: "0.59.0"
      sourceRef:
        kind: HelmRepository
        name: vector
  values:
    role: Stateless-Aggregator
    replicas: 1
    resources:
      requests: { cpu: 100m, memory: 128Mi }
      limits:   { cpu: "1", memory: 512Mi }
    extraVolumes:
      - name: os-creds
        secret: { secretName: vector-opensearch, defaultMode: 0400 }
      - name: syslog-tls
        secret: { secretName: vector-syslog-tls, defaultMode: 0400 }
      - name: homelab-ca
        configMap: { name: homelab-ca }
      - name: hosts
        configMap: { name: vector-hosts }
    extraVolumeMounts:
      - { name: os-creds,   mountPath: /etc/vector-secrets, readOnly: true }
      - { name: syslog-tls, mountPath: /etc/vector-tls,     readOnly: true }
      - { name: homelab-ca, mountPath: /etc/homelab-ca,     readOnly: true }
      - { name: hosts,      mountPath: /etc/vector-hosts,  readOnly: true }
    containerPorts:
      - { name: syslog-tls, containerPort: 6514, protocol: TCP }
      - { name: api,        containerPort: 8686, protocol: TCP }
    service:
      enabled: true
      type: LoadBalancer
      externalTrafficPolicy: Local
      annotations:
        metallb.io/loadBalancerIPs: 192.168.5.200
      ports:
        - { name: syslog-tls, port: 6514, targetPort: 6514, protocol: TCP }
    customConfig:
      data_dir: /vector-data-dir
      api:
        enabled: true
        address: 0.0.0.0:8686
      log_schema:
        timestamp_key: "@timestamp"
      secret:
        os:
          type: directory
          path: /etc/vector-secrets
      enrichment_tables:
        hosts:
          type: file
          file:
            path: /etc/vector-hosts/hosts.csv
            encoding:
              type: csv
          schema:
            ip: string
            hostname: string
      sources:
        firewall:
          type: syslog
          mode: tcp
          address: 0.0.0.0:6514
          tls:
            enabled: true
            crt_file: /etc/vector-tls/tls.crt
            key_file: /etc/vector-tls/tls.key
            ca_file: /etc/homelab-ca/ca.crt
            verify_certificate: true
      transforms:
        parse_firewall:
          type: remap
          inputs: [firewall]
          source: |
            del(.host)
            del(.source_type)
            del(.version)
            del(.facility)
            del(.severity)
            del(.procid)
            .observer.hostname = del(.hostname)
            .observer.ip = del(.source_ip)
            .process.name = del(.appname)
            if .process.name == "pf" {
              .log_type = "pf"
              .event.dataset = "openbsd.pf"
              p, err = parse_regex(string!(.message), r'rule (?P<rule>[\d.]+)/\((?P<reason>[^)]*)\) (?P<action>\w+) (?P<direction>in|out) on (?P<iface>\S+): (?P<src_ip>\d+\.\d+\.\d+\.\d+)(?:\.(?P<src_port>\d+))? > (?P<dst_ip>\d+\.\d+\.\d+\.\d+)(?:\.(?P<dst_port>\d+))?: (?P<rest>.*?)(?: label=(?P<label>\S+))?$')
              if err == null {
                .rule.id = p.rule
                if p.label != null && p.label != "" { .rule.name = p.label }
                .event.reason = p.reason
                .event.action = p.action
                .network.direction = p.direction
                .source.ip = p.src_ip
                .destination.ip = p.dst_ip
                src_host, src_err = get_enrichment_table_record("hosts", {"ip": .source.ip})
                if src_err == null { .source.hostname = src_host.hostname } else if starts_with(string(.source.ip) ?? "", "192.168.") { .source.hostname = "unknown" }
                dst_host, dst_err = get_enrichment_table_record("hosts", {"ip": .destination.ip})
                if dst_err == null { .destination.hostname = dst_host.hostname } else if starts_with(string(.destination.ip) ?? "", "192.168.") { .destination.hostname = "unknown" }
                if p.src_port != null && p.src_port != "" { .source.port = to_int(p.src_port) ?? null }
                if p.dst_port != null && p.dst_port != "" { .destination.port = to_int(p.dst_port) ?? null }
                rest = string(p.rest) ?? ""
                if starts_with(rest, "udp") {
                  .network.transport = "udp"
                } else if starts_with(rest, "icmp") {
                  .network.transport = "icmp"
                } else if .destination.port != null {
                  .network.transport = "tcp"
                }
                ifname = string(p.iface) ?? ""
                .observer.ingress.interface.name = ifname
                aliases = {
                  "igc0": "WAN", "igc1": "ADMIN", "igc2": "APMGMT", "igc3": "PS5",
                  "igc4": "MEDIA", "igc5": "SERVERS", "vlan10": "TRUSTED", "vlan20": "IOT", "vlan30": "GUEST", "wg0": "VPN"
                }
                alias = get(aliases, [ifname]) ?? null
                if is_null(alias) { alias = ifname }
                .observer.ingress.interface.alias = alias
              } else {
                .tags = ["pf_parse_failure"]
              }
            } else {
              .log_type = "system"
              .event.dataset = "openbsd.system"
              if includes(["sshd", "sshd-session", "sshd-auth"], .process.name) {
                m = string!(.message)
                s, e1 = parse_regex(m, r'^(?P<outcome>Accepted|Failed) (?P<method>\S+) for (?:invalid user )?(?P<user>\S+) from (?P<ip>[0-9.]+) port (?P<port>\d+)(?: ssh2)?(?:: (?P<keytype>\S+) (?P<fp>SHA256:\S+))?')
                if e1 == null {
                  .event.category = "authentication"
                  .event.outcome = if s.outcome == "Accepted" { "success" } else { "failure" }
                  .user.name = s.user
                  .source.ip = s.ip
                  .source.port = to_int(s.port) ?? null
                  .ssh.method = s.method
                  if s.fp != null && s.fp != "" { .ssh.key.fingerprint = s.fp; .ssh.key.type = s.keytype }
                }
                f1, e2 = parse_regex(m, r'^Invalid user (?P<user>\S*) from (?P<ip>[0-9.]+) port')
                if e2 == null { .event.category = "authentication"; .event.outcome = "failure"; .user.name = f1.user; .source.ip = f1.ip }
                f2, e3 = parse_regex(m, r'^Connection closed by authenticating user (?P<user>\S+) (?P<ip>[0-9.]+) port .*\[preauth\]')
                if e3 == null { .event.category = "authentication"; .event.outcome = "failure"; .user.name = f2.user; .source.ip = f2.ip }
                f3, e4 = parse_regex(m, r'^User (?P<user>\S+) from (?P<ip>[0-9.]+) not allowed')
                if e4 == null { .event.category = "authentication"; .event.outcome = "failure"; .user.name = f3.user; .source.ip = f3.ip }
                if exists(.source.ip) {
                  sh, she = get_enrichment_table_record("hosts", {"ip": .source.ip})
                  if she == null { .source.hostname = sh.hostname }
                }
              }
            }
      sinks:
        opensearch:
          type: elasticsearch
          inputs: [parse_firewall]
          endpoints: ["https://opensearch-cluster-master.logging.svc:9200"]
          api_version: v8
          mode: bulk
          bulk:
            index: 'fw-{{ "{{" }} log_type {{ "}}" }}-%Y.%m.%d'
          encoding:
            except_fields: ["log_type"]
          auth:
            strategy: basic
            user: "SECRET[os.username]"
            password: "SECRET[os.password]"
          tls:
            ca_file: /etc/homelab-ca/ca.crt
            verify_certificate: true
            verify_hostname: true
  • The pf parser turns each line into fields: action, direction, interface and its network's name, addresses, ports, protocol, and the rule's name from pflog2syslog.
  • SSH logins become authentication events with the user, the source and the key's fingerprint, which the alerts in the next chapter use.
  • Logs go to one index per type and day: fw-pf-… and fw-system-….
  • The syslog source asks every client for a certificate from your CA (verify_certificate). Without one, the TLS handshake fails before a single line is read: nothing else on the network can pose as the firewall.
apps/logging/kustomization.yaml repositoryDownload
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - namespace.yaml
  - opensearch-certs.yaml
  - opensearch-security.sops.yaml
  - opensearch-dashboards-account.sops.yaml
  - vector-opensearch.sops.yaml
  - opensearch.yaml
  - opensearch-dashboards.yaml
  - vector-certs.yaml
  - vector.yaml
configMapGenerator:
  - name: homelab-ca
    namespace: logging
    files:
      - ca.crt=homelab-ca.crt
  - name: vector-hosts
    namespace: logging
    files:
      - hosts.csv
generatorOptions:
  disableNameSuffixHash: true
laptop
cd ~/homelab && git add apps && git commit -m "Logging: Vector, with TLS syslog on siem.internal:6514" && git push
flux reconcile kustomization flux-system --with-source
kubectl -n logging wait --for=create helmrelease/vector --timeout=5m
kubectl -n logging wait --for=condition=Ready helmrelease/vector --timeout=5m
kubectl -n logging rollout status deploy/vector

Three waits, one for each thing that happens in turn: Flux applies the apps layer after the other two, which creates the HelmRelease (--for=create; a plain kubectl wait fails at once with NotFound for something that does not exist yet); Helm then installs the chart (Ready); and the pod starts (rollout status). Checked any sooner, there is no Vector yet.

Check

laptop
kubectl -n logging get svc vector
kubectl -n logging logs deploy/vector --tail=30 | grep -iE 'healthcheck|error|listening'

The service has the external address 192.168.5.200, and Vector reports its OpenSearch health check as passed.

2. The firewall sends#

The firewall needs three things: the CA to check Vector's certificate, a client certificate of its own so Vector accepts it, and one more line in syslog.conf. Its key is made on the firewall and never leaves it; only a signing request travels to the laptop, where your CA signs it.

firewall
doas openssl ecparam -genkey -name prime256v1 -noout -out /etc/ssl/private/syslog-client.key
doas chmod 600 /etc/ssl/private/syslog-client.key
doas openssl req -new -key /etc/ssl/private/syslog-client.key -subj /CN=fw.internal -out /tmp/fw-syslog.csr
laptop
scp fw:/tmp/fw-syslog.csr ~/homelab-ca/
cd ~/homelab-ca
cat > fw-syslog.ext <<'EOF'
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectAltName = DNS:fw.internal
EOF
openssl x509 -req -in fw-syslog.csr -CA ca.crt -CAkey ca.key \
  -set_serial 0x$(openssl rand -hex 16) -days 397 -sha256 \
  -extfile fw-syslog.ext -out fw-syslog.crt
openssl verify -CAfile ca.crt -purpose sslclient fw-syslog.crt
scp ca.crt fw:/tmp/homelab-ca.crt
scp fw-syslog.crt fw:/tmp/

A client certificate only (clientAuth): stolen, it could not pose as a server. It is valid for 397 days: put a reminder in your calendar to renew it a month before.

/etc/syslog.conf add to the endDownload
# To the SIEM over TLS: everything at notice or above, plus pf, logins and doas at info
*.notice;local0.info;auth.info;authpriv.info		@tls://siem.internal:6514
  • pf's lines, logins and doas at info, and everything else at notice or above.
  • Shown for reference: the printf … | doas tee -a line below adds it. Do one or the other, not both, or every message reaches the SIEM twice.
firewall
doas install -o root -g wheel -m 0644 /tmp/homelab-ca.crt /etc/ssl/homelab-ca.crt
doas install -o root -g wheel -m 0644 /tmp/fw-syslog.crt /etc/ssl/syslog-client.crt
doas rm -f /tmp/homelab-ca.crt /tmp/fw-syslog.crt /tmp/fw-syslog.csr
printf '*.notice;local0.info;auth.info;authpriv.info\t\t@tls://siem.internal:6514\n' | doas tee -a /etc/syslog.conf
doas rcctl set syslogd flags -h -Z -C /etc/ssl/homelab-ca.crt -c /etc/ssl/syslog-client.crt -k /etc/ssl/private/syslog-client.key
doas rcctl restart syslogd

-h adds the firewall's name to each line, -Z uses ISO timestamps with the time zone, -C is the CA to check the server against, and -c and -k are the certificate and key the firewall shows Vector. -Z applies to the firewall's own log files too: from now on their lines start with a UTC time such as 2026-10-17T22:42:06.605Z instead of Oct 18 00:42:06.

Check

firewall
netstat -an -f inet | grep 6514

One connection to 192.168.5.200.6514, ESTABLISHED.

Check

laptop
. scripts/os.sh
os "$URL/_cat/indices/fw-*?v"
os "$URL/fw-pf-*/_count?q=tags:pf_parse_failure"
kubectl -n logging exec -it deploy/vector -- vector tap --outputs-of parse_firewall --limit 3

A fw-pf- index for today, growing; zero parse failures; and parsed events with rule.name, source.hostname and the network's name. vector tap keeps going: stop it with Ctrl-C. The index names use the UTC date. The fw-system- index appears with the first event of its kind, such as an SSH login to the firewall.

Last, prove that nothing else can send. From a cluster board, on the same network as Vector so pf never sees it, a client without a certificate tries to slip in a line:

Check

laptop
ssh k8s-cp1 'echo "<13>Oct 25 10:00:00 fw fake: injected" | openssl s_client -quiet -connect 192.168.5.200:6514 2>&1 | tail -1'
sleep 5; os "$URL/fw-*/_count?q=injected"

SSL alert number 40 (handshake failure: Vector wanted a certificate), and a count of 0.

Vector accepts any client certificate from your CA, not only the firewall's: someone who can read the certificate secrets in the cluster could still send. Vector can add the client certificate's details to each event (tls.client_metadata_key), so a filter could keep only fw.internal.