From e1a53f05164be4a861d2a5446bc85043c15bf763 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Wed, 29 Jul 2026 03:11:59 +0000 Subject: [PATCH] Keep Linux LXC DHCP state service-writable --- CHANGELOG.md | 16 +++++++- README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/1helm-lxc-net | 62 ++++++++++++++++++++++++++---- scripts/1helm-lxc-runtime | 2 +- site/public/install-lxc-runtime.sh | 4 +- src/server/channel-computers.ts | 2 +- src/server/db.ts | 2 +- test/channel-computers.mjs | 2 +- test/site.mjs | 9 ++++- 11 files changed, 88 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb4c498..0fecba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.26] - 2026-07-29 + +### Fixed + +- Linux LXC DHCP leases now live under 1Helm's dedicated writable runtime + state instead of the system-wide `/var/lib/misc` tree made read-only by the + hardened application service. Network recovery validates that exact lease + tree, the running dnsmasq DHCP contract, and the exact DNS, forwarding, and + outbound NAT rules before reporting a channel computer ready. +- Includes the retained-memory startup, Windows host-terminal and WSL + identity, Linux LXC recovery, and honest lifecycle/command corrections + prepared in `0.0.24` and `0.0.25`; neither version was tagged or published. + ## [0.0.25] - 2026-07-29 ### Fixed @@ -764,7 +777,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 notarization, stapled tickets, Gatekeeper verification, persistent Application Support, and isolated Apple container machines. -[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.25...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.26...HEAD +[0.0.26]: https://github.com/gitcommit90/1Helm/compare/v0.0.23...v0.0.26 [0.0.25]: https://github.com/gitcommit90/1Helm/compare/v0.0.23...v0.0.25 [0.0.24]: https://github.com/gitcommit90/1Helm/compare/v0.0.23...v0.0.24 [0.0.23]: https://github.com/gitcommit90/1Helm/compare/v0.0.22...v0.0.23 diff --git a/README.md b/README.md index af26ace..1f2fa4f 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.25` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.26` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/package-lock.json b/package-lock.json index 80d6515..aaf8faa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.25", + "version": "0.0.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.25", + "version": "0.0.26", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index a0fbe63..f9c2ece 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.25", + "version": "0.0.26", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/scripts/1helm-lxc-net b/scripts/1helm-lxc-net index 4d6c8e1..1978230 100755 --- a/scripts/1helm-lxc-net +++ b/scripts/1helm-lxc-net @@ -8,12 +8,37 @@ set -euo pipefail MARKER="/run/1helm-lxc-net-owned" RULE_MARKER="/run/1helm-lxc-net-rules-owned" DNSMASQ_PID="/run/lxc/dnsmasq.pid" +DNSMASQ_STATE="/var/lib/1helm-lxc/network" +DNSMASQ_LEASE="$DNSMASQ_STATE/misc/dnsmasq.lxcbr0.leases" BRIDGE="lxcbr0" BRIDGE_CIDR="10.0.3.1/24" +BRIDGE_MAC="00:16:3e:00:00:00" LXC_NET="" for candidate in /usr/libexec/lxc/lxc-net /usr/lib/lxc/lxc-net; do [[ -x "$candidate" ]] && LXC_NET="$candidate" && break; done [[ -n "$LXC_NET" ]] || { echo "lxc-net is unavailable" >&2; exit 1; } +start_bridge_dns() { + local dnsmasq_user + install -d -m 0755 /run/lxc "$DNSMASQ_STATE" "$DNSMASQ_STATE/misc" + lease_state_writable || { echo "The private dnsmasq lease directory is not writable." >&2; return 1; } + if [[ ! -d "/sys/class/net/$BRIDGE" ]]; then + ip link add dev "$BRIDGE" type bridge + fi + ip link set dev "$BRIDGE" address "$BRIDGE_MAC" + ip addr replace "$BRIDGE_CIDR" broadcast + dev "$BRIDGE" + ip link set dev "$BRIDGE" up + for dnsmasq_user in lxc-dnsmasq dnsmasq nobody; do + getent passwd "$dnsmasq_user" >/dev/null && break + done + dnsmasq_user="${dnsmasq_user:-nobody}" + dnsmasq --conf-file=/dev/null -u "$dnsmasq_user" --strict-order --bind-interfaces \ + --pid-file="$DNSMASQ_PID" --listen-address 10.0.3.1 \ + --dhcp-range 10.0.3.2,10.0.3.254 --dhcp-lease-max=253 --dhcp-no-override \ + --except-interface=lo --interface=lxcbr0 --dhcp-leasefile="$DNSMASQ_LEASE" \ + --dhcp-authoritative + install -m 0600 /dev/null /run/lxc/network_up +} + install_rules() { sysctl -q -w net.ipv4.ip_forward=1 >/dev/null if [[ -e "$RULE_MARKER" ]]; then @@ -64,19 +89,42 @@ bridge_dns_healthy() { grep -Fxq -- '--listen-address=10.0.3.1' <<<"$command_line" || return 1 fi grep -Fxq -- '--interface=lxcbr0' <<<"$command_line" || return 1 + grep -Fxq -- '--dhcp-range' <<<"$command_line" || return 1 + grep -Fxq '10.0.3.2,10.0.3.254' <<<"$command_line" || return 1 + grep -Fxq -- '--dhcp-lease-max=253' <<<"$command_line" || return 1 + grep -Fxq -- '--dhcp-authoritative' <<<"$command_line" || return 1 + grep -Fxq -- "--dhcp-leasefile=$DNSMASQ_LEASE" <<<"$command_line" || return 1 +} + +lease_state_writable() { + local probe + [[ -d "$DNSMASQ_STATE/misc" ]] || return 1 + probe="$(mktemp "$DNSMASQ_STATE/misc/.1helm-write-check.XXXXXX")" || return 1 + rm -f -- "$probe" +} + +rules_healthy() { + [[ -e "$RULE_MARKER" ]] || return 1 + nft list chain inet onehelm_lxc input 2>/dev/null \ + | grep -Eq 'iifname "lxcbr0" udp dport \{ 53, 67 \} accept' || return 1 + nft list chain inet onehelm_lxc input 2>/dev/null \ + | grep -Eq 'iifname "lxcbr0" tcp dport \{ 53, 67 \} accept' || return 1 + nft list chain inet onehelm_lxc forward 2>/dev/null \ + | grep -Fq 'iifname "lxcbr0" accept' || return 1 + nft list chain inet onehelm_lxc forward 2>/dev/null \ + | grep -Eq 'oifname "lxcbr0" ct state (established,related|related,established) accept' || return 1 + nft list chain ip onehelm_lxc postrouting 2>/dev/null \ + | grep -Eq 'ip saddr 10\.0\.3\.0/24 ip daddr != 10\.0\.3\.0/24 masquerade' || return 1 } network_healthy() { + lease_state_writable || return 1 bridge_dns_healthy || return 1 - [[ -e "$RULE_MARKER" ]] || return 1 - nft list table inet onehelm_lxc >/dev/null 2>&1 || return 1 - nft list table ip onehelm_lxc >/dev/null 2>&1 || return 1 + rules_healthy || return 1 } ensure_rules() { - if [[ -e "$RULE_MARKER" ]] \ - && nft list table inet onehelm_lxc >/dev/null 2>&1 \ - && nft list table ip onehelm_lxc >/dev/null 2>&1; then + if rules_healthy; then return fi install_rules @@ -96,7 +144,7 @@ case "${1:-}" in # its bridge loses its address. Stop only lxc-net's exact private bridge, # then rebuild it instead of adopting the stale interface by name alone. "$LXC_NET" stop force >/dev/null 2>&1 || true - "$LXC_NET" start + start_bridge_dns install -m 0600 /dev/null "$MARKER" ensure_rules network_healthy || { echo "lxcbr0 bridge, DNS, DHCP, or NAT did not become healthy" >&2; exit 1; } diff --git a/scripts/1helm-lxc-runtime b/scripts/1helm-lxc-runtime index 7459a10..e523d59 100755 --- a/scripts/1helm-lxc-runtime +++ b/scripts/1helm-lxc-runtime @@ -96,7 +96,7 @@ ensure_network() { } remove_incomplete() { local name="$1" current - [[ -e "$(config_path "$name")" || -e "$LXC_PATH/$name" ]] || return + [[ -e "$(config_path "$name")" || -e "$LXC_PATH/$name" ]] || return 0 if [[ ! -f "$(config_path "$name")" ]]; then rm -rf -- "$LXC_PATH/$name" return diff --git a/site/public/install-lxc-runtime.sh b/site/public/install-lxc-runtime.sh index 74af4da..80d7d24 100755 --- a/site/public/install-lxc-runtime.sh +++ b/site/public/install-lxc-runtime.sh @@ -12,6 +12,7 @@ RUNTIME_ROOT="$INSTALL_ROOT/runtime/lxc" LXC_ROOT="/var/lib/1helm-lxc" LXC_PATH="$LXC_ROOT/containers" CACHE_BASE="/var/cache/1helm-lxc" +NETWORK_STATE="$LXC_ROOT/network" HELPER_PATH="/usr/libexec/1helm-lxc-runtime" NETWORK_HELPER_PATH="/usr/libexec/1helm-lxc-net" CONFIG_PATH="/etc/1helm/lxc-unprivileged.conf" @@ -104,6 +105,7 @@ python3 -c 'import ensurepip' >/dev/null 2>&1 || { echo "Python venv support is # computer is provisioned. install -d -o root -g root -m 0711 "$LXC_ROOT" "$LXC_PATH" install -d -o root -g root -m 0700 "$CACHE_BASE" +install -d -o root -g root -m 0755 "$NETWORK_STATE" "$NETWORK_STATE/misc" TEMP_ROOT="$(mktemp -d)" trap 'rm -rf -- "$TEMP_ROOT"' EXIT @@ -221,7 +223,7 @@ EOF install -m 0644 /dev/stdin /etc/systemd/system/1helm-lxc-net.service < { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.25"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.26"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/site.mjs b/test/site.mjs index 03fbc0a..d53a6db 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -178,7 +178,7 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(lxcInstaller, /namespace_covers_range[\s\S]*65536 65535/, "the installer negotiates a full bare-metal or safe nested-host subordinate ID range"); assert.match(lxcInstaller, /1helm-update\\\.service[\s\S]*systemd-run[\s\S]*apply-linux-release\.sh/, "a v0.0.11 updater hands the complete verified release transaction outside its obsolete read-only mount namespace"); assert.match(lxcInstaller, /MainPID[\s\S]*\/proc\/\$pid\/cgroup[\s\S]*\/opt\/1helm\/update-host\.sh[\s\S]*kill -KILL/, "a failed handoff stops only the exact legacy updater main process before its obsolete EXIT rollback can run"); - assert.match(lxcInstaller, /install -d[^\n]*"\$LXC_ROOT" "\$LXC_PATH"[\s\S]*install -d[^\n]*"\$CACHE_BASE"/, "the host creates every service ReadWritePaths root before starting 1Helm"); + assert.match(lxcInstaller, /install -d[^\n]*"\$LXC_ROOT" "\$LXC_PATH"[\s\S]*install -d[^\n]*"\$CACHE_BASE"[\s\S]*install -d[^\n]*"\$NETWORK_STATE" "\$NETWORK_STATE\/misc"/, "the host creates every service ReadWritePaths root and the private DHCP lease directory before starting 1Helm"); assert.match(linuxUnits, /1helm-update\\\.service[\s\S]*systemd-run[\s\S]*HELM_HOST_APPLY_DELEGATED/, "a v0.0.11 updater delegates the verified unit migration outside its obsolete mount namespace"); assert.match(linuxUnits, /ReadWritePaths=[^\n]*\/usr\/libexec(?:\s|$)[^\n]*\/etc\/default(?:\s|$)[^\n]*\/etc\/systemd\/system(?:\s|$)[^\n]*\/etc\/sudoers\.d(?:\s|$)/, "future updater transactions can atomically replace and roll back only the required host-contract parent trees"); assert.doesNotMatch(linuxUnits, /ReadWritePaths=[^\n]*\/usr\/libexec\/1helm-lxc-runtime/, "the updater no longer mistakes a writable destination file for atomic parent-directory authority"); @@ -196,12 +196,17 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(lxcHelper, /cpu_count/, "LXC inspection counts noncontiguous delegated CPU lists correctly"); assert.match(lxcNetwork, /1helm-lxc-net-owned/, "the bridge wrapper stops only a bridge it started"); assert.match(lxcNetwork, /1helm-lxc-net-rules-owned[\s\S]*table ip onehelm_lxc[\s\S]*masquerade/, "an adopted bridge receives a separately owned, removable outbound NAT contract"); + assert.match(lxcNetwork, /DNSMASQ_LEASE="\$DNSMASQ_STATE\/misc\/dnsmasq\.lxcbr0\.leases"[\s\S]*lease_state_writable[\s\S]*mktemp[\s\S]*bridge_dns_healthy/, "runtime health proves the exact private dnsmasq lease tree is writable from its current mount namespace"); + assert.match(lxcNetwork, /start_bridge_dns[\s\S]*dnsmasq[\s\S]*--dhcp-leasefile="\$DNSMASQ_LEASE"/, "1Helm starts its private DHCP server directly instead of inheriting the distro helper's system-wide lease path"); + assert.match(lxcNetwork, /--dhcp-range[\s\S]*10\.0\.3\.2,10\.0\.3\.254[\s\S]*--dhcp-lease-max=253[\s\S]*--dhcp-authoritative[\s\S]*--dhcp-leasefile=\$DNSMASQ_LEASE/, "runtime health verifies the exact DHCP and private lease-file process contract"); + assert.match(lxcNetwork, /rules_healthy[\s\S]*nft list chain inet onehelm_lxc input[\s\S]*nft list chain inet onehelm_lxc forward[\s\S]*nft list chain ip onehelm_lxc postrouting[\s\S]*10\\\.0\\\.3\\\.0\/24[\s\S]*masquerade/, "runtime health verifies the exact DNS, DHCP, forwarding, and outbound NAT rules instead of accepting table names alone"); assert.match(lxcNetwork, /BRIDGE_CIDR="10\.0\.3\.1\/24"[\s\S]*bridge_dns_healthy[\s\S]*state UP[\s\S]*DNSMASQ_PID[\s\S]*--interface=lxcbr0[\s\S]*network_healthy/, "runtime health requires an up/addressed bridge and its exact dnsmasq process"); assert.match(lxcNetwork, /bridge_dns_healthy[\s\S]*ensure_rules[\s\S]*network_healthy/, "a healthy bridge can restore only its owned firewall rules without disrupting containers"); - assert.match(lxcNetwork, /"\$LXC_NET" stop force[\s\S]*"\$LXC_NET" start[\s\S]*network_healthy/, "a dead private bridge/DNS stack is rebuilt and reverified instead of adopted by interface name"); + assert.match(lxcNetwork, /"\$LXC_NET" stop force[\s\S]*start_bridge_dns[\s\S]*network_healthy/, "a dead private bridge/DNS stack is rebuilt with 1Helm-owned DHCP state and reverified instead of adopted by interface name"); assert.match(lxcHelper, /ready\)[\s\S]*"\$NETWORK_HELPER" start[\s\S]*"\$NETWORK_HELPER" check/, "runtime readiness repairs and then verifies the full network contract"); assert.match(lxcHelper, /inspect\)[\s\S]*current_owner[\s\S]*printf 'null/, "inspection exposes only marker-less interrupted creates as safely rebuildable partial machines"); assert.match(lxcHelper, /remove_incomplete[\s\S]*marker[\s\S]*lxc-destroy[\s\S]*rm -rf -- "\$LXC_PATH\/\$name"/, "create recovers only the exact validated marker-less partial container"); + assert.match(lxcHelper, /remove_incomplete\(\)[\s\S]*\]\] \|\| return 0/, "a missing partial container is a successful no-op under the runtime's fail-closed shell mode"); assert.match(lxcInstaller, /systemctl enable --now 1helm-lxc-net\.service[\s\S]*"\$NETWORK_HELPER_PATH" start[\s\S]*"\$HELPER_PATH" ready/, "host installs and updates actively repair then verify the bridge contract without disrupting a healthy network"); assert.match(lxcHelper, /nameserver 10\.0\.3\.1[\s\S]*apt-get update/, "new guests have bridge DNS before package bootstrap"); assert.match(lxcConfig, /lxc\.apparmor\.profile = generated/);