Aside0's AI Blog

AI-generated tinkering journal. No hand typing, guaranteed.
  1. Main page
  2. Network
  3. Main content

Home access without public IPv4: NATMap, ddns-go, nginx, and acme.sh

2026年6月18日 16hotness 0likes 0comments
Date: 2026-06-18

Public version note: this post keeps the architecture, access path, and main operational decisions. Real domains, internal addresses, tokens, KV names, and service ports have been replaced with example values.

This is a lightweight home access setup for residential broadband behind ISP NAT. The home router does not have a public IPv4 address. The setup does not rely on public IPv6, DMZ, or a public server. The home side keeps one reachable entry path. When the external port changes, a fixed redirect entry hides that change from the user.

The home gateway runs four main pieces:

```text
natmap: keeps a NAT mapping and reports the current external port
ddns-go: points service domains to the current external IP
nginx: accepts HTTPS and reverse proxies to LAN services
acme.sh: issues and renews certificates with Cloudflare DNS-01
```

Cloudflare Worker and KV are only a helper layer. They provide a fixed 302 redirect entry, so the user does not have to remember the current NATMap port.

The problem is practical:

```text
the home router has no public IPv4 address
natmap can produce an external IP:port, but the port can change
ddns-go can track IP changes, but not URL port changes
nginx can consolidate LAN services, but the outside client still needs the mapped port
Worker returns a 302 from a fixed entry to the current service domain and port
```

The outside user experience becomes:

```text
open https://go.example.com/nas
receive a 302 to https://nas.example.com:<current mapped port>/
nginx receives the request and routes it by Host
```

## Related posts

- [Raspberry Pi Tailscale exit node and Xray transparent proxy setup](https://aside0.me/en/raspberry-pi-tailscale-xray-2026-06-15-en/)
- [Raspberry Pi Wi-Fi hotspot through Xray: DNS protection and transparent proxying](https://aside0.me/en/raspberry-pi-wifi-hotspot-xray-dns-2026-06-19-en/)
- [Route Cloudflare Tunnel through Xray: cloudflared, nftables, and sniffing](https://aside0.me/en/cloudflared-tunnel-xray-sniff-nft-2026-06-17-en/)

## Scope

This is not a VPN, not Cloudflare Tunnel, and not a way to turn a home line into a stable public hosting platform.

It fits these cases:

- You mainly need temporary access to NAS, Home Assistant, downloaders, or internal web tools.
- The home router has no public IPv4 address and sits behind ISP NAT.
- You do not want to rely on IPv6.
- You do not want to use DMZ.
- The NAT behavior between home and the ISP works for NATMap, usually close to Full Cone or NAT-1.
- You can accept occasional external port changes.
- You want one fixed entry URL instead of manually checking NATMap logs.

It does not fit these cases:

- You need to provide a highly stable public service.
- Many people depend on the entry and cannot tolerate short entry changes.
- The ISP NAT behavior does not allow outside connections back in.
- The home path has strict multi-layer NAT and the mapped address is not reachable.
- The target service has no login protection but would still be exposed to the internet.

The requirement is not "I own a public IP". The requirement is that NATMap can maintain an externally reachable NAT mapping. Outside clients still connect to a public `IP:port`, but that entry is discovered and updated rather than statically assigned.

## Devices and accounts

There is no public server in this design. The home network does need one always-on entry device. This article uses the following example layout:

| Device or account | Example | Role |
| --- | --- | --- |
| Outside client | Phone, laptop, tablet | Opens a fixed URL such as `https://go.example.com/nas` |
| DNS and Worker platform | Cloudflare account | Hosts DNS, Worker, KV, and DNS APIs for ddns-go and acme.sh |
| Upstream home router | Modem, main router, ISP gateway | Connects to the ISP; no DMZ or public IPv6 required |
| Kwrt / OpenWrt device | Soft router, mini PC, side router | Runs NATMap, nginx, ddns-go, and acme.sh |
| LAN services | NAS, Home Assistant, code-server | Actual services, not directly exposed |

Example addresses:

```text
Upstream router LAN: 192.168.10.1
Kwrt address: 192.168.10.2
NAS address: 192.168.10.10
Home Assistant address: 192.168.10.20
code-server address: 192.168.10.30
```

Example domains:

```text
Fixed entry domain: go.example.com
Service domain suffix: example.com
NAS domain: nas.example.com
Home Assistant domain: ha.example.com
code-server domain: code.example.com
```

Cloudflare needs:

```text
Zone: example.com
Worker custom domain or route: go.example.com
KV namespace: nat_port
Worker KV binding name: REDIRECT_CONFIG
DNS API token for ddns-go and acme.sh, limited to this zone
```

Start with a domain you control and host its DNS on Cloudflare. `example.com` is only a placeholder. The roles are:

```text
example.com: the Cloudflare DNS zone
go.example.com: fixed Worker entry, only returns 302 redirects
nas.example.com / ha.example.com / code.example.com: real service domains updated by ddns-go
```

This article deliberately avoids service domains such as `nas.home.example.com`. Cloudflare Universal SSL on the free plan normally covers `example.com` and `*.example.com`, but not `*.home.example.com`. DNS only plus acme.sh on the origin can still work, but readers often mix up Cloudflare edge certificates and origin certificates. One-level service domains avoid that confusion.

Do not mix up `go.example.com` and `nas.example.com`. The first one is the fixed entry. The second one is the real target. The user remembers `go.example.com/nas`; the browser eventually visits `nas.example.com:<current NATMap port>`.

`go.example.com` can be attached to a Cloudflare Worker. The final service domains such as `nas.example.com`, `ha.example.com`, and `code.example.com` should stay DNS only. The final URL contains NATMap's dynamic port, and the browser must connect directly to the home external `IP:port`. Cloudflare proxying is not the data path here.

If `go.example.com` uses a Worker Custom Domain, Cloudflare attaches that hostname directly to the Worker and handles the edge certificate. If you use a Worker Route, make sure the DNS record for `go.example.com` is proxied and matches the route. The examples below assume Custom Domain because it is easier to reason about.

## Home topology

The whole Kwrt device is not exposed. NATMap maintains one entry mapping. nginx keeps listening on local `443`. NATMap binds a stable inside port such as `8443`, then either forwards traffic to nginx `443` itself or relies on firewall rules for the inside forwarding path. nginx then routes requests to LAN services.

```mermaid
flowchart LR
  isp["ISP NAT network<br/>no public IPv4 at home"]
  router["Modem / main router<br/>normal NAT"]
  kwrt["Kwrt / OpenWrt<br/>192.168.10.2"]
  natmap["NATMap<br/>binds 8443"]
  nginx["nginx<br/>listens on 443"]
  nas["NAS<br/>192.168.10.10:5000"]
  ha["Home Assistant<br/>192.168.10.20:8123"]
  code["code-server<br/>192.168.10.30:8080"]

  isp --> router
  router --> kwrt
  kwrt --> natmap
  natmap --> nginx
  nginx --> nas
  nginx --> ha
  nginx --> code
```

Cloudflare only participates in fixed entry redirects and DNS:

```mermaid
flowchart LR
  user["Outside client"]
  worker["go.example.com<br/>Cloudflare Worker"]
  dns["Cloudflare DNS<br/>nas.example.com"]
  nat["ISP NAT mapping<br/>external IP:dynamic port"]
  natmap["Kwrt NATMap<br/>binds 8443"]
  nginx["Kwrt nginx<br/>local 443"]
  service["LAN service"]

  user -->|opens fixed entry| worker
  worker -->|302 to nas.example.com:dynamic port| user
  user -->|resolves domain| dns
  user -->|connects to external IP:port| nat
  nat --> natmap
  natmap --> nginx
  nginx --> service
```

There are two kinds of domains:

```text
go.example.com: fixed entry, served by Cloudflare Worker, returns 302
nas.example.com / ha.example.com / code.example.com: real service domains, DNS only, direct to home
```

If Kwrt sits behind another router, the upstream device still does not need DMZ. Give Kwrt a fixed LAN address and add only the forwarding or allow rules required by this one entry path.

```text
Kwrt as the main router:
ISP NAT mapping -> Kwrt NATMap 8443 -> nginx 443

Kwrt behind an upstream router:
ISP NAT mapping -> upstream router port forward -> Kwrt NATMap 8443 -> nginx 443
```

Whether the entry can be reached from outside must be verified from an outside network. NATMap logs alone are not enough.

## Software roles

Each tool owns one part of the chain. That makes failures easier to isolate.

| Tool or service | Main function | How it is used here | Problem it solves |
| --- | --- | --- | --- |
| Kwrt / OpenWrt | Always-on runtime | Runs nginx, NATMap, ddns-go, and acme.sh | A stable home entry device |
| nginx | HTTPS entry and reverse proxy | Listens on `443`, routes by `server_name` | LAN services should not all be exposed separately |
| acme.sh | Certificate issue and renewal | Uses Cloudflare DNS-01, installs certs into nginx | Valid HTTPS without stable public 80/443 |
| ddns-go | Dynamic external IP updates | Updates service domains in Cloudflare DNS | Residential IP changes |
| NATMap | NAT mapping and port discovery | Binds `8443`, discovers external `IP:port`, triggers callback | No fixed external port |
| Cloudflare Worker | Fixed entry and 302 redirect | `https://go.example.com/nas` redirects to current `nas.example.com:port` | Users should not memorize changing ports |
| Cloudflare KV | Stores the current port | `/update-port` writes the port, GET reads it | Worker is stateless |
| Cloudflare DNS | DNS and DNS-01 validation | Hosts service records and ACME TXT records | DNS, cert validation, and fixed entry support |

The DDNS tool in this article is `ddns-go`. It supports Cloudflare directly and gives you a web UI for domains, tokens, intervals, and logs.

## How the pieces fit

Configuration path:

```text
acme.sh issues certificates through DNS-01
  -> certificates are installed into nginx
  -> nginx routes services by domain
  -> ddns-go points those service domains to the current external IP
  -> NATMap maps local entry port 8443 to an external dynamic port
  -> NATMap callback writes the current port into Worker KV
```

Outside access path:

```text
outside client opens go.example.com/nas
  -> Worker reads the current port from KV
  -> Worker returns 302 to nas.example.com:<current port>
  -> browser resolves nas.example.com to the DDNS-updated external IP
  -> browser connects to external IP:<current port>
  -> NAT mapping sends the connection to Kwrt entry port
  -> entry port forwards to nginx 443
  -> nginx routes by Host to the NAS
```

Different components solve different problems:

```text
ddns-go answers "which IP should I connect to?"
Worker/KV answers "which port should I connect to?"
nginx answers "which LAN service does this host name mean?"
acme.sh answers "is the HTTPS certificate valid?"
NATMap answers "can an outside connection reach this local entry port?"
```

Cloudflare Worker does not carry the LAN traffic. It returns one 302 response. After that, the browser connects directly to `nas.example.com:<dynamic port>`, then the NAT mapping sends the connection back to Kwrt and into nginx.

## Daily use

Normally you do not check the NATMap port. You open fixed entries:

```text
https://go.example.com/nas
https://go.example.com/ha
https://go.example.com/code
```

The Worker redirects them to the current dynamic port:

```text
https://nas.example.com:<current port>/
https://ha.example.com:<current port>/
https://code.example.com:<current port>/
```

When NATMap remaps and the port changes, the callback updates KV. The next fixed-entry request redirects to the new port.

To add another web service:

1. Add an nginx `server_name` and `proxy_pass`.
2. Confirm the wildcard certificate covers the new name.
3. Add the new service domain to ddns-go or Cloudflare DNS, such as `photo.example.com`.
4. Use a fixed entry such as `https://go.example.com/photo`.

As long as all web services share the same nginx `443` entry, you do not need one NATMap mapping per service.

## Example environment

This article uses Kwrt or a similar OpenWrt device. The name does not matter. The device must reliably run NATMap, nginx, acme.sh, and ddns-go.

```text
Entry device: Kwrt
LAN address: 192.168.10.2
nginx local listener: 443
NATMap bind port: 8443
Local forward target: 127.0.0.1:443; if nginx is on another LAN machine, use that LAN IP
Service domain suffix: example.com
Fixed redirect entry: go.example.com
Cloudflare Worker KV binding name: REDIRECT_CONFIG
```

Required on Kwrt:

```text
nginx: listens on 443 and reverse proxies HTTPS
acme.sh: issues and renews certificates
NATMap: maintains the external dynamic port mapping
ddns-go: maintains Cloudflare DNS A records
curl: lets the NATMap callback update the Worker port
```

LAN service examples:

```text
nas.example.com  -> 192.168.10.10:5000
ha.example.com   -> 192.168.10.20:8123
code.example.com -> 192.168.10.30:8080
```

Put all web services behind nginx. NATMap then maintains only one local entry port and one external mapped port.

## 1. nginx as the HTTPS entry

nginx does not do NAT traversal. It turns several LAN services into normal HTTPS virtual hosts.

Example:

```nginx
server {
    listen 443 ssl http2;
    server_name nas.example.com;

    ssl_certificate     /etc/nginx/certs/example.com/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/example.com/privkey.pem;

    location / {
        proxy_pass http://192.168.10.10:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
    }
}
```

For WebSocket services, also add:

```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```

Multiple services can use separate `server` blocks:

```nginx
server_name nas.example.com;
server_name ha.example.com;
server_name code.example.com;
```

Even though the outside URL includes a dynamic port, TLS SNI and HTTP Host still use names like `nas.example.com`. nginx can still pick the right certificate and upstream.

## 2. acme.sh with Cloudflare DNS validation

Do not rely on HTTP-01 here. HTTP-01 needs a stable public port 80 entry. This setup has a dynamic NATMap port and no stable public 80 entry. DNS-01 is a better fit.

Use the Cloudflare DNS plugin for `acme.sh`. The token needs DNS edit access for this zone. In practice, it usually also needs zone read access so the tool can locate the zone. Do not give it account-wide access to every zone.

Example environment variables:

```sh
export CF_Token="<cloudflare-dns-api-token>"
export CF_Account_ID="<cloudflare-account-id>"
```

Issue a wildcard certificate:

```sh
acme.sh --issue --dns dns_cf \
  -d example.com \
  -d '*.example.com'
```

Install it for nginx:

```sh
acme.sh --install-cert -d example.com \
  --key-file       /etc/nginx/certs/example.com/privkey.pem \
  --fullchain-file /etc/nginx/certs/example.com/fullchain.pem \
  --reloadcmd      "nginx -s reload"
```

The wildcard certificate covers names like `photo.example.com` and `download.example.com`, so new services do not need a new certificate flow.

Keep the Cloudflare DNS API token out of public scripts and nginx config. It should only live in tools that need DNS access, such as acme.sh and ddns-go.

## 3. ddns-go for the dynamic IP

DDNS only updates the IP. In this setup, `ddns-go` updates the real service domains in Cloudflare DNS.

Recommended DNS shape:

```text
go.example.com          -> Worker custom domain or Worker route
nas.example.com         -> A record, DNS only, current external IP
ha.example.com          -> A record, DNS only, current external IP
code.example.com        -> A record, DNS only, current external IP
```

`go.example.com` is the fixed Worker entry. The real service domains should stay DNS only. If they are proxied, the browser path to `https://nas.example.com:<dynamic port>/` becomes a different Cloudflare proxy model and may not work with arbitrary dynamic ports.

ddns-go can run directly on Kwrt:

```text
Option 1: install luci-app-ddns-go and configure it from OpenWrt LuCI
Option 2: install the ddns-go binary as a service and configure it from the web UI
```

Binary install shape:

```sh
./ddns-go -s install
```

Then open:

```text
http://<kwrt-ip>:9876
```

Example ddns-go web UI settings:

```text
DNS provider: Cloudflare
Token: <cloudflare-dns-api-token>
IPv4: enabled
IPv4 source: public interface, NIC, command, or another supported method
IPv4 domains: nas.example.com,ha.example.com,code.example.com
TTL: automatic or a short value supported by Cloudflare
Interval: default 5 minutes is fine, shorter if needed
```

Do not expose the ddns-go management page through NATMap or nginx. Port `9876` is for operations, not outside access.

The stricter source of the outside IP is the mapped address reported by NATMap, because that is what outside clients will reach. If NATMap and ddns-go report the same IPv4 address, normal ddns-go detection is fine. Verify from outside that `nas.example.com` resolves to the external address used by the NATMap mapping.

This article recommends listing service domains explicitly:

```text
nas.example.com
ha.example.com
code.example.com
```

You can use a DNS only wildcard A record such as `*.example.com` if you are sure it will not conflict with other subdomains. It is not the default here.

After ddns-go updates DNS, `nas.example.com` resolves to the right external IP. The port is still dynamic. NATMap callback plus Worker/KV handles that part.

## 4. NATMap for the dynamic port

NATMap maintains a NAT mapping and reports the external `public IP:public port`. It is not a reverse proxy and not a certificate tool.

The question it answers is:

```text
Which external IP:port should an outside client connect to right now?
```

There are three ports to keep separate:

```text
Public dynamic port: the port NATMap reports, such as 32451
NATMap bind port: the stable local entry port, such as 8443
nginx service port: the HTTPS port, such as 443
```

The browser finally opens the public dynamic port:

```text
https://nas.example.com:32451/
```

The inside rules handle the stable entry:

```text
natmap bind: 8443
nginx listen: 443
local forwarding: 8443 -> 443
```

Worker/KV stores `32451`. The inside forwarding rule handles `8443 -> 443`. Those are not the same port.

### NATMap modes

The simplest shape is NATMap forward mode. NATMap listens on the bind port and forwards traffic to nginx:

```text
external dynamic port -> NATMap 8443 -> nginx 127.0.0.1:443
```

CLI shape:

```sh
natmap -4 -s turn.cloudflare.com -h example.com \
  -b 8443 \
  -t 127.0.0.1 \
  -p 443 \
  -e /etc/natmap/update-worker-port.sh
```

Parameter meaning:

```text
-4: IPv4 only
-s turn.cloudflare.com: STUN server
-h example.com: external HTTP host used by NATMap for probing and keepalive, not your nginx target domain
-b 8443: local bind port
-t 127.0.0.1: forward target, here local nginx
-p 443: target port, here nginx HTTPS
-e script: callback script after mapping is created or changed
```

`-h example.com` is a placeholder. Use a stable reachable external HTTP host. It is only for NATMap probing and does not participate in NAS access.

If nginx is on another LAN machine:

```sh
natmap -4 -s turn.cloudflare.com -h example.com \
  -b 8443 \
  -t 192.168.10.50 \
  -p 443 \
  -e /etc/natmap/update-worker-port.sh
```

The second shape is NATMap bind mode plus firewall DNAT, mainly when nginx is on another LAN host. NATMap maintains the mapping but does not forward business traffic:

```text
external dynamic port -> Kwrt firewall 8443 -> nginx 192.168.10.50:443
```

NATMap only binds the port:

```sh
natmap -4 -s turn.cloudflare.com -h example.com \
  -b 8443 \
  -e /etc/natmap/update-worker-port.sh
```

Then configure the port forward:

```text
Protocol: TCP
External port: 8443
Internal IP: 192.168.10.50
Internal port: 443
```

OpenWrt/Kwrt LuCI shape:

```text
Network -> Firewall -> Port Forwards
Source zone: wan
External port: 8443
Destination zone: lan
Internal IP address: 192.168.10.50
Internal port: 443
Protocol: TCP
```

If nginx runs on Kwrt itself, the normal Port Forwards page is usually not the best fit because it is mainly for forwarding to LAN hosts. Prefer NATMap forward mode, where NATMap forwards `8443` to `127.0.0.1:443`. A local redirect/input firewall rule can also work, but treat it as local redirection, not LAN forwarding.

### Working with an upstream router

If there is a modem or main router in front of Kwrt, the cleanest option is to run NATMap on the outermost OpenWrt/Kwrt device. NATMap bind port, firewall rules, and nginx entry then live in one place.

If NATMap runs on Kwrt behind another router, the upstream router must send entry traffic to Kwrt. DMZ is not needed. Use one explicit forward:

```text
upstream router WAN 8443 -> Kwrt 192.168.10.2:8443
Kwrt NATMap forward mode 8443 -> nginx 443
```

Avoid the default idea of forwarding upstream WAN `8443` directly to Kwrt `443` while NATMap only binds `8443`. That splits the NATMap bind port from the business entry port and is more sensitive to router and firewall behavior.

Preferred split:

```text
upstream router: forwards 8443 to Kwrt
NATMap: discovers the public dynamic port and accepts 8443
nginx: handles HTTPS and reverse proxying
Worker: redirects the fixed entry to the public dynamic port
```

The upstream router rule uses inside port `8443`. It does not use the public dynamic port reported by NATMap. The public dynamic port is stored in Worker/KV for outside clients.

### Callback to Worker/KV

NATMap calls a script after the mapping is created or changed. The script receives public address and public port. Only the public port needs to be written to Worker/KV.

Example:

```sh
#!/bin/sh
set -eu

PUBLIC_ADDR="$1"
PUBLIC_PORT="$2"
PRIVATE_PORT="$4"
PROTO="$5"

curl -fsS -X POST https://go.example.com/update-port \
  -H 'Authorization: Bearer <update-token>' \
  -H 'Content-Type: application/json' \
  -d "{\"port\":\"${PUBLIC_PORT}\",\"key\":\"home\"}"
```

`PUBLIC_PORT` is the port used by outside clients. `PRIVATE_PORT` is usually NATMap's bind port, such as `8443`.

Some OpenWrt packages or wrappers expose a different variable name:

```sh
curl -X POST https://go.example.com/update-port \
    -H 'Authorization: Bearer <update-token>' \
    -H 'Content-Type: application/json' \
    -d '{"port": "%MAPPED_PORT%", "key": "home"}'
```

The rule is the same: write NATMap's current public dynamic port into Worker/KV.

### NATMap validation

Do not start by testing the fixed entry. Split the chain:

```text
NATMap logs show public address and public port
Worker KV contains that public port
upstream router or Kwrt forwarding rules exist
outside curl https://nas.example.com:<public port>/ reaches nginx
nginx access.log sees the request
```

Only then test:

```text
https://go.example.com/nas
```

If `nas.example.com:<public port>` works but the fixed entry fails, check Worker/KV and the 302. If the direct URL fails too, check NATMap, upstream forwarding, firewall, and nginx.

## 5. Gist, Worker, and KV

The Gist is not the running service. It is a Worker template plus a sample `curl` command. The running component is the Worker you deploy on Cloudflare.

It solves this:

```text
NATMap's public port can change
ddns-go can update IP but not URL ports
users should not read NATMap logs each time
Worker reads the current port from KV and returns 302
```

Access shape:

```text
https://go.example.com/nas
  -> 302 https://nas.example.com:<current port>/

https://go.example.com/ha/lovelace
  -> 302 https://ha.example.com:<current port>/lovelace
```

The first path segment becomes the target subdomain:

```text
/nas              -> nas.example.com
/ha/lovelace      -> ha.example.com/lovelace
/code/?folder=x   -> code.example.com/?folder=x
```

### Gist contents

Gist URL:

```text
https://gist.github.com/leconio/76ba824a97fb005f2bc3924d6e6dacae
```

Files:

```text
worker.js: Cloudflare Worker logic
post.sh: sample manual port update
README.md: original notes
```

`worker.js` does two things:

```text
POST /update-port: checks Bearer Token and writes the new port into KV
GET /<service>: reads KV and returns 302 to <service>.<TARGET_DOMAIN>:<port>
```

### Prepare Worker and KV

Cloudflare side:

```text
Worker: runs worker.js
KV namespace: stores current public dynamic port, such as nat_port
Custom Domain or route: sends go.example.com to this Worker
```

Console flow:

```text
1. Create a Worker in Workers & Pages.
2. Paste in worker.js from the Gist.
3. Set SECRET_TOKEN, WEB_DOMAIN, and TARGET_DOMAIN.
4. Create a Workers KV namespace, such as nat_port.
5. Add a KV binding in Worker Settings / Bindings.
6. Binding name: REDIRECT_CONFIG. Namespace: nat_port.
7. Add go.example.com under Domains & Routes.
8. Deploy the Worker.
```

If you use Custom Domain, `go.example.com` should not point to the home DDNS record. It is the Worker entry, not a real service domain.

The binding name must match the code:

```text
REDIRECT_CONFIG
```

The Gist code calls:

```js
await REDIRECT_CONFIG.put(newKey ?? 'nat1', newPort.toString());
await REDIRECT_CONFIG.get(keyValue);
```

If the binding is named differently, the Worker cannot access KV unless you also edit the code.

### Worker constants

Set at least:

```js
const SECRET_TOKEN = "<update-token>";
const WEB_DOMAIN = "go.example.com";
const TARGET_DOMAIN = "example.com";
```

Meaning:

```text
SECRET_TOKEN: Bearer Token used by the NATMap update request
WEB_DOMAIN: the Worker hostname
TARGET_DOMAIN: service domain suffix. /nas becomes nas.example.com
```

`SECRET_TOKEN` is not a Cloudflare API token. It is only the value this Worker checks before accepting port updates.

### KV contents

KV stores only the port:

```text
key: home
value: 32451
```

With one NATMap entry, the key can be `home` or the Gist default `nat1`. With multiple entries:

```text
home -> 32451
office -> 41023
backup -> 28001
```

Use a query parameter to select a key:

```text
https://go.example.com/nas?key=home
```

If `key` is omitted, the Gist defaults to `nat1`. Either change the default to `home`, or make the callback write `nat1`.

### Manual port test

Before connecting NATMap callback, post a test port:

```http
POST https://go.example.com/update-port
Authorization: Bearer <update-token>
Content-Type: application/json

{
  "port": "32451",
  "key": "home"
}
```

With curl:

```sh
curl -X POST https://go.example.com/update-port \
  -H 'Authorization: Bearer <update-token>' \
  -H 'Content-Type: application/json' \
  -d '{"port":"32451","key":"home"}'
```

Then test the 302:

```sh
curl -I 'https://go.example.com/nas?key=home'
```

Expected:

```http
HTTP/1.1 302 Found
Location: https://nas.example.com:32451/
Cache-Control: no-store, max-age=0
```

The original Gist template works for redirecting, but it does not validate whether the port is empty, numeric, or in the `1-65535` range. Before public use, add a port check. If KV has no port yet, return 404 or 503 instead of building a `:null` URL.

### Connect NATMap callback

After manual testing, connect the earlier callback script through `natmap -e` or the plugin notification field. Check:

```text
POST URL is https://go.example.com/update-port
Bearer Token matches SECRET_TOKEN
submitted port is NATMap public dynamic port, not 8443
```

The `key` must match your manual test. The examples use `home`:

```text
https://go.example.com/nas?key=home
```

If you keep the Gist default `nat1`, write `nat1` from the callback or omit the query key when visiting.

### This is not access control

The Worker is only a fixed entry and redirect layer. It does not hide the final target and does not protect nginx or NAS. After the 302, the browser opens:

```text
https://nas.example.com:<current port>/
```

Security still belongs to the target service, nginx authentication, access rules, and whether you share the fixed entry.

Keep `Cache-Control: no-store`. The redirect contains a dynamic port, and caching an old port makes debugging confusing.

## Why not put the port into DDNS

Normal DNS A and AAAA records do not express HTTPS ports.

You can update:

```text
nas.example.com -> current external IP
```

But not:

```text
nas.example.com -> current external IP:current port
```

Browsers use port 443 for HTTPS unless the URL explicitly includes a port. So the design splits the problem:

```text
DDNS solves IP
Worker/KV solves port
```

That is the reason for the fixed redirect entry.

## Why nginx sits behind NATMap

Running one NATMap entry per service gets messy:

```text
NAS has one dynamic port
Home Assistant has another
code-server has another
downloader has another
```

With nginx in the middle:

```text
external dynamic port -> NATMap 8443 -> nginx 443
nginx routes by domain to different LAN services
```

Adding a web service usually means:

1. Add an nginx `server_name`.
2. Add a LAN `proxy_pass`.
3. Confirm the wildcard cert covers the name.
4. Access it through the fixed entry path.

NATMap and Worker do not need a new setup for every service.

## Validation

Validate from outside. Turn off Wi-Fi on a phone and use mobile data.

Check Worker 302:

```sh
curl -I https://go.example.com/nas
```

Expected:

```text
HTTP/2 302
location: https://nas.example.com:<current port>/
cache-control: no-store, max-age=0
```

Check DNS:

```sh
dig +short nas.example.com
```

Open the redirected URL directly:

```sh
curl -I https://nas.example.com:<current port>/
```

Watch nginx logs:

```sh
tail -f /var/log/nginx/access.log
tail -f /var/log/nginx/error.log
```

Then check the target service logs to confirm the request reached the LAN service, not just nginx.

## Things that broke along the way

### 1. DDNS alone is not enough

DDNS only points the name to the current external IP. NATMap's external port is still separate. Without a redirect entry, users must manually remember the port.

### 2. LAN success does not prove outside success

Opening `nas.example.com` from home Wi-Fi may use internal DNS, NAT loopback, or router shortcuts. Validate from a real outside network.

### 3. HTTP-01 is the wrong certificate path here

HTTP-01 expects a stable public port 80 entry. This setup does not have one. Use DNS-01.

### 4. Worker is a redirect, not a proxy

After the 302, traffic goes directly to `nas.example.com:<port>`. It does not pass through the Worker.

### 5. nginx Host and WebSocket headers matter

Many web apps depend on Host, scheme, and WebSocket upgrade headers. A minimal reverse proxy config can cause login loops, wrong redirects, or broken realtime connections.

### 6. Do not expose unauthenticated services

NATMap creates an externally reachable entry. Anyone who knows the domain and port can try it. A fixed entry that you do not publish is still not access control.

Management services need their own login protection. Sensitive services should add Basic Auth, allow lists, or another authentication layer.

## Operation order

Start with the LAN path:

```text
LAN client -> nginx -> target service
```

Then certificates:

```text
acme.sh DNS-01 wildcard certificate -> nginx HTTPS loads correctly
```

Then ddns-go:

```text
nas.example.com / ha.example.com / code.example.com point to current external IP
```

Then NATMap:

```text
local entry 8443 maps to an external dynamic port and forwards to nginx 443
```

Finally Worker:

```text
NATMap callback updates KV
go.example.com/<service> redirects to <service>.example.com:<dynamic port>
```

This order keeps failures isolated.

## Rollback

If NATMap or Worker redirect becomes unstable, roll back to internal nginx first. Do not touch the certificates or LAN services.

Rollback shape:

```text
stop NATMap
keep nginx and acme.sh
keep ddns-go DNS records
make the Worker fixed entry return maintenance or 404
LAN services remain available only at home
```

If only port updates fail:

```text
manually POST the current port to /update-port
confirm KV updated
curl -I the fixed entry
confirm Location contains the new port
```

The layers are separate enough to test and roll back independently.

## References

- NATMap upstream project: `https://github.com/heiher/natmap`
- ddns-go upstream project: `https://github.com/jeessy2/ddns-go`
- acme.sh Cloudflare DNS plugin: `https://github.com/acmesh-official/acme.sh/blob/master/dnsapi/dns_cf.sh`
- Worker/KV 302 template used here: `https://gist.github.com/leconio/76ba824a97fb005f2bc3924d6e6dacae`
- Cloudflare Universal SSL: `https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/`
- Cloudflare Universal SSL limitations: `https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/limitations/`
- Cloudflare Workers Custom Domains: `https://developers.cloudflare.com/workers/configuration/routing/custom-domains/`
- Cloudflare Workers bindings: `https://developers.cloudflare.com/workers/runtime-apis/bindings/`
Tag: Nothing
Last updated:2026年6月20日

This person is a lazy dog and has left nothing

Like
< Last article
Next article >

Comments

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
Cancel

Archives

  • June 2026

Categories

  • Network
  • Server

COPYRIGHT © 2026 ASIDE0. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang

中文EN