Files
ubicloud/prog/vnet/update_firewall_rules.rb
Furkan Sahin d503dadcd3 Add private_ipv4 and private_ipv6 utility functions
Our code base had many vm.nics.first.private_ipv(4/6) calls. This commit
moves these into 2 util functions in the vm model and reuses that to
improve code readability and maintainability.
2025-01-30 12:24:20 +01:00

295 lines
12 KiB
Ruby

# frozen_string_literal: true
class Prog::Vnet::UpdateFirewallRules < Prog::Base
subject_is :vm
FirewallRuleObj = Struct.new(:cidr, :port_range)
def before_run
pop "firewall rule is added" if vm.destroy_set?
end
label def update_firewall_rules
rules = vm.firewalls.map(&:firewall_rules).flatten
allowed_ingress_ip4_port_set, allowed_ingress_ip4_lb_dest_set = consolidate_rules(rules.select { !_1.ip6? && _1.port_range })
allowed_ingress_ip6_port_set, allowed_ingress_ip6_lb_dest_set = consolidate_rules(rules.select { _1.ip6? && _1.port_range })
guest_ephemeral, clover_ephemeral = subdivide_network(vm.ephemeral_net6).map(&:to_s)
globally_blocked_ipv4s, globally_blocked_ipv6s = generate_globally_blocked_lists
load_balancer_allow_rule = if vm.load_balancer
allow_ipv4_lb_neigh_incoming = "ip saddr . tcp sport { #{vm.load_balancer.vms.reject { _1.id == vm.id }.map { "#{_1.private_ipv4} . #{vm.load_balancer.src_port}" }.join(", ")} } ct state established,related,new counter accept" if vm.load_balancer.vms.reject { _1.id == vm.id }.any?
allow_ipv6_lb_neigh_incoming = "ip6 saddr . tcp sport { #{vm.load_balancer.vms.reject { _1.id == vm.id }.map { "#{_1.private_ipv6} . #{vm.load_balancer.src_port}" }.join(", ")} } ct state established,related,new counter accept" if vm.load_balancer.vms.reject { _1.id == vm.id }.any?
<<~LOAD_BALANCER_ALLOW_RULE
#{allow_ipv4_lb_neigh_incoming}
#{allow_ipv6_lb_neigh_incoming}
# The traffic that is routed to the local VM from the load balancer
# is marked with 0x00B1C100D. We need to allow this traffic to
# the local VM.
meta mark 0x00B1C100D ip saddr . tcp dport @allowed_ipv4_lb_dest_set ct state established,related,new counter accept
meta mark 0x00B1C100D ip6 saddr . tcp dport @allowed_ipv6_lb_dest_set ct state established,related,new counter accept
LOAD_BALANCER_ALLOW_RULE
else
""
end
vm.vm_host.sshable.cmd("sudo ip netns exec #{vm.inhost_name} nft --file -", stdin: <<TEMPLATE)
# An nftables idiom for idempotent re-create of a named entity: merge
# in an empty table (a no-op if the table already exists) and then
# delete, before creating with a new definition.
table inet fw_table;
delete table inet fw_table;
table inet fw_table {
set allowed_ipv4_port_tuple {
type ipv4_addr . inet_service;
flags interval;
#{allowed_ingress_ip4_port_set.empty? ? "" : "elements = {#{allowed_ingress_ip4_port_set}}"}
}
set allowed_ipv4_lb_dest_set {
type ipv4_addr . inet_service;
flags interval;
#{allowed_ingress_ip4_lb_dest_set.empty? ? "" : "elements = {#{allowed_ingress_ip4_lb_dest_set}}"}
}
set allowed_ipv6_port_tuple {
type ipv6_addr . inet_service;
flags interval;
#{allowed_ingress_ip6_port_set.empty? ? "" : "elements = {#{allowed_ingress_ip6_port_set}}"}
}
set allowed_ipv6_lb_dest_set {
type ipv6_addr . inet_service;
flags interval;
#{allowed_ingress_ip6_lb_dest_set.empty? ? "" : "elements = {#{allowed_ingress_ip6_lb_dest_set}}"}
}
set private_ipv4_cidrs {
type ipv4_addr;
flags interval;
elements = {
#{generate_private_ip4_list}
}
}
set private_ipv6_cidrs {
type ipv6_addr
flags interval
elements = { #{generate_private_ip6_list} }
}
set globally_blocked_ipv4s {
type ipv4_addr;
flags interval;
#{globally_blocked_ipv4s.empty? ? "" : "elements = {#{globally_blocked_ipv4s}}"}
}
set globally_blocked_ipv6s {
type ipv6_addr;
flags interval;
#{globally_blocked_ipv6s.empty? ? "" : "elements = {#{globally_blocked_ipv6s}}"}
}
flowtable ubi_flowtable {
hook ingress priority filter
devices = { #{vm.nics.map(&:ubid_to_tap_name).join(",")} }
}
chain forward_ingress {
type filter hook forward priority filter; policy drop;
# Offload to ubi_flowtable. This is used to offload already filtered
# traffic to reduce the latency.
meta l4proto { tcp, udp } flow offload @ubi_flowtable
# Destination port 111 is reserved for the portmapper. We block it to
# prevent abuse.
meta l4proto { tcp, udp } th dport 111 drop
# Drop all traffic from globally blocked IPs. This is mainly used to
# block access to malicious IPs that are known to cause issues on the
# internet.
ip saddr @globally_blocked_ipv4s drop
ip6 saddr @globally_blocked_ipv6s drop
ip daddr @globally_blocked_ipv4s drop
ip6 daddr @globally_blocked_ipv6s drop
# If we are using @private_ipv4_cidrs as source address, we allow all
# established,related,new traffic because this is outgoing traffic.
ip saddr @private_ipv4_cidrs ct state established,related,new counter accept
# If we are using clover_ephemeral, that means we are using ipsec. We need
# to allow traffic for the private communication and block via firewall
# rules through @allowed_ipv4_port_tuple and @allowed_ipv6_port_tuple in the
# next section of rules.
ip6 daddr #{clover_ephemeral} counter accept
ip6 saddr #{clover_ephemeral} counter accept
# Allow TCP and UDP traffic for allowed_ipv4_port_tuple and
# allowed_ipv6_port_tuple into the VM using any address, such as;
# - public ipv4
# - private ipv4
# - public ipv6 (guest_ephemeral)
# - private ipv6
# - private clover ephemeral ipv6
ip saddr . tcp dport @allowed_ipv4_port_tuple ct state established,related,new counter accept
ip saddr . udp dport @allowed_ipv4_port_tuple ct state established,related,new counter accept
ip6 saddr . tcp dport @allowed_ipv6_port_tuple ct state established,related,new counter accept
ip6 saddr . udp dport @allowed_ipv6_port_tuple ct state established,related,new counter accept
# Allow outgoing traffic from the VM using the following addresses as
# source address.
ip6 saddr @private_ipv6_cidrs ct state established,related,new counter accept
ip6 saddr #{guest_ephemeral} ct state established,related,new counter accept
# Allow incoming traffic to the VM using the following addresses as
# destination address. This is needed to allow the return traffic.
ip6 daddr @private_ipv6_cidrs ct state established,related counter accept
ip6 daddr #{guest_ephemeral} ct state established,related counter accept
ip daddr @private_ipv4_cidrs ct state established,related counter accept
# Allow ping for all
ip saddr 0.0.0.0/0 icmp type echo-request counter accept
ip daddr 0.0.0.0/0 icmp type echo-request counter accept
ip saddr 0.0.0.0/0 icmp type echo-reply counter accept
ip daddr 0.0.0.0/0 icmp type echo-reply counter accept
ip6 saddr ::/0 icmpv6 type echo-request counter accept
ip6 daddr ::/0 icmpv6 type echo-request counter accept
ip6 saddr ::/0 icmpv6 type echo-reply counter accept
ip6 daddr ::/0 icmpv6 type echo-reply counter accept
# Allow load balancer traffic
#{load_balancer_allow_rule}
}
}
TEMPLATE
pop "firewall rule is added"
end
def generate_globally_blocked_lists
globally_blocked_ipv4s = []
globally_blocked_ipv6s = []
GloballyBlockedDnsname.each do |globally_blocked_dnsname|
ips = globally_blocked_dnsname.ip_list || []
ips.each do |ip|
globally_blocked_ipv4s << "#{ip}/32" if ip.ipv4?
globally_blocked_ipv6s << "#{ip}/128" if ip.ipv6?
end
end
summ_ipv4 = NetAddr.summ_IPv4Net(globally_blocked_ipv4s.map { NetAddr::IPv4Net.parse(_1.to_s) })
summ_ipv6 = NetAddr.summ_IPv6Net(globally_blocked_ipv6s.map { NetAddr::IPv6Net.parse(_1.to_s) })
[summ_ipv4.join(", "), summ_ipv6.join(", ")]
end
# This method is needed to properly consolidate port_ranges + cidrs.
# For example, if we have the following rules:
# 1. 10.10.10.8/29 . 80-8080
# 2. 10.10.10.0/27 . 5432-10000
#
# We can't just merge the cidrs because the port ranges overlap. We need to
# first identify where the overlap is in the port ranges and then merge the
# cidrs for the overlapping port ranges. The result should be:
# 1. 10.10.10.8/29 . 80-5431
# 2. 10.10.10.0/27 . 5432-10000
#
# In the processing of these 2 rules, we first identify the port segments as;
# 1. 80-5431
# 2. 5432-8080
# 3. 8081-10000
#
# Then we identify the cidrs for each segment:
# 1. 10.10.10.8/29 (This is simply used from the first rule because the first
# rule is the only rule that has a cidr that overlaps with this segment)
# 2. 10.10.10.8/29 + 10.10.10.0/27: The combination of these will result in
# 10.10.10.0/27
# 3. 10.10.10.0/27 (This is simply used from the second rule because the
# second rule is the only rule that has a cidr that overlaps with this
# segment)
#
# For the combination of the cidrs, we use the summ_IPv4/6Net method from the
# netaddr gem. This method will combine the cidrs and remove any duplicates.
# If we don't perform this combination, we will end up with an error from
# nftables saying file exists.
#
# Additionally, the customers may have thousands of rules and possibly, they
# overlap. We want to minimize the number of rules that we create on the
# nftables side to avoid performance issues.
def consolidate_rules(rules)
port_segments = create_port_segments(rules)
consolidated_rules = []
port_segments.each do |segment|
# Find rules that overlap with the current segment
overlapping_rules = rules.select do |r|
r.port_range.begin <= segment[:end] && r.port_range.end - 1 >= segment[:begin]
end
# Merge cidrs for overlapping rules
merged_cidrs = if rules.first.cidr.version == 4
NetAddr.summ_IPv4Net(overlapping_rules.map(&:cidr))
else
NetAddr.summ_IPv6Net(overlapping_rules.map(&:cidr))
end
merged_cidrs.each do |cidr|
consolidated_rules << FirewallRuleObj.new(cidr, {begin: segment[:begin], end: segment[:end] + 1})
end
end
combined_rules = combine_continuous_ranges_for_same_subnet(consolidated_rules)
combined_rules_self = combined_rules.map do |r|
if r.port_range[:begin] != r.port_range[:end] - 1
"#{r.cidr} . #{r.port_range[:begin]}-#{r.port_range[:end] - 1}"
else
"#{r.cidr} . #{r.port_range[:begin]}"
end
end.join(",")
combined_rules_lb_dest = vm.load_balancer ? combined_rules.filter_map do |r|
if r.port_range[:begin] <= vm.load_balancer.src_port && vm.load_balancer.src_port <= r.port_range[:end] - 1
"#{r.cidr} . #{vm.load_balancer.dst_port}"
end
end.join(",") : []
[combined_rules_self, combined_rules_lb_dest]
end
def combine_continuous_ranges_for_same_subnet(rules)
rules.sort_by { |r| [r.cidr.to_s, r.port_range[:begin]] }.chunk_while { |a, b| a.cidr.to_s == b.cidr.to_s && a.port_range[:end] == b.port_range[:begin] }.map do |chunk|
if chunk.size > 1
FirewallRuleObj.new(chunk.first.cidr, {begin: chunk.first.port_range[:begin], end: chunk.last.port_range[:end]})
else
chunk.first
end
end
end
def create_port_segments(rules)
# Extract unique start and end points from port ranges
points = rules.flat_map { |r| [r.port_range.begin.to_i, r.port_range.end.to_i] }.uniq.sort
segments = []
# Create segments based on unique points
points.each_cons(2) do |start_point, end_point|
segments << {begin: start_point, end: end_point - 1}
end
segments
end
def generate_private_ip4_list
vm.nics.map { _1.private_ipv4.to_s }.join(",")
end
def generate_private_ip6_list
vm.nics.map { _1.private_ipv6.to_s }.join(",")
end
def subdivide_network(net)
prefix = net.netmask.prefix_len + 1
halved = net.resize(prefix)
[halved, halved.next_sib]
end
end