Add pmp tests and mapping protocol

This commit is contained in:
Arnaud 2026-05-18 15:16:27 +04:00
parent adde1b5d27
commit 1269e80605
No known key found for this signature in database
GPG Key ID: A6C7C781817146FA
10 changed files with 115 additions and 28 deletions

View File

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

View File

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

16
api.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
vendor/libplum vendored

@ -1 +1 @@
Subproject commit 7b3639bcf973ff9e3de554545f703f5a595a0536
Subproject commit f6d2c12a9f347dcd6958b97c9df6418c43d975f2