X-Git-Url: https://git.proxmox.com/?p=pve-firewall.git;a=blobdiff_plain;f=src%2FPVE%2FFirewall.pm;h=d22b15a506387bd7462ee8ae8ffa0f60f3ddf96e;hp=255bb9aaa790e46d02b0e678b51c3bc7ab315bc3;hb=HEAD;hpb=56a4714097acf3149529f4f857f0ecf2c9de68ed diff --git a/src/PVE/Firewall.pm b/src/PVE/Firewall.pm index 255bb9a..5dca91f 100644 --- a/src/PVE/Firewall.pm +++ b/src/PVE/Firewall.pm @@ -24,6 +24,8 @@ use PVE::SafeSyslog; use PVE::Tools qw($IPV4RE $IPV6RE); use PVE::Tools qw(run_command lock_file dir_glob_foreach); +use PVE::Firewall::Helpers; + my $pvefw_conf_dir = "/etc/pve/firewall"; my $clusterfw_conf_filename = "$pvefw_conf_dir/cluster.fw"; @@ -66,9 +68,13 @@ PVE::JSONSchema::register_format('IPorCIDR', \&pve_verify_ip_or_cidr); sub pve_verify_ip_or_cidr { my ($cidr, $noerr) = @_; - if ($cidr =~ m!^(?:$IPV6RE|$IPV4RE)(/(\d+))?$!) { - return $cidr if Net::IP->new($cidr); + if ($cidr =~ m!^(?:$IPV6RE|$IPV4RE)(?:/\d+)?$!) { + # Net::IP throws an error if the masked CIDR part isn't zero, e.g., `192.168.1.155/24` + # fails but `192.168.1.0/24` succeeds. clean_cidr removes the non zero bits from the CIDR. + my $clean_cidr = clean_cidr($cidr); + return $cidr if Net::IP->new($clean_cidr); return undef if $noerr; + die Net::IP::Error() . "\n"; } return undef if $noerr; @@ -79,11 +85,24 @@ PVE::JSONSchema::register_format('IPorCIDRorAlias', \&pve_verify_ip_or_cidr_or_a sub pve_verify_ip_or_cidr_or_alias { my ($cidr, $noerr) = @_; - return if $cidr =~ m/^(?:$ip_alias_pattern)$/; + return if $cidr =~ m@^(dc/|guest/)?(?:$ip_alias_pattern)$@; return pve_verify_ip_or_cidr($cidr, $noerr); } +sub clean_cidr { + my ($cidr) = @_; + my ($ip, $len) = split('/', $cidr); + return $cidr if !$len; + my $ver = ($ip =~ m!^$IPV4RE$!) ? 4 : 6; + + my $bin_ip = Net::IP::ip_iptobin( Net::IP::ip_expand_address($ip, $ver), $ver); + my $bin_mask = Net::IP::ip_get_mask($len, $ver); + my $clean_ip = Net::IP::ip_compress_address( Net::IP::ip_bintoip($bin_ip & $bin_mask, $ver), $ver); + + return "${clean_ip}/$len"; +} + PVE::JSONSchema::register_standard_option('ipset-name', { description => "IP set name.", type => 'string', @@ -213,7 +232,7 @@ my $pve_fw_macros = { { action => 'PARAM', proto => 'udp', dport => '6881' }, ], 'Ceph' => [ - "Ceph Storage Cluster traffic (Ceph Monitors, OSD & MDS Deamons)", + "Ceph Storage Cluster traffic (Ceph Monitors, OSD & MDS Daemons)", # Legacy port for protocol v1 { action => 'PARAM', proto => 'tcp', dport => '6789' }, # New port for protocol v2 @@ -276,7 +295,7 @@ my $pve_fw_macros = { { action => 'PARAM', proto => 'tcp', dport => '9418' }, ], 'HKP' => [ - "OpenPGP HTTP keyserver protocol traffic", + "OpenPGP HTTP key server protocol traffic", { action => 'PARAM', proto => 'tcp', dport => '11371' }, ], 'HTTP' => [ @@ -494,6 +513,10 @@ my $pve_fw_macros = { { action => 'PARAM', proto => '41' }, { action => 'PARAM', proto => 'udp', dport => '5072,8374' }, ], + 'SPICEproxy' => [ + "Proxmox VE SPICE display proxy traffic", + { action => 'PARAM', proto => 'tcp', dport => '3128' }, + ], 'Squid' => [ "Squid web proxy traffic", { action => 'PARAM', proto => 'tcp', dport => '3128' }, @@ -555,6 +578,18 @@ my $pve_fw_macros = { ], }; +my $pve_fw_helpers = { + 'amanda' => { proto => 'udp', dport => '10080', 'v4' => 1, 'v6' => 1 }, + 'ftp' => { proto => 'tcp', dport => '21', 'v4' => 1, 'v6' => 1}, + 'irc' => { proto => 'tcp', dport => '6667', 'v4' => 1 }, + 'netbios-ns' => { proto => 'udp', dport => '137', 'v4' => 1 }, + 'pptp' => { proto => 'tcp', dport => '1723', 'v4' => 1, }, + 'sane' => { proto => 'tcp', dport => '6566', 'v4' => 1, 'v6' => 1 }, + 'sip' => { proto => 'udp', dport => '5060', 'v4' => 1, 'v6' => 1 }, + 'snmp' => { proto => 'udp', dport => '161', 'v4' => 1 }, + 'tftp' => { proto => 'udp', dport => '69', 'v4' => 1, 'v6' => 1}, +}; + my $pve_fw_parsed_macros; my $pve_fw_macro_descr; my $pve_fw_macro_ipversion = {}; @@ -592,7 +627,6 @@ $pve_std_chains_conf->{4} = { # same as shorewall 'Drop', which is equal to DROP, # but REJECT/DROP some packages to reduce logging, # and ACCEPT critical ICMP types - { action => 'PVEFW-reject', proto => 'tcp', dport => '43' }, # REJECT 'auth' # we are not interested in BROADCAST/MULTICAST/ANYCAST { action => 'PVEFW-DropBroadcast' }, # ACCEPT critical ICMP types @@ -615,7 +649,6 @@ $pve_std_chains_conf->{4} = { # same as shorewall 'Reject', which is equal to Reject, # but REJECT/DROP some packages to reduce logging, # and ACCEPT critical ICMP types - { action => 'PVEFW-reject', proto => 'tcp', dport => '43' }, # REJECT 'auth' # we are not interested in BROADCAST/MULTICAST/ANYCAST { action => 'PVEFW-DropBroadcast' }, # ACCEPT critical ICMP types @@ -636,7 +669,7 @@ $pve_std_chains_conf->{4} = { ], 'PVEFW-tcpflags' => [ # same as shorewall tcpflags action. - # Packets arriving on this interface are checked for som illegal combinations of TCP flags + # Packets arriving on this interface are checked for some illegal combinations of TCP flags { match => '-p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG FIN,PSH,URG', target => '-g PVEFW-logflags' }, { match => '-p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG NONE', target => '-g PVEFW-logflags' }, { match => '-p tcp -m tcp --tcp-flags SYN,RST SYN,RST', target => '-g PVEFW-logflags' }, @@ -729,7 +762,7 @@ $pve_std_chains_conf->{6} = { ], 'PVEFW-tcpflags' => [ # same as shorewall tcpflags action. - # Packets arriving on this interface are checked for som illegal combinations of TCP flags + # Packets arriving on this interface are checked for some illegal combinations of TCP flags { match => '-p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG FIN,PSH,URG', target => '-g PVEFW-logflags' }, { match => '-p tcp -m tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG NONE', target => '-g PVEFW-logflags' }, { match => '-p tcp -m tcp --tcp-flags SYN,RST SYN,RST', target => '-g PVEFW-logflags' }, @@ -785,12 +818,14 @@ my $icmp_type_names = { # ip6tables -p icmpv6 -h my $icmpv6_type_names = { - 'any' => 1, 'destination-unreachable' => 1, 'no-route' => 1, 'communication-prohibited' => 1, + 'beyond-scope' => 1, 'address-unreachable' => 1, 'port-unreachable' => 1, + 'failed-policy' => 1, + 'reject-route' => 1, 'packet-too-big' => 1, 'time-exceeded' => 1, 'ttl-zero-during-transit' => 1, @@ -810,6 +845,22 @@ my $icmpv6_type_names = { 'redirect' => 1, }; +my $is_valid_icmp_type = sub { + my ($type, $valid_types) = @_; + + if ($type =~ m/^\d+$/) { + # values for icmp-type range between 0 and 255 (8 bit field) + die "invalid icmp-type '$type'\n" if $type > 255; + } else { + die "unknown icmp-type '$type'\n" if !defined($valid_types->{$type}); + } +}; + +my $proto_is_icmp = sub { + my $proto = shift; + return $proto eq 'icmp' || $proto eq 'icmpv6' || $proto eq 'ipv6-icmp'; +}; + sub init_firewall_macros { $pve_fw_parsed_macros = {}; @@ -1016,7 +1067,7 @@ sub parse_address_list { return; } - if ($str =~ m/^${ip_alias_pattern}$/) { + if ($str =~ m@^(dc/|guest/)?${ip_alias_pattern}$@) { die "alias name too long\n" if length($str) > $max_alias_name_length; return; } @@ -1049,6 +1100,9 @@ sub parse_address_list { return $ipversion; } +# $dport must only be set to 1 if the parsed parameter is dport and the +# protocol is one of the ICMP variants - ICMP type values used to be stored in +# the dport parameter. sub parse_port_name_number_or_range { my ($str, $dport) = @_; @@ -1080,7 +1134,7 @@ sub parse_port_name_number_or_range { } } - die "ICPM ports not allowed in port range\n" if $icmp_port && $count > 0; + die "ICMP ports not allowed in port range\n" if $icmp_port && $count > 0; # I really don't like to use the word number here, but it's the only thing # that makes sense in a literal way. The range 1:100 counts as 2, not as @@ -1091,6 +1145,19 @@ sub parse_port_name_number_or_range { return (scalar(@elements) > 1); } +PVE::JSONSchema::register_format('pve-fw-conntrack-helper', \&pve_fw_verify_conntrack_helper); +sub pve_fw_verify_conntrack_helper { + my ($list) = @_; + + my @helpers = split(/,/, $list); + die "extraneous commas in list\n" if $list ne join(',', @helpers); + foreach my $helper (@helpers) { + die "unknown helper $helper" if !$pve_fw_helpers->{$helper}; + } + + return $list; +} + PVE::JSONSchema::register_format('pve-fw-sport-spec', \&pve_fw_verify_sport_spec); sub pve_fw_verify_sport_spec { my ($portstr) = @_; @@ -1131,6 +1198,19 @@ sub pve_fw_verify_protocol_spec { return $proto; } +PVE::JSONSchema::register_format('pve-fw-icmp-type-spec', \&pve_fw_verify_icmp_type_spec); +sub pve_fw_verify_icmp_type_spec { + my ($icmp_type) = @_; + + if ($icmp_type_names->{$icmp_type} || $icmpv6_type_names->{$icmp_type}) { + return $icmp_type; + } + + die "invalid icmp-type value '$icmp_type'\n" if $icmp_type ne ''; + + return $icmp_type; +} + # helper function for API @@ -1230,7 +1310,7 @@ our $cluster_option_properties = { type => 'integer', minimum => 0, optional => 1, - description => 'Inital burst of packages which will get logged', + description => 'Initial burst of packages which will always get logged before the rate is applied', default => 5, }, }, @@ -1297,6 +1377,13 @@ our $host_option_properties = { default => 0, optional => 1, }, + nf_conntrack_helpers => { + type => 'string', format => 'pve-fw-conntrack-helper', + description => "Enable conntrack helpers for specific protocols. ". + "Supported protocols: amanda, ftp, irc, netbios-ns, pptp, sane, sip, snmp, tftp", + default => '', + optional => 1, + }, protection_synflood => { description => "Enable synflood protection", type => 'boolean', @@ -1321,6 +1408,12 @@ our $host_option_properties = { default => 0, optional => 1 }, + nftables => { + description => "Enable nftables based firewall (tech preview)", + type => 'boolean', + default => 0, + optional => 1, + }, }; our $vm_option_properties = { @@ -1333,7 +1426,7 @@ our $vm_option_properties = { macfilter => { description => "Enable/disable MAC address filter.", type => 'boolean', - default => 0, + default => 1, optional => 1, }, dhcp => { @@ -1423,11 +1516,13 @@ my $rule_properties = { description => "Restrict packet source address. $addr_list_descr", type => 'string', format => 'pve-fw-addr-spec', optional => 1, + maxLength => 512, }, dest => { description => "Restrict packet destination address. $addr_list_descr", type => 'string', format => 'pve-fw-addr-spec', optional => 1, + maxLength => 512, }, proto => { description => "IP protocol. You can use protocol names ('tcp'/'udp') or simple numbers, as defined in '/etc/protocols'.", @@ -1458,6 +1553,11 @@ my $rule_properties = { type => 'string', optional => 1, }, + 'icmp-type' => { + description => "Specify icmp-type. Only valid if proto equals 'icmp' or 'icmpv6'/'ipv6-icmp'.", + type => 'string', format => 'pve-fw-icmp-type-spec', + optional => 1, + }, }; sub add_rule_properties { @@ -1578,7 +1678,7 @@ sub verify_rule { my $set_ip_version = sub { my $vers = shift; if ($vers) { - die "detected mixed ipv4/ipv6 adresses in rule\n" + die "detected mixed ipv4/ipv6 addresses in rule\n" if $ipversion && ($vers != $ipversion); $ipversion = $vers; } @@ -1589,19 +1689,26 @@ sub verify_rule { if (my $value = $rule->{$name}) { if ($value =~ m/^\+/) { - if ($value =~ m/^\+(${ipset_name_pattern})$/) { - &$add_error($name, "no such ipset '$1'") - if !($cluster_conf->{ipset}->{$1} || ($fw_conf && $fw_conf->{ipset}->{$1})); + if ($value =~ m@^\+(guest/|dc/)?(${ipset_name_pattern})$@) { + &$add_error($name, "no such ipset '$2'") + if !($cluster_conf->{ipset}->{$2} || ($fw_conf && $fw_conf->{ipset}->{$2})); } else { &$add_error($name, "invalid ipset name '$value'"); } - } elsif ($value =~ m/^${ip_alias_pattern}$/){ - my $alias = lc($value); + } elsif ($value =~ m@^(guest/|dc/)?(${ip_alias_pattern})$@){ + my $scope = $1 // ""; + my $alias = lc($2); &$add_error($name, "no such alias '$value'") if !($cluster_conf->{aliases}->{$alias} || ($fw_conf && $fw_conf->{aliases}->{$alias})); - my $e = $fw_conf ? $fw_conf->{aliases}->{$alias} : undef; - $e = $cluster_conf->{aliases}->{$alias} if !$e && $cluster_conf; + + my $e; + if ($scope ne 'dc/' && $fw_conf) { + $e = $fw_conf->{aliases}->{$alias}; + } + if ($scope ne 'guest/' && !$e && $cluster_conf) { + $e = $cluster_conf->{aliases}->{$alias}; + } &$set_ip_version($e->{ipversion}); } @@ -1647,22 +1754,37 @@ sub verify_rule { } } + my $is_icmp = 0; if ($rule->{proto}) { eval { pve_fw_verify_protocol_spec($rule->{proto}); }; &$add_error('proto', $@) if $@; &$set_ip_version(4) if $rule->{proto} eq 'icmp'; - &$set_ip_version(6) if $rule->{proto} eq 'icmpv6'; + &$set_ip_version(6) if $rule->{proto} eq 'icmpv6'; + &$set_ip_version(6) if $rule->{proto} eq 'ipv6-icmp'; + $is_icmp = $proto_is_icmp->($rule->{proto}); } if ($rule->{dport}) { - eval { parse_port_name_number_or_range($rule->{dport}, 1); }; + eval { parse_port_name_number_or_range($rule->{dport}, $is_icmp); }; &$add_error('dport', $@) if $@; my $proto = $rule->{proto}; &$add_error('proto', "missing property - 'dport' requires this property") if !$proto; &$add_error('dport', "protocol '$proto' does not support ports") - if !$PROTOCOLS_WITH_PORTS->{$proto} && - $proto ne 'icmp' && $proto ne 'icmpv6'; # special cases + if !$PROTOCOLS_WITH_PORTS->{$proto} && !$is_icmp; #special cases + } + + if (my $icmp_type = $rule ->{'icmp-type'}) { + my $proto = $rule->{proto}; + &$add_error('proto', "missing property - 'icmp-type' requires this property") + if !$is_icmp; + &$add_error('icmp-type', "'icmp-type' cannot be specified together with 'dport'") + if $rule->{dport}; + if ($proto eq 'icmp' && !$icmp_type_names->{$icmp_type}) { + &$add_error('icmp-type', "invalid icmp-type '$icmp_type' for proto 'icmp'"); + } elsif (($proto eq 'icmpv6' || $proto eq 'ipv6-icmp') && !$icmpv6_type_names->{$icmp_type}) { + &$add_error('icmp-type', "invalid icmp-type '$icmp_type' for proto '$proto'"); + } } if ($rule->{sport}) { @@ -1771,11 +1893,9 @@ sub rules_audit_permissions { } # core functions -my $bridge_firewall_enabled = 0; sub enable_bridge_firewall { - return if $bridge_firewall_enabled; # only once PVE::ProcFSTools::write_proc_entry("/proc/sys/net/bridge/bridge-nf-call-iptables", "1"); PVE::ProcFSTools::write_proc_entry("/proc/sys/net/bridge/bridge-nf-call-ip6tables", "1"); @@ -1783,7 +1903,6 @@ sub enable_bridge_firewall { # make sure syncookies are enabled (which is default on newer 3.X kernels anyways) PVE::ProcFSTools::write_proc_entry("/proc/sys/net/ipv4/tcp_syncookies", "1"); - $bridge_firewall_enabled = 1; } sub iptables_restore_cmdlist { @@ -1905,6 +2024,8 @@ sub ipset_get_chains { return if $line =~ m/^\s*$/; if ($line =~ m/^(?:\S+)\s(PVEFW-\S+)\s(?:\S+).*/) { my $chain = $1; + # ignore initval from ipset v7.7+, won't set that yet so it'd mess up change detection + $line =~ s/\binitval 0x[0-9a-f]+//; $line =~ s/\s+$//; # delete trailing white space push @{$chains->{$chain}}, $line; } else { @@ -1927,13 +2048,22 @@ sub ebtables_get_chains { my $res = {}; my $chains = {}; + my $table; my $parser = sub { my $line = shift; return if $line =~ m/^#/; return if $line =~ m/^\s*$/; - if ($line =~ m/^:(\S+)\s\S+$/) { + if ($line =~ m/^\*(\S+)$/) { + $table = $1; + return; + } + + return if $table ne "filter"; + + if ($line =~ m/^:(\S+)\s(ACCEPT|DROP|RETURN)$/) { # Make sure we know chains exist even if they're empty. $chains->{$1} //= []; + $res->{$1}->{policy} = $2; } elsif ($line =~ m/^(?:\S+)\s(\S+)\s(?:\S+).*/) { my $chain = $1; $line =~ s/\s+$//; @@ -1953,7 +2083,7 @@ sub ebtables_get_chains { return $res; } -# substitude action of rule according to action hash +# substitute action of rule according to action hash sub rule_substitude_action { my ($rule, $actions) = @_; @@ -1978,12 +2108,13 @@ sub ipt_gen_src_or_dst_match { my $match; if ($adr =~ m/^\+/) { - if ($adr =~ m/^\+(${ipset_name_pattern})$/) { - my $name = $1; + if ($adr =~ m@^\+(guest/|dc/)?(${ipset_name_pattern})$@) { + my $scope = $1 // ""; + my $name = $2; my $ipset_chain; - if ($fw_conf && $fw_conf->{ipset}->{$name}) { + if ($scope ne 'dc/' && $fw_conf && $fw_conf->{ipset}->{$name}) { $ipset_chain = compute_ipset_chain_name($fw_conf->{vmid}, $name, $ipversion); - } elsif ($cluster_conf && $cluster_conf->{ipset}->{$name}) { + } elsif ($scope ne 'guest/' && $cluster_conf && $cluster_conf->{ipset}->{$name}) { $ipset_chain = compute_ipset_chain_name(0, $name, $ipversion); } else { die "no such ipset '$name'\n"; @@ -1992,10 +2123,16 @@ sub ipt_gen_src_or_dst_match { } else { die "invalid security group name '$adr'\n"; } - } elsif ($adr =~ m/^${ip_alias_pattern}$/){ - my $alias = lc($adr); - my $e = $fw_conf ? $fw_conf->{aliases}->{$alias} : undef; - $e = $cluster_conf->{aliases}->{$alias} if !$e && $cluster_conf; + } elsif ($adr =~ m@^(dc/|guest/)?(${ip_alias_pattern})$@){ + my $scope = $1 // ""; + my $alias = lc($2); + my $e; + if ($scope ne 'dc/' && $fw_conf) { + $e = $fw_conf->{aliases}->{$alias}; + } + if ($scope ne 'guest/' && !$e && $cluster_conf) { + $e = $cluster_conf->{aliases}->{$alias}; + } die "no such alias '$adr'\n" if !$e; $match = "-${dir} $e->{cidr}"; } elsif ($adr =~ m/\-/){ @@ -2030,28 +2167,27 @@ sub ipt_rule_to_cmds { if (my $proto = $rule->{proto}) { push @match, "-p $proto"; + my $is_icmp = $proto_is_icmp->($proto); - my $multidport = defined($rule->{dport}) && parse_port_name_number_or_range($rule->{dport}, 1); + my $multidport = defined($rule->{dport}) && parse_port_name_number_or_range($rule->{dport}, $is_icmp); my $multisport = defined($rule->{sport}) && parse_port_name_number_or_range($rule->{sport}, 0); my $add_dport = sub { - return if !$rule->{dport}; + return if !defined($rule->{dport}); + # NOTE: we re-use dport to store --icmp-type for icmp* protocol if ($proto eq 'icmp') { - # Note: we use dport to store --icmp-type - die "unknown icmp-type '$rule->{dport}'\n" - if $rule->{dport} !~ /^\d+$/ && !defined($icmp_type_names->{$rule->{dport}}); + $is_valid_icmp_type->($rule->{dport}, $icmp_type_names); push @match, "-m icmp --icmp-type $rule->{dport}"; } elsif ($proto eq 'icmpv6') { - # Note: we use dport to store --icmpv6-type - die "unknown icmpv6-type '$rule->{dport}'\n" - if $rule->{dport} !~ /^\d+$/ && !defined($icmpv6_type_names->{$rule->{dport}}); + $is_valid_icmp_type->($rule->{dport}, $icmpv6_type_names); push @match, "-m icmpv6 --icmpv6-type $rule->{dport}"; } elsif (!$PROTOCOLS_WITH_PORTS->{$proto}) { die "protocol $proto does not have ports\n"; } elsif ($multidport) { push @match, "--match multiport", "--dports $rule->{dport}"; } else { + return if !$rule->{dport}; push @match, "--dport $rule->{dport}"; } }; @@ -2068,7 +2204,18 @@ sub ipt_rule_to_cmds { } }; + my $add_icmp_type = sub { + return if !defined($rule->{'icmp-type'}) || $rule->{'icmp-type'} eq ''; + + die "'icmp-type' can only be set if 'icmp', 'icmpv6' or 'ipv6-icmp' is specified\n" + if !$is_icmp; + my $type = $proto eq 'icmp' ? 'icmp-type' : 'icmpv6-type'; + + push @match, "-m $proto --$type $rule->{'icmp-type'}"; + }; + # order matters - single port before multiport! + $add_icmp_type->(); $add_dport->() if $multisport; $add_sport->(); $add_dport->() if !$multisport; @@ -2086,8 +2233,7 @@ sub ipt_rule_to_cmds { $targetstr = $rule->{target}; } else { my $action = (defined $rule->{action}) ? $rule->{action} : ""; - my $goto = 1 if $action eq 'PVEFW-SET-ACCEPT-MARK'; - $targetstr = ($goto) ? "-g $action" : "-j $action"; + $targetstr = $action eq 'PVEFW-SET-ACCEPT-MARK' ? "-g $action" : "-j $action"; } my @iptcmds; @@ -2401,7 +2547,8 @@ sub generate_tap_rules_direction { my $tapchain = "$iface-$direction"; my $ipfilter_name = compute_ipfilter_ipset_name($netid); - my $ipfilter_ipset = compute_ipset_chain_name($vmid, $ipfilter_name, $ipversion) + my $ipfilter_ipset; + $ipfilter_ipset = compute_ipset_chain_name($vmid, $ipfilter_name, $ipversion) if $options->{ipfilter} || $vmfw_conf->{ipset}->{$ipfilter_name}; if ($options->{enable}) { @@ -2491,6 +2638,7 @@ sub enable_host_firewall { $rule->{iface_in} = $rule->{iface} if $rule->{iface}; eval { + $rule->{logmsg} = "$rule->{action}: "; if ($rule->{type} eq 'group') { ruleset_add_group_rule($ruleset, $cluster_conf, $chain, $rule, 'IN', $accept_action, $ipversion); } elsif ($rule->{type} eq 'in') { @@ -2691,32 +2839,36 @@ sub parse_fw_rule { last if $rule->{type} eq 'group'; - if ($line =~ s/^-p (\S+)\s*//) { + if ($line =~ s/^(?:-p|--?proto) (\S+)\s*//) { $rule->{proto} = $1; next; } - if ($line =~ s/^-dport (\S+)\s*//) { + if ($line =~ s/^--?dport (\S+)\s*//) { $rule->{dport} = $1; next; } - if ($line =~ s/^-sport (\S+)\s*//) { + if ($line =~ s/^--?sport (\S+)\s*//) { $rule->{sport} = $1; next; } - if ($line =~ s/^-source (\S+)\s*//) { + if ($line =~ s/^--?source (\S+)\s*//) { $rule->{source} = $1; next; } - if ($line =~ s/^-dest (\S+)\s*//) { + if ($line =~ s/^--?dest (\S+)\s*//) { $rule->{dest} = $1; next; } - if ($line =~ s/^-log (emerg|alert|crit|err|warning|notice|info|debug|nolog)\s*//) { + if ($line =~ s/^--?log (emerg|alert|crit|err|warning|notice|info|debug|nolog)\s*//) { $rule->{log} = $1; next; } + if ($line =~ s/^--?icmp-type (\S+)\s*//) { + $rule->{'icmp-type'} = $1; + next; + } last; } @@ -2783,12 +2935,16 @@ sub parse_hostfw_option { my $loglevels = "emerg|alert|crit|err|warning|notice|info|debug|nolog"; - if ($line =~ m/^(enable|nosmurfs|tcpflags|ndp|log_nf_conntrack|nf_conntrack_allow_invalid|protection_synflood):\s*(0|1)\s*$/i) { + if ($line =~ m/^(enable|nosmurfs|tcpflags|ndp|log_nf_conntrack|nf_conntrack_allow_invalid|protection_synflood|nftables):\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) { $opt = lc($1); $value = $2 ? lc($3) : ''; + } elsif ($line =~ m/^(nf_conntrack_helpers):\s*(((\S+)[,]?)+)\s*$/i) { + $opt = lc($1); + $value = lc($2); + pve_fw_verify_conntrack_helper($value); } elsif ($line =~ m/^(nf_conntrack_max|nf_conntrack_tcp_timeout_established|nf_conntrack_tcp_timeout_syn_recv|protection_synflood_rate|protection_synflood_burst|protection_limit):\s*(\d+)\s*$/i) { $opt = lc($1); $value = int($2); @@ -2827,11 +2983,26 @@ sub parse_clusterfw_option { } sub resolve_alias { - my ($clusterfw_conf, $fw_conf, $cidr) = @_; + my ($clusterfw_conf, $fw_conf, $cidr, $scope) = @_; + + # When we're on the cluster level, the cluster config only gets + # saved into fw_conf, so we need some extra handling here (to + # stay consistent) + my ($cluster_config, $local_config); + if (!$clusterfw_conf) { + ($cluster_config, $local_config) = ($fw_conf, undef); + } else { + ($cluster_config, $local_config) = ($clusterfw_conf, $fw_conf); + } my $alias = lc($cidr); - my $e = $fw_conf ? $fw_conf->{aliases}->{$alias} : undef; - $e = $clusterfw_conf->{aliases}->{$alias} if !$e && $clusterfw_conf; + my $e; + if ($scope ne 'dc/' && $local_config) { + $e = $local_config->{aliases}->{$alias}; + } + if ($scope ne 'guest/' && !$e && $cluster_config) { + $e = $cluster_config->{aliases}->{$alias}; + } die "no such alias '$cidr'\n" if !$e;; @@ -2860,7 +3031,7 @@ sub parse_alias { my ($line) = @_; # we can add single line comments to the end of the line - my $comment = decode('utf8', $1) if $line =~ s/\s*#\s*(.*?)\s*$//; + my $comment = $line =~ s/\s*#\s*(.*?)\s*$// ? decode('utf8', $1) : undef; if ($line =~ m/^(\S+)\s(\S+)$/) { my ($name, $cidr) = ($1, $2); @@ -2896,6 +3067,8 @@ sub generic_fw_config_parser { } return {} if !$raw; + my $curr_group_keys = {}; + my $linenr = 0; while ($raw =~ /^\h*(.*?)\h*$/gm) { my $line = $1; @@ -2956,6 +3129,8 @@ sub generic_fw_config_parser { } $res->{$section}->{$group} = []; + $curr_group_keys = {}; + $res->{ipset_comments}->{$group} = decode('utf8', $comment) if $comment; next; @@ -3003,7 +3178,7 @@ sub generic_fw_config_parser { push @{$res->{$section}->{$group}}, $rule; } elsif ($section eq 'ipset') { # we can add single line comments to the end of the rule - my $comment = decode('utf8', $1) if $line =~ s/#\s*(.*?)\s*$//; + my $comment = $line =~ s/#\s*(.*?)\s*$// ? decode('utf8', $1) : undef; $line =~ m/^(\!)?\s*(\S+)\s*$/; my $nomatch = $1; @@ -3015,11 +3190,15 @@ sub generic_fw_config_parser { } eval { - if ($cidr =~ m/^${ip_alias_pattern}$/) { - resolve_alias($cluster_conf, $res, $cidr); # make sure alias exists + if ($cidr =~ m@^(dc/|guest/)?(${ip_alias_pattern}$)@) { + my $scope = $1 // ""; + my $alias = $2; + resolve_alias($cluster_conf, $res, $alias, $scope); # make sure alias exists } else { $cidr = parse_ip_or_cidr($cidr); } + die "duplicate ipset entry for '$cidr'\n" + if defined($curr_group_keys->{$cidr}); }; if (my $err = $@) { chomp $err; @@ -3043,6 +3222,7 @@ sub generic_fw_config_parser { } push @{$res->{$section}->{$group}}, $entry; + $curr_group_keys->{$cidr} = 1; } else { warn "$prefix: skip line - unknown section\n"; next; @@ -3052,6 +3232,8 @@ sub generic_fw_config_parser { return $res; } +# this is only used to prevent concurrent runs of rule compilation/application +# see lock_*_conf for cfs locks protectiong config modification sub run_locked { my ($code, @param) = @_; @@ -3088,18 +3270,23 @@ sub read_local_vm_config { } } } elsif ($d->{type} eq 'lxc') { - if ($have_lxc) { - my $cfspath = PVE::LXC::Config->cfs_config_path($vmid); - if (my $conf = PVE::Cluster::cfs_read_file($cfspath)) { - $lxc->{$vmid} = $conf; - } - } - } + if ($have_lxc) { + my $cfspath = PVE::LXC::Config->cfs_config_path($vmid); + if (my $conf = PVE::Cluster::cfs_read_file($cfspath)) { + $lxc->{$vmid} = $conf; + } + } + } } return $vmdata; }; +# FIXME: move use sites over to moved helper and break older packages, then remove this here +sub lock_vmfw_conf { + return PVE::Firewall::Helpers::lock_vmfw_conf(@_); +} + sub load_vmfw_conf { my ($cluster_conf, $rule_env, $vmid, $dir) = @_; @@ -3145,6 +3332,7 @@ my $format_rules = sub { $raw .= " -dport $rule->{dport}" if $rule->{dport}; $raw .= " -sport $rule->{sport}" if $rule->{sport}; $raw .= " -log $rule->{log}" if $rule->{log}; + $raw .= " -icmp-type $rule->{'icmp-type'}" if defined($rule->{'icmp-type'}) && $rule->{'icmp-type'} ne ''; } $raw .= " # " . encode('utf8', $rule->{comment}) @@ -3178,7 +3366,7 @@ my $format_aliases = sub { my $raw = ''; $raw .= "[ALIASES]\n\n"; - foreach my $k (keys %$aliases) { + foreach my $k (sort keys %$aliases) { my $e = $aliases->{$k}; $raw .= "$e->{name} $e->{cidr}"; $raw .= " # " . encode('utf8', $e->{comment}) @@ -3206,7 +3394,13 @@ my $format_ipsets = sub { my $nethash = {}; foreach my $entry (@$options) { - $nethash->{$entry->{cidr}} = $entry; + my $cidr = $entry->{cidr}; + if (defined($nethash->{$cidr})) { + warn "ignoring duplicate ipset entry '$cidr'\n"; + next; + } + + $nethash->{$cidr} = $entry; } foreach my $cidr (sort keys %$nethash) { @@ -3253,27 +3447,14 @@ sub save_vmfw_conf { } } +# FIXME: remove with 8.0 and break older qemu-server/pve-container sub remove_vmfw_conf { - my ($vmid) = @_; - - my $vmfw_conffile = "$pvefw_conf_dir/$vmid.fw"; - - unlink $vmfw_conffile; + return PVE::Firewall::Helpers::remove_vmfw_conf(@_); } +# FIXME: remove with 8.0 and break older qemu-server/pve-container sub clone_vmfw_conf { - my ($vmid, $newid) = @_; - - my $sourcevm_conffile = "$pvefw_conf_dir/$vmid.fw"; - my $clonevm_conffile = "$pvefw_conf_dir/$newid.fw"; - - if (-f $clonevm_conffile) { - unlink $clonevm_conffile; - } - if (-f $sourcevm_conffile) { - my $data = PVE::Tools::file_get_contents($sourcevm_conffile); - PVE::Tools::file_set_contents($clonevm_conffile, $data); - } + return PVE::Firewall::Helpers::clone_vmfw_conf(@_); } sub read_vm_firewall_configs { @@ -3363,8 +3544,10 @@ sub generate_ipset_chains { next if $entry->{errors}; # skip entries with errors eval { my ($cidr, $ver); - if ($entry->{cidr} =~ m/^${ip_alias_pattern}$/) { - ($cidr, $ver) = resolve_alias($clusterfw_conf, $fw_conf, $entry->{cidr}); + if ($entry->{cidr} =~ m@^(dc/|guest/)?(${ip_alias_pattern})$@) { + my $scope = $1 // ""; + my $alias = $2; + ($cidr, $ver) = resolve_alias($clusterfw_conf, $fw_conf, $alias, $scope); } else { ($cidr, $ver) = parse_ip_or_cidr($entry->{cidr}); } @@ -3396,9 +3579,13 @@ sub generate_ipset_chains { $hashsize = round_powerof2($hashsize); } + my $bucketsize = 12; # lower than the default of 14, faster but slightly more memory use + my $family = $ipversion == "6" ? "inet6" : "inet"; - $ipset_ruleset->{$name} = ["create $name hash:net family $family hashsize $hashsize maxelem $hashsize"]; + $ipset_ruleset->{$name} = [ + "create $name hash:net family $family hashsize $hashsize maxelem $hashsize bucketsize $bucketsize" + ]; foreach my $cidr (sort keys %$data) { my $entry = $data->{$cidr}; @@ -3447,6 +3634,15 @@ my $set_global_log_ratelimit = sub { } }; +sub lock_clusterfw_conf { + my ($timeout, $code, @param) = @_; + + my $res = PVE::Cluster::cfs_lock_firewall("cluster", $timeout, $code, @param); + die $@ if $@; + + return $res; +} + sub load_clusterfw_conf { my ($filename) = @_; @@ -3510,6 +3706,17 @@ sub save_clusterfw_conf { } } +sub lock_hostfw_conf : prototype($$$@) { + my ($node, $timeout, $code, @param) = @_; + + $node = $nodename if !defined($node); + + my $res = PVE::Cluster::cfs_lock_firewall("host-$node", $timeout, $code, @param); + die $@ if $@; + + return $res; +} + sub load_hostfw_conf { my ($cluster_conf, $filename) = @_; @@ -3520,7 +3727,9 @@ sub load_hostfw_conf { } sub save_hostfw_conf { - my ($hostfw_conf) = @_; + my ($hostfw_conf, $filename) = @_; + + $filename = $hostfw_conf_filename if !defined($filename); my $raw = ''; @@ -3535,9 +3744,9 @@ sub save_hostfw_conf { } if ($raw) { - PVE::Tools::file_set_contents($hostfw_conf_filename, $raw); + PVE::Tools::file_set_contents($filename, $raw); } else { - unlink $hostfw_conf_filename; + unlink $filename; } } @@ -3605,6 +3814,9 @@ sub compile_iptables_raw { my $hostfw_options = $hostfw_conf->{options} || {}; my $protection_synflood = $hostfw_options->{protection_synflood} || 0; + my $conntrack_helpers = $hostfw_options->{nf_conntrack_helpers} || ''; + + ruleset_create_chain($ruleset, "PVEFW-PREROUTING") if $protection_synflood != 0 || $conntrack_helpers ne ''; if($protection_synflood) { @@ -3615,10 +3827,14 @@ sub compile_iptables_raw { $protection_synflood_expire = $protection_synflood_expire * 1000; my $protection_synflood_mask = $ipversion == 4 ? 32 : 64; - ruleset_create_chain($ruleset, "PVEFW-PREROUTING"); ruleset_addrule($ruleset, "PVEFW-PREROUTING", "-p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -m hashlimit --hashlimit-above $protection_synflood_rate/sec --hashlimit-burst $protection_synflood_burst --hashlimit-mode srcip --hashlimit-name syn --hashlimit-htable-size 2097152 --hashlimit-srcmask $protection_synflood_mask --hashlimit-htable-expire $protection_synflood_expire", "-j DROP"); } + foreach my $conntrack_helper (split(/,/, $conntrack_helpers)) { + my $helper = $pve_fw_helpers->{$conntrack_helper}; + ruleset_addrule($ruleset, "PVEFW-PREROUTING", "-p $helper->{proto} -m $helper->{proto} --dport $helper->{dport} -j CT", "--helper $conntrack_helper") if $helper && $helper->{"v$ipversion"}; + } + return $ruleset; } @@ -3749,7 +3965,7 @@ sub compile_ipsets { 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 + # is no 'ipfilter-netX' ipset defined gets an implicit empty default # ipset. # The reason is that ipfilter ipsets are always filled with standard # IPv6 link-local filters. @@ -3788,7 +4004,7 @@ sub compile_ipsets { 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 + # is no 'ipfilter-netX' ipset defined 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 @@ -3853,7 +4069,7 @@ sub compile_ebtables_filter { eval { my $conf = $vmdata->{qemu}->{$vmid}; my $vmfw_conf = $vmfw_configs->{$vmid}; - return if !$vmfw_conf; + return if !$vmfw_conf || !$vmfw_conf->{options}->{enable}; my $ipsets = $vmfw_conf->{ipset}; foreach my $netid (sort keys %$conf) { @@ -3903,7 +4119,9 @@ sub compile_ebtables_filter { # ebtables changes this to a .0/MASK network but we just # want the address here, no network - see #2193 $ip =~ s|/(\d+)$||; - push @$arpfilter, $ip; + if ($ip ne 'dhcp') { + push @$arpfilter, $ip; + } } generate_tap_layer2filter($ruleset, $iface, $macaddr, $vmfw_conf, $vmid, $arpfilter); } @@ -3927,7 +4145,7 @@ sub generate_tap_layer2filter { ruleset_create_chain($ruleset, $tapchain); if (defined($macaddr) && !(defined($options->{macfilter}) && $options->{macfilter} == 0)) { - ruleset_addrule($ruleset, $tapchain, "-s ! $macaddr", '-j DROP'); + ruleset_addrule($ruleset, $tapchain, "-s ! $macaddr", '-j DROP'); } if (@$arpfilter){ @@ -4002,6 +4220,7 @@ sub get_ruleset_status { if (defined($change_only_regex)) { $action = 'ignore' if ($chain !~ m/$change_only_regex/); $statushash->{$chain}->{rules} = $active_chains->{$chain}->{rules}; + $statushash->{$chain}->{policy} = $active_chains->{$chain}->{policy}; $sig = $sig->{sig}; } $statushash->{$chain}->{action} = $action; @@ -4102,7 +4321,8 @@ sub get_ebtables_cmdlist { my $pve_include = 0; foreach my $chain (sort keys %$statushash) { next if ($statushash->{$chain}->{action} eq 'delete'); - $cmdlist .= ":$chain ACCEPT\n"; + my $policy = $statushash->{$chain}->{policy} // 'ACCEPT'; + $cmdlist .= ":$chain $policy\n"; $pve_include = 1 if ($chain eq 'PVEFW-FORWARD'); } @@ -4459,28 +4679,54 @@ sub remove_pvefw_chains_ebtables { ebtables_restore_cmdlist(get_ebtables_cmdlist({})); } -sub init { - my $cluster_conf = load_clusterfw_conf(); - my $cluster_options = $cluster_conf->{options}; - my $enable = $cluster_options->{enable}; +sub is_nftables { + my ($cluster_conf, $host_conf) = @_; + + if (!-x "/usr/libexec/proxmox/proxmox-firewall") { + return 0; + } + + $cluster_conf = load_clusterfw_conf() if !defined($cluster_conf); + $host_conf = load_hostfw_conf($cluster_conf) if !defined($host_conf); - return if !$enable; + return $host_conf->{options}->{nftables}; +} + +sub is_enabled_and_not_nftables { + my ($cluster_conf, $host_conf) = @_; + + $cluster_conf = load_clusterfw_conf() if !defined($cluster_conf); + $host_conf = load_hostfw_conf($cluster_conf) if !defined($host_conf); + + return $cluster_conf->{options}->{enable} && !is_nftables($cluster_conf, $host_conf); +} + +sub init { + return if !is_enabled_and_not_nftables(); # load required modules here } +# This is checked in proxmox-firewall to avoid log-spam due to failing to parse the config +my $FORCE_NFT_DISABLE_FLAG_FILE = "/run/proxmox-nftables-firewall-force-disable"; + sub update { my $code = sub { my $cluster_conf = load_clusterfw_conf(); - my $cluster_options = $cluster_conf->{options}; + my $hostfw_conf = load_hostfw_conf($cluster_conf); - if (!$cluster_options->{enable}) { + if (!is_enabled_and_not_nftables($cluster_conf, $hostfw_conf)) { + unlink($FORCE_NFT_DISABLE_FLAG_FILE) + or $!{ENOENT} or warn "failed to unlink flag file '$FORCE_NFT_DISABLE_FLAG_FILE' - $!\n"; PVE::Firewall::remove_pvefw_chains(); return; } + if (! -e $FORCE_NFT_DISABLE_FLAG_FILE) { + open(my $_fh, '>', $FORCE_NFT_DISABLE_FLAG_FILE) + or warn "failed to create flag file '$FORCE_NFT_DISABLE_FLAG_FILE' – $!\n"; + } - my $hostfw_conf = load_hostfw_conf($cluster_conf); my ($ruleset, $ipset_ruleset, $rulesetv6, $ebtables_ruleset) = compile($cluster_conf, $hostfw_conf);