Documentation
¶
Index ¶
Constants ¶
const ( DefaultHandler = "/usr/lib/net-dhcp/dhcp-handler" VendorID = "docker-net-dhcp" )
const EventFIFOEnv = "NETDHCP_EVENT_FIFO"
EventFIFOEnv is the environment variable, pushed to dhcpcd's hook via the `env` config directive, that tells the handler where to write its JSON events. dhcpcd's hook stdout is unusable as a data channel (/dev/null once daemonised, interleaved with dhcpcd's log in foreground), so the parent opens a FIFO and passes its path here.
Variables ¶
var ValidIfaceName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,14}$`).MatchString
ValidIfaceName accepts only a kernel-legal network interface name: 1–15 characters (IFNAMSIZ-1), starting with an alphanumeric and otherwise limited to alphanumerics, dot, dash and underscore.
WHAT THE LEADING-ALPHANUMERIC RULE ACTUALLY GUARDS. Not re-splitting. The interface name is interpolated into the dhcpcd argv that runs under `unshare -m /bin/sh -c '… exec "$0" "$@"'`, and the `"$@"` quoting does prevent re-splitting — which is why this comment used to call the alnum-first rule "defence-in-depth". That reason was wrong, and a rule whose stated reason is wrong is a rule someone relaxes.
The real mechanism is getopt PERMUTATION. dhcpcd 10.3.2's getopt permutes, so the interface — which renderArgs places LAST, as a trailing positional — is re-read as an option if it looks like one. Measured: with the interface replaced by `-c/out/evil.sh`, dhcpcd ran that script as uid 0 for PREINIT and CARRIER. Nothing about quoting enters into it; `"$@"` delivered the argument faithfully and dhcpcd parsed it as a flag.
The rule holds today because no `-c<abs-path>` payload fits inside IFNAMSIZ once a leading alphanumeric is required and the kernel refuses '/' in a name — but that is a consequence of THIS rule, not an independent guard, and the argument only works if the reason is written down correctly. See #706 and, for the precedent, #638.
Exported because pkg/plugin applies the same rule one step earlier, at CreateNetwork and CreateEndpoint, so a bad name fails the request loudly instead of surviving to the argv (#705, #706).
Functions ¶
func FirstSearchDomain ¶ added in v1.8.0
FirstSearchDomain keeps only the first whitespace-separated token of an option-15 domain, reporting whether it had to cut anything.
SafeDirectiveValue CANNOT do this job, and the reason is worth writing down: it rejects r < 0x20 || r == 0x7f, and 0x20 -- the space -- is precisely the field separator of the sink it protects. So a space passes the filter, `search %s` renders it verbatim, and one search domain becomes several. Measured end to end: dhcpcd's option-15 dname validation accepts "a.attacker.test b.attacker.test", and the generated file carried both.
This is the completeness gap #689 recorded one character short of closing. DNSServers and SearchList are structurally safe because they reach us through strings.Fields; Domain is taken whole, and that asymmetry is the whole defect. Impact is low -- it needs propagate_dns, where the same server already owns `nameserver` via option 6 -- but it lets the attacker put his domain FIRST in the search order, which changes which host a bare name resolves to (#704).
Exported for the same reason as SafeDirectiveValue: the filter that counts runs at the BuildEvent boundary, and the renderer keeps the same rule as an uncounted backstop, so both need it.
func SafeDirectiveValue ¶ added in v1.8.0
SafeDirectiveValue reports whether s can be interpolated into a dhcpcd config directive without changing the FILE'S STRUCTURE.
Exported because pkg/plugin applies the same rule one step earlier, so the drop can reach a health counter (see Plugin.safeHostname).
renderConfig writes one directive per line as "<keyword> <value>", so a value carrying a newline does not produce a malformed directive — it produces an ADDITIONAL, attacker-chosen one, and dhcpcd applies it. That matters most for the values the plugin does not originate: the hostname is the container's own (Docker performs no validation on it, verified), and the vendor class and server lists come from network options.
dhcpcd resolves a repeated directive last-wins, and renderConfig writes `duid` near the top while the hostname lands near the bottom, so an injected `duid` overrides the identity this plugin pinned — the DUID is derived from the endpoint MAC, and every endpoint's MAC is observable on a shared L2 segment. The same trick voids a `blacklist` written from dhcp_deny_servers, because dhcpcd stops consulting a blacklist once a whitelist exists.
The rule is deliberately about control characters rather than about well-formedness: an over-strict check here would reject hostnames Docker and real deployments accept (underscores, for one) and break containers that are doing nothing wrong. Structure is what must be protected; anything dhcpcd itself dislikes about a flat token is dhcpcd's to complain about.
This is the config-file sibling of ValidIfaceName, which guards the same class of problem on the argv side.
func SweepOrphans ¶ added in v1.8.0
SweepOrphans finds dhcpcd processes left behind by a previous plugin process and kills them, returning how many it killed.
WHY THIS EXISTS. The dhcpcd child is not bound to the plugin's lifetime: there is no Pdeathsig (see NewDHCPClient) and the plugin shares the host PID namespace, so a plugin that dies without running its shutdown path — SIGKILL, an OOM kill, a panic that skips Close — leaves every persistent client running and renewing inside the container's still-live netns. The plugin then restarts, recoverEndpoints starts a SECOND client per endpoint with the same DUID, IAID and client-id, and two clients manage one binding. On the eventual Leave one sends a DHCPRELEASE while the other keeps renewing, and the server may reallocate an address that is still in use (#722).
WHY SIGKILL, AND NOT SIGTERM. This is the part that is easy to get backwards. When the plugin restarts, the containers are still RUNNING and still using their addresses. The persistent client omits dhcpcd's -p, so it releases its lease when it is asked to stop politely — which means a SIGTERM sweep would send a DHCPRELEASE for every address a live container currently holds, and invite the server to hand those addresses to somebody else. That is #524's duplicate assignment, manufactured by the cleanup. SIGKILL leaves the binding untouched at the server, so the address stays allocated to this host until the replacement client claims it back.
This is the same asymmetry #720 turns on: a missed reclaim leaves a lease to expire on its own, a wrong one takes an address away from something using it.
WHAT IT MATCHES, IN THREE PARTS. Every client's argv carries the absolute path of its own work directory, which is created with workDirPrefix — dhcpcd's `-f <workdir>/dhcpcd.conf`. That prefix is the marker; nothing else on the host writes it. The process's own comm must ALSO be dhcpcd, so a process that merely mentions the path (a shell, a grep, an unshare that has not exec'd yet) is not a candidate. And its parent must NOT be a live plugin process, which is what makes the word "orphaned" in this function's name true rather than assumed — see selfComm.
Without that third test the predicate is "is a dhcpcd of ours", and every live client of every RUNNING plugin process satisfies it identically: same marker in argv, same comm. A second instance starting up would then SIGKILL the first instance's live clients, and the first instance would never learn — it is not waiting on a signal it did not send, its containers keep running, and their leases simply stop renewing at T2 with no counter moving anywhere. That is the same user-visible outcome this function exists to prevent, caused by it. Two instances is a supported configuration (`--alias`), and the work directory comes from os.MkdirTemp in the plugin's own private /tmp, so the paths are identical STRINGS across instances rather than merely similar.
This runs as root against the host PID namespace and sends SIGKILL, so every read that fails is a "no". PID reuse is real, so the whole match is re-read immediately before the kill rather than trusted from the scan: a pid recycled between the two reads fails the second check and is skipped.
Types ¶
type DHCPClient ¶
type DHCPClient struct {
Opts *DHCPClientOptions
// contains filtered or unexported fields
}
DHCPClient represents a dhcpcd client managing one interface/family.
func NewDHCPClient ¶
func NewDHCPClient(iface string, opts *DHCPClientOptions) (*DHCPClient, error)
NewDHCPClient creates a dhcpcd client for iface. It allocates a per-client working directory, generates the dhcpcd config (pinned identity + observe-only + the event FIFO) and the event FIFO itself, and builds the (mount-namespace-wrapped) command. Start runs it.
func (*DHCPClient) Finish ¶
func (c *DHCPClient) Finish(ctx context.Context) error
Finish stops the client and waits for it to exit. For the persistent client it sends SIGTERM (dhcpcd releases its lease and exits); the one-shot client exits on its own (-1), so Finish only awaits it.
func (*DHCPClient) Start ¶
func (c *DHCPClient) Start() (chan Event, error)
Start starts dhcpcd and returns a channel of lease events read from the FIFO. The channel is closed when the dhcpcd process exits (on its own for one-shot, or via Finish for the persistent client).
Concurrency contract: when Opts.NetNS is set, Start enters the target netns by locking the calling goroutine to its OS thread, switching netns, spawning the child (which inherits the netns), and switching back. It is *not* re-entrant on the same goroutine. Concurrent Starts on *different* goroutines are safe. On netns-restore failure the calling thread is deliberately leaked so the wrong-netns state never re-enters Go's thread pool.
type DHCPClientOptions ¶
type DHCPClientOptions struct {
Hostname string
V6 bool
Once bool
// NetNS is the network namespace to spawn dhcpcd in, as an OPEN
// FILE DESCRIPTOR. nil means "spawn in the caller's namespace".
//
// This is deliberately not a path. It used to be one, and the
// caller built it from a container PID; Start then re-resolved that
// string independently of the caller's own resolution, so the two
// could land in different namespaces if the PID was recycled in
// between (#688's hazard, reaching netlink and a root dhcpcd). A
// descriptor cannot be re-resolved into something else.
//
// The handle is BORROWED: Start enters it and never closes it, so
// its lifetime belongs to whoever opened it.
NetNS *netns.NsHandle
// MAC is the endpoint's (pinned) hardware address. It is the sole
// input to the DUID-LL and IAID pinned in the generated config, so
// the one-shot (host netns) and persistent (container netns) clients
// derive an identical identity and the DHCP server returns a single
// binding (#152).
MAC net.HardwareAddr
// RequestedIP, when non-empty, becomes dhcpcd's `request ADDR`
// (DHCPv4): the client asks for that specific address, the server
// ACKs if the lease is still valid and otherwise falls back to a
// fresh offer. Used on plugin-restart recovery and container restart
// to keep the same lease. v4 only — for v6 use PreferredV6.
RequestedIP string
// PreferredV6, when non-empty, becomes the address in dhcpcd's
// `ia_na <iaid> / ADDR` (DHCPv6): a preferred-address hint in the
// pinned IA_NA. v6 only.
PreferredV6 string
// AllowServers restricts which DHCPv4 servers this client may accept
// a lease from (dhcpcd `whitelist`). Empty imposes no restriction.
// The plugin derives it from the network's dhcp_servers preference
// list; for a tiered acquisition it holds a single tier, and for the
// persistent client the whole allowed set so renew/rebind can still
// reach a surviving server (#111).
AllowServers []string
// DenyServers rejects specific DHCPv4 servers (dhcpcd `blacklist`).
// Must be empty whenever AllowServers is set — dhcpcd ignores a
// blacklist once a whitelist exists (#669).
DenyServers []string
// ClientID, when non-empty, is sent as DHCPv4 option 61 (dhcpcd
// `clientid`), prefixed with the type-0 ("opaque") byte the busybox
// path used so existing server reservations keyed on it keep
// matching. v6 identity is carried by DUID+IAID, so this is ignored
// for v6.
ClientID []byte
// VendorClass overrides DHCPv4 option 60 (dhcpcd `vendorclassid`).
// Empty falls back to the VendorID constant. v4 only.
VendorClass string
// Broadcast requests an L2-broadcast reply (ipvlan-L2, where every
// slave shares the parent MAC). Emitted as the dhcpcd `broadcast`
// directive (v4 only) — the busybox `-B` equivalent; see renderConfig
// and #243.
Broadcast bool
// FQDN, when non-empty, sets dhcpcd's `fqdn` directive mode (e.g.
// "both"), making the client send the DHCP FQDN option (81 v4 / 39 v6)
// built from Hostname and ask the server to register it in DNS (#261).
// Empty omits it (the default — DDNS registration is opt-in).
FQDN string
HandlerScript string
}
type Event ¶
type Event struct {
Type string
Data Info
// UnsafeValuesDropped is how many server-chosen string values
// BuildEvent refused because they carried a control character
// (#703).
//
// It rides the event because the filter runs in the dhcpcd hook
// process and the health counter lives in the plugin, which is a
// different process on the other side of the FIFO. Without it the
// drop would be invisible to operators, and a filter whose work
// leaves no trace is indistinguishable from an attack that was
// never attempted.
UnsafeValuesDropped int `json:",omitempty"`
}
func BuildEvent ¶
BuildEvent assembles an Event from a dhcpcd hook invocation: the `$reason` string plus the `new_*` lease variables dhcpcd exports to its --script. Returns (event, true) when the caller should emit the event downstream; (zero, false) when the reason is one we don't act on, or when a lease-bearing event carries an unparseable address.
Migration note (#152): this replaced busybox udhcpc/udhcpc6. busybox passed the event type as argv and a flat set of env vars (ip/mask/router/ipv6/dns6/…); dhcpcd passes the reason in $reason and the lease as the documented new_* variables, with the DHCPv6 IA_NA address in the indexed new_dhcp6_ia_na1_ia_addr1. The downstream Event/Info contract is unchanged, so the plugin's renew()/counter paths did not move.
The #128 hardening is preserved: an emitted bound/renew NEVER carries an IP string that netlink.ParseAddr would later reject — malformed input skips the event instead of blowing up mid-renewal.
type Getenv ¶
Getenv reads one environment variable. The handler binary supplies os.Getenv at runtime; tests inject a closure over a fixed map so they can exercise every branch of BuildEvent without setenv churn.
type Info ¶
type Info struct {
IP string
Gateway string
Domain string
// DNSServers is the DNS server list from DHCP option 6 (v4) or
// option 23 (v6). Empty when the server didn't supply the option.
// Consumers MUST treat empty as "do not change container resolv.conf"
// — overwriting with empty would silently drop name resolution.
DNSServers []string `json:",omitempty"`
// MTU is the Interface MTU from DHCP option 26. 0 when the server
// didn't supply the option. Consumers MUST treat 0 as "do not change
// link MTU" — applying 0 would set a useless link state. Renewals
// can include a different MTU; consumers should compare and only
// re-apply on change.
MTU int `json:",omitempty"`
// NTPServers is the NTP server list from DHCP option 42 (dhcpcd
// env var `new_ntp_servers`). Empty when the server didn't supply the
// option. Surfaced to operators via plugin logs at info level on
// bind/renew; not auto-applied to the container — workloads
// needing NTP should consume the value themselves (typically via
// a sidecar that reads docker logs or polls Plugin.Health).
NTPServers []string `json:",omitempty"`
// SearchList is the DNS Domain Search List from DHCP option 119
// (dhcpcd env var `new_domain_search`). Empty when the server didn't supply
// the option. When PropagateDNS=true the plugin emits this as the
// `search` line in the container's /etc/resolv.conf; falls back
// to the single-domain `Domain` (option 15) when SearchList is
// empty.
SearchList []string `json:",omitempty"`
// TFTPServer is the TFTP server hostname from DHCP option 66
// (dhcpcd env var `new_tftp_server_name`). Empty when not supplied. Used for
// PXE-boot-style scenarios; surfaced to operators via plugin
// logs, not auto-applied to the container.
TFTPServer string `json:",omitempty"`
// BootFile is the boot file name from DHCP option 67 (dhcpcd env
// var `new_bootfile_name`). Same surfacing semantics as TFTPServer.
BootFile string `json:",omitempty"`
// WPAD is the Web Proxy Auto-Discovery URL from DHCP option 252
// (dhcpcd env var `new_wpad`; option 252 is non-standard, so the
// config `define`s it). PosixTimezone / TZDBTimezone come from the
// RFC 4833 timezone options 100 (PCode, `new_posix_timezone`) and
// 101 (TCode, `new_tzdb_timezone`); TimeOffset is the legacy option 2
// (seconds from UTC, `new_time_offset`). All observe-only, like
// TFTPServer/BootFile: surfaced to operators via plugin logs, never
// pushed into the container (the no-plumbing bar, #262).
WPAD string `json:",omitempty"`
PosixTimezone string `json:",omitempty"`
TZDBTimezone string `json:",omitempty"`
TimeOffset string `json:",omitempty"`
// Routes are the classless static routes from DHCP option 121
// (RFC 3442, dhcpcd env var `new_classless_static_routes`). v4 only —
// DHCPv6 carries no route option (routes come from RAs). Empty when
// the server didn't supply the option. A 0.0.0.0/0 entry is NOT
// included here: per RFC 3442 its gateway supersedes option 3 and is
// folded into Gateway during parsing. Applied at Join as additional
// container StaticRoutes; `skip_routes=true` opts out.
Routes []Route `json:",omitempty"`
// LeaseSeconds is the lease lifetime the server granted, in seconds
// (v4 `new_dhcp_lease_time`; v6 the IA_NA valid lifetime
// `new_dhcp6_ia_na1_ia_addr1_vltime`). 0 when the server didn't
// supply it.
//
// It exists so the plugin can tell "healthy client, quietly holding a
// long lease" apart from "client that stopped getting service"
// WITHOUT depending on a lease-loss hook (#353). dhcpcd does not
// reliably deliver one: under `--noconfigure`, which this plugin
// always runs, a lapsed lease fires the hook as RELEASE rather than
// EXPIRE — and RELEASE is indistinguishable from the one a graceful
// stop produces, so it can never be counted as a failure. The lease
// deadline carries no such ambiguity.
//
// The renewal time (T1, option 58) is deliberately NOT carried here
// even though dhcpcd exports it, because under `--noconfigure` it is
// not a deadline anything meets: with no address configured on the
// link, dhcpcd's T1 unicast renewal always fails ("failed to renew
// DHCP, rebinding") and the lease is actually renewed at T2 by
// broadcast rebind. Verified against dhcpcd 10.3.2 with a healthy
// server: on a 120s lease the only post-bind hook was REBIND at
// t+105s. A T1-derived deadline would therefore fire on every
// healthy client.
LeaseSeconds int `json:",omitempty"`
}
func GetIP ¶
GetIP obtains a lease via one-shot dhcpcd runs, retrying transient acquisition failures until the passed context's deadline. Retries exist because a failed exchange is often momentary (#325: lost server response under boot-time load, slow upstream) while the price of giving up — Docker refusing to start the container — is high. Permanent failures are returned immediately, unwrapped, so errors.Is/As classification (and the #247 stderr diagnostics) keep working; on deadline the last attempt's error is chained with %w for the same reason (ErrToStatus's 502, the probe's ErrNoLease branch).
The caller's opts is not mutated — we work on a local copy so a caller that reuses the options struct between persistent and one-shot calls doesn't get its Once flag flipped on.
type Route ¶
type Route struct {
// Destination is the canonical CIDR (e.g. "10.0.0.0/8").
Destination string
// Gateway is the next hop. Empty means the route is on-link (dhcpcd
// reported the gateway as 0.0.0.0).
Gateway string `json:",omitempty"`
}
Route is a single classless static route from DHCP option 121.