Open-source project
heiher/natmap avatar
heiher/natmap

natmap pins a public port through a full-cone NAT using a STUN keep-alive

TCP/UDP port mapping for full-cone NAT

1,968 stars150 forksCMIT

At a glance

What is it?
natmap is a C tool that creates a TCP or UDP port mapping from an ISP NAT address to a local service by holding a connection open against a STUN server, which only works when every NAT layer on the path is a full cone. Its notify-script and IP4P mechanisms are what make it usable for real services.
Who is it for?
Adopt natmap if you sit behind a full-cone NAT, need a reachable TCP or UDP port for a self-hosted service, and want a single static binary with no dependency on an external tunnel broker, since bind mode keeps the mapping with a STUN keep-alive instead of routing your traffic through anyone else's server.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 12 days ago.
What is it written in?
Mainly C, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The mapping exists because a STUN connection stays open

natmap establishes a TCP or UDP port mapping from an ISP NAT public address to a local private address. The README is precise about the condition: if all layers of NAT are full cones, that is NAT-1, any host can access internal services through the mapped public address.

That single sentence defines both the value and the limit. A full cone NAT maps an internal port to a public port and forwards traffic from any external host. A symmetric NAT, which most ISPs deploy for residential connections, allocates a different public port per destination, so the port you opened against one server is not the port another host can reach. No amount of configuration on your side changes that, which is why natmap is a good fit behind some networks and useless behind others.

The TCP mechanism is described as two steps. First, establish a connection with the HTTP server from the specified bind port and keep it alive. Second, establish a connection with the STUN server from the same port and obtain the public address.

That is the whole trick, and it is why both `-s` and `-h` exist. The STUN server tells you what public address and port your socket appears to have; the HTTP server connection is what stops the NAT from reclaiming the mapping while you have nothing to send. `-k <interval>` sets seconds between each keep-alives and `-c <count>` sets the UDP STUN check cycle measured in intervals, so the liveness probe rate is tunable separately from the keep-alive.

UDP mode is simpler because UDP has no connection to hold, which is why STUN is the only server involved there:

bash
natmap -u -s turn.cloudflare.com -b 443

If the mapping is up but traffic does not arrive, the firewall is the next suspect rather than the NAT. The README says plainly that you need to open the firewall to allow access to the bind port, and gives an OpenWrt recipe: Network, Firewall, Traffic Rules, protocol TCP/UDP, source zone wan, destination zone Device, destination port the bind port, action accept, everything else at defaults.

Bind mode versus forward mode is the distinction that matters

The opening line of the README states that in bind mode all traffic does not go through this program. That is the architectural difference between the two modes and it deserves attention before you pick one.

In bind mode, natmap creates the mapping and steps aside. Your own service listens on the bind port and receives the traffic directly from the outside world. The program is only maintaining the NAT state. That is the mode to use for anything real.

In forward mode, the program listens on the bound port, accepts incoming connections, and forwards them to a target address:

bash
natmap -s turn.cloudflare.com -h example.com -b 80 -t 10.0.0.2 -p 80
natmap -u -s turn.cloudflare.com -b 443 -t 10.0.0.2 -p 443

So in forward mode your traffic does go through natmap, which means the process is in the data path and its availability is your service's availability. The README also notes the alternative for the same goal: use the firewall's DNAT, which requires bind mode rather than forward mode. On OpenWrt that means a port forward rule with source zone wan, external port the bind port, destination zone lan, internal IP and internal port, and defaults elsewhere.

The forward options are `-t <address>` for the target domain or address, `-p <port>` for the target port where 0 means use the public port, `-T <timeout>` for the port forwarding timeout in seconds, and `-C <congestion>` for the TCP congestion control algorithm.

Port selection under `-b` is more expressive than a single number, and this is worth reading before you hardcode anything:

- `<0>` allocates randomly. - `<port>` specifies a fixed port. - `<port>~<port>` allocates randomly within the range. - `<port>-<port>` allocates sequentially within the range.

That matters if you need a predictable public port, because your notify script and your DNS records both depend on knowing which port landed where.

The notify script and IP4P turn a port into an address

Once the mapping exists, something has to be told about it. `-e <path>` points at a script that natmap calls after the mapping is established, and the README states the script can update the DNS record for external access.

The argument list is documented precisely, which is what makes this usable rather than a black box:

code
{public-addr} {public-port} {ip4p} {private-port} {protocol} {private-addr}

argv[1] is the public address, argv[2] the public port, argv[3] the IP4P value, argv[4] the bind port as the private port, argv[5] the protocol, and argv[6] the private address.

IP4P is the mechanism that makes an IPv6 DNS record carry an IPv4 endpoint. The format uses IPv6 special addresses to encode IPv4 addresses and ports for easy distribution through DNS AAAA records, laid out as:

code
2001::{port}:{ipv4-hi16}:{ipv4-lo16}

So the port is encoded alongside the two 16-bit halves of the IPv4 address, and clients that understand the convention can read a single AAAA lookup and know both host and port. That is what makes it possible to publish a dynamic endpoint through DNS without a second SRV record, and it is the feature a self-hosted service would actually build its discovery on.

The reference for this is in the Docker compose example, which mounts a script at `/opt/cf_ip4p.sh` and passes `-e` pointing at it, with the image referenced as `ghcr.io/heiher/natmap:main`.

Two operational details attach to any notify script. The script is invoked with the mapping details and must be executable by the user running the program, which is why the Docker notes call out mounting volumes and making sure you have permission to run. And since the mapping can move when the keep-alive lapses, the script has to be idempotent rather than appending records.

Platform quirks: Windows binding, OpenWrt, Docker and port reuse

Four platform notes are worth carrying into any deployment.

The Windows binding rule is the one that catches people. In TCP mode on Windows, your application server must be bound to the local network IP, not to `0.0.0.0` and `::`. In UDP mode on Windows the rule inverts: bind to `0.0.0.0` or `::`, not to the local network IP. Opposite instructions for the two modes, and getting them wrong produces a mapping that exists and never delivers traffic.

The OpenWrt path is a package, `opkg install natmap luci-app-natmap`, from the OpenWrt packages repository, requiring OpenWrt 22.03 or newer.

The Docker image supports amd64, arm64, arm, riscv64 and s390x, with tags for `latest` and release tags such as 20250512. The reference compose file gives the shape of a working configuration:

yaml
services:
  natmap:
    image: ghcr.io/heiher/natmap:main
    command: -u -s stun.qq.com -b 30101 -t 127.0.0.1 -p 51820 -e /opt/cf_ip4p.sh -k 60
    volumes:
      - ./natmap/cf_ip4p.sh:/opt/cf_ip4p.sh
    cap_add:
      - NET_ADMIN
    privileged: true
    environment:
      - TZ=Asia/Shanghai
    network_mode: host
    restart: always

`privileged: true` together with `NET_ADMIN` and `network_mode: host` is the price of doing raw NAT-adjacent work in a container, and it is a real cost to weigh before putting this on a shared host.

The port reuse feature deserves its own mention because it solves a specific failure. If port binding fails because the port is already in use, natmap tries to find which local service process occupies the port and enable port reuse remotely. That works in Linux kernel 5.6 and later and needs to run as root.

Other options round out the surface: `-4` and `-6` select the address family, `-d` runs as a daemon, `-i <interface>` takes a network interface or IP address, and `-f <mark>` sets a firewall mark in hex, decimal or octal.

On Windows there is also a Scoop manifest: `scoop bucket add main` followed by `scoop install main/natmap`.

Building it, and what the build says about the code

The build is a plain Makefile with git submodules, so the clone has to be recursive:

bash
git clone --recursive https://github.com/heiher/natmap.git
cd natmap
make

Three variants are documented. `make ENABLE_STATIC=1` produces a statically linked binary. `make CROSS_PREFIX=${cross-toolchain}/bin/x86_64-unknown-linux-` cross compiles. On Android you clone the repository into a directory named `jni` and run `ndk-build`, which is why the repository root carries `Android.mk` and `Application.mk`.

On Windows under MSYS2 the README requires `export MSYS=winsymlinks:native` before cloning, because the Makefile tests for `MSYSTEM` and appends `-lmsys-2.0 -lws2_32` to the link flags when it matches.

The Makefile gives a decent picture of the codebase. Compiler flags are `-O3 -pipe -Wall -Werror`, which means warnings are build failures, and the linker pulls in `lpthread` plus `lhev-task-system` from a vendored `third-part/` directory that is a git submodule, hence the recursive clone. There is a feature probe that adds `-fno-stack-clash-protection` when the compiler accepts it, and `ENABLE_DEBUG=1` switches to `-g -O0 -DENABLE_DEBUG` with a no-op strip.

The third-party dependency is hev-task-system, and the compile flags point at its headers and its `src/lib/rbtree` directory, which tells you the program uses a red-black tree for its event scheduling. The maintainer of that library, hev, is listed among the contributors, which explains the dependency.

Versions are date-based rather than semantic: 20250721, 20250924 and 20260214 are the recent tags, and the last push was on 2026-10-11. That naming convention lines up with the Docker tag scheme the README describes.

The licence is MIT and the README links a Chinese-language wiki at github.com/heiher/natmap/wiki as the fuller documentation.

Editorial conclusion

Adopt natmap if you sit behind a full-cone NAT, need a reachable TCP or UDP port for a self-hosted service, and want a single static binary with no dependency on an external tunnel broker, since bind mode keeps the mapping with a STUN keep-alive instead of routing your traffic through anyone else's server. Do not adopt it behind a symmetric NAT, because the mapping depends on every NAT layer on the path being a full cone, and treat forward mode with suspicion since it sends your traffic through this program while bind mode does not. Verify first that `-s` points at a STUN server that works from your network and `-h` at an HTTP server, that your firewall allows the bind port, and on Windows that your application binds to the local network IP in TCP mode and to `0.0.0.0` or `::` in UDP mode.

Frequently asked questions

What kind of NAT does natmap require?

A full cone NAT, that is NAT-1, at every layer between you and the internet. The README states that if all layers of NAT are full cones, any host can access internal services through the mapped public address. Behind a symmetric NAT there is no configuration that makes this work.

How do I create a port mapping with natmap?

Run `natmap -s <stun-server> -h <http-server> -b <port>` for TCP, or add `-u` for UDP. In TCP mode it opens an HTTP connection from the bind port and keeps it alive, then connects to the STUN server from the same port to learn the public address, then calls the script given by `-e` with the mapping details.

What is the difference between bind mode and forward mode in natmap?

In bind mode all traffic does not go through the program; your own service listens on the bind port and natmap only maintains the NAT mapping. In forward mode natmap listens on the bound port and forwards connections to the target given by `-t` and `-p`, so the program sits in your data path.

What does IP4P encode in natmap?

It uses IPv6 special addresses to encode an IPv4 address and port for distribution through DNS AAAA records, in the format `2001::{port}:{ipv4-hi16}:{ipv4-lo16}`. The value is passed to the notify script as its third argument.

How do I install natmap on OpenWrt and Windows?

On OpenWrt 22.03 or newer, run `opkg install natmap luci-app-natmap`. On Windows, add the Scoop bucket with `scoop bucket add main` and install with `scoop install main/natmap`, or build under MSYS2 after exporting `MSYS=winsymlinks:native`.

How should my server bind on Windows when using natmap?

In TCP mode bind the application to the local network IP, not to `0.0.0.0` or `::`. In UDP mode the opposite applies: bind to `0.0.0.0` or `::`, not to the local network IP.

Official sources

  1. heiher/natmap on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/heiher-natmap.svg)](https://hysenlabs.com/projects/heiher-natmap)

Was this useful?