#!/usr/bin/perl
#
# Construct an iptables or nft rules file from fragments.
#
# Given a directory full of iptables or nft configuration fragments,
# this script adds a standard prefix and suffix to build a complete
# set of rules.  Shell commands can be run before and after the
# rules file is generated.
#
# Written by Russ Allbery <rra@stanford.edu>
# Adapted by Digant C Kasundra <digant@stanford.edu>
# Updated by Bill MacAllister <whm@dropbox.com>
# Adapted to support NFT Updated by Bill MacAllister <bill@ca-zephyr.org>
# Copyright 2005, 2006, 2013
#     The Board of Trustees of the Leland Stanford Junior University
# Copyright 2015, 2022
#     Dropbox
# Copyright 2026
#     CZ Software
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

##############################################################################
# Modules and declarations
##############################################################################

use 5.008;
use strict;
use warnings;

use AppConfig      qw(:argcount :expand);
use File::Basename qw(basename);
use File::Copy;
use Getopt::Long::Descriptive qw(describe_options);
use IO::Handle;

my $opt;
my $usage;

my $CONF;

##############################################################################
# filter manipulation
##############################################################################

# $file - Full path of file to read
#
# Returns: Text of the file as an array of lines or a string
#  Throws: Text exception on failure to read from the file
sub read_fragment {
    my ($file) = @_;
    my @data;
    dbg("Opening $file for input");
    open(my $fragment, '<', $file) or die "$0: cannot open $file: $!\n";
    while (!eof($fragment)) {
        my $line;
        if (!defined($line = <$fragment>)) {
            die "$0: cannot read from $file: $!\n";
        }
        push(@data, $line);
    }
    close($fragment) or die "$0: cannot close $file: $!\n";
    return wantarray ? @data : join(q{}, @data);
}

# Build the filter rules for this system.  Reads fragments from the
# source directory, prepends the prefix and appends the suffix, and
# returns the result as either an array of lines or a string.
#
# Returns: Text of the file as an array of lines or a string
#  Throws: Text exception on failure to read from the file
sub build_filters {
    my @rules;

    # Add the prefix and a blank line.
    for my $l (@{ $CONF->prefix }) {
        push(@rules, $l, "\n");
    }
    push(@rules, "\n");

    # Build the list of fragment paths, which is every file in source
    # directory that does not begin with a period.
    my @fragments;
    for my $src_dir (@{ $CONF->rules_source }) {
        if (-d $src_dir) {
            opendir(my $this_dir, $src_dir)
              or die "$0: cannot open $src_dir: $!\n";
            my @modules = grep { !m{ \A [.] }xms } sort readdir($this_dir);
            closedir($this_dir)
              or die "$0: cannot read from $src_dir: $!\n";
            my @frags = map { $src_dir . $_ } @modules;
            push @fragments, @frags;
        }
    }
    if (!@fragments) {
        return;
    }

    # Append the fragment contents with a blank line between them.
    for my $fragment (@fragments) {
        push(@rules, read_fragment($fragment), "\n");
    }

    # Don't change anything if no rules have been defined.
    if (scalar(@rules) == 0) {
        die "$0: No rules found.  No updates performed.\n";
    }

    # Append the suffix and return the results.
    for my $l (@{ $CONF->suffix }) {
        push(@rules, $l, "\n");
    }
    return wantarray ? @rules : join(q{}, @rules);
}

# Write lines to a file atomically, using a separate file and then atomically
# replacing the file.  Uses $file with ".new" appended as the temporary file.
#
# $file - Output file name
# @data - List of chunks of data to put into the file
#
# Returns: undef
#  Throws: Text exception on failure to write to or rename the file
sub write_file {
    my ($file, @data) = @_;
    dbg("Opening $file for output");
    open(my $new, '>', "${file}.new")
      or die "$0: cannot create ${file}.new: $!\n";
    print {$new} @data
      or die "$0: cannot write to ${file}.new: $!\n";
    close($new)
      or die "$0: cannot flush ${file}.new: $!\n";
    dbg("Renaming $file.new to $file");
    move("$file.new", $file)
      or die "$0: cannot install new $file: $!\n";
    return;
}

# Given the array of new filter data, install a new filter
# configuration and load it into the kernel.
#
# @rules - New filter data
#
# Returns: undef
#  Throws: Text exception on failure to write new data or reload it
#          Text exception on failure to persist rules
sub install_filters {
    my @rules = @_;

    write_file($CONF->rules_file, @rules);
    if ($CONF->restore_script) {
        system($CONF->restore_script . ' < ' . $CONF->rules_file) == 0
          or die "$0: restore_script = " . $CONF->restore_script . "\n";
    }
    if ($CONF->persist_cmd) {
        system($CONF->persist_cmd) == 0
          or die "$0: persist_cmd = " . $CONF->persist_cmd . "\n";
    }
    return;
}

##############################################################################
# Utility routines
##############################################################################

# Debugging displays
sub dbg {
    my $txt = @_;
    if ($opt->debug) {
        prt("DEBUG: $txt\n");
    }
    return;
}

# Print to STDOUT and trap an errors
sub prt {
    my ($t) = @_;
    print {*STDOUT} $t
      or die "$0: cannot write to standard output: $!\n";
    return;
}

sub read_config {
    my ($conf_file) = @_;

    my @rules_source_default = ('/etc/nftables.d/');

    my @prefix_default = (
        '#!/usr/sbin/nft -f',
        '# Clean out old rules',
        'flush ruleset',
        '# Prefix to rules',
        'add table ip filter',
'add chain ip filter INPUT { type filter hook input priority 0; policy drop; }',
'add chain ip filter FORWARD { type filter hook forward priority 0; policy accept; }',
'add chain ip filter OUTPUT { type filter hook output priority 0; policy accept; }',
        'add rule ip filter INPUT iifname "lo" counter accept',
        'add rule ip filter INPUT ct state related,established counter accept',
    );

    my @suffix_default = (
        '# Suffix to rules',
        'add rule ip filter INPUT icmp type echo-request counter accept',
        'add rule ip filter INPUT icmp type echo-reply counter accept',
'add rule ip filter INPUT ip protocol tcp counter reject with tcp reset',
        'add rule ip filter INPUT ip protocol udp counter reject',
'add rule ip filter INPUT counter reject with icmp type prot-unreachable',
    );

    $CONF = AppConfig->new({});
    $CONF->define(
        'rules_file',
        {
            ARGCOUNT => ARGCOUNT_ONE,
            DEFAULT  => '/etc/iptables.rules'
        }
    );
    $CONF->define('rules_source',   { ARGCOUNT => ARGCOUNT_LIST });
    $CONF->define('prefix',         { ARGCOUNT => ARGCOUNT_LIST });
    $CONF->define('suffix',         { ARGCOUNT => ARGCOUNT_LIST });
    $CONF->define('restore_script', { ARGCOUNT => ARGCOUNT_ONE });
    $CONF->define(
        'persist_cmd',
        {
            ARGCOUNT => ARGCOUNT_ONE,
            DEFAULT  => '/usr/bin/systemctl reload nftables'
        }
    );

    # Read values from the file if there is one.
    if (-e $conf_file) {
        $CONF->file($conf_file);
    }

    # Defaults there no values were read from the file
    if (scalar(@{ $CONF->rules_source }) == 0) {
        push(@{ $CONF->rules_source }, @rules_source_default);
    }
    if (scalar(@{ $CONF->prefix }) == 0) {
        push(@{ $CONF->prefix }, @prefix_default);
    }
    if (scalar(@{ $CONF->suffix }) == 0) {
        push(@{ $CONF->suffix }, @suffix_default);
    }

    return;
}

# Read the configuration file
sub print_example {

    prt("# File: /etc/rebuild-filters.conf\n");
    prt("# Configuration file for rebuild-filters script\n");
    prt("#\n");
    prt("# The output file for the assembled rules\n");
    prt("rules_file = " . $CONF->rules_file . "\n");

    prt("#\n");
    prt("# The scripts to use after the rules file is generated.\n");
    prt("#\n");
    prt("# The restore script is run before the persist script and\n");
    prt("# and the rules file is feed into the script using the shell\n");
    if ($CONF->restore_script) {
        prt("restore_script = " . $CONF->restore_script . "\n");
    } else {
        prt("# restore_script = \n");
    }
    prt("#\n");
    prt("# The persis script is run without adding the rules script to \n");
    prt("# to the command line.\n");
    prt("persist_cmd = " . $CONF->persist_cmd . "\n");
    prt("#\n");
    prt("# The directories holding rules fragments\n");

    for my $d (@{ $CONF->rules_source }) {
        prt("rules_source = $d\n");
    }

    prt("#\n");
    prt("# Prefix written to the beginning assembled rules file\n");

    for my $txt (@{ $CONF->prefix }) {
        prt("prefix = $txt\n");
    }

    prt("#\n");
    prt("# Suffix Prefix written to the end of the assembled rules file\n");

    for my $txt (@{ $CONF->prefix }) {
        prt("suffix = $txt\n");
    }

    return;
}

##############################################################################
# Main routine
##############################################################################

# Always flush output.
STDOUT->autoflush;

# Clean up the script name for error reporting.
my $fullpath = $0;
local $0 = basename($0);

# Parse the argument list.
($opt, $usage) = describe_options(
    '%c %o',
    ['config|c=s',   'The name of the configuration file'],
    ['debug|d',      'Debugging displays'],
    ['example|e',    'Print an example configuration file'],
    ['help|h',       'Print usage message and exit'],
    ['manual|man|m', 'Print full manual and exit'],
    ['print|p',      'Print out the generated rules without updating them'],
);
if ($opt->help || ($ARGV[0] && $ARGV[0] =~ /^help/i)) {
    print {*STDOUT} 'Usage: ' . $usage->text
      or die "$0: cannot write to standard output: $!\n";
    exit(0);
} elsif ($opt->manual || ($ARGV[0] && $ARGV[0] =~ /^man/i)) {
    print {*STDOUT} "Feeding myself to perldoc, please wait...\n"
      or die "$0: cannot write to standard output: $!\n";
    exec('perldoc', '-t', $fullpath);
}

# Set the configuration file.
my $conf_file = '/etc/rebuild-filters.conf';
if ($opt->config) {
    $conf_file = $opt->config;
}

# Read the configuration
dbg("Reading configuration file $conf_file");
read_config($conf_file);

# Print an example configuration file and exit
if ($opt->example) {
    print_example();
    exit(0);
}

# Build the filter rules for this host.
my @rules = build_filters();
# If told to just print out the results, do so.  Otherwise,
# install the new rules.
if ($opt->print) {
    print {*STDOUT} @rules
      or die "$0: cannot write to standard output: $!\n";
} else {
    install_filters(@rules);
}

exit(0);

__END__

##############################################################################
# Documentation
##############################################################################

=for stopwords
Bill MacAllister Digant Kasundra iptables nft rebuild-filters Allbery TCP
UDP -hmp ifup loopback startup

=head1 NAME

rebuild-filters - Install an iptables or nft rules file from fragments

=head1 SYNOPSIS

rebuild-filters [B<-ehmp>] [B<-c config.file>]

=head1 DESCRIPTION

B<rebuild-filters> constructs an iptables or nft configuration file by
concatenating various file fragments found in the rules directory.
The resulting configuration file is written and can be loaded into the
kernel.

Each fragment just a text file located in the configuration directory
that contains one or more rule lines (basically the arguments to an
B<iptables> or B<nft> invocation), blank lines, or comments (lines
starting with C<#>).  Comments and blank lines will be ignored by the
system.

Along with the fragments in the directory specified, a standard prefix
and suffix will be added automatically.  Default values for both
prefix and suffix can be examined using the --example switch.  The
values can be overridded in the configuration if destired.

If the configuration file is specified on the command line with the
B<--config> switch that specifies which configuration file is used.
If no configuration file is specified the script scans the fragments
directory for files of the form rebuild*.conf and processes all that
are found.  If there are no configuration files found in the fragments
directory then the script attempts to open the
/etc/rebuild-filters.conf file.

=head1 OPTIONS

=over 4

=item B<-c>, B<--config> = configuration-file

The path to the configuration file.  The default is
/etc/rebuild-filters.conf.

=item B<-e>, B<--example>

Print an example configration file and exit.  The example
configuration file documents the scripts defaults.

=item B<-h>, B<--help>

Print a short usage message and exit.

=item B<-m>, B<--manual>, B<--man>

Display this manual and exit.

=item B<-p>, B<--print>

Rather than installing the new rules and loading them into the kernel,
just print the combined rules to standard output and exit.  This will
include all of the comments and blank lines that would be in the rule
set as stored on disk.

=back

=head1 EXAMPLES

=head2 IP Tables Cnfiguration Example

    # Configuration file for rebuild-iptables script
    #
    # The output file for the assembled rules
    rules_file = /etc/iptables/rules
    #
    # The scripts to use to load and persist the assembled rules
    restore_script = /sbin/iptables-restore
    persist_cmd = /usr/sbin/service iptables-persistent save
    #
    # The directories holding rules fragments
    rules_source = /etc/iptables/rules.d/
    rules_source = /etc/iptables/rules_local.d/
    #
    # Prefix written to the beginning assembled rules file
    prefix = *filter
    prefix = :INPUT DROP [0:0]
    prefix = :FORWARD ACCEPT [0:0]
    prefix = :OUTPUT ACCEPT [0:0]
    prefix = -A INPUT -i lo -j ACCEPT
    prefix = -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
    #
    # Suffix Prefix written to the end of the assembled rules file
    suffix = -A INPUT -p icmp -m icmp --icmp-type 8 -j ACCEPT
    suffix = -A INPUT -p icmp -m icmp --icmp-type 0 -j ACCEPT
    suffix = -A INPUT -p tcp -j REJECT --reject-with tcp-reset
    suffix = -A INPUT -p udp -j REJECT --reject-with icmp-port-unreachable
    suffix = -A INPUT -j REJECT --reject-with icmp-proto-unreachable
    suffix = COMMIT

=head1 AUTHOR

Russ Allbery <rra@stanford.edu>,
Digant C Kasundra <digant@stanford.edu>, and
Bill MacAllister <bill@ca-zephyr.org>

=cut
