Add integration tests

This commit is contained in:
Arnaud 2026-05-18 11:43:28 +04:00
parent d287a36444
commit 4fef654c60
No known key found for this signature in database
GPG Key ID: A6C7C781817146FA
6 changed files with 339 additions and 44 deletions

View File

@ -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

View File

@ -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()

51
tests/Dockerfile Normal file
View File

@ -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"]

View File

@ -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

169
tests/miniupnpd_stub_rdr.c Normal file
View File

@ -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 <stdint.h>
#include <sys/socket.h>
/* 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; }

View File

@ -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()