使用 Perl 生成防火墙规则

作者:Mike Diehl

像大多数 Linux 用户一样,我一开始使用一个简单的 Bash 脚本来配置我的 Linux 机器上的防火墙策略。最终,我厌倦了一遍又一遍地编写相同的代码,所以我决定使用一些程序循环来尝试分解一些冗余。我还决定将实际策略与程序的其余部分分开;这意味着程序将读取外部配置文件。由于我的 Perl 技能远胜于我的 Bash 技能,所以我决定用 Perl 编写我的防火墙规则。

我在本文中概述的程序也可以很容易地用 Bash 或另一种脚本语言(甚至 C++,就此而言)编写。语言并不重要。重要的是要意识到,一旦您编写并调试了这样的程序,您所要做的就是更改配置文件并重新运行脚本来修改安全规则。配置文件应该具有直观的格式,以便于人类阅读、理解和修改。

代码清单 1 显示了 Perl 脚本。我 практику上自顶向下的编程,所以前几行代码应该让您很好地了解程序的功能。希望即使您不是 Perl 程序员,您也应该能够理解程序的功能。

代码清单 1. firewall.pl

#!/usr/bin/perl

$default_policy = "DROP";

$iptables = "/sbin/iptables";
$work_dir = "/root/fw";

set_ip_forwarding(0);

load_interfaces();

$protocols{tcp}++; $protocols{udp}++; $protocols{icmp}++;

init();

set_default_policy();

add_good_hosts();
add_bad_hosts();

build_chains();
add_rules();

set_default_action();

set_ip_forwarding(1);

exit;

###################################################

sub    load_interfaces {
    my($int, $name);
    local(*FILE);

    open FILE, "$work_dir/interfaces.conf";
    while (<FILE>) {
        chomp($_);
        if ($_ eq "") { next; }

        ($name, $int) = split(/\s*=\s*/, $_);
        $interface{$name} = $int;
    }
}

sub    init {
    iptables("-F");  # flush rules
    iptables("-t nat -F");
    iptables("-X");  # delete chains
    iptables("-Z");  # zero counters

    iptables("-t nat -A POSTROUTING -j MASQUERADE");
    iptables("-A INPUT -m conntrack --ctstate ESTABLISHED
        -j ACCEPT");
}

sub    set_default_policy {
    iptables("-P INPUT $default_policy");

    iptables("-P OUTPUT ACCEPT");
    iptables("-P FORWARD ACCEPT");

    return;
}

sub    build_chains {
    my($interface, $protocol, $chain);

    foreach $interface (keys %interface) {
        foreach $protocol (keys %protocols) {
            $chain = "$interface-$protocol";

            iptables("-N $chain");
            iptables("-A INPUT -i $interface{$interface}
                -p $protocol -j $chain");
        }
    }
}

sub    add_rules {
    local(*FILE);

    open FILE, "$work_dir/ports.conf";
    while (<FILE>) {
        chomp($_);
        $_ =~ s/#.?//;
        if ($_ eq "") { next; }

        ($int, $proto, $port) = split(/\t/, $_);

        $i = $interface{$int};
        $chain = "$int-$proto";

        if ($proto eq "all") {
            foreach $proto (keys %protocols) {
                $chain = "$int-$proto";
                iptables("-A $chain -i $i -p $proto -j ACCEPT");
            }
            next;
        }

        if ($proto eq "udp") {
            iptables("-A $chain -i $i -p udp --dport $port
                -j ACCEPT");
            iptables("-A $chain -i $i -p udp --sport $port
                -j ACCEPT");
        }

        if ($proto eq "tcp") {
            iptables("-A $chain -i $i -p tcp --dport $port --syn
                -j ACCEPT");
            iptables("-A $chain -i $i -p tcp --dport $port
                -j ACCEPT");
        }
    }
}

sub    set_default_action {
    my($interface, $protocol, $chain);

    foreach $interface (keys %interface) {
        foreach $protocol (keys %protocols) {
            $chain = "$interface-$protocol";
            iptables("-A $chain -j LOG
                --log-prefix DEFAULT_$default_policy-$chain-");
            iptables("-A $chain -j $default_policy");
        }
    }
}

sub    iptables {
    my($line) = @_;
    print "$iptables $line > /dev/null\n" if ($debug);
    $result = system("$iptables $line > /dev/null");
    if ($result != 0) {
        print "X: ($result) iptables $line\n";
    }
}

sub    set_ip_forwarding {
    my($value) = @_;
    local(*FILE);

    print "Setting IP forwarding to $value.\n";
    open FILE, ">/proc/sys/net/ipv4/ip_forward";
    print FILE $value;
    close FILE;
}

sub    add_good_hosts {
    my($host, $comment);
    local(*FILE);

    open FILE, "$work_dir/good_hosts.conf";
    while (<FILE>) {
        ($host, $comment) = split(/\t/, $_);

        iptables("-A INPUT -s $host -j ACCEPT");
        iptables("-A OUTPUT -d $host -j ACCEPT");
    }

}

sub    add_bad_hosts {
    my($host, $comment);
    local(*FILE);

    open FILE, "$work_dir/bad_hosts.conf";
    while (<FILE>) {
        chomp($_);
        ($host, $comment) = split(/\t/, $_);

        iptables("-A INPUT -s $host -j LOG
             --log-prefix $comment");
        iptables("-A OUTPUT -d $host -j LOG
             --log-prefix $comment");

        iptables("-A INPUT -s $host -j DROP");
        iptables("-A OUTPUT -d $host -j DROP");
    }
}

正如您所见,该程序不是很长,当然也不是很复杂。但是,该程序足够灵活,允许我完全将单个机器或整个网络列入白名单或黑名单。正如我们稍后将看到的,build_chains() 和 add_rules() 函数实现了一种规则修剪算法,该算法可防止 Linux 内核不得不评估不相关的规则。

set_ip_forwarding() 函数的功能正如其名称所暗示的那样;它告诉 Linux 内核转发 IP 数据包或不转发数据包。该函数接受一个参数,0 或 1,它决定内核是否转发。脚本最初关闭所有转发,同时加载防火墙策略。然后,在脚本退出之前,脚本重新打开转发。这些额外步骤的原因是我们希望路由器在加载实际规则时处于安全状态。最好阻止所有流量,而不是允许哪怕一次攻击通过。

load interfaces() 函数读取网络接口的名称并将助记标签分配给它们。然后,这些标签在配置的其余部分中用于引用实际接口。能够将接口称为 lan 甚至 vpn_to_work 可以减少错误配置。这也使得为了适应我的朋友而轻松进行更改。在许多情况下,我只需调整 interfaces.conf 文件以反映我朋友的网络,突然,我的朋友就拥有了合理的防火墙配置。

该脚本通过四个配置文件工作:interfaces.conf、good_hosts.conf、bad_hosts.conf 和 ports.conf。

代码清单 2 显示了我的 interfaces.conf 文件的内容。正如您所见,我的路由器中有六个网络接口。我的互联网连接在 eth5 上。我有一个用于房屋布线的 10/100TX 以太网。我有一个千兆以太网将我的 MythTV PVR 连接到路由器以进行文件存储。我有用于 Wi-Fi 和 VoIP 的接口。最后,我有一个到我一些朋友的计算机的 VPN 连接。记住 lan 是我的 10/100 铜缆网络比尝试记住 eth3 是我的 VoIP 还是 Wi-Fi 接口容易得多。您最不想做的事情是将正确的防火墙规则应用于错误的接口。

代码清单 2. interfaces.conf

lo = lo
gig = eth0
lan = eth1
wifi = eth2
voip = eth3
wan = eth5
tun = tun0

init() 函数执行 iptables 环境的初始设置。首先,我们刷新或删除所有规则和用户链。然后,我们清除所有规则计数器。稍后,这些计数器将使我们能够确定防火墙中每个规则捕获了多少数据包。然后,脚本设置 IP 伪装。我还放入了一条规则,允许与已建立的连接相关的流量通过防火墙,而无需进一步评估。这使得整个规则集不必为每个传入的数据包进行评估。这些规则仅适用于每个新的连接请求。

set_default_policy() 函数配置我们在没有任何其他防火墙规则的情况下如何处理网络流量。在这种情况下,我只对管理传入流量感兴趣;该策略接受传出流量和内核必须路由到其最终目的地的流量。对此脚本进行简单的修改将允许您配置每个方向的策略。默认情况下,我的脚本拒绝流量,并要求管理员显式列出所有允许的流量。这是构建防火墙最安全的方式,而不是默认允许流量并依赖管理员专门拒绝危险流量的防火墙。您永远无法预先知道所有危险流量,因此拒绝除充分理解的流量之外的所有流量是一个好主意。

add_good_hosts() 函数创建防火墙规则,允许来自 good_hosts.conf 文件中列出的主机或网络的所有流量。请注意,我没有将这些规则与任何特定接口绑定。来自白名单主机或网络的流量可以从任何接口进入。我通常在此文件中为我在家庭办公室的工作站以及工作网络放置一个条目。这样,即使我犯了一个愚蠢的错误,导致我无法远程登录到我的路由器,我仍然可以从工作场所或我的办公室工作站登录以撤消更改。当然,这也假设我的工作站和工作网络没有被入侵。通常,此文件的内容确实非常简短。

代码清单 3. good_hosts.conf

127.0.0.1    Loopback
224.0.0.0/8    Multicast
10.4.0.0/16    VPN
10.0.1.1/32    Home office

相反,add_bad_hosts() 函数创建防火墙规则,阻止来自 bad_hosts.conf 文件中列出的主机或网络的所有流量。此函数的工作方式几乎与 add_good_hosts() 函数完全相同,但有一个重要的区别。当来自黑名单主机的流量到达路由器时,路由器不仅会记录此事实,还会在日志中包含来自 bad_hosts.conf 文件的注释。这样,我可以查看我的日志文件,了解特定主机被阻止的原因。对此函数的一个有用的改进是让它将坏主机规则放在一个单独的链中,并在规则集中尽早调用该链。这将使您能够方便地从外部程序从此链中添加和删除主机,可能作为对服务器日志文件中条目的响应。

代码清单 4. bad_hosts.conf

216.250.128.12  My_comment
www.microsoft.com       Microsoft

build_chains() 函数构建一系列防火墙规则链。我为接口和协议的每种组合构建一个单独的链。例如,如果我有一个带有 eth0、eth1、eth2 和 eth3 的 Linux 路由器,我将为 eth0-tcp、eth0-udp、eth1-tcp、eth1-udp 等创建链。然后,我构建必要的规则,以将决策过程发送到适当的链。我们最终得到的是一个决策树,它确定如何处理进入路由器的每个数据包。与线性的防火墙规则列表不同,决策树防止内核不得不评估明显不相关的规则。例如,WAN 接口上进入的 TCP 数据包永远不会针对 Wi-Fi 接口上 UDP 数据包的规则进行测试。

我没有做任何客观测试来查看这种树修剪是否真的能显着提高性能。另一方面,一旦程序编写和调试完成,更改配置文件并自动生成此决策树对我来说没有任何成本。因此,即使它仅将性能提高一小部分,它对程序的复杂性增加也很少,我认为这样做是有意义的。

add_rules() 函数是完成大部分工作的地方。此函数读取 ports.conf 的内容,如代码清单 5 所示。在我们详细讨论 add_rules() 函数之前,我们应该讨论 ports.conf 文件的格式和内容。

ports.conf 文件每条防火墙规则包含一行。每行包含三列和一个可选注释,注释前带有 # 字符。第一列是规则将应用到的接口的用户定义标签。第二列是协议,即 tcp、udp 或特殊情况 all。对协议使用 all 会创建一个规则,允许在相关接口上的所有流量。最后,我们有端口号。例如,文件的第一行创建一个规则,允许 SSH 流量进入 wan 接口。

代码清单 5. ports.conf

wan    tcp    22    # ssh
wan    tcp    25    # smtp
wan    tcp    80    # http
wan    udp    53    # dns
wan    udp    1194    # openvpn
wan    udp    5060    # sip
wan    udp    4569    # iax2
wan    udp    10000:20000    # rtp
lo    all
lan    tcp    22    # ssh
lan    tcp    25    # smtp
lan    udp    53    # dns
lan    tcp    53    # dns
lan    udp    67    # dhcp
lan    udp    68    # dhcp
lan    tcp    80    # http
lan    tcp    111    # portmapper
lan    udp    111    # portmapper
lan    tcp    143    # imap
lan    tcp    443    # https
lan    tcp    2049    # nfs
lan    udp    2049    # nfs
lan    tcp    3306    # mysql
lan    udp    4569    # iax2
lan    udp    5060    # sip
lan    tcp    5432    # postgresql
lan    tcp    10000    # webmin
lan    all
gig    all
tun    all
wifi    udp    1194    # openvpn
voip    udp    5060    # sip
voip    udp    4569    # iax2
voip    udp    53    # dns
voip    tcp    22    # ssh
voip    udp    10000:20000    # rtp
voip    tcp    80    # http

您会注意到我有一个规则允许 lo 或环回接口上的所有流量。这很重要,因为没有此规则,许多程序会以难以诊断的方式崩溃。您可能还会问,为什么我费力为我的 LAN 接口创建如此多的防火墙规则,却在最后使用 all。我这样做有几个原因。主要原因是,在我的孩子们长大并上网之前,我信任本地网络上的流量。但是,通过为我运行的每项服务设置规则,我能够提取有关每项服务生成多少流量的统计信息。此外,安全是一个迭代过程。随着时间的推移,我将添加规则,进一步加强我的防火墙;最终,我将从策略中删除最后的 all。

现在,回到 add_rules() 函数。即使这是整个程序中最长的函数,它仍然不太难理解。处理 tcp 和 udp 规则的代码部分只是为 ports.conf 文件中的每个规则创建两个规则。一个规则绑定到目标端口号;另一个规则绑定到源端口号。起初,这可能看起来很奇怪,因为我们仅管理入站流量。我们正在做的是确保允许入站和出站连接通过。例如,在 WAN 接口上进入且目标端口设置为 80 的数据包对应于到我的 Web 服务器的入站连接。另一方面,在我的 WAN 接口上进入且源端口设置为 80 的传入数据包来自外部 Web 服务器,以响应来自我的网络内部的请求。

处理 all 规则的代码是一种特殊情况。在这种情况下,我们在给定接口上为每个协议创建一个规则。事后看来,这可能过于复杂,但它有一个有趣的副作用。如果路由器遇到包含未知协议(例如 IPSec)的数据包,即使我们要求它传递此接口上的所有流量,防火墙也会退回到其默认策略。因此,从某种意义上说,“所有协议”实际上意味着“所有已知协议”。我认为这是一件好事。

就其价值而言,脚本会将防火墙规则以大致相同的顺序放入内核,就像它们出现在 ports.conf 文件中一样。我说大致,是因为规则将根据它们匹配的接口和协议放入适当的链中。但在每个链中,规则将按顺序执行。

set_default_action() 函数创建规则,确定不匹配任何先前规则的数据包会发生什么情况。这听起来与 set_default_policy() 函数的目的非常相似,但有一个细微的差别。set_default_policy() 函数配置默认防火墙策略,而 set_default_action() 函数创建防火墙规则,在内核退回到默认策略之前捕获不匹配的流量,本质上是限制每个链。一旦这些规则匹配数据包,它们就会为数据包创建一个日志条目,然后它们会实施我们想要的任何策略,在本例中为 DROP。再次,日志条目使我能够确定正在丢弃哪些流量以及原因。

我并不是想告诉您此程序是完美的,也不是它会完成您想要它完成的一切。您甚至可能会在其中发现错误。事实上,当您阅读本文时,我可能已经对脚本进行了一些改进。就目前而言,该脚本无法为 ICMP 协议配置任何防火墙策略。例如,如果能够允许出站 ping 请求并拒绝入站请求,那将是很好的。如果能够为传出和路由的流量配置防火墙策略也会很有用。并且因为我正在使用 VoIP,所以我正在考虑更改我的脚本以允许我配置服务质量 (QoS)。如果您对该脚本提出有用的修改,我很乐意听取您的意见。

但这就是它的全部,就其本身而言。在不到 200 行的 Perl 代码中,我能够实现相当灵活且高效的防火墙策略,其中可能包含数百个单独的规则。同时,对我的防火墙策略进行更改非常简单,即使是大多数 Linux 初学者也可以做出正确的更改。

Mike Diehl 在新墨西哥州阿尔伯克基的 Sandia 国家实验室为 SAIC 工作,他在那里编写网络管理软件。Mike 与他的妻子和两个年幼的儿子住在一起,可以通过电子邮件 mdiehl@diehlnet.com 与他联系。

加载 Disqus 评论