Initial commit

This commit is contained in:
Arnaud 2026-05-14 12:23:10 +04:00
commit 21e98b41a1
No known key found for this signature in database
GPG Key ID: A6C7C781817146FA
14 changed files with 808 additions and 0 deletions

14
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,14 @@
name: CI
on:
push:
branches:
- master
pull_request:
workflow_dispatch:
jobs:
build:
uses: status-im/nimbus-common-workflow/.github/workflows/common.yml@main
with:
test-command: |
nimble test

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
nimcache/
# Compiled binaries
*.so
*.dylib
*.a
*.la
*.exe
*.dll
/examples/port_mapping
/tests/test_plum
nimble.develop
nimble.paths
nimbledeps

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "vendor/libplum"]
path = vendor/libplum
url = file:///home/arnaud/Work/2-towns/libplum

201
LICENSE-APACHEv2 Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2018 Status Research & Development GmbH
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

21
LICENSE-MIT Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2018 Status Research & Development GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

82
README.md Normal file
View File

@ -0,0 +1,82 @@
# nim-libplum
Nim binding for [libplum](https://github.com/paullouisageneau/libplum), a portable C library for NAT port mapping via PCP, NAT-PMP, and UPnP-IGD.
libplum tries each protocol in order (PCP → NAT-PMP → UPnP-IGD) and falls back automatically. If the local address is already public, it uses it directly.
## Installation
Clone with submodules:
```bash
git clone --recurse-submodules <REPO_URL>
```
Or after cloning:
```bash
git submodule update --init --recursive
```
Install via Nimble:
```bash
nimble install
```
## Usage
```nim
import chronos
import libplum/plum
proc main() {.async.} =
check init().isOk()
let r = await createMapping(TCP, 8080)
if r.isErr():
echo "failed: ", r.error
return
let res = r.value
echo "external: ", res.mapping.externalHost, ":", res.mapping.externalPort
destroyMapping(res.id)
discard cleanup()
waitFor main()
```
See the [examples](examples) directory for a complete example.
### Ongoing state changes
Pass an `onStateChange` callback to `createMapping` to be notified when the mapping is renewed or lost:
```nim
proc onStateChange(state: PlumState, mapping: PlumMapping) {.cdecl, raises: [], gcsafe.} =
echo "state changed: ", state, " external: ", mapping.externalHost, ":", mapping.externalPort
let r = await createMapping(TCP, 8080, onStateChange = onStateChange)
```
### Configurable timeouts
The discovery and mapping timeouts can be configured via `init`:
```nim
discard init(
discoverTimeout = 5000, # ms, default 10000
mappingTimeout = 5000, # ms, default 10000
recheckPeriod = 60000, # ms, default 300000
)
```
## License
Licensed and distributed under either of
* MIT license: [LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT
* Apache License, Version 2.0: [LICENSE-APACHEv2](LICENSE-APACHEv2) or http://www.apache.org/licenses/LICENSE-2.0
at your option.

7
config.nims Normal file
View File

@ -0,0 +1,7 @@
switch("threads", "on")
# begin Nimble config (version 2)
--noNimblePath
when withDir(thisDir(), system.fileExists("nimble.paths")):
include "nimble.paths"
# end Nimble config

1
examples/nim.cfg Normal file
View File

@ -0,0 +1 @@
-p:".."

41
examples/port_mapping.nim Normal file
View File

@ -0,0 +1,41 @@
# Copyright (c) 2026 Status Research & Development GmbH
# Licensed under either of
# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE))
# * MIT license ([LICENSE-MIT](LICENSE-MIT))
# at your option.
# This file may not be copied, modified, or distributed except according to
# those terms.
# This example creates a TCP port mapping via PCP, NAT-PMP, or UPnP-IGD,
# prints the external address, and destroys the mapping before exiting.
import chronos
import libplum/plum
proc main() {.async.} =
let initRes = init()
if initRes.isErr():
echo "init failed: ", initRes.error
return
echo "Local address: ", getLocalAddress().valueOr("<unknown>")
let r = await createMapping(TCP, 8080)
if r.isErr():
echo "createMapping failed: ", r.error
discard cleanup()
return
let res = r.value
echo "Mapping created:"
echo " external host: ", res.mapping.externalHost
echo " external port: ", res.mapping.externalPort
echo " internal port: ", res.mapping.internalPort
destroyMapping(res.id)
let cleanupRes = cleanup()
if cleanupRes.isErr():
echo "cleanup failed: ", cleanupRes.error
waitFor main()

28
libplum.nimble Normal file
View File

@ -0,0 +1,28 @@
mode = ScriptMode.Verbose
packageName = "libplum"
version = "0.0.1"
author = "Status Research & Development GmbH"
description = "Nim binding for libplum (PCP, NAT-PMP, UPnP-IGD port mapping)"
license = "Apache License 2.0 or MIT"
installDirs = @["libplum", "vendor"]
### Dependencies
requires "nim >= 1.6.0",
"results >= 0.4.0",
"chronos >= 4.2.0 & < 5.0.0"
proc compileStaticLibraries() =
withDir "vendor/libplum":
exec("cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF")
exec("cmake --build build")
task buildBundledLibs, "build bundled libraries":
compileStaticLibraries()
task test, "run tests":
compileStaticLibraries()
exec("nim c -r tests/test_plum.nim")
before install:
compileStaticLibraries()

106
libplum/libplum.nim Normal file
View File

@ -0,0 +1,106 @@
# Copyright (c) 2026 Status Research & Development GmbH
# Licensed under either of
# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE))
# * MIT license ([LICENSE-MIT](LICENSE-MIT))
# at your option.
# This file may not be copied, modified, or distributed except according to
# those terms.
import std/[os, strutils]
const
rootPath = currentSourcePath.parentDir().parentDir().replace('\\', '/')
libplumPath = rootPath & "/vendor/libplum"
includePath = libplumPath & "/include/plum"
libraryPath = libplumPath & "/build/libplum.a"
{.passc: "-I" & includePath & " -DPLUM_STATIC".}
{.passl: libraryPath.}
# libplum declares some parameters as `const T*` in C (read-only pointer).
# Nim has no equivalent, so the generated C code drops the `const`, causing
# a type mismatch warning in GCC 15+. This flag suppresses that warning.
# It is safe because we never write through those pointers.
{.passc: "-Wno-incompatible-pointer-types".}
when defined(windows):
{.passl: "-lws2_32 -liphlpapi -lbcrypt".}
else:
{.passl: "-lpthread".}
const
PLUM_ERR_SUCCESS* = cint(0)
PLUM_ERR_INVALID* = cint(-1)
PLUM_ERR_FAILED* = cint(-2)
PLUM_ERR_NOT_AVAIL* = cint(-3)
PLUM_MAX_HOST_LEN* = 256
PLUM_MAX_ADDRESS_LEN* = 64
# Import plum enums with cint size
type
plum_log_level_t* {.importc: "plum_log_level_t", header: "plum.h",
size: sizeof(cint).} = enum
PLUM_LOG_LEVEL_VERBOSE = 0
PLUM_LOG_LEVEL_DEBUG = 1
PLUM_LOG_LEVEL_INFO = 2
PLUM_LOG_LEVEL_WARN = 3
PLUM_LOG_LEVEL_ERROR = 4
PLUM_LOG_LEVEL_FATAL = 5
PLUM_LOG_LEVEL_NONE = 6
plum_ip_protocol_t* {.importc: "plum_ip_protocol_t", header: "plum.h",
size: sizeof(cint).} = enum
PLUM_IP_PROTOCOL_TCP = 0
PLUM_IP_PROTOCOL_UDP = 1
plum_state_t* {.importc: "plum_state_t", header: "plum.h",
size: sizeof(cint).} = enum
PLUM_STATE_DESTROYED = 0
PLUM_STATE_PENDING = 1
PLUM_STATE_SUCCESS = 2
PLUM_STATE_FAILURE = 3
PLUM_STATE_DESTROYING = 4
# Define the callback to receive the plum logs
plum_log_callback_t* = proc(level: plum_log_level_t, message: cstring) {.cdecl.}
# Define the config struct, passed by copy (usual for struct).
plum_config_t* {.importc: "plum_config_t", header: "plum.h", bycopy.} = object
log_level* {.importc: "log_level".}: plum_log_level_t
log_callback* {.importc: "log_callback".}: plum_log_callback_t
dummytls_domain* {.importc: "dummytls_domain".}: cstring
# 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
# Define the callback to receive the mapping result
plum_mapping_callback_t* =
proc(id: cint, state: plum_state_t, mapping: ptr plum_mapping_t) {.cdecl.}
# Import plum functions
proc plum_init*(config: ptr plum_config_t): cint
{.importc: "plum_init", header: "plum.h".}
proc plum_cleanup*(): cint
{.importc: "plum_cleanup", header: "plum.h".}
proc plum_create_mapping*(mapping: ptr plum_mapping_t,
callback: plum_mapping_callback_t): cint
{.importc: "plum_create_mapping", header: "plum.h".}
proc plum_query_mapping*(id: cint, state: ptr plum_state_t,
mapping: ptr plum_mapping_t): cint
{.importc: "plum_query_mapping", header: "plum.h".}
proc plum_destroy_mapping*(id: cint): cint
{.importc: "plum_destroy_mapping", header: "plum.h".}
proc plum_get_local_address*(buffer: cstring, size: csize_t): cint
{.importc: "plum_get_local_address", header: "plum.h".}

214
libplum/plum.nim Normal file
View File

@ -0,0 +1,214 @@
# Copyright (c) 2026 Status Research & Development GmbH
# Licensed under either of
# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE))
# * MIT license ([LICENSE-MIT](LICENSE-MIT))
# at your option.
# This file may not be copied, modified, or distributed except according to
# those terms.
import std/[atomics, locks, tables]
import chronos
import chronos/threadsync
import results
import ./libplum
export results
{.pragma: callback, cdecl, raises: [], gcsafe.}
type
PlumProtocol* = enum
TCP = PLUM_IP_PROTOCOL_TCP.int
UDP = PLUM_IP_PROTOCOL_UDP.int
PlumState* = enum
Destroyed = PLUM_STATE_DESTROYED.int
Pending = PLUM_STATE_PENDING.int
Success = PLUM_STATE_SUCCESS.int
Failure = PLUM_STATE_FAILURE.int
Destroying = PLUM_STATE_DESTROYING.int
PlumMapping* = object
protocol*: PlumProtocol
internalPort*: uint16
externalPort*: uint16
externalHost*: string
MappingResult* = object
id*: cint
mapping*: PlumMapping
PlumStateCallback* =
proc(state: PlumState, mapping: PlumMapping) {.callback.}
MappingHandle = ref object
signal: ThreadSignalPtr
# Indicate that the first call of the mapping handle is
# done. In that case, resolvedState and resolvedMapping
# will contain the result.
# MappingHandle can be called multiple times after that,
# but the result will be passed through the callback.
resolved: Atomic[bool]
resolvedState: PlumState
resolvedMapping: PlumMapping
# Use abandoned pattern for memory freeing
abandoned: Atomic[bool]
onStateChange: PlumStateCallback
# libplum calls mappingCallback from its own C thread. Under refc, any thread
# that touches Nim objects must register with the GC first.
template foreignThreadGc(body: untyped) =
when declared(setupForeignThreadGc): setupForeignThreadGc()
body
when declared(tearDownForeignThreadGc): tearDownForeignThreadGc()
var activeMappingsLock: Lock
var activeMappings {.guard: activeMappingsLock.}: Table[cint, MappingHandle]
initLock(activeMappingsLock)
# We can be confident that the pattern is GC Safe using
# a lock.
template withSafeLock(body: untyped) =
{.cast(gcsafe).}:
withLock activeMappingsLock:
body
proc mappingCallback(id: cint, state: plum_state_t,
raw: ptr plum_mapping_t) {.cdecl, raises: [].} =
## Called from libplum's internal C thread on SUCCESS, FAILURE, and DESTROYED.
foreignThreadGc:
let handle = cast[MappingHandle](raw[].user_ptr)
if handle.isNil:
return
let plumState = PlumState(state.int)
if plumState == Destroyed:
withSafeLock:
activeMappings.del(id)
# The handle can be abandoned after a timeout during a
# mapping creation.
# In that case, destroy is called internally and the
# signal pointer can be closed.
if handle.abandoned.load():
discard handle.signal.close()
return
# Skip states other than Success and Failure
if plumState notin {Success, Failure}:
return
let mapping = PlumMapping(
protocol: PlumProtocol(raw[].protocol.int),
internalPort: raw[].internal_port,
externalPort: raw[].external_port,
externalHost: $cast[cstring](unsafeAddr raw[].external_host[0])
)
if not handle.resolved.exchange(true):
# If this is the first time the handle mapping
# is called, let's set the result in the handle values,
# and fire the signal.
handle.resolvedState = plumState
handle.resolvedMapping = mapping
discard handle.signal.fireSync()
else:
# Otherwise, just call the callback
if not handle.onStateChange.isNil:
handle.onStateChange(plumState, mapping)
proc init*(logLevel = PLUM_LOG_LEVEL_NONE): Result[void, string] {.raises: [].} =
## init MUST be called to setup internal plum thread (plum_init).
var config = plum_config_t(
log_level: logLevel,
log_callback: nil,
dummytls_domain: nil
)
let res = plum_init(addr config)
if res == PLUM_ERR_SUCCESS:
ok()
else:
err("plum_init failed: " & $res)
proc cleanup*(): Result[void, string] {.raises: [].} =
## cleanup MUST be called to stop the thread and clean the setup.
let res = plum_cleanup()
if res == PLUM_ERR_SUCCESS:
ok()
else:
err("plum_cleanup failed: " & $res)
proc createMapping*(
protocol: PlumProtocol,
internalPort: uint16,
externalPort: uint16 = 0,
onStateChange: PlumStateCallback = nil
): Future[Result[MappingResult, string]] {.async: (raises: [CancelledError]).} =
let signal = ThreadSignalPtr.new().valueOr:
return err("plum: cannot create signal: " & $error)
let handle = MappingHandle(
signal: signal,
onStateChange: onStateChange
)
var req = plum_mapping_t(
protocol: plum_ip_protocol_t(protocol.int),
internal_port: internalPort,
external_port: externalPort,
user_ptr: cast[pointer](handle)
)
let id = plum_create_mapping(addr req, mappingCallback)
if id < 0:
discard signal.close()
return err("plum_create_mapping failed: " & $id)
withSafeLock:
activeMappings[id] = handle
var completed = false
try:
# Wait for the callback to fireSync
completed = await withTimeout(signal.wait(), seconds(30))
except CancelledError:
raise
finally:
if not completed:
# The signal reached timeout or was cancelled,
# we cannot close it now otherwise the pending operation
# might trigger a memory issue.
# When entering into DESTROYING state, libplum will ignore
# the pending operation so closing the signal in the DESTROYED
# callback is safe.
handle.abandoned.store(true)
discard plum_destroy_mapping(id)
else:
# The signal is completed, we can close it
discard signal.close()
if not completed:
return err("plum: mapping " & $id & " timed out")
if handle.resolvedState == Success:
return ok(MappingResult(id: id, mapping: handle.resolvedMapping))
else:
discard plum_destroy_mapping(id)
return err("plum: mapping " & $id & " failed")
proc destroyMapping*(id: cint) {.raises: [].} =
## Must be called exactly once after a successful createMapping.
discard plum_destroy_mapping(id)
proc getLocalAddress*(): Result[string, string] {.raises: [].} =
var buf = newString(PLUM_MAX_ADDRESS_LEN)
let res = plum_get_local_address(buf.cstring, buf.len.csize_t)
if res >= 0:
buf.setLen(min(res, PLUM_MAX_ADDRESS_LEN))
ok(buf)
else:
err("plum_get_local_address failed: " & $res)

73
tests/test_plum.nim Normal file
View File

@ -0,0 +1,73 @@
# Copyright (c) 2026 Status Research & Development GmbH
# Licensed under either of
# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE))
# * MIT license ([LICENSE-MIT](LICENSE-MIT))
# at your option.
# This file may not be copied, modified, or distributed except according to
# those terms.
import std/envvars
import unittest2
import chronos
import libplum/plum
suite "plum":
test "init and cleanup":
let r = init()
check r.isOk()
let c = cleanup()
check c.isOk()
test "double cleanup returns error":
discard init()
discard cleanup()
let c = cleanup()
check c.isErr()
test "getLocalAddress after init":
discard init()
let r = getLocalAddress()
check r.isOk()
check r.value.len > 0
discard cleanup()
test "createMapping fails without router":
# In CI with no NAT device, expect Failure or timeout — both return err.
discard init()
let r = waitFor createMapping(UDP, 12345)
check r.isErr()
discard cleanup()
suite "plum - NAT port mapping (requires NAT_TEST_PLUM=1)":
test "createMapping TCP and destroyMapping":
if getEnv("NAT_TEST_PLUM") != "1":
skip()
return
check init().isOk()
let r = waitFor createMapping(TCP, 8101)
check r.isOk()
let res = r.value
check res.mapping.externalPort > 0
check res.mapping.externalHost.len > 0
destroyMapping(res.id)
check cleanup().isOk()
test "createMapping UDP":
if getEnv("NAT_TEST_PLUM") != "1":
skip()
return
check init().isOk()
let r = waitFor createMapping(UDP, 8090)
check r.isOk()
let res = r.value
check res.mapping.externalPort > 0
destroyMapping(res.id)
check cleanup().isOk()

1
vendor/libplum vendored Submodule

@ -0,0 +1 @@
Subproject commit ff91874909882e0ba708a84cd9f1cf5761631869