]> git.proxmox.com Git - pve-firewall.git/blobdiff - src/PVE/Firewall.pm
LXC refactoring
[pve-firewall.git] / src / PVE / Firewall.pm
index c1f871dc7cb4ae3192e067d0e5a612297b4f0b48..a7faeaf40499cbebf840ca7d321acc9e870b80ca 100644 (file)
@@ -138,10 +138,14 @@ 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' => [
+       { 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' },
@@ -592,7 +596,7 @@ $pve_std_chains->{4} = {
     'PVEFW-smurfs' => [
        # same as shorewall smurfs action
        # Filter packets for smurfs (packets with a broadcast address as the source).
-       "-s 0.0.0.0/32 -j RETURN",
+       "-s 0.0.0.0/32 -j RETURN", # allow DHCP
        "-m addrtype --src-type BROADCAST -g PVEFW-smurflog",
        "-s 224.0.0.0/4 -g PVEFW-smurflog",
     ],
@@ -991,7 +995,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 +1011,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 +1026,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, 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);
+   parse_port_name_number_or_range($portstr, 1);
 
    return $portstr;
 }
@@ -1089,7 +1102,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;
     }
@@ -1148,11 +1164,11 @@ my $rule_properties = {
        optional => 1,
     },
     sport => {
-       type => 'string', format => 'pve-fw-port-spec',
+       type => 'string', format => 'pve-fw-sport-spec',
        optional => 1,
     },
     dport => {
-       type => 'string', format => 'pve-fw-port-spec',
+       type => 'string', format => 'pve-fw-dport-spec',
        optional => 1,
     },
     comment => {
@@ -1356,14 +1372,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};
@@ -1513,12 +1529,12 @@ sub iptables_get_chains {
 
        return 1 if $name =~ m/^PVEFW-\S+$/;
 
-       return 1 if $name =~ m/^tap\d+i\d+-(:?IN|OUT)$/;
+       return 1 if $name =~ m/^tap\d+i\d+-(?:IN|OUT)$/;
 
-       return 1 if $name =~ m/^veth\d+.\d+-(:?IN|OUT)$/; # fixme: dev name is configurable
+       return 1 if $name =~ m/^veth\d+i\d+-(?:IN|OUT)$/;
 
-       return 1 if $name =~ m/^fwbr\d+(v\d+)?-(:?FW|IN|OUT|IPS)$/;
-       return 1 if $name =~ m/^GROUP-(:?[^\s\-]+)-(:?IN|OUT)$/;
+       return 1 if $name =~ m/^fwbr\d+(v\d+)?-(?:FW|IN|OUT|IPS)$/;
+       return 1 if $name =~ m/^GROUP-(?:$security_group_name_pattern)-(?:IN|OUT)$/;
 
        return undef;
     };
@@ -1618,8 +1634,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 = ();
 
@@ -1702,11 +1718,13 @@ sub ruleset_generate_cmdstr {
        if ($rule->{dport}) {
            if ($rule->{proto} && $rule->{proto} eq 'icmp') {
                # Note: we use dport to store --icmp-type
-               die "unknown icmp-type '$rule->{dport}'\n" if !defined($icmp_type_names->{$rule->{dport}});
+               die "unknown icmp-type '$rule->{dport}'\n"
+                   if $rule->{dport} !~ /^\d+$/ && !defined($icmp_type_names->{$rule->{dport}});
                push @cmd, "-m icmp --icmp-type $rule->{dport}";
            } elsif ($rule->{proto} && $rule->{proto} eq 'icmpv6') {
                # Note: we use dport to store --icmpv6-type
-               die "unknown icmpv6-type '$rule->{dport}'\n" if !defined($icmpv6_type_names->{$rule->{dport}});
+               die "unknown icmpv6-type '$rule->{dport}'\n"
+                   if $rule->{dport} !~ /^\d+$/ && !defined($icmpv6_type_names->{$rule->{dport}});
                push @cmd, "-m icmpv6 --icmpv6-type $rule->{dport}";
            } else {
                if ($nbdport > 1) {
@@ -1864,6 +1882,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) = @_;
 
@@ -1902,26 +1932,45 @@ sub ruleset_create_vm_chain {
     my $accept = generate_nfqueue($options);
 
     if (!(defined($options->{dhcp}) && $options->{dhcp} == 0)) {
-       if ($direction eq 'OUT') {
-           ruleset_generate_rule($ruleset, $chain, $ipversion, 
-                                 { action => 'PVEFW-SET-ACCEPT-MARK',
-                                   proto => 'udp', sport => 68, dport => 67 });
-       } else {
-           ruleset_generate_rule($ruleset, $chain, $ipversion,
-                                 { action => 'ACCEPT',
-                                   proto => 'udp', sport => 67, dport => 68 });
+       if ($ipversion == 4) {
+           if ($direction eq 'OUT') {
+               ruleset_generate_rule($ruleset, $chain, $ipversion, 
+                                     { action => 'PVEFW-SET-ACCEPT-MARK',
+                                       proto => 'udp', sport => 68, dport => 67 });
+           } else {
+               ruleset_generate_rule($ruleset, $chain, $ipversion,
+                                     { action => 'ACCEPT',
+                                       proto => 'udp', sport => 67, dport => 68 });
+           }
+       } elsif ($ipversion == 6) {
+           if ($direction eq 'OUT') {
+               ruleset_generate_rule($ruleset, $chain, $ipversion,
+                                     { action => 'PVEFW-SET-ACCEPT-MARK',
+                                       proto => 'udp', sport => 546, dport => 547 });
+           } else {
+               ruleset_generate_rule($ruleset, $chain, $ipversion,
+                                     { action => 'ACCEPT',
+                                       proto => 'udp', sport => 547, dport => 546 });
+           }
        }
+
     }
 
     if ($direction eq 'OUT') {
        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
     }
+
+    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 {
@@ -2024,7 +2073,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);
@@ -2078,6 +2127,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
@@ -2139,6 +2189,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
 
@@ -2309,7 +2360,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) {
@@ -2335,7 +2386,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) {
@@ -2663,7 +2714,7 @@ sub read_local_vm_config {
            }
         } 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;
                 }
@@ -2813,10 +2864,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 {
@@ -2918,11 +2972,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 = {};
@@ -2937,7 +2997,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$||;
@@ -3038,8 +3101,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 {
@@ -3069,7 +3136,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 {
@@ -3095,14 +3166,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}) {
@@ -3117,7 +3181,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 = {};
 
@@ -3145,8 +3217,6 @@ 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
@@ -3159,8 +3229,6 @@ sub compile_iptables_filter {
            my $vmfw_conf = $vmfw_configs->{$vmid};
            return if !$vmfw_conf;
 
-           generate_ipset_chains($ipset_ruleset, $cluster_conf, $vmfw_conf);
-
            foreach my $netid (keys %$conf) {
                next if $netid !~ m/^net(\d+)$/;
                my $net = PVE::QemuServer::parse_net($conf->{$netid});
@@ -3184,12 +3252,10 @@ sub compile_iptables_filter {
             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) {
                     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};
@@ -3207,9 +3273,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 ($net->{ip} =~ m!^($IPV4RE)(?:/\d+)?$!) {
+                   push @$set, { cidr => $1 };
+               }
+               if ($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 {
@@ -3383,7 +3568,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");