- Python 99.8%
- Shell 0.2%
|
|
||
|---|---|---|
| .vscode | ||
| assets | ||
| docs | ||
| scripts | ||
| src/namebind | ||
| tests | ||
| .copier-answers.yml | ||
| .gitignore | ||
| CHANGELOG.md | ||
| LICENCE.md | ||
| poetry.lock | ||
| poetry.toml | ||
| pyproject.toml | ||
| README.md | ||
namebind
Run ordinary host programs -- not container images -- each inside its own
network namespace, and reach their services by name instead of by port
number. namebind binds a name, so you stop globally coordinating
8080/8087/5174/6130/... and remembering which is which.
$ namebind run --app web --service ui:5173 -- npm run dev
# ...now, from the host, a browser, or any other app:
$ curl http://ui.web.namebind.internal/ # portless: any port works
Two unrelated apps can both bind the literal 127.0.0.1:8080 with no collision
(each has a private port space); the ports you name become reachable from the
host and from other apps, each at its own stable IP and a human-readable
.namebind.internal name. To be reachable, name a port -- nothing resolves that
you did not declare.
Contents
- What it is, precisely
- Requirements
- Install
- Quick start
- The naming model
- IPv6 (dual-stack) and transports
- Commands
- Docker
- Configuration
- How it works
- Known limitations
- License
What it is, precisely
An app is a persistent, refcounted podman pod -- a shared network
namespace with a stable bridge IP. A run drops one host process into that pod's
namespace with nsenter, then setprivs it back to your own user. The only
thing that changes about your process is its network namespace:
The invariant.
namebind run -- cmdis identical to runningcmddirectly, except thatcmdnow lives in the app's private port space and is reachable at the app's bridge IP. Same user, same working directory, same environment (the caller's, captured before the privilege hop and applied tocmdat the caller's own privilege), same filesystem, same stdio. namebind is net-only by default -- it adds no mount, PID, or user namespace, no image, and no filesystem sandbox. The one deliberate opt-out is--own-resolv: it adds a mount namespace holding a single bind-mounted/etc/resolv.conf-- so a run's DNS survives a rewritten host file -- and nothing else, still no PID or user namespace and no filesystem sandbox.One residue is unavoidable: entering a rootful netns needs a capability holder, so
cmd's parent is systemd, not your shell. Lifetime and signals are preserved regardless: killing thenamebind runprocess -- any signal, evenSIGKILL-- endscmdexactly as it would an unwrapped child (in a terminal the tty's own job control governs; elsewhere the run is leashed to its client), and catchable signals sent tonamebind runare forwarded tocmd.
That last point cuts both ways: a service you expose is exactly as exposed as the program behind it would be if you ran it plainly -- the name just makes it reachable. Bind your servers from a sensible directory and treat a namebind name as exactly as trusted as the thing it points at.
Requirements
- Linux with systemd (uses
systemd-runtransient units andsystemd-resolvedfor name routing). - podman (rootful; the bridge must live in the host's own network namespace).
- nftables, dnsmasq, sudo, plus
nsenter/setpriv(util-linux) andip(iproute2), which are part of a base system.
namebind is rootful by necessity: a rootless podman bridge is sealed inside the rootless network namespace and is not routable from the host, so an app's IP would be unreachable from your shell or a browser. Running rootful puts the bridge in the host namespace; your processes are still dropped back to your own user.
Install
On Arch (and derivatives), from the AUR:
$ yay -S namebind # release, tracks tagged versions
# or
$ yay -S namebind-git # the master branch
(or paru instead of yay if you use that)
Then enable the resolver once (the package does not auto-enable services -- that's your choice):
$ sudo systemctl enable --now namebind-dnsmasq.service
$ sudo systemctl restart systemd-resolved # apply the ~namebind.internal route
After this, <svc>.<app>.<zone> names resolve on the host and in browsers.
Without it, apps still get their bridge IPs -- only the names are inactive.
If you use attach (wiring a foreign network namespace, rather than run), also
enable the network substrate, which keeps the bridge up without a pod of its own:
$ sudo systemctl enable --now namebind-net.service
Quick start
Run a process in an app and reach a named service. run creates the app's
pod if absent, else joins the existing one:
$ namebind run --app web --service ui:5173 -- npm run dev
--service ui:5173 declares a service ui on the producer's loopback port
5173. While the run lives, reach it portlessly:
http://ui.web.namebind.internal/ # the scheme's default port; any port works
The bare app also resolves (web.namebind.internal -> the pod's IP) and fronts
each declared port on its real number, so web.namebind.internal:5173 works too.
namebind ip ui.web prints the IP if you want IP:port directly.
Several processes in one app talk over loopback as usual. A second run in
another shell joins the same pod (same namespace), so co-located services reach
each other the ordinary way, 127.0.0.1:<port>, with no namebind involvement:
$ namebind run --app web --service api:8080 -- ./api-server # joins 'web'
# inside the app: ui -> http://127.0.0.1:8080 (unchanged)
# from outside: http://api.web.namebind.internal/
Give an app a production-shaped name with a custom zone. The zone is frozen on the app's first run; only that app's subtree is routed, so the rest of the TLD keeps resolving normally:
$ namebind run --app shop --zone acme.test --service storefront:3000 -- ./shop
# -> storefront.shop.acme.test (the real acme.test elsewhere is untouched)
Inspect.
$ namebind status # apps, presence, and services (== view workloads)
$ namebind view fabric # how the fabric is wired, segment by segment
$ namebind ip storefront.shop # the bridge IP for a name
Lifecycle is automatic. The pod comes up with the first run and is torn down
-- DNS, routing, and all -- when the last run in that app exits (even on
SIGKILL). There is no stop command; just exit your processes.
The naming model
Names are read right to left: ui.web.namebind.internal is the ui service of
the web app under the namebind.internal zone.
An app is a namespace, and to be reachable you name a port. Nothing resolves that you did not declare.
- Service name
<svc>.<app>.<zone>resolves to that service's own dedicated bridge IP, and is port-agnostic: a packet to any port on that IP is forwarded to the producer's real loopback port. So you omit the port entirely and let the URL scheme's default stand. This per-service IP is what makes naming portless. - App name
<app>.<zone>resolves only when the app is single-address -- arun/attachpod (one netns, one bridge IP) or thehostpseudo-app. It answers each of the app's declared ports on its real number:<app>.<zone>:8080reaches the pod's127.0.0.1:8080when8080is a named service. An undeclared port is not fronted, and a serviceless app declares nothing, so it is nameless. - A multi-container Docker app (
namebind.appgrouping several containers) spans no interface of its own, so its bare name resolves to nothing at all -- only its services do. Reach a container itself by its raw fabric IP. - The port is erased, the protocol is not: use the right scheme (
http/https).
Because each service has a dedicated IP, a custom zone like --zone com is
routed as ~someapp.com -- the app subtree only -- never the bare ~com, so it
cannot blackhole the rest of a TLD.
A service name usually arises from a run's --service, scoped to that run's
lifetime. advertise declares one out of band against an existing app -- no
run, no restart -- so you can name a port of an app you did not start with
--service, or of a host-native process via the host pseudo-app. By default
the declaration is scoped to the app's current lifecycle and swept with its
teardown; advertise --persistent pins it across lifecycles, replayed whenever
the app comes up.
Two services, one host:port (the app front vs named ingress)
The two name forms are two roles, and occasionally picking the right one
matters. Both are built from the same --service declarations -- nothing
resolves that you did not declare.
<app>.<zone>is the app front -- one address carrying every port you declared, each on its real number. Reach a named port at<app>.<zone>:<port>. This is the plane for code that thinks inhost:port. An undeclared port is not fronted, and the front exists only for a single-address app (arun/attachpod, orhost).<svc>.<app>.<zone>is a named ingress -- a stable front door to one service, where the name is the address and the port is abstracted away (port-agnostic). It is what you bake into another service's config so consumers never learn the number.
The app front solves a common dev annoyance: a frontend that derives its API URL from the page's own host plus a fixed port, e.g.
const apiBase = `${location.protocol}//${location.hostname}:4000`
Run the UI and the backend as two services of the same app -- a second
run --app joins the first's pod, so they share one loopback:
$ namebind run --app myapp --svc web:8080 -- vite --port 8080 # the web UI
$ namebind run --app myapp --svc api:4000 -- ./api-server --port 4000 # joins myapp
Open the UI at the app name:
http://myapp.namebind.internal:8080/
Now the page's location.hostname is myapp.namebind.internal, so the script's
:4000 becomes http://myapp.namebind.internal:4000/, which the app front
forwards to 127.0.0.1:4000 -- the backend. The lazy pattern works with no
injected config and no reverse proxy. The same two declarations also give you the
portless ingress names (web.myapp.<zone>, api.myapp.<zone>) for free: one
declaration, both roles.
The trap is the origin. Had you opened the UI at the ingress
web.myapp.namebind.internal, that name is port-collapsing -- every port on it
funnels to the web service -- so :4000 would loop back to the UI, not the
backend. Rule of thumb: pick the origin by what its scripts will do. Serve from
the app name when they do host:port arithmetic; serve from an ingress when
everything they call is itself a named ingress.
For a real public domain, TLS, standard ports, or muxing across different apps, put a reverse proxy in front -- it targets these stable names and composes cleanly. The app front only multiplexes ports within one pod, and namebind deliberately does no load-balancing of its own.
IPv6 (dual-stack) and transports
The fabric is dual-stack by default. Alongside the v4 /16, namebind runs a
systemwide IPv6 ULA /48 (fd42:6e62::/48 -- 6e62 spells "nb"), and the two
families are the same fabric seen twice: each segment gets a /64 at the same
slot as its /24, every name resolves to an AAAA beside its A, and every
fabric address has a v6 sibling at the same host index --
10.42.<slot>.<n> mirrors fd42:6e62:0:<slot>::<n>. There is nothing to turn on.
$ getent ahosts web.myapp.namebind.internal
10.42.7.5 STREAM web.myapp.namebind.internal
fd42:6e62:0:7::5 STREAM
The design keeps this cheap and non-migrating: the registry stays v4-canonical -- an app's v6 address is derived from its frozen v4 address, never allocated separately -- so there is one allocator, no second collision surface, and no on-disk change when you upgrade into dual-stack.
Why it matters (the concrete bug it fixes). A dev server that binds the IPv6
loopback -- [::1]:port, the default for Node/Vite/Next and many others -- was
unreachable through a v4-only exposure: the name's A record pointed at a v4
fabric IP, the DNAT rewrote the destination to 127.0.0.1, and standard
netfilter cannot NAT an IPv4 connection onto an [::1] socket, so you got a fast
ECONNREFUSED against a rule that looked correct. Dual-stack reaches such a
producer over a genuine IPv6 path: AAAA -> the v6 fabric IP -> a [::1] DNAT.
Bind ::, 0.0.0.0, 127.0.0.1, or [::1] -- all are reachable now.
Transports. A service names its transport, TCP by default:
$ namebind run --app dns --svc resolver:53/udp -- ./my-resolver
$ namebind run --app sig --svc assoc:38412/sctp -- ./my-sctp-server
--service NAME:PORT[/tcp|/udp|/sctp] threads the transport into the DNAT, so a
UDP or SCTP service is forwarded on its own protocol. Application protocols need
nothing: WebSockets, HTTP, gRPC, and SSE ride a TCP service transparently (the
DNAT carries the connection, the L7 upgrade rides it untouched), and HTTP/3 is
simply QUIC over UDP -- the transport axis, not a separate feature.
Egress is dual-stack too. Off-fabric reach carries both families in every
mode. The default userspace relay (pasta) is dual-stack out of the box; kernel
host egress (host-egress via="kernel") masquerades and routes both families
(and its assume-open check now refuses a default-deny FORWARD in either
family, so a v6-only deny can't silently drop v6 egress); and a managed gateway
takes a v6 external identity -- external-ip6="slaac" autoconfigures it from the
LAN's Router Advertisements (re-forming it when the prefix rotates, the
zero-config choice) or a static address, with via6 for the v6 next-hop and
v6-identity/tune to shape the ipvlan's autoconf. See
Configuration.
Seeing it. view addresses, view workloads, and view dns show each
address's v6 sibling stacked beneath its v4. Switch the v6 display off for one
glance with --no-ipv6, or set the standing default in the views config -- it
cascades like every other decoration, so views { ipv6 false } opts every lens
out and a view "addresses" { ipv6 true } overrides it back.
Turning IPv6 off entirely. Set ipv6 false in the config for a pure-v4
fabric -- no AAAA, no v6 addresses, no v6 egress; the v4 fabric is unchanged.
Change the ULA prefix with subnet6 "fd..::/48" (like subnet, an install-time
choice: derived addresses freeze on first use).
Secure contexts (out of scope, noted). A browser grants an origin a secure
context -- unlocking the Web Crypto API, service workers, and friends -- only
over https:// or a loopback host; a plain-http:// named ingress does not
qualify, so a page served from http://app.<zone>/ sees crypto.subtle as
undefined. namebind is an L3/L4 endpoint remapper and does not terminate TLS,
so this is a documented consequence, not something it solves. TLS termination
will arrive later as one of the planned ingress plugins; until then, put a
reverse proxy in front for https, or serve dev tools that need a secure context
over a loopback origin.
Commands
Porcelain (what you use):
| Command | Purpose |
|---|---|
namebind run --app A [--service N:P[/tcp|udp|sctp]]... [--zone Z] [--segment S] [--egress E] [--own-resolv] -- CMD... |
Run CMD in app A's namespace, creating or joining its pod. --service/--svc is repeatable and names a transport after the port (default tcp); --zone is frozen on first run. --segment places the app at creation and is frozen while it lives: a matching value on a join is a no-op, a contradicting one is refused. --own-resolv gives the run a private mount namespace whose /etc/resolv.conf names the fabric resolver, so its DNS stays namebind's even when a host agent rewrites the host file -- an explicit opt-in past strict net-only. |
namebind create --app A [--zone Z] [--segment S] [--egress E] |
Create app A with nothing running in it -- a weak handle held by an idle anchor member. The first real run dissolves the anchor; thereafter plain refcounting governs, so the app tears down when that run exits. Pair with advertise to name its ports out of band. |
namebind status [--separators thin|spacy|none] [--ipv6|--no-ipv6] |
Everything namebind owns, app by app: address (with its v6 sibling stacked beneath on a dual-stack fabric), segment, zone, frozen egress, presence (pod with live runs/anchor, attached netns, or down with names kept), and each service with its IP, fronted port, and lifetime provenance. A top-level alias for view workloads: its services hang beneath each app as a small connector-drawn tree, and on a terminal it colours the view (an app accented as the anchor it is elsewhere, a presence coloured by health), underlines a thin rule between apps, and roams when it overflows; down a pipe it stays plain text. |
namebind view workloads [--separators thin|spacy|none] [--ipv6|--no-ipv6] |
The workload lens, the L5 view the fabric and address maps sit beneath -- the same listing as status, under the view family. Each address stacks its derived v6 sibling beneath its v4 on a dual-stack fabric; --ipv6/--no-ipv6 overrides the configured v6 display for one glance. --separators overrides the configured decoration style (a thin tty-only underline between apps, a drawn ─ rule spanning the listing, or nothing) for one glance; set the standing default with a hierarchical views { view "workloads" { … } } block in the config, which also carries per-element knobs like services { header "shown" } to legend the service columns, column "APP" { confines "box" } to drop the tree's stem, and a weighted border { } block at any scope -- a view frames the whole table (view "workloads" { border { outer { bottom "spacy" } } }), a column rules its own edges (column "APP" { border { inner { header "false" } } }) -- where each edge is thin, spacy, or the reactive separators weight, and coincident borders merge to one line. |
namebind view fabric [--flat] [--sort KEY] [--graph] [--separators thin|spacy|none] |
How the fabric is wired, not just what runs on it: each segment as a box -- namespace, /24, host-side router and in-fabric egress gateways, firewall posture, sealed flag, peerings -- with its members beneath, columns aligned per depth level so the map reads down as well as across. --flat lists the fabric's own devices (bridges, router and peer veths, gateway ipvlans), each qualified by the namespace it lives in and ordered by --sort (netns default, or iface/kind/role/addr). --graph draws it as a node-and-edge diagram -- boxes joined by their veths -- laid out by Graphviz (dot, needs the graphviz package) and inked as box-drawing ASCII. Both the tree and the --flat listing tune through views { view "fabric" { … } } like the other lenses -- the tree's per-segment separator is now a separators/border setting there, not only the --separators flag. Read-only and derived offline from the topology and stores; a canvas larger than the terminal becomes a roamable viewport (arrows/hjkl to pan, q to quit). |
namebind view addresses [--probe [live|stale]] [--ipv6|--no-ipv6] |
The IPAM map: a fabric overview line (the /16 and, dual-stack, the /48, with the free /24 slot count), then a bordered table per segment -- each segment a row naming its /24 and /64, its held host indices (gateway, resolver, app, container, or service) beneath as a boxed child table with an ADDRESS column that stacks each index's v4 and its derived v6. Reads the allocation ledger (registry + Docker IPAM), so a down app's frozen slot still shows. --probe classifies each slot live/held/stale against the live survey (needs sudo); --probe stale previews exactly what free --stale releases -- the registry reclaims nothing on its own, which is what keeps a returning app's addresses stable. --ipv6/--no-ipv6 overrides the configured v6 display for one glance. Read-only and offline by default; roams when it overflows the terminal. |
namebind view egress |
The north-south lens: how each app reaches off-fabric, grouped under its egress target -- none, host (a userspace pasta bind or the host kernel's forwarding + NAT, with its FORWARD-coexistence mode), or a named gateway. Each target is defined once (a gateway names the segment its ipvlan lives in, its external LAN address, its ipvlan nbg<id> and source-policy table, and its LAN next-hop via/uplink) with the apps that froze it beneath under an app/segment header, each with its own segment (and, for a pasta app, its bind name nbns-<app>); a declared gateway no app uses shows as an empty target. Its decoration tunes through the same views tree as workloads (views { view "egress" { ... } }) -- two headers (the per-target APP/SEGMENT legend, on by default; a PLACEMENT/DETAIL band over the targets, off), each a header "shown"/"hidden" toggle that replaces the code default, plus the header rule, the target stem, and the separators between targets. Reads namebind's own record of the leg set up for each app, so a name shows exactly when it has an egress; whether that leg is live right now is view doctor's to answer -- it probes the relay's unit and flags a leg this lens still names but reality lacks. Read-only; roams when it overflows. |
namebind view dns [--ipv6|--no-ipv6] |
The name-resolution lens: every name that resolves under the fabric's zone and the address it answers with -- a service (<svc>.<app>.<zone>) to its own dedicated address, and an app root (<app>.<zone>) to the pod's IP where the app is single-address (a run/attach pod, or host). A multi-container Docker app owns no address, so it shows as an address-less namespace header (tagged ns) with its services beneath -- each namespace's header first, its A and (dual-stack) AAAA stacked in one row -- drawn as the zone at the root with every name beneath it, its columns tuning through views { view "dns" { … } } like the other lenses. --ipv6/--no-ipv6 overrides the configured v6 display. Reads the served hosts.d files directly (the exact records dnsmasq answers from; no-hosts makes that directory its whole local source), so a name shows exactly when it resolves -- a down app whose file was removed is absent -- and a service's namespace is read from the name hierarchy, robust to a custom zone of any depth. Beneath the records a resolution paths block then traces how a query is actually answered, observed live (needs sudo): the host resolver split read from resolvectl (the global scope routing the fabric zone to dnsmasq, the nbup0 uplink link carrying the ~. default to the real upstream) and, per network namespace, the nftables redirects the probe finds (a pod's or container's 127.0.0.53 stub DNAT'd to its segment gateway, the host's --dns bridge to dnsmasq) -- each leg shown only where it is enacted, never reconstructed. Read-only; roams when it overflows. |
namebind view reach |
The east-west policy lens: an adjacency matrix of which segments can reach which -- L2 on the diagonal (a segment reaches itself), peer across a managed-to-managed peering, pub where the host publishes its surface to a segment, and - where the namebind_isolation drop denies it. Read-only and derived offline from the topology (no probe), so it answers "can A reach B, is this segment isolated?" at a glance; roams when it overflows. The host's always-true management reach is not a matrix edge -- the lens shows the configured policy, so an unpeered segment reads isolated. |
namebind view rules |
The packet-processing lens: the nftables actually loaded, grouped by network namespace (the host root netns, each managed segment, each namebind pod, each namebind-managed Docker container) because table names collide across namespaces -- namebind_egress is the host's kernel NAT in the root netns but a gateway's masquerade in a segment's. Under each netns its tables branch, every namebind table attributed to its purpose (isolation, dns-bridge, egress, fabric, resolver, exposure, host-publish) with its nft name, chain hooks, and a plain-English gloss; a table namebind did not create (Docker's, the host's own) reads as external, shown but unspoken-for, with each rule beneath exactly as nft printed it. Unlike the offline lenses this one is fully online -- the ruleset it shows is observed (nft list ruleset per netns, needs sudo), never reconstructed from intent -- so it answers "why was this dropped, redirected, or NAT'd?" against what is really loaded; a torn-down netns or absent daemon degrades the survey rather than failing it. Its columns -- the netns, its tables, their rules -- tune through views { view "rules" { … } } like the other lenses. Read-only; roams when it overflows. |
namebind view doctor |
The consistency/health lens: everything orphaned, drifted, conflicting, or blocked, one finding per line, loudest first -- each leading with a severity block (a colour dot beside a STATUS word, break for a fault or smell for a latent issue, so severity reads even down a pipe where colour is stripped), then its category, subject, where, and detail. Diffs the owned intent (config, topology, the stores) against the probed reality (live namespaces, veths, gateways, registry addresses, forward posture) and reports each discrepancy -- a live segment namespace the config no longer declares, a declared peering or gateway whose device never came up, a frozen registry address no live app claims, an app whose frozen userspace egress names a relay that is not running (so it has no route off the fabric at all), or host kernel egress a default-deny FORWARD silently drops. One category does not diff against reality at all: a conflict is intent contradicting itself -- two names holding one fabric address -- which no probe can reveal, because the kernel carries out both halves without complaint (Linux does no conflict detection on ip addr add) and the damage shows only as traffic reaching the wrong workload. Only the ledgers can be asked. Reality is observed and intent read from owned artifacts (never a reconstructed ruleset), so each finding is a real diff and an empty report is a clean bill; every check is causally gated, so a lazily-uncreated artifact never reads as a fault. Its findings columns tune through the same views tree as the other lenses (views { view "doctor" { … } }). Reads live state (needs sudo); read-only; roams when it overflows. |
namebind ip NAME |
Print the bridge IP assigned to an app or <svc>.<app> name. |
namebind version |
The build revision installed (stamped at package time as pkgver-pkgrel, carrying the git-describe .rN.g<sha>) and the one the running docker-driver was started from. The driver reads its code and config once at start, so a mismatch means it is serving a build older than is installed after an unrestarted upgrade -- systemctl restart namebind-docker.service. The installed line needs no privilege; querying the daemon needs its root-owned socket. |
sudo namebind attach --app A --pid PID [--service N:P]... [--zone Z] [--segment S] [--egress E] |
Wire an existing (foreign) network namespace -- any process's, e.g. a container's -- as app A and expose its services. The building block for "another engine manages the containers, namebind owns the network." |
sudo namebind detach --app A |
Reverse attach for app A. |
sudo namebind harden-dns --pid PID |
Give an already-running workload (a container, an attach target) a private /etc/resolv.conf naming the fabric resolver -- the post-factum counterpart of run --own-resolv, grafted into the target's existing mount namespace without writing into it or touching the host's file. Refused if the target shares the host mount namespace. The graft lasts the namespace's life, so re-run after a restart. |
sudo namebind advertise --app A [--persistent] NAME:PORT... |
Name loopback ports of an existing, live app, with no run and no restart. The declaration is scoped to the app's current lifecycle (swept by its teardown); --persistent pins it across lifecycles instead -- it then survives teardown, may be declared ahead of the first run, and is replayed whenever the app comes up. Against the reserved app host, names a host-native process's port (advertise --app host broker:9229 -> broker.host.<zone>, reachable from host-peered segments). |
sudo namebind unadvertise --app A NAME... |
Reverse advertise for the named services (either lifetime). |
sudo namebind destroy --app A |
Force A's end of life: stop every holder of its refcount (a pod app's member runs via systemd, an attached app's holder process) and let the ordinary teardown run. |
sudo namebind free {--app A | --stale} |
Forget a down app's permanent names: release its frozen IP assignments and persistent advertisements, making the addresses mintable afresh. Refused while the app is up. Nothing frees a name on its own -- that freeze is exactly what gives an app the same addresses across restarts -- so the ghosts of apps that are never coming back accumulate until you say otherwise. --stale says it in bulk: it releases every app the live survey no longer backs, precisely the set view addresses --probe stale previews and view doctor reports. A down app whose declarations still stand is held, not stale, and is never touched. Not folded into gc, which reclaims what a crash left behind: a frozen address is kept on purpose, so ending that contract stays your decision. |
sudo namebind gc |
Detach attached apps whose namespace has gone away, reclaim the egress leftovers (relay unit, netns bind) of anything no longer up, and reclaim the DNS records and IPs of Docker endpoints removed while the driver was down. Every reclaim must prove its subject dead: a container's egress rides the same netns bind an app's does, so the sweep asks Docker as well as podman, and an unreachable Docker frees nothing rather than mistaking silence for death. |
Plumbing (namebind plumbing <cmd>) -- launch, create, pod-up, pod-down,
run-exec, run-down, ensure-fabric, teardown-fabric, docker-driver --
are the low-level callbacks run/create and the systemd units invoke. They
are documented but you rarely type them by hand.
Docker
namebind ships a libnetwork remote driver, so a Docker network is a namebind segment and
its containers are first-class apps -- reachable by name, with collision-free IPs from the same
allocator as run/attach, isolated and host-immune. Enable the driver daemon (it needs Docker
running):
$ sudo systemctl enable --now namebind-docker.service
Back a network with namebind and run a container on it:
$ docker network create --driver namebind --ipam-driver namebind proj
Services, by name not by -p. The same rule as native: nothing resolves that you did not
declare. A container label publishes a port as a named, port-collapsing service:
$ docker run -d --network proj --name web --label namebind.service.http=8080 nginx
# -> http.web.default.<zone> reaches the container's :8080 (on any port -- it collapses)
A container's services hang under its faithful <container>.<segment>.<zone> base -- qualified by
the segment it lands in (the reachability boundary namebind owns), not the Docker network name --
resolvable from the host and from every app the segment reaches, once name resolution is enabled
(namebind-dnsmasq; see Install). An unbound network lands in the everywhere-default managed
segment (containers need a shared L2, which the flat host segment lacks), hence default above; bind
a specific segment with --ipam-opt namebind.segment=<seg> (declared in the config), or the host
segment with namebind.segment=host.
A container that declares no service is nameless. It is still on the fabric at its own
address -- every peer can reach it by IP, and view addresses lists it under its container name --
it simply has no name namebind minted for it. Within its own Docker network, Docker's embedded
resolver still resolves it by container name as always; namebind adds the cross-boundary name, and
only for ports you asked it to.
One app, many containers. --label namebind.app=<name> joins the container to an app. The
app is a namespace, not a destination: <name>.<zone> resolves to nothing at all, because an app
spanning several containers spans no interface of its own. Its services resolve --
db.myapp.<zone>, cache.myapp.<zone>, ... -- so a whole stack lives under one app with each
component a distinct, individually addressable service. There is no round-robin across the members;
namebind is not a load balancer, and if you want one, put a reverse proxy in front.
Native and Docker, one app. The namebind.app join spans both planes: a run/attach app
and driver-managed containers can share a namespace and reach each other by the fabric name
(<svc>.<app>.<zone>), so a stack can mix a native backend with containerised ones. One rule keeps
it coherent -- an app name binds to one segment: place the native app and bind the containers'
network to the same segment and their services all resolve under the one app; a container
claiming a name a native app already owns in a different segment is refused the join (its services
fall back to its own faithful <container>.<segment>.<zone> base). Bare Compose short-names
(redis, no zone) are Docker's own container-to-container resolver, not namebind's -- the fabric
FQDN is the cross-plane name.
Egress. A Docker---internal network hands egress to namebind: add --opt namebind.egress=host
and its containers reach off-fabric through the host's own leg (pasta), which keeps working behind a
default-deny firewall (a DNS/egress-enforcing VPN among them) that drops Docker's forwarded
traffic. A non---internal network keeps Docker's own egress.
Container-side fabric DNS. A driver-managed container uses Docker's embedded resolver
(127.0.0.11), so to resolve fabric names from inside it, name the fabric resolver -- one fixed
address, 10.42.0.53 by default (<subnet>.0.53) -- in --dns. Compose short-names and the
internet keep working; only fabric names need it. See docs/dns.md.
Compose. The labels and dns: work as-is, but Docker Compose drops ipam.options, so a
segment cannot be bound in a Compose file. Either omit it (default managed segment) or pre-create
the network by CLI and reference it as external:
$ docker network create -d namebind --ipam-driver namebind \
--ipam-opt namebind.segment=dmz --internal --opt namebind.egress=host dmznet
networks:
fabric: { external: true, name: dmznet }
Configuration
The packaged defaults live at /usr/share/namebind/config.kdl (rewritten on every
update -- don't edit it). To change a setting, copy it to /etc/namebind/config.kdl
and edit the copy; namebind reads /etc if present, otherwise the packaged
defaults, so your edits survive updates (the pipewire pattern):
// systemwide
dns-port 5354 // namebind's dnsmasq loopback port (off 5353/mDNS)
subnet "10.42" // the two octets of the systemwide /16 (each segment a /24 of it)
// ipv6 false // uncomment for a pure-v4 fabric; else dual-stack (below is default)
// subnet6 "fd42:6e62::/48" // the IPv6 ULA /48 the per-segment /64s carve from
// segments: isolated L2 domains; `peer` grants reachability between two
segment "dmz" {
enable-firewalling // expose this segment's app<->app to its own netns netfilter
gateway "lan" external-ip="192.168.0.21" external-ip6="slaac" // dual-stack named egress
}
peer "dmz" "default" // a symmetric veth: apps in dmz and default now reach each other
// per-app config: `everywhere` = the defaults, `where SEL { }` = selector overrides
everywhere {
zone "namebind.internal" // the zone an app's names live under: <svc>.<app>.<zone>
segment "default" // the segment an app lands in (isolated from others unless peered)
egress "host" // placement: none | host | <a gateway the segment can reach>
}
where app="db" { // refine per app; most-specific wins, doc order breaks ties
segment "dmz" // (`app` is the only selector today; KDL needs a multi-line body)
egress "lan"
}
everywhere { } holds the per-app defaults; any number of where SEL { } blocks
override zone/egress for apps whose selectors all match (AND), resolved
most-specific-first with a run/attach flag winning over both — and frozen on the app's
first run. Config can be split across files: include r#"conf.d/*.kdl"# merges every
matched file's nodes in at that point (glob relative to the including file; matching
nothing is fine), so drop-ins under a conf.d directory compose into one config — the
path is a raw, hash-fenced string so it is taken literally regardless of its characters.
install-assets.sh --zone ... --port ... --subnet ... writes the
/usr/share defaults and bakes the default zone/dns-port into the dnsmasq config
and the resolved routing drop-in too, so they agree. Because of that, overriding zone
or dns-port in /etc also needs an install-assets re-run (or a package update);
egress, segment/peer, subnet, and where rules are read only by namebind, so they
take effect from /etc directly. For development, NAMEBIND_ZONE, NAMEBIND_DNS_PORT,
NAMEBIND_SUBNET, NAMEBIND_EGRESS, and NAMEBIND_STATE_DIR override at runtime.
Change subnet to avoid clashing with another 10.x/172.x network on the host.
It is always a /16, so only the two network octets are honoured. Note this is
effectively an install-time choice: an app's address is derived from the
subnet and frozen in the registry on first use, so changing it after apps exist
invalidates their IPs (clear /var/lib/namebind/registry and re-run them).
IPv6. The fabric is dual-stack unless you set ipv6 false (a pure-v4 fabric).
subnet6 sets the ULA /48 the per-segment /64s carve from (fd42:6e62::/48
by default) — like subnet, an install-time choice, since v6 addresses are
derived from the v4 and freeze on first use. A gateway carries IPv6 when it names
a v6 external identity: external-ip6="slaac" (autoconfigured from the LAN's
Router Advertisements, the rotation-robust default) or a static v6 address, with
an optional via6 next-hop, a v6-identity "stable"|"private" source-address
style, and a whitelisted tune { … } block of per-ipvlan autoconf/DAD sysctls
(a key outside the whitelist is refused, so no config can reach a host-scope
sysctl). Whether the address lenses display IPv6 is a separate presentation
knob: views { ipv6 false } (cascading, per-view-overridable) or --no-ipv6
per glance. See IPv6 (dual-stack) and transports.
Segments. Every app lives in a segment — an isolated L2 domain with its own network
namespace and /24, always immune to the host's netfilter whatever Docker/CNI does to
global bridge state. Apps in different segments cannot reach each other at all unless
the segments are peered: containment is the absence of a veth, provable by inspection,
not a firewall rule. segment "NAME" { … } declares one; its optional children are subnet "10.42.N" (its /24, else a stable value derived from the name), enable-firewalling
(expose this segment's app↔app to its own netns netfilter — counters and nft drops,
still host-immune, needs br_netfilter), sealed (a contained zone whose only way out is a
gateway it owns), and the gateways it owns. peer "A" "B" is one symmetric veth granting
A↔B reachability — not transitive (A–B and B–C does not give A–C). The host
segment is special: the host's own netns, the universal hub reaching every segment,
configured by nothing; an app placed there (segment "host") runs in the host netns and
egresses as the host. peer "X" "host" publishes the host's loopback surface to X as
the host pseudo-app: apps in X reach any host-native service — a process never run
through namebind, like a plain npm run or a broker socket — at host.<zone>:<port>,
one name for exactly one address, with the rest of the host plane staying dark.
Apps land in default unless everywhere/where/run --segment places them elsewhere.
See docs/segments.md.
egress controls how an app reaches off-fabric (north-south) addresses; east-west always
rides the segment's bridge and is unaffected. An app's egress is a placement — none
(hermetic), host, or a named gateway — and it can only select a gateway its segment
can reach (one it owns, or reaches through a peer); selecting an unreachable one is an
error. host goes out as the host: the immune userspace relay (pasta; needs passt),
or host-egress via="kernel" for fast kernel NAT. A gateway "lan" external-ip="…" is the
app's own LAN address via a dedicated ipvlan in its segment's netns, immune to any host
firewall; define several — each optionally off a specific uplink — and apps reference them
by name, each leaving by its own ipvlan (source policy routing). The config value is the
default; a single app overrides with run --egress none|host|<gateway> (or attach --egress …), frozen on its first run like --zone. See docs/egress.md.
(Docker-driver containers egress via Docker's own gateway bridge, so this does not apply to
them.)
namebind is not a firewall — policy is your firewall's job (ufw, firewalld, raw
nftables). What it does offer the firewall is identity: alongside DNS, it keeps
/run/namebind/identity.nft current with one nftables set per app and service
(@namebind_app_web, @namebind_svc_web_api, @namebind_all). include it inside your
own table and you can write policy in terms of app names instead of addresses, while
namebind never touches a rule of yours. The sets span the whole fabric — driver-managed
Docker containers are in them exactly as native apps are, which matters because a
container's address is reallocated when it is recreated, so the set is the only durable
way to name one in a rule. See docs/identity.md and
docs/scope.md.
State lives under /var/lib/namebind (the permanent registry of name->IP
assignments, the per-app DNS hosts.d, and ephemeral activation), with volatile
custom-zone bookkeeping under /run/namebind.
How it works
- Addressing. An app name hashes to a stable address in its segment's
/24(a slice of the systemwide/16,10.42by default) by double hashing (an odd stride gives a full-period probe sequence, not a cycle); the first resolution is frozen in an append-only,flock-guarded registry, so an app's IP never shifts. Each named service gets its own derived IP. - Segments. Each segment's bridge lives in its own network namespace with
bridge-nf-call-iptables=0, so app↔app L2 is immune to the host's netfilter (a running Docker sets that sysctl host-wide and a default-denyFORWARD, which would otherwise drop it). An app is placed into its segment's netns; the host reaches each segment over a router veth, and apeeris a veth joining two segment namespaces — so a non-peered segment is unreachable by construction, and a standing host rule refuses to forward between two segments even when kernel egress turns host forwarding on. podman supplies the per-app netns (--network none); namebind plumbs the veth onto the segment's bridge andnsenter's your process in,setpriv'd back to your uid.attachand the Docker driver place into a segment the same way. - Exposure. Each service IP is added to the pod's
eth0, and nftables DNATs all traffic for that IP to the producer's loopback port (preroutingandoutput, so pod-local callers reach the names too). The whole table is rebuilt as one idempotent replace from the live service set. - DNS. namebind writes one dnsmasq
hostsdirfile per app; dnsmasq reloads on its own. On the host, systemd-resolved routes the umbrella (a static drop-in) and any custom zones (a volatile/rundrop-in) to that dnsmasq. Inside a pod, an nftables redirect points the inherited resolver (127.0.0.53) at namebind's dnsmasq over the fabric, so names resolve in every egress mode -- not just userspace; dnsmasq is authoritative for the zone and forwards the rest upstream. See docs/dns.md. - Lifecycle. The pod is an installed systemd template unit with
StopWhenUnneeded=yes; each run is a transient member unit thatBindsToand is orderedAfterit. systemd's dependency graph is the refcount: the first member pulls the pod up, the last to leave lets it fall away, and the pod'sExecStoptears everything down -- crash-safe, even onSIGKILL.
Known limitations
- No filesystem isolation, by design (see the invariant above).
- A client doing its own DNS-over-HTTPS (a browser with DoH on, or an app shipping its own resolver) bypasses the OS resolver, so zone names need DoH off or a per-domain exception for it. Names resolved through the OS resolver are unaffected: zone names are answered authoritatively and never sent to DoH.
- A Docker Compose file cannot bind a network to a segment: Compose drops
ipam.options, sonamebind.segmentnever reaches the driver. Pre-create the network withdocker network create --ipam-opt namebind.segment=<seg>and reference it asexternal, or omit it and use the default managed segment (see Docker). - A plain-
http://named ingress is not a secure context, so a page served from one sees the Web Crypto API and other secure-context-gated features as unavailable. namebind does not terminate TLS (that lands later as an ingress plugin); serve such tools over a loopback origin or a reverse proxy meanwhile. See IPv6 (dual-stack) and transports.