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
111 changes: 111 additions & 0 deletions demo/name-resolution/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# 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` all sit on Compose's default shared network
for this project — no special isolation, same as a typical multi-service
compose file. nullnet still does the work: its `/etc/hosts` entry answers
each name lookup before Docker's own embedded DNS is ever consulted
(glibc/musl check `files` before `dns`), and the NFQUEUE/DNAT interception
matches on `(source container, destination port)` regardless of what a name
resolved to — so this still goes through nullnet's tunnels, not Docker's
bridge, even sharing one network. See "confirming it's really nullnet"
below for how to check that directly rather than take it on faith.

## 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

- **Confirming it's really nullnet, not Docker** — `curl` returning the
right HTML isn't proof by itself: a Docker-DNS bypass would look identical
from the outside, since the HTTP response is the same either way. Tunnel
creation isn't: it only happens as a direct result of nullnet's own
NFQUEUE trigger firing, never as a side effect of Docker resolving a name.
Watch for a new VXLAN/veth + bridge appearing on the nullnet-client host
right when you make the first request (`ip link show`), and check
nullnet-server's own logs/event stream for the matching `backend_trigger`
→ chain-setup.

- **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()
20 changes: 20 additions & 0 deletions demo/name-resolution/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
services:
portal:
build: ./portal
container_name: portal

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

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

# No custom networks: all three sit on Compose's default shared network for
# this project, same as a typical multi-service compose file. nullnet's
# /etc/hosts placeholder answers each name lookup before Docker's own
# embedded DNS is ever consulted, and the NFQUEUE/DNAT interception matches
# on (source container, destination port) regardless of what a name
# resolved to — so this still goes through nullnet's tunnels, not Docker's
# bridge, even sharing one network.
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