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#
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 address192.168.5.200.
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.
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-…andfw-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.
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: truecd ~/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/vectorThree 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
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.
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.csrscp 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.
# 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 -aline below adds it. Do one or the other, not both, or every message reaches the SIEM twice.
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
netstat -an -f inet | grep 6514One connection to 192.168.5.200.6514, ESTABLISHED.
Check
. 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 3A 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
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.