From 1269e80605c468f1bbdeeff77319156290b42407 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 18 May 2026 15:16:27 +0400 Subject: [PATCH] Add pmp tests and mapping protocol --- .github/workflows/ci.yml | 3 ++- README.md | 13 ++++++++++--- api.md | 16 ++++++++++++--- libplum.nimble | 9 ++++++--- libplum/libplum.nim | 19 +++++++++++++----- libplum/plum.nim | 12 ++++++++++++ tests/Dockerfile | 13 +++++++++---- tests/docker-entrypoint.sh | 16 ++++++++++++--- tests/test_plum.nim | 40 +++++++++++++++++++++++++++++++++----- vendor/libplum | 2 +- 10 files changed, 115 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d37c4fa..2b92374 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: branches: - - master + - main pull_request: workflow_dispatch: @@ -12,3 +12,4 @@ jobs: with: test-command: | nimble test + nimble testIntegration diff --git a/README.md b/README.md index dfde878..76290f5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ proc main() {.async.} = let res = r.value echo "external: ", res.mapping.externalHost, ":", res.mapping.externalPort + echo "protocol: ", res.mapping.mappingProtocol # PCP, NatPmp, UPnP, or Direct destroyMapping(res.id) discard cleanup() @@ -95,13 +96,19 @@ Podman or Docker as fallback will be used for testing with `NET_ADMIN` capabilit nimble testIntegration ``` -This builds the image and runs two containers: one for PCP and one for UPnP. +This builds the image and runs three containers: PCP, UPnP, and a NAT-PMP fallback scenario +(miniupnpd compiled without PCP so libplum must fall back from PCP timeout to NAT-PMP). 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`: + +Three env vars control verbosity: + +- `TEST_VERBOSE=1`: print resolved external addresses +- `MINIUPNPD_VERBOSE=1`: print miniupnpd logs +- `LIBPLUM_VERBOSE=1`: enable verbose libplum internal logs ```bash -TEST_VERBOSE=1 nimble testIntegration +TEST_VERBOSE=1 MINIUPNPD_VERBOSE=1 LIBPLUM_VERBOSE=1 nimble testIntegration ``` ## License diff --git a/api.md b/api.md index 0af8707..11d08fb 100644 --- a/api.md +++ b/api.md @@ -17,9 +17,19 @@ type PlumState* = enum Destroying ``` +```nim +type MappingProtocol* = enum + Unknown ## not yet determined (mapping pending) + PCP ## Port Control Protocol + NatPmp ## NAT Port Mapping Protocol + UPnP ## UPnP-IGD + Direct ## no mapping needed, local address is already public +``` + ```nim type PlumMapping* = object - protocol*: PlumProtocol + protocol*: PlumProtocol ## IP protocol (TCP/UDP) + mappingProtocol*: MappingProtocol ## NAT traversal protocol used internalPort*: uint16 externalPort*: uint16 externalHost*: string @@ -51,7 +61,7 @@ proc init*( Initializes the library and starts the internal thread. Must be called before any other proc. -- `logLevel`: verbosity of internal logs (default: none) +- `logLevel`: verbosity of internal logs, from `PLUM_LOG_LEVEL_VERBOSE` to `PLUM_LOG_LEVEL_NONE` (default: none); import `libplum/libplum` to access these constants - `discoverTimeout`: how long to probe for a NAT device, in ms (default: 10000) - `mappingTimeout`: how long to wait for a mapping response, in ms (default: 10000) - `recheckPeriod`: interval between periodic mapping rechecks, in ms (default: 300000) @@ -84,7 +94,7 @@ Requests a port mapping from the NAT device. Tries PCP, then NAT-PMP, then UPnP- - `timeout`: how long to wait for the mapping to be established (default: 30s) - `onStateChange`: optional callback invoked when the mapping state changes after the initial result -Returns a `MappingResult` containing the mapping `id` (needed for `destroyMapping`) and the `PlumMapping` with the external address and port. +Returns a `MappingResult` containing the mapping `id` (needed for `destroyMapping`) and the `PlumMapping` with the external address, port, and `mappingProtocol` indicating which protocol was used (`PCP`, `NatPmp`, or `UPnP`). Returns an error if no NAT device is found, the mapping fails, or the timeout expires. diff --git a/libplum.nimble b/libplum.nimble index 01b740a..675691c 100644 --- a/libplum.nimble +++ b/libplum.nimble @@ -30,9 +30,12 @@ task test, "run tests": 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) + proc envFlag(name: string): string = + if getEnv(name) != "": " -e " & name & "=" & getEnv(name) else: "" + let flags = envFlag("TEST_VERBOSE") & envFlag("MINIUPNPD_VERBOSE") & envFlag("LIBPLUM_VERBOSE") + exec(docker & " run --rm --cap-add=NET_ADMIN -e TEST_MINIUPNP_PCP=1" & flags & " " & packageName) + exec(docker & " run --rm --cap-add=NET_ADMIN -e TEST_MINIUPNP_UPNP=1" & flags & " " & packageName) + exec(docker & " run --rm --cap-add=NET_ADMIN -e TEST_MINIUPNP_NATPMP=1" & flags & " " & packageName) before install: compileStaticLibraries() diff --git a/libplum/libplum.nim b/libplum/libplum.nim index 931866a..4892461 100644 --- a/libplum/libplum.nim +++ b/libplum/libplum.nim @@ -60,6 +60,14 @@ type PLUM_STATE_SUCCESS = 2 PLUM_STATE_FAILURE = 3 PLUM_STATE_DESTROYING = 4 + + plum_mapping_protocol_t* {.importc: "plum_mapping_protocol_t", header: "plum.h", + size: sizeof(cint).} = enum + PLUM_MAPPING_PROTOCOL_UNKNOWN = 0 + PLUM_MAPPING_PROTOCOL_PCP = 1 + PLUM_MAPPING_PROTOCOL_NATPMP = 2 + PLUM_MAPPING_PROTOCOL_UPNP = 3 + PLUM_MAPPING_PROTOCOL_DIRECT = 4 # Define the callback to receive the plum logs plum_log_callback_t* = proc(level: plum_log_level_t, message: cstring) {.cdecl.} @@ -76,11 +84,12 @@ type # Define the mapping struct, passed by copy (usual for struct). # The user_ptr is a pointer to the MappingHandle in order to receive the result plum_mapping_t* {.importc: "plum_mapping_t", header: "plum.h", bycopy.} = object - protocol* {.importc: "protocol".}: plum_ip_protocol_t - internal_port* {.importc: "internal_port".}: uint16 - external_port* {.importc: "external_port".}: uint16 - external_host* {.importc: "external_host".}: array[PLUM_MAX_HOST_LEN, char] - user_ptr* {.importc: "user_ptr".}: pointer + protocol* {.importc: "protocol".}: plum_ip_protocol_t + mapping_protocol* {.importc: "mapping_protocol".}: plum_mapping_protocol_t + internal_port* {.importc: "internal_port".}: uint16 + external_port* {.importc: "external_port".}: uint16 + external_host* {.importc: "external_host".}: array[PLUM_MAX_HOST_LEN, char] + user_ptr* {.importc: "user_ptr".}: pointer # Define the callback to receive the mapping result plum_mapping_callback_t* = diff --git a/libplum/plum.nim b/libplum/plum.nim index 5c3a351..702e126 100644 --- a/libplum/plum.nim +++ b/libplum/plum.nim @@ -28,8 +28,16 @@ type Failure = PLUM_STATE_FAILURE.int Destroying = PLUM_STATE_DESTROYING.int + MappingProtocol* = enum + Unknown = PLUM_MAPPING_PROTOCOL_UNKNOWN.int + PCP = PLUM_MAPPING_PROTOCOL_PCP.int + NatPmp = PLUM_MAPPING_PROTOCOL_NATPMP.int + UPnP = PLUM_MAPPING_PROTOCOL_UPNP.int + Direct = PLUM_MAPPING_PROTOCOL_DIRECT.int + PlumMapping* = object protocol*: PlumProtocol + mappingProtocol*: MappingProtocol internalPort*: uint16 externalPort*: uint16 externalHost*: string @@ -51,6 +59,7 @@ type resolved: Atomic[bool] resolvedState: PlumState resolvedProtocol: PlumProtocol + resolvedMappingProtocol: MappingProtocol resolvedInternalPort: uint16 resolvedExternalPort: uint16 resolvedExternalHost: array[PLUM_MAX_HOST_LEN, char] @@ -111,6 +120,7 @@ proc mappingCallback(id: cint, state: plum_state_t, # and fire the signal. handle.resolvedState = plumState handle.resolvedProtocol = PlumProtocol(raw[].protocol.int) + handle.resolvedMappingProtocol = MappingProtocol(raw[].mapping_protocol.int) handle.resolvedInternalPort = raw[].internal_port handle.resolvedExternalPort = raw[].external_port handle.resolvedExternalHost = raw[].external_host @@ -120,6 +130,7 @@ proc mappingCallback(id: cint, state: plum_state_t, if not handle.onStateChange.isNil: let mapping = PlumMapping( protocol: PlumProtocol(raw[].protocol.int), + mappingProtocol: MappingProtocol(raw[].mapping_protocol.int), internalPort: raw[].internal_port, externalPort: raw[].external_port, externalHost: $cast[cstring](addr raw[].external_host) @@ -232,6 +243,7 @@ proc createMapping*( resolvedState = h.resolvedState resolvedMapping = PlumMapping( protocol: h.resolvedProtocol, + mappingProtocol: h.resolvedMappingProtocol, internalPort: h.resolvedInternalPort, externalPort: h.resolvedExternalPort, externalHost: $cast[cstring](unsafeAddr h.resolvedExternalHost) diff --git a/tests/Dockerfile b/tests/Dockerfile index ab778d2..7322459 100644 --- a/tests/Dockerfile +++ b/tests/Dockerfile @@ -16,6 +16,9 @@ RUN git clone --depth=1 --branch miniupnpd_2_3_9 \ && cp /tmp/stub_rdr.c . \ && make NETFILTEROBJS=stub_rdr.o miniupnpd \ && install -m 755 miniupnpd /usr/local/sbin/miniupnpd \ + && sed -i 's/^#define ENABLE_PCP$/\/*#define ENABLE_PCP*\//' config.h \ + && make NETFILTEROBJS=stub_rdr.o miniupnpd \ + && install -m 755 miniupnpd /usr/local/sbin/miniupnpd-natpmponly \ && rm -rf /tmp/miniupnp /tmp/stub_rdr.c # Install Nim @@ -41,10 +44,12 @@ RUN cmake -B 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 +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 && \ + nim c -d:miniupnp_protocol=natpmp --mm:orc -o:tests/test_natpmp_orc tests/test_plum.nim && \ + nim c -d:miniupnp_protocol=natpmp --mm:refc -o:tests/test_natpmp_refc tests/test_plum.nim COPY tests/docker-entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/tests/docker-entrypoint.sh b/tests/docker-entrypoint.sh index 396de78..256e9b6 100644 --- a/tests/docker-entrypoint.sh +++ b/tests/docker-entrypoint.sh @@ -14,7 +14,7 @@ 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 proto=$1 enable_pcp_pmp=$2 listen_on=$3 binary=${4:-miniupnpd} local conf="$RUNDIR/miniupnpd-$proto.conf" local pidfile="$RUNDIR/miniupnpd-$proto.pid" @@ -29,7 +29,7 @@ port=0 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 & + "$binary" -d -f "$conf" > "$RUNDIR/miniupnpd-$proto.log" 2>&1 & echo $! > "$pidfile" sleep 1 kill -0 "$(cat "$pidfile")" 2>/dev/null \ @@ -44,7 +44,7 @@ run_tests() { echo "--- $proto $mm ---" "/app/tests/test_${proto}_${mm}" || failed=1 done - if [ "${TEST_VERBOSE:-}" = "1" ]; then + if [ "${MINIUPNPD_VERBOSE:-}" = "1" ]; then echo "--- miniupnpd log ---" cat "$logfile" 2>/dev/null || true fi @@ -67,3 +67,13 @@ if [ "${TEST_MINIUPNP_UPNP:-}" = "1" ]; then run_tests upnp "$RUNDIR/miniupnpd-upnp.log" fi +if [ "${TEST_MINIUPNP_NATPMP:-}" = "1" ]; then + # miniupnpd-natpmponly is compiled without ENABLE_PCP: PCP probes get no + # response (timeout) and libplum must fall back to NAT-PMP on its own. + # Same route trick as PCP: point the default route at LAN_IP so libplum + # sends NAT-PMP to the local miniupnpd rather than the real gateway. + ip route replace default via "$LAN_IP" dev "$LAN_IF" + start_miniupnpd natpmp yes "$LAN_IF" miniupnpd-natpmponly + run_tests natpmp "$RUNDIR/miniupnpd-natpmp.log" +fi + diff --git a/tests/test_plum.nim b/tests/test_plum.nim index 4b74baa..d33541e 100644 --- a/tests/test_plum.nim +++ b/tests/test_plum.nim @@ -10,6 +10,7 @@ import std/os import unittest2 import chronos import libplum/plum +import libplum/libplum suite "plum": test "init and cleanup": @@ -36,10 +37,14 @@ suite "plum": 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() +when miniupnp_protocol == "natpmp": + # miniupnpd is compiled without PCP: PCP probes time out and libplum must + # fall back to NAT-PMP on its own. + suite "plum - natpmp fallback (PCP timeout)": + test "createMapping TCP via NAT-PMP fallback": + let logLevel = if getEnv("LIBPLUM_VERBOSE") == "1": PLUM_LOG_LEVEL_VERBOSE + else: PLUM_LOG_LEVEL_NONE + check init(discoverTimeout = 15000, logLevel = logLevel).isOk() let r = waitFor createMapping(TCP, 8101, timeout = seconds(40)) check r.isOk() @@ -47,6 +52,28 @@ when miniupnp_protocol != "": let res = r.value check res.mapping.externalPort > 0 check res.mapping.externalHost.len > 0 + check res.mapping.mappingProtocol == NatPmp + check hasMapping(res.id) + if getEnv("TEST_VERBOSE") == "1": + echo "NAT-PMP TCP: " & res.mapping.externalHost & ":" & $res.mapping.externalPort + destroyMapping(res.id) + + discard cleanup() + +elif miniupnp_protocol != "": + suite "plum - " & miniupnp_protocol & " using miniupnp": + test "createMapping TCP and destroyMapping": + let logLevel = if getEnv("LIBPLUM_VERBOSE") == "1": PLUM_LOG_LEVEL_VERBOSE + else: PLUM_LOG_LEVEL_NONE + check init(discoverTimeout = 15000, logLevel = logLevel).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 res.mapping.mappingProtocol == (if miniupnp_protocol == "pcp": PCP else: UPnP) check hasMapping(res.id) if getEnv("TEST_VERBOSE") == "1": echo miniupnp_protocol & " TCP: " & res.mapping.externalHost & ":" & $res.mapping.externalPort @@ -55,13 +82,16 @@ when miniupnp_protocol != "": discard cleanup() test "createMapping UDP and destroying": - check init(discoverTimeout = 2000).isOk() + let logLevel = if getEnv("LIBPLUM_VERBOSE") == "1": PLUM_LOG_LEVEL_VERBOSE + else: PLUM_LOG_LEVEL_NONE + check init(discoverTimeout = 2000, logLevel = logLevel).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 + check res.mapping.mappingProtocol == (if miniupnp_protocol == "pcp": PCP else: UPnP) if getEnv("TEST_VERBOSE") == "1": echo miniupnp_protocol & " UDP: " & res.mapping.externalHost & ":" & $res.mapping.externalPort destroyMapping(res.id) diff --git a/vendor/libplum b/vendor/libplum index 7b3639b..f6d2c12 160000 --- a/vendor/libplum +++ b/vendor/libplum @@ -1 +1 @@ -Subproject commit 7b3639bcf973ff9e3de554545f703f5a595a0536 +Subproject commit f6d2c12a9f347dcd6958b97c9df6418c43d975f2