Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions demo/name-resolution/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Demo: backend-trigger dependencies by their real container name

Three tiny HTTP servers exercising the feature end to end: `portal` calls
`dep-a` and `dep-b` by their literal Docker container names — no
`*.nullnet.com` alias anywhere — and combines both responses into one HTML
page. `dep-a` and `dep-b` deliberately share the same real port (80), so
this also exercises nullnet's same-port disambiguation: two trigger chains
on one port, told apart by `chain[0]` (the dependency's name) rather than
each hiding behind its own port.

```
curl -H "Host: portal" http://<proxy-host>/ ──► nullnet-proxy ──► portal
```

Once inside, portal always does the same thing:
```
portal ──► http://dep-a/ (bare name, port 80)
└─► http://dep-b/ (bare name, same port 80)
combines both replies into HTML, returns it
```

`portal`, `dep-a`, and `dep-b` are each on their own isolated Docker network
(see `docker-compose.yml`) — deliberately, so there is no path between them
except the one nullnet builds on demand. If they shared a network, Docker's
own embedded DNS would resolve the bare name directly and bypass nullnet.

## Setup

1. Copy `services.toml` into the server's services directory:
```
cp services.toml <repo>/members/nullnet-server/services/demo.toml
```
2. Start `nullnet-server`, `nullnet-client`, and `nullnet-proxy` per the repo
root `README.md` (`./setup-server.sh`, `./setup-client.sh`,
`./setup-proxy.sh`). `portal` declares `timeout = 30` in `services.toml`,
which is what makes it proxy-reachable at all.
3. Build and start the demo stack:
```
cd demo/name-resolution
docker compose up -d --build
```

## Test

Through the proxy, routed by `Host` header — the normal way to reach any
proxy-reachable nullnet service:
```
curl -H "Host: portal" http://<proxy-host>/
```
`<proxy-host>` is wherever `nullnet-proxy` is listening.

The first request pays the backend-trigger round-trip for both dependencies
(tunnel setup, typically tens to a couple hundred ms); you should get back
combined HTML from both `dep-a` and `dep-b`. Subsequent requests are fast —
both chains are already up.

## Things worth inspecting

- **Before the first request** — each name already has its own distinct
placeholder seeded, before any packet exists:
```
docker exec portal getent hosts dep-a
docker exec portal getent hosts dep-b
```
Both resolve to *different* `203.0.113.x` addresses (RFC 5737 — the
placeholder range) — that's the disambiguator, since both names share the
same real port.

- **After the first request** — both placeholders have been swapped for
their real (distinct) overlay addresses:
```
docker exec portal cat /etc/hosts
```

- **On the nullnet-client host** — two independent DNAT rules on the *same*
port, distinguished by `-d`:
```
sudo iptables -t nat -L NULLNET_DNAT -n
```
You should see two rules for port 80 from `portal`'s container IP, each
with a different `-d <placeholder>` and a different DNAT target.

- **Idle teardown and re-trigger** — after the configured idle timeout tears
a tunnel down, `docker exec portal getent hosts dep-a` (or `dep-b`) should
show its placeholder again (re-seeded on teardown, not deleted), and a
fresh `curl -H "Host: portal" http://<proxy-host>/` should re-trigger just
that dependency and succeed again rather than dead-ending.

- **Container restart** — `docker compose restart dep-a` wipes its
container's own `/etc/hosts`; `portal`'s entries for `dep-a`/`dep-b`
survive because they live in *portal's* container, but restarting `portal`
itself wipes them there — the next declare-services reconcile pass (≤10s,
or immediately on the `docker events` fast-path) reseeds both.

- **Admin UI** — the *Services* page's Config panel now lists both chains
under "Trigger :80" for `portal`, one row per chain.
3 changes: 3 additions & 0 deletions demo/name-resolution/dep-a/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM python:3.12-alpine
COPY app.py /app.py
CMD ["python3", "/app.py"]
19 changes: 19 additions & 0 deletions demo/name-resolution/dep-a/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from http.server import BaseHTTPRequestHandler, HTTPServer

BODY = b"Hello from dep-a (the alpha service)!"


class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)

def log_message(self, fmt, *args):
print(f"[dep-a] {self.address_string()} - {fmt % args}", flush=True)


if __name__ == "__main__":
HTTPServer(("0.0.0.0", 80), Handler).serve_forever()
3 changes: 3 additions & 0 deletions demo/name-resolution/dep-b/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM python:3.12-alpine
COPY app.py /app.py
CMD ["python3", "/app.py"]
19 changes: 19 additions & 0 deletions demo/name-resolution/dep-b/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from http.server import BaseHTTPRequestHandler, HTTPServer

BODY = b"Hello from dep-b (the beta service)!"


class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(BODY)))
self.end_headers()
self.wfile.write(BODY)

def log_message(self, fmt, *args):
print(f"[dep-b] {self.address_string()} - {fmt % args}", flush=True)


if __name__ == "__main__":
HTTPServer(("0.0.0.0", 80), Handler).serve_forever()
31 changes: 31 additions & 0 deletions demo/name-resolution/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
services:
portal:
build: ./portal
container_name: portal
networks:
- portal_net

dep-a:
build: ./dep-a
container_name: dep-a
networks:
- dep_a_net

dep-b:
build: ./dep-b
container_name: dep-b
networks:
- dep_b_net

# Each service gets its own isolated network — no two of these containers
# share a Docker network, so there is no path between them except the one
# nullnet builds on demand. This is deliberate: if portal and dep-a shared a
# network, Docker's own embedded DNS would resolve "dep-a" directly and
# bypass nullnet entirely (see the investigation memo's Blocker A).
networks:
portal_net:
driver: bridge
dep_a_net:
driver: bridge
dep_b_net:
driver: bridge
3 changes: 3 additions & 0 deletions demo/name-resolution/portal/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM python:3.12-alpine
COPY app.py /app.py
CMD ["python3", "/app.py"]
65 changes: 65 additions & 0 deletions demo/name-resolution/portal/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer

# Literal Docker container names — exactly what docker-compose.yml calls
# these two services, and exactly what services.toml declares as each
# trigger's chain[0]. No "*.nullnet.com" alias anywhere in this app.
# Both dependencies listen on the same real port (80) — this exercises
# nullnet's same-port disambiguation (two chains sharing a trigger port,
# told apart by chain[0]/target_name) rather than each getting a distinct
# port to hide behind.
DEP_A_URL = "http://dep-a/"
DEP_B_URL = "http://dep-b/"


def fetch(url: str) -> str:
with urllib.request.urlopen(url, timeout=5) as resp:
return resp.read().decode()


class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != "/":
self.send_response(404)
self.end_headers()
return

try:
a = fetch(DEP_A_URL)
b = fetch(DEP_B_URL)
except Exception as e: # noqa: BLE001 - demo endpoint, keep it simple
body = f"upstream call failed: {e}".encode()
self.send_response(502)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return

html = f"""<!doctype html>
<html>
<head><title>Portal</title></head>
<body>
<h1>Portal</h1>
<p>Combined response from two backend-trigger dependencies, reached by
their real Docker container name (no nullnet.com alias involved):</p>
<h2>dep-a says:</h2>
<p>{a}</p>
<h2>dep-b says:</h2>
<p>{b}</p>
</body>
</html>
"""
body = html.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def log_message(self, fmt, *args):
print(f"[portal] {self.address_string()} - {fmt % args}", flush=True)


if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
41 changes: 41 additions & 0 deletions demo/name-resolution/services.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Demo stack for testing backend-trigger dependencies resolved by their real
# Docker container name — no "*.nullnet.com" alias anywhere.
#
# Copy (or symlink) this file to members/nullnet-server/services/demo.toml —
# the filename minus .toml becomes the stack name.
#
# portal (the entry point) calls dep-a and dep-b by their literal container
# names, exactly as declared in docker-compose.yml. nullnet-client pre-seeds
# a placeholder /etc/hosts entry for each name so the bare name resolves and
# a real first packet reaches the NFQUEUE trigger; once the tunnel is up,
# DNAT redirects that traffic to the real dependency, and the placeholder is
# swapped for the real overlay IP.
#
# dep-a and dep-b deliberately share the same real port (80) — two chains on
# one trigger port, disambiguated at trigger time by chain[0] (target_name):
# the client gives each name its own deterministic placeholder address, so
# the destination the packet was actually sent to tells the two apart.

[[services]]
name = "portal"
docker_container = "portal"
port = 8000
timeout = 30

[[services.triggers]]
port = 80 # the real port portal dials to reach dep-a
chain = ["dep-a"]

[[services.triggers]]
port = 80 # same port, different dependency — told apart by chain[0]
chain = ["dep-b"]

[[services]] # backend-only: reached via portal's trigger chain, not the proxy
name = "dep-a"
docker_container = "dep-a"
port = 80

[[services]] # backend-only: reached via portal's trigger chain, not the proxy
name = "dep-b"
docker_container = "dep-b"
port = 80
47 changes: 38 additions & 9 deletions members/nullnet-client/src/commands/dnat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,38 @@ pub(crate) fn init() {

/// Install a DNAT for `port → overlay_ip:port`. When `container_ip` is a
/// real address, the rule is scoped to that source via `-s` so co-located
/// replicas hit independent chains. `Ipv4Addr::UNSPECIFIED` (0.0.0.0) means
/// "no source filter" — used by legacy callers that don't know the source.
/// replicas hit independent chains. When `dest_ip` is a real address, the
/// rule is additionally scoped to that destination via `-d` — so two
/// backend-trigger dependencies sharing a port from the same initiator (each
/// with its own placeholder address, see `placeholder.rs`) get independent
/// rules instead of one clobbering the other. `Ipv4Addr::UNSPECIFIED`
/// (0.0.0.0) means "no filter" for either — used by legacy callers that
/// don't know the source/destination.
/// Returns `false` if any of the per-proto `iptables` rules failed to apply.
pub(crate) fn install(port: u16, overlay_ip: Ipv4Addr, container_ip: Ipv4Addr) -> bool {
pub(crate) fn install(
port: u16,
overlay_ip: Ipv4Addr,
container_ip: Ipv4Addr,
dest_ip: Ipv4Addr,
) -> bool {
let mut ok = true;
for proto in PROTOS {
ok &= run_iptables("-A", proto, port, overlay_ip, container_ip);
ok &= run_iptables("-A", proto, port, overlay_ip, container_ip, dest_ip);
}
flush_conntrack(port);
ok
}

/// Returns `false` if any of the per-proto `iptables` rules failed to delete.
pub(crate) fn remove(port: u16, overlay_ip: Ipv4Addr, container_ip: Ipv4Addr) -> bool {
pub(crate) fn remove(
port: u16,
overlay_ip: Ipv4Addr,
container_ip: Ipv4Addr,
dest_ip: Ipv4Addr,
) -> bool {
let mut ok = true;
for proto in PROTOS {
ok &= run_iptables("-D", proto, port, overlay_ip, container_ip);
ok &= run_iptables("-D", proto, port, overlay_ip, container_ip, dest_ip);
}
flush_conntrack(port);
ok
Expand All @@ -57,14 +72,19 @@ fn run_iptables(
port: u16,
overlay_ip: Ipv4Addr,
container_ip: Ipv4Addr,
dest_ip: Ipv4Addr,
) -> bool {
let port_s = port.to_string();
let target = format!("{overlay_ip}:{port}");
let container_ip_s = container_ip.to_string();
let dest_ip_s = dest_ip.to_string();
let mut args: Vec<&str> = vec!["iptables", "-t", "nat", action, CHAIN, "-p", proto];
if !container_ip.is_unspecified() {
args.extend_from_slice(&["-s", &container_ip_s]);
}
if !dest_ip.is_unspecified() {
args.extend_from_slice(&["-d", &dest_ip_s]);
}
args.extend_from_slice(&[
"--dport",
&port_s,
Expand All @@ -79,19 +99,28 @@ fn run_iptables(
} else {
container_ip_s.clone()
};
let dst = if dest_ip.is_unspecified() {
"any".to_string()
} else {
dest_ip_s.clone()
};
match status {
Ok(s) if s.success() => {
println!("[dnat] iptables {action} {CHAIN} {proto}/{port} -s {src} -> {target}");
println!(
"[dnat] iptables {action} {CHAIN} {proto}/{port} -s {src} -d {dst} -> {target}"
);
true
}
Ok(s) => {
eprintln!(
"[dnat] iptables {action} {CHAIN} {proto}/{port} -s {src} -> {target} exited {s}"
"[dnat] iptables {action} {CHAIN} {proto}/{port} -s {src} -d {dst} -> {target} exited {s}"
);
false
}
Err(e) => {
eprintln!("[dnat] iptables {action} {CHAIN} {proto}/{port} -s {src} -> {target}: {e}");
eprintln!(
"[dnat] iptables {action} {CHAIN} {proto}/{port} -s {src} -d {dst} -> {target}: {e}"
);
false
}
}
Expand Down
Loading