Skip to content

Settings → IP/DNS and Hostname: editable DNS + per-host /etc/hosts editor

Synced read-only from /home/synnet/mirrors/synvirt-product/docs/superpowers/specs/2026-06-29-settings-dns-hosts-design.md. Edit at the source, not here.

Settings → IP/DNS and Hostname: editable DNS + per-host /etc/hosts editor

Section titled “Settings → IP/DNS and Hostname: editable DNS + per-host /etc/hosts editor”
  • Date: 2026-06-29
  • Status: Approved (design) — pending implementation plan
  • Owner crate(s): synvirt-network (data plane), synvirt-daemon (API), web-ux-v2 (UI)
  • Scope: per-host (no fleet fan-out)

While wiring an external migrator source (a vCenter at vcenter.synnet.mx) on synvirt-02 (10.10.26.12), discovery failed with a transport error (could not connect) because the name does not resolve: .12’s /etc/resolv.conf carries only 8.8.8.8 (the hardcoded default), and there is no internal record for vcenter.synnet.mx on any reachable resolver. Trying to fix this from the dashboard surfaced two real gaps:

  1. DNS is not editable in Settings. The Settings section is literally named “IP/DNS and Hostname” (web-ux-v2/src/views/SettingsView.vue:81, described as managing “DNS servers, and search domains”), and the plumbing already carries DNS — the draft and the apply payload both include dns (NetworkPanel.vue:67,124; ServicePortConfig.dns in api/network.ts:44). But the DNS <input> is missing from the form: the management-interface editor only renders Address / Gateway / VLAN / MTU (NetworkPanel.vue:265-296). The panel reads the current DNS and re-applies it unchanged, with no way to edit it.

  2. /etc/hosts has no surface anywhere in the running product — no API, no UI. Only the installer writes the 127.0.1.1 line once at install time (daemon/src/api/install.rs:1225, installer/src/apply/hostname.rs). There is no way to add a host entry like vcenter.synnet.mx → <IP> from the dashboard.

Goals

  • Make the per-management-interface DNS servers editable from Settings → IP/DNS and Hostname (frontend-only; backend already supports it).
  • Add a per-host /etc/hosts entries editor: model + materializer in synvirt-network, REST surface on the daemon, and a card in the same Settings panel.

Non-goals (explicitly out of scope)

  • Search domains (the VmKernel model has no field for them today; deferred as a separate extension — Domains= in the resolved drop-in + a new model field).
  • Fleet-wide fan-out of host entries (entries are per-host by decision).
  • Editing the system-owned lines of /etc/hosts (loopback etc.). We only own a delimited managed block.

VmKernel.dns: Vec<String> already exists (synvirt-network/src/spec/mod.rs:406) and the apply path (applyRowServicePortConfig → vSwitch service-port apply, commit-confirm + rollback) already round-trips dns. The only change is UI: add a DNS input to the management-interface grid in web-ux-v2/src/views/settings/NetworkPanel.vue (next to Address/Gateway/VLAN/ MTU), bound to draftOf(m.vswitch).dns with comma/space-separated text ↔ string[] conversion (the exact parse the ServicePortModal/VSwitchModal already use: value.split(/[,\s]+/).map(s=>s.trim()).filter(Boolean)). Dirty detection already compares d.dns.join(',') !== m.dns.join(',') (NetworkPanel.vue:102).

No backend, no API change.

Data model — new top-level field on NetworkYaml (synvirt-network/src/spec/mod.rs:27):

/// One managed entry in `/etc/hosts`: an address mapped to one or more names.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostEntry {
/// IPv4 or IPv6 literal.
pub ip: String,
/// One or more hostnames/aliases for this address (RFC 1123 each).
pub hostnames: Vec<String>,
/// Optional operator note, rendered as a trailing `# comment`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
// NetworkYaml gains:
// #[serde(default)] pub host_entries: Vec<HostEntry>,

#[serde(default)] keeps existing network.yaml files (no host_entries key) parsing unchanged.

Materializer — new module synvirt-network/src/hosts.rs, mirroring dns.rs:

pub fn apply_hosts(yaml: &NetworkYaml) -> Result<HostsApplyReport, HostsApplyError>;
  • Target file: /etc/hosts.

  • Rewrites only a delimited managed block; everything outside is preserved byte-for-byte:

    # >>> SYNVIRT MANAGED — do not edit by hand
    172.16.11.50 vcenter.synnet.mx vcenter # added via dashboard
    # <<< SYNVIRT MANAGED
  • Idempotent: regenerating with the same entries produces an identical file.

  • If the block is absent, append it at EOF; if host_entries is empty, remove the block (and its surrounding blank line) entirely.

  • Never touches loopback/system lines. Refuses to emit an entry whose hostname collides with localhost/the loopback names or the host’s own canonical name (validation, below).

  • Writes atomically (temp file in /etc + rename) to avoid a torn /etc/hosts.

Apply timing — two invocation points, same as DNS:

  1. Boot reconcile: call hosts::apply_hosts(&yaml) in synvirt-network/src/main.rs immediately after the existing dns::apply_dns call (main.rs:334), inside spawn_blocking. This makes the block boot-persistent.
  2. On-demand: when the operator saves from the UI, the daemon persists YAML via the controller and the controller materializes the block immediately, so resolution works without a reboot. This is a local file write only — it does not run OVS reconcile or touch L2/L3, so the nested-host bond-break risk does not apply.

Controller surfacesynvirt-network/src/controller.rs, following the hostname() / set_hostname() pair (controller.rs:966,974):

pub async fn host_entries(&self) -> Vec<HostEntry>;
pub async fn set_host_entries(&self, entries: Vec<HostEntry>) -> Result<(), NetworkError>;

set_host_entries validates, writes network.yaml via yaml_store, then calls hosts::apply_hosts on the new YAML.

Daemon API — extend the existing Settings → Network surface (daemon/src/settings/api/network.rs, mounted in settings/api/mod.rs):

  • GET /api/v1/settings/network — add host_entries: Vec<HostEntryView> to NetworkSettingsView (the view that already returns hostname + ipMGMT).
  • PUT /api/v1/settings/network/hosts — new route in settings/api/mod.rs; body { "entries": [ { "ip", "hostnames": [...], "comment"? } ] }. Handler validates and delegates to NetworkController::set_host_entries. Audited like the hostname PUT. operation_id = "put_settings_network_hosts".

Validation (in synvirt-network, surfaced as E_* codes):

  • ip parses as IpAddr (v4 or v6) — else E_HOST_ENTRY_BAD_IP.
  • each hostname passes is_rfc1123_hostname (spec/mod.rs:574) — else E_HOST_ENTRY_BAD_NAME.
  • at least one hostname per entry — else E_HOST_ENTRY_NO_NAME.
  • no entry may map a name to 127.0.0.1/::1 aliases reserved for loopback, or shadow the host’s own canonical hostname — else E_HOST_ENTRY_RESERVED_NAME.
  • duplicate (ip, hostname) pairs are de-duplicated (warning, not error).

Frontendweb-ux-v2:

  • New card “Host file entries” inside views/settings/NetworkPanel.vue (same panel as DNS/hostname — it is a name-resolution concern). An editable table: IP, hostnames (comma/space-separated), comment; add-row / delete-row; an Apply button with commit semantics + outcome banner.
  • Extend useHostNetwork composable + api/settings/network.ts with the host_entries read and the PUT .../hosts call. Map the new E_* codes in ERROR_MAP.
  • Regenerate openapi.snapshot.json after the endpoint lands and re-run generate:api:from-file (frozen-contract rule).
  • synvirt-network (hosts.rs) — primary risk, TDD here:
    • generates the managed block with correct formatting for v4 + v6 + multiple hostnames + comment;
    • preserves all lines outside the block byte-for-byte;
    • idempotent re-apply produces an identical file;
    • empty host_entries removes the block cleanly;
    • rejects invalid IP / invalid hostname / reserved-name collisions;
    • atomic write (no torn file on simulated failure).
  • controller: set_host_entries rejects invalid input and persists + materializes valid input (mirror set_hostname_rejects_invalid_and_persists_valid, controller/tests.rs:2190).
  • daemon: PUT /api/v1/settings/network/hosts validation + persistence; GET returns the entries.
  • web-ux-v2 (vitest): the new card parses comma/space input, computes dirty/apply state, and renders mapped errors; the DNS input round-trips string[] ↔ text.
Layer File Change
model synvirt-network/src/spec/mod.rs HostEntry struct + NetworkYaml.host_entries
materializer synvirt-network/src/hosts.rs (new) apply_hosts + managed-block render + validation
boot synvirt-network/src/main.rs call hosts::apply_hosts after dns::apply_dns (~:334)
controller synvirt-network/src/controller.rs host_entries() / set_host_entries()
lib synvirt-network/src/lib.rs pub mod hosts;
API daemon/src/settings/api/network.rs host_entries in view + put_hosts handler
API daemon/src/settings/api/mod.rs route PUT /v1/settings/network/hosts
UI web-ux-v2/.../settings/NetworkPanel.vue DNS input + Host-file-entries card
UI web-ux-v2/src/composables/useHostNetwork.ts host_entries read + PUT
UI web-ux-v2/src/api/settings/network.ts types + client call
contract daemon openapi + openapi.snapshot.json re-dump + regenerate TS

Build + commit lands autonomously. Fleet deploy (.11/.12/.13) is gated on an explicit go per the standing SEV-0 deploy discipline; this spec does not deploy. The first real use is adding vcenter.synnet.mx → <IP> on .12 once the editor ships (still pending the vCenter’s IP).

None blocking. Search domains intentionally deferred (§2). Reserved-name policy (loopback + own-hostname) is the one judgment call; implemented as a hard reject with a clear E_* message.