POSTFWD3(1)

๐Ÿ“ฆ version 2.03, in alpine 3.24, arch rolling

postfwd3 โ€” postfix firewall daemon ๐Ÿ’–

๐ŸŒธ NAME ๐ŸŒธ

postfwd3 โ€” postfix firewall daemon. That's it. That's the whole personality. ๐Ÿ’…

โœจ SYNOPSIS โœจ

Okay bestie, here's how you summon the daemon:

postfwd3 [OPTIONS] [COMMAND]

postfwd3 [OPTIONS] --cmd [SOURCE1, SOURCE2, ...]

๐ŸŽ€ Ruleset: (at least one, multiple use is allowed):

  • -f, --file <file> โ€” reads rules from <file>
  • -r, --rule <rule> โ€” adds <rule> to config
  • -s, --scores <v>=<r> โ€” returns <r> when score exceeds <v>

๐ŸŽ€ Settings: (multiple use allowed)

  • -F, --loadsettings <file> โ€” loads program settings from <file>
  • --savesettings <file> โ€” saves program settings to <file>
  • --showsettings โ€” exports program settings to stdout

๐ŸŽ€ Server:

  • -i, --interface <dev> โ€” listen on interface <dev>
  • -p, --port <port> โ€” listen on port <port>
  • --proto <proto> โ€” socket type (tcp or unix)
  • --server_socket <sock> โ€” e.g. tcp:127.0.0.1:10045
  • -u, --user <name> โ€” set uid to user <name>
  • -g, --group <name> โ€” set gid to group <name>
  • --umask <mask> โ€” umask for master filepermissions
  • --server_umask <mask> โ€” umask for server filepermissions
  • --pidfile <path> โ€” create pidfile under <path>
  • --min_servers <i> โ€” spawn at least <i> children
  • --max_servers <i> โ€” do not spawn more than <i> children
  • --min_spare_servers <i> โ€” minimum idle children
  • --max_spare_servers <i> โ€” maximum idle children

๐ŸŽ€ Cache:

  • -c, --cache <int> โ€” sets the request-cache timeout to <int> seconds
  • --cleanup-requests <int> โ€” cleanup interval in seconds for request cache
  • --cache_interface <dev> โ€” listen on interface <dev>
  • --cache_port <port> โ€” listen on port <port>
  • --cache_proto <proto> โ€” socket type (tcp or unix)
  • --cache_socket <sock> โ€” e.g. tcp:127.0.0.1:10043
  • --cache_umask <mask> โ€” umask for cache filepermissions
  • --cacheid <list> โ€” list of request items for cache-id
  • --cacheid_md5 โ€” cacheid => md5sum(request)
  • --cache-rdomain-only โ€” skip recipient localpart for cache-id
  • --cache-no-sender โ€” skip sender address for cache-id
  • --cache-no-size โ€” skip size for cache-id
  • --no_parent_request_cache โ€” disable parent request cache
  • --no_parent_rate_cache โ€” disable parent rate cache
  • --no_parent_dns_cache โ€” disable parent dns cache (default)
  • --no_parent_group_cache โ€” disable parent group cache
  • --no_parent_cache โ€” disable all parent caches

๐ŸŽ€ Groups:

  • --default_group_ttl <i> โ€” default group TTL
  • --group_maxitems <i> โ€” max items per group
  • --cleanup-groups <int> โ€” cleanup interval in seconds for group objects

๐ŸŽ€ Rates:

  • --cleanup-rates <int> โ€” cleanup interval in seconds for rate cache

๐ŸŽ€ Control:

  • -k, --kill, --stop โ€” terminate postfwd3
  • --reload, --hup โ€” reload postfwd3
  • --watchdog <w> โ€” watchdog timer in seconds
  • --respawn <r> โ€” respawn delay in seconds
  • --failures <f> โ€” max respawn failure counter
  • -d, --daemon โ€” execute program in background
  • --nodaemon โ€” execute program in foreground
  • --daemons <list> โ€” list of daemons to start
  • --personality <type> โ€” type of policy server, allows 'PreFork' or 'Multiplex'
  • --autopersonality โ€” determine personality by program name (see manpage)
  • --noautopersonality โ€” don't do it (see above :)
  • --v1 โ€” set personality to 'Multiplex'
  • --v2 โ€” set personality to 'PreFork'
  • --dumpcache โ€” show cache contents
  • --dumpstats โ€” show statistics
  • -R, --chroot <path> โ€” chroot to <path> before start
  • --delcache <item> โ€” removes an item from the request cache
  • --delrate <item> โ€” removes an item from the rate cache

๐ŸŽ€ DNS:

  • -n, --nodns โ€” skip any dns based test
  • --dns_timeout <i> โ€” dns query timeout in seconds
  • --dns_timeout_max <i> โ€” disable dnsbl after <i> timeouts
  • --dns_timeout_interval <i> โ€” reenable dnsbl after <i> seconds
  • --cache-rbl-timeout <i> โ€” default dns ttl if not specified in ruleset
  • --cache-rbl-default <s> โ€” default dns pattern if not specified in ruleset
  • --cleanup-rbls <i> โ€” cleanup old dns cache items every <i> seconds
  • --dns_async_txt โ€” perform dnsbl A and TXT lookups simultaneously
  • --dns_max_ns_lookups โ€” max names to look up with sender_ns_addrs
  • --dns_max_mx_lookups โ€” max names to look up with sender_mx_addrs
  • --ipv6_dnsbl โ€” enables dnsbl checks for IPv6 addresses

๐ŸŽ€ Optional:

  • -t, --test โ€” testing, always returns "dunno"
  • -S, --summary <i> โ€” show stats every <i> seconds
  • --noidlestats โ€” disables statistics when idle
  • --norulestats โ€” disables per rule statistics
  • -I, --instantcfg โ€” reloads ruleset on every new request
  • --config_timeout <i> โ€” parser timeout in seconds
  • --keep_groups โ€” do not clear group cache on reload
  • --save_groups <file> โ€” save and load group cache on disk
  • --keep_rates โ€” do not clear rate limit counters on reload
  • --save_rates <file> โ€” save and load rate limits on disk
  • -A, --aggregate_addrs โ€” pre-compute ip address lists to subnets
  • --no_netaddr โ€” don't use NetAddr::IP functions
  • --no_netcidr โ€” don't use Net::CIDR::Lite functions
  • --cidr_method=s โ€” use method <s> for network checks

๐ŸŽ€ Plugins:

  • --plugins <file> โ€” loads postfwd plugins from file

๐ŸŽ€ Logging:

  • -l, --logname <label> โ€” label for syslog messages
  • --facility <s> โ€” use syslog facility <s>
  • --socktype <s> โ€” use syslog socktype <s>
  • --nodnslog โ€” do not log dns results
  • --anydnslog โ€” log any dns (even cached) results
  • --norulelog โ€” do not log rule actions
  • --nolog|--perfmon โ€” no logging at all
  • -v, --verbose โ€” verbose logging, use twice to increase
  • --debug <s> โ€” list of debugging classes
  • --debugclasses โ€” shows all available debug classes and exits the program

๐ŸŽ€ Information (use only at command-line!):

  • -h, --help โ€” display this help and exit
  • -m, --manual โ€” shows program manual
  • -V, --version โ€” output version information and exit
  • -D, --defaults โ€” show postfwd3 settings and exit
  • -C, --showconfig โ€” show postfwd3 ruleset and exit (-v allowed)
  • -L, --stdout โ€” redirect syslog messages to stdout (--stdoutlog works for compatibility)
  • --stdin โ€” pull request from stdin instead of a network socket
  • --cmd โ€” shorthand form of --stdin, --stdout and --nodaemon
  • -q, --quiet โ€” no syslogging, no stdout (-P works for compatibility)

๐ŸŽ€ Commands:

  • start โ€” start the program [default]
  • stop โ€” same as --stop
  • reload โ€” same as --reload
  • dumpcache โ€” same as --dumpcache
  • dumpstats โ€” same as --dumpstats
  • defaults โ€” same as --defaults
  • showsettings โ€” same as --showsettings
  • delcache <item> โ€” same as --delcache <item>
  • delrate <item> โ€” same as --delrate <item>

๐ŸŽ€ Obsolete (only for compatibility with postfwd v1):

  • --shortlog, --dns_queuesize, --dns_retries

๐ŸŒˆ DESCRIPTION ๐ŸŒˆ

๐Ÿ’ซ INTRODUCTION ๐Ÿ’ซ

postfwd3 is written to combine complex postfix restrictions in a ruleset similar to those of the most firewalls. The program uses the postfix policy delegation protocol to control access to the mail system before a message has been accepted (please visit <http://www.postfix.org/SMTPD_POLICY_README.html> for more information).

postfwd3 allows you to choose an action (e.g. reject, dunno) for a combination of several smtp parameters (like sender and recipient address, size or the client's TLS fingerprint). Also it offers simple macros/acls which should allow straightforward and easy-to-read configurations.

Features:

  • Complex combinations of smtp parameters
  • Combined RBL/RHSBL lookups with arbitrary actions depending on results
  • Scoring system
  • Date/time based rules
  • Macros/ACLs, Dynamic groups, Negation
  • Compare request attributes (e.g. client_name and helo_name)
  • Internal caching for requests and dns lookups
  • Built in statistics for rule efficiency analysis

๐Ÿ’ซ CONFIGURATION ๐Ÿ’ซ

A configuration line consists of optional item=value pairs, separated by semicolons (;) and the appropriate desired action:

[ <item1>=<value>; <item2>=<value>; ... ] action=<result>

Example:

client_address=192.168.1.1 ; sender==no@bad.local ; action=REJECT

This will deny all mail from 192.168.1.1 with envelope sender no@bad.local. The order of the elements is not important. So the following would lead to the same result as the previous example:

action=REJECT ; client_address=192.168.1.1 ; sender==no@bad.local

The way how request items are compared to the ruleset can be influenced in the following way:

okay so before we go any further, here's the โœจvibe-check tableโœจ of comparison operators, because this is literally the grammar that runs the whole show:

               ====================================================================
                ITEM == VALUE                true if ITEM equals VALUE
                ITEM => VALUE                true if ITEM >= VALUE
                ITEM =< VALUE                true if ITEM <= VALUE
                ITEM >  VALUE                true if ITEM >  VALUE
                ITEM <  VALUE                true if ITEM <  VALUE
                ITEM =~ VALUE                true if ITEM ~= /^VALUE$/i
                ITEM != VALUE                false if ITEM equals VALUE
                ITEM !> VALUE                false if ITEM >= VALUE
                ITEM !< VALUE                false if ITEM <= VALUE
                ITEM !~ VALUE                false if ITEM ~= /^VALUE$/i
                ITEM =  VALUE                default behaviour (see ITEMS section)
               ====================================================================

So like, to actually find your own rules again in your log files (iykyk), bestie, you can slap a unique identifier on every single one of them:

id=R_001 ; action=REJECT ; client_address=192.168.1.1 ; sender==no@bad.local

You can totally use those identifiers as the target for the jump() command (the ACTIONS section below has the tea on that, don't worry). Leading or trailing whitespace characters? Ignored. And use '#' to comment your config. Others will appreciate it. Fr fr, they will. ๐Ÿซถ

A ruleset is just one or a whole bunch of rules, and you can load them from files or pass them as command line arguments. The COMMAND LINE section below spills all of that. ๐Ÿ’…

Okay so since postfwd version 1.30, rules spanning span multiple lines can be defined by prefixing the following lines with one or multiple whitespace characters (or '}' for macros):

id=RULE001
        client_address=192.168.1.0/24
        sender==no@bad.local
        action=REJECT no access

But postfwd versions prior to 1.30 require trailing ';' and '\'-characters, so if you're on the old stuff:

id=RULE001; \
        client_address=192.168.1.0/24; \
        sender==no@bad.local; \
        action=REJECT no access

โœจ ITEMS โœจ

Here's the roster, queen by queen:

  • id โ€” a unique rule id, which can be used for log analysis. ids also serve as targets for the "jump" command.
  • date, time โ€” a time or date range within the specified rule shall hit
# FORMAT:
# Feb, 29th
date=29.02.2008
# Dec, 24th - 26th
date=24.12.2008-26.12.2008
# from today until Nov, 23rd
date=-23.09.2008
# from April, 1st until today
date=01.04.2008-
  • days, months โ€” a range of weekdays (Sun-Sat) or months (Jan-Dec) within the specified rule shall hit.
  • score โ€” when the specified score is hit (peep the ACTIONS section) the specified action will be returned to postfix. Heads up: scores are set global until redefined! ๐Ÿ‘‘
  • request_score โ€” this value allows to access a request's score. it may be used as variable ($$request_score).
  • rbl, rhsbl, rhsbl_client, rhsbl_sender, rhsbl_reverse_client โ€” query the specified RBLs/RHSBLs, possible values are: <name>[/<reply>/<maxcache>, <name>/<reply>/<maxcache>] (defaults: reply=^127\.0\.0\.\d+$ maxcache=3600) the results of all rhsbl_* queries will be combined in rhsbl_count (see below).
  • rblcount, rhsblcount โ€” minimum RBL/RHSBL hitcounts to match. if not specified a single RBL/RHSBL hit will match the rbl/rhsbl items. you may specify 'all' to evaluate all items, and use it as variable in an action (see ACTIONS section) (default: 1)
  • sender_localpart, sender_domain โ€” the local-/domainpart of the sender address
  • recipient_localpart, recipient_domain โ€” the local-/domainpart of the recipient address
  • helo_address โ€” postfwd3 tries to look up the helo_name. use helo_address=!!(0.0.0.0/0) to check for unknown.
Please do not use this for positive access control (whitelisting), as it might be forged. ๐Ÿšจ
  • sender_ns_names, sender_ns_addrs โ€” postfwd3 tries to look up the names/ip addresses of the nameservers for the sender domain part.
Please do not use this for positive access control (whitelisting), as it might be forged. ๐Ÿšจ
  • sender_mx_names, sender_mx_addrs โ€” postfwd3 tries to look up the names/ip addresses of the mx records for the sender domain part.
Please do not use this for positive access control (whitelisting), as it might be forged. ๐Ÿšจ
  • version โ€” postfwd3 version, contains "postfwd3 n.nn" this enables version based checks in your rulesets (e.g. for migration). works with old versions too, because a non-existing item always returns false:
# version >= 1.10
id=R01; version~=1\.[1-9][0-9]; sender_domain==some.org \
      ; action=REJECT sorry no access
  • postfwd_port โ€” postfwd server port, allows to use the same ruleset different instances:
# rule only hits for instance at port 10045
id=PORT10045; postfwd_port==10045; action=DUNNO
  • postfwd_interface โ€” postfwd server initerface (yes bestie, the manpage really spells it that way, we don't make the rules ๐Ÿ˜ญ), see postfwd_port
  • ratecount โ€” only available for rate(), size() and rcpt() actions. contains the actual limit counter:
id=R01; action=rate(sender/200/600/REJECT limit of 200 exceeded [$$ratecount hits])
id=R02; action=rate(sender/100/600/WARN limit of 100 exceeded [$$ratecount hits])

Besides these you can specify any attribute of the postfix policy delegation protocol. Feel free to combine them the way you need it (have a look at the EXAMPLES section below, it eats ๐Ÿ’…).

Most values can be specified as regular expressions (PCRE). Please see the table below for details:

# ==========================================================
# ITEM=VALUE                            TYPE
# ==========================================================
id=something                            mask = string
date=01.04.2007-22.04.2007              mask = date (DD.MM.YYYY-DD.MM.YYYY)
time=08:30:00-17:00:00                  mask = time (HH:MM:SS-HH:MM:SS)
days=Mon-Wed                            mask = weekdays (Mon-Wed) or numeric (1-3)
months=Feb-Apr                          mask = months (Feb-Apr) or numeric (1-3)
score=5.0                               mask = maximum floating point value
rbl=zen.spamhaus.org                    mask = <name>/<reply>/<maxcache>[,...]
rblcount=2                              mask = numeric, will match if rbl hits >= 2
helo_address=<a.b.c.d/nn>               mask = CIDR[,CIDR,...]
sender_ns_names=some.domain.tld         mask = PCRE
sender_mx_names=some.domain.tld         mask = PCRE
sender_ns_addrs=<a.b.c.d/nn>            mask = CIDR[,CIDR,...]
sender_mx_addrs=<a.b.c.d/nn>            mask = CIDR[,CIDR,...]
# ------------------------------
# Postfix version 2.1 and later:
# ------------------------------
client_address=<a.b.c.d/nn>             mask = CIDR[,CIDR,...]
client_name=another.domain.tld          mask = PCRE
reverse_client_name=another.domain.tld  mask = PCRE
helo_name=some.domain.tld               mask = PCRE
sender=foo@bar.tld                      mask = PCRE
recipient=bar@foo.tld                   mask = PCRE
recipient_count=5                       mask = numeric, will match if recipients >= 5
# ------------------------------
# Postfix version 2.2 and later:
# ------------------------------
sasl_method=plain                       mask = PCRE
sasl_username=you                       mask = PCRE
sasl_sender=                            mask = PCRE
size=12345                              mask = numeric, will match if size >= 12345
ccert_subject=blackhole.nowhere.local   mask = PCRE (only if tls verified)
ccert_issuer=John+20Doe                 mask = PCRE (only if tls verified)
ccert_fingerprint=AA:BB:CC:DD:EE:...    mask = PCRE (do NOT use "..." here)
# ------------------------------
# Postfix version 2.3 and later:
# ------------------------------
encryption_protocol=TLSv1/SSLv3         mask = PCRE
encryption_cipher=DHE-RSA-AES256-SHA    mask = PCRE
encryption_keysize=256                  mask = numeric, will match if keysize >= 256
...

the current list can be found at <http://www.postfix.org/SMTPD_POLICY_README.html>. Please read carefully about which attribute can be used at which level of the smtp transaction (e.g. size will only work reliably at END-OF-MESSAGE level). Pattern matching is performed case insensitive. โœจ


Multiple use of the same item is allowed and will compared as logical OR, which means that this will work as expected:

id=TRUST001; action=OK; encryption_keysize=64
        ccert_fingerprint=11:22:33:44:55:66:77:88:99
        ccert_fingerprint=22:33:44:55:66:77:88:99:00
        ccert_fingerprint=33:44:55:66:77:88:99:00:11
        sender=@domain\.local$

client_address, rbl and rhsbl items may also be specified as whitespace-or-comma-separated values:

id=SKIP01; action=dunno
        client_address=192.168.1.0/24, 172.16.254.23
id=SKIP02; action=dunno
        client_address= 10.10.3.32 10.216.222.0/27

The following items must be unique:

id, minimum and maximum values, rblcount and rhsblcount

Any item can be negated by preceeding '!!' to it, e.g.:

...and if you're like "wait, what does !! even do", bestie, it's the inverted match. So this:

id=HOST001 ;  hostname == !!secure.trust.local ;  action=REJECT only secure.trust.local please

is the same energy as using the right compare operator instead:

id=HOST001 ;  hostname != secure.trust.local ;  action=REJECT only secure.trust.local please

And to avoid confusion with regexps, or literally just for better visibility, you can wrap it in !!(...) like this:

id=USER01 ;  sasl_username =~ !!( /^(bob|alice)$/ )  ;  action=REJECT who is that?

Request attributes can be compared by preceeding $$ characters, e.g.:

id=R-003 ;  client_name = !! $$helo_name      ;  action=WARN helo does not match DNS
# or
id=R-003 ;  client_name = !!($$(helo_name))   ;  action=WARN helo does not match DNS
Heads up: this is only valid for PCRE values (see the list above, bestie). The comparison will be performed as case insensitive exact match. Use the -vv option to debug. ๐Ÿ›

Now the special items that get reset for any new rule ๐Ÿซง:

  • rblcount โ€” contains the number of RBL answers
  • rhsblcount โ€” contains the number of RHSBL answers
  • matches โ€” contains the number of matched items
  • dnsbltext โ€” contains the dns TXT part of all RBL and RHSBL replies in the form rbltype:rblname:<txt>; rbltype:rblname:<txt>; ...

And these special items get changed for any matching rule โœจ:

  • request_hits โ€” contains ids of all matching rules

Which basically means it might be necessary to save them, if you plan to use these values in later rules. Like, if you don't save them, they're gone, gone, gone ๐Ÿ’€:

# set vals
id=RBL01 ; rhsblcount=all; rblcount=all
        action=set(HIT_rhls=$$rhsblcount,HIT_rbls=$$rblcount,HIT_txt=$$dnsbltext)
        rbl=list.dsbl.org, bl.spamcop.net, dnsbl.sorbs.net, zen.spamhaus.org
        rhsbl_client=rddn.dnsbl.net.au, rhsbl.ahbl.org, rhsbl.sorbs.net
        rhsbl_sender=rddn.dnsbl.net.au, rhsbl.ahbl.org, rhsbl.sorbs.net

# compare
id=RBL02 ; HIT_rhls>=1 ; HIT_rbls>=1 ; action=554 5.7.1 blocked using $$HIT_rhls RHSBLs and $$HIT_rbls RBLs [INFO: $$HIT_txt]
id=RBL03 ; HIT_rhls>=2               ; action=554 5.7.1 blocked using $$HIT_rhls RHSBLs [INFO: $$HIT_txt]
id=RBL04 ; HIT_rbls>=2               ; action=554 5.7.1 blocked using $$HIT_rbls RBLs [INFO: $$HIT_txt]

โœจ FILES โœจ

Since postfwd1 v1.15 and postfwd3 v0.18, long item lists can be stored in separate files ๐Ÿ—‚๏ธ:

id=R001 ;  ccert_fingerprint==file:/etc/postfwd/wl_ccerts ;  action=DUNNO

So postfwd3 will read a list of items (one item per line) from /etc/postfwd/wl_ccerts. And yes, comments are allowed ๐Ÿซถ:

# client1
11:22:33:44:55:66:77:88:99
# client2
22:33:44:55:66:77:88:99:00
# client3
33:44:55:66:77:88:99:00:11

If you wanna use existing tables in key=value format, you can do:

id=R001 ;  ccert_fingerprint==table:/etc/postfwd/wl_ccerts ;  action=DUNNO
Note: this will ignore the right-hand value.

And the items can be mixed together, no problem ๐Ÿ’…:

id=R002 ;  action=REJECT
        client_name==unknown
        client_name==file:/etc/postfwd/blacklisted

and for non-pcre (comma separated) items:

id=R003 ;  action=REJECT
        client_address==10.1.1.1, file:/etc/postfwd/blacklisted

id=R004 ;  action=REJECT
        rbl=myrbl.home.local, zen.spamhaus.org, file:/etc/postfwd/rbls_changing

You can vibe-check your configuration with the --show_config option at the command line ๐Ÿ”:

# postfwd3 --showconfig --rule='action=DUNNO; client_address=10.1.0.0/16, file:/etc/postfwd/wl_clients, 192.168.2.1'

and it should give you something like:

Rule   0: id->"R-0"; action->"DUNNO"; client_address->"=;10.1.0.0/16, =;194.123.86.10, =;186.4.6.12, =;192.168.2.1"

If a file can not be read, it will be ignored ๐Ÿ™ˆ:

# postfwd3 --showconfig --rule='action=DUNNO; client_address=10.1.0.0/16, file:/etc/postfwd/wl_clients, 192.168.2.1'
[LOG warning]: error: file /etc/postfwd/wl_clients not found - file will be ignored ?
Rule   0: id->"R-0"; action->"DUNNO"; client_address->"=;10.1.0.0/16, =;192.168.2.1"

File items are evaluated at configuration stage. Which means postfwd3 needs to be reloaded if a file has changed. Periodt.

But wait โ€” if you want to specify a file that will be reloaded for each request, you can use lfile: and ltable: ๐Ÿ”„:

id=R001; client_address=lfile:/etc/postfwd/client_whitelist; action=dunno

This will check the modification time of /etc/postfwd/client_whitelist every time the rule is evaluated, and reload it as necessary. Of course this might increase the system load, so please use it with care. โš ๏ธ

The --showconfig option illustrates the difference, watch this:

## evaluated at configuration stage
# postfwd3 --nodaemon -L --rule='client_address=table:/etc/postfwd/clients; action=dunno' -C
Rule   0: id->"R-0"; action->"dunno"; client_address->"=;1.1.1.1, =;1.1.1.2, =;1.1.1.3"

## evaluated for any rulehit
# postfwd3 --nodaemon -L --rule='client_address=ltable:/etc/postfwd/clients; action=dunno' -C
Rule   0: id->"R-0"; action->"dunno"; client_address->"=;ltable:/etc/postfwd/clients"

Oh and โ€” plot twist โ€” files can refer to other files ๐Ÿฆ‹. The following is valid:

-- FILE /etc/postfwd/rules.cf --
id=R01; client_address=file:/etc/postfwd/clients_master.cf; action=DUNNO

-- FILE /etc/postfwd/clients_master.cf --
192.168.1.0/24
file:/etc/postfwd/clients_east.cf
file:/etc/postfwd/clients_west.cf

-- FILE /etc/postfwd/clients_east.cf --
192.168.2.0/24

-- FILE /etc/postfwd/clients_west.cf --
192.168.3.0/24
BIG caveat bestie: there is currently no loop detection (/a/file calls /a/file) ๐Ÿ’€ and this feature is only available with postfwd1 v1.15 and postfwd3 v0.18 and higher.

๐Ÿ’… ACTIONS ๐Ÿ’…

General

Actions will be executed when all rule items have matched a request (or at least one of any item list). And you can refer to request attributes by preceeding $$ characters, like this:

id=R-003; client_name = !!$$helo_name; action=WARN helo '$$helo_name' does not match DNS '$$client_name'
# or
id=R-003; client_name = !!$$helo_name; action=WARN helo '$$(helo_name)' does not match DNS '$$(client_name)'

postfix actions

Actions will be replied to postfix as result to policy delegation requests. Any action that postfix understands is allowed โ€” see man 5 access or <http://www.postfix.org/access.5.html> for a description. And if no action is specified, the postfix WARN action โ€” which simply logs the event โ€” will be used for the corresponding rule. ๐Ÿ˜Œ

Also, postfwd3 will return dunno if it has reached the end of the ruleset and no rule has matched. You can change that by placing a last rule containing only an action statement:

...
action=dunno ; sender=@domain.local     # sender is ok
action=reject                           # default deny

postfwd3 actions

These control the behaviour of the program itself. Currently you can specify the following ๐Ÿ‘‡:

  • jump (<id>) โ€” jumps to rule with id <id>, use this to skip certain rules. you can jump backwards - but remember that there is no loop detection at the moment! jumps to non-existing ids will be skipped.
  • score (<score>) โ€” the request's score will be modified by the specified <score>, which must be a floating point value. the modificator can be either
    • +n.nn โ€” adds n.nn to current score
    • -n.nn โ€” sustracts n.nn from the current score
    • *n.nn โ€” multiplies the current score by n.nn
    • /n.nn โ€” divides the current score through n.nn
    • =n.nn โ€” sets the current score to n.nn

if the score exceeds the maximum set by --scores option (see COMMAND LINE) or the score item (see ITEMS section), the action defined for this case will be returned (default: 5.0=>"REJECT postfwd3 score exceeded").

  • set (<item>=<value>,<item>=<value>,...) โ€” this command allows you to insert or override request attributes, which then may be compared to your further ruleset. use this to speed up repeated comparisons to large item lists. please see the EXAMPLES section for more information. you may separate multiple key=value pairs by , characters.
  • rate (<item>/<max>/<time>/<action>) โ€” this command creates a counter for the given <item>, which will be increased any time a request containing it arrives. if it exceeds <max> within <time> seconds it will return <action> to postfix. rate counters are very fast as they are executed before the ruleset is parsed. please note that <action> was limited to postfix actions (no postfwd actions) for postfwd versions <1.33!
# no more than 3 requests per 5 minutes
# from the same "unknown" client
id=RATE01 ;  client_name==unknown
   action=rate(client_address/3/300/450 4.7.1 sorry, max 3 requests per 5 minutes)
  • size (<item>/<max>/<time>/<action>) โ€” this command works similar to the rate() command with the difference, that the rate counter is increased by the request's size attribute. to do this reliably you should call postfwd3 from smtpd_end_of_data_restrictions. if you want to be sure, you could check it within the ruleset:
# size limit 1.5mb per hour per client
id=SIZE01 ;  protocol_state==END-OF-MESSAGE ;  client_address==!!(10.1.1.1)
   action=size(client_address/1572864/3600/450 4.7.1 sorry, max 1.5mb per hour)
  • rcpt (<item>/<max>/<time>/<action>) โ€” this command works similar to the rate() command with the difference, that the rate counter is increased by the request's recipient_count attribute. to do this reliably you should call postfwd from smtpd_data_restrictions or smtpd_end_of_data_restrictions. if you want to be sure, you could check it within the ruleset:
# recipient count limit 3 per hour per client
id=RCPT01 ;  protocol_state==END-OF-MESSAGE ;  client_address==!!(10.1.1.1)
   action=rcpt(client_address/3/3600/450 4.7.1 sorry, max 3 recipients per hour)
  • rate5321,size5321,rcpt5321 (<item>/<max>/<time>/<action>) โ€” same as the corresponding non-5321 functions, with the difference that the localpart of sender oder recipient addresses are evaluated case-sensitive according to rfc5321. That means that requests from bob@example.local and BoB@example.local will be treated differently ๐Ÿ‘€
  • groupadd(<groupname>/<item>[/<ttl>]) โ€” saves <item> into the dynamic group <group> for later use, bestie ๐Ÿ’พ. the object gets yeeted out of that group after ttl seconds. if you don't set it, postfwd3 falls back to the default --default_group_ttl [3600s]. Please read the chapter about dynamic groups before use! like, fr fr, go read it first.
# add client_address to group %%blacklisted_hosts
id=ADDGROUP
     rbl=zen.spamhaus.org,bl.spamcop.net,ix.dnsbl.manitu.net
     action=groupadd(%%blacklisted_hosts/client_address/86400)
  • groupdel(<groupname>/<item>) โ€” removes <item> from dynamic group <group>. again: Please read the chapter about dynamic groups before use! ๐Ÿ™
# remove a client_address from group %%blacklisted_hosts
id=DELGROUP
     rbl=lists.dnswl.org
     action=groupdel(%%blacklisted_hosts/client_address)
  • ask (<addr>:<port>[:<ignore>]) โ€” lets you hand the whole policy decision off to another policy service (postgrey girlies, this one's for you). the first and second arguments (address and port) are mandatory. a third optional argument can be given to tell postfwd3 to ignore certain answers and just keep on parsing the ruleset โœจ
# example1: query postgrey and return it's answer to postfix
id=GREY; client_address==10.1.1.1; action=ask(127.0.0.1:10031)
# example2: query postgrey but ignore it's answer, if it matches 'DUNNO'
# and continue parsing postfwd's ruleset
id=GREY; client_address==10.1.1.1; action=ask(127.0.0.1:10031:^dunno$)
  • mail(server/helo/from/to/subject/body) โ€” This command is deprecated. you should try to use the sendmail() action instead, no cap. it's a very basic mail command that sends a message with the given arguments. LIMITATIONS: this basically performs a telnet ๐Ÿ’€. no authentication or TLS are available. additionally it does not track notification state and will notify you any time the corresponding rule hits.
  • sendmail(sendmail-path::from::to::subject::body) โ€” a mail command that uses an existing sendmail binary and sends a message with the given arguments ๐Ÿ“ง. LIMITATIONS: the command does not track notification state and will notify you any time the corresponding rule hits (which could mean 100 mails for a mail with 100 recipients at RCPT stage ๐Ÿ˜ญ).
  • wait (<delay>) โ€” pauses the program execution for <delay> seconds. use this for delaying or throtteling connections โณ.
  • note (<string>) โ€” just logs the given string and continues parsing the ruleset. if the string is empty, nothing will be logged (noop) ๐Ÿคซ.
  • quit (<code>) โ€” terminates the program with the given exit-code. postfix doesn`t like that too much, so use it with care โš ๏ธ.

You can reference to request attributes, like

id=R-HELO ;  helo_name=^[^\.]+$ ;  action=REJECT invalid helo '$$helo_name'

Since postfwd3 version 2.00 a rule can have multiple postfwd actions, like

# if client is found on list.dnswl.org,
#   1. send a note
#   2. add it's ip to group %%WHITELISTED
#   3. permit the request
id=ADDR01
    rbl=list.dnswl.org
    action=note(adding $$client_address to WHITELIST)
    action=groupadd(%%WHITELISTED/client_address)
    action=PERMIT_AUTH_DESTINATION

๐ŸŒธ MACROS/ACLS ๐ŸŒธ

Multiple use of long items or combinations of them may be abbreviated by macros, bestie โ€” it's giving shortcuts for your shortcuts ๐Ÿ’…. Those must be prefixed by '&&' (two '&' characters). First the macros have to be defined as follows:

&&RBLS { rbl=zen.spamhaus.org,list.dsbl.org,bl.spamcop.net,dnsbl.sorbs.net,ix.dnsbl.manitu.net; };

Then these may be used in your rules, like:

&&RBLS ;  client_name=^unknown$                         ; action=REJECT
&&RBLS ;  client_name=(\d+[\.-_]){4}                    ; action=REJECT
&&RBLS ;  client_name=[\.-_](adsl|dynamic|ppp|)[\.-_]   ; action=REJECT

Macros can contain actions, too:

# definition
&&GONOW { action=REJECT your request caused our spam detection policy to reject this message. More info at http://www.domain.local; };
# rules
&&GONOW ;  &&RBLS ;  client_name=^unknown$
&&GONOW ;  &&RBLS ;  client_name=(\d+[\.-_]){4}
&&GONOW ;  &&RBLS ;  client_name=[\.-_](adsl|dynamic|ppp|)[\.-_]

Macros can contain macros, too (macro-ception, iconic ๐ŸŒ€):

# definition
&&RBLS{
    rbl=zen.spamhaus.org
    rbl=list.dsbl.org
    rbl=bl.spamcop.net
    rbl=dnsbl.sorbs.net
    rbl=ix.dnsbl.manitu.net
};
&&DYNAMIC{
    client_name=^unknown$
    client_name=(\d+[\.-_]){4}
    client_name=[\.-_](adsl|dynamic|ppp|)[\.-_]
};
&&GOAWAY { &&RBLS; &&DYNAMIC; };
# rules
&&GOAWAY ; action=REJECT dynamic client and listed on RBL

Basically macros are simple text substitutions โ€” see the "PARSER" section for more information ๐Ÿ“š.


๐Ÿ‘‘ DYNAMIC GROUPS ๐Ÿ‘‘

Dynamic groups are list objects. You can add or remove items and compare request items to their content within your ruleset. Groups are saved persistant for further requests โ€” so you're basically hoarding info for later ๐Ÿซถ. This way you can save information for later use. Every item of a group has a time-to-live value.

Group-names have to be preceeded by double '%'-characters, like %%GROUPNAME:

# add client_address to group %%BLACKLISTED, if sent to spamtrap
id=SPAMTRAP; recipient==spamtrap@domain.local; action=groupadd(%%BLACKLISTED/client_address)

# reject any request from client_address in group %%BLACKLISTED
id=BLACKLISTED; client_address=%%BLACKLISTED; action=REJECT

The groupadd() function receives the following arguments. The <ttl> value is optional. If not specified, postfwd3 will use the default --default_group_ttl [300s]:

groupadd ( <Groupname> / <item> [ / <ttl> ] )

postfwd will save this data in the following structure:

%Group_Cache -> %<Groupname> -> $<item> = <ttl>

The groupdel() function removes an item from a group:

groupdel ( <Groupname> / <item> )

Expired members will be removed at cleanup stage after --cleanup_groups <i> seconds ๐Ÿงน.

To compare requests with dynamic groups, these must be refered by preceeding '%%'-characters:

<item> = %%<Groupname>

Empty groups always compare false. The following ruleset is possible:

# allow any request from client_address in group %%WHITELISTED
id=WHITEGROUP; client_address=%%WHITELISTED; action=DUNNO

# reject any request from client_address in group %%BLACKLISTED
id=BLACKGROUP; client_address=%%BLACKLISTED; action=REJECT

# ... other rules ...

# ... whitelist file ...
id=W_TRUST; action=set(WHITE=1)
    client_address=file:/etc/postfwd/whitelist_networks

# ... whitelist dns ...
id=W_DNSWL; action=set(WHITE=1)
    rbl=list.dnswl.org

# ... -> add it to whitelist for a day and allow request
id=W_GROUP; WHITE==1
    action=groupadd(%%WHITELISTED/client_address/86400)
    action=DUNNO

# ... or blacklist by sending to spamtrap ...
id=B_SPAMTRAP; action=set(BLACK=1)
    recipient==spamtrap@domain.local

# ... or blacklisted by dns ...
id=B_DNSBL; action=set(BLACK=1)
    rbl=zen.spamhaus.org

# ... -> add it to group BLACKLISTED for 1 hour and reject
id=B_GROUP; BLACK==1
    action=groupadd(%%BLACKLISTED/client_address/3600)
    action=REJECT

Limitations

Currently dynamic groups are kept in memory. By default postfwd3 will only accept a maximum of 999999 members in a group. To deactivate this limit set --group_maxitems to '-1'.

With PreFork personality, you should be aware that this information must be shared through the cache daemon.

In short: Don't let these lists get excessively big unless you have enough system capacity to do so. If necessary tune your config by setting the <ttl> and --group_maxitems โš–๏ธ.


๐Ÿฆ‹ PLUGINS ๐Ÿฆ‹

Description

The plugin interface allow you to define your own checks and enhance postfwd's functionality. Feel free to share useful things! ๐Ÿ’–

Warning

Note that the plugin interface is still at devel stage. Please test your plugins carefully, because errors may cause postfwd to break! It is also allowed to override attributes or built-in functions, but be sure that you know what you do because some of them are used internally.
Please keep security in mind, when you access sensible ressources and never, ever run postfwd as privileged user! Also never trust your input (especially hostnames, and e-mail addresses). ๐Ÿ”’

ITEMS

Item plugins are perl subroutines which integrate additional attributes to requests before they are evaluated against postfwd's ruleset like any other item of the policy delegation protocol. This allows you to create your own checks โœจ.

plugin-items can not be used selective. these functions will be executed for every request postfwd receives, so keep performance in mind ๐Ÿ‘€.

SYNOPSIS: postfwd_items_plugin{<name>}($request)

means that your subroutine, called <name>, has access to a hash-reference called $request, which contains all request attributes, like $request->{client_name} and saves values in the following form:

save: $result->{<item>} = <value>

this creates the new item <item> containing <value>, which will be integrated in the policy delegation request and therefore may be used in postfwd's ruleset.

# do NOT remove the next line
%postfwd_items_plugin = (

    # EXAMPLES - integrated in postfwd. no need to activate them here.

        # allows to check postfwd version in ruleset
        "version" => sub {
            my($request) = shift;
            $request->{version} => $NAME." ".$VERSION,
        },

        # sender_domain and recipient_domain
        "address_parts" => sub {
            my($request) = shift;
            $request->{sender} =~ /@([^@]*)$/;
            $request->{sender_domain} = ($1 || '');
            $request->{recipient} =~ /@([^@]*)$/;
            $request->{recipient_domain} = ($1 || '');
        },

# do NOT remove the next line
);

โญ COMPARE โญ

Compare plugins are how you tell postfwd3 exactly how your shiny new items should get compared against the ruleset. They're fully optional btw โ€” if you don't give one, the default kicks in (== for exact match, =~ for PCRE, ...) and honestly that's usually fine.

SYNOPSIS:  <item> => sub { return &{$postfwd_compare{<type>}}(@_); },

# do NOT remove the next line
%postfwd_compare_plugin = (

    EXAMPLES - integrated in postfwd. no need to activate them here.

        # Simple example
        # SYNOPSIS:  <result> = <item> (return &{$postfwd_compare{<type>}}(@_))
        "client_address"  => sub { return &{$postfwd_compare{cidr}}(@_); },
        "size"            => sub { return &{$postfwd_compare{numeric}}(@_); },
        "recipient_count" => sub { return &{$postfwd_compare{numeric}}(@_); },

        # Complex example
        # SYNOPSIS:  <result> = <item>(<operator>, <ruleset value>, <request value>, <request>)
        "numeric" => sub {
            my($cmp,$val,$myitem,$request) = @_;
            my($myresult) = undef;  $myitem ||= "0"; $val ||= "0";
            if ($cmp eq '==') {
                $myresult = ($myitem == $val);
            } elsif ($cmp eq '=<') {
                $myresult = ($myitem <= $val);
            } elsif ($cmp eq '=>') {
                $myresult = ($myitem >= $val);
            } elsif ($cmp eq '<') {
                $myresult = ($myitem < $val);
            } elsif ($cmp eq '>') {
                $myresult = ($myitem > $val);
            } elsif ($cmp eq '!=') {
                $myresult = not($myitem == $val);
            } elsif ($cmp eq '!<') {
                $myresult = not($myitem <= $val);
            } elsif ($cmp eq '!>') {
                $myresult = not($myitem >= $val);
            } else {
                $myresult = ($myitem >= $val);
            };
            return $myresult;
        },

# do NOT remove the next line
);
๐Ÿšจ do NOT remove the # do NOT remove the next line lines bestie, i'm so serious ๐Ÿ˜ญ

๐ŸŽ€ ACTIONS ๐ŸŽ€

Action plugins let you define brand new postfwd actions, which is giving main character energy ngl. By setting the $stop-flag you get to decide whether to keep going or stop parsing the ruleset. Your call, your rules. ๐Ÿ’…

SYNOPSIS:  (<stop rule parsing>, <next rule index>, <return action>, <logprefix>) =
        <action> (<current rule index>, <current time>, <command name>, <argument>, <logprefix>, <request>)

# do NOT remove the next line
%postfwd_actions_plugin = (

    # EXAMPLES - integrated in postfwd. no need to activate them here.

        # note(<logstring>) command
        "note"  => sub {
            my($index,$now,$mycmd,$myarg,$myline,$request) = @_;
            my($myaction) = 'dunno'; my($stop) = 0;
            log_info "[RULES] ".$myline." - note: ".$myarg if $myarg;
            return ($stop,$index,$myaction,$myline);
        },

        # skips next <myarg> rules
        "skip" => sub {
            my($index,$now,$mycmd,$myarg,$myline,$request) = @_;
            my($myaction) = 'dunno'; my($stop) = 0;
            $index += $myarg if ( $myarg and not(($index + $myarg) > $#Rules) );
            return ($stop,$index,$myaction,$myline);
        },

        # dumps current request contents to syslog
        "dumprequest" => sub {
            my($index,$now,$mycmd,$myarg,$myline,$request) = @_;
            my($myaction) = 'dunno'; my($stop) = 0;
            map { log_info "[DUMP] rule=$index, Attribute: $_=$request{$_}" } (keys %{$request});
            return ($stop,$index,$myaction,$myline);
        },

# do NOT remove the next line
);

๐Ÿ’ป COMMAND LINE ๐Ÿ’ป

Ruleset

These args are how you tell postfwd3 where your ruleset is actually coming from. Translation: you need at least ONE of these or postfwd3 simply will not function. Periodt.

-f, --file <file>
Reads rules from <file>. Please see the CONFIGURATION section
for more information.

-r, --rule <rule>
Adds <rule> to ruleset. Remember that you might have to quote
strings that contain whitespaces or shell characters.
  • -f, --file <file> โ€” pulls your rules out of <file>. Peep the CONFIGURATION section for the deets. ๐Ÿ“‚
  • -r, --rule <rule> โ€” drops <rule> straight into the ruleset. Quick heads up tho: you might have to quote strings that have whitespaces or shell characters in them, so mind your quoting bestie. โœจ

Settings

-F, --loadsettings <file>
Loads program settings from <file>. data must be in Data::Dumper format.
Please read the SETTINGS section for more information.

--savesettings <file>
Saves program settings to <file> in Data::Dumper format.
Please read the SETTINGS section for more information.

--showsettings
Exports program settings in Data::Dumper format to stdout. This output
can be saved to a file and later used with the --loadsettings option.
  • -F, --loadsettings <file> โ€” loads your program settings from <file>. The data must be in Data::Dumper format. Go read the SETTINGS section for more. ๐Ÿ“–
  • --savesettings <file> โ€” writes your program settings out to <file> in Data::Dumper format. Again, SETTINGS section, go look. ๐Ÿ’พ
  • --showsettings โ€” dumps program settings to stdout in Data::Dumper format. You can save that output to a file and feed it back later with the --loadsettings option. Chef's kiss workflow honestly. ๐Ÿ‘‘

Scoring

-s, --scores <val>=<action>
Returns <action> to postfix, when the request's score exceeds <val>
  • -s, --scores <val>=<action> โ€” hands <action> back to postfix whenever the request's score goes past <val>. ๐Ÿ“ˆ

You can use it multiple times btw โ€” just chain your arguments, like:

postfwd3 -r "<item>=<value>;action=<result>" -f <file> -f <file> ...
  or
postfwd3 --scores 4.5="WARN high score" --scores 5.0="REJECT postfwd3 score too high" ...

And when you've got multiple scores? The highest match is the one that counts. The order of your arguments is what gets reflected in the postfwd3 ruleset. No cap. ๐Ÿซถ

Networking

postfwd3 can run as a daemon so it just sits there listening on the network for incoming requests, like a little bouncer. ๐Ÿšช These args control how it behaves when it's in that mode.

  • -d, --daemon, --nodaemon โ€” postfwd3 runs as a daemon and listens on the network for incoming queries (default 127.0.0.1:10045). Use --nodaemon to keep postfwd3 running in the foreground instead. ๐ŸŒ™
  • -i, --interface <dev> โ€” binds postfwd3 to the interface you specify (default 127.0.0.1). ๐Ÿ›ฐ๏ธ
  • -p, --port <port> โ€” postfwd3 listens on <port> (default tcp/10045). ๐Ÿ”Œ
  • --proto <type> โ€” the protocol type for postfwd's socket. Right now you can use 'tcp' or 'unix' here. Wanna run postfwd3 with a unix domain socket? Do it like this:
postfwd3 --proto=unix --port=/somewhere/postfwd.socket
  • -u, --user <name> โ€” changes the real and effective user to <name>. ๐Ÿง‘
  • -g, --group <name> โ€” changes the real and effective group to <name>. ๐Ÿ‘ฅ
  • --personality <type> โ€” the type of policy server. Lets you pick 'PreFork' or 'Multiplex'. Heads up: this option overrides --autopersonality. ๐ŸŽญ
  • --v1 โ€” sets personality to 'Multiplex'. This option overrides --autopersonality. 1๏ธโƒฃ
  • --v2 โ€” sets personality to 'PreFork'. This option overrides --autopersonality. 2๏ธโƒฃ
  • --autopersonality โ€” postfwd3 figures out the personality by program name:
    • 'Multiplex' for 'postfwd' or 'postfwd1'
    • 'PreFork' for 'postfwd2'

You can turn this off with --noautopersonality, or just be explicit with --personality, --v1 or --v2. โœจ

  • --umask <mask> โ€” changes the umask for filepermissions of the master process (pidfile). Attention: this is umask, not chmod โ€” you have to specify the bits that should NOT apply. E.g.: umask 077 equals to chmod 700. Don't get it twisted. ๐Ÿ™ˆ
  • --cache_umask <mask> โ€” changes the umask for filepermissions of the cache process (unix domain socket). ๐Ÿ—‚๏ธ
  • --server_umask <mask> โ€” changes the umask for filepermissions of the server process (unix domain socket). ๐Ÿ–ฅ๏ธ
  • -R, --chroot <path> โ€” chroots the process to the specified <path>. Please look at http://postfwd.org/postfwd3-chroot.html before use! For real, go read it first. ๐Ÿ“š
  • --pidfile <path> โ€” the process id gets saved in the file you specify. ๐Ÿชช
  • --facility <f> โ€” sets the syslog facility, default is 'mail'. ๐Ÿ“ฎ
  • --socktype <s> โ€” sets the Sys::Syslog socktype to 'native', 'inet' or 'unix'. The default is to auto-detect this depending on module version and os. ๐Ÿค–
  • -l, --logname <label> โ€” labels the syslog messages. Super useful when you're running multiple instances of postfwd. ๐Ÿท๏ธ
  • --loglen <int> โ€” truncates any syslog message after <int> characters. โœ‚๏ธ

Plugins

  • --plugins <file> โ€” loads postfwd plugins from <file>. Please see http://postfwd.org/postfwd.plugins or the plugins.postfwd.sample that's available from the tarball for more info. ๐Ÿงฉ

Optional arguments

These parameters change the way postfwd3 works. Any of them can be combined โ€” mix and match, it's your world. ๐ŸŒ

  • -v, --verbose โ€” verbose logging shows a lot of useful info but can make your logfiles grow noticeably. So use it with caution, bestie. Set the option twice (-vv) to get even more info (logs all request attributes). ๐Ÿ—ฃ๏ธ
  • -c, --cache <int> โ€” (default=600) timeout for the request cache. Results for identical requests get cached until the config is reloaded or this time (in seconds) expires. A setting of 0 disables this feature entirely. โฑ๏ธ
  • --cache-no-size โ€” ignores the size attribute for cache comparisons, which leads to better cache-hit rates. You should set this option if you don't use the size item in your ruleset. ๐Ÿ“
  • --cache-no-sender โ€” ignores the sender address for cache comparisons, which leads to better cache-hit rates. You should set this option if you don't use the sender item in your ruleset. ๐Ÿ“ฌ
  • --cache-rdomain-only โ€” strips the localpart of the recipient's address before filling the cache. This may considerably increase cache-hit rates. ๐ŸŽฏ
  • --cache-rbl-timeout <timeout> โ€” (default=3600) this default value is used as the timeout in seconds for rbl cache items, if it's not specified in the ruleset. โณ
  • --cache-rbl-default <pattern> โ€” (default=^127\.0\.0\.\d+$) matches <pattern> to rbl/rhsbl answers (regexp) if it's not specified in the ruleset. ๐Ÿ”
  • --cacheid <item>, <item>, ... โ€” a csv-separated list of request attributes that gets used to build the request cache identifier. Only touch this if you know exactly what you're doing, bestie, fr fr. Say you're only running postfwd3 for RBL/RHSBL control โ€” then you could set it like this:
postfwd3 --cache=3600 --cacheid=client_name,client_address

This makes caching way more efficient and boosts postfwd's performance. ๐Ÿ’…

Warning: you have to list all the items here that are used in your ruleset! Every single one, no cap.
  • --cacheid_md5, --nocacheid_md5 โ€” (default=1) the cacheid gets built with a md5sum of the request items. โœจ
  • --cleanup-requests <interval> โ€” (default=600) the request cache gets scanned for timed-out items after this <interval> in seconds. It's a minimum value, okay? The cleanup only happens when a new request shows up. ๐Ÿ‘‘
  • --cleanup-rbls <interval> โ€” (default=600) same deal but for the rbl cache โ€” timed-out items get swept after this <interval> in seconds. Also a minimum, also only triggered when a new request arrives. ๐Ÿงน
  • --cleanup-rates <interval> โ€” (default=600) yep, the rate cache too. Timed-out items are searched for after this <interval> in seconds, minimum value, and only when a new request comes in. ๐Ÿซถ
  • -S, --summary <int> โ€” (default=600) shows some usage statistics (program uptime, request counter, matching rules) every <int> seconds. This option is included by the -v switch. It runs on the alarm signal, so you can force postfwd3 to dump the stats with kill -ALRM <pid> โ€” where <pid> is the process id of postfwd. ๐Ÿ˜ค iconic.

Example:

Aug 19 12:39:45 mail1 postfwd[666]: [STATS] Counters: 213000 seconds uptime, 39 rules
Aug 19 12:39:45 mail1 postfwd[666]: [STATS] Requests: 71643 overall, 49 last interval, 62.88% cache hits
Aug 19 12:39:45 mail1 postfwd[666]: [STATS] Averages: 20.18 overall, 4.90 last interval, 557.30 top
Aug 19 12:39:45 mail1 postfwd[666]: [STATS] Contents: 44 cached requests, 239 cached dnsbl results
Aug 19 12:39:45 mail1 postfwd[666]: [STATS] Rule ID: R-001   matched: 2704 times
Aug 19 12:39:45 mail1 postfwd[666]: [STATS] Rule ID: R-002   matched: 9351 times
Aug 19 12:39:45 mail1 postfwd[666]: [STATS] Rule ID: R-003   matched: 3116 times
...
  • --no-rulestats โ€” turns off per-rule statistics. Keeps your log looking clean if you're not using them. Heads up though: this option does nothing without --summary or --verbose set. ๐Ÿ’€
  • -L, --stdout โ€” redirects all syslog messages to stdout for debugging. Do NOT use this in daemon mode! ๐Ÿšซ
  • --stdin โ€” tells postfwd to pull the request data from STDIN instead of a network socket. Super handy for testing rulesets straight from the command line: ๐ŸŒธ

Example:

postfwd -f /etc/postfwd/postfwd.cf --stdin --stdout --nodaemon ../tools/request.sample
  • --cmd โ€” shorthand for the combo --stdin, --stdout and --nodaemon. chef's kiss. ๐Ÿ‘ฉโ€๐Ÿณ

Example:

postfwd -f /etc/postfwd/postfwd.cf --cmd ../tools/request.sample
  • -t, --test โ€” in test mode postfwd3 always returns "dunno", but it still logs according to its ruleset. And yeah, -v gets set automatically with this option. ๐Ÿ™ˆ
  • -n, --nodns โ€” disables all DNS based checks like RBL checks. Any rules containing such elements just get ignored. ๐Ÿฆ‹
  • -n, --nodnslog โ€” disables logging of dns events. (Yes bestie, the manual really does list -n twice โ€” not my typo, it's canon. ๐Ÿ’€)
  • --dns_timeout โ€” (default: 14) sets the timeout for asynchronous dns queries in seconds. This value applies to all dns items in a rule. โณ
  • --dns_timeout_max โ€” (default: 10) sets the maximum timeout counter for dnsbl lookups. If the timeouts go past this value, the corresponding dnsbl gets deactivated for a while (see --dns_timeout_interval). ๐Ÿ˜ญ
  • --dns_timeout_interval โ€” (default=1200) the dnsbl timeout counter gets cleaned after this interval in seconds. Use it alongside the --dns_timeout_max parameter. ๐Ÿงผ
  • --dns_async_txt โ€” performs dnsbl A and TXT lookups at the same time (otherwise only for listings with at least one A record). This needs more network bandwidth because of the increased queries, but it might increase throughput since the lookups can be parallelized. ๐Ÿ”ฅ
  • --dns_max_ns_lookups โ€” (default=0) maximum ns names to look up with the sender_ns_addrs item. use 0 for no maximum.
  • --dns_max_mx_lookups โ€” (default=0) maximum mx names to look up with the sender_mx_addrs item. use 0 for no maximum.
  • --ipv6_dnsbl โ€” (default=0) enables dnsbl checks for IPv6 addresses. ๐Ÿ’œ
  • -I, --instantcfg โ€” the config files specified by -f get re-read for every request postfwd3 receives. This enables on-the-fly configuration changes without restarting, which is kinda slay. But note: files are only read if necessary (meaning their access times changed since the last read), and this might significantly increase system load. โš ๏ธ
  • --config_timeout โ€” (default=3) timeout in seconds to parse a single configuration line. If it's exceeded, the rule gets skipped. This exists to prevent problems from large files or loops. ๐ŸŒ€
  • --keep_groups โ€” (default=0) with this option set, postfwd3 does not clear the group cache on reload. Please note: you have to restart (not reload) postfwd with this option if you change any group based rules. ๐Ÿซ 
  • --save_groups โ€” (default=none) with this option postfwd saves existing groups to disk and reloads them on program start. This allows persistent rate limits across program restarts or reboots. Also: postfwd needs read and write access to the specified file. ๐Ÿ“
  • --keep_rates โ€” (default=0) with this option set, postfwd3 does not clear the rate limit counters on reload. And again: you have to restart (not reload) postfwd with this option if you change any rate limit rules. ๐Ÿ˜ค
  • --save_rates โ€” (default=none) with this option postfwd saves existing rate limit counters to disk and reloads them on program start. Persistent rate limits across restarts or reboots, periodt. And yes, postfwd needs read and write access to the specified file. ๐Ÿ’พ
  • -A, --aggregate_addrs, --noaggregate_addrs โ€” pre-computes ip address lists to subnets, so that for example:
client_address=10.0.0.0/24, 10.0.1.0/24, 10.0.2.0/24, 10.0.3.0/24
     will result in
client_address=10.0.0.0/22

This increases the performance of cidr-matching. ๐Ÿš€

  • --no_netaddr โ€” do not use functions from the module NetAddr::IP, even if it was found on the system. This option implicitly disables --aggregate_addrs. ๐Ÿ™…โ€โ™€๏ธ
  • --no_netcidr โ€” do not use functions from the module Net::CIDR::Lite, even if it was found on the system. ๐Ÿ™…
  • --cidr_method=s โ€” (default=autodetect) use method <s> for network checks. Valid arguments are:
    • netcidr โ€” compare networks using Net::CIDR::Lite Module (v4 and v6 cidr)
    • netaddr โ€” compare networks using NetAddr::IP Module (v4 and v6 cidr)
    • postfwd โ€” old but very fast method (v4=cidr, v6=regex)

If nothing is specified, postfwd tries to load modules in the above order and falls back to 'postfwd' if it finds nothing. ๐ŸŒˆ


๐Ÿ“ฌ Informational arguments ๐Ÿ“ฌ

These arguments are for command line usage only. Never ever use them with postfix! fr fr, do not.

  • -C, --showconfig โ€” displays the current ruleset. Use -v for verbose output. โœจ
  • -V, --version โ€” displays the program version. ๐Ÿ‘‘
  • -h, --help โ€” shows program usage. ๐Ÿซถ
  • -m, --manual โ€” displays the program manual. ๐Ÿ“–
  • -D, --defaults โ€” displays complete postfwd3 settings. ๐Ÿ’…
  • -P, --perfmon โ€” this option turns off any syslogging and output. It's included for performance testing. ๐Ÿ”‡
  • --dumpstats โ€” displays program usage statistics. ๐Ÿ“Š
  • --dumpcache โ€” displays cache contents. ๐Ÿ—‚๏ธ
  • --delcache <item> โ€” removes an item from the request cache. Use --dumpcache to identify objects. E.g.:
# postfwd --dumpcache
...
%rate_cache -> %sender=gmato@jqvo.org -> %RATE002+2_600 -> @count    -> '1'
%rate_cache -> %sender=gmato@jqvo.org -> %RATE002+2_600 -> @maxcount -> '2'
...
# postfwd --delrate="sender=gmato@jqvo.org"
rate cache item 'sender=gmato@jqvo.org' removed
  • --delrate <item> โ€” removes an item from the rate cache. Use --dumpcache to identify objects. ๐Ÿงน

๐Ÿ”„ REFRESH ๐Ÿ”„

In daemon mode postfwd3 reloads its ruleset after receiving a HUP signal. And if you want your configuration refreshed for every request postfwd3 receives, go read the description of the '-I' switch. ๐Ÿ’–


๐ŸŒŸ EXAMPLES ๐ŸŒŸ

## whitelisting
# 1. networks 192.168.1.0/24, 192.168.2.4
# 2. client_names *.gmx.net and *.gmx.de
# 3. sender *@someshop.tld from 11.22.33.44
id=WL001; action=dunno ; client_address=192.168.1.0/24, 192.168.2.4
id=WL002; action=dunno ; client_name=\.gmx\.(net|de)$
id=WL003; action=dunno ; sender=@someshop\.tld$ ; client_address=11.22.33.44

## TLS control
# 1. *@authority.tld only with correct TLS fingerprint
# 2. *@secret.tld only with keysizes >=64
id=TL001; action=dunno                          ; sender=@authority\.tld$ ; ccert_fingerprint=AA:BB:CC..
id=TL002; action=REJECT wrong TLS fingerprint   ; sender=@authority\.tld$
id=TL003; action=REJECT tls keylength < 64      ; sender=@secret\.tld$ ; encryption_keysize=64
## Combined RBL checks
# This will reject mail if
# 1. listed on ix.dnsbl.manitu.net
# 2. listed on zen.spamhaus.org (sbl and xbl, dns cache timeout 1200s instead of 3600s)
# 3. listed on min 2 of bl.spamcop.net, list.dsbl.org, dnsbl.sorbs.net
# 4. listed on bl.spamcop.net and one of rhsbl.ahbl.org, rhsbl.sorbs.net
id=RBL01 ; action=REJECT listed on ix.dnsbl.manitu.net  ; rbl=ix.dnsbl.manitu.net
id=RBL02 ; action=REJECT listed on zen.spamhaus.org     ; rbl=zen.spamhaus.org/127.0.0.[2-8]/1200
id=RBL03 ; action=REJECT listed on too many RBLs        ; rblcount=2 ; rbl=bl.spamcop.net, list.dsbl.org, dnsbl.sorbs.net
id=RBL04 ; action=REJECT combined RBL+RHSBL check       ; rbl=bl.spamcop.net ; rhsbl=rhsbl.ahbl.org, rhsbl.sorbs.net

okay so that's four separate ways to yeet a mail based on blocklists โ€” one single RBL, one RBL with a custom dns cache timeout (1200s instead of 3600s, she's โœจspecificโœจ), a count-based one where you need at least 2 hits, and finally the combo plate: one RBL plus one RHSBL. iconic behaviour honestly ๐Ÿ’…


Now bestie, message size limits. Heads up: this one needs message_size_limit to be set to 30000000 on the postfix side or it's giving nothing.

## Message size (requires message_size_limit to be set to 30000000)
# 1. 30MB for systems in *.customer1.tld
# 2. 20MB for SASL user joejob
# 3. 10MB default
id=SZ001; protocol_state==END-OF-MESSAGE; action=DUNNO; size<=30000000 ; client_name=\.customer1.tld$
id=SZ002; protocol_state==END-OF-MESSAGE; action=DUNNO; size<=20000000 ; sasl_username==joejob
id=SZ002; protocol_state==END-OF-MESSAGE; action=DUNNO; size<=10000000
id=SZ100; protocol_state==END-OF-MESSAGE; action=REJECT message too large

so the customer1.tld girlies get 30MB, joejob gets 20MB, everyone else gets the 10MB default, and if none of those DUNNO rules fire you get hit with REJECT message too large. periodt. ๐ŸŽ€


๐Ÿฆ‹ Selective Greylisting ๐Ÿฆ‹

Real talk: postfwd does not include greylisting. This whole setup needs a running postgrey service chillin' at port 10031, plus this postfix restriction class in your main.cf:
smtpd_restriction_classes = check_postgrey, ...
check_postgrey = check_policy_service inet:127.0.0.1:10031
## Selective Greylisting
##
## Note that postfwd does not include greylisting. This setup requires a running postgrey service
## at port 10031 and the following postfix restriction class in your main.cf:
##
##      smtpd_restriction_classes = check_postgrey, ...
##      check_postgrey = check_policy_service inet:127.0.0.1:10031
#
# 1. if listed on zen.spamhaus.org with results 127.0.0.10 or .11, dns cache timeout 1200s
# 2. Client has no rDNS
# 3. Client comes from several dialin domains
id=GR001; action=check_postgrey ; rbl=dul.dnsbl.sorbs.net, zen.spamhaus.org/127.0.0.1[01]/1200
id=GR002; action=check_postgrey ; client_name=^unknown$
id=GR003; action=check_postgrey ; client_name=\.(t-ipconnect|alicedsl|ish)\.de$

three flavours of sus: listed on the zen spamhaus with results 127.0.0.10 or .11, no rDNS at all, or coming from one of those dialin domains. all of them get sent to the greylist corner ๐Ÿซถ


๐ŸŒˆ Date Time ๐ŸŒˆ

## Date Time
date=24.12.2007-26.12.2007          ;  action=450 4.7.1 office closed during christmas
time=04:00:00-05:00:00              ;  action=450 4.7.1 maintenance ongoing, try again later
time=-07:00:00 ;  sasl_username=jim ;  action=450 4.7.1 to early for you, jim
time=22:00:00- ;  sasl_username=jim ;  action=450 4.7.1 to late now, jim
months=-Apr                         ;  action=450 4.7.1 see you in may
days=!!Mon-Fri                      ;  action=check_postgrey

the range notation here is chef's kiss โ€” 24.12.2007-26.12.2007 is a date window, 04:00:00-05:00:00 is a time window, -07:00:00 means everything up to 7am, and 22:00:00- means everything from 10pm onwards. also months=-Apr = up to April, and days=!!Mon-Fri with that double-bang negation = weekends only, bestie. ๐Ÿ˜ญ


๐Ÿ‘‘ Usage of jump ๐Ÿ‘‘

this one's the glow-up: 30MB for the VIPs, 10MB for everybody else. the jump() hops straight to another rule id.

## Usage of jump
# The following allows a message size of 30MB for different
# users/clients while others will only have 10MB.
id=R001 ; action=jump(R100) ; sasl_username=^(Alice|Bob|Jane)$
id=R002 ; action=jump(R100) ; client_address=192.168.1.0/24
id=R003 ; action=jump(R100) ; ccert_fingerprint=AA:BB:CC:DD:...
id=R004 ; action=jump(R100) ; ccert_fingerprint=AF:BE:CD:DC:...
id=R005 ; action=jump(R100) ; ccert_fingerprint=DD:CC:BB:DD:...
id=R099 ; protocol_state==END-OF-MESSAGE; action=REJECT message too big (max. 10MB); size=10000000
id=R100 ; protocol_state==END-OF-MESSAGE; action=REJECT message too big (max. 30MB); size=30000000

Alice, Bob and Jane stay winning, so do the 192.168.1.0/24 girlies and anyone holding one of those three cert fingerprints. everyone else meets R099. no cap. ๐Ÿ’€


๐Ÿ’œ Usage of score ๐Ÿ’œ

## Usage of score
# The following rejects a mail, if the client
# - is listed on 1 RBL and 1 RHSBL
# - is listed in 1 RBL or 1 RHSBL and has no correct rDNS
# - other clients without correct rDNS will be greylist-checked
# - some whitelists are used to lower the score
id=S01 ; score=2.6              ; action=check_postgrey
id=S02 ; score=5.0              ; action=REJECT postfwd score too high
id=R00 ; action=score(-1.0)     ; rbl=exemptions.ahbl.org,list.dnswl.org,query.bondedsender.org,spf.trusted-forwarder.org
id=R01 ; action=score(2.5)      ; rbl=bl.spamcop.net, list.dsbl.org, dnsbl.sorbs.net
id=R02 ; action=score(2.5)      ; rhsbl=rhsbl.ahbl.org, rhsbl.sorbs.net
id=N01 ; action=score(-0.2)     ; client_name==$$helo_name
id=N02 ; action=score(2.7)      ; client_name=^unknown$
...

it's giving โœจcredit score but for spammersโœจ. whitelists lower the score (-1.0, -0.2), blocklists raise it (2.5, 2.7), 2.6 gets you greylist-checked and 5.0 gets you rejected. the math is mathing. ๐Ÿ”ฅ


๐Ÿ“ Usage of rate and size ๐Ÿ“

## Usage of rate and size
# The following temporary rejects requests from "unknown" clients, if they
# 1. exceeded 30 requests per hour or
# 2. tried to send more than 1.5mb within 10 minutes
id=RATE01 ;  client_name==unknown ;  protocol_state==RCPT
        action=rate(client_address/30/3600/450 4.7.1 sorry, max 30 requests per hour)
id=SIZE01 ;  client_name==unknown ;  protocol_state==END-OF-MESSAGE
        action=size(client_address/1572864/600/450 4.7.1 sorry, max 1.5mb per 10 minutes)

unknown clients are on thin ice: more than 30 requests per hour, or more than 1.5mb inside 10 minutes, and it's a temporary 450 for you, babe. ๐Ÿ™ˆ


๐Ÿงš Macros ๐Ÿงš

## Macros
# definition
&&RBLS { rbl=zen.spamhaus.org,list.dsbl.org,bl.spamcop.net,dnsbl.sorbs.net,ix.dnsbl.manitu.net; };
&&GONOW { action=REJECT your request caused our spam detection policy to reject this message. More info at http://www.domain.local; };
# rules
&&GONOW ;  &&RBLS ;  client_name=^unknown$
&&GONOW ;  &&RBLS ;  client_name=(\d+[\.-_]){4}
&&GONOW ;  &&RBLS ;  client_name=[\.-_](adsl|dynamic|ppp|)[\.-_]

macros are literally reusable fit checks for your rules. define once, wear everywhere. &&RBLS holds the whole blocklist squad, &&GONOW holds the reject action, and you just snap them together in each rule. ๐Ÿ’…


๐ŸŒธ Groups ๐ŸŒธ

## Groups
# definition
&&RBLS{
        rbl=zen.spamhaus.org
        rbl=list.dsbl.org
        rbl=bl.spamcop.net
        rbl=dnsbl.sorbs.net
        rbl=ix.dnsbl.manitu.net
};
&&RHSBLS{
        ...
};
&&DYNAMIC{
        client_name==unknown
        client_name~=(\d+[\.-_]){4}
        client_name~=[\.-_](adsl|dynamic|ppp|)[\.-_]
        ...
};
&&BAD_HELO{
        helo_name==my.name.tld
        helo_name~=^([^\.]+)$
        helo_name~=\.(local|lan)$
        ...
};
&&MAINTENANCE{
        date=15.01.2007
        date=15.04.2007
        date=15.07.2007
        date=15.10.2007
        time=03:00:00 - 04:00:00
};
# rules
id=COMBINED    ;  &&RBLS ;  &&DYNAMIC ;  action=REJECT dynamic client and listed on RBL
id=MAINTENANCE ;  &&MAINTENANCE       ;  action=DEFER maintenance time - please try again later

groups are macros that hold multiple items of the same type โ€” a whole list of rbl=, client_name=, helo_name= or date=/time= lines. then the rules stack them up: dynamic client and listed on an RBL = REJECT, and the maintenance windows = DEFER. main character energy. ๐Ÿ‘‘


โญ Groups + set(), the sequel โญ

# now with the set() command, note that long item
# lists don't have to be compared twice
id=RBL01    ;  &&RBLS      ;  action=set(HIT_rbls=1)
id=HELO01   ;  &&BAD_HELO  ;  action=set(HIT_helo=1)
id=DYNA01   ;  &&DYNAMIC   ;  action=set(HIT_dyna=1)
id=REJECT01 ;  HIT_rbls==1 ;  HIT_helo==1  ; action=REJECT please see http://some.org/info?reject=01 for more info
id=REJECT02 ;  HIT_rbls==1 ;  HIT_dyna==1  ; action=REJECT please see http://some.org/info?reject=02 for more info
id=REJECT03 ;  HIT_helo==1 ;  HIT_dyna==1  ; action=REJECT please see http://some.org/info?reject=03 for more info

this is the optimisation era: instead of re-running those long item lists twice, you set() a little flag (HIT_rbls, HIT_helo, HIT_dyna) the first time, then every combo rule after just checks the flag. efficiency? we love her. ๐ŸŽ€

## combined with enhanced rbl features
#
id=RBL01 ; rhsblcount=all ; rblcount=all ; &&RBLS ; &&RHSBLS
     action=set(HIT_dnsbls=$$rhsblcount,HIT_dnsbls+=$$rblcount,HIT_dnstxt=$$dnsbltext)
id=RBL02 ; HIT_dnsbls>=2  ; action=554 5.7.1 blocked using $$HIT_dnsbls DNSBLs [INFO: $$HIT_dnstxt]

count everything, stash the total in HIT_dnsbls and the text in HIT_dnstxt, then reject with 554 5.7.1 once you've hit 2 or more DNSBLs. she ate. ๐Ÿ’…


๐ŸŽ€ PARSER ๐ŸŽ€

Configuration

so the postfwd3 ruleset can be given right there on the commandline (that's the -r option) or read from files (that's -f). the order of your arguments is kept, bestie. and pleaseeee check the parser with the -C | --showconfig switch at the command line before you apply a new config โ€” it's the vibe check your ruleset deserves. this call:

postfwd3 --showconfig \
        -r "id=TEST; recipient_count=100; action=WARN mail with 100+ recipients" \
        -f /etc/postfwd.cf \
        -r "id=DEFAULT; action=dunno";

will produce the following output:

Rule   0: id->"TEST" action->"WARN mail with 100+ recipients"; recipient_count->"100"
...
... <content of /etc/postfwd.cf> ...
...
Rule <n>: id->"DEFAULT" action->"dunno"

multiple items of the same type get added to lists (go peek at the "ITEMS" section for more on that):

postfwd3 --showconfig \
        -r "client_address=192.168.1.0/24; client_address=172.16.26.32; action=dunno"

will result in:

Rule   0: id->"R-0"; action->"dunno"; client_address->"192.168.1.0/24, 172.16.26.32"

see how both client_address values just moved in together? ๐Ÿ’–

macros are evaluated at configuration stage, which means that

postfwd3 --showconfig \
        -r "&&RBLS { rbl=bl.spamcop.net; client_name=^unknown$; };" \
        -r "id=RBL001; &&RBLS; action=REJECT listed on spamcop and bad rdns";

will result in:

Rule   0: id->"RBL001"; action->"REJECT listed on spamcop and bad rdns"; rbl->"bl.spamcop.net"; client_name->"^unknown$"

the macro got unrolled before anything ran. lowkey magic. โœจ


Request processing

when a policy delegation request arrives it gets compared against postfwd's ruleset. to inspect the processing in detail you should increase verbosity using the -v or -vv switch. -L redirects log messages to stdout. ๐Ÿ”

keeping the order of the ruleset in general, items will be compared in random order, which basically means that

id=R001; action=dunno; client_address=192.168.1.1; sender=bob@alice.local

equals to

id=R001; sender=bob@alice.local; client_address=192.168.1.1; action=dunno

lists however will be evaluated in the specified order โ€” which is your little hack to put the faster expressions first:

postfwd3 -vv --cmd -r "id=RBL001; rbl=localrbl.local zen.spamhaus.org; action=REJECT" /some/where/request.sample

produces the following

[LOGS info]: compare rbl: "remotehost.remote.net[68.10.1.7]"  ->  "localrbl.local"
[LOGS info]: count1 rbl:  "2"  ->  "0"
[LOGS info]: query rbl:   localrbl.local 7.1.10.68 (7.1.10.68.localrbl.local)
[LOGS info]: count2 rbl:  "2"  ->  "0"
[LOGS info]: match rbl:   FALSE
[LOGS info]: compare rbl: "remotehost.remote.net[68.10.1.7]"  ->  "zen.spamhaus.org"
[LOGS info]: count1 rbl:  "2"  ->  "0"
[LOGS info]: query rbl:   zen.spamhaus.org 7.1.10.68 (7.1.10.68.zen.spamhaus.org)
[LOGS info]: count2 rbl:  "2"  ->  "0"
[LOGS info]: match rbl:   FALSE
[LOGS info]: Action: dunno

the negation operator !!(<value>) has the highest priority and therefore gets evaluated first. then variable substitutions are performed:

postfwd3 -vv --cmd -r "id=TEST; action=REJECT; client_name=!!($$heloname)" /some/where/request.sample

will give

[LOGS info]: compare client_name:     "unknown"  ->  "!!($$helo_name)"
[LOGS info]: negate client_name:      "unknown"  ->  "$$helo_name"
[LOGS info]: substitute client_name:  "unknown"  ->  "english-breakfast.cloud8.net"
[LOGS info]: match client_name:  TRUE
[LOGS info]: Action: REJECT

๐Ÿง  Ruleset evaluation ๐Ÿง 

So here's the tea, bestie: a rule hits when all its items have matched โ€” and for a list, it counts as matched as soon as at least one element of that list matched. But the instant one item fails to compare against the request attribute (or all the elements of a list fail), the parser is like "k bye" and jumps straight to the next rule in the postfwd3 ruleset. No partial credit, no half-sends. ๐Ÿ’…

Now, if a rule does match, there are exactly two possibilities:

  • Rule returns a postfix action (dunno, reject, ...) โ€” the parser stops rule processing and hands the action back to postfix. The other rules? They don't get evaluated. Periodt.
  • Rule returns a postfwd3 action (jump(), note(), ...) โ€” the parser runs that action and then just keeps going with the next rule. Exception alert ๐Ÿšจ: this does not apply to the jump() or quit() actions โ€” go read the "ACTIONS" section for the whole story on those. Nothing gets sent to postfix from this path.

And if no rule matched and you've hit the end of the ruleset, postfwd3 returns dunno without logging a single thing โ€” unless you're in verbose mode. Want different behaviour? Drop a last catch-all rule like this:

... <your rules> ...
id=DEFAULT ;  action=dunno

That bad boy will log any request that passes through the whole ruleset without having hit a prior rule. So you'll actually know when something slips through the cracks ๐Ÿซถ


โš™๏ธ SETTINGS โš™๏ธ

Since version postfwd3 2.00-pre6, the program settings can be pulled from a file, and that info has to be parseable by the perl eval() function. Comments are totally allowed, queen:

# example settings for postfwd3
{
   # enable verbose logging
   verbose => 1,

   # user and group for postfwd
   base => {
       group => 'postfw',
       user => 'postfw'
   },

   # server settings
   server => {
       proto => 'tcp',
       host => '127.0.0.1',
       port => '10099'
   }
}

postfwd3 can also export the program settings in that same format using Data::Dumper. Iconic.

# show configuration
postfwd3 --showsettings

# save configuration to file
postfwd3 --savesettings=/path/to/file

# read configuration from file
postfwd3 --loadsettings=/path/to/file

You can use --loadsettings (or its short form -F) multiple times โ€” multiple usage is allowed, and the order will be kept. And any commandline arguments override whatever got pulled from a file. Slay.

# load base settings, override with node settings and set port and verbose logging
postfwd3 -v -p 10050 -F /path/to/basefile -F /path/to/nodefile

Samples for settings files are distributed in the etc/-folder of the postfwd3 tarball. Go look, it's all there ๐ŸŽ€


๐Ÿ› DEBUGGING ๐Ÿ›

Wanna debug specific steps of the parser? The --debug switch takes a list of debug classes. And since postfwd3 2.00, you can just ask for the whole menu of available debug classes by running:

postfwd3 --debugclasses

No guessing required ๐Ÿ’–


๐Ÿ”Œ INTEGRATION ๐Ÿ”Œ

Integration via daemon mode

The common way to use postfwd3 is to start it as a daemon, listening at a specified tcp port. postfwd3 will spawn multiple child processes, and those communicate with a parent cache. This is the prefered way to use postfwd3 in high volume environments. Start postfwd3 with these parameters:

postfwd3 -d -f /etc/postfwd.cf -i 127.0.0.1 -p 10045 -u nobody -g nobody -S

For efficient caching you should check if you can use the options --cacheid, --cache-rdomain-only, --cache-no-sender and --cache-no-size. ๐Ÿ‘‘

Now check your syslogs (default facility "mail") for a line like:

Aug  9 23:00:24 mail postfwd[5158]: postfwd3 n.nn ready for input

...and use netstat -an|grep 10045 to check for something like

tcp  0  0  127.0.0.1:10045  0.0.0.0:*  LISTEN

If everything works, open your postfix main.cf and insert the following:

127.0.0.1:10045_time_limit      = 3600                                          <--- integration
smtpd_recipient_restrictions    = permit_mynetworks                             <--- recommended
                                  reject_unauth_destination                     <--- recommended
                                  check_policy_service inet:127.0.0.1:10045     <--- integration

Reload your configuration with postfix reload and watch your logs. If it works, you should see lines like the following in your mail log:

Aug  9 23:01:24 mail postfwd[5158]: rule=22, id=ML_POSTFIX, client=english-breakfast.cloud9.net[168.100.1.7], sender=owner-postfix-users@postfix.tld, recipient=someone@domain.local, helo=english-breakfast.cloud9.net, proto=ESMTP, state=RCPT, action=dunno

If you want to check for size or rcpt_count items you must integrate postfwd3 in smtp_data_restrictions or smtpd_end_of_data_restrictions. And of course you can also specify a restriction class and use it in your access tables. First create a file /etc/postfix/policy containing:

domain1.local           postfwdcheck
domain2.local           postfwdcheck
...

Then postmap that file (postmap hash:/etc/postfix/policy), open your main.cf and enter:

# Restriction Classes
smtpd_restriction_classes       = postfwdcheck, <some more>...                          <--- integration
postfwdcheck                    = check_policy_service inet:127.0.0.1:10045             <--- integration

127.0.0.1:10045_time_limit      = 3600                                                  <--- integration
smtpd_recipient_restrictions    = permit_mynetworks,                                    <--- recommended
                                  reject_unauth_destination,                            <--- recommended
                                  ...                                                   <--- optional
                                  check_recipient_access hash:/etc/postfix/policy,      <--- integration
                                  ...                                                   <--- optional

Reload postfix and watch your logs. That's it, you're live ๐Ÿฆ‹

Integration via docker

postfwd can be run in a docker container. The relevant options are --nodaemon and --stdout. More information can be found in the included doc/docker.html or at <http://postfwd.org/docker>. ๐Ÿณ


๐Ÿงช TESTING ๐Ÿงช

First you have to create a ruleset (see the Configuration section). Check it with

postfwd3 -f /etc/postfwd.cf -C

There's an example policy request distributed with postfwd, called 'request.sample'. Simply change it to meet your requirements and use

postfwd3 --cmd -f /etc/postfwd.cf request.sample

You should get an answer like

action=<whateveryouconfigured>

For network tests I use netcat:

nc 127.0.0.1 10045 <request.sample

...to send a request to postfwd. If you receive nothing, make sure that postfwd3 is actually running and listening on the specified network settings. Vibe check your daemon first, bestie ๐Ÿ˜ญ


๐Ÿš€ PERFORMANCE ๐Ÿš€

Some of these proposals might not match your environment. Please check your requirements and test new options carefully!

  • use caching options
  • use the correct match operator ==, <=, >=
  • use ^ and/or $ in regular expressions
  • use item lists (faster than single rules)
  • use set() action on repeated item lists
  • use jumps and rate limits
  • use a pre-lookup rule for rbl/rhsbls with empty note() action

๐Ÿ‘€ SEE ALSO ๐Ÿ‘€

See <http://www.postfix.org/SMTPD_POLICY_README.html> for a description of how Postfix policy servers work. ๐Ÿ“–


๐Ÿ’– DONATIONS ๐Ÿ’–

Development, testing and hosting of postfwd consumes time and ressources. It is and will stay free software (see "LICENSE" below). If you want to support this, consider a donation at <https://www.paypal.me/postfwd>. Every little bit is appreciated fr fr ๐Ÿซถ


๐Ÿ“œ LICENSE ๐Ÿ“œ

postfwd3 is free software and released under BSD license, which basically means that you can do what you want as long as you keep the copyright notice:

Copyright (c) 2009, Jan Peter Kessler All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

  • Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
  • Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
  • Neither the name of the authors nor the names of his contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY ME ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


โœ๏ธ AUTHOR โœ๏ธ

Jan Peter Kessler <info (AT) postfwd (DOT) org>. Let me know, if you have any suggestions. ๐Ÿ’Œ


And that's the whole page, bestie โ€” we did it ๐ŸŽ‰ This lil masterpiece is from perl v5.30.3, dated 2020-07-30, and it's POSTFWD3(1). Thanks for reading all the way through, you absolute icon ๐Ÿ’…โœจ

๐Ÿ“ฆ where this page comes from