{"id":601,"date":"2025-11-15T17:15:10","date_gmt":"2025-11-16T01:15:10","guid":{"rendered":"https:\/\/nramkumar.org\/tech\/?p=601"},"modified":"2025-11-15T17:17:06","modified_gmt":"2025-11-16T01:17:06","slug":"descending-into-rsyslog-madness","status":"publish","type":"post","link":"https:\/\/nramkumar.org\/tech\/blog\/2025\/11\/15\/descending-into-rsyslog-madness\/","title":{"rendered":"Descending into rsyslog madness"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">I had setup rsyslog and alloy + loki to consolidate logs from all my servers in my home network for analysis, graphing and alerting. My goal was to:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Store all logs in a central log storage in files &#8211; categorized by host (and ideally by process\/system unit)<\/li>\n\n\n\n<li>Push these logs into loki via alloy with structured fields at least for hostname and appname<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">After some struggle, I was able to set this up with a combination of Dynafile and RSYSLOG_SyslogProtocol23Format in the loki forward format. It mostly worked &#8211; the keyword is mostly. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Some of the issues I wanted to fix:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Getting the hostname or appname was actually prone to error because Unifi sends logs that are strange at times (The UX sends a log like UX UCG-Ultra &lt;log line&gt; and then the appname is UCG-Ultra)<\/li>\n\n\n\n<li>I wanted to also have queuing + dropping of logs with rate limiting setup<\/li>\n\n\n\n<li>I wanted to forward logs from the log server itself to the same setup<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Let&#8217;s begin the madness. We start first with a highly embarrassing mistake from me &#8211; I simply added to \/etc\/rsyslog.conf:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>*.* @@logserver:514<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And all hell broke loose. Guess what I just did &#8211; I setup an infinite loop of log lines. Since remote files send logs through the same interface and I just setup a forward rule for all log lines, we started an insane amount of writes by sending the same logs over and over again in a never-ending, ever-growing cascade of log lines.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ok &#8211; obviously that was not very smart. Let&#8217;s make sure we create a ruleset that processes remote log lines and then add the forwarding rule which will then only forward local log lines.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ruleset(\n  name=\"RS_Remote\"\n  queue.type=\"LinkedList\"       # In-memory queue\n  queue.size=\"100000\"            # Size appropriate for this action\n  queue.dequeueBatchSize=\"1024\"  # Fine-tune batching for IOPS\n  queue.minDequeueBatchSize=\"500\"        # minimum msgs before dequeue\n) {\n    if $programname == \"\" then {\n      action(\n        type=\"omfile\"\n        dynaFile=\"RemoteLogsWithoutProgram\"\n      )   \n    } else {\n      action(\n        type=\"omfile\" \n        dynaFile=\"RemoteLogs\"\n      )   \n    }   \n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Ok &#8211; that stopped the endless log spew, but it completely broke the working dynaFile setup (and loki appname) where it was working most of the time. Suddenly, <code>$programname<\/code> is always empty once I made this change. For reference, here&#8217;s the dynaFile setup that worked prior to making this a ruleset based processing:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>template(name=\"RemoteLogs\" type=\"string\" string=\"\/storage\/logs\/remote\/%HOSTNAME%\/%PROGRAMNAME%.log\")\ntemplate(name=\"RemoteLogsWithoutProgram\" type=\"string\" string=\"\/storage\/logs\/remote\/%HOSTNAME%\/default.log\")\n\nif $programname == \"\" then {\n  action(type=\"omfile\" dynaFile=\"RemoteLogsWithoutProgram\")\n} else {\n  action(type=\"omfile\" dynaFile=\"RemoteLogs\")\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note that nothing really changed other than moving the if\/else into a ruleset and attaching that ruleset to my imudp and imtcp inputs. And now, our steep descent into the madness that is rsyslog begins for real.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">After several abortive hours of search + AI fu (AI assistants are absolutely horrible with anything complex related to rsyslog) and just blindly trying to get things working without putting in any effort to actually understand rsyslog, I gave up that approach. Clearly, rsyslog, like ed (the king of editors), was meant for people who knew what they were doing and had the attention span longer than a 6 month old kitten.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">After spending more time than I care to admit reading the simultaneously sufficient yet difficult to navigate + read + understand documentation of rsyslog, here&#8217;s my glorious solution. This is probably the most complex example of a rsyslog configuration on the web that you can find &#8211; I am pretty sure there are more complex setups being run, but no one probably wants to admit to it in public \ud83d\ude42<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># \/etc\/rsyslog.conf configuration file for rsyslog\n#\n# For more information install rsyslog-doc and see\n# \/usr\/share\/doc\/rsyslog-doc\/html\/configuration\/index.html\n\n\n#################\n#### MODULES ####\n#################\n\n#module(load=\"imuxsock\") # provides support for local system logging\n#module(load=\"imklog\")   # provides kernel logging support\n#module(load=\"immark\")  # provides --MARK-- message capability\n\n$ActionFileDefaultTemplate RSYSLOG_TraditionalFileFormat\n\n\nruleset(name=\"PROGRAMNAME_FIXER\") {\n\n    set $.programname = \"\";\n    set $.procid = \"\";\n\n    if $programname == \"\" then {\n        if re_match($syslogtag, '^&#91;^&#91;]+\\\\&#91;&#91;0-9]+\\\\]') then {            \n            set $.programname = re_extract($syslogtag, '^(&#91;^&#91;]+)', 0, 1, \"\");\n            set $.procid = re_extract($syslogtag, '\\\\&#91;(&#91;0-9]+)\\\\]', 0, 1, \"\");\n        }\n        else if re_match($syslogtag, '^\\\\&#91;.*\\\\]$' ) then {\n            set $.programname = re_extract($syslogtag, '^\\\\&#91;(.*)\\\\]$', 0, 1, \"\");\n        } else {\n            set $.syslogtag = field($msg, 32, 1);\n            if $.syslogtag == \"\" then {\n                set $.syslogtag = field($msg, 32, 2);\n            }\n            if re_match($.syslogtag, '^&#91;^&#91;]+\\\\&#91;&#91;0-9]+\\\\]') then {\n                set $.programname = re_extract($.syslogtag, '^(&#91;^&#91;]+)', 0, 1, \"\");\n                set $.procid = re_extract($.syslogtag, '\\\\&#91;(&#91;0-9]+)\\\\]', 0, 1, \"\");\n            }\n            else if re_match($syslogtag, ':') and re_match($syslogtag, '^&#91;^,+]+$') then {\n                set $.programname = re_extract($syslogtag, '(&#91;^:]+)', 0, 1, \"\");\n            }\n            else if re_match($.syslogtag, '&#91;a-zA-Z]&#91;a-zA-Z_0-9-]+:') then {\n                set $.programname = re_extract($.syslogtag, '(&#91;a-zA-Z_0-9-]+)', 0, 1, \"\");\n            }\n       }\n    }\n\n    if $.programname == \"\" then {\n      set $.programname = \"-\";\n    }\n    if $.procid == \"\" then {\n      set $.procid = \"-\";\n    }\n\n    set $!custom!programname = $.programname;\n    set $!custom!procid = $.procid;\n}\n\ntemplate(name=\"DBG\" type=\"list\") {\n  constant(value=\"' syslogtag: '\") property(name=\"syslogtag\")\n  constant(value=\"' custom-program-name: '\") property(name=\"$!custom!programname\")\n  constant(value=\"' custom-proc-id: '\") property(name=\"$!custom!procid\")\n  constant(value=\"' msg: '\") property(name=\"msg\" controlCharacters=\"escape\")\n  constant(value=\"' rawmsg: '\") property(name=\"rawmsg\" controlCharacters=\"escape\")\n  constant(value=\"DBG: FROMHOST: '\") property(name=\"fromhost\")\n  constant(value=\"' inputname: '\") property(name=\"inputname\")\n  constant(value=\"' time reported: '\") property(name=\"timereported\" dateFormat=\"rfc3339\")\n  constant(value=\"' hostname: '\") property(name=\"hostname\")\n  constant(value=\"'\\n\")\n}\n\ntemplate(name=\"RemoteLogs\" type=\"string\" string=\"\/storage\/logs\/remote\/%HOSTNAME%\/%PROGRAMNAME%.log\")\ntemplate(name=\"RemoteLogsWithoutProgram\" type=\"string\" string=\"\/storage\/logs\/remote\/%HOSTNAME%\/default.log\")\n\ntemplate(name=\"RFC5424_CustomRobustList\" type=\"list\") {\n    # 1. PRI and VERSION\n    constant(value=\"&lt;\")\n    property(name=\"pri\")\n    constant(value=\"&gt;1 \")\n\n    # 2. TIMESTAMP\n    property(name=\"timereported\" dateFormat=\"rfc3339\")\n    constant(value=\" \")\n\n    # 3. HOSTNAME\n    property(name=\"hostname\")\n    constant(value=\" \")\n\n    # 4. APP-NAME (Using custom property, outputs '-' if empty)\n    property(name=\"$!custom!programname\" controlCharacters=\"space\")\n    constant(value=\" \")\n\n    # 5. PROCID (Using custom property, outputs '-' if empty)\n    property(name=\"$!custom!procid\" controlCharacters=\"space\")\n    constant(value=\" \")\n\n    # 6. MSGID (Outputs '-' if empty)\n    property(name=\"msgid\" controlCharacters=\"space\")\n    constant(value=\" \")\n\n    # 7. STRUCTURED-DATA (Outputs '-' if empty)\n    constant(value=\"-\")\n    #property(name=\"structureddata\")\n\n    # 8. MESSAGE\n    constant(value=\" \")\n    property(name=\"msg\")\n    constant(value=\"\\n\")\n}\n\nruleset(\n  name=\"RS_Remote\"\n  queue.type=\"LinkedList\"       # In-memory queue\n  queue.size=\"100000\"            # Size appropriate for this action\n  queue.dequeueBatchSize=\"1024\"  # Fine-tune batching for IOPS\n  queue.minDequeueBatchSize=\"500\"        # minimum msgs before dequeue\n) {\n    call PROGRAMNAME_FIXER\n\n    if $programname == \"\" then {\n      action(\n        type=\"omfile\"\n        dynaFile=\"RemoteLogsWithoutProgram\"\n      )\n    } else {\n      action(\n        type=\"omfile\" \n        dynaFile=\"RemoteLogs\"\n      )\n    }\n\n    if $!custom!programname == \"-\" then {\n      action(type=\"omfile\" file=\"\/storage\/logs\/remote\/log\/rsyslog-debug.log\" template=\"DBG\")\n    }\n\n    action(\n      type=\"omfwd\"\n      target=\"loki.lan\"\n      port=\"1514\"\n      protocol=\"tcp\"\n      template=\"RFC5424_CustomRobustList\"\n    )\n}\n\n## provides UDP syslog reception\nmodule(load=\"imudp\")\ninput(\n    type=\"imudp\" \n    port=\"514\" \n    address=\"&lt;ip&gt;\" \n    rateLimit.Interval=\"600\" \n    rateLimit.Burst=\"1000\" \n    ruleSet=\"RS_Remote\"\n)\n\n# provides TCP syslog reception\nmodule(load=\"imtcp\")\ninput(\n    type=\"imtcp\" \n    port=\"514\" \n    address=\"&lt;ip&gt;\" \n    rateLimit.Interval=\"600\" \n    rateLimit.Burst=\"10000\" \n    ruleSet=\"RS_Remote\"\n)\n\n\n###########################\n#### GLOBAL DIRECTIVES ####\n###########################\n\n#\n# Set the default permissions for all log files.\n#\n$FileOwner syslog\n$FileGroup adm\n$FileCreateMode 0640\n$DirCreateMode 0755\n$Umask 0022\n\n#\n# Where to place spool and state files\n#\n$WorkDirectory \/storage\/logs\/spool\/rsyslog\n\n# Main queue: in-RAM linked list with large batched dequeues\nmain_queue(\n  queue.type=\"LinkedList\"\n  queue.size=\"200000\"                    # total msgs in RAM (tune to RAM)\n  queue.highWatermark=\"150000\"\n  queue.lowWatermark=\"50000\"\n\n  # Batch processing to reduce small writes; trades a bit of latency for IOPS\n  queue.dequeueBatchSize=\"1000\"          # max msgs per dequeue\n  queue.minDequeueBatchSize=\"500\"        # minimum msgs before dequeue\n)\n\n# Use traditional timestamp format\n\n# Drop to lower privilege\n$PrivDropToUser syslog\n$PrivDropToGroup syslog\n\n###############\n#### RULES ####\n###############\n\n#\n# Log anything besides private authentication messages to a single log file\n#\n#*.*;auth,authpriv.none         -\/storage\/logs\/syslog\n\n#\n# Log commonly used facilities to their own log file\n#\n#auth,authpriv.*                        \/storage\/logs\/auth.log\n#cron.*                         -\/storage\/logs\/cron.log\n#kern.*                         -\/storage\/logs\/kern.log\n#mail.*                         -\/storage\/logs\/mail.log\n#user.*                         -\/storage\/logs\/user.log\n\n#\n# Emergencies are sent to everybody logged in.\n#\n#*.emerg                                :omusrmsg:*\n\n#*.* -?RemoteLogs\n\n\n# BEGIN ANSIBLE MANAGED BLOCK: remote log forwarding\n*.* @@&lt;ip&gt;:514\n# END ANSIBLE MANAGED BLOCK: remote log forwarding<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note that in this config, while Loki gets structured appname, I haven&#8217;t segmented the stored log files via omfwd yet. I am still debating whether I want to do this or not &#8211; it should be fairly straightforward since I do have the <code>$!custom!programname<\/code> set up already.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A lot of the insanity is in the <code>PROGRAMNAME_FIXER<\/code> ruleset &#8211; this ruleset uses a bunch of regex munging to handle all the different types of log spew (Unifi in particular spews so many interesting varieties of logs) so we can extract a meaningful <code>appname<\/code> (and possibly <code>procid<\/code>) for loki.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Some important and hopefully useful tips to work with RSyslog Configuration, Alloy and Loki:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Loki syslog receiver that you configure in Alloy by default accepts RFC5424 format input.<\/li>\n\n\n\n<li>RFC5424 expects the following fields:\n<ul class=\"wp-block-list\">\n<li><code>PRI - Priority<\/code><\/li>\n\n\n\n<li><code>VER - Version<\/code><\/li>\n\n\n\n<li><code>space<\/code><\/li>\n\n\n\n<li><code>TIMESTAMP - RFC3339 format<\/code><\/li>\n\n\n\n<li><code>space<\/code><\/li>\n\n\n\n<li><code>hostname<\/code><\/li>\n\n\n\n<li><code>space<\/code><\/li>\n\n\n\n<li><code>appname<\/code><\/li>\n\n\n\n<li><code>space<\/code><\/li>\n\n\n\n<li><code>procid<\/code><\/li>\n\n\n\n<li><code>space<\/code><\/li>\n\n\n\n<li><code>msgid<\/code><\/li>\n\n\n\n<li><code>space<\/code><\/li>\n\n\n\n<li><code>structured data<\/code><\/li>\n\n\n\n<li><code>space<\/code><\/li>\n\n\n\n<li><code>msg<\/code><\/li>\n<\/ul>\n<\/li>\n\n\n\n<li>If any field is empty, it should be represented by <code>'-'<\/code><\/li>\n\n\n\n<li>In RSyslog config file language (aka RainerScript)\n<ul class=\"wp-block-list\">\n<li><code>$!<\/code> variables are &#8220;global&#8221; message level variables. They are available once set elsewhere later in the message processing pipeline.<\/li>\n\n\n\n<li><code>$.<\/code> variables are &#8220;local&#8221; variables. They are only available in the context of that ruleset or processing entity in the message processing pipeline.<\/li>\n\n\n\n<li>Templates are how you control output. You can only use message level properties or constants in templates.<\/li>\n\n\n\n<li>Rulesets are how you can attach a processing pipe to an input or process a message further. Rulesets can call each other. A ruleset can have multiple actions. Actions can be conditional.<\/li>\n\n\n\n<li>You can do printf debugging by using a combination of a debug template, setting arbitrary message variables which are used by the debug template and then an output omfile action with the debug template. Now this file will spew out your glorious printf debugging for RainerScript.<\/li>\n\n\n\n<li>The Regex dialect used in RainerScript is <a href=\"https:\/\/en.wikibooks.org\/wiki\/Regular_Expressions\/POSIX-Extended_Regular_Expressions\">Posix Extended Regular Expressions<\/a>.<\/li>\n\n\n\n<li><code>set<\/code> statements need a <code>;<\/code> at the end. I can&#8217;t remember how many times I kept forgetting that!<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, let me re-emphasize: Every single AI assistant was beyond useless in this exercise and made it a lot more frustrating that it needed to be. They did not understand any single important information listed in the bullets above about RainerScript and kept driving me insane with their plausible but completely incorrect guidance. Hopefully, now that this blog post is out, the agents will learn and become better at RainerScript?<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I had setup rsyslog and alloy + loki to consolidate logs from all my servers in my home network for analysis, graphing and alerting. My goal was to: After some struggle, I was able to set this up with a combination of Dynafile and RSYSLOG_SyslogProtocol23Format in the loki forward format. It mostly worked &#8211; the&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-601","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts\/601","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/comments?post=601"}],"version-history":[{"count":2,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts\/601\/revisions"}],"predecessor-version":[{"id":603,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts\/601\/revisions\/603"}],"wp:attachment":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/media?parent=601"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/categories?post=601"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/tags?post=601"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}