]> git.proxmox.com Git - pve-firewall.git/blobdiff - src/PVE/Firewall.pm
add multicast DNS to the list of Macros
[pve-firewall.git] / src / PVE / Firewall.pm
index 21237f87616fb866bc728552022e61d171930ba3..9aa9be177f928f04d9dacafdf93983a4af5c0cff 100644 (file)
@@ -12,6 +12,7 @@ use PVE::JSONSchema qw(register_standard_option get_standard_option);
 use PVE::Cluster;
 use PVE::ProcFSTools;
 use PVE::Tools qw($IPV4RE $IPV6RE);
+use PVE::Network;
 use File::Basename;
 use File::Path;
 use IO::File;
@@ -28,6 +29,7 @@ my $clusterfw_conf_filename = "$pvefw_conf_dir/cluster.fw";
 my $have_qemu_server;
 eval {
     require PVE::QemuServer;
+    require PVE::QemuConfig;
     $have_qemu_server = 1;
 };
 
@@ -48,7 +50,7 @@ our $ip_alias_pattern = '[A-Za-z][A-Za-z0-9\-\_]+';
 
 my $max_alias_name_length = 64;
 my $max_ipset_name_length = 64;
-my $max_group_name_length = 20;
+my $max_group_name_length = 18;
 
 PVE::JSONSchema::register_format('IPorCIDR', \&pve_verify_ip_or_cidr);
 sub pve_verify_ip_or_cidr {
@@ -138,10 +140,15 @@ my $pve_ipv6fw_macros = {
     ],
     'NeighborDiscovery' => [
        "IPv6 neighbor solicitation, neighbor and router advertisement",
+       { action => 'PARAM', proto => 'icmpv6', dport => 'router-solicitation' },
        { action => 'PARAM', proto => 'icmpv6', dport => 'router-advertisement' },
        { action => 'PARAM', proto => 'icmpv6', dport => 'neighbor-solicitation' },
        { action => 'PARAM', proto => 'icmpv6', dport => 'neighbor-advertisement' },
     ],
+    'DHCPv6' => [
+       "DHCPv6 traffic",
+       { action => 'PARAM', proto => 'udp', dport => '546:547', sport => '546:547' },
+    ],
     'Trcrt' => [
        { action => 'PARAM', proto => 'udp', dport => '33434:33524' },
        { action => 'PARAM', proto => 'icmpv6', dport => 'echo-request' },
@@ -315,6 +322,10 @@ my $pve_fw_macros = {
        { action => 'PARAM', proto => 'tcp', dport => '465' },
        { action => 'PARAM', proto => 'tcp', dport => '587' },
     ],
+    'MDNS' => [
+       "Multicast DNS",
+       { action => 'PARAM', proto => 'udp', dport => '5353' },
+    ],
     'Munin' => [
        "Munin networked resource monitoring traffic",
        { action => 'PARAM', proto => 'tcp', dport => '4949' },
@@ -510,10 +521,13 @@ my $pve_fw_macro_descr;
 my $pve_fw_macro_ipversion = {};
 my $pve_fw_preferred_macro_names = {};
 
+my $FWACCEPTMARK_ON  = "0x80000000/0x80000000";
+my $FWACCEPTMARK_OFF = "0x00000000/0x80000000";
+
 my $pve_std_chains = {};
 $pve_std_chains->{4} = {
     'PVEFW-SET-ACCEPT-MARK' => [
-       "-j MARK --set-mark 1",
+       "-j MARK --set-mark $FWACCEPTMARK_ON",
     ],
     'PVEFW-DropBroadcast' => [
        # same as shorewall 'Broadcast'
@@ -600,7 +614,7 @@ $pve_std_chains->{4} = {
 
 $pve_std_chains->{6} = {
     'PVEFW-SET-ACCEPT-MARK' => [
-        "-j MARK --set-mark 1",
+        "-j MARK --set-mark $FWACCEPTMARK_ON",
     ],
     'PVEFW-DropBroadcast' => [
         # same as shorewall 'Broadcast'
@@ -866,24 +880,6 @@ sub get_etc_protocols {
     return $etc_protocols;
 }
 
-my $ipv4_mask_hash_localnet = {
-    '255.255.0.0' => 16,
-    '255.255.128.0' => 17,
-    '255.255.192.0' => 18,
-    '255.255.224.0' => 19,
-    '255.255.240.0' => 20,
-    '255.255.248.0' => 21,
-    '255.255.252.0' => 22,
-    '255.255.254.0' => 23,
-    '255.255.255.0' => 24,
-    '255.255.255.128' => 25,
-    '255.255.255.192' => 26,
-    '255.255.255.224' => 27,
-    '255.255.255.240' => 28,
-    '255.255.255.248' => 29,
-    '255.255.255.252' => 30,
-};
-
 my $__local_network;
 
 sub local_network {
@@ -908,7 +904,7 @@ sub local_network {
            if ($isv6) {
                $mask = $entry->{prefix};
            } else {
-               $mask = $ipv4_mask_hash_localnet->{$entry->{mask}};
+               $mask = $PVE::Network::ipv4_mask_hash_localnet->{$entry->{mask}};
                next if !defined($mask);
            }
            my $cidr = "$entry->{dest}/$mask";
@@ -991,7 +987,7 @@ sub parse_address_list {
 }
 
 sub parse_port_name_number_or_range {
-    my ($str) = @_;
+    my ($str, $dport) = @_;
 
     my $services = PVE::Firewall::get_etc_services();
     my $count = 0;
@@ -1007,9 +1003,9 @@ sub parse_port_name_number_or_range {
            my $port = $1;
            die "invalid port '$port'\n" if $port > 65535;
        } else {
-           if ($icmp_type_names->{$item}) {
+           if ($dport && $icmp_type_names->{$item}) {
                $icmp_port = 1;
-           } elsif ($icmpv6_type_names->{$item}) {
+           } elsif ($dport && $icmpv6_type_names->{$item}) {
                $icmp_port = 1;
            } else {
                die "invalid port '$item'\n" if !$services->{byname}->{$item};
@@ -1022,11 +1018,20 @@ sub parse_port_name_number_or_range {
     return $count;
 }
 
-PVE::JSONSchema::register_format('pve-fw-port-spec', \&pve_fw_verify_port_spec);
-sub pve_fw_verify_port_spec {
+PVE::JSONSchema::register_format('pve-fw-sport-spec', \&pve_fw_verify_sport_spec);
+sub pve_fw_verify_sport_spec {
    my ($portstr) = @_;
 
-   parse_port_name_number_or_range($portstr);
+   parse_port_name_number_or_range($portstr, 0);
+
+   return $portstr;
+}
+
+PVE::JSONSchema::register_format('pve-fw-dport-spec', \&pve_fw_verify_dport_spec);
+sub pve_fw_verify_dport_spec {
+   my ($portstr) = @_;
+
+   parse_port_name_number_or_range($portstr, 1);
 
    return $portstr;
 }
@@ -1089,7 +1094,10 @@ sub copy_list_with_digest {
            next if !defined($v);
            $data->{$k} = $v;
            # Note: digest ignores refs ($rule->{errors})
-           $sha->add($k, ':', $v, "\n") if !ref($v); ;
+           # since Digest::SHA expects a series of bytes,
+           #  we have to encode the value here to prevent errors when
+           #  using utf8 characters (eg. in comments)
+           $sha->add($k, ':', encode_utf8($v), "\n") if !ref($v); ;
        }
        push @$res, $data;
     }
@@ -1103,6 +1111,130 @@ sub copy_list_with_digest {
     return wantarray ? ($res, $digest) : $res;
 }
 
+our $cluster_option_properties = {
+    enable => {
+       description => "Enable or disable the firewall cluster wide.",
+       type => 'integer',
+       minimum => 0,
+       optional => 1,
+    },
+    policy_in => {
+       description => "Input policy.",
+       type => 'string',
+       optional => 1,
+       enum => ['ACCEPT', 'REJECT', 'DROP'],
+    },
+    policy_out => {
+       description => "Output policy.",
+       type => 'string',
+       optional => 1,
+       enum => ['ACCEPT', 'REJECT', 'DROP'],
+    },
+};
+
+our $host_option_properties = {
+    enable => {
+       description => "Enable host firewall rules.",
+       type => 'boolean',
+       optional => 1,
+    },
+    log_level_in =>  get_standard_option('pve-fw-loglevel', {
+       description => "Log level for incoming traffic." }),
+    log_level_out =>  get_standard_option('pve-fw-loglevel', {
+       description => "Log level for outgoing traffic." }),
+    tcp_flags_log_level =>  get_standard_option('pve-fw-loglevel', {
+       description => "Log level for illegal tcp flags filter." }),
+    smurf_log_level =>  get_standard_option('pve-fw-loglevel', {
+       description => "Log level for SMURFS filter." }),
+    nosmurfs => {
+       description => "Enable SMURFS filter.",
+       type => 'boolean',
+       optional => 1,
+    },
+    tcpflags => {
+       description => "Filter illegal combinations of TCP flags.",
+       type => 'boolean',
+       optional => 1,
+    },
+    nf_conntrack_max => {
+       description => "Maximum number of tracked connections.",
+       type => 'integer',
+       optional => 1,
+       minimum => 32768,
+    },
+    nf_conntrack_tcp_timeout_established => {
+       description => "Conntrack established timeout.",
+       type => 'integer',
+       optional => 1,
+       minimum => 7875,
+    },
+    ndp => {
+       description => "Enable NDP.",
+       type => 'boolean',
+       optional => 1,
+    },
+};
+
+our $vm_option_properties = {
+    enable => {
+       description => "Enable/disable firewall rules.",
+       type => 'boolean',
+       optional => 1,
+    },
+    macfilter => {
+       description => "Enable/disable MAC address filter.",
+       type => 'boolean',
+       optional => 1,
+    },
+    dhcp => {
+       description => "Enable DHCP.",
+       type => 'boolean',
+       optional => 1,
+    },
+    ndp => {
+       description => "Enable NDP.",
+       type => 'boolean',
+       optional => 1,
+    },
+    radv => {
+       description => "Allow sending Router Advertisement.",
+       type => 'boolean',
+       optional => 1,
+    },
+    ipfilter => {
+       description => "Enable default IP filters. " .
+          "This is equivalent to adding an empty ipfilter-net<id> ipset " .
+          "for every interface. Such ipsets implicitly contain sane default " .
+          "restrictions such as restricting IPv6 link local addresses to " .
+          "the one derived from the interface's MAC address. For containers " .
+          "the configured IP addresses will be implicitly added.",
+       type => 'boolean',
+       optional => 1,
+    },
+    policy_in => {
+       description => "Input policy.",
+       type => 'string',
+       optional => 1,
+       enum => ['ACCEPT', 'REJECT', 'DROP'],
+    },
+    policy_out => {
+       description => "Output policy.",
+       type => 'string',
+       optional => 1,
+       enum => ['ACCEPT', 'REJECT', 'DROP'],
+    },
+    log_level_in =>  get_standard_option('pve-fw-loglevel', {
+       description => "Log level for incoming traffic." }),
+    log_level_out =>  get_standard_option('pve-fw-loglevel', {
+       description => "Log level for outgoing traffic." }),
+
+};
+
+
+my $addr_list_descr = "This can refer to a single IP address, an IP set ('+ipsetname') or an IP alias definition. You can also specify an address range like '20.34.101.207-201.3.9.99', or a list of IP addresses and networks (entries are separated by comma). Please do not mix IPv4 and IPv6 addresses inside such lists.";
+
+my $port_descr = "You can use service names or simple numbers (0-65535), as defined in '/etc/services'. Port ranges can be specified with '\\d+:\\d+', for example '80:85', and you can use comma separated list to match several ports or ranges.";
+
 my $rule_properties = {
     pos => {
        description => "Update rule at position <pos>.",
@@ -1112,6 +1244,7 @@ my $rule_properties = {
     },
     digest => get_standard_option('pve-config-digest'),
     type => {
+       description => "Rule type.",
        type => 'string',
        optional => 1,
        enum => ['in', 'out', 'group'],
@@ -1125,37 +1258,48 @@ my $rule_properties = {
        minLength => 2,
     },
     macro => {
+       description => "Use predefined standard macro.",
        type => 'string',
        optional => 1,
        maxLength => 128,
     },
-    iface => get_standard_option('pve-iface', { optional => 1 }),
+    iface => get_standard_option('pve-iface', {
+       description => "Network interface name. You have to use network configuration key names for VMs and containers ('net\\d+'). Host related rules can use arbitrary strings.",
+       optional => 1
+    }),
     source => {
+       description => "Restrict packet source address. $addr_list_descr",
        type => 'string', format => 'pve-fw-addr-spec',
        optional => 1,
     },
     dest => {
+       description => "Restrict packet destination address. $addr_list_descr",
        type => 'string', format => 'pve-fw-addr-spec',
        optional => 1,
     },
     proto => {
+       description => "IP protocol. You can use protocol names ('tcp'/'udp') or simple numbers, as defined in '/etc/protocols'.",
        type => 'string', format => 'pve-fw-protocol-spec',
        optional => 1,
     },
     enable => {
+       description => "Flag to enable/disable a rule.",
         type => 'integer',
        minimum => 0,
        optional => 1,
     },
     sport => {
-       type => 'string', format => 'pve-fw-port-spec',
+       description => "Restrict TCP/UDP source port. $port_descr",
+       type => 'string', format => 'pve-fw-sport-spec',
        optional => 1,
     },
     dport => {
-       type => 'string', format => 'pve-fw-port-spec',
+       description => "Restrict TCP/UDP destination port. $port_descr",
+       type => 'string', format => 'pve-fw-dport-spec',
        optional => 1,
     },
     comment => {
+       description => "Descriptive comment.",
        type => 'string',
        optional => 1,
     },
@@ -1356,14 +1500,14 @@ sub verify_rule {
     }
 
     if ($rule->{dport}) {
-       eval { parse_port_name_number_or_range($rule->{dport}); };
+       eval { parse_port_name_number_or_range($rule->{dport}, 1); };
        &$add_error('dport', $@) if $@;
        &$add_error('proto', "missing property - 'dport' requires this property")
            if !$rule->{proto};
     }
 
     if ($rule->{sport}) {
-       eval { parse_port_name_number_or_range($rule->{sport}); };
+       eval { parse_port_name_number_or_range($rule->{sport}, 0); };
        &$add_error('sport', $@) if $@;
        &$add_error('proto', "missing property - 'sport' requires this property")
            if !$rule->{proto};
@@ -1618,8 +1762,8 @@ sub ruleset_generate_cmdstr {
 
     die "unable to emit macro - internal error" if $rule->{macro}; # should not happen
 
-    my $nbdport = defined($rule->{dport}) ? parse_port_name_number_or_range($rule->{dport}) : 0;
-    my $nbsport = defined($rule->{sport}) ? parse_port_name_number_or_range($rule->{sport}) : 0;
+    my $nbdport = defined($rule->{dport}) ? parse_port_name_number_or_range($rule->{dport}, 1) : 0;
+    my $nbsport = defined($rule->{sport}) ? parse_port_name_number_or_range($rule->{sport}, 0) : 0;
 
     my @cmd = ();
 
@@ -1866,6 +2010,18 @@ sub ruleset_add_chain_policy {
     }
 }
 
+sub ruleset_chain_add_ndp {
+    my ($ruleset, $chain, $ipversion, $options, $direction, $accept) = @_;
+    return if $ipversion != 6 || (defined($options->{ndp}) && !$options->{ndp});
+
+    ruleset_addrule($ruleset, $chain, "-p icmpv6 --icmpv6-type router-solicitation $accept");
+    if ($direction ne 'OUT' || $options->{radv}) {
+       ruleset_addrule($ruleset, $chain, "-p icmpv6 --icmpv6-type router-advertisement $accept");
+    }
+    ruleset_addrule($ruleset, $chain, "-p icmpv6 --icmpv6-type neighbor-solicitation $accept");
+    ruleset_addrule($ruleset, $chain, "-p icmpv6 --icmpv6-type neighbor-advertisement $accept");
+}
+
 sub ruleset_chain_add_conn_filters {
     my ($ruleset, $chain, $accept) = @_;
 
@@ -1932,11 +2088,17 @@ sub ruleset_create_vm_chain {
        if (defined($macaddr) && !(defined($options->{macfilter}) && $options->{macfilter} == 0)) {
            ruleset_addrule($ruleset, $chain, "-m mac ! --mac-source $macaddr -j DROP");
        }
+       if ($ipversion == 6 && !$options->{radv}) {
+           ruleset_addrule($ruleset, $chain, '-p icmpv6 --icmpv6-type router-advertisement -j DROP');
+       }
        if ($ipfilter_ipset) {
            ruleset_addrule($ruleset, $chain, "-m set ! --match-set $ipfilter_ipset src -j DROP");
        }
-       ruleset_addrule($ruleset, $chain, "-j MARK --set-mark 0"); # clear mark
+       ruleset_addrule($ruleset, $chain, "-j MARK --set-mark $FWACCEPTMARK_OFF"); # clear mark
     }
+
+    my $accept_action = $direction eq 'OUT' ? '-g PVEFW-SET-ACCEPT-MARK' : "-j $accept";
+    ruleset_chain_add_ndp($ruleset, $chain, $ipversion, $options, $direction, $accept_action);
 }
 
 sub ruleset_add_group_rule {
@@ -1956,7 +2118,7 @@ sub ruleset_add_group_rule {
        ruleset_addrule($ruleset, $chain, "-j $group_chain");
     }
 
-    ruleset_addrule($ruleset, $chain, "-m mark --mark 1 -j $action");
+    ruleset_addrule($ruleset, $chain, "-m mark --mark $FWACCEPTMARK_ON -j $action");
 }
 
 sub ruleset_generate_vm_rules {
@@ -2039,7 +2201,7 @@ sub generate_tap_rules_direction {
 
     my $ipfilter_name = compute_ipfilter_ipset_name($netid);
     my $ipfilter_ipset = compute_ipset_chain_name($vmid, $ipfilter_name, $ipversion)
-       if $vmfw_conf->{ipset}->{$ipfilter_name};       
+       if $options->{ipfilter} || $vmfw_conf->{ipset}->{$ipfilter_name};
 
     # create chain with mac and ip filter
     ruleset_create_vm_chain($ruleset, $tapchain, $ipversion, $options, $macaddr, $ipfilter_ipset, $direction);
@@ -2093,6 +2255,7 @@ sub enable_host_firewall {
     ruleset_addrule($ruleset, $chain, "-i lo -j ACCEPT");
 
     ruleset_chain_add_conn_filters($ruleset, $chain, 'ACCEPT');
+    ruleset_chain_add_ndp($ruleset, $chain, $ipversion, $options, 'IN', '-j RETURN');
     ruleset_chain_add_input_filters($ruleset, $chain, $ipversion, $options, $cluster_conf, $loglevel);
 
     # we use RETURN because we need to check also tap rules
@@ -2154,6 +2317,7 @@ sub enable_host_firewall {
 
     # we use RETURN because we may want to check other thigs later
     $accept_action = 'RETURN';
+    ruleset_chain_add_ndp($ruleset, $chain, $ipversion, $options, 'OUT', "-j $accept_action");
 
     ruleset_addrule($ruleset, $chain, "-p igmp -j $accept_action"); # important for multicast
 
@@ -2209,7 +2373,7 @@ sub generate_group_rules {
     my $chain = "GROUP-${group}-IN";
 
     ruleset_create_chain($ruleset, $chain);
-    ruleset_addrule($ruleset, $chain, "-j MARK --set-mark 0"); # clear mark
+    ruleset_addrule($ruleset, $chain, "-j MARK --set-mark $FWACCEPTMARK_OFF"); # clear mark
 
     foreach my $rule (@$rules) {
        next if $rule->{type} ne 'in';
@@ -2222,7 +2386,7 @@ sub generate_group_rules {
     $chain = "GROUP-${group}-OUT";
 
     ruleset_create_chain($ruleset, $chain);
-    ruleset_addrule($ruleset, $chain, "-j MARK --set-mark 0"); # clear mark
+    ruleset_addrule($ruleset, $chain, "-j MARK --set-mark $FWACCEPTMARK_OFF"); # clear mark
 
     foreach my $rule (@$rules) {
        next if $rule->{type} ne 'out';
@@ -2241,6 +2405,14 @@ for (my $i = 0; $i < $MAX_NETS; $i++)  {
     $valid_netdev_names->{"net$i"} = 1;
 }
 
+sub get_mark_values {
+    my ($value, $mask) = @_;
+    $value = hex($value) if $value =~ /^0x/;
+    $mask = hex($mask) if defined($mask) && $mask =~ /^0x/;
+    $mask = 0xffffffff if !defined($mask);
+    return ($value, $mask);
+}
+
 sub parse_fw_rule {
     my ($prefix, $line, $cluster_conf, $fw_conf, $rule_env, $verbose) = @_;
 
@@ -2324,7 +2496,7 @@ sub parse_vmfw_option {
 
     my $loglevels = "emerg|alert|crit|err|warning|notice|info|debug|nolog";
 
-    if ($line =~ m/^(enable|dhcp|macfilter|ips):\s*(0|1)\s*$/i) {
+    if ($line =~ m/^(enable|dhcp|ndp|radv|macfilter|ipfilter|ips):\s*(0|1)\s*$/i) {
        $opt = lc($1);
        $value = int($2);
     } elsif ($line =~ m/^(log_level_in|log_level_out):\s*(($loglevels)\s*)?$/i) {
@@ -2350,7 +2522,7 @@ sub parse_hostfw_option {
 
     my $loglevels = "emerg|alert|crit|err|warning|notice|info|debug|nolog";
 
-    if ($line =~ m/^(enable|nosmurfs|tcpflags):\s*(0|1)\s*$/i) {
+    if ($line =~ m/^(enable|nosmurfs|tcpflags|ndp):\s*(0|1)\s*$/i) {
        $opt = lc($1);
        $value = int($2);
     } elsif ($line =~ m/^(log_level_in|log_level_out|tcp_flags_log_level|smurf_log_level):\s*(($loglevels)\s*)?$/i) {
@@ -2671,14 +2843,14 @@ sub read_local_vm_config {
        next if !$d->{type};
        if ($d->{type} eq 'qemu') {
            if ($have_qemu_server) {
-               my $cfspath = PVE::QemuServer::cfs_config_path($vmid);
+               my $cfspath = PVE::QemuConfig->cfs_config_path($vmid);
                if (my $conf = PVE::Cluster::cfs_read_file($cfspath)) {
                    $qemu->{$vmid} = $conf;
                }
            }
         } elsif ($d->{type} eq 'lxc') {
             if ($have_lxc) {
-                my $cfspath = PVE::LXC::cfs_config_path($vmid);
+                my $cfspath = PVE::LXC::Config->cfs_config_path($vmid);
                 if (my $conf = PVE::Cluster::cfs_read_file($cfspath)) {
                     $lxc->{$vmid} = $conf;
                 }
@@ -2828,10 +3000,13 @@ sub save_vmfw_conf {
        $raw .= "\n";
     }
 
-    mkdir $pvefw_conf_dir;
-
     my $filename = "$pvefw_conf_dir/$vmid.fw";
-    PVE::Tools::file_set_contents($filename, $raw);
+    if ($raw) {
+       mkdir $pvefw_conf_dir;
+       PVE::Tools::file_set_contents($filename, $raw);
+    } else {
+       unlink $filename;
+    }
 }
 
 sub remove_vmfw_conf {
@@ -2933,11 +3108,17 @@ sub generate_std_chains {
 }
 
 sub generate_ipset_chains {
-    my ($ipset_ruleset, $clusterfw_conf, $fw_conf) = @_; #fixme
+    my ($ipset_ruleset, $clusterfw_conf, $fw_conf, $device_ips, $ipsets) = @_;
 
-    foreach my $ipset (keys %{$fw_conf->{ipset}}) {
+    foreach my $ipset (keys %{$ipsets}) {
 
-       my $options = $fw_conf->{ipset}->{$ipset};
+       my $options = $ipsets->{$ipset};
+
+       if ($device_ips && $ipset =~ /^ipfilter-(net\d+)$/) {
+           if (my $ips = $device_ips->{$1}) {
+               $options = [@$options, @$ips];
+           }
+       }
 
        # remove duplicates
        my $nethash = {};
@@ -2952,7 +3133,10 @@ sub generate_ipset_chains {
                }
                #http://backreference.org/2013/03/01/ipv6-address-normalization/
                if ($ver == 6) {
-                   $cidr = lc(Net::IP::ip_compress_address($cidr, 6));
+                   # ip_compress_address takes an address only, no CIDR
+                   my ($addr, $prefix_len) = ($cidr =~ m@^([^/]*)(/.*)?$@);
+                   $cidr = lc(Net::IP::ip_compress_address($addr, 6));
+                   $cidr .= $prefix_len if defined($prefix_len);
                    $cidr =~ s|/128$||;
                } else {
                    $cidr =~ s|/32$||;
@@ -3053,8 +3237,12 @@ sub save_clusterfw_conf {
        }
     }
 
-    mkdir $pvefw_conf_dir;
-    PVE::Tools::file_set_contents($clusterfw_conf_filename, $raw);
+    if ($raw) {
+       mkdir $pvefw_conf_dir;
+       PVE::Tools::file_set_contents($clusterfw_conf_filename, $raw);
+    } else {
+       unlink $clusterfw_conf_filename;
+    }
 }
 
 sub load_hostfw_conf {
@@ -3084,7 +3272,11 @@ sub save_hostfw_conf {
        $raw .= "\n";
     }
 
-    PVE::Tools::file_set_contents($hostfw_conf_filename, $raw);
+    if ($raw) {
+       PVE::Tools::file_set_contents($hostfw_conf_filename, $raw);
+    } else {
+       unlink $hostfw_conf_filename;
+    }
 }
 
 sub compile {
@@ -3110,14 +3302,7 @@ sub compile {
        $vmfw_configs = read_vm_firewall_configs($cluster_conf, $vmdata, undef, $verbose);
     }
 
-    my ($ruleset, $ipset_ruleset) = compile_iptables_filter($cluster_conf, $hostfw_conf, $vmfw_configs, $vmdata, 4, $verbose);
-    my ($rulesetv6) = compile_iptables_filter($cluster_conf, $hostfw_conf, $vmfw_configs, $vmdata, 6, $verbose);
-
-    return ($ruleset, $ipset_ruleset, $rulesetv6);
-}
-
-sub compile_iptables_filter {
-    my ($cluster_conf, $hostfw_conf, $vmfw_configs, $vmdata, $ipversion, $verbose) = @_;
+    return ({},{},{}) if !$cluster_conf->{options}->{enable};
 
     my $localnet;
     if ($cluster_conf->{aliases}->{local_network}) {
@@ -3132,7 +3317,15 @@ sub compile_iptables_filter {
 
     push @{$cluster_conf->{ipset}->{management}}, { cidr => $localnet };
 
-    return ({}, {}) if !$cluster_conf->{options}->{enable};
+    my $ruleset = compile_iptables_filter($cluster_conf, $hostfw_conf, $vmfw_configs, $vmdata, 4, $verbose);
+    my $rulesetv6 = compile_iptables_filter($cluster_conf, $hostfw_conf, $vmfw_configs, $vmdata, 6, $verbose);
+    my $ipset_ruleset = compile_ipsets($cluster_conf, $vmfw_configs, $vmdata);
+
+    return ($ruleset, $ipset_ruleset, $rulesetv6);
+}
+
+sub compile_iptables_filter {
+    my ($cluster_conf, $hostfw_conf, $vmfw_configs, $vmdata, $ipversion, $verbose) = @_;
 
     my $ruleset = {};
 
@@ -3160,23 +3353,19 @@ sub compile_iptables_filter {
 
     my $hostfw_enable = !(defined($hostfw_options->{enable}) && ($hostfw_options->{enable} == 0));
 
-    my $ipset_ruleset = {};
-
     if ($hostfw_enable) {
        eval { enable_host_firewall($ruleset, $hostfw_conf, $cluster_conf, $ipversion); };
        warn $@ if $@; # just to be sure - should not happen
     }
 
     # generate firewall rules for QEMU VMs
-    foreach my $vmid (keys %{$vmdata->{qemu}}) {
+    foreach my $vmid (sort keys %{$vmdata->{qemu}}) {
        eval {
            my $conf = $vmdata->{qemu}->{$vmid};
            my $vmfw_conf = $vmfw_configs->{$vmid};
            return if !$vmfw_conf;
 
-           generate_ipset_chains($ipset_ruleset, $cluster_conf, $vmfw_conf);
-
-           foreach my $netid (keys %$conf) {
+           foreach my $netid (sort keys %$conf) {
                next if $netid !~ m/^net(\d+)$/;
                my $net = PVE::QemuServer::parse_net($conf->{$netid});
                next if !$net->{firewall};
@@ -3193,18 +3382,16 @@ sub compile_iptables_filter {
     }
 
     # generate firewall rules for LXC containers
-    foreach my $vmid (keys %{$vmdata->{lxc}}) {
+    foreach my $vmid (sort keys %{$vmdata->{lxc}}) {
         eval {
             my $conf = $vmdata->{lxc}->{$vmid};
             my $vmfw_conf = $vmfw_configs->{$vmid};
             return if !$vmfw_conf;
 
-            generate_ipset_chains($ipset_ruleset, $cluster_conf, $vmfw_conf);
-
             if ($vmfw_conf->{options}->{enable}) {
-               foreach my $netid (keys %$conf) {
+               foreach my $netid (sort keys %$conf) {
                     next if $netid !~ m/^net(\d+)$/;
-                    my $net = PVE::LXC::parse_lxc_network($conf->{$netid});
+                    my $net = PVE::LXC::Config->parse_lxc_network($conf->{$netid});
                     next if !$net->{firewall};
                     my $iface = "veth${vmid}i$1";
                    my $macaddr = $net->{hwaddr};
@@ -3222,9 +3409,128 @@ sub compile_iptables_filter {
        ruleset_insertrule($ruleset, "PVEFW-FORWARD", "-m conntrack --ctstate RELATED,ESTABLISHED -j PVEFW-IPS");
     }
 
-    generate_ipset_chains($ipset_ruleset, undef, $cluster_conf);
+    return $ruleset;
+}
+
+sub mac_to_linklocal {
+    my ($macaddr) = @_;
+    my @parts = split(/:/, $macaddr);
+    # The standard link local address uses the fe80::/64 prefix with the
+    # modified EUI-64 identifier derived from the MAC address by flipping the
+    # universal/local bit and inserting FF:FE in the middle.
+    # See RFC 4291.
+    $parts[0] = sprintf("%02x", hex($parts[0]) ^ 0x02);
+    my @meui64 = (@parts[0,1,2], 'ff', 'fe', @parts[3,4,5]);
+    return "fe80::$parts[0]$parts[1]:$parts[2]FF:FE$parts[3]:$parts[4]$parts[5]";
+}
+
+sub compile_ipsets {
+    my ($cluster_conf, $vmfw_configs, $vmdata) = @_;
+
+    my $localnet;
+    if ($cluster_conf->{aliases}->{local_network}) {
+       $localnet = $cluster_conf->{aliases}->{local_network}->{cidr};
+    } else {
+       my $localnet_ver;
+       ($localnet, $localnet_ver) = parse_ip_or_cidr(local_network() || '127.0.0.0/8');
+
+       $cluster_conf->{aliases}->{local_network} = { 
+           name => 'local_network', cidr => $localnet, ipversion => $localnet_ver };
+    }
+
+    push @{$cluster_conf->{ipset}->{management}}, { cidr => $localnet };
+
+
+    my $ipset_ruleset = {};
+
+    # generate ipsets for QEMU VMs
+    foreach my $vmid (keys %{$vmdata->{qemu}}) {
+       eval {
+           my $conf = $vmdata->{qemu}->{$vmid};
+           my $vmfw_conf = $vmfw_configs->{$vmid};
+           return if !$vmfw_conf;
+
+           # When the 'ipfilter' option is enabled every device for which there
+           # is no 'ipfilter-netX' ipset defiend gets an implicit empty default
+           # ipset.
+           # The reason is that ipfilter ipsets are always filled with standard
+           # IPv6 link-local filters.
+           my $ipsets = $vmfw_conf->{ipset};
+           my $implicit_sets = {};
+
+           my $device_ips = {};
+           foreach my $netid (keys %$conf) {
+               next if $netid !~ m/^net(\d+)$/;
+               my $net = PVE::QemuServer::parse_net($conf->{$netid});
+               next if !$net->{firewall};
+
+               if ($vmfw_conf->{options}->{ipfilter} && !$ipsets->{"ipfilter-$netid"}) {
+                   $implicit_sets->{"ipfilter-$netid"} = [];
+               }
+
+               my $macaddr = $net->{macaddr};
+               my $linklocal = mac_to_linklocal($macaddr);
+               $device_ips->{$netid} = [
+                   { cidr => $linklocal },
+                   { cidr => 'fe80::/10', nomatch => 1 }
+               ];
+           }
+
+           generate_ipset_chains($ipset_ruleset, $cluster_conf, $vmfw_conf, $device_ips, $ipsets);
+           generate_ipset_chains($ipset_ruleset, $cluster_conf, $vmfw_conf, $device_ips, $implicit_sets);
+       };
+       warn $@ if $@; # just to be sure - should not happen
+    }
+
+    # generate firewall rules for LXC containers
+    foreach my $vmid (keys %{$vmdata->{lxc}}) {
+       eval {
+           my $conf = $vmdata->{lxc}->{$vmid};
+           my $vmfw_conf = $vmfw_configs->{$vmid};
+           return if !$vmfw_conf;
+
+           # When the 'ipfilter' option is enabled every device for which there
+           # is no 'ipfilter-netX' ipset defiend gets an implicit empty default
+           # ipset.
+           # The reason is that ipfilter ipsets are always filled with standard
+           # IPv6 link-local filters, as well as the IP addresses configured
+           # for the container.
+           my $ipsets = $vmfw_conf->{ipset};
+           my $implicit_sets = {};
+
+           my $device_ips = {};
+           foreach my $netid (keys %$conf) {
+               next if $netid !~ m/^net(\d+)$/;
+               my $net = PVE::LXC::Config->parse_lxc_network($conf->{$netid});
+               next if !$net->{firewall};
+
+               if ($vmfw_conf->{options}->{ipfilter} && !$ipsets->{"ipfilter-$netid"}) {
+                   $implicit_sets->{"ipfilter-$netid"} = [];
+               }
+
+               my $macaddr = $net->{hwaddr};
+               my $linklocal = mac_to_linklocal($macaddr);
+               my $set = $device_ips->{$netid} = [
+                   { cidr => $linklocal },
+                   { cidr => 'fe80::/10', nomatch => 1 }
+               ];
+               if (defined($net->{ip}) && $net->{ip} =~ m!^($IPV4RE)(?:/\d+)?$!) {
+                   push @$set, { cidr => $1 };
+               }
+               if (defined($net->{ip6}) && $net->{ip6} =~ m!^($IPV6RE)(?:/\d+)?$!) {
+                   push @$set, { cidr => $1 };
+               }
+           }
+
+           generate_ipset_chains($ipset_ruleset, $cluster_conf, $vmfw_conf, $device_ips, $ipsets);
+           generate_ipset_chains($ipset_ruleset, $cluster_conf, $vmfw_conf, $device_ips, $implicit_sets);
+       };
+       warn $@ if $@; # just to be sure - should not happen
+    }
+
+    generate_ipset_chains($ipset_ruleset, undef, $cluster_conf, undef, $cluster_conf->{ipset});
 
-    return ($ruleset, $ipset_ruleset);
+    return $ipset_ruleset;
 }
 
 sub get_ruleset_status {
@@ -3398,7 +3704,7 @@ sub apply_ruleset {
     enable_bridge_firewall();
 
     my ($ipset_create_cmdlist, $ipset_delete_cmdlist, $ipset_changes) =
-       get_ipset_cmdlist($ipset_ruleset, undef, $verbose);
+       get_ipset_cmdlist($ipset_ruleset, $verbose);
 
     my ($cmdlist, $changes) = get_ruleset_cmdlist($ruleset, $verbose);
     my ($cmdlistv6, $changesv6) = get_ruleset_cmdlist($rulesetv6, $verbose, "ip6tables");