From d5cb18083eb949d081c65fd7b794b7b79bd8bcb5 Mon Sep 17 00:00:00 2001 From: jcav Date: Wed, 27 Mar 2024 13:59:56 +0800 Subject: [PATCH 1/3] add support for ipv6 --- hooks | 114 ++++++++++++++++++++++++++++++++++++++++++++++ hooks.schema.json | 18 ++++++++ 2 files changed, 132 insertions(+) diff --git a/hooks b/hooks index b81dd3e..6406346 100755 --- a/hooks +++ b/hooks @@ -37,6 +37,8 @@ CONFIG_SCHEMA_FILENAME = os.getenv('CONFIG_SCHEMA_FILENAME') or os.path.join( CONFIG_PATH, "hooks.schema.json") IPTABLES_BINARY = os.getenv('IPTABLES_BINARY') or subprocess.check_output([ "which", "iptables"]).strip() +IP6TABLES_BINARY = os.getenv('IP6TABLES_BINARY') or subprocess.check_output([ + "which", "ip6tables"]).strip() # Allow comments in json, copied from https://github.com/getify/JSON.minify @@ -115,6 +117,21 @@ def host_ip(): cmd, shell=True).decode().strip() return host_ip._host_ip.split('\n')[0] +def host_ipv6(): + """Returns the default route interface IPv6 (if any). + + Modified from host_ip() to return the IPv6 address of the default route + """ + if not hasattr(host_ipv6, "_host_ipv6"): + cmd = "ip -6 route | grep default | cut -d' ' -f5 | head -n1" + default_route_interface = subprocess.check_output( + cmd, shell=True).decode().strip() + cmd = "ip addr show {0} | grep -E 'inet6 .*global' | cut -d' ' -f6 | cut -d'/' -f1".format( + default_route_interface) + host_ipv6._host_ipv6 = subprocess.check_output( + cmd, shell=True).decode().strip() + return host_ipv6._host_ipv6.split('\n')[0] + def config(validate=True): """Returns the hook configuration. @@ -148,12 +165,23 @@ def create_chain(table, name): subprocess.call([IPTABLES_BINARY, "-t", table, "-N", name]) +def create_chain_v6(table, name): + """ Creates the named chain. """ + subprocess.call([IP6TABLES_BINARY, "-t", table, "-N", name]) + + def delete_chain(table, name): """ Flushes and deletes the named chain. """ subprocess.call([IPTABLES_BINARY, "-t", table, "-F", name]) subprocess.call([IPTABLES_BINARY, "-t", table, "-X", name]) +def delete_chain_v6(table, name): + """ Flushes and deletes the named chain. """ + subprocess.call([IP6TABLES_BINARY, "-t", table, "-F", name]) + subprocess.call([IP6TABLES_BINARY, "-t", table, "-X", name]) + + def populate_chains(dnat_chain, snat_chain, fwd_chain, public_ip, private_ip, domain, source_ip=None): """ Fills the two custom chains with the port mappings. """ port_map = domain["port_map"] @@ -194,6 +222,46 @@ def populate_chains(dnat_chain, snat_chain, fwd_chain, public_ip, private_ip, do "-d", private_ip, "--dport", ports_range, "-j", "ACCEPT"] + interface) +def pupulate_chains_v6(dnat_chain, snat_chain, fwd_chain, public_ip, private_ip, domain, source_ip=None): + """ Fills the two custom chains with the port mappings. """ + port_map = domain["port_map"] + for protocol in port_map: + for ports in port_map[protocol]: + # a single integer 80 is equivalent to [80, 80] + public_port, private_port = ports if isinstance(ports, list) else [ + ports, ports] + dest = "[{0}]:{1}".format(private_ip, str(private_port)) + subprocess.call([IP6TABLES_BINARY, "-t", "nat", "-A", dnat_chain, "-p", protocol, + "-d", public_ip, "--dport", str(public_port), "-j", "DNAT", "--to", dest] + + (["-s", source_ip] if source_ip else [])) + subprocess.call([IP6TABLES_BINARY, "-t", "nat", "-A", snat_chain, "-p", protocol, + "-s", private_ip, "--dport", str(private_port), "-j", "SNAT", "--to-source", public_ip]) + subprocess.call([IP6TABLES_BINARY, "-t", "nat", "-A", snat_chain, "-p", protocol, + "-s", private_ip, "-d", private_ip, "--dport", str(public_port), "-j", "MASQUERADE"]) + interface = ["-o", domain["interface"] + ] if "interface" in domain else [] + subprocess.call([IP6TABLES_BINARY, "-t", "filter", "-A", fwd_chain, "-p", protocol, + "-d", private_ip, "--dport", str(private_port), "-j", "ACCEPT"] + interface) + + # Iterate over all port ranges + if "port_range" in domain: + for port_range in domain["port_range"]: + ports_range = (str(port_range["init_port"]) + ":" + + str(port_range["init_port"] + port_range["ports_num"] - 1)) + dest = "[{0}]:{1}".format( + private_ip, ports_range.replace(":", "-", 1)) + subprocess.call([IP6TABLES_BINARY, "-t", "nat", "-A", dnat_chain, "-p", port_range["protocol"], + "-d", public_ip, "--dport", ports_range, "-j", "DNAT", "--to", dest]) + subprocess.call([IP6TABLES_BINARY, "-t", "nat", "-A", snat_chain, "-p", port_range["protocol"], + "-s", private_ip, "--dport", ports_range, "-j", "SNAT", "--to-source", public_ip]) + subprocess.call([IP6TABLES_BINARY, "-t", "nat", "-A", snat_chain, "-p", port_range["protocol"], + "-s", private_ip, "-d", private_ip, "--dport", ports_range, "-j", "MASQUERADE"]) + interface = ["-o", domain["interface"] + ] if "interface" in domain else [] + subprocess.call([IP6TABLES_BINARY, "-t", "filter", "-A", fwd_chain, "-p", port_range["protocol"], + "-d", private_ip, "--dport", ports_range, "-j", "ACCEPT"] + interface) + + def insert_chains(action, dnat_chain, snat_chain, fwd_chain, public_ip, private_ip): """ inserts (action='-I') or removes (action='-D') the custom chains.""" subprocess.call([IPTABLES_BINARY, "-t", "nat", action, @@ -210,6 +278,20 @@ def insert_chains(action, dnat_chain, snat_chain, fwd_chain, public_ip, private_ # the snat_chain doesn't work unless we turn off filtering bridged packets # https://wiki.libvirt.org/page/Net.bridge.bridge-nf-call_and_sysctl.conf + + +def insert_chains_v6(action, dnat_chain, snat_chain, fwd_chain, public_ip, private_ip): + """ inserts (action='-I') or removes (action='-D') the custom chains.""" + subprocess.call([IP6TABLES_BINARY, "-t", "nat", action, + "OUTPUT", "-d", public_ip, "-j", dnat_chain]) + subprocess.call([IP6TABLES_BINARY, "-t", "nat", action, + "PREROUTING", "-d", public_ip, "-j", dnat_chain]) + + subprocess.call([IP6TABLES_BINARY, "-t", "nat", action, "POSTROUTING", + "-s", private_ip, "-d", private_ip, "-j", snat_chain]) + subprocess.call([IP6TABLES_BINARY, "-t", "filter", action, + "FORWARD", "-d", private_ip, "-j", fwd_chain]) + def disable_bridge_filtering(): @@ -230,6 +312,19 @@ def start_forwarding(dnat_chain, snat_chain, fwd_chain, public_ip, private_ip, d insert_chains("-I", dnat_chain, snat_chain, fwd_chain, public_ip, private_ip) + + +def start_forwarding_v6(dnat_chain, snat_chain, fwd_chain, public_ip, private_ip, domain, source_ip=None): + """ sets up iptables port-forwarding rules based on the port_map. """ + disable_bridge_filtering() + create_chain_v6("nat", dnat_chain) + create_chain_v6("nat", snat_chain) + create_chain_v6("filter", fwd_chain) + pupulate_chains_v6(dnat_chain, snat_chain, fwd_chain, + public_ip, private_ip, domain, source_ip) + + insert_chains_v6("-I", dnat_chain, snat_chain, + fwd_chain, public_ip, private_ip) def stop_forwarding(dnat_chain, snat_chain, fwd_chain, public_ip, private_ip): @@ -241,6 +336,15 @@ def stop_forwarding(dnat_chain, snat_chain, fwd_chain, public_ip, private_ip): delete_chain("filter", fwd_chain) +def stop_forwarding_v6(dnat_chain, snat_chain, fwd_chain, public_ip, private_ip): + """ tears down the iptables port-forwarding rules. """ + insert_chains_v6("-D", dnat_chain, snat_chain, + fwd_chain, public_ip, private_ip) + delete_chain_v6("nat", dnat_chain) + delete_chain_v6("nat", snat_chain) + delete_chain_v6("filter", fwd_chain) + + def substitute_domain_name(domain_name, index_str): # handle the 28 char limit of iptables # we need to take the possible 5 chars of "DNAT-" and @@ -256,12 +360,22 @@ def handle_domain(action, domain, vir_domain): private_ip = domain["private_ip"] source_ip = domain.get("source_ip") + public_ipv6 = domain.get("public_ipv6", host_ipv6()) + private_ipv6 = domain["private_ipv6"] + source_ipv6 = domain.get("source_ipv6") + if action in ["stopped", "reconnect"]: stop_forwarding(dnat_chain, snat_chain, fwd_chain, public_ip, private_ip) + if private_ipv6: + stop_forwarding_v6(dnat_chain, snat_chain, + fwd_chain, public_ipv6, private_ipv6) if action in ["start", "reconnect"]: start_forwarding(dnat_chain, snat_chain, fwd_chain, public_ip, private_ip, domain, source_ip) + if private_ipv6: + start_forwarding_v6(dnat_chain, snat_chain, fwd_chain, + public_ipv6, private_ipv6, domain, source_ipv6) if __name__ == "__main__": diff --git a/hooks.schema.json b/hooks.schema.json index 4c46d94..2a41a9d 100644 --- a/hooks.schema.json +++ b/hooks.schema.json @@ -15,12 +15,21 @@ "private_ip": { "type": "string" }, + "private_ipv6": { + "type": "string" + }, "public_ip": { "type": "string" }, + "public_ipv6": { + "type": "string" + }, "source_ip": { "type": "string" }, + "source_ipv6": { + "type": "string" + }, "port_map": { "type": "object", "patternProperties": { @@ -109,12 +118,21 @@ "private_ip": { "type": "string" }, + "private_ipv6": { + "type": "string" + }, "public_ip": { "type": "string" }, + "public_ipv6": { + "type": "string" + }, "source_ip": { "type": "string" }, + "source_ipv6": { + "type": "string" + }, "port_map": { "type": "object", "patternProperties": { From 4fdb2ea5aac66289c33b7eb5f842d7bdfc5363d0 Mon Sep 17 00:00:00 2001 From: jcav Date: Wed, 29 May 2024 13:58:45 +0800 Subject: [PATCH 2/3] fix: host_ipv6() may return empty addr --- .gitignore | 1 + hooks | 30 ++++++++++++++++++++++++------ hooks.schema.json | 3 +++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index a597a27..0b2ead0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.pyc qemuc +.vscode/ \ No newline at end of file diff --git a/hooks b/hooks index 6406346..9175bac 100755 --- a/hooks +++ b/hooks @@ -97,7 +97,8 @@ def json_minify(string, strip_space=True): new_str.append(' ' * len(val)) new_str.append(string[index:]) - return ''.join(new_str) + new_str = ''.join(new_str) + return new_str def host_ip(): @@ -117,15 +118,32 @@ def host_ip(): cmd, shell=True).decode().strip() return host_ip._host_ip.split('\n')[0] -def host_ipv6(): +def host_ipv6(devname=None): """Returns the default route interface IPv6 (if any). Modified from host_ip() to return the IPv6 address of the default route + + *** THIS FUNCTION IS BUGGY *** + `ip -6 route | grep default | cut -d' ' -f5 | head -n1` do no always return the correct device name + for example, `ip -6 route` may return: + ``` + ... + default proto ra metric 100 pref medium + nexthop via fe80::9e3a:9aff:fe97:6882 dev enp6s0 weight 1 + nexthop via fe80::6aed:34ff:fe13:a2c9 dev enp6s0 weight 1 + ``` + in this case, the correct device name is `enp6s0`, but the command above will return `100` + and the function will return the wrong empty IPv6 address. + + So add devname parameter to specify the device name to get the IPv6 address """ if not hasattr(host_ipv6, "_host_ipv6"): - cmd = "ip -6 route | grep default | cut -d' ' -f5 | head -n1" - default_route_interface = subprocess.check_output( - cmd, shell=True).decode().strip() + if not devname: + cmd = "ip -6 route | grep default | cut -d' ' -f5 | head -n1" + default_route_interface = subprocess.check_output( + cmd, shell=True).decode().strip() + else: + default_route_interface = devname cmd = "ip addr show {0} | grep -E 'inet6 .*global' | cut -d' ' -f6 | cut -d'/' -f1".format( default_route_interface) host_ipv6._host_ipv6 = subprocess.check_output( @@ -360,7 +378,7 @@ def handle_domain(action, domain, vir_domain): private_ip = domain["private_ip"] source_ip = domain.get("source_ip") - public_ipv6 = domain.get("public_ipv6", host_ipv6()) + public_ipv6 = domain.get("public_ipv6", host_ipv6(devname=domain.get("public_interface"))) private_ipv6 = domain["private_ipv6"] source_ipv6 = domain.get("source_ipv6") diff --git a/hooks.schema.json b/hooks.schema.json index 2a41a9d..f9732be 100644 --- a/hooks.schema.json +++ b/hooks.schema.json @@ -12,6 +12,9 @@ "interface": { "type": "string" }, + "public_interface": { + "type": "string" + }, "private_ip": { "type": "string" }, From 984f9ed0471e82b89cc81ad428cd5d64d8f9974e Mon Sep 17 00:00:00 2001 From: jcav Date: Mon, 9 Sep 2024 17:16:55 +0800 Subject: [PATCH 3/3] fix: devname --- hooks | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/hooks b/hooks index 9175bac..b417bd0 100755 --- a/hooks +++ b/hooks @@ -101,7 +101,7 @@ def json_minify(string, strip_space=True): return new_str -def host_ip(): +def host_ip(devname=None): """Returns the default route interface IP (if any). In other words, the public IP used to access the virtualization host. It @@ -109,9 +109,12 @@ def host_ip(): specify a different public IP to forward from. """ if not hasattr(host_ip, "_host_ip"): - cmd = "ip route | grep default | cut -d' ' -f5 | head -n1" - default_route_interface = subprocess.check_output( - cmd, shell=True).decode().strip() + if not devname: + cmd = "ip -6 route | grep default | cut -d' ' -f5 | head -n1" + default_route_interface = subprocess.check_output( + cmd, shell=True).decode().strip() + else: + default_route_interface = devname cmd = "ip addr show {0} | grep -E 'inet .*{0}' | cut -d' ' -f6 | cut -d'/' -f1".format( default_route_interface) host_ip._host_ip = subprocess.check_output( @@ -374,7 +377,7 @@ def handle_domain(action, domain, vir_domain): dnat_chain = "DNAT-{0}".format(vir_domain) snat_chain = "SNAT-{0}".format(vir_domain) fwd_chain = "FWD-{0}".format(vir_domain) - public_ip = domain.get("public_ip", host_ip()) + public_ip = domain.get("public_ip", host_ip(devname=domain.get("public_interface"))) private_ip = domain["private_ip"] source_ip = domain.get("source_ip")