FreeBSD hostnamed — porting plan freebsd-launchd-mach (v2) effort

A clean-room daemon that picks a sensible default hostname from hardware identity at first boot, sets kern.hostname, and publishes Apple's three-name shape (ComputerName / LocalHostName / HostName) to configd's SCDynamicStore so mDNSResponder and other consumers can subscribe. Closest port we can do today of Apple's configd SetHostname plugin, kept as a standalone daemon because this repo's configd has no plugin loader (see configd plan's "deferred" list). Companion to launchd, configd, hwregd, mDNSResponder, IPConfiguration.

Status: planning v0 — ready for review, no code yet

1. Goal & non-goals

1.1 Goal

Provide a working /usr/sbin/hostnamed on freebsd-launchd-mach so:

1.2 Non-goals (this iteration)

1.3 What Apple has open source vs. what we add

PieceApple sourceStrategy
Precedence-based hostname decision (prefs > DHCP > reverse-DNS PTR > mDNS > localhost) Plugins/IPMonitor/set-hostname.c (~500 LOC, APSL 2.0) Vendor in iter 3. Apple-canonical. DHCP option 12 + PTR lookup come along for free.
sethostname(2) + notify_post("com.apple.system.hostname") same file Vendor as part of iter 3. iter 1 calls sethostname(2) + the same notify_post directly — tiny enough to clean-room until iter 3 lands.
SCDynamicStore key paths + key-creation helpers (SCDynamicStoreKeyCreateComputerName, SCDynamicStoreKeyCreateHostNames) SystemConfiguration.fproj/SCDHostName.c + the SCSchemaDefinitions constants Already vendored as part of our libSystemConfiguration. iter 1 calls the existing helpers — Setup:/System and Setup:/Network/HostNames.
LocalHostName validation (RFC 1035 LDH, reject dots, length cap) SCDHostName.c uses _SC_CFStringIsValidDNSName() Reuse from libSystemConfiguration if exported; else copy the ~30 LOC helper directly (APSL 2.0). Avoids a buggy clean-room sanitizer.
SCPreferences ComputerName read _SCPreferencesCopyComputerName() in SCDHostName.c Reuse via existing libSystemConfiguration. iter 2 calls it. Sees through preferences.plist.
SCPreferences ComputerName write (System Preferences-side) SCPreferencesSetComputerName() in SCDHostName.c Reuse. iter 4 (Bonjour conflict feedback) needs the write side.
PreferencesMonitor plugin — bridges preferences.plist → SCDynamicStore Setup: keys Plugins/PreferencesMonitor/ Skip. Apple's PreferencesMonitor is a configd plugin we'd need a plugin loader for. iter 1's hostnamed writes the Setup: keys directly, bypassing this layer — one daemon does the synthesis + publish + sethostname.
Synthesize default name from hardware identity (model + serial / MAC) Setup Assistant — closed source. Clean-room. ~150 LOC C against kenv(2) + getifaddrs(3) + sysctl kern.hostuuid. This is the "Setup Assistant equivalent" piece. Detailed in §5 below.

2. Repository layout

Monorepo. New directory src/hostnamed/ alongside the other Mach-track daemons:

freebsd-launchd-mach/
├── src/
│   ├── launchd/
│   ├── configd/
│   ├── hwregd/
│   ├── IPConfiguration/
│   ├── DiskArbitration/
│   ├── mDNSResponder/
│   ├── hostnamed/                                              <-- this plan
│   │   ├── hostnamed.c                ~400 LOC, the whole daemon
│   │   ├── Makefile                   BSD bsd.prog.mk
│   │   └── hostnametest.c             smoke-test client (read SCDynamicStore keys)
│   ├── libSystemConfiguration/        already shipped (SCDynamicStore client API)
│   └── ...
├── overlays/
│   └── System/Library/LaunchDaemons/
│       └── com.apple.hostnamed.plist                            <-- new
└── overlays/usr/tests/freebsd-launchd-mach/
    └── run.sh                                                    <-- new HOSTNAMED-OK marker

No new pkg deps. Builds against base FreeBSD libc + the in-tree libSystemConfiguration + libCoreFoundation.

3. Apple's three-name model

macOS tracks three related but distinct names, all owned by configd. We copy this shape verbatim because mDNSResponder is already coded to it:

NamePurposeWhere it lands on macOSWhere it lands on us
ComputerName Human-readable; shown in About This Mac, Sharing prefs, Bonjour service names. Allows spaces, mixed case, apostrophes (John's MacBook Pro). preferences.plistSystemSystemComputerName; SCDynamicStore Setup:/System mirror. iter 1: synthesized only, published to SCDynamicStore Setup:/System/ComputerName. iter 2: also read from SCPreferences if present.
LocalHostName The Bonjour .local name. RFC 1035 LDH-safe (letters / digits / hyphens; no leading or trailing hyphen; ≤63 chars). Derived from ComputerName by sanitization. SCDynamicStore State:/Network/HostNamesLocalHostName. Same key, written by hostnamed via SCDynamicStoreSetValue.
HostName BSD-style FQDN; often unset on consumer Macs. Becomes kern.hostname — what hostname(1) prints, what gethostname(3) returns. sysctlbyname("kern.hostname", …); SCDynamicStore State:/Network/HostNamesHostName. Same: sysctlbyname + SCDynamicStore write.

For a synthesized name like ThinkPad-T420-8AB123 all three end up identical — the name is already DNS-safe so the LDH sanitization is a no-op. For a user-set name like John's MacBook Pro (spaces and apostrophe), ComputerName keeps them and the other two become Johns-MacBook-Pro.

4. Architecture

+------------------------------------+ | /boot/loader.conf.d/local.conf | | hostname.override="..." (opt) | +------------------+-----------------+ | | kenv(2) v +------------------------------------+ +-----------------------+ | hostnamed (iter 1) | | configd | | one-shot, exits after publish | | com.apple.System | | | | Configuration | | 1. read kenv hostname.override | | | | 2. read kenv smbios.system.* | | SCDynamicStore: | | 3. read getifaddrs(AF_LINK) |--MIG--->| Setup:/System | | 4. read sysctl kern.hostuuid | RPC | ComputerName | | | | ComputerName- | | 5. synthesize name (3-tier) | | Encoding | | | | Setup:/Network/ | | 6. SCDynamicStoreSetValue: | | HostNames | | Setup:/System, | | LocalHostName | | Setup:/Network/HostNames | | HostName | | (libSystemConfiguration; uses | | | | SCDynamicStoreKeyCreate- | +-----------+-----------+ | ComputerName + ...HostNames) | | | | | notify | 7. sethostname(2) [POSIX] | v | [sysctlbyname("kern.hostname") | +-----------+-----------+ | is equivalent on FreeBSD; | | notifyd | | sethostname is what Apple's | | (Phase J2 iter 1) | | set-hostname.c calls so | | | | we match the syscall path] | | notify_post("com. | | |--notify-> apple.system. | | 8. notify_post("com.apple.system. | | hostname") | | hostname") [Apple-canonical | +-----------+-----------+ | hostname-change broadcast] | | | | | dispatch | 9. log + emit HOSTNAMED-OK marker | v | | +-----------+-----------+ | 10. exit(0) | | mDNSResponder | +------------------------------------+ | (iter 4b subscriber, | | re-announces | | .local on change) | +-----------------------+

iter 1 is a one-shot because synthesis inputs are persistent (factory hardware identity), so re-running synthesis on every boot produces the same name. There's nothing to watch until iter 2 / 3 / 4 add SCPreferences, DHCP-option-12, and Bonjour-conflict event sources. iter 3 promotes hostnamed to KeepAlive=true when the vendored set-hostname.c decision engine arrives — it needs to subscribe to SCDynamicStore notifications for primary-service changes, DHCP-lease changes, and reverse-DNS query callbacks.

Two Apple-canonical syscalls on the publish path:

  1. sethostname(2) — what Apple's set-hostname.c calls. On FreeBSD, sethostname(2) internally writes kern.hostname, so the visible effect is identical. We pick the syscall Apple uses for fidelity.
  2. notify_post("com.apple.system.hostname") — the documented hostname-change broadcast. Goes through our notifyd (already shipped, Phase J2). Apple-canonical subscribers (mDNSResponder, anyone else) listen via notify_register_* for this key and re-fetch the new value.

5. Synthesis algorithm

Three precedence tiers, highest wins. Stops at the first tier that yields a non-empty name.

5.1 Tier 1 — kenv hostname.override

If kenv(2) returns a non-empty value for the key hostname.override, use it verbatim. This is the "Setup Assistant pre-populated the name" equivalent — set in /boot/loader.conf or /boot/loader.conf.d/local.conf at install time:

hostname.override="my-t420"

Bypasses synthesis entirely; LDH sanitization still applied for LocalHostName / HostName. Whitelist for characters allowed in the override: [A-Za-z0-9 _.\-]{1,253} (UTF-8 letters allowed in ComputerName only; sanitized out of the LDH names).

5.2 Tier 2 — SCPreferences ComputerName iter 2+

Not in iter 1. Once iter 2 lands, hostnamed opens preferences.plist via SCPreferences, checks SystemSystemComputerName. If present, use it verbatim (sanitize for the other two names) and skip Tier 3. Matches Apple's user-set name beats synthesis behavior.

5.3 Tier 3 — synthesize slug-suffix

Default path. Compose ${slug}-${suffix} from hardware identity:

5.3.1 slug — the model part

First non-empty of:

  1. kenv smbios.system.version — this is the human-readable model on most laptops. ThinkPad T420 shows "ThinkPad T420" here. Apple shows "MacBookPro16,1"; Dell shows "Latitude E7470"; ASUS shows "ROG STRIX G15 G513QY_G513QY".
  2. kenv smbios.system.product — the SKU / MTM string. Lenovo's MTM is "4236AB1", Apple's is "Mac-mini". Used as fallback when .version is empty / placeholder.
  3. literal "freebsd" — final fallback (no SMBIOS data at all; VMs or weird embedded boards).

Sanitization: trim whitespace, replace runs of whitespace with single -, strip anything not in [A-Za-z0-9-], collapse double-hyphens, trim leading / trailing -, truncate to 40 chars. The result: "ThinkPad T420""ThinkPad-T420".

5.3.2 suffix — the per-machine salt

First non-empty of:

  1. Last 6 alphanumeric chars of kenv smbios.system.serial. Skip if value matches any of the well-known empty-firmware placeholders: "", "None", "To be filled by O.E.M.", "Default string", "0", "0123456789", "System Serial Number". Lenovo serials always pass (factory-set, 7-char alphanumerics like R8AB123; suffix = last 6 = 8AB123).
  2. Last 6 hex chars of the primary NIC MAC. Picked by walking getifaddrs(AF_LINK) for the first non-loopback Ethernet (IFT_ETHER, MAC != 00:00:00:00:00:00). em0 / re0 / bge0 / etc.
  3. 6 hex chars from the first segment of sysctl kern.hostuuid. Always exists (the kernel synthesizes a v4 UUID on first boot if absent and persists it). Final fallback.

5.3.3 Composition

Concatenate as "${slug}-${suffix}"; truncate composite to 63 chars (the RFC 1035 label cap that LocalHostName needs anyway). Empty slug + non-empty suffix"freebsd-${suffix}". Both empty (impossible in practice) → literal "freebsd".

5.3.4 Worked examples

Machinesmbios.system.versionsmbios.system.serialSynthesized name
ThinkPad T420 #1ThinkPad T420R8AB123ThinkPad-T420-8AB123
ThinkPad T420 #2ThinkPad T420R7CD456ThinkPad-T420-7CD456
Mac mini 2018Macmini8,1C07XXXX1234Macmini81-XXX1234Macmini81-X1234 (last 6)
Dell Latitude E7470Latitude E7470CXYZ123Latitude-E7470-YZ123 (last 6 of CXYZ123 = XYZ123... wait, 6 chars = XYZ123; Latitude-E7470-XYZ123)
QEMU/SLIRP (CI)Standard PC (Q35 + ICH9, 2009)Not Specified (skipped)Standard-PC-Q35--ICH9-2009-345678 (NIC suffix from 52:54:00:12:34:56) → truncated to 63
Bare board w/ no NIC, no SMBIOSfreebsd-${hostuuid_first6}

6. Predicting the name without booting

Three answers in order of effort — the user shouldn't need to plug in a monitor to find their machine on the LAN.

  1. Read the sticker. Every Lenovo / Dell / HP enterprise laptop has a bottom-panel sticker with the factory serial. The synthesized name's suffix is the last 6 alphanumeric chars of that serial. For a T420 with S/N: R8AB123, the name is ThinkPad-T420-8AB123. Predictable standing in the room with the laptop.
  2. Boot once, look at the console. The first boot prints:
    hostnamed 2026-MM-DDTHH:MM:SSZ ComputerName: ThinkPad-T420-8AB123
    hostnamed 2026-MM-DDTHH:MM:SSZ   source: smbios.system.version="ThinkPad T420" smbios.system.serial="R8AB123"
    hostnamed 2026-MM-DDTHH:MM:SSZ   override via: kenv hostname.override="<name>" in /boot/loader.conf
    and the getty login banner becomes FreeBSD/amd64 (ThinkPad-T420-8AB123). The synthesis is deterministic so the name is the same on subsequent boots.
  3. Override before first boot. Mount the disk image, edit /boot/loader.conf.d/local.conf:
    hostname.override="my-t420"
    Tier 1 wins, synthesis doesn't run. Same effect as Apple's Setup-Assistant pre-fill.

7. SCDynamicStore key shape

iter 1 writes exactly two SCDynamicStore keys via SCDynamicStoreSetValue, plus the sethostname(2) + notify_post() pair. The key paths are constructed using Apple's own helpers from libSystemConfiguration (SCDynamicStoreKeyCreateComputerName, SCDynamicStoreKeyCreateHostNames), so they're guaranteed to match what mDNSResponder's subscriber code expects without us hard-coding paths.

Key (path resolves to)TypeValue
Setup:/System — built by SCDynamicStoreKeyCreateComputerName() as "/${kSCDynamicStoreDomainSetup}/${kSCCompSystem}" CFDictionary
{
  "ComputerName"         = "ThinkPad-T420-8AB123";
  "ComputerNameEncoding" = 134217984;  // kCFStringEncodingUTF8
}
Setup:/Network/HostNames — built by SCDynamicStoreKeyCreateHostNames() as "/${kSCDynamicStoreDomainSetup}/${kSCCompNetwork}/${kSCCompHostNames}" CFDictionary
{
  "HostName"      = "ThinkPad-T420-8AB123";
  "LocalHostName" = "ThinkPad-T420-8AB123";
}
(kern.hostnamenot SCDynamicStore, set via libc) kernel string sethostname("ThinkPad-T420-8AB123", 20). Apple's set-hostname.c uses this syscall; it ends up at kern.hostname internally. We match Apple's choice of sethostname(2) over sysctlbyname("kern.hostname", …) for fidelity — visible effect is identical.
(com.apple.system.hostnamenot SCDynamicStore, notifyd broadcast) notify token notify_post("com.apple.system.hostname"). This is the documented Apple-canonical hostname-change broadcast; mDNSResponder + future SCDHostName-aware consumers re-fetch when they see this token fire.

Note: Apple uses the Setup: domain (not State:) for both these keys — double-checked against SCDHostName.c source. Setup: is the "intended / persistent configuration" domain that PreferencesMonitor normally writes after reading preferences.plist; we bypass PreferencesMonitor and write directly because the read-from-prefs path is iter 2, and iter 1 is solely the synthesis-fallback role.

Notification semantics: SCDynamicStoreSetValue on the existing configd session sends the standard SCDynamicStore notification on any key that's subscribed via SCDynamicStoreSetNotificationKeys. The notify_post is the Apple-canonical higher-level signal for the same event — both fire from iter 1 because Apple's set-hostname.c does both, and we want subscribers to be able to use whichever notification surface fits them.

The visible-on-the-console "I am <name>" banner has three layers:

Deferring the "getty re-renders on hostname change" wiring is fine because synthesis is deterministic across reboots — the user sees the right name on the second boot regardless of any first-boot getty race.

9. Iteration plan

iter 1 — clean-room synthesis + Apple-shape publish clean-room daemon body + reuses libSystemConfiguration helpers

iter 2 — SCPreferences read awareness reuses libSystemConfiguration's _SCPreferencesCopyComputerName

iter 3 — vendor Apple's set-hostname.c as the decision engine vendor + freebsd-shim layer

iter 4 — Bonjour-conflict feedback SCPreferences write side

10. CI marker shape

QEMU/SLIRP has no SMBIOS-meaningful data — smbios.system.version = "Standard PC (Q35 + ICH9, 2009)", smbios.system.serial = "Not Specified" (skipped per the placeholder filter). So in CI the synthesis falls through to the MAC-suffix tier. The em0 MAC under QEMU SLIRP is the deterministic 52:54:00:12:34:56, so the expected CI name suffix is 123456.

Expected CI marker:

hostnamed 2026-MM-DDTHH:MM:SSZ ComputerName: Standard-PC-Q35--ICH9-2009-123456
hostnamed 2026-MM-DDTHH:MM:SSZ HOSTNAMED-OK

The CI test verifies the name matches the regex [A-Za-z0-9-]+-[0-9a-f]{6} rather than pinning the exact string — the slug derivation may evolve and we don't want CI to fight that. The regex enforces the load-bearing property: a non-empty slug, a hyphen separator, a 6-hex per-machine suffix.

Verifier checks in run.sh:

  1. $(hostname) equals $(grep '^hostnamed.*ComputerName:' /var/log/hostnamed.stderr | sed 's/.*ComputerName: //')
  2. A tiny client (hostnametest, reuses configd's MIG client patterns) reads State:/Network/HostNames and confirms LocalHostName + HostName are populated and DNS-safe.
  3. Boot log shows FreeBSD/amd64 (<synthesized>) not (Amnesiac) on the second-and-later getty prompt.

11. Open questions for review before iter 1 lands

Q1. Daemon shape for iter 1: one-shot that exits (the plan as written) vs. KeepAlive=true that sits forever. One-shot is cleaner now; KeepAlive is what iter 3 (vendored decision engine) needs anyway. Going one-shot first means a small refactor at iter 3 (mostly just adding the dispatch event loop around the existing call). OK with the small refactor, or skip straight to KeepAlive at iter 1?
Q2. Slug source preference: smbios.system.version wins by default. For Apple hardware where .version shows "Macmini8,1" and .product shows "Mac-mini", the user-friendly name is .product. Worth swapping the precedence, or carrying a small per-vendor table (vendor=="Apple Inc." ? .product : .version)?
Q3. Suffix source: smbios.system.serial wins over MAC. Serial is more stable (NIC swap doesn't change it) but some firmwares ship garbage / blank serials. The placeholder filter handles the well-known offenders, but there's a long tail. Are we OK with the fallback chain (serial → MAC → kern.hostuuid), or should we use the MAC unconditionally for predictability ("the sticker has the MAC")?
Q4. Boot banner: should iter 1 write to /dev/console directly so the synthesized name appears in the boot transcript even if getty's first prompt races, or is the stderr log + getty's eventual respawn enough?
Q5. Vendoring scope for iter 3: full file as-is (~500 LOC, preserve APSL 2.0 header) vs. cherry-pick the precedence skeleton + DHCP option 12 logic into a smaller adapted file. Vendor verbatim is the plan as written — tracks Apple's bug fixes / behavior changes for free at next sync; the only modifications are the freebsd-shim redirections at the call boundary. Confirm this is the right call vs. a forked-and-rewritten approach.
Q6. Synthesis vs. Apple's "localhost" final fallback: Apple's set-hostname.c falls back to "localhost" if no prefs / DHCP / PTR / mDNS name is available. Our iter 3 inserts synthesized slug+suffix between mDNS and localhost — so the synthesized name only fires if every Apple-canonical source came up empty. Confirm this is the right insertion point (vs. "synthesized always wins as the last guaranteed-non-localhost source").
Q7. Where to source _SC_CFStringIsValidDNSName: reuse from libSystemConfiguration if it exports the symbol (it's a SPI; check with nm), else lift the ~30 LOC. The lift is trivial; the reuse means we don't drift from Apple's validation rules as they update. Default to "reuse if available, lift if not."