diff --git a/README.md b/README.md index 98e4b28..dfde878 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,20 @@ Basic tests run without a router: nimble test ``` -To run tests against a real NAT device: +Integration tests run miniupnpd inside a Docker/Podman container and exercise the PCP and UPnP-IGD flows. +Podman or Docker as fallback will be used for testing with `NET_ADMIN` capability: ```bash -NAT_TEST_PLUM=1 nimble test +nimble testIntegration +``` + +This builds the image and runs two containers: one for PCP and one for UPnP. +Each protocol is tested under both `orc` and `refc` memory managers. +miniupnpd is built with a stub firewall backend (`tests/miniupnpd_stub_rdr.c`) so it accepts mapping requests without requiring iptables or nftables in the container. +To see the miniupnpd logs and the resolved external addresses, pass `TEST_VERBOSE=1`: + +```bash +TEST_VERBOSE=1 nimble testIntegration ``` ## License diff --git a/libplum.nimble b/libplum.nimble index eb4ec5f..01b740a 100644 --- a/libplum.nimble +++ b/libplum.nimble @@ -24,7 +24,15 @@ task buildBundledLibs, "build bundled libraries": task test, "run tests": compileStaticLibraries() exec("nimble setup") - exec("nim c -r tests/test_plum.nim") + exec("nim c -o:tests/test_plum tests/test_plum.nim") + exec("./tests/test_plum") + +task testIntegration, "run miniupnpd integration tests in Docker / Podman": + let docker = if findExe("podman") != "": "podman" else: "docker" + exec(docker & " build -t " & packageName & " -f tests/Dockerfile .") + let verbose = if getEnv("TEST_VERBOSE") != "": " -e TEST_VERBOSE=" & getEnv("TEST_VERBOSE") else: "" + exec(docker & " run --rm --cap-add=NET_ADMIN -e TEST_MINIUPNP_PCP=1" & verbose & " " & packageName) + exec(docker & " run --rm --cap-add=NET_ADMIN -e TEST_MINIUPNP_UPNP=1" & verbose & " " & packageName) before install: compileStaticLibraries() diff --git a/tests/Dockerfile b/tests/Dockerfile new file mode 100644 index 0000000..ab778d2 --- /dev/null +++ b/tests/Dockerfile @@ -0,0 +1,51 @@ +FROM ubuntu:24.04 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + cmake gcc make git curl ca-certificates xz-utils \ + libc-dev \ + iproute2 \ + && rm -rf /var/lib/apt/lists/* + +# Build miniupnpd with stub redirector: no iptables/nftables needed, always +# returns success for port mapping requests — safe for isolated test containers. +COPY tests/miniupnpd_stub_rdr.c /tmp/stub_rdr.c +RUN git clone --depth=1 --branch miniupnpd_2_3_9 \ + https://github.com/miniupnp/miniupnp.git /tmp/miniupnp \ + && cd /tmp/miniupnp/miniupnpd \ + && ./configure \ + && cp /tmp/stub_rdr.c . \ + && make NETFILTEROBJS=stub_rdr.o miniupnpd \ + && install -m 755 miniupnpd /usr/local/sbin/miniupnpd \ + && rm -rf /tmp/miniupnp /tmp/stub_rdr.c + +# Install Nim +ARG NIM_VERSION=2.2.10 +RUN curl -fsSL "https://nim-lang.org/download/nim-${NIM_VERSION}-linux_x64.tar.xz" \ + | tar -xJ -C /opt && \ + ln -s "/opt/nim-${NIM_VERSION}/bin/nim" /usr/local/bin/nim && \ + ln -s "/opt/nim-${NIM_VERSION}/bin/nimble" /usr/local/bin/nimble + +# Install nim deps (cached layer) +WORKDIR /app +COPY libplum.nimble . +COPY libplum/ libplum/ +COPY vendor/ vendor/ +RUN nimble setup -y + +# Build libplum static library +RUN cmake -B vendor/libplum/build \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + vendor/libplum && \ + cmake --build vendor/libplum/build + +# Compile test binaries (protocol x memory manager) +COPY tests/ tests/ +RUN nim c -d:miniupnp_protocol=pcp --mm:orc -o:tests/test_pcp_orc tests/test_plum.nim && \ + nim c -d:miniupnp_protocol=pcp --mm:refc -o:tests/test_pcp_refc tests/test_plum.nim && \ + nim c -d:miniupnp_protocol=upnp --mm:orc -o:tests/test_upnp_orc tests/test_plum.nim && \ + nim c -d:miniupnp_protocol=upnp --mm:refc -o:tests/test_upnp_refc tests/test_plum.nim + +COPY tests/docker-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tests/docker-entrypoint.sh b/tests/docker-entrypoint.sh new file mode 100644 index 0000000..396de78 --- /dev/null +++ b/tests/docker-entrypoint.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -euo pipefail + +RUNDIR=/tmp/plum-test +mkdir -p "$RUNDIR" + +LAN_IF=$(ip route show default | awk '/default/{print $5; exit}') +LAN_IP=$(ip -4 addr show "$LAN_IF" | awk '/inet /{print $2; exit}' | cut -d/ -f1) + +# Use a public (non-reserved) WAN IP. miniupnpd disables port forwarding when +# the external interface has a private/RFC1918 address (treats it as double-NAT). +ip link add plum-wan type dummy +ip addr add 1.2.3.4/24 dev plum-wan +ip link set plum-wan up + +start_miniupnpd() { + local proto=$1 enable_pcp_pmp=$2 listen_on=$3 + local conf="$RUNDIR/miniupnpd-$proto.conf" + local pidfile="$RUNDIR/miniupnpd-$proto.pid" + + cat > "$conf" << EOF +ext_ifname=plum-wan +listening_ip=$listen_on +enable_pcp_pmp=$enable_pcp_pmp +# port=0: pick a random HTTP port to avoid conflicts with host services. +port=0 +# Without an allow rule miniupnpd denies all mapping requests by default, +# returning NO_RESOURCES (PCP) or ActionFailed (UPnP). +allow 1024-65535 0.0.0.0/0 1024-65535 +EOF + # -d: don't daemonize; we background it ourselves with & to capture the pid. + miniupnpd -d -f "$conf" > "$RUNDIR/miniupnpd-$proto.log" 2>&1 & + echo $! > "$pidfile" + sleep 1 + kill -0 "$(cat "$pidfile")" 2>/dev/null \ + || { echo "miniupnpd-$proto failed to start" >&2; exit 1; } + echo "miniupnpd-$proto started (pid $(cat "$pidfile"))" +} + +run_tests() { + local proto=$1 logfile=$2 + local failed=0 + for mm in orc refc; do + echo "--- $proto $mm ---" + "/app/tests/test_${proto}_${mm}" || failed=1 + done + if [ "${TEST_VERBOSE:-}" = "1" ]; then + echo "--- miniupnpd log ---" + cat "$logfile" 2>/dev/null || true + fi + [ $failed -eq 0 ] || exit 1 +} + +if [ "${TEST_MINIUPNP_PCP:-}" = "1" ]; then + # PCP requires the UDP source IP to equal the client_address field in the + # MAP request header. libplum sets client_address by connecting a UDP socket + # to the gateway IP. Pointing the default route at LAN_IP makes libplum + # use LAN_IP as both the gateway (where it sends PCP) and the source IP, + # so the two match and miniupnpd accepts the request without ADDRESS_MISMATCH. + ip route replace default via "$LAN_IP" dev "$LAN_IF" + start_miniupnpd pcp yes "$LAN_IF" + run_tests pcp "$RUNDIR/miniupnpd-pcp.log" +fi + +if [ "${TEST_MINIUPNP_UPNP:-}" = "1" ]; then + start_miniupnpd upnp no "$LAN_IF" + run_tests upnp "$RUNDIR/miniupnpd-upnp.log" +fi + diff --git a/tests/miniupnpd_stub_rdr.c b/tests/miniupnpd_stub_rdr.c new file mode 100644 index 0000000..15e45b0 --- /dev/null +++ b/tests/miniupnpd_stub_rdr.c @@ -0,0 +1,169 @@ +/* Stub firewall backend for miniupnpd. + * Replaces iptcrdr.o + iptpinhole.o + nfct_get.o. + * All mapping operations succeed without touching the kernel. */ + +#include +#include + +/* commonrdr.h interface */ + +int init_redirect(void) { return 0; } +void shutdown_redirect(void) {} + +int get_redirect_rule_count(const char *ifname) +{ (void)ifname; return 0; } + +int get_redirect_rule(const char *ifname, unsigned short eport, int proto, + char *iaddr, int iaddrlen, unsigned short *iport, + char *desc, int desclen, + char *rhost, int rhostlen, + unsigned int *timestamp, + uint64_t *packets, uint64_t *bytes) +{ (void)ifname; (void)eport; (void)proto; (void)iaddr; (void)iaddrlen; + (void)iport; (void)desc; (void)desclen; (void)rhost; (void)rhostlen; + (void)timestamp; (void)packets; (void)bytes; return -1; } + +int get_redirect_rule_by_index(int index, + char *ifname, unsigned short *eport, + char *iaddr, int iaddrlen, unsigned short *iport, + int *proto, char *desc, int desclen, + char *rhost, int rhostlen, + unsigned int *timestamp, + uint64_t *packets, uint64_t *bytes) +{ (void)index; (void)ifname; (void)eport; (void)iaddr; (void)iaddrlen; + (void)iport; (void)proto; (void)desc; (void)desclen; (void)rhost; + (void)rhostlen; (void)timestamp; (void)packets; (void)bytes; return -1; } + +unsigned short *get_portmappings_in_range(unsigned short startport, + unsigned short endport, + int proto, unsigned int *number) +{ (void)startport; (void)endport; (void)proto; *number = 0; return 0; } + +int update_portmapping(const char *ifname, unsigned short eport, int proto, + unsigned short iport, const char *desc, + unsigned int timestamp) +{ (void)ifname; (void)eport; (void)proto; (void)iport; (void)desc; + (void)timestamp; return 0; } + +int update_portmapping_desc_timestamp(const char *ifname, + unsigned short eport, int proto, + const char *desc, unsigned int timestamp) +{ (void)ifname; (void)eport; (void)proto; (void)desc; (void)timestamp; + return 0; } + +/* iptcrdr.h interface */ + +int add_redirect_rule2(const char *ifname, + const char *rhost, unsigned short eport, + const char *iaddr, unsigned short iport, int proto, + const char *desc, unsigned int timestamp) +{ (void)ifname; (void)rhost; (void)eport; (void)iaddr; (void)iport; + (void)proto; (void)desc; (void)timestamp; return 0; } + +int add_peer_redirect_rule2(const char *ifname, + const char *rhost, unsigned short rport, + const char *eaddr, unsigned short eport, + const char *iaddr, unsigned short iport, int proto, + const char *desc, unsigned int timestamp) +{ (void)ifname; (void)rhost; (void)rport; (void)eaddr; (void)eport; + (void)iaddr; (void)iport; (void)proto; (void)desc; (void)timestamp; + return 0; } + +int add_filter_rule2(const char *ifname, + const char *rhost, const char *iaddr, + unsigned short eport, unsigned short iport, + int proto, const char *desc) +{ (void)ifname; (void)rhost; (void)iaddr; (void)eport; (void)iport; + (void)proto; (void)desc; return 0; } + +int delete_redirect_and_filter_rules(unsigned short eport, int proto) +{ (void)eport; (void)proto; return 0; } + +int delete_filter_rule(const char *ifname, unsigned short port, int proto) +{ (void)ifname; (void)port; (void)proto; return 0; } + +int add_peer_dscp_rule2(const char *ifname, + const char *rhost, unsigned short rport, + unsigned char dscp, + const char *iaddr, unsigned short iport, int proto, + const char *desc, unsigned int timestamp) +{ (void)ifname; (void)rhost; (void)rport; (void)dscp; (void)iaddr; + (void)iport; (void)proto; (void)desc; (void)timestamp; return 0; } + +int get_peer_rule_by_index(int index, + char *ifname, unsigned short *eport, + char *iaddr, int iaddrlen, unsigned short *iport, + int *proto, char *desc, int desclen, + char *rhost, int rhostlen, unsigned short *rport, + unsigned int *timestamp, + uint64_t *packets, uint64_t *bytes) +{ (void)index; (void)ifname; (void)eport; (void)iaddr; (void)iaddrlen; + (void)iport; (void)proto; (void)desc; (void)desclen; (void)rhost; + (void)rhostlen; (void)rport; (void)timestamp; (void)packets; (void)bytes; + return -1; } + +int get_nat_redirect_rule(const char *nat_chain_name, const char *ifname, + unsigned short eport, int proto, + char *iaddr, int iaddrlen, unsigned short *iport, + char *desc, int desclen, + char *rhost, int rhostlen, + unsigned int *timestamp, + uint64_t *packets, uint64_t *bytes) +{ (void)nat_chain_name; (void)ifname; (void)eport; (void)proto; (void)iaddr; + (void)iaddrlen; (void)iport; (void)desc; (void)desclen; (void)rhost; + (void)rhostlen; (void)timestamp; (void)packets; (void)bytes; return -1; } + +int list_redirect_rule(const char *ifname) +{ (void)ifname; return 0; } + +/* commonrdr.h USE_NETFILTER interface */ + +int set_rdr_name(int param, const char *string) +{ (void)param; (void)string; return 0; } + +/* nfct_get.c interface */ + +int get_nat_ext_addr(struct sockaddr *src, struct sockaddr *dst, uint8_t proto, + struct sockaddr *ret_ext) +{ (void)src; (void)dst; (void)proto; (void)ret_ext; return -1; } + +/* iptpinhole.h interface */ + +int find_pinhole(const char *ifname, + const char *rem_host, unsigned short rem_port, + const char *int_client, unsigned short int_port, + int proto, char *desc, int desc_len, unsigned int *timestamp) +{ (void)ifname; (void)rem_host; (void)rem_port; (void)int_client; + (void)int_port; (void)proto; (void)desc; (void)desc_len; (void)timestamp; + return -1; } + +int add_pinhole(const char *ifname, + const char *rem_host, unsigned short rem_port, + const char *int_client, unsigned short int_port, + int proto, const char *desc, unsigned int timestamp) +{ (void)ifname; (void)rem_host; (void)rem_port; (void)int_client; + (void)int_port; (void)proto; (void)desc; (void)timestamp; return 0; } + +int update_pinhole(unsigned short uid, unsigned int timestamp) +{ (void)uid; (void)timestamp; return 0; } + +int delete_pinhole(unsigned short uid) +{ (void)uid; return 0; } + +int get_pinhole_info(unsigned short uid, + char *rem_host, int rem_hostlen, unsigned short *rem_port, + char *int_client, int int_clientlen, + unsigned short *int_port, + int *proto, char *desc, int desclen, + unsigned int *timestamp, + uint64_t *packets, uint64_t *bytes) +{ (void)uid; (void)rem_host; (void)rem_hostlen; (void)rem_port; + (void)int_client; (void)int_clientlen; (void)int_port; (void)proto; + (void)desc; (void)desclen; (void)timestamp; (void)packets; (void)bytes; + return -1; } + +int get_pinhole_uid_by_index(int index) +{ (void)index; return -1; } + +int clean_pinhole_list(unsigned int *next_timestamp) +{ (void)next_timestamp; return 0; } diff --git a/tests/test_plum.nim b/tests/test_plum.nim index ba119e0..4b74baa 100644 --- a/tests/test_plum.nim +++ b/tests/test_plum.nim @@ -6,7 +6,7 @@ # This file may not be copied, modified, or distributed except according to # those terms. -import std/envvars +import std/os import unittest2 import chronos import libplum/plum @@ -34,48 +34,36 @@ suite "plum": test "hasMapping returns false for unknown id": check not hasMapping(999) - test "createMapping fails without router": - # In CI with no NAT device, expect Failure or timeout — both return err. - if getEnv("NAT_TEST_PLUM") == "1": - skip() - return +const miniupnp_protocol {.strdefine.} = "" +# The flag is passed by the Docker / Podman container +when miniupnp_protocol != "": + suite "plum - " & miniupnp_protocol & " using miniupnp": + test "createMapping TCP and destroyMapping": + check init(discoverTimeout = 15000).isOk() - discard init() - let r = waitFor createMapping(UDP, 12345, timeout = seconds(5)) - check r.isErr() - discard cleanup() + let r = waitFor createMapping(TCP, 8101, timeout = seconds(40)) + check r.isOk() + if r.isOk(): + let res = r.value + check res.mapping.externalPort > 0 + check res.mapping.externalHost.len > 0 + check hasMapping(res.id) + if getEnv("TEST_VERBOSE") == "1": + echo miniupnp_protocol & " TCP: " & res.mapping.externalHost & ":" & $res.mapping.externalPort + destroyMapping(res.id) -suite "plum - NAT port mapping (requires NAT_TEST_PLUM=1)": - test "createMapping TCP and destroyMapping": - if getEnv("NAT_TEST_PLUM") != "1": - skip() - return + discard cleanup() - check init(discoverTimeout = 15000).isOk() + test "createMapping UDP and destroying": + check init(discoverTimeout = 2000).isOk() - let r = waitFor createMapping(TCP, 8101, timeout = seconds(40)) - check r.isOk() - if r.isOk(): - let res = r.value - check res.mapping.externalPort > 0 - check res.mapping.externalHost.len > 0 - check hasMapping(res.id) - destroyMapping(res.id) + let r = waitFor createMapping(UDP, 8090, timeout = seconds(40)) + check r.isOk() + if r.isOk(): + let res = r.value + check res.mapping.externalPort > 0 + if getEnv("TEST_VERBOSE") == "1": + echo miniupnp_protocol & " UDP: " & res.mapping.externalHost & ":" & $res.mapping.externalPort + destroyMapping(res.id) - discard cleanup() - - test "createMapping UDP": - if getEnv("NAT_TEST_PLUM") != "1": - skip() - return - - check init(discoverTimeout = 2000).isOk() - - let r = waitFor createMapping(UDP, 8090, timeout = seconds(40)) - check r.isOk() - if r.isOk(): - let res = r.value - check res.mapping.externalPort > 0 - destroyMapping(res.id) - - discard cleanup() + discard cleanup()