diff --git a/test/case/infix_containers/Readme.adoc b/test/case/infix_containers/Readme.adoc index 4eb951db7..e67eb1b03 100644 --- a/test/case/infix_containers/Readme.adoc +++ b/test/case/infix_containers/Readme.adoc @@ -1,6 +1,14 @@ :testgroup: == infix-containers +Verifies Infix Docker container support: + + - Basic web server container running in host network mode + - Common setup with a docker0 bridge, automatic VETH pairs to container(s) + - Connecting a container with a VETH pair to a standard Linux bridge + - Assigning a physical Ethernet interface to a container + - Basic firewall container running in host network mode and full privileges + <<< include::container_basic/Readme.adoc[] @@ -10,3 +18,5 @@ include::container_bridge/Readme.adoc[] include::container_phys/Readme.adoc[] include::container_veth/Readme.adoc[] + +include::container_firewall_basic/Readme.adoc[] diff --git a/test/case/infix_containers/container_basic/test.py b/test/case/infix_containers/container_basic/test.py index d95059d7d..4415a902c 100755 --- a/test/case/infix_containers/container_basic/test.py +++ b/test/case/infix_containers/container_basic/test.py @@ -20,7 +20,7 @@ def _verify(server): - # Should really use mDNS here.... + # TODO: Should really use mDNS here.... url = infamy.Furl(f"http://[{server}]:91/index.html") return url.check("It works") @@ -49,7 +49,7 @@ def _verify(server): "container": [ { "name": f"{NAME}", - "image": f"oci-archive:{infamy.Container.IMAGE}", + "image": f"oci-archive:{infamy.Container.HTTPD_IMAGE}", "command": "/usr/sbin/httpd -f -v -p 91", "network": { "host": True diff --git a/test/case/infix_containers/container_bridge/test.py b/test/case/infix_containers/container_bridge/test.py index ea4ebd378..627ca874a 100755 --- a/test/case/infix_containers/container_bridge/test.py +++ b/test/case/infix_containers/container_bridge/test.py @@ -16,11 +16,10 @@ """ import base64 import infamy -from infamy.util import until +from infamy.util import until, to_binary with infamy.Test() as test: NAME = "web-docker0" - IMAGE = "curios-httpd-edge.tar.gz" DUTIP = "10.0.0.2" OURIP = "10.0.0.1" BODY = "

Kilroy was here

" @@ -35,7 +34,7 @@ with test.step("Create container 'web-docker0' from bundled OCI image"): _, ifname = env.ltop.xlate("target", "data") - enc = base64.b64encode(BODY.encode('utf-8')) + data = to_binary(BODY) target.put_config_dict("ietf-interfaces", { "interfaces": { "interface": [ @@ -67,12 +66,12 @@ "container": [ { "name": f"{NAME}", - "image": f"oci-archive:{infamy.Container.IMAGE}", + "image": f"oci-archive:{infamy.Container.HTTPD_IMAGE}", "command": "/usr/sbin/httpd -f -v -p 91", "mount": [ { "name": "index.html", - "content": f"{enc.decode('utf-8')}", + "content": f"{data}", "target": "/var/www/index.html" } ], diff --git a/test/case/infix_containers/container_firewall_basic/Readme.adoc b/test/case/infix_containers/container_firewall_basic/Readme.adoc new file mode 100644 index 000000000..296d03096 --- /dev/null +++ b/test/case/infix_containers/container_firewall_basic/Readme.adoc @@ -0,0 +1,54 @@ +=== Basic Firewall Container +==== Description +Verify that an nftables container can be used for IP masquerading and +port forwarding to another container running a basic web server. + +.... + <--- Docker containers ---> +.-------------. .----------------------. .--------..---------------. +| | mgmt |------------| mgmt | | | | fire || | web | +| host | data |------------| ext0 | target | int0 | | wall || eth0 | server | +'-------------'.42 .1'----------------------' '--------''---------------' + \ .1 .2 / + 192.168.0.0/24 \ 10.0.0.0/24 / + `-- VETH pair --' +.... + +The web server container is connected to the target on an internal +network, using a VETH pair, serving HTTP on port 91. + +The firewall container sets up a port forward with IP masquerding +to/from `ext0:8080` to 10.0.0.2:91. + +Correct operation is verified using HTTP GET requests for internal port +91 and external port 8080, to ensure the web page, with a known key +phrase, is only reachable from the public interface `ext0`, on +192.168.0.1:8080. + +==== Topology +ifdef::topdoc[] +image::../../test/case/infix_containers/container_firewall_basic/topology.png[Basic Firewall Container topology] +endif::topdoc[] +ifndef::topdoc[] +ifdef::testgroup[] +image::container_firewall_basic/topology.png[Basic Firewall Container topology] +endif::testgroup[] +ifndef::testgroup[] +image::topology.png[Basic Firewall Container topology] +endif::testgroup[] +endif::topdoc[] +==== Test sequence +. Set up topology and attach to target DUT +. Set hostname to 'container-host' +. Create VETH pair for web server container +. Create firewall container from bundled OCI image +. Create web server container from bundled OCI image +. Verify firewall container has started +. Verify web container has started +. Verify connectivity, host can reach target:ext0 +. Verify 'web' is NOT reachable on http://container-host.local:91 +. Verify 'web' is reachable on http://container-host.local:8080 + + +<<< + diff --git a/test/case/infix_containers/container_firewall_basic/test.py b/test/case/infix_containers/container_firewall_basic/test.py new file mode 100755 index 000000000..0f1c9d0a9 --- /dev/null +++ b/test/case/infix_containers/container_firewall_basic/test.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +r"""Basic Firewall Container + +Verify that an nftables container can be used for IP masquerading and +port forwarding to another container running a basic web server. + +.... + <--- Docker containers ---> +.-------------. .----------------------. .--------..---------------. +| | mgmt |------------| mgmt | | | | fire || | web | +| host | data |------------| ext0 | target | int0 | | wall || eth0 | server | +'-------------'.42 .1'----------------------' '--------''---------------' + \ .1 .2 / + 192.168.0.0/24 \ 10.0.0.0/24 / + `-- VETH pair --' +.... + +The web server container is connected to the target on an internal +network, using a VETH pair, serving HTTP on port 91. + +The firewall container sets up a port forward with IP masquerding +to/from `ext0:8080` to 10.0.0.2:91. + +Correct operation is verified using HTTP GET requests for internal port +91 and external port 8080, to ensure the web page, with a known key +phrase, is only reachable from the public interface `ext0`, on +192.168.0.1:8080. + +""" +import infamy +from infamy.util import until, to_binary + + +with infamy.Test() as test: + NFTABLES = f"oci-archive:{infamy.Container.NFTABLES_IMAGE}" + HTTPD = f"oci-archive:{infamy.Container.HTTPD_IMAGE}" + WEBIP = "10.0.0.2" + INTIP = "10.0.0.1" + EXTIP = "192.168.0.1" + OURIP = "192.168.0.42" + WEBNM = "web" + NFTNM = "firewall" + GOOD_URL = f"http://{EXTIP}:8080/index.html" + BAD_URL = f"http://{EXTIP}:91/index.html" + + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + target = env.attach("target", "mgmt") + _, ext0 = env.ltop.xlate("target", "ext0") + _, hport = env.ltop.xlate("host", "data") + addr = target.get_mgmt_ip() + + if not target.has_model("infix-containers"): + test.skip() + + with test.step("Set hostname to 'container-host'"): + target.put_config_dict("ietf-system", { + "system": { + "hostname": "container-host" + } + }) + + with test.step("Create VETH pair for web server container"): + target.put_config_dict("ietf-interfaces", { + "interfaces": { + "interface": [ + { + "name": f"{ext0}", + "ipv4": { + "forwarding": True, + "address": [{ + "ip": f"{EXTIP}", + "prefix-length": 24 + }] + } + }, + { + "name": "int0", + "type": "infix-if-type:veth", + "enabled": True, + "infix-interfaces:veth": { + "peer": f"{WEBNM}" + }, + "ipv4": { + "forwarding": True, + "address": [{ + "ip": f"{INTIP}", + "prefix-length": 24, + }] + } + }, + { + "name": f"{WEBNM}", + "type": "infix-if-type:veth", + "enabled": True, + "infix-interfaces:veth": { + "peer": "int0" + }, + "ipv4": { + "address": [{ + "ip": f"{WEBIP}", + "prefix-length": 24, + }] + }, + "container-network": {} + }, + ] + } + }) + + with test.step("Create firewall container from bundled OCI image"): + # Store the nftables .conf file contents as a multi-line string + config = to_binary(f"""#!/usr/sbin/nft -f + +flush ruleset + +define WAN = "{ext0}" +define INT = "int0" +define WIP = "{WEBIP}" + """ + """ + +table ip nat { + chain prerouting { + type nat hook prerouting priority 0; policy accept; + iifname $WAN tcp dport 8080 dnat to $WIP:91 + } + + chain postrouting { + type nat hook postrouting priority 100; policy accept; + oifname $WAN masquerade + oifname $INT masquerade + } +} +""") + + target.put_config_dict("infix-containers", { + "containers": { + "container": [ + { + "name": f"{NFTNM}", + "image": f"{NFTABLES}", + "network": { + "host": True + }, + "mount": [ + { + "name": "nftables.conf", + "content": config, + "target": "/etc/nftables.conf" + } + ], + "privileged": True + } + ] + } + }) + + with test.step("Create web server container from bundled OCI image"): + target.put_config_dict("infix-containers", { + "containers": { + "container": [ + { + "name": f"{WEBNM}", + "image": f"{HTTPD}", + "command": "/usr/sbin/httpd -f -v -p 91", + "network": { + "interface": [ + {"name": f"{WEBNM}"} + ] + } + } + ] + } + }) + + with test.step("Verify firewall container has started"): + c = infamy.Container(target) + until(lambda: c.running(NFTNM), attempts=10) + + with test.step("Verify web container has started"): + c = infamy.Container(target) + until(lambda: c.running(WEBNM), attempts=10) + + with infamy.IsolatedMacVlan(hport) as ns: + NEEDLE = "tiny web server from the curiOS docker" + ns.addip(OURIP) + with test.step("Verify connectivity, host can reach target:ext0"): + ns.must_reach(EXTIP) + with test.step("Verify 'web' is NOT reachable on http://container-host.local:91"): + url = infamy.Furl(BAD_URL) + until(lambda: not url.nscheck(ns, NEEDLE)) + with test.step("Verify 'web' is reachable on http://container-host.local:8080"): + url = infamy.Furl(GOOD_URL) + until(lambda: url.nscheck(ns, NEEDLE)) + + test.succeed() diff --git a/test/case/infix_containers/container_firewall_basic/topology.dot b/test/case/infix_containers/container_firewall_basic/topology.dot new file mode 100644 index 000000000..19ab78607 --- /dev/null +++ b/test/case/infix_containers/container_firewall_basic/topology.dot @@ -0,0 +1,24 @@ +graph "1x2" { + layout="neato"; + overlap="false"; + esep="+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt | data }", + pos="0,12!", + kind="controller", + ]; + + target [ + label="{ mgmt | ext0 } | target", + pos="10,12!", + + kind="infix", + ]; + + host:mgmt -- target:mgmt [kind=mgmt, color=lightgrey] + host:data -- target:ext0 [color=black, headlabel=".1 ", taillabel=" .2", label="\n 192.168.0.1/24 "] +} diff --git a/test/case/infix_containers/container_firewall_basic/topology.png b/test/case/infix_containers/container_firewall_basic/topology.png new file mode 100644 index 000000000..31229f672 Binary files /dev/null and b/test/case/infix_containers/container_firewall_basic/topology.png differ diff --git a/test/case/infix_containers/container_phys/test.py b/test/case/infix_containers/container_phys/test.py index 63001e548..082566499 100755 --- a/test/case/infix_containers/container_phys/test.py +++ b/test/case/infix_containers/container_phys/test.py @@ -15,7 +15,6 @@ with infamy.Test() as test: NAME = "web-phys" - IMAGE = "curios-httpd-edge.tar.gz" DUTIP = "10.0.0.2" OURIP = "10.0.0.1" URL = f"http://{DUTIP}:91/index.html" @@ -51,7 +50,7 @@ "container": [ { "name": f"{NAME}", - "image": f"oci-archive:{infamy.Container.IMAGE}", + "image": f"oci-archive:{infamy.Container.HTTPD_IMAGE}", "command": "/usr/sbin/httpd -f -v -p 91", "network": { "interface": [ diff --git a/test/case/infix_containers/container_veth/Readme.adoc b/test/case/infix_containers/container_veth/Readme.adoc index 8fb695688..57aea0e54 100644 --- a/test/case/infix_containers/container_veth/Readme.adoc +++ b/test/case/infix_containers/container_veth/Readme.adoc @@ -5,7 +5,7 @@ regular bridge, a VETH pair connects the container to the bridge. .... .-------------. .---------------. .--------. - | | tgt |---------| mgmt | | | web- | + | | mgmt |---------| mgmt | | | web- | | host | data |---------| data | target | | server | '-------------' '---------------' '--------' | / diff --git a/test/case/infix_containers/container_veth/test.py b/test/case/infix_containers/container_veth/test.py index 09600be88..f67319c10 100755 --- a/test/case/infix_containers/container_veth/test.py +++ b/test/case/infix_containers/container_veth/test.py @@ -11,7 +11,7 @@ .... .-------------. .---------------. .--------. - | | tgt |---------| mgmt | | | web- | + | | mgmt |---------| mgmt | | | web- | | host | data |---------| data | target | | server | '-------------' '---------------' '--------' | / @@ -26,7 +26,6 @@ with infamy.Test() as test: NAME = "web-br0-veth" - IMAGE = "curios-httpd-edge.tar.gz" DUTIP = "10.0.0.2" OURIP = "10.0.0.1" URL = f"http://{DUTIP}:91/index.html" @@ -85,7 +84,7 @@ "container": [ { "name": f"{NAME}", - "image": f"oci-archive:{infamy.Container.IMAGE}", + "image": f"oci-archive:{infamy.Container.HTTPD_IMAGE}", "command": "/usr/sbin/httpd -f -v -p 91", "network": { "interface": [ diff --git a/test/case/infix_containers/infix_containers.yaml b/test/case/infix_containers/infix_containers.yaml index 0caa72fa6..3d364de74 100644 --- a/test/case/infix_containers/infix_containers.yaml +++ b/test/case/infix_containers/infix_containers.yaml @@ -11,3 +11,7 @@ - name: container_veth case: container_veth/test.py + +- name: container_firewall_basic + case: container_firewall_basic/test.py + diff --git a/test/infamy/container.py b/test/infamy/container.py index 21b7de895..9989cf38e 100644 --- a/test/infamy/container.py +++ b/test/infamy/container.py @@ -5,7 +5,8 @@ class Container: """Helper methods""" - IMAGE = "curios-httpd-v24.05.0.tar.gz" + HTTPD_IMAGE = "curios-httpd-v24.05.0.tar.gz" + NFTABLES_IMAGE = "curios-nftables-v24.05.0.tar.gz" def __init__(self, target): self.system = target diff --git a/test/infamy/util.py b/test/infamy/util.py index 632ecd376..96a8370ef 100644 --- a/test/infamy/util.py +++ b/test/infamy/util.py @@ -1,8 +1,10 @@ +"""Helper functions for tests""" +import base64 import time import threading import infamy.neigh -import infamy.netconf as netconf -import infamy.restconf as restconf +from infamy import netconf +from infamy import restconf class ParallelFn(threading.Thread): @@ -59,6 +61,14 @@ def is_reachable(neigh, env, pwd): return netconf_reachable and restconf_reachable +def to_binary(text): + """Base64 encode the text, removing newlines""" + enc = base64.b64encode(text.encode('utf-8')) + + # Convert the encoded bytes to a string and remove any newlines + return enc.decode('utf-8').replace('\n', '') + + def wait_boot(target, env): print(f"{target} is shutting down ...") until(lambda: not target.reachable(), attempts=100)