commit 21e98b41a1bd629d818fd84909fbdd7d60363c8a Author: Arnaud Date: Thu May 14 12:23:10 2026 +0400 Initial commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d37c4fa --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ec8ad2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +nimcache/ + +# Compiled binaries +*.so +*.dylib +*.a +*.la +*.exe +*.dll + +/examples/port_mapping +/tests/test_plum + +nimble.develop +nimble.paths +nimbledeps diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..98deb1f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/libplum"] + path = vendor/libplum + url = file:///home/arnaud/Work/2-towns/libplum diff --git a/LICENSE-APACHEv2 b/LICENSE-APACHEv2 new file mode 100644 index 0000000..782d1bf --- /dev/null +++ b/LICENSE-APACHEv2 @@ -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. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..8766e65 --- /dev/null +++ b/LICENSE-MIT @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a67c16 --- /dev/null +++ b/README.md @@ -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 +``` + +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. diff --git a/config.nims b/config.nims new file mode 100644 index 0000000..05af83c --- /dev/null +++ b/config.nims @@ -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 diff --git a/examples/nim.cfg b/examples/nim.cfg new file mode 100644 index 0000000..4e57d3d --- /dev/null +++ b/examples/nim.cfg @@ -0,0 +1 @@ +-p:".." diff --git a/examples/port_mapping.nim b/examples/port_mapping.nim new file mode 100644 index 0000000..22c8252 --- /dev/null +++ b/examples/port_mapping.nim @@ -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("") + + 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() diff --git a/libplum.nimble b/libplum.nimble new file mode 100644 index 0000000..1742f01 --- /dev/null +++ b/libplum.nimble @@ -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() diff --git a/libplum/libplum.nim b/libplum/libplum.nim new file mode 100644 index 0000000..fd4b133 --- /dev/null +++ b/libplum/libplum.nim @@ -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".} diff --git a/libplum/plum.nim b/libplum/plum.nim new file mode 100644 index 0000000..7dd013b --- /dev/null +++ b/libplum/plum.nim @@ -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) diff --git a/tests/test_plum.nim b/tests/test_plum.nim new file mode 100644 index 0000000..a972a4d --- /dev/null +++ b/tests/test_plum.nim @@ -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() diff --git a/vendor/libplum b/vendor/libplum new file mode 160000 index 0000000..ff91874 --- /dev/null +++ b/vendor/libplum @@ -0,0 +1 @@ +Subproject commit ff91874909882e0ba708a84cd9f1cf5761631869