Forwarding syslog events between networks sounds straightforward. It is not. Between NAT boundaries, relay servers, message parsers, and hostname rewrites, the original source of an event can dissolve into the infrastructure before it ever reaches disk. This guide is a ground-up walkthrough of what it actually takes to get events from a device on one network reliably written to disk on another — with the correct source identity intact.

The scenario here is real: a router sitting on a private network sends syslog to a relay server. That relay forwards everything to a central aggregator that writes logs to disk organized by source host. The problem — and it is a surprisingly deep one — is that by the time events land on disk, the host field reads as the relay server’s IP, not the original device. Fixing this requires understanding how syslog-ng handles host identity at every stage of the pipeline.


The Architecture

Before touching configuration, it helps to be precise about the topology. There are three actors:

RoleIP
Router / source device192.168.1.1
Relay (forwarding syslog-ng)100.126.95.1
Aggregator (writes to disk)10.1.30.131

The device sends raw syslog to the relay over UDP 514. The relay forwards to the aggregator over TCP 514. The aggregator writes to disk. The critical issue is what happens to the host identity of each message as it moves through these hops.

The core problem: When the relay opens a TCP connection to the aggregator, the aggregator sees the relay’s IP as the source of that connection. Without explicit configuration, syslog-ng uses the TCP connection’s source IP as $HOST — discarding whatever host identity was embedded in the message payload.


How syslog-ng Determines $HOST

Understanding the problem requires understanding how syslog-ng resolves the $HOST macro. There is a hierarchy, and it matters enormously in relay topologies.

1. The syslog message header A properly formed RFC 3164 message includes a hostname field: <PRI>TIMESTAMP HOSTNAME MESSAGE. If the receiving source parses this correctly and keep-hostname(yes) is set, this becomes $HOST.

2. The TCP/UDP source IP If parsing fails, or if keep-hostname is off (the default), syslog-ng falls back to using the IP address of whatever machine made the connection. In a relay scenario, this is always the relay’s IP.

3. DNS reverse lookup If use-dns(yes) is configured, syslog-ng may attempt a reverse lookup on the source IP. In most hardened environments this is disabled.

The trap in a relay topology is that the aggregator never sees the original device’s TCP connection — it only sees the relay’s. So even if the original device sent a perfectly formed RFC 3164 message with 192.168.1.1 in the hostname field, that information is embedded in the payload the relay forwards, not in the TCP connection metadata the aggregator uses to set $HOST.


The Forwarding Server: Receiving and Rewriting

The relay’s job is to receive raw syslog from the device and forward it onward. Two critical decisions must be made on the relay side.

Receive with flags(no-parse)

Embedded devices and consumer routers frequently send non-standard syslog. Their messages often look like this on the wire:

<134>localhost 2026 May 14 19:01:13 lua: [GUI.6][SECURITY] User login to web success from 192.168.1.162

The hostname field says localhost — not the router’s actual IP. This is common with devices that are not configured to include their own hostname in outgoing syslog. On the relay, receive these with flags(no-parse) to prevent syslog-ng from mangling the message during ingestion:

source s_network {
    udp(ip(0.0.0.0) port(514) flags(no-parse));
    tcp(ip(0.0.0.0) port(514) max-connections(100) flags(no-parse));
};

Stamp the correct host before forwarding

The relay knows the true source IP of every incoming message via ${SOURCEIP} — populated from the UDP/TCP packet source at receive time, before any parsing. Use this to set $HOST on the relay so it travels with the message to the aggregator:

rewrite r_set_original_host {
    set("${SOURCEIP}", value("HOST"));
};

# Optionally replace the device's "localhost" in the message body with a meaningful name
rewrite r_fix_device_hostname {
    subst("^localhost ", "verrtr01 ", value("MESSAGE"));
};

Forward with a clean RFC 3164 template

The forwarding destination template determines what the aggregator receives on the wire. It must produce a valid RFC 3164 syslog line with $HOST embedded in the correct position so the aggregator can extract it:

destination d_aggregator {
    tcp("10.1.30.131" port(514)
        template("<${PRI}>${HOST} ${MESSAGE}\n")
        disk-buffer(
            mem-buf-length(10000)
            disk-buf-size(1G)
        )
    );
};

This produces wire traffic that looks like:

<134>192.168.1.1 2026 May 14 21:06:11 lua: [GUI.6][SECURITY] User login to web success from 192.168.1.162

The aggregator will receive this and — with the right configuration — parse 192.168.1.1 as $HOST.

Apply rewrites in the log path

log {
    source(s_network);
    filter(f_router);          # netmask("192.168.1.1/32")
    rewrite(r_set_original_host);
    rewrite(r_fix_device_hostname);
    destination(d_aggregator);
};

Rewrite order matters: Always set $HOST from ${SOURCEIP} before any rewrite that touches the message body. Once the message is forwarded, ${SOURCEIP} on the aggregator refers to the relay, not the original device.


The Aggregator: Parsing, Not Trusting

The aggregator receives forwarded messages and writes them to disk. Because syslog-ng by default uses the TCP connection’s source IP as $HOST, and because the TCP connection comes from the relay, every message arrives attributed to 100.126.95.1 regardless of what is embedded in the payload.

The keep-hostname trap

The conventional advice is to set keep-hostname(yes) — either globally or on the source — which tells syslog-ng to trust the hostname field in the incoming message rather than overwriting it with the connection source. This works when the incoming message is a properly parsed syslog message. It does not work when the message arrives as raw unparsed data, because syslog-ng has nothing to extract the hostname from yet.

The symptom is instructive: even with keep-hostname(yes) set, $HOST remains the relay’s IP, and the embedded 192.168.1.1 either disappears entirely or ends up as part of $MESSAGE.

keep-hostname(yes) tells syslog-ng to trust the host field it has already parsed — it does not force syslog-ng to parse a host field it would otherwise skip.

Receive raw, then parse explicitly

The reliable solution is to receive with flags(no-parse) on the aggregator source — preserving the full raw message including the embedded 192.168.1.1 — and then use a regexp parser to extract the host field explicitly:

source s_aggregation {
    udp(ip(0.0.0.0) port(514) flags(no-parse));
    tcp(ip(0.0.0.0) port(514) flags(no-parse));
};

parser p_extract_host {
    regexp-parser(
        patterns("^<(?<PRI>[0-9]+)>(?<HOST>[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+) (?<MESSAGE>.*)$")
    );
};

This regexp matches the wire format <134>192.168.1.1 2026 May 14... and extracts three named groups into $PRI, $HOST, and $MESSAGE respectively. After the parser runs, $HOST is 192.168.1.1 regardless of what TCP connection delivered the message.

Wire up the log path on the aggregator

filter f_router { match("192.168.1.1" value("HOST")); };

destination d_router {
    file("/var/log/data/192.168.1.1/$YEAR-$MONTH-$DAY-fw.log"
        create_dirs(yes)
        template("${ISODATE} 192.168.1.1 ${MESSAGE}\n")
    );
};

log {
    source(s_aggregation);
    parser(p_extract_host);
    filter(f_router);
    destination(d_router);
    flags(final);     # stop processing after match
};

The flags(final) directive is important: it prevents a matched message from continuing to be evaluated by other log paths. Without it, events can be written to multiple destinations or matched by catch-all paths.


Where Rewrites Should Live

A common point of confusion is deciding whether to rewrite on the relay or on the aggregator. The answer depends on what information is available at each stage.

OperationWhereWhy
Set $HOST from source IPRelay${SOURCEIP} on the relay is the original device. On the aggregator it is the relay.
Rewrite device hostname in message bodyRelayNormalizing early keeps downstream config simple.
Parse $HOST from forwarded messageAggregatorThe aggregator cannot know which TCP connection carries which device without parsing.
Route to per-device log filesAggregatorRouting requires $HOST to be correctly set, which happens after the parser runs.
Apply flags(final) to log pathsAggregatorPrevents events from landing in multiple destinations.

Diagnosing Host Identity Problems

When something goes wrong in a relay pipeline, the first instinct is to check config files. A more reliable approach is to inspect what is actually on the wire and what syslog-ng actually parsed.

Check the wire first

Before touching any configuration, capture what the relay is actually sending to the aggregator:

tcpdump -i any -A port 514 | grep -v "^E\|^\.\|Flags\|seq\|ack"

This tells you definitively what format the aggregator is receiving. If 192.168.1.1 appears in the raw packet, the problem is on the aggregator side. If it does not appear at all, the problem is on the relay side — the rewrite or template is not working as expected.

Build a debug catch-all destination

Add a temporary destination that writes every incoming message with all relevant macros expanded. Place it after your other log paths but without a filter, so nothing escapes it:

destination d_debug {
    file("/var/log/data/debug-all.log"
        create_dirs(yes)
        template("HOST=${HOST} SOURCEIP=${SOURCEIP} PRI=${PRI} MSG=${MESSAGE}\n")
    );
};

log {
    source(s_aggregation);
    parser(p_extract_host);    # include parser if you want post-parse values
    destination(d_debug);
};

The output immediately tells you whether $HOST is being set correctly. Two possible outcomes:

Parser not working:

HOST=100.126.95.1 SOURCEIP=100.126.95.1 PRI=13 MSG=<134>192.168.1.1 2026 May 15...

The 192.168.1.1 is sitting inside $MSG still wrapped in the PRI tag. The regexp pattern does not match.

Parser working:

HOST=192.168.1.1 SOURCEIP=100.126.95.1 PRI=134 MSG=2026 May 15 11:20:53 lua:...

$HOST is correctly set and 192.168.1.1 has been removed from $MSG. If the destination directory still does not exist, the problem is a filter mismatch — check for typos.

Remove debug destinations when done. A catch-all with no filter writes every message from every device to a single file. On a busy aggregator this can fill disk quickly.

Validate your regexp independently

Before blaming syslog-ng, confirm the regexp matches your exact message format using standard shell tools:

echo '<134>192.168.1.1 2026 May 14 21:06:11 lua: [GUI.6] User login' \
  | grep -oP '^<(?P<PRI>[0-9]+)>(?P<HOST>[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+) (?P<MESSAGE>.*)$'

If this produces no output, the pattern does not match the message. Adjust the pattern against the actual wire capture, not against what you think the message should look like.

Always validate syntax before reloading

syslog-ng --syntax-only
systemctl reload syslog-ng

syslog-ng will silently continue running the old configuration if the new one fails to parse. A reload with a broken config is a silent no-op. Always run --syntax-only first.


Common Pitfalls

Typos in filter expressions A filter like match("192.198.1.1" value("HOST")) will silently never match if the actual host is 192.168.1.1. The debug catch-all makes this obvious immediately — you will see HOST=192.168.1.1 in the debug output but no entries in the expected destination directory. Always double-check IP addresses in filter expressions character by character.

Log path ordering syslog-ng processes log paths in the order they appear in the configuration file. A catch-all log path with no filter that appears before a specific log path will capture messages before the specific path gets a chance to apply its parser. Always put the debug catch-all last, and place specific log paths before general ones.

Confusing $HOST and $SOURCEIP These are different things. $SOURCEIP is always the IP of the machine that made the TCP/UDP connection to syslog-ng. $HOST is whatever syslog-ng resolved as the host identity — either from message parsing or from $SOURCEIP as a fallback. In relay scenarios they diverge, which is the entire problem being solved here.

Assuming keep-hostname works without parsing As covered above, keep-hostname(yes) only preserves a hostname that was already parsed from a syslog message header. If the source uses flags(no-parse), there is no parsed hostname to keep — the raw message is stored in $MESSAGE and syslog-ng falls back to the connection source IP for $HOST.


The Complete Configuration at a Glance

Relay (100.126.95.1)

options {
    keep_hostname(no);
    chain_hostnames(no);
    flush_lines(1);
    use_dns(no);
    use_fqdn(no);
};

source s_network {
    udp(ip(0.0.0.0) port(514) flags(no-parse));
    tcp(ip(0.0.0.0) port(514) max-connections(100) flags(no-parse));
};

rewrite r_set_original_host {
    set("${SOURCEIP}", value("HOST"));
};

rewrite r_fix_device_hostname {
    subst("^localhost ", "verrtr01 ", value("MESSAGE"));
};

filter f_router { netmask("192.168.1.1/32"); };

destination d_aggregator {
    tcp("10.1.30.131" port(514)
        template("<${PRI}>${HOST} ${MESSAGE}\n")
        disk-buffer(
            mem-buf-length(10000)
            disk-buf-size(1G)
        )
    );
};

log {
    source(s_network);
    filter(f_router);
    rewrite(r_set_original_host);
    rewrite(r_fix_device_hostname);
    destination(d_aggregator);
};

Aggregator (10.1.30.131)

options {
    chain_hostnames(off);
    flush_lines(0);
    use_dns(no);
    use_fqdn(no);
    dns_cache(no);
    keep_hostname(yes);
};

source s_aggregation {
    udp(ip(0.0.0.0) port(514) flags(no-parse));
    tcp(ip(0.0.0.0) port(514) flags(no-parse));
};

parser p_extract_host {
    regexp-parser(
        patterns("^<(?<PRI>[0-9]+)>(?<HOST>[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+) (?<MESSAGE>.*)$")
    );
};

filter f_router { match("192.168.1.1" value("HOST")); };

destination d_router {
    file("/var/log/data/192.168.1.1/$YEAR-$MONTH-$DAY-fw.log"
        create_dirs(yes)
        template("${ISODATE} 192.168.1.1 ${MESSAGE}\n")
    );
};

log {
    source(s_aggregation);
    parser(p_extract_host);
    filter(f_router);
    destination(d_router);
    flags(final);
};

Summary

Getting syslog from a device on one network written to disk on another server with correct host attribution requires deliberate configuration at every stage.

The relay must:

  • Capture the true source identity via ${SOURCEIP} before it is lost
  • Stamp it into $HOST with a rewrite
  • Forward with a template that embeds $HOST in RFC 3164 position

The aggregator must:

  • Receive with flags(no-parse) to avoid premature parsing that discards the embedded host
  • Run an explicit regexp-parser to extract the host from the raw message payload
  • Filter on value("HOST") after the parser has run
  • Use flags(final) on matched log paths

None of this is obvious from the documentation, and each failure mode looks different on the wire and in the logs. The tools that matter most are tcpdump on the aggregator, a debug catch-all destination, the habit of always running syslog-ng --syntax-only before reloading, and careful proofreading of every IP address in every filter expression.