From c7dc6cbf651e9ff42f04efb45b2782a8660043de Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Wed, 29 Jul 2026 17:49:48 +0000 Subject: [PATCH 1/3] Fix fresh Linux LXC state path --- CHANGELOG.md | 14 +++++++++++++- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- site/public/install-lxc-runtime.sh | 4 ++-- site/public/install.sh | 2 +- src/server/channel-computers.ts | 2 +- src/server/db.ts | 2 +- test/channel-computers.mjs | 2 +- test/site.mjs | 5 ++++- 10 files changed, 27 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d69e98..f90e3b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.28] - 2026-07-29 + +### Fixed + +- Fresh Linux installs now create and use the same `/var/lib/1helm-lxc/machines` + runtime tree, so a newly created channel can provision, start, and enter its + private LXC computer instead of failing with a missing lifecycle path. + +- Includes the durable installer payload and API-quota corrections from the + superseded Linux-only `0.0.27` prerelease. + ## [0.0.27] - 2026-07-29 ### Fixed @@ -786,7 +797,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.27...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.28...HEAD +[0.0.28]: https://github.com/gitcommit90/1Helm/compare/v0.0.27...v0.0.28 [0.0.27]: https://github.com/gitcommit90/1Helm/compare/v0.0.26...v0.0.27 [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 diff --git a/README.md b/README.md index b0a8666..1456b8e 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.27` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.28` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/package-lock.json b/package-lock.json index 82ed353..29a4e1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.27", + "version": "0.0.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.27", + "version": "0.0.28", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 18396ed..8d9d35c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.27", + "version": "0.0.28", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/site/public/install-lxc-runtime.sh b/site/public/install-lxc-runtime.sh index 821e4aa..5377852 100755 --- a/site/public/install-lxc-runtime.sh +++ b/site/public/install-lxc-runtime.sh @@ -10,7 +10,7 @@ APP_SOURCE="${1:-}" INSTALL_ROOT="/opt/1helm" RUNTIME_ROOT="$INSTALL_ROOT/runtime/lxc" LXC_ROOT="/var/lib/1helm-lxc" -LXC_PATH="$LXC_ROOT/containers" +LXC_PATH="$LXC_ROOT/machines" CACHE_BASE="/var/cache/1helm-lxc" NETWORK_STATE="$LXC_ROOT/network" HELPER_PATH="/usr/libexec/1helm-lxc-runtime" @@ -20,7 +20,7 @@ IDMAP_PATH="/etc/1helm/lxc-idmap" SUDOERS_PATH="/etc/sudoers.d/1helm-lxc-runtime" SERVICE_USER="1helm" IMAGE_BUILD="20260726_07:42" -IMAGE_RELEASE="0.0.27" +IMAGE_RELEASE="0.0.28" # v0.0.11's updater unit made the exact destination files writable under # ProtectSystem=strict. Atomic replacement still requires write access to each diff --git a/site/public/install.sh b/site/public/install.sh index 8006215..c20b5d1 100644 --- a/site/public/install.sh +++ b/site/public/install.sh @@ -10,7 +10,7 @@ NODE_LINK="$INSTALL_ROOT/node-current" STATE_ROOT="/var/lib/1helm" SERVICE_USER="1helm" NODE_VERSION="22.23.1" -RELEASE_VERSION="0.0.27" +RELEASE_VERSION="0.0.28" HOST_CONTRACT_PATHS=( /usr/libexec/1helm-lxc-runtime /usr/libexec/1helm-lxc-net diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 791396e..df1075b 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -67,7 +67,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.27"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.28"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const LXC_RUNTIME_VERSION = "1helm-lxc-runtime-v1"; const LXC_HELPER_CANDIDATES = [ diff --git a/src/server/db.ts b/src/server/db.ts index b71575a..80e59f8 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -1009,7 +1009,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "lxc", "wsl", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.27"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.28"); // Earlier Linux/Windows releases persisted the compatibility `native` // seam into every channel row. A production host update must actually // move those rows onto the platform isolation backend; changing the unit's diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index fbf48b0..5052c75 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -168,7 +168,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { 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.27"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.28"); 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 45df641..77bc7ee 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -140,7 +140,7 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(installer, /snapshot_host_contract[\s\S]*rollback_host_contract[\s\S]*TRANSACTION_ACTIVE/, "fresh and repeat installs restore runtime files and unit state after any transactional failure"); assert.match(installer, /rollback_host_contract[\s\S]*1helm\.service\.active[\s\S]*api\/setup\/status[\s\S]*restored_healthy/, "installer rollback verifies the restored service before claiming recovery"); assert.match(installer, /NODE_VERSION="22\.23\.1"/); - assert.match(installer, /RELEASE_VERSION="0\.0\.27"/, "fresh installs use the deliberately published Linux release instead of a rate-limited API lookup"); + assert.match(installer, /RELEASE_VERSION="0\.0\.28"/, "fresh installs use the deliberately published Linux release instead of a rate-limited API lookup"); assert.doesNotMatch(installer, /api\.github\.com/, "fresh installs do not depend on unauthenticated GitHub API quota"); assert.match(installer, /need=\([^\n]*flock[^\n]*make[^\n]*c\+\+[^\n]*python3[^\n]*\)/, "the host updater and native dependency toolchain are probed even when download prerequisites already exist"); assert.match(installer, /import ensurepip[\s\S]*python3-venv/, "the Linux host installs Python's venv support required by durable memory instead of accepting a python3 executable alone"); @@ -172,6 +172,9 @@ test("installer assets are explicit and syntax-valid", () => { const lxcNetwork = readFileSync(`${root}/scripts/1helm-lxc-net`, "utf8"); const lxcConfig = readFileSync(`${root}/deploy/1helm-lxc-unprivileged.conf`, "utf8"); const uninstaller = readFileSync(`${root}/site/public/uninstall-host.sh`, "utf8"); + assert.match(lxcInstaller, /LXC_PATH="\$LXC_ROOT\/machines"/, "installation and the runtime use the same LXC state directory"); + assert.match(lxcHelper, /LXC_PATH="\$LXC_ROOT\/machines"/, "the runtime reads the installer-created LXC state directory"); + assert.doesNotMatch(lxcInstaller, /LXC_ROOT\/containers/, "the obsolete mismatched LXC state directory is not installed"); assert.match(lxcInstaller, /20260726_07:42/); assert.match(lxcInstaller, /9c23724d6d22b3a5adf5d0f79d7e3779ded16a6d45f928bce93e14c48113d955/); assert.match(lxcInstaller, /d5351325dc23e344c4974d7ff546e5e0c91b8e47a9caeb26f39cdc60eaad19e8/); From 717fa868ecf436b9b19c3e4273935595706d97f8 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Wed, 29 Jul 2026 18:00:53 +0000 Subject: [PATCH 2/3] Wait for LXC guest networking before bootstrap --- CHANGELOG.md | 10 +++++++--- scripts/1helm-lxc-runtime | 27 ++++++++++++++++++++++++--- test/site.mjs | 3 +++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f90e3b4..020ef0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fresh Linux installs now create and use the same `/var/lib/1helm-lxc/machines` - runtime tree, so a newly created channel can provision, start, and enter its - private LXC computer instead of failing with a missing lifecycle path. +- Fresh Linux channel provisioning now waits for the new LXC guest to acquire + its private DHCP address, default route, and working DNS before package + bootstrap. A channel no longer repeatedly creates and cleans up its computer + after starting `apt` during the guest-network race. + +- The host installer now prepares the same `/var/lib/1helm-lxc/machines` tree + used by the runtime instead of leaving an obsolete empty `containers` tree. - Includes the durable installer payload and API-quota corrections from the superseded Linux-only `0.0.27` prerelease. diff --git a/scripts/1helm-lxc-runtime b/scripts/1helm-lxc-runtime index 917f842..6d464a5 100755 --- a/scripts/1helm-lxc-runtime +++ b/scripts/1helm-lxc-runtime @@ -76,6 +76,28 @@ start() { done die "container did not start" } +wait_for_guest_network() { + local name="$1" attempt + for attempt in {1..300}; do + if lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/sh -c ' + ip -4 -o addr show dev eth0 scope global | grep -q " inet 10\.0\.3\." \ + && ip -4 route show default | grep -q " via 10\.0\.3\.1 dev eth0" + ' >/dev/null 2>&1; then + lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/sh -c ' + rm -f /etc/resolv.conf + printf "nameserver 10.0.3.1\n" >/etc/resolv.conf + ' + if lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/sh -c ' + getent ahostsv4 archive.ubuntu.com >/dev/null \ + && getent ahostsv4 security.ubuntu.com >/dev/null + ' >/dev/null 2>&1; then + return + fi + fi + sleep 0.2 + done + die "container started but its private DHCP route and DNS did not become ready" +} marker() { local name="$1" current if [[ "$(state "$name")" == "RUNNING" ]]; then @@ -224,11 +246,10 @@ case "$operation" in cpu_set="$(select_cpus "$cpus")" printf '\nlxc.cgroup2.cpuset.cpus = %s\nlxc.cgroup2.memory.max = %s\n' "$cpu_set" "$((memory_mb * 1024 * 1024))" >>"$config" start "$name" + wait_for_guest_network "$name" lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/sh -eu -c ' export DEBIAN_FRONTEND=noninteractive - rm -f /etc/resolv.conf - printf "nameserver 10.0.3.1\n" >/etc/resolv.conf - apt-get update + apt-get update -o APT::Update::Error-Mode=any apt-get install -y --no-install-recommends bash build-essential ca-certificates coreutils cron curl dbus file findutils git gzip iproute2 iputils-ping jq less locales man-db nano openssh-client procps python3 python3-pip rsync sudo systemd systemd-sysv tar tzdata unzip vim-tiny wget xz-utils zip apt-get clean rm -rf /var/lib/apt/lists/* diff --git a/test/site.mjs b/test/site.mjs index 77bc7ee..629e602 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -199,6 +199,9 @@ test("installer assets are explicit and syntax-valid", () => { assert.doesNotMatch(hostMigration, /https?:\/\/(?!127\.0\.0\.1)|\beval\b|curl[^\n]*\|[^\n]*(?:sh|bash)/, "the privileged host migration never fetches or evaluates remote code"); assert.match(lxcHelper, /ownership marker does not match/); assert.match(lxcHelper, /cleanup_incomplete_create[\s\S]*rm -rf -- "\$LXC_PATH\/\$name"[\s\S]*created=1[\s\S]*lxc-create/, "a failed create removes only its validated partial container directory"); + assert.match(lxcHelper, /wait_for_guest_network[\s\S]*10\\\.0\\\.3\\\.[\s\S]*via 10\\\.0\\\.3\\\.1 dev eth0/, "fresh LXC provisioning waits for its DHCP address and default route"); + assert.match(lxcHelper, /archive\.ubuntu\.com[\s\S]*security\.ubuntu\.com/, "fresh LXC provisioning proves DNS before package bootstrap"); + assert.match(lxcHelper, /start "\$name"[\s\S]*wait_for_guest_network "\$name"[\s\S]*apt-get update -o APT::Update::Error-Mode=any/, "package bootstrap begins only after bounded guest-network readiness"); assert.match(lxcHelper, /cpuset\.cpus\.effective/, "LXC CPU limits are selected from the service's actually delegated host CPUs"); 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"); From 9abd1a2b50d14dd73817e2439987a072bbcff037 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Wed, 29 Jul 2026 18:11:00 +0000 Subject: [PATCH 3/3] Allow LXC traffic through host firewall --- CHANGELOG.md | 4 ++++ scripts/1helm-lxc-net | 48 ++++++++++++++++++++++++++------------- scripts/1helm-lxc-runtime | 8 ++++--- test/site.mjs | 9 ++++---- 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 020ef0f..257e381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 bootstrap. A channel no longer repeatedly creates and cleans up its computer after starting `apt` during the guest-network race. +- 1Helm's narrow owned LXC forwarding rules are inserted ahead of host firewall + policies such as Docker's `FORWARD=DROP`, allowing resident guests to reach + package mirrors without replacing Docker or Tailscale chains. + - The host installer now prepares the same `/var/lib/1helm-lxc/machines` tree used by the runtime instead of leaving an obsolete empty `containers` tree. diff --git a/scripts/1helm-lxc-net b/scripts/1helm-lxc-net index 1978230..4fb12ea 100755 --- a/scripts/1helm-lxc-net +++ b/scripts/1helm-lxc-net @@ -7,6 +7,8 @@ set -euo pipefail MARKER="/run/1helm-lxc-net-owned" RULE_MARKER="/run/1helm-lxc-net-rules-owned" +INPUT_CHAIN="ONEHELM_LXC_INPUT" +FORWARD_CHAIN="ONEHELM_LXC_FORWARD" DNSMASQ_PID="/run/lxc/dnsmasq.pid" DNSMASQ_STATE="/var/lib/1helm-lxc/network" DNSMASQ_LEASE="$DNSMASQ_STATE/misc/dnsmasq.lxcbr0.leases" @@ -42,20 +44,26 @@ start_bridge_dns() { install_rules() { sysctl -q -w net.ipv4.ip_forward=1 >/dev/null if [[ -e "$RULE_MARKER" ]]; then + remove_filter_rules nft delete table inet onehelm_lxc 2>/dev/null || true nft delete table ip onehelm_lxc 2>/dev/null || true - elif nft list table inet onehelm_lxc >/dev/null 2>&1 || nft list table ip onehelm_lxc >/dev/null 2>&1; then + elif iptables -S "$INPUT_CHAIN" >/dev/null 2>&1 || iptables -S "$FORWARD_CHAIN" >/dev/null 2>&1 \ + || nft list table inet onehelm_lxc >/dev/null 2>&1 || nft list table ip onehelm_lxc >/dev/null 2>&1; then echo "A non-1Helm nftables ruleset already uses the onehelm_lxc name." >&2 exit 1 fi + # Docker and other host software can set the real FORWARD chain policy to + # DROP. A parallel nftables base chain cannot override a later base-chain + # drop, so jump to 1Helm's exact owned rules before those host policies. + iptables -w -N "$INPUT_CHAIN" + iptables -w -A "$INPUT_CHAIN" -i "$BRIDGE" -p udp -m multiport --dports 53,67 -j ACCEPT + iptables -w -A "$INPUT_CHAIN" -i "$BRIDGE" -p tcp -m multiport --dports 53,67 -j ACCEPT + iptables -w -I INPUT 1 -j "$INPUT_CHAIN" + iptables -w -N "$FORWARD_CHAIN" + iptables -w -A "$FORWARD_CHAIN" -i "$BRIDGE" -j ACCEPT + iptables -w -A "$FORWARD_CHAIN" -o "$BRIDGE" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + iptables -w -I FORWARD 1 -j "$FORWARD_CHAIN" nft -f - <<'EOF' -add table inet onehelm_lxc -add chain inet onehelm_lxc input { type filter hook input priority -10; policy accept; } -add rule inet onehelm_lxc input iifname "lxcbr0" udp dport { 53, 67 } accept -add rule inet onehelm_lxc input iifname "lxcbr0" tcp dport { 53, 67 } accept -add chain inet onehelm_lxc forward { type filter hook forward priority -10; policy accept; } -add rule inet onehelm_lxc forward iifname "lxcbr0" accept -add rule inet onehelm_lxc forward oifname "lxcbr0" ct state established,related accept add table ip onehelm_lxc add chain ip onehelm_lxc postrouting { type nat hook postrouting priority srcnat; policy accept; } add rule ip onehelm_lxc postrouting ip saddr 10.0.3.0/24 ip daddr != 10.0.3.0/24 masquerade @@ -63,8 +71,18 @@ EOF install -m 0600 /dev/null "$RULE_MARKER" } +remove_filter_rules() { + while iptables -w -C INPUT -j "$INPUT_CHAIN" >/dev/null 2>&1; do iptables -w -D INPUT -j "$INPUT_CHAIN"; done + while iptables -w -C FORWARD -j "$FORWARD_CHAIN" >/dev/null 2>&1; do iptables -w -D FORWARD -j "$FORWARD_CHAIN"; done + iptables -w -F "$INPUT_CHAIN" 2>/dev/null || true + iptables -w -X "$INPUT_CHAIN" 2>/dev/null || true + iptables -w -F "$FORWARD_CHAIN" 2>/dev/null || true + iptables -w -X "$FORWARD_CHAIN" 2>/dev/null || true +} + remove_rules() { if [[ -e "$RULE_MARKER" ]]; then + remove_filter_rules nft delete table inet onehelm_lxc 2>/dev/null || true nft delete table ip onehelm_lxc 2>/dev/null || true rm -f -- "$RULE_MARKER" @@ -105,14 +123,12 @@ lease_state_writable() { 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 + [[ "$(iptables -S INPUT | sed -n '2p')" == "-A INPUT -j $INPUT_CHAIN" ]] || return 1 + [[ "$(iptables -S FORWARD | sed -n '2p')" == "-A FORWARD -j $FORWARD_CHAIN" ]] || return 1 + iptables -S "$INPUT_CHAIN" 2>/dev/null | grep -Fxq -- "-A $INPUT_CHAIN -i $BRIDGE -p udp -m multiport --dports 53,67 -j ACCEPT" || return 1 + iptables -S "$INPUT_CHAIN" 2>/dev/null | grep -Fxq -- "-A $INPUT_CHAIN -i $BRIDGE -p tcp -m multiport --dports 53,67 -j ACCEPT" || return 1 + iptables -S "$FORWARD_CHAIN" 2>/dev/null | grep -Fxq -- "-A $FORWARD_CHAIN -i $BRIDGE -j ACCEPT" || return 1 + iptables -S "$FORWARD_CHAIN" 2>/dev/null | grep -Fxq -- "-A $FORWARD_CHAIN -o $BRIDGE -m conntrack --ctstate RELATED,ESTABLISHED -j 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 } diff --git a/scripts/1helm-lxc-runtime b/scripts/1helm-lxc-runtime index 6d464a5..63f894c 100755 --- a/scripts/1helm-lxc-runtime +++ b/scripts/1helm-lxc-runtime @@ -89,7 +89,9 @@ wait_for_guest_network() { ' if lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/sh -c ' getent ahostsv4 archive.ubuntu.com >/dev/null \ - && getent ahostsv4 security.ubuntu.com >/dev/null + && getent ahostsv4 security.ubuntu.com >/dev/null \ + && curl -4 -fsS --connect-timeout 2 --max-time 5 \ + http://archive.ubuntu.com/ubuntu/dists/noble/InRelease >/dev/null ' >/dev/null 2>&1; then return fi @@ -249,8 +251,8 @@ case "$operation" in wait_for_guest_network "$name" lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/sh -eu -c ' export DEBIAN_FRONTEND=noninteractive - apt-get update -o APT::Update::Error-Mode=any - apt-get install -y --no-install-recommends bash build-essential ca-certificates coreutils cron curl dbus file findutils git gzip iproute2 iputils-ping jq less locales man-db nano openssh-client procps python3 python3-pip rsync sudo systemd systemd-sysv tar tzdata unzip vim-tiny wget xz-utils zip + apt-get update -o Acquire::ForceIPv4=true -o APT::Update::Error-Mode=any + apt-get install -y -o Acquire::ForceIPv4=true --no-install-recommends bash build-essential ca-certificates coreutils cron curl dbus file findutils git gzip iproute2 iputils-ping jq less locales man-db nano openssh-client procps python3 python3-pip rsync sudo systemd systemd-sysv tar tzdata unzip vim-tiny wget xz-utils zip apt-get clean rm -rf /var/lib/apt/lists/* existing_group="$(getent group 1000 | cut -d: -f1 || true)" diff --git a/test/site.mjs b/test/site.mjs index 629e602..91c4ee1 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -200,16 +200,17 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(lxcHelper, /ownership marker does not match/); assert.match(lxcHelper, /cleanup_incomplete_create[\s\S]*rm -rf -- "\$LXC_PATH\/\$name"[\s\S]*created=1[\s\S]*lxc-create/, "a failed create removes only its validated partial container directory"); assert.match(lxcHelper, /wait_for_guest_network[\s\S]*10\\\.0\\\.3\\\.[\s\S]*via 10\\\.0\\\.3\\\.1 dev eth0/, "fresh LXC provisioning waits for its DHCP address and default route"); - assert.match(lxcHelper, /archive\.ubuntu\.com[\s\S]*security\.ubuntu\.com/, "fresh LXC provisioning proves DNS before package bootstrap"); - assert.match(lxcHelper, /start "\$name"[\s\S]*wait_for_guest_network "\$name"[\s\S]*apt-get update -o APT::Update::Error-Mode=any/, "package bootstrap begins only after bounded guest-network readiness"); + assert.match(lxcHelper, /archive\.ubuntu\.com[\s\S]*security\.ubuntu\.com[\s\S]*curl -4[\s\S]*InRelease/, "fresh LXC provisioning proves DNS and outbound IPv4 HTTP before package bootstrap"); + assert.match(lxcHelper, /start "\$name"[\s\S]*wait_for_guest_network "\$name"[\s\S]*apt-get update -o Acquire::ForceIPv4=true -o APT::Update::Error-Mode=any/, "package bootstrap begins only after bounded guest-network readiness"); assert.match(lxcHelper, /cpuset\.cpus\.effective/, "LXC CPU limits are selected from the service's actually delegated host CPUs"); 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, /1helm-lxc-net-rules-owned[\s\S]*ONEHELM_LXC_INPUT[\s\S]*ONEHELM_LXC_FORWARD[\s\S]*iptables -w -I INPUT 1[\s\S]*iptables -w -I FORWARD 1[\s\S]*table ip onehelm_lxc[\s\S]*masquerade/, "an adopted bridge receives owned filter jumps ahead of host drop policies plus removable outbound NAT"); 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, /rules_healthy[\s\S]*iptables -S INPUT[\s\S]*iptables -S FORWARD[\s\S]*nft list chain ip onehelm_lxc postrouting[\s\S]*10\\\.0\\\.3\\\.0\/24[\s\S]*masquerade/, "runtime health verifies first-position host filter jumps and exact outbound NAT instead of accepting an ineffective parallel base chain"); + assert.match(lxcNetwork, /FORWARD_CHAIN="ONEHELM_LXC_FORWARD"[\s\S]*-A "\$FORWARD_CHAIN" -i "\$BRIDGE" -j ACCEPT[\s\S]*-A "\$FORWARD_CHAIN" -o "\$BRIDGE"/, "the owned forwarding chain accepts guest egress and only established return traffic"); 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]*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");