Blob Blame History Raw
#!/usr/bin/perl
# Script to test our nftables configuration files.
#
# Reads a tree of *.nft files from TEST_NFT_INDIR (default: ./salt/files/nftables),
# creates dummy interfaces to satisfy iif/oif, and executes a nftables config test.
# Set TEST_NFT_DEBUG=1 to enable verbose output.
#
# Careful, this will overwrite /etc/nftables.d and hence refuse to run outside
# a container unless TEST_NFT_REALLY is set.
# This is necessary as our nftables snippets tend to use absolute include paths.
#
# Recommended base container image (large sets might require privileged operation):
# registry.opensuse.org/opensuse/infrastructure/containers/heroes-salt-testing-nftables
#
# Copyright (C) 2024 Georg Pfuetzenreuter <mail+opensuse@georg-pfuetzenreuter.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

use v5.26;  # Leap 15.5

if (! ($ENV{container} || $ENV{TEST_NFT_REALLY})) {
  die "You probably want to run this inside a test container and not on your real system.\n";
}
if ( $> != 0 ) {
  die "Needs to run with root privileges.\n";
}

use Archive::Tar;
use File::Basename;
use File::Find::Rule;
use File::Copy::Recursive 'dircopy';
use File::Path 'rmtree';

my $debug = $ENV{TEST_NFT_DEBUG};
my $dumpif = $ENV{TEST_NFT_DUMP_INTERFACES};
my $dumpnft = $ENV{TEST_NFT_DUMP_NFT};
my $indir = $ENV{TEST_NFT_INDIR};
if (! $indir) {
  $indir = 'salt/files/nftables'
}
my $workdir = '/etc/nftables.d';

my @directories = File::Find::Rule->mindepth(1)->maxdepth(1)->directory->in( $indir );
my $exit = 0;

foreach (@directories) {
  my $tree = $_;
  my %interfaces;
  my %groups;
  my $treestatus = 0;
  print "Analyzing nftables tree \"$tree\" ...\n";
  rmtree($workdir);
  dircopy($tree, $workdir);
  my @files = File::Find::Rule->file()->name( '*.nft' )->in( $workdir );
  if (!@files) {
    print "Directory $tree does not contain any .nft files, skipping!\n";
    $exit = 1;
    next;
  }

  my $treebase = basename($tree);

  if ($dumpnft) {
    my $tar = Archive::Tar->new();
    $tar->add_files(@files);
    $tar->write("$treebase.tar.gz", 5);
  }

  foreach (@files) {
    my $file = $_;
    open(fh, '<', $file);
    my $next_line_interesting = 0;
    while (<fh>) {
      chomp;
      if ( $_ =~ /^\s*#/ ) {
        next;
      }
      if ( $_ =~ /[\s\t]+$/ ) {
        print "Trailing spaces or tabs in $file, line $..\n";
        $treestatus = 1;
        next;
      }
      my $interface;
      my $group;
      if ( $_ =~ /^\s*include "(.*)"$/ ) {
        my $include = $1;
        if ($debug) {
          print "Analyzing include $include ...\n";
        }
        if (index($include, '*') != -1) {
          if (! ( () = glob($include) ) ) {
            print "No files match include \"$include\" in $file.\n";
            $treestatus = 1;
          }
        } elsif (! -e $include) {
          print "Included file \"$include\" in $file does not exist!\n";
          $treestatus = 1;
        }
      } elsif ($next_line_interesting) {
        if ( $_ =~ /^\s*([\w-]+)\s+:/ ) {
          $interface = $1;
        } elsif ( $_ =~ /^\s*}/) {
          $next_line_interesting = 0;
          if ($debug) {
            print "Found end of vmap in $file\n";
          }
        }
      } elsif ( $_ =~ /([io]if)(?: !?=?)? (?!lo)([\w-]+)/ ) {
        $interface = $2;
        if ( $interface == 'vmap' ) {
          $next_line_interesting = 1;
          if ($debug) {
            print "Starting to analyze vmap in $file\n";
          }
          next;
        }
        # meta skgid != { foo, bar, ... }
        # meta skgid foo
      } elsif ( $_ =~ /meta skgid (?:!= )?(?:\{ )?((?:[\w]+(?:, )?)+)(?: \})? / ) {
        $group = $1;
      }

      if ($interface) {
        if ($debug) {
          print "Found interface $interface in $file\n";
        }
        $interfaces{$interface} = ();
      }

      if ($group) {
        if ($debug) {
          print "Found group(s) $group in $file\n";
        }
        if (index($group, ',') == -1) {
          $groups{$group} = ();
        } else {
          foreach my $group (split(', ', $group)) {
            $groups{$group} = ();
          }
        }
      }

    }
    close(fh);
  }

  foreach my $interface (keys %interfaces) {
    if ($debug) {
      print "Creating dummy interface: $interface\n";
    }
    system( ip => l => a => $interface => type => 'dummy' );
  }

  foreach my $group (keys %groups) {
    if ($debug) {
      print "Creating group: $group\n";
    }
    system( "getent group $group >/dev/null || groupadd $group" );
  }
  
  my $return = system( nft => -c => "flush ruleset ; include \"$workdir/*.nft\"" );
  my $msg = "nftables tree \"$tree\" is";
  if ($return == 0 && $treestatus == 0) {
    print "\e[32mPASSED\e[0m: $msg valid.\n";
  } else {
    print "\e[31mFAILED\e[0m: $msg is invalid.\n";
    $exit = 1;
  }

  my $ifout = $treebase . '.interfaces';
  if ($dumpif) {
    if ($debug) {
      print "Writing interfaces to $ifout\n";
    }
    open(IFH, '>', $ifout);
  }
  foreach my $interface (keys %interfaces) {
    if ($debug) {
      print "Deleting dummy interface: $interface\n";
    }
    system( ip => l => d => $interface );
    if ($dumpif) {
      print IFH "$interface\n";
    }
  }
  if ($dumpif) {
    close(IFH);
  }
}

exit $exit;