diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..95a1bd0 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,7 @@ +self-hosted-runner: + # Depot-managed GitHub Actions runners (https://depot.dev/docs/github-actions/runner-types) + labels: + - depot-macos-26 + - depot-macos-latest + - depot-ubuntu-latest + - depot-ubuntu-24.04-arm diff --git a/.github/workflows/release-binary.yml b/.github/workflows/release-binary.yml index 6224557..d3797b2 100644 --- a/.github/workflows/release-binary.yml +++ b/.github/workflows/release-binary.yml @@ -4,12 +4,21 @@ on: push: branches: - main + tags: + - "v*" pull_request: workflow_dispatch: +permissions: + contents: write + jobs: build: runs-on: macos-26 + env: + # Set when the Developer ID secrets are configured; release binaries are + # then notarized instead of ad-hoc signed. + HAVE_SIGNING: ${{ secrets.MACOS_SIGN_P12 != '' }} steps: - uses: actions/checkout@v6 @@ -35,7 +44,35 @@ jobs: CC_LINUX=/opt/homebrew/bin/aarch64-linux-musl-gcc \ cargo build --release --target aarch64-unknown-linux-musl - - name: Codesign release binary + - name: Import Developer ID certificate + if: env.HAVE_SIGNING == 'true' + env: + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_P12_PASSWORD: ${{ secrets.MACOS_SIGN_P12_PASSWORD }} + run: | + keychain="$RUNNER_TEMP/signing.keychain-db" + keychain_password="$(uuidgen)" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + echo "$MACOS_SIGN_P12" | base64 --decode > "$RUNNER_TEMP/signing.p12" + security import "$RUNNER_TEMP/signing.p12" -k "$keychain" \ + -P "$MACOS_SIGN_P12_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: \ + -s -k "$keychain_password" "$keychain" + security list-keychains -d user -s "$keychain" login.keychain + + - name: Sign and notarize release binary + if: env.HAVE_SIGNING == 'true' + env: + SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + run: scripts/sign-notarize.sh target/release/lnx + + - name: Codesign release binary (ad-hoc fallback) + if: env.HAVE_SIGNING != 'true' run: codesign --entitlements entitlements.plist --force -s - target/release/lnx - name: Package artifact @@ -53,3 +90,14 @@ jobs: path: | lnx-macos-arm64.tar.gz lnx-macos-arm64.tar.gz.sha256 + + - name: Attach binary to release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF#refs/tags/}" + gh release view "$tag" >/dev/null 2>&1 || \ + gh release create "$tag" --title "lnx $tag" --generate-notes + gh release upload "$tag" --clobber \ + lnx-macos-arm64.tar.gz lnx-macos-arm64.tar.gz.sha256 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 90586d4..f3e3a81 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,19 +8,55 @@ on: workflow_dispatch: jobs: - test: + # Unit tests do not boot VMs, so they run fine on GitHub-hosted runners. + unit: runs-on: macos-26 + timeout-minutes: 60 + env: + CC_LINUX: /opt/homebrew/bin/aarch64-linux-musl-gcc + steps: + - uses: actions/checkout@v6 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-unknown-linux-musl + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: third_party/gvproxy-bridge/go.mod + + - name: Install host dependencies + run: brew install FiloSottile/musl-cross/musl-cross llvm + + - name: Rust formatting + run: cargo fmt --check + + - name: Rust tests + run: bun run test + + # The system suite boots VMs, which needs Hypervisor.framework on bare-metal + # macOS. No hosted CI provides that (macOS VMs cannot nest HVF), so this job + # targets a self-hosted Mac and only runs on manual dispatch until one is + # registered. + system: + if: github.event_name == 'workflow_dispatch' + runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 180 env: CC_LINUX: /opt/homebrew/bin/aarch64-linux-musl-gcc - LNX_BASE: ${{ runner.temp }}/lnx-state LNX_RUN_BROWSER_TEST: "1" LNX_RUN_PRIVILEGED_INGRESS_TEST: "1" steps: - uses: actions/checkout@v6 - - name: Set up Depot CLI - uses: depot/setup-action@v1 + # The runner context is not available in job-level env. + - name: Set LNX_BASE + run: echo "LNX_BASE=$RUNNER_TEMP/lnx-state" >> "$GITHUB_ENV" - name: Set up Rust uses: dtolnay/rust-toolchain@stable @@ -30,16 +66,15 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: third_party/gvproxy-bridge/go.mod + - name: Install host dependencies run: | brew install FiloSottile/musl-cross/musl-cross - brew install e2fsprogs zstd - - - name: Rust formatting - run: cargo fmt --check - - - name: Rust tests - run: cargo test + brew install e2fsprogs zstd podman llvm - name: Full system tests run: bun run test:full diff --git a/.gitignore b/.gitignore index a53195e..72172ff 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ # Test artifacts /tmp/ -/results/**/*.json +/results/ /.lnx-nested-debug/ /.lnx-nested-kvm*/ /.lnx-chaos/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 4586ba2..0000000 --- a/.gitmodules +++ /dev/null @@ -1,12 +0,0 @@ -[submodule "old/third_party/vz"] - path = old/third_party/vz - url = https://github.com/semistrict/vz.git - branch = lnx-patches -[submodule "old/third_party/criu"] - path = old/third_party/criu - url = https://github.com/semistrict/criu.git - branch = lnx -[submodule "old/third_party/qemu"] - path = old/third_party/qemu - url = https://github.com/semistrict/qemu.git - branch = lnx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a76b676 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing + +Thanks for your interest in lnx! + +## Prerequisites + +- Apple Silicon Mac +- Rust (stable, 1.85+) with the `aarch64-unknown-linux-musl` target +- [Bun](https://bun.sh) for the build/test scripts +- `brew install FiloSottile/musl-cross/musl-cross podman llvm` + +## Building + +```sh +bun run build # debug build + codesign +bun run release # release build + codesign +``` + +Always build through the Bun scripts rather than raw `cargo build`: the +hypervisor entitlement requires the binary to be codesigned, and the scripts +handle that (an unsigned binary fails with `HV_DENIED`). They also build the +Linux helper binary used for nested runs. + +## Testing + +```sh +bun run test # Rust unit tests +bun run test:system # core integration suite +bun run test:full # everything CI runs +``` + +See [docs/testing.md](docs/testing.md) for the full suite list and opt-in +tests. Guest kernel and rootfs images are downloaded automatically +(`lnx init --global`); building them from source is only needed when changing +`kernel.config`, `kernel-patches/`, or the Dockerfiles. + +Tests must encode intended correct behavior — do not add tests that pass +because a known bug reproduces. + +## Layout + +- `src/` — host CLI and VM runner (Rust, libkrun) +- `guest-agent/` — static Linux agent, PID-1 staging and exec service +- `lnx-protocol/` — host/guest wire protocol +- `third_party/libkrun` — vendored libkrun with snapshot/restore patches +- `scripts/test/` — integration suites (Bun/TypeScript) +- `docs/` — architecture, security, testing notes + +## Pull requests + +- Keep changes focused; include tests that prove the new behavior. +- `cargo fmt` before pushing; CI checks formatting. +- Licensing is Apache-2.0; contributions are accepted under the same terms. diff --git a/Cargo.toml b/Cargo.toml index af69efc..b4a7654 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,9 @@ name = "lnx" version = "0.3.0" edition = "2024" rust-version = "1.85" +license = "Apache-2.0" +repository = "https://github.com/semistrict/lnx" +description = "Linux VM runner for macOS with warm memory/disk snapshots between commands" [workspace] members = [ diff --git a/Formula/lnx.rb b/Formula/lnx.rb new file mode 100644 index 0000000..c0020eb --- /dev/null +++ b/Formula/lnx.rb @@ -0,0 +1,29 @@ +# Homebrew formula for the prebuilt lnx binary. +# +# The repo doubles as a tap: +# brew tap semistrict/lnx https://github.com/semistrict/lnx +# brew install semistrict/lnx/lnx +# +# When tagging a release, update `version` and `sha256` below to match the +# lnx-macos-arm64.tar.gz asset produced by .github/workflows/release-binary.yml +# (the .sha256 file is published next to it). +class Lnx < Formula + desc "Linux VMs on macOS that resume with memory and disk state intact" + homepage "https://github.com/semistrict/lnx" + version "0.3.0" + url "https://github.com/semistrict/lnx/releases/download/v#{version}/lnx-macos-arm64.tar.gz" + sha256 "0000000000000000000000000000000000000000000000000000000000000000" # TODO: set from the release .sha256 asset + license "Apache-2.0" + + depends_on :macos + depends_on arch: :arm64 + depends_on "podman" # provides gvproxy for guest networking + + def install + bin.install "lnx" + end + + test do + assert_match "Linux VM runner", shell_output("#{bin}/lnx --help") + end +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..6ce494f --- /dev/null +++ b/NOTICE @@ -0,0 +1,12 @@ +lnx +Copyright 2026 Ramon Nogueira + +This product includes software developed as part of the libkrun project +(https://github.com/containers/libkrun), licensed under the Apache License, +Version 2.0. A modified copy of libkrun (with snapshot/restore support) is +vendored under third_party/libkrun; see third_party/libkrun/LICENSE and +third_party/libkrun/AUTHORS. + +This product includes gvproxy-bridge under third_party/gvproxy-bridge, which +builds against gvisor-tap-vsock (https://github.com/containers/gvisor-tap-vsock), +licensed under the Apache License, Version 2.0. diff --git a/README.md b/README.md index e5f182d..56951e1 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,133 @@ # lnx -`lnx` is a Rust/libkrun Linux VM runner for macOS. It boots a Linux kernel -directly, uses a normal systemd rootfs, and preserves VM memory plus disk state -with libkrun snapshots between commands. - -The important architectural difference from libkrun's simple `krun_set_root` -examples is that `lnx` keeps using the existing `rootfs.ext4` as the real -systemd root: - -1. `krun_add_disk(ctx, "rootfs", rootfs.ext4, false)` attaches the rootfs image. -2. The host generates an initramfs containing `lnx-agent` as both `/init` and - `/lnx-agent`. -3. libkrun's bootstrap init execs `/init --init`. -4. `/init --init` mounts `/dev/vda` at `/newroot`, copies `/lnx-agent` into - `/newroot/usr/local/lib/lnx/lnx-agent`, writes a systemd unit in - `/newroot/etc/systemd/system`, then `chroot`s and execs `/sbin/init`. -5. Linux userspace therefore comes from the existing ext4 image, not from a - host-directory virtiofs root. - -Basic exec flow: - -1. The Rust build script compiles `guest-agent/src/main.rs` into a static Linux - binary named `lnx-agent`. -2. Before boot, the host writes an initramfs containing that binary. -3. The binary's `--init` mode stages itself into `/usr/local/lib/lnx` in the - real root. -4. systemd starts `lnx-agent --agent 10240`. -5. The host connects to `lnx-agent` over libkrun's vsock-to-Unix-socket port - mapping, sends one argv vector, streams stdout/stderr frames, and exits with - the guest command status. - -Build requirements on macOS: +Linux VMs on macOS that wake up with their memory, disk, and systemd state +intact. ```sh -brew install FiloSottile/musl-cross/musl-cross -brew install podman -CC_LINUX=/opt/homebrew/bin/aarch64-linux-musl-gcc cargo build -codesign --entitlements entitlements.plist --force -s - target/debug/lnx -target/debug/lnx /bin/echo hello +lnx echo hello # boots a full Linux VM, runs the command, exits +lnx apt-get install -y postgresql +lnx psql --version # same machine, still installed ``` -`lnx` builds against the `wip/snapshot-restore-20260525-0606` branch of -`https://github.com/semistrict/libkrun`. `CC_LINUX` is needed because libkrun -compiles its own embedded Linux init helper. +Between commands there is **no VM running**. When a command finishes and the +instance goes idle, `lnx` snapshots the VM — RAM, devices, disk — and exits. +The next command restores from that snapshot: systemd is already up, services +are still running, the page cache is still warm. Rapid-fire commands reuse the +live VM without a restore at all. -Networking uses podman's `gvproxy` via libkrun's `krun_add_net_unixgram` -backend. The default path is `/opt/homebrew/opt/podman/libexec/podman/gvproxy`; -set `GVPROXY_PATH` if it lives somewhere else. +On an M5 Pro, `lnx /bin/true` completes in about **0.9s** when it restores a +4 GiB VM from a memory snapshot, and about **40ms** when the VM is still live +from a previous command. -Ingress: +Because instance state is just files (APFS clones for disk, snapshot files for +RAM), instances are cheap to **fork**: prepare one instance — repo checked +out, dependencies installed, server running — then stamp out copies of it, one +per experiment, test shard, or coding agent. + +## What you get + +- **A real Linux machine, not a container.** Ubuntu userland with systemd, + its own kernel, apt, Docker-in-VM if you want it. Persistent per-instance + rootfs. +- **Memory snapshots between commands.** Warm restores via libkrun dirty-page + tracking and APFS clones — only changed RAM and disk blocks are written. +- **Instance forking and checkpoints.** `lnx fork` clones a prepared instance; + checkpoints roll the filesystem back to a known-good state. +- **Host integration.** The current directory is shared into the guest + (virtio-fs with DAX), host timezone forwarded, ports forwarded with + `--forward`, and optional `https://p-.lnx` URLs via ingress. +- **Nested KVM.** Pass `--nested-kvm` and run KVM workloads (including lnx + itself) inside the guest. + +Requirements: Apple Silicon Mac. The guest is arm64 Linux. + +## Install + +Download the latest release from +[GitHub Releases](https://github.com/semistrict/lnx/releases): + +```sh +curl -LO https://github.com/semistrict/lnx/releases/latest/download/lnx-macos-arm64.tar.gz +tar -xzf lnx-macos-arm64.tar.gz +mv lnx ~/.local/bin/ # or anywhere on PATH +lnx echo hello # downloads the kernel + rootfs image on first run +``` + +Or build from source: + +```sh +brew install FiloSottile/musl-cross/musl-cross podman +git clone https://github.com/semistrict/lnx && cd lnx +bun run install # builds, signs, installs to ~/.cargo/bin +lnx echo hello +``` + +Guest networking uses podman's `gvproxy` (`brew install podman`, or set +`GVPROXY_PATH`). + +## Usage + +```sh +lnx bash # interactive shell in the default instance +lnx --instance dev bash # named instances are isolated machines +lnx --forward 8080:80 nginx # forward Mac localhost:8080 to guest :80 +lnx checkpoint -m "deps installed" +lnx fork dev2 # clone the instance, disk and all +lnx instances list +lnx set cpus=4 memory-mib=8192 # persist per-instance settings +``` + +Optional HTTPS ingress — stable local URLs for every instance: ```sh sudo lnx ingress enable -open https://p6080.default.lnx/ +open https://p6080-default.lnx/ ``` -`ingress enable` installs the `.lnx` resolver, starts local HTTP and HTTPS -listeners, and trusts a local `lnx` CA in the macOS System keychain. HTTPS -certificates are generated per `.lnx` host on first use and terminate at the -host ingress before proxying plain HTTP/WebSocket traffic to the guest port. +Ingress installs a `.lnx` resolver, loopback listeners, and a local CA that is +**name-constrained to `.lnx` hosts only** — it cannot sign certificates for +real domains. `lnx ingress disable` removes the CA from the keychain; +`sudo lnx ingress uninstall` removes every trace. Details in +[docs/security.md](docs/security.md). + +## How it works -Memory snapshot restore defaults to `~/.lnx/instances//memory-snapshots/latest` -and can be overridden with `--snapshot `. The VM runs in a detached -`_vm-owner` process, so `lnx` exits as soon as the guest command's status -arrives. The owner keeps the VM alive for an idle grace period (5s by default, -`LNX_BROKER_IDLE_TTL_MS` to override) so rapid-fire commands reuse the live VM -without a restore; once idle it asks the guest to quiesce, snapshots, and -exits, so the next exec restores systemd, the agent, and the rootfs from that -point. A fresh boot writes a full memory snapshot; restored runs use libkrun -dirty tracking and APFS clones to patch only changed RAM and disk blocks. +`lnx` boots a Linux kernel directly with [libkrun](https://github.com/containers/libkrun) +on Hypervisor.framework. A small static agent is injected via initramfs, +stages itself into the real ext4 rootfs, and hands off to systemd; commands +stream over vsock. A detached owner process holds the VM through an idle grace +period (default 5s), then quiesces the guest and snapshots. Snapshot restore +brings back the full machine state. -Per-run timings are appended to `~/.lnx/instances//timings.log`. -Incremental snapshots skip `fsync` by default for speed; set -`KRUN_SNAPSHOT_SYNC=1` to make snapshot files crash-durable before returning. +The snapshot/restore support is carried as patches on a copy of libkrun +vendored in-tree at `third_party/libkrun`; upstreaming is planned once the +interface stabilizes. -Host shares always mount with virtio-fs DAX. The cache mode is recorded in -the snapshot compatibility stamp, so snapshots created under the removed -non-DAX mode refuse to memory-restore; clear them with -`lnx --instance snapshots clear`. +More in [docs/architecture.md](docs/architecture.md). -Packages: +## Documentation -The managed rootfs image ships with a development toolchain baked in: the -latest Node.js (node/npm/npx, from the official nodejs.org tarball) plus pnpm -in `/usr/local`, alongside the Ubuntu userland. Install anything else with -`apt-get` inside the guest; each instance's rootfs is persistent. +- [Architecture](docs/architecture.md) +- [Security notes](docs/security.md) — what ingress installs, the + name-constrained CA, full uninstall +- [FAQ](docs/faq.md) — vs OrbStack/Lima/Apple `container`, vendored libkrun, + platform support +- [Troubleshooting](docs/troubleshooting.md) +- [Testing](docs/testing.md) -Nested KVM testing: +## Building and developing ```sh -bun run test:nested-kvm +brew install FiloSottile/musl-cross/musl-cross podman llvm +bun run build # debug build + codesign +bun run test # Rust unit tests +bun run test:system # core integration suite ``` -The nested test compiles `lnx` for `aarch64-unknown-linux-musl`, boots an outer -`lnx --nested-kvm` guest, verifies that an inner `lnx` VM can boot after the -outer VM has gone through `lnxctl snapshot-exit`, then runs the Linux-host -compatible part of the integration suite inside the nested-capable guest. - -Current caveats: - -- Inner nested `lnx` runs use `LNX_ROOTFS_BACKEND=block`; pmem/DAX rootfs inside - the nested Linux host still hits KVM mapping limitations. -- Linux libkrun snapshot APIs are wired for a full-RAM KVM/aarch64 capture and - restore path. Incremental dirty-log snapshots are not implemented yet, so the - Linux path is expected to be correct but heavier than the macOS/HVF path until - it grows KVM dirty-log support. -- `system` and `stress` have nested-safe coverage for their non-snapshot - behavior; their snapshot-specific assertions should move into the nested - Linux suite after the Linux full-RAM restore path has end-to-end runtime - coverage. -- Linux virtiofs write allowlist enforcement is not active today, so the - policy-specific virtiofs restore/fork checks do not run inside the nested - Linux host. -- `stock-ubuntu` remains excluded: `snapd` panics while parsing the nested guest - kernel command line under nested KVM, and a stock boot/apt probe hung instead - of producing bounded signal. -- Browser snapshot coverage remains opt-in and snapshot/fork-dependent. -- Ingress and privileged ingress tests are macOS host tests because they depend - on launchd, `/etc/resolver`, keychain/sudo setup, and privileged host ports. +The hypervisor entitlement requires every runnable binary to be codesigned; +the `bun run` scripts handle that. See [CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +Apache-2.0. Vendored third-party code retains its own notices; see +[NOTICE](NOTICE). diff --git a/TODO.txt b/TODO.txt deleted file mode 100644 index 993b81a..0000000 --- a/TODO.txt +++ /dev/null @@ -1,5 +0,0 @@ -Reliability test gaps and opt-in prerequisites: - -- Browser pixel/cursor verification still needs a reliable VNC client dependency such as vncdotool or a Playwright-controlled noVNC canvas check. `scripts/test/browser-snapshot.ts` is opt-in with `LNX_RUN_BROWSER_TEST=1` and currently verifies stock snap Chromium install plus noVNC endpoint survival across checkpoint/fork, but not actual rendered pixels or cursor visibility. -- Privileged ingress launchd install is opt-in with `LNX_RUN_PRIVILEGED_INGRESS_TEST=1` because it uses sudo, `/etc/resolver`, launchd, and privileged ports. -- Dirty filesystem offline fsck requires host `e2fsck`; the test skips clearly when it is unavailable. diff --git a/copy.bara.sky b/copy.bara.sky deleted file mode 100644 index b1ca93f..0000000 --- a/copy.bara.sky +++ /dev/null @@ -1,22 +0,0 @@ -core.workflow( - name = "libkrun", - origin = git.origin( - url = "https://github.com/semistrict/libkrun.git", - ref = "main", - ), - destination = folder.destination( - path = "third_party/libkrun", - ), - origin_files = glob( - ["**"], - exclude = [ - ".git/**", - "target/**", - "build/**", - ".codex-scratch/**", - ], - ), - authoring = authoring.pass_thru("lnx copybara "), - mode = "SQUASH", - transformations = [], -) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b367079 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,110 @@ +# Architecture + +`lnx` boots a Linux kernel directly with [libkrun](https://github.com/containers/libkrun), +uses a normal systemd rootfs, and preserves VM memory plus disk state with +libkrun snapshots between commands. + +## Real systemd root + +The important architectural difference from libkrun's simple `krun_set_root` +examples is that `lnx` keeps using the existing `rootfs.ext4` as the real +systemd root: + +1. `krun_add_disk(ctx, "rootfs", rootfs.ext4, false)` attaches the rootfs image. +2. The host generates an initramfs containing `lnx-agent` as both `/init` and + `/lnx-agent`. +3. libkrun's bootstrap init execs `/init --init`. +4. `/init --init` mounts `/dev/vda` at `/newroot`, copies `/lnx-agent` into + `/newroot/usr/local/lib/lnx/lnx-agent`, writes a systemd unit in + `/newroot/etc/systemd/system`, then `chroot`s and execs `/sbin/init`. +5. Linux userspace therefore comes from the existing ext4 image, not from a + host-directory virtiofs root. + +## Exec flow + +1. The Rust build script compiles `guest-agent/src/main.rs` into a static Linux + binary named `lnx-agent`. +2. Before boot, the host writes an initramfs containing that binary. +3. The binary's `--init` mode stages itself into `/usr/local/lib/lnx` in the + real root. +4. systemd starts `lnx-agent --agent 10240`. +5. The host connects to `lnx-agent` over libkrun's vsock-to-Unix-socket port + mapping, sends one argv vector, streams stdout/stderr frames, and exits with + the guest command status. + +## Snapshot lifecycle + +Memory snapshot restore defaults to +`~/.lnx/instances//memory-snapshots/latest` and can be overridden +with `--snapshot `. The VM runs in a detached `_vm-owner` process, so +`lnx` exits as soon as the guest command's status arrives. The owner keeps the +VM alive for an idle grace period (5s by default, `LNX_BROKER_IDLE_TTL_MS` to +override) so rapid-fire commands reuse the live VM without a restore; once +idle it asks the guest to quiesce, snapshots, and exits, so the next exec +restores systemd, the agent, and the rootfs from that point. A fresh boot +writes a full memory snapshot; restored runs use libkrun dirty tracking and +APFS clones to patch only changed RAM and disk blocks. + +Per-run timings are appended to `~/.lnx/instances//timings.log`. +Incremental snapshots skip `fsync` by default for speed; set +`KRUN_SNAPSHOT_SYNC=1` to make snapshot files crash-durable before returning. + +Host shares always mount with virtio-fs DAX. The cache mode is recorded in +the snapshot compatibility stamp, so snapshots created under the removed +non-DAX mode refuse to memory-restore; clear them with +`lnx --instance snapshots clear`. + +## Networking + +Networking uses podman's `gvproxy` via libkrun's `krun_add_net_unixgram` +backend. The default path is `/opt/homebrew/opt/podman/libexec/podman/gvproxy`; +set `GVPROXY_PATH` if it lives somewhere else. + +## Ingress + +`lnx ingress enable` installs a `.lnx` resolver, starts local HTTP and HTTPS +listeners, and trusts a local, name-constrained `lnx` CA in the macOS System +keychain. HTTPS certificates are generated per `.lnx` host on first use and +terminate at the host ingress before proxying plain HTTP/WebSocket traffic to +the guest port. See [security.md](security.md) for exactly what ingress +installs and how to remove it. + +## Guest images + +The managed rootfs image ships with a development toolchain baked in: the +latest Node.js (node/npm/npx, from the official nodejs.org tarball) plus pnpm +in `/usr/local`, alongside the Ubuntu userland. Install anything else with +`apt-get` inside the guest; each instance's rootfs is persistent. + +## Nested KVM + +```sh +bun run test:nested-kvm +``` + +The nested test compiles `lnx` for `aarch64-unknown-linux-musl`, boots an outer +`lnx --nested-kvm` guest, verifies that an inner `lnx` VM can boot after the +outer VM has gone through `lnxctl snapshot-exit`, then runs the Linux-host +compatible part of the integration suite inside the nested-capable guest. + +### Current caveats + +- Inner nested `lnx` runs use `LNX_ROOTFS_BACKEND=block`; pmem/DAX rootfs inside + the nested Linux host still hits KVM mapping limitations. +- Linux libkrun snapshot APIs are wired for a full-RAM KVM/aarch64 capture and + restore path. Incremental dirty-log snapshots are not implemented yet, so the + Linux path is expected to be correct but heavier than the macOS/HVF path until + it grows KVM dirty-log support. +- Linux virtiofs write allowlist enforcement is not active today, so the + policy-specific virtiofs restore/fork checks do not run inside the nested + Linux host. + +## Vendored libkrun + +`lnx` builds against the copy of libkrun vendored in-tree at +`third_party/libkrun`. It carries patches adding memory snapshot +capture/restore with dirty tracking on macOS/HVF, which +[upstream libkrun](https://github.com/containers/libkrun) does not have yet; +the intent is to upstream the snapshot work once it stabilizes. `CC_LINUX` is +needed at build time because libkrun compiles its own embedded Linux init +helper. diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..3c6d267 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,61 @@ +# FAQ + +## How is this different from OrbStack, Lima, colima, or Apple's `container`? + +Those tools all keep a Linux VM (or several) running and give you fast access +to it. `lnx` is built around a different primitive: **the VM's memory and disk +state are a snapshot on disk**. When no command is running, there is no VM — +the next command restores systemd, running services, page cache, and all, from +the last snapshot. That enables things the others don't do: + +- **No idle cost.** Nothing runs between commands; state still feels warm. +- **Fork.** `lnx fork` clones an instance — including its disk — using APFS + clones, so fan-out is cheap. Prepare one instance (checkout, deps installed, + server running), then fork it per experiment or per agent. +- **Checkpoints.** Roll an instance's filesystem back to a known-good point. + +If you want a always-on Docker replacement, OrbStack is great. If you want +disposable-but-stateful Linux environments that appear on demand, that's +`lnx`. + +## Why is libkrun vendored? + +Upstream [libkrun](https://github.com/containers/libkrun) has no memory +snapshot/restore. The copy vendored in-tree at `third_party/libkrun` adds +snapshot capture/restore with dirty-page tracking on macOS/HVF. The plan is to +upstream it once the interface stabilizes. Everything needed to build `lnx` +lives in this repository. + +## Does it run on Intel Macs? + +No. `lnx` is Apple Silicon (arm64) only. The guest is arm64 Linux. + +## Does it run on Linux? + +The core exec path also works on Linux hosts with KVM (that is how the nested +test suite runs), but macOS is the primary target and the Linux snapshot path +currently captures full RAM rather than incremental dirty pages. + +## What about x86 binaries inside the guest? + +The guest is arm64. Use Rosetta-free options like qemu-user or arm64 builds. +Running the guest itself under emulation is out of scope. + +## Is the name related to the lnx search engine? + +No relation. This `lnx` is a Linux VM runner; the name is just "Linux" minus +two vowels. + +## Why does ingress install a trusted CA? + +So `https://p-.lnx` URLs work without per-site warnings. The +CA is name-constrained to `.lnx` hosts only, `lnx ingress disable` removes it +from the keychain, and `sudo lnx ingress uninstall` deletes its on-disk state +too — see [security.md](security.md). Ingress is entirely optional; port +forwarding with `--forward` works without it. + +## Where does my data live? + +Everything is under `~/.lnx`: instance rootfs images, memory snapshots, +checkpoints, logs, and ingress state. Delete an instance directory (or all of +`~/.lnx`) and it is gone. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..49f809a --- /dev/null +++ b/docs/security.md @@ -0,0 +1,59 @@ +# Security notes + +`lnx` itself runs unprivileged: VMs use Apple's Hypervisor.framework via +libkrun, state lives in `~/.lnx`, and no daemon runs as root. The one feature +that touches system state is ingress, and it only does so when you explicitly +run `sudo lnx ingress enable`. + +## What `lnx ingress enable` installs + +Ingress gives every instance stable `https://p-.lnx` URLs that +terminate TLS on the host and proxy to guest ports. Enabling it installs: + +| What | Where | Why | +|---|---|---| +| Resolver file | `/etc/resolver/lnx` | Routes `.lnx` DNS lookups to a local resolver on 127.0.0.1 | +| launchd service | `/Library/LaunchDaemons` (or per-user LaunchAgents for unprivileged ports) | Runs the DNS/HTTP/HTTPS listeners | +| Local CA | `~/.lnx/ingress/ca`, trusted in the System keychain | Signs per-host `.lnx` certificates | + +Nothing is sent off the machine. All listeners bind loopback addresses. + +## The local CA is name-constrained + +The generated CA carries an X.509 `nameConstraints` extension (critical) +permitting only `DNS:.lnx` names and excluding all IP addresses, plus +`basicConstraints` with `pathlen:0`. Even with the CA trusted in the System +keychain, certificates it signs are only valid for `.lnx` hosts — it cannot be +used to intercept traffic to any real domain. The CA key never leaves +`~/.lnx/ingress/ca`, each `ingress enable` regenerates it, and +`ingress disable` removes it from the keychain. + +You can verify the constraints yourself: + +```sh +openssl x509 -in ~/.lnx/ingress/ca/lnx-ca.crt -text -noout +``` + +## Removing everything + +`lnx ingress disable` stops the listeners, removes the resolver file and the +launchd service, and removes the CA from the System keychain. (Re-enabling +later regenerates a fresh CA and prompts for authorization again.) + +To also delete the on-disk CA and certificate state: + +```sh +sudo lnx ingress uninstall +``` + +This does everything `disable` does and additionally removes the +CA/certificate state under `~/.lnx/ingress`. + +To remove lnx entirely: run `sudo lnx ingress uninstall` (if you ever enabled +ingress), then delete `~/.lnx` and the `lnx` binary. + +## Reporting + +Please report suspected vulnerabilities via GitHub security advisories on +[semistrict/lnx](https://github.com/semistrict/lnx/security/advisories) rather +than public issues. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..791bddf --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,51 @@ +# Testing + +Rust unit tests: + +```sh +bun run test +``` + +Integration suites (each `bun run test:` builds first and runs one +suite; see `package.json` for the full list): + +```sh +bun run test:system # core exec, shares, forwarding +bun run test:checkpoint # checkpoint/fork behavior +bun run test:snapshot-roundtrip +bun run test:full # everything CI runs +``` + +Nested KVM coverage (Linux-host paths inside an outer lnx guest): + +```sh +bun run test:nested-kvm +``` + +## Opt-in suites and prerequisites + +- Browser pixel/cursor verification still needs a reliable VNC client + dependency such as vncdotool or a Playwright-controlled noVNC canvas check. + `scripts/test/browser-snapshot.ts` is opt-in with `LNX_RUN_BROWSER_TEST=1` + and currently verifies stock snap Chromium install plus noVNC endpoint + survival across checkpoint/fork, but not actual rendered pixels or cursor + visibility. +- Privileged ingress launchd install is opt-in with + `LNX_RUN_PRIVILEGED_INGRESS_TEST=1` because it uses sudo, `/etc/resolver`, + launchd, and privileged ports. +- Dirty filesystem offline fsck requires host `e2fsck`; the test skips clearly + when it is unavailable. + +## Known coverage gaps + +- `system` and `stress` have nested-safe coverage for their non-snapshot + behavior; their snapshot-specific assertions should move into the nested + Linux suite after the Linux full-RAM restore path has end-to-end runtime + coverage. +- `stock-ubuntu` remains excluded from the nested suite: `snapd` panics while + parsing the nested guest kernel command line under nested KVM, and a stock + boot/apt probe hung instead of producing bounded signal. +- Browser snapshot coverage remains opt-in and snapshot/fork-dependent. +- Ingress and privileged ingress tests are macOS host tests because they + depend on launchd, `/etc/resolver`, keychain/sudo setup, and privileged + host ports. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..7980cdc --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,59 @@ +# Troubleshooting + +## `HV_DENIED` or the VM refuses to start after a rebuild + +The binary must be codesigned with the hypervisor entitlement. A raw +`cargo build` produces an unsigned binary; always build through the repo +scripts, which sign automatically: + +```sh +bun run build # debug +bun run release # release +``` + +or sign manually: + +```sh +codesign --entitlements entitlements.plist --force -s - target/debug/lnx +``` + +## `gvproxy` not found + +Guest networking uses podman's `gvproxy`. Install podman +(`brew install podman`) or point `GVPROXY_PATH` at a gvproxy binary. + +## Snapshot refuses to restore after upgrading lnx + +Snapshots carry a compatibility stamp. When the VM configuration changes +incompatibly, clear the instance's snapshots and let the next run boot fresh: + +```sh +lnx --instance snapshots clear +``` + +## Downloaded release binary is blocked by Gatekeeper + +If you downloaded the tarball with a browser, macOS may quarantine it: + +```sh +xattr -d com.apple.quarantine lnx +``` + +`curl`/`tar` downloads are not quarantined. + +## Build fails with `libclang.dylib` not found + +libkrun's bindgen build needs LLVM's libclang: + +```sh +brew install llvm +export LIBCLANG_PATH=/opt/homebrew/opt/llvm/lib +``` + +(The `bun run` scripts set this automatically for tests.) + +## Where to look + +- Per-run timing traces: `~/.lnx/instances//timings.log` +- Instance logs: `lnx logs` +- Instance state and configuration: `lnx inspect` diff --git a/guest-agent/Cargo.toml b/guest-agent/Cargo.toml index 2fdff8c..ecf3716 100644 --- a/guest-agent/Cargo.toml +++ b/guest-agent/Cargo.toml @@ -3,6 +3,8 @@ name = "lnx-agent" version = "0.3.0" edition = "2024" rust-version = "1.85" +license = "Apache-2.0" +repository = "https://github.com/semistrict/lnx" [dependencies] lnx-protocol = { path = "../lnx-protocol" } diff --git a/lnx-protocol/Cargo.toml b/lnx-protocol/Cargo.toml index 5fa106a..b5a7de5 100644 --- a/lnx-protocol/Cargo.toml +++ b/lnx-protocol/Cargo.toml @@ -3,6 +3,8 @@ name = "lnx-protocol" version = "0.3.0" edition = "2024" rust-version = "1.85" +license = "Apache-2.0" +repository = "https://github.com/semistrict/lnx" [dependencies] serde = { version = "1.0", features = ["derive"] } diff --git a/old/.codex b/old/.codex deleted file mode 100644 index e69de29..0000000 diff --git a/old/.github/workflows/asciinema-gif.yml b/old/.github/workflows/asciinema-gif.yml deleted file mode 100644 index 909f056..0000000 --- a/old/.github/workflows/asciinema-gif.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Build asciinema GIF - -on: - workflow_dispatch: - push: - branches: [main] - paths: - - "docs/asciinema/**" - - ".github/workflows/asciinema-gif.yml" - pull_request: - branches: [main] - paths: - - "docs/asciinema/**" - - ".github/workflows/asciinema-gif.yml" - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Install system packages - run: | - sudo apt-get update - sudo apt-get install -y pkg-config libfontconfig1-dev libfreetype6-dev - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install agg - run: cargo install --locked --git https://github.com/asciinema/agg - - - name: Render GIF - run: ./render-gif.sh - working-directory: docs/asciinema - - - name: Upload GIF artifact - uses: actions/upload-artifact@v7 - with: - name: asciinema-gif - path: docs/asciinema/out/ingress-demo.gif - retention-days: 14 diff --git a/old/.github/workflows/build.yml b/old/.github/workflows/build.yml deleted file mode 100644 index 12f7d66..0000000 --- a/old/.github/workflows/build.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Build - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - submodules: true - - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Check formatting - run: test -z "$(gofmt -l . | grep -v third_party/)" || (gofmt -l . | grep -v third_party/ && exit 1) - - - name: Build guest init (linux/arm64) - run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o cmd/lnx/init ./cmd/init - - - name: Vet (Linux-compatible packages) - run: go vet ./internal/... ./cmd/init/... - - - name: Run tests (Linux-compatible packages) - run: go test -race -v ./internal/... - - build-macos: - runs-on: macos-latest - needs: check - steps: - - uses: actions/checkout@v6 - with: - submodules: true - - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Build guest init (linux/arm64) - run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o cmd/lnx/init ./cmd/init - - - name: Vet - run: go vet ./... - - - name: Build lnx (macOS) - run: go build -ldflags '-extldflags "-Wl,-no_warn_duplicate_libraries"' -o lnx ./cmd/lnx - - - name: Run tests - run: go test -race -v ./... - - - name: Upload binary - uses: actions/upload-artifact@v7 - with: - name: lnx-darwin-arm64 - path: lnx diff --git a/old/.github/workflows/kernel.yml b/old/.github/workflows/kernel.yml deleted file mode 100644 index aa7e458..0000000 --- a/old/.github/workflows/kernel.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Build kernel - -on: - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build kernel - run: | - docker buildx build --platform linux/arm64 -f Dockerfile.kernel -t lnx-kernel --load . - docker create --name lnx-kernel-extract lnx-kernel true - docker cp lnx-kernel-extract:/build/arch/arm64/boot/Image vmlinuz - docker rm lnx-kernel-extract - gzip -9 vmlinuz - ls -lh vmlinuz.gz - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: vmlinuz.gz - path: vmlinuz.gz diff --git a/old/.github/workflows/release-images.yml b/old/.github/workflows/release-images.yml deleted file mode 100644 index 960e0d0..0000000 --- a/old/.github/workflows/release-images.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Release VM images - -on: - workflow_dispatch: - inputs: - version: - description: "Image version tag (e.g. v0.1.0)" - required: true - -jobs: - kernel: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build kernel - run: | - docker buildx build --platform linux/arm64 -f Dockerfile.kernel -t lnx-kernel --load . - docker create --name lnx-kernel-extract lnx-kernel true - docker cp lnx-kernel-extract:/build/arch/arm64/boot/Image vmlinuz - docker rm lnx-kernel-extract - gzip -9 vmlinuz - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: vmlinuz.gz - path: vmlinuz.gz - - rootfs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build rootfs - run: | - docker buildx build --platform linux/arm64 -f Dockerfile.rootfs -t lnx-rootfs --load . - docker create --name lnx-rootfs-extract lnx-rootfs true - docker cp lnx-rootfs-extract:/rootfs.ext4 rootfs.ext4 - docker rm lnx-rootfs-extract - - - name: Compress rootfs - run: zstd -T0 -9 rootfs.ext4 -o rootfs.ext4.zst - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: rootfs.ext4.zst - path: rootfs.ext4.zst - - release: - needs: [kernel, rootfs] - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Download kernel - uses: actions/download-artifact@v7 - with: - name: vmlinuz.gz - - - name: Download rootfs - uses: actions/download-artifact@v7 - with: - name: rootfs.ext4.zst - - - name: Create release - uses: softprops/action-gh-release@v2 - with: - tag_name: images-${{ inputs.version }} - name: "VM images ${{ inputs.version }}" - body: | - Pre-built kernel and rootfs images for lnx. - - - `vmlinuz.gz` — Linux kernel (arm64, gzip compressed) - - `rootfs.ext4.zst` — Ubuntu rootfs (zstd compressed) - - Install with: `lnx init` - files: | - vmlinuz.gz - rootfs.ext4.zst diff --git a/old/.github/workflows/rootfs.yml b/old/.github/workflows/rootfs.yml deleted file mode 100644 index 0f01fc2..0000000 --- a/old/.github/workflows/rootfs.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Build rootfs - -on: - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build rootfs - run: | - docker buildx build --platform linux/arm64 -f Dockerfile.rootfs -t lnx-rootfs --load . - docker create --name lnx-rootfs-extract lnx-rootfs true - docker cp lnx-rootfs-extract:/rootfs.ext4 rootfs.ext4 - docker rm lnx-rootfs-extract - ls -lh rootfs.ext4 - - - name: Compress rootfs - run: zstd -T0 -9 rootfs.ext4 -o rootfs.ext4.zst && ls -lh rootfs.ext4.zst - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: rootfs.ext4.zst - path: rootfs.ext4.zst diff --git a/old/.gitignore b/old/.gitignore deleted file mode 100644 index 9faa119..0000000 --- a/old/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -# Build artifacts -/lnx -/lnx-linux -/init -/cmd/lnx/init -/kernel.Image -/rootfs.ext4 -/vmlinuz -/vmlinuz-for-firecracker - -# Go workspace (local override) -go.work -go.work.sum - -# Claude Code -.claude/settings.local.json -.inbox/ - -# Test artifacts -/tmp/ - -# Codesign helper -/tmp/lnx-codesign diff --git a/old/.gitmodules b/old/.gitmodules deleted file mode 100644 index a0dbb37..0000000 --- a/old/.gitmodules +++ /dev/null @@ -1,12 +0,0 @@ -[submodule "third_party/vz"] - path = third_party/vz - url = https://github.com/semistrict/vz.git - branch = lnx-patches -[submodule "third_party/criu"] - path = third_party/criu - url = https://github.com/semistrict/criu.git - branch = lnx -[submodule "third_party/qemu"] - path = third_party/qemu - url = https://github.com/semistrict/qemu.git - branch = lnx diff --git a/old/CLAUDE.md b/old/CLAUDE.md deleted file mode 100644 index 4291843..0000000 --- a/old/CLAUDE.md +++ /dev/null @@ -1,133 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## What is lnx - -lnx is a lightweight Linux VM runner for macOS using Apple's Virtualization.framework. It boots a Linux kernel directly (no UEFI/bootloader), runs a custom init binary as PID 1, and communicates between host and guest over vsock. - -## Build & Test Commands - -```bash -make # Build host binary (cross-compiles guest init, embeds it, codesigns) -make install # Install to $GOPATH/bin -make test # Unit tests (any platform) -make test-integration # Integration tests (macOS only, needs ~/.lnx/vmlinuz + rootfs.ext4) -make test-integration RUN=TestName # Run a single integration test -make kernel # Build Linux kernel in Docker -make rootfs # Build rootfs ext4 image in Docker -``` - -The guest init is cross-compiled (`CGO_ENABLED=0 GOOS=linux GOARCH=arm64`) and embedded into the host binary via `//go:embed`. The host binary must be codesigned with virtualization entitlements. Integration tests use a codesign wrapper (`cmd/codesign/`) that signs the test binary before execution. - -## Architecture - -### Two binaries, one repo - -- **Host binary** (`cmd/lnx/`): macOS CLI that creates and manages VMs via `github.com/Code-Hex/vz/v3` (Go bindings for Virtualization.framework). -- **Guest init** (`cmd/init/`): Linux binary that runs as PID 1 inside the VM. All files have `//go:build linux`. - -`go.mod` replaces `Code-Hex/vz/v3` with the `semistrict/vz` fork. A gitignored `go.work` file overrides this with the local `third_party/vz/` submodule for development. If you need to modify the vz bindings, work in `third_party/vz/`. - -### Host ↔ Guest communication over vsock - -All communication uses virtio-vsock (no serial console for I/O). Ports are defined in `internal/protocol/protocol.go`: - -| Port | Purpose | Encoding | -|------|---------|----------| -| 1024 | Control (setup; VM-level lifecycle only) | gob | -| 1025 | Guest → host logging | JSON lines | -| 1026 | Status queries | gob | -| 1027 | Exec commands + per-session signals/resize (host connects per session) | gob | -| 1028 | Guest → host requests (checkpoint, open URL) | gob | -| 1030 | Port forward notifications | gob | -| 1031 | Port forward data (host connects via `VirtioSocketDevice.Connect`) | raw bytes with 2-byte port header | -| 1032 | Interactive exec PTY I/O (host connects via `VirtioSocketDevice.Connect`) | raw bytes | -| 1033 | 9P file server (host home dir, read-only) | 9P2000.L | -| 1034 | SSH agent forwarding (host listens, guest dials) | SSH agent protocol | -| 1035 | Guest HTTP debug endpoints (host → guest) | HTTP | - -The `Msg` envelope in protocol.go has exactly one non-nil field per message. - -### VM lifecycle — daemon model (vm.go) - -The VM runs as a background daemon process. All `lnx ` invocations are exec clients. - -1. Client checks `~/.lnx/instances//status.sock` for a running daemon -2. If no daemon: client spawns `lnx _daemon --instance ` in the background, waits for `status.sock` -3. Daemon boots: lock rootfs (flock), write initramfs, build VM config, start VM -4. Guest init boots, dials host on port 1024, receives `Setup` message -5. Guest starts services (exec server, status server, port forwarder) -6. Daemon listens on `status.sock` -7. Client connects via WebSocket (`GET /exec/ws`) or HTTP (`POST /exec`) -8. Multiple clients can exec concurrently — each gets its own vsock connection on port 1027 -9. Signals/resize are per-session via `ExecSignal`/`ExecResize` messages on the gob connection -10. Interactive I/O uses WebSocket: binary frames = PTY data, text frames = signals/resize/exit_code -11. When all exec sessions finish (active count → 0), daemon shuts down automatically -12. `lnx stop` can also shut down the daemon via `POST /stop` - -### Filesystem mounts - -- **CWD**: virtiofs share, mounted read-write in the guest at the same path as the host. -- **Home directory**: 9P over vsock (port 1033), mounted read-only. Host serves via `hugelgupf/p9` localfs. Mount failure is non-fatal. - -### 9P security filtering (p9filter.go) - -The home directory 9P share blocks sensitive paths via `blockedDirs` in p9filter.go (`.ssh`, `.gnupg`, `.aws`, `.docker`, `.kube`, browser profiles, keychains, etc.). Walk and Readdir calls return `EACCES` for blocked paths. CWD and extra virtiofs shares have no filtering. - -### Guest networking (internal/lnxnet/) - -Pure Go network stack used by the guest init: ARP, DHCP, ethernet frame handling, IPv4, TCP, UDP, and bridge. No CGO, runs inside the VM. - -### Port forwarding (portfwd.go, cmd/init/portfwd.go) - -Guest scans `/proc/net/tcp` every 2s for listening ports, sends updates to host on port 1030. Host binds `127.0.0.1:` and forwards TCP connections to the guest using `VirtioSocketDevice.Connect` on port 1031. - -### Host API server (status.go) - -HTTP server on `~/.lnx/instances//status.sock` (unix socket) exposes: -- `GET /status` — VM status (uptime, memory, disk, load) -- `GET /ports` — forwarded ports -- `POST /exec` — non-interactive exec (NDJSON streaming response) -- `GET /exec/ws` — interactive exec over WebSocket (binary frames = PTY, text frames = control) -- `POST /stop` — shut down the daemon - -CLI commands (`lnx status`, `lnx ports list`, `lnx stop`) are thin HTTP clients. `lnx ` uses `/exec/ws` for interactive or `/exec` for non-interactive. - -## Test structure - -- **Unit tests** (`*_test.go`): No build tags, run anywhere, test protocol logic and utilities. -- **Integration tests** (`*_intg_test.go`): Tagged `//go:build darwin && integration`, each test boots a real VM. Use `setupTestDir(t)` which clones rootfs via APFS clonefile. Most use `t.Parallel()`. -- **ForceQuit test** is NOT parallel because it sends `SIGINT` to the process. - -## Testing requirements - -A feature is NOT complete until it has tests. Do not declare a feature done without them. - -- **Unit tests**: For any new protocol messages, config parsing, or pure logic. -- **Integration tests (midterm)**: For any user-facing behavior that involves the VM. PTY tests use `creack/pty` + `vito/midterm` to simulate a real terminal. Use `--ephemeral` or `LNX_INSTANCE=test-xxx` with a cloned rootfs to avoid rootfs lock contention with other parallel tests. -- **Run the test before declaring it works**. Use `make install` first for PTY/midterm tests (they exec the `lnx` binary from PATH). Run the specific test in isolation first (`make test-integration RUN=TestName`), then the full suite. -- If a feature touches host↔guest communication (new vsock port, new Setup field, new guest service), the integration test must verify end-to-end behavior through the actual VM, not just the host side. - -## When adding fields to Config or protocol.Setup - -- `Config` fields are manually copied in the ephemeral path (`vm.go`). If you add a field, you MUST update that copy. `TestConfig_AllFieldsCopied` will fail if you forget — run `make test` first. -- `protocol.Setup` fields are gob-encoded. New fields work automatically, but test the round-trip with `TestControlProtocol_SetupDelivered`. - -## Debugging failures - -When a test or feature doesn't work, check the **data flow** first, not the build system: -- Trace the value through each layer: CLI flag → Config → Setup message → guest init -- Check if there's a manual struct copy that drops fields (ephemeral path, gob re-encoding) -- Do NOT spend time on build cache issues (`go clean -cache`, MD5 comparisons, binary extraction) until you've ruled out logic bugs - -## Key conventions - -- All interactive I/O goes through WebSocket (`handleExecWS` in status.go). Binary frames carry PTY data, text frames carry JSON control messages (signals, resize, exit_code). -- Each exec gets its own vsock connection (host connects to guest per session on port 1027), so multiple execs can run concurrently. -- Signals and resize are per-session via `ExecSignal`/`ExecResize` gob messages on port 1027, not the control connection. -- Double Ctrl-C force-quits the current session (exit 130). If it was the last session, the daemon shuts down. -- The CLI bypasses cobra for guest commands: if the first arg isn't a known subcommand or flag, it goes directly to `runVM()` so flags like `-g` pass through to the guest. -- Guest commands that aren't found print `name: command not found` (exit 127). -- `LNX_LOG=debug` enables host-side debug logging to `~/.lnx/lnx.log`. -- The `_daemon` subcommand is hidden/internal — never invoke it directly. It's spawned by `runVM()` when no VM is running. diff --git a/old/Dockerfile.kernel b/old/Dockerfile.kernel deleted file mode 100644 index 3e6c7e3..0000000 --- a/old/Dockerfile.kernel +++ /dev/null @@ -1,23 +0,0 @@ -FROM ubuntu:26.04 - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - bc \ - bison \ - flex \ - dwarves \ - libelf-dev \ - libssl-dev \ - linux-source-7.0.0 \ - cpio \ - && rm -rf /var/lib/apt/lists/* - -RUN mkdir /build && \ - tar xf /usr/src/linux-source-7.0.0.tar.bz2 -C /build --strip-components=1 - -WORKDIR /build - -COPY kernel.config .config -RUN make olddefconfig && make -j$(nproc) - -# Output is arch/arm64/boot/Image diff --git a/old/Dockerfile.rootfs b/old/Dockerfile.rootfs deleted file mode 100644 index 9f87a6c..0000000 --- a/old/Dockerfile.rootfs +++ /dev/null @@ -1,59 +0,0 @@ -FROM ubuntu:26.04 - -RUN apt-get update && apt-get install -y --no-install-recommends \ - debootstrap e2fsprogs \ - && rm -rf /var/lib/apt/lists/* - -# Layer 1: base debootstrap (slow, cached) -RUN debootstrap --arch=arm64 resolute /rootfs http://ports.ubuntu.com/ubuntu-ports - -# Layer 2: enable universe -RUN echo 'deb http://ports.ubuntu.com/ubuntu-ports resolute main universe' > /rootfs/etc/apt/sources.list && \ - echo 'deb http://ports.ubuntu.com/ubuntu-ports resolute-updates main universe' >> /rootfs/etc/apt/sources.list - -# Layer 3: apt update (separate so package install can change without re-updating) -RUN chroot /rootfs apt-get update - -# Layer 4: dev tools -RUN chroot /rootfs apt-get install -y --no-install-recommends \ - git curl wget build-essential ca-certificates openssh-client \ - vim less file strace gdb - -# Layer 5: system / network tools -RUN chroot /rootfs apt-get install -y --no-install-recommends \ - iproute2 iputils-ping dnsutils net-tools tcpdump \ - procps sysstat iptables nftables isc-dhcp-client - -# Layer 6: perf -RUN chroot /rootfs apt-get install -y --no-install-recommends \ - linux-tools-common - -# Layer 6b: CRIU build deps -RUN chroot /rootfs apt-get install -y --no-install-recommends \ - libprotobuf-dev libprotobuf-c-dev protobuf-c-compiler protobuf-compiler \ - pkg-config python3-protobuf libbsd-dev libcap-dev \ - libnl-3-dev libnet1-dev libaio-dev libgnutls28-dev \ - libnftables-dev nftables uuid-dev python3-yaml - -# Layer 6c: CRIU (checkpoint/restore in userspace) — built from our fork -# (third_party/criu) which patches SO_PASSSEC handling for kernel 7.0-rc4. -COPY third_party/criu /rootfs/tmp/criu -RUN chroot /rootfs sh -c '\ - PROTO_INC=$(find /usr -path "*/google/protobuf/descriptor.proto" -printf "%h/../..\n" 2>/dev/null | head -1); \ - [ -z "$PROTO_INC" ] && PROTO_INC=/usr/include; \ - mkdir -p /tmp/criu/images/google/protobuf && \ - cp "$PROTO_INC/google/protobuf/descriptor.proto" /tmp/criu/images/google/protobuf/' && \ - chroot /rootfs make -C /tmp/criu -j$(nproc) && \ - chroot /rootfs make -C /tmp/criu install-criu PREFIX=/usr && \ - rm -rf /rootfs/tmp/criu - -# Layer 7: containers -RUN chroot /rootfs apt-get install -y --no-install-recommends \ - podman buildah skopeo uidmap slirp4netns fuse-overlayfs - -# Layer 8: cleanup -RUN chroot /rootfs apt-get clean && rm -rf /rootfs/var/lib/apt/lists/* - -# Layer 9: build ext4 -RUN truncate -s 4G /rootfs.ext4 && \ - mke2fs -t ext4 -d /rootfs -L lnx /rootfs.ext4 diff --git a/old/Makefile b/old/Makefile deleted file mode 100644 index 7c9c2bd..0000000 --- a/old/Makefile +++ /dev/null @@ -1,76 +0,0 @@ -.PHONY: all cmd/lnx/init lnx lnx-linux kernel rootfs test test-integration install deps-macos clean help - -all: lnx - -help: - @echo "Usage: make [target]" - @echo "" - @echo "Build:" - @echo " all Build the lnx binary (default)" - @echo " lnx Build the lnx binary with codesign (macOS)" - @echo " lnx-linux Build the lnx binary for Linux/arm64" - @echo " install Install to \$$GOPATH/bin" - @echo " kernel Build the Linux kernel in Docker" - @echo " rootfs Build the ext4 rootfs image in Docker" - @echo "" - @echo "Test:" - @echo " test Run unit tests (any platform)" - @echo " test-integration Run integration tests (macOS, optional TEST=regex filter)" - @echo "" - @echo "Other:" - @echo " deps-macos Install local macOS dependencies (currently zstd)" - @echo " clean Remove build artifacts" - @echo " help Show this help" - -# Cross-compile guest init binary (linux/arm64) -cmd/lnx/init: - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o $@ ./cmd/init - -# Build host binary with embedded init -lnx: cmd/lnx/init - go build -ldflags '-extldflags "-Wl,-no_warn_duplicate_libraries"' -o $@ ./cmd/lnx - codesign --entitlements entitlements.plist --force -s - $@ - -# Build Linux binary (no codesign needed) -lnx-linux: cmd/lnx/init - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o $@ ./cmd/lnx - -# Build kernel in Docker -kernel: - docker build --platform linux/arm64 -f Dockerfile.kernel -t lnx-kernel . - docker rm -f lnx-kernel-extract 2>/dev/null; true - docker create --name lnx-kernel-extract lnx-kernel true - docker cp lnx-kernel-extract:/build/arch/arm64/boot/Image vmlinuz - docker rm lnx-kernel-extract - @echo "Kernel built: vmlinuz" - -# Build rootfs ext4 image in Docker -rootfs: - docker build --platform linux/arm64 -f Dockerfile.rootfs -t lnx-rootfs . - docker rm -f lnx-rootfs-extract 2>/dev/null; true - docker create --name lnx-rootfs-extract lnx-rootfs true - docker cp lnx-rootfs-extract:/rootfs.ext4 rootfs.ext4 - docker rm lnx-rootfs-extract - @echo "Rootfs built: rootfs.ext4" - -# Unit tests (run anywhere) -test: - go test -v ./... - -# Integration tests (macOS only, needs kernel+rootfs+init in place) -# Usage: make test-integration [TEST=Regex] -test-integration: lnx - go build -o /tmp/lnx-codesign ./cmd/codesign - PATH="$(PWD):$$PATH" go test -v -timeout 180s -tags integration -exec /tmp/lnx-codesign $(if $(TEST),-run '$(TEST)') ./... - -# Install to $GOPATH/bin -install: cmd/lnx/init - go build -ldflags '-extldflags "-Wl,-no_warn_duplicate_libraries"' -o "$$(go env GOPATH)/bin/lnx" ./cmd/lnx - codesign --entitlements entitlements.plist --force -s - "$$(go env GOPATH)/bin/lnx" - -# Install local macOS dependencies used by lnx. -deps-macos: - brew install zstd - -clean: - rm -f lnx lnx-linux cmd/lnx/init vmlinuz vmlinuz.gz rootfs.ext4 diff --git a/old/README.md b/old/README.md deleted file mode 100644 index f90ede5..0000000 --- a/old/README.md +++ /dev/null @@ -1,117 +0,0 @@ -# lnx - -Lightweight Linux VM runner for macOS using Apple's Virtualization.framework. Boots a Linux kernel directly (no UEFI/bootloader), runs a custom init as PID 1, communicates over vsock. - -## Install - -``` -brew install semistrict/tap/lnx --HEAD -``` - -Or build from source: - -``` -make install -``` - -Then initialize (downloads kernel, rootfs, and creates a dedicated APFS volume): - -``` -lnx init -``` - -### Shell completion - -Zsh (add to `~/.zshrc`): - -```zsh -eval "$(lnx completion zsh)" -``` - -Or generate a static file (faster shell startup): - -```zsh -mkdir -p ~/.zsh/completions -lnx completion zsh > ~/.zsh/completions/_lnx -# Add to .zshrc before compinit: fpath=(~/.zsh/completions $fpath) -``` - -Bash (add to `~/.bashrc`): - -```bash -eval "$(lnx completion bash)" -``` - -Fish: - -```fish -lnx completion fish | source -``` - -## Usage - -``` -lnx # login shell (bash -l) -lnx python3 server.py # run a command -lnx --ssh-agent git push # with SSH agent forwarding -lnx --ephemeral make test # disposable VM, rootfs discarded on exit -lnx --instance dev bash -l # named instance -``` - -## Instances - -Each instance gets its own rootfs and checkpoints under `~/.lnx/images//`, with runtime state (sockets, logs) in `~/.lnx/instances//`. - -``` -lnx clone dev # clone from the default instance -lnx instance list # show all instances -lnx --instance dev bash -l # boot a specific instance -lnx instance delete dev # remove an instance -``` - -## Shares - -Share host directories read-write into the VM via virtiofs: - -``` -lnx share add ~/src # persisted per-instance -lnx share list -lnx share remove ~/src -``` - -The current working directory is always shared automatically. - -## Docker - -Docker works out of the box after installing it in the VM: - -``` -sudo apt install docker-ce docker-ce-cli containerd.io -sudo systemctl enable docker -``` - -Docker auto-starts on boot. No `sudo` needed for `docker` commands. - -## Other commands - -``` -lnx status # VM status (all running instances) -lnx ports list # forwarded ports -lnx expose web:8080 --as=:8081 # expose web:8080 on localhost:8081 -lnx ingress enable # install .lnx resolver and local HTTP ingress -curl http://p8080.dev.lnx/ # route to dev:8080 (VM must already be running) -lnx exec [-i] command # exec into a running VM -lnx disk grow 16G # grow rootfs (resized on next boot) -lnx checkpoints list # list rootfs checkpoints -``` - -Manual `.lnx` ingress test: - -```sh -# Terminal 1 -lnx python3 -m http.server 5173 - -# Terminal 2 -sudo lnx ingress enable -curl http://p5173.default.lnx/ -``` diff --git a/old/RELEASE_NOTES.md b/old/RELEASE_NOTES.md deleted file mode 100644 index f901663..0000000 --- a/old/RELEASE_NOTES.md +++ /dev/null @@ -1,83 +0,0 @@ -# Release Notes - -## Mach-O section injection for `lnx pack` - -`lnx pack` now embeds the kernel and rootfs in a proper `__LNX,__lnxpack` Mach-O section instead of appending bytes after the code signature. The packed binary is a valid Mach-O that can be re-signed with `codesign`. A CGo placeholder section is created at build time; the pack command replaces it with the compressed payloads. - -## Docker containers and `lnx clone --image` - -`lnx docker run` now creates ephemeral containers with APFS-cloned rootfs images, cleaned up on exit. Docker-style `-p`/`-P` port mapping and `lnx docker ps` are supported. - -`lnx clone --image ` creates a persistent instance from an OCI image. - -OCI pull/build logic has been extracted to `internal/lnxoci`. - -## Sync Shares - -Host directories can now be shared with near-native ext4 speed using lazy-cache FUSE overlays. Files are copied into the guest's rootfs on first access and served from ext4 on subsequent reads. A background goroutine keeps the cache fresh within ~5 seconds of host-side changes. - -```bash -lnx sync add ~/src/myrepo -``` - -The home directory (`$HOME`) also uses this mechanism automatically, replacing the previous 9P mount. Sensitive paths (`.ssh`, `.gnupg`, `.aws`, etc.) remain blocked. - -**Performance** (`git status` on a ~3,600-file repo): - -| | virtiofs (before) | Sync share | -|-|-------------------|------------| -| Cold cache | 4.7s | 1.4s | -| Warm cache | 4.7s | 0.05s | - -See [docs/sync-shares.md](docs/sync-shares.md) for the full design. - -## SSH access to lnx VMs - -`ssh .lnx` now works out of the box. An embedded SSH server runs inside the guest init (PID 1) over vsock — no sshd installation required. - -### How it works - -On `lnx init`, a `Host *.lnx` block is added to `~/.ssh/config` with a `ProxyCommand` that routes through `lnx _ssh-proxy`. When you run `ssh foo.lnx`: - -1. The proxy command auto-starts the VM if it isn't running -2. The host daemon opens a vsock connection to the guest's embedded SSH server (port 1040) -3. SSH protocol flows end-to-end over vsock — key exchange, auth, channels, PTY all work normally - -Commands run as the same user, in the same CWD, with the same environment as `lnx exec`. PTY, window resize, scp, and sftp all work. - -### SSH config (installed automatically by `lnx init`) - -``` -Host *.lnx - ProxyCommand lnx _ssh-proxy %h %p - StrictHostKeyChecking no - UserKnownHostsFile /dev/null -``` - -## CRIU Checkpoints and VM Fork - -This release adds process-level checkpoint/restore using CRIU (Checkpoint/Restore In Userspace), enabling fast snapshotting and forking of running VMs. - -### New features - -**CRIU checkpoints** (`lnx checkpoints create --criu `) - -Captures the full state of all running processes — memory, file descriptors, TCP connections, pipes, Unix domain sockets — alongside the disk. Restore with `lnx checkpoints restore ` to roll back a VM to the exact moment of the checkpoint, including in-flight network connections and in-memory state. - -**VM fork** (`lnx fork`) - -Clones a running VM into an independent copy. The child VM boots with all processes restored to the same state as the parent at the moment of the fork. The parent continues uninterrupted. - -**Guest-initiated fork** (pipe-based, like `fork()`) - -Processes inside the VM can trigger a fork by writing to fd 3 and reading the result from fd 4. In the parent, the read returns the child instance name. In the CRIU-restored child, the read returns EOF. See `examples/fork.py` for the pattern. - -### Changes - -- CRIU images are stored on a dedicated block device (`criu.ext4`), separate from the rootfs. This keeps checkpoint data out of the root filesystem and enables independent cloning. -- CRIU is now built from a local fork (`third_party/criu`) that patches `SO_PASSSEC` handling for kernels without LSM support. -- The rootfs Dockerfile (`Dockerfile.rootfs`) builds CRIU from the local fork instead of cloning upstream. -- Process-level checkpoint/restore is listed alongside disk-only checkpoints in `lnx checkpoints list` (shown as type `criu` vs `disk`). -- The `lnx fork` command can optionally exec into the child VM with `lnx fork -- `. -- Leaked file descriptors (vsock sockets from the VZ framework) are no longer inherited by guest processes, fixing CRIU dump failures on processes that don't explicitly close inherited fds. -- CRIU auto-restore on boot now runs before any process-forking commands (network setup, resize2fs), preventing PID conflicts that would cause restore failures. diff --git a/old/TODO.md b/old/TODO.md deleted file mode 100644 index a3172b6..0000000 --- a/old/TODO.md +++ /dev/null @@ -1,23 +0,0 @@ -# Linux Host (Firecracker) — TODO - -Experimental Linux/KVM backend (`LNX_EXPERIMENTS=linux_host`). Basic non-interactive exec works. The following needs testing/implementation: - -## Not tested - -- [ ] Interactive PTY (`lnx bash` inside nested VM) -- [ ] CWD mounting via 9P — nested `lnx ls .` won't see host files -- [ ] Extra share mounting via 9P -- [ ] Home dir 9P mount inside nested VM -- [ ] Port forwarding from nested guest to host -- [ ] SSH agent forwarding -- [ ] `lnx status`, `lnx stop`, `lnx sessions` for nested instances -- [ ] Internet connectivity from inside nested VM (TAP + NAT configured but unverified) -- [ ] Checkpoint / ephemeral mode -- [ ] Multiple concurrent exec sessions -- [ ] `lnx clone` / `lnx instance delete` for nested instances - -## Known issues - -- [ ] Nested client logging writes to read-only 9P mount (silently fails, not harmful) -- [ ] TAP device cleanup is best-effort — if Firecracker crashes, stale TAP may block next boot until outer VM restarts -- [ ] Nested VM requires `sudo` for daemon (handled automatically, but NOPASSWD sudoers required) diff --git a/old/checkpoint.go b/old/checkpoint.go deleted file mode 100644 index cf83b66..0000000 --- a/old/checkpoint.go +++ /dev/null @@ -1,77 +0,0 @@ -package lnx - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "time" -) - -// checkpoint clones the rootfs to the checkpoint directory. -// Returns the path of the checkpoint. -func checkpoint(rootfsPath, checkpointDir string) (string, error) { - return CreateCheckpoint(rootfsPath, checkpointDir, "") -} - -// CreateCheckpoint clones the rootfs to the checkpoint directory. -// If name is empty, a timestamp-based name is generated. -// Returns the path of the checkpoint. -func CreateCheckpoint(rootfsPath, checkpointDir, name string) (string, error) { - if err := os.MkdirAll(checkpointDir, 0755); err != nil { - return "", fmt.Errorf("create checkpoint dir: %w", err) - } - - if name == "" { - name = time.Now().Format("2006-01-02T15-04-05") - } - if filepath.Base(name) != name || name == "." || name == ".." { - return "", fmt.Errorf("invalid checkpoint name %q", name) - } - if !strings.HasSuffix(name, ".ext4") { - name += ".ext4" - } - dst := filepath.Join(checkpointDir, name) - if _, err := os.Stat(dst); err == nil { - return "", fmt.Errorf("checkpoint %q already exists", name) - } else if !os.IsNotExist(err) { - return "", fmt.Errorf("stat checkpoint %s: %w", dst, err) - } - - if err := cloneFile(rootfsPath, dst); err != nil { - return "", fmt.Errorf("clone %s -> %s: %w", rootfsPath, dst, err) - } - - return dst, nil -} - -// CreateCRIUCheckpoint clones both rootfs and CRIU volume into a -// checkpoint directory. The directory structure is: -// -// checkpoints// -// rootfs.ext4 — APFS clone of rootfs -// criu.ext4 — APFS clone of CRIU images volume -// -// Returns the checkpoint directory path. -func CreateCRIUCheckpoint(rootfsPath, criuPath, checkpointDir string) (string, error) { - if _, err := os.Stat(checkpointDir); err == nil { - return "", fmt.Errorf("checkpoint %q already exists", filepath.Base(checkpointDir)) - } - if err := os.MkdirAll(checkpointDir, 0755); err != nil { - return "", fmt.Errorf("create checkpoint dir: %w", err) - } - - rootfsDst := filepath.Join(checkpointDir, "rootfs.ext4") - if err := cloneFile(rootfsPath, rootfsDst); err != nil { - os.RemoveAll(checkpointDir) - return "", fmt.Errorf("clone rootfs: %w", err) - } - - criuDst := filepath.Join(checkpointDir, "criu.ext4") - if err := cloneFile(criuPath, criuDst); err != nil { - os.RemoveAll(checkpointDir) - return "", fmt.Errorf("clone criu volume: %w", err) - } - - return checkpointDir, nil -} diff --git a/old/cli_intg_test.go b/old/cli_intg_test.go deleted file mode 100644 index 9983ca3..0000000 --- a/old/cli_intg_test.go +++ /dev/null @@ -1,273 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCLI_MissingCommand(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - home, _ := os.UserHomeDir() - base := filepath.Join(home, ".lnx") - if _, err := os.Stat(filepath.Join(base, "vmlinuz")); err != nil { - t.Skipf("skipping: vmlinuz not found in ~/.lnx (run 'lnx init' first)") - } - if findDefaultRootfs(base) == "" { - t.Skipf("skipping: default instance rootfs not found (run 'lnx init' first)") - } - - cmd := exec.Command(bin, "--ephemeral", "doesnotexist42") - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - require.Error(t, err) - - exitErr, ok := err.(*exec.ExitError) - require.True(t, ok, "expected process exit error, got %T: %v", err, err) - assert.Equal(t, 127, exitErr.ExitCode()) - assert.Contains(t, stderr.String(), "doesnotexist42: command not found") -} - -func TestCLI_FollowupExecsAgainstRunningVM(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := "test-followup-exec" - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - cmd, stderr, done := startTimedInstance(t, bin, inst, 8*time.Second) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - for i := 0; i < 5; i++ { - out := runCLISuccess(t, bin, "--instance", inst, "cat", "/etc/os-release") - assert.Contains(t, out, `PRETTY_NAME="Ubuntu Resolute Raccoon (development branch)"`) - } - - waitForProcessSuccess(t, done, 12*time.Second, stderr.String()) -} - -func TestCLI_StopWaitsUntilVMStops(t *testing.T) { - bin, err := filepath.Abs("lnx") - require.NoError(t, err) - if _, err := os.Stat(bin); err != nil { - t.Skipf("skipping: repo lnx binary not found at %s", bin) - } - - inst := "test-stop-waits" - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - runCLISuccess(t, bin, "--instance", inst, "true") - - stopOut := runCLISuccess(t, bin, "--instance", inst, "stop") - assert.Contains(t, stopOut, "VM stopped") - - statusOut, err := runCLI(bin, "--instance", inst, "status") - require.NoError(t, err) - assert.Contains(t, statusOut, "no VM running") -} - -func TestCLI_EnvNotForwardedByDefault(t *testing.T) { - bin, err := filepath.Abs("lnx") - require.NoError(t, err) - if _, err := os.Stat(bin); err != nil { - t.Skipf("skipping: repo lnx binary not found at %s", bin) - } - - env := append(os.Environ(), "LNX_TEST_ENV=secret") - out, err := runCLIEnv(bin, env, "--ephemeral", "sh", "-lc", `test -z "${LNX_TEST_ENV:-}" && echo OK`) - require.NoError(t, err, out) - assert.Contains(t, out, "OK") -} - -func TestCLI_EnvForwardSpecificVar(t *testing.T) { - bin, err := filepath.Abs("lnx") - require.NoError(t, err) - if _, err := os.Stat(bin); err != nil { - t.Skipf("skipping: repo lnx binary not found at %s", bin) - } - - env := append(os.Environ(), "LNX_TEST_ENV=secret") - out, err := runCLIEnv(bin, env, "--ephemeral", "--env", "LNX_TEST_ENV", "sh", "-lc", `test "$LNX_TEST_ENV" = secret && echo OK`) - require.NoError(t, err, out) - assert.Contains(t, out, "OK") -} - -func TestCLI_PreserveEnv(t *testing.T) { - bin, err := filepath.Abs("lnx") - require.NoError(t, err) - if _, err := os.Stat(bin); err != nil { - t.Skipf("skipping: repo lnx binary not found at %s", bin) - } - - hostHome := os.Getenv("HOME") - hostPath := os.Getenv("PATH") - require.NotEmpty(t, hostHome) - require.NotEmpty(t, hostPath) - - env := append(os.Environ(), - "LNX_TEST_ENV=secret", - "LNX_TEST_ENV2=second", - ) - script := `test "$LNX_TEST_ENV" = secret && test "$LNX_TEST_ENV2" = second && test "$HOME" != "` + hostHome + `" && test "$PATH" != "` + hostPath + `" && echo OK` - out, err := runCLIEnv(bin, env, "--ephemeral", "--preserve-env", "sh", "-lc", script) - require.NoError(t, err, out) - assert.Contains(t, out, "OK") -} - -func TestCLI_EnvForwardFromFile(t *testing.T) { - bin, err := filepath.Abs("lnx") - require.NoError(t, err) - if _, err := os.Stat(bin); err != nil { - t.Skipf("skipping: repo lnx binary not found at %s", bin) - } - - envFile := filepath.Join(t.TempDir(), ".env") - require.NoError(t, os.WriteFile(envFile, []byte("LNX_FILE_ENV=secret\nLNX_FILE_ENV2=\"quoted value\"\n"), 0644)) - - out, err := runCLIEnv(bin, os.Environ(), "--ephemeral", "--env", "@"+envFile, "sh", "-lc", `test "$LNX_FILE_ENV" = secret && test "$LNX_FILE_ENV2" = "quoted value" && echo OK`) - require.NoError(t, err, out) - assert.Contains(t, out, "OK") -} - -func TestCLI_CheckpointsCreateStoppedAndRunning(t *testing.T) { - bin, err := filepath.Abs("lnx") - require.NoError(t, err) - if _, err := os.Stat(bin); err != nil { - t.Skipf("skipping: repo lnx binary not found at %s", bin) - } - - home, _ := os.UserHomeDir() - base := filepath.Join(home, ".lnx") - - stoppedInst := "test-checkpoint-stopped" - createClonedInstance(t, stoppedInst) - stoppedOut := runCLISuccess(t, bin, "--instance", stoppedInst, "checkpoints", "create", "stopped") - assert.Contains(t, stoppedOut, `created checkpoint "stopped.ext4"`) - _, err = os.Stat(filepath.Join(base, "images", stoppedInst, "checkpoints", "stopped.ext4")) - require.NoError(t, err) - - runningInst := "test-checkpoint-running" - createClonedInstance(t, runningInst) - registerInstanceStopCleanup(t, bin, runningInst) - - cmd, stderr, done := startTimedInstance(t, bin, runningInst, 8*time.Second) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - runningOut := runCLISuccess(t, bin, "--instance", runningInst, "checkpoints", "create", "running") - assert.Contains(t, runningOut, `created checkpoint "running.ext4"`) - _, err = os.Stat(filepath.Join(base, "images", runningInst, "checkpoints", "running.ext4")) - require.NoError(t, err) - - waitForProcessSuccess(t, done, 12*time.Second, stderr.String()) -} - -func TestCLI_InstanceCreateFromNamedCheckpointCopiesMetadata(t *testing.T) { - bin, err := filepath.Abs("lnx") - require.NoError(t, err) - if _, err := os.Stat(bin); err != nil { - t.Skipf("skipping: repo lnx binary not found at %s", bin) - } - - home, _ := os.UserHomeDir() - base := filepath.Join(home, ".lnx") - - srcInst := "test-clone-from-checkpoint-src" - dstInst := "test-clone-from-checkpoint-dst" - createClonedInstance(t, srcInst) - registerInstanceStopCleanup(t, bin, srcInst, dstInst) - t.Cleanup(func() { - _ = os.RemoveAll(filepath.Join(base, "instances", dstInst)) - _ = os.RemoveAll(filepath.Join(base, "images", dstInst)) - }) - - shareDir := t.TempDir() - runCLISuccess(t, bin, "--instance", srcInst, "share", "add", shareDir) - runCLISuccess(t, bin, "--instance", srcInst, "sh", "-lc", `echo from-checkpoint > "$HOME/from-checkpoint.txt"`) - runCLISuccess(t, bin, "--instance", srcInst, "checkpoints", "create", "base") - runCLISuccess(t, bin, "--instance", srcInst, "sh", "-lc", `echo after-checkpoint > "$HOME/after-checkpoint.txt"`) - - out := runCLISuccess(t, bin, "--instance", srcInst, "clone", "--checkpoint", "base", dstInst) - assert.Contains(t, out, `created instance "`+dstInst+`" from "`+srcInst+`:base"`) - - fromCheckpoint := runCLISuccess(t, bin, "--instance", dstInst, "sh", "-lc", `cat "$HOME/from-checkpoint.txt"`) - assert.Equal(t, "from-checkpoint\n", fromCheckpoint) - - missingOut, err := runCLI(bin, "--instance", dstInst, "sh", "-lc", `cat "$HOME/after-checkpoint.txt"`) - require.Error(t, err) - assert.Contains(t, missingOut, "No such file") - - shareOut := runCLISuccess(t, bin, "--instance", dstInst, "share", "list") - assert.Contains(t, shareOut, shareDir) - - // Checkpoint should not be copied to destination instance images dir. - _, err = os.Stat(filepath.Join(base, "images", dstInst, "checkpoints", "base.ext4")) - require.ErrorIs(t, err, os.ErrNotExist) -} - -func TestCLI_InstanceCreateFromRunningSourceAutoCheckpoint(t *testing.T) { - bin, err := filepath.Abs("lnx") - require.NoError(t, err) - if _, err := os.Stat(bin); err != nil { - t.Skipf("skipping: repo lnx binary not found at %s", bin) - } - - home, _ := os.UserHomeDir() - base := filepath.Join(home, ".lnx") - - srcInst := "test-clone-running-src" - dstInst := "test-clone-running-dst" - createClonedInstance(t, srcInst) - registerInstanceStopCleanup(t, bin, srcInst, dstInst) - t.Cleanup(func() { - _ = os.RemoveAll(filepath.Join(base, "instances", dstInst)) - _ = os.RemoveAll(filepath.Join(base, "images", dstInst)) - }) - - shareDir := t.TempDir() - runCLISuccess(t, bin, "--instance", srcInst, "share", "add", shareDir) - - cmd, stderr, done := startTimedInstance(t, bin, srcInst, 8*time.Second) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - runCLISuccess(t, bin, "--instance", srcInst, "sh", "-lc", `echo running-checkpoint > "$HOME/running-checkpoint.txt"`) - - out := runCLISuccess(t, bin, "--instance", srcInst, "clone", dstInst) - assert.Contains(t, out, `created instance "`+dstInst+`" from "`+srcInst+`"`) - - cloned := runCLISuccess(t, bin, "--instance", dstInst, "sh", "-lc", `cat "$HOME/running-checkpoint.txt"`) - assert.Equal(t, "running-checkpoint\n", cloned) - - shareOut := runCLISuccess(t, bin, "--instance", dstInst, "share", "list") - assert.Contains(t, shareOut, shareDir) - - checkpoints, err := filepath.Glob(filepath.Join(base, "images", srcInst, "checkpoints", "*.ext4")) - require.NoError(t, err) - assert.NotEmpty(t, checkpoints) - - _, err = os.Stat(filepath.Join(base, "images", dstInst, "checkpoints")) - require.ErrorIs(t, err, os.ErrNotExist) - - waitForProcessSuccess(t, done, 12*time.Second, stderr.String()) -} diff --git a/old/clone_darwin.go b/old/clone_darwin.go deleted file mode 100644 index 842b980..0000000 --- a/old/clone_darwin.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build darwin - -package lnx - -import "golang.org/x/sys/unix" - -// cloneFile creates a copy-on-write clone of src at dst using APFS clonefile. -func cloneFile(src, dst string) error { - return unix.Clonefile(src, dst, 0) -} - -// CloneFile is the exported version of cloneFile for tests. -func CloneFile(src, dst string) error { - return cloneFile(src, dst) -} diff --git a/old/clone_linux.go b/old/clone_linux.go deleted file mode 100644 index 04dc900..0000000 --- a/old/clone_linux.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build linux - -package lnx - -import ( - "fmt" - "io" - "os" -) - -// cloneFile copies src to dst. On Linux there's no APFS clonefile; -// the kernel may use reflinks transparently if the filesystem supports it. -func cloneFile(src, dst string) error { - sf, err := os.Open(src) - if err != nil { - return fmt.Errorf("open source: %w", err) - } - defer sf.Close() - - df, err := os.Create(dst) - if err != nil { - return fmt.Errorf("create dest: %w", err) - } - defer df.Close() - - if _, err := io.Copy(df, sf); err != nil { - os.Remove(dst) - return fmt.Errorf("copy: %w", err) - } - return df.Close() -} diff --git a/old/cmd/codesign/main.go b/old/cmd/codesign/main.go deleted file mode 100644 index ff30c59..0000000 --- a/old/cmd/codesign/main.go +++ /dev/null @@ -1,62 +0,0 @@ -// Command codesign is a test executor that signs the test binary with the -// virtualization entitlement before running it. Use with: -// -// go test -exec "go run ./cmd/codesign" -tags integration ./... -package main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" -) - -func main() { - if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: codesign [args...]") - os.Exit(1) - } - - binary := os.Args[1] - - // Find entitlements.plist relative to this file's module root. - entitlements := findEntitlements() - - sign := exec.Command("codesign", "--entitlements", entitlements, "--force", "-s", "-", binary) - sign.Stderr = os.Stderr - if err := sign.Run(); err != nil { - fmt.Fprintf(os.Stderr, "codesign failed: %v\n", err) - os.Exit(1) - } - - cmd := exec.Command(binary, os.Args[2:]...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - os.Exit(exitErr.ExitCode()) - } - fmt.Fprintf(os.Stderr, "exec failed: %v\n", err) - os.Exit(1) - } -} - -func findEntitlements() string { - // Walk up from cwd to find entitlements.plist. - dir, _ := os.Getwd() - for { - p := filepath.Join(dir, "entitlements.plist") - if _, err := os.Stat(p); err == nil { - return p - } - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - // Fallback. - return "entitlements.plist" -} diff --git a/old/cmd/init/control.go b/old/cmd/init/control.go deleted file mode 100644 index 760be0f..0000000 --- a/old/cmd/init/control.go +++ /dev/null @@ -1,393 +0,0 @@ -//go:build linux - -package main - -import ( - "encoding/gob" - "encoding/json" - "fmt" - "io" - "log/slog" - "net" - "net/http" - "net/http/pprof" - "os" - "os/exec" - "path/filepath" - "sync" - "syscall" - - "github.com/mdlayher/vsock" - "github.com/semistrict/lnx/internal/protocol" -) - -const guestControlSock = "/var/run/lnx/control.sock" - -// startGuestControlServer dials the host on the guest control vsock port -// and starts an HTTP server on a unix socket inside the guest. -func startGuestControlServer() { - hostConn, err := vsock.Dial(vsockHostCID, protocol.GuestControlPort, nil) - if err != nil { - slog.Warn("guest control vsock dial failed", "error", err) - return - } - - os.MkdirAll("/var/run/lnx", 0755) - os.Remove(guestControlSock) - - ln, err := net.Listen("unix", guestControlSock) - if err != nil { - slog.Warn("guest control socket listen failed", "error", err) - hostConn.Close() - return - } - // Make it world-accessible so non-root users can curl it. - os.Chmod(guestControlSock, 0666) - - gc := &guestControl{ - enc: gob.NewEncoder(hostConn), - dec: gob.NewDecoder(hostConn), - } - setGuestControl(gc) - - mux := newGuestControlMux(gc) - - go http.Serve(ln, mux) - - vsockLn, err := vsock.Listen(protocol.GuestHTTPPort, nil) - if err != nil { - slog.Warn("guest control vsock listen failed", "error", err, "port", protocol.GuestHTTPPort) - return - } - go http.Serve(vsockLn, mux) -} - -func newGuestControlMux(gc *guestControl) *http.ServeMux { - mux := http.NewServeMux() - mux.HandleFunc("POST /checkpoint", gc.handleCheckpoint) - mux.HandleFunc("POST /open", gc.handleOpen) - mux.HandleFunc("POST /tcp/expose", gc.handleTCPExpose) - mux.HandleFunc("POST /criu/dump", gc.handleCRIUDump) - mux.HandleFunc("POST /criu/fork-dump", gc.handleCRIUForkDump) - mux.HandleFunc("POST /fork", gc.handleFork) - mux.HandleFunc("GET /debug/pprof/", pprof.Index) - mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline) - mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile) - mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol) - mux.HandleFunc("POST /debug/pprof/symbol", pprof.Symbol) - mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace) - for _, name := range []string{ - "allocs", - "block", - "goroutine", - "heap", - "mutex", - "threadcreate", - } { - mux.Handle("GET /debug/pprof/"+name, pprof.Handler(name)) - } - return mux -} - -type guestControl struct { - mu sync.Mutex - enc *gob.Encoder - dec *gob.Decoder -} - -var globalGuestControl struct { - mu sync.Mutex - gc *guestControl -} - -func setGuestControl(gc *guestControl) { - globalGuestControl.mu.Lock() - globalGuestControl.gc = gc - globalGuestControl.mu.Unlock() -} - -func getGuestControl() *guestControl { - globalGuestControl.mu.Lock() - defer globalGuestControl.mu.Unlock() - return globalGuestControl.gc -} - -type guestTCPExpose struct { - listenPort uint16 - host string - hostPort uint16 - listener net.Listener -} - -var ( - guestTCPExposeMu sync.Mutex - guestTCPExposes = map[uint16]*guestTCPExpose{} -) - -func guestInternalPort(port uint16) bool { - guestTCPExposeMu.Lock() - defer guestTCPExposeMu.Unlock() - _, ok := guestTCPExposes[port] - return ok -} - -func (gc *guestControl) handleCheckpoint(w http.ResponseWriter, r *http.Request) { - syscall.Sync() - - var req struct { - Name string `json:"name"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - - gc.mu.Lock() - defer gc.mu.Unlock() - - if err := gc.enc.Encode(protocol.Msg{CheckpointReq: &protocol.CheckpointReq{Name: req.Name}}); err != nil { - http.Error(w, fmt.Sprintf("send checkpoint request: %v", err), http.StatusInternalServerError) - return - } - - var msg protocol.Msg - if err := gc.dec.Decode(&msg); err != nil { - http.Error(w, fmt.Sprintf("read checkpoint response: %v", err), http.StatusInternalServerError) - return - } - - if msg.CheckpointResp == nil { - http.Error(w, "unexpected response", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - if msg.CheckpointResp.Error != "" { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": msg.CheckpointResp.Error}) - return - } - - json.NewEncoder(w).Encode(map[string]string{"path": msg.CheckpointResp.Path}) -} - -func (gc *guestControl) handleOpen(w http.ResponseWriter, r *http.Request) { - var req struct { - URL string `json:"url"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - if req.URL == "" { - http.Error(w, "url required", http.StatusBadRequest) - return - } - - gc.mu.Lock() - defer gc.mu.Unlock() - - if err := gc.enc.Encode(protocol.Msg{OpenURLReq: &protocol.OpenURLReq{URL: req.URL}}); err != nil { - http.Error(w, fmt.Sprintf("send open request: %v", err), http.StatusInternalServerError) - return - } - - var msg protocol.Msg - if err := gc.dec.Decode(&msg); err != nil { - http.Error(w, fmt.Sprintf("read open response: %v", err), http.StatusInternalServerError) - return - } - - if msg.OpenURLResp == nil { - http.Error(w, "unexpected response", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - if msg.OpenURLResp.Error != "" { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": msg.OpenURLResp.Error}) - return - } - - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -// handleCRIUDump dumps all user processes to the CRIU volume. -// The images are written to /mnt/criu// on the CRIU block device, -// which the host can then APFS-clone. -func (gc *guestControl) handleCRIUDump(w http.ResponseWriter, r *http.Request) { - var req struct { - Name string `json:"name"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - if req.Name == "" { - http.Error(w, "name required", http.StatusBadRequest) - return - } - - dir := filepath.Join(criuMountPoint, req.Name) - if err := criuDump(req.Name, dir, true); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - syncCRIUVolume() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -func (gc *guestControl) handleCRIUForkDump(w http.ResponseWriter, r *http.Request) { - if err := criuDump("fork", criuForkDir, true); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - syscall.Sync() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ready"}) -} - -func (gc *guestControl) handleFork(w http.ResponseWriter, r *http.Request) { - // CRIU dump if available — skip for QEMU VMs. - hasCRIU := false - if _, err := exec.LookPath("criu"); err == nil { - hasCRIU = true - if err := criuDump("fork", criuForkDir, true); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - syscall.Sync() - } - - // Ask the host to clone rootfs + spawn a child instance. - gc.mu.Lock() - defer gc.mu.Unlock() - - if err := gc.enc.Encode(protocol.Msg{ForkReq: &protocol.ForkReq{}}); err != nil { - http.Error(w, fmt.Sprintf("send fork request: %v", err), http.StatusInternalServerError) - return - } - - var msg protocol.Msg - if err := gc.dec.Decode(&msg); err != nil { - http.Error(w, fmt.Sprintf("read fork response: %v", err), http.StatusInternalServerError) - return - } - if msg.ForkResp == nil { - http.Error(w, "unexpected response from host", http.StatusInternalServerError) - return - } - if msg.ForkResp.Error != "" { - http.Error(w, msg.ForkResp.Error, http.StatusInternalServerError) - return - } - - if hasCRIU { - os.RemoveAll(criuForkDir) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "role": "parent", - "child_instance": msg.ForkResp.Instance, - }) -} - -func (gc *guestControl) handleTCPExpose(w http.ResponseWriter, r *http.Request) { - var req struct { - ListenPort uint16 `json:"listen_port"` - Host string `json:"host"` - HostPort uint16 `json:"host_port"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - if req.ListenPort == 0 || req.Host == "" || req.HostPort == 0 { - http.Error(w, "listen_port, host, and host_port are required", http.StatusBadRequest) - return - } - - guestTCPExposeMu.Lock() - if existing, ok := guestTCPExposes[req.ListenPort]; ok && existing != nil { - if existing.host == req.Host && existing.hostPort == req.HostPort { - guestTCPExposeMu.Unlock() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]bool{"created": false}) - return - } - guestTCPExposeMu.Unlock() - http.Error(w, fmt.Sprintf("port %d is already exposed", req.ListenPort), http.StatusConflict) - return - } - guestTCPExposeMu.Unlock() - - ln, err := net.Listen("tcp", fmt.Sprintf(":%d", req.ListenPort)) - if err != nil { - http.Error(w, fmt.Sprintf("listen on port %d: %v", req.ListenPort, err), http.StatusConflict) - return - } - - expose := &guestTCPExpose{ - listenPort: req.ListenPort, - host: req.Host, - hostPort: req.HostPort, - listener: ln, - } - - guestTCPExposeMu.Lock() - if existing, ok := guestTCPExposes[req.ListenPort]; ok && existing != nil { - guestTCPExposeMu.Unlock() - _ = ln.Close() - if existing.host == req.Host && existing.hostPort == req.HostPort { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]bool{"created": false}) - return - } - http.Error(w, fmt.Sprintf("port %d is already exposed", req.ListenPort), http.StatusConflict) - return - } - guestTCPExposes[req.ListenPort] = expose - guestTCPExposeMu.Unlock() - - go expose.acceptLoop() - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]bool{"created": true}) -} - -func (e *guestTCPExpose) acceptLoop() { - for { - conn, err := e.listener.Accept() - if err != nil { - return - } - go e.forward(conn) - } -} - -func (e *guestTCPExpose) forward(src net.Conn) { - defer src.Close() - - dst, err := net.Dial("tcp", net.JoinHostPort(e.host, itoa(int(e.hostPort)))) - if err != nil { - return - } - defer dst.Close() - - done := make(chan struct{}) - go func() { - io.Copy(dst, src) - if tc, ok := dst.(*net.TCPConn); ok { - _ = tc.CloseWrite() - } - close(done) - }() - io.Copy(src, dst) - _ = src.Close() - <-done -} diff --git a/old/cmd/init/criu.go b/old/cmd/init/criu.go deleted file mode 100644 index 99bdb7b..0000000 --- a/old/cmd/init/criu.go +++ /dev/null @@ -1,802 +0,0 @@ -//go:build linux - -package main - -import ( - "encoding/gob" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" - "os/exec" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "github.com/creack/pty" - "github.com/mdlayher/vsock" - "github.com/semistrict/lnx/internal/protocol" - "golang.org/x/sys/unix" -) - -const ( - // criuDevice is the block device for CRIU images (vdc). - criuDevice = "/dev/vdc" - // criuMountPoint is where the CRIU device is mounted. - criuMountPoint = "/mnt/criu" - // criuForkDir is the subdirectory used for fork dumps. - criuForkDir = "/mnt/criu/fork" - // forkRolePath is written by the child after a fork restore. - forkRolePath = "/var/run/lnx/fork-role" -) - -// forkSession holds the PTY master and criu command of a CRIU-restored fork -// child, so the fork attach server can serve it to the host. -type forkSession struct { - ptmx *os.File // PTY master — restored process has the slave - pts *os.File // PTY slave — kept open to prevent EIO until host connects - pid int // restored process PID (session leader) - cmd *exec.Cmd // criu restore command (nil if --restore-detached was used) - cleanupDir string // directory to remove after criu exits - extraPTMX *os.File // criu's session PTY master (tty path); kept alive to prevent SIGHUP -} - -var pendingFork struct { - mu sync.Mutex - sess *forkSession -} - -func setPendingForkSession(fs *forkSession) { - pendingFork.mu.Lock() - pendingFork.sess = fs - pendingFork.mu.Unlock() -} - -func consumePendingForkSession() *forkSession { - pendingFork.mu.Lock() - defer pendingFork.mu.Unlock() - fs := pendingFork.sess - pendingFork.sess = nil - return fs -} - -// criuCheckpointMetadata is written alongside CRIU image dirs so we -// know what was dumped. -type criuCheckpointMetadata struct { - Name string `json:"name"` - PIDs []int `json:"pids"` - Timestamp time.Time `json:"timestamp"` - PipeInodes map[int][]string `json:"pipe_inodes,omitempty"` // PID → external pipe inodes - StdioPipes map[int][]string `json:"stdio_pipes,omitempty"` // PID → [stdout_inode, stderr_inode] - StdioTTY map[int]uint64 `json:"stdio_tty,omitempty"` // PID → tty rdev (if stdout is a PTY) -} - -// mountCRIUDevice mounts the CRIU block device. If the device has no -// filesystem (first boot), it formats it with ext4 first. -func mountCRIUDevice() { - if _, err := os.Stat(criuDevice); err != nil { - slog.Debug("no CRIU device, skipping", "device", criuDevice) - return - } - - os.MkdirAll(criuMountPoint, 0755) - - // Try mounting first (preserves existing data from checkpoint restore). - if err := syscall.Mount(criuDevice, criuMountPoint, "ext4", syscall.MS_NOATIME, "errors=continue"); err == nil { - slog.Info("mounted CRIU device", "device", criuDevice, "target", criuMountPoint) - return - } else { - slog.Warn("CRIU device mount failed, will format", "error", err) - } - - // Not formatted yet — format and mount. - slog.Info("formatting CRIU device", "device", criuDevice) - if out, err := exec.Command("/sbin/mke2fs", "-t", "ext4", "-q", criuDevice).CombinedOutput(); err != nil { - slog.Warn("mke2fs CRIU device failed", "error", err, "output", string(out)) - return - } - - if err := syscall.Mount(criuDevice, criuMountPoint, "ext4", syscall.MS_NOATIME, "errors=continue"); err != nil { - slog.Warn("mount CRIU device failed after format", "error", err) - return - } - slog.Info("formatted and mounted CRIU device", "device", criuDevice, "target", criuMountPoint) -} - -// syncCRIUVolume forces all dirty data on the CRIU filesystem to disk. -func syncCRIUVolume() { - syscall.Sync() -} - -// criuDump dumps each tracked user process tree with CRIU. -// Each PID gets its own sub-directory under dir. -// If leaveRunning is true, processes continue after the dump. -func criuDump(name, dir string, leaveRunning bool) error { - pids := listUserPIDs() - if len(pids) == 0 { - return fmt.Errorf("no user processes to checkpoint") - } - - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("create CRIU dir: %w", err) - } - - var dumpedPIDs []int - pipeInodesMap := make(map[int][]string) - stdioPipesMap := make(map[int][]string) - stdioTTYMap := make(map[int]uint64) - var lastDumpErr string - - for _, pid := range pids { - pidDir := filepath.Join(dir, strconv.Itoa(pid)) - if err := os.MkdirAll(pidDir, 0755); err != nil { - return fmt.Errorf("create PID dir %d: %w", pid, err) - } - - // Find unsupported sockets (vsock etc) and externalize them - // so CRIU drops them during dump instead of failing. - externals := findUnsupportedSocketInodes(pid) - - args := []string{ - "dump", - "--tree", strconv.Itoa(pid), - "--images-dir", pidDir, - "--shell-job", - "--tcp-established", - "--ext-unix-sk", - } - for _, inode := range externals { - args = append(args, "--external", "socket["+inode+"]") - } - - // Find pipes shared with init (fork pipes etc.) and externalize - // them so CRIU doesn't try to checkpoint cross-boundary pipes. - pipeInodes := findExternalPipeInodes(pid) - for _, inode := range pipeInodes { - args = append(args, "--external", "pipe["+inode+"]") - } - - if leaveRunning { - args = append(args, "--leave-running") - } - - slog.Info("criu dump", "pid", pid, "dir", pidDir, "leaveRunning", leaveRunning, - "externals", len(externals), "pipes", len(pipeInodes)) - cmd := exec.Command("criu", args...) - output, err := cmd.CombinedOutput() - if err != nil { - // If this process can't be dumped (e.g. unsupported socket - // types that --external can't handle), skip it and try the - // rest. This happens for exec session shells that inherit - // vsock FDs from init. - lastDumpErr = fmt.Sprintf("pid %d: %v: %s", pid, err, string(output)) - slog.Warn("criu dump failed, skipping", "pid", pid, "error", err, - "output", string(output)) - os.RemoveAll(pidDir) - continue - } - dumpedPIDs = append(dumpedPIDs, pid) - // Record stdout/stderr info so the restore path can wire them - // to a PTY instead of /dev/null or a disconnected tty. - stdioPipes := findStdioPipeInodes(pid) - if len(stdioPipes) > 0 { - stdioPipesMap[pid] = stdioPipes - } - if rdev := findStdioTTYRdev(pid); rdev != 0 { - stdioTTYMap[pid] = rdev - } - if len(pipeInodes) > 0 { - pipeInodesMap[pid] = pipeInodes - } - } - - if len(dumpedPIDs) == 0 { - if lastDumpErr != "" { - return fmt.Errorf("no user processes were dumped (last: %s)", lastDumpErr) - } - return fmt.Errorf("no user processes were dumped") - } - - // Write metadata. - meta := criuCheckpointMetadata{ - Name: name, - PIDs: dumpedPIDs, - Timestamp: time.Now(), - PipeInodes: pipeInodesMap, - StdioPipes: stdioPipesMap, - StdioTTY: stdioTTYMap, - } - metaData, err := json.Marshal(meta) - if err != nil { - return fmt.Errorf("marshal metadata: %w", err) - } - if err := os.WriteFile(filepath.Join(dir, "metadata.json"), metaData, 0644); err != nil { - return fmt.Errorf("write metadata: %w", err) - } - - return nil -} - -// findUnsupportedSocketInodes returns the inodes of sockets in the -// process tree that CRIU can't handle natively (e.g. vsock). -// These should be passed as --external socket[inode] to CRIU dump. -func findUnsupportedSocketInodes(pid int) []string { - // Walk the process tree rooted at pid. - var allPids []int - allPids = append(allPids, pid) - // Read children from /proc//task//children file. - if data, err := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", pid, pid)); err == nil { - for _, f := range strings.Fields(string(data)) { - if childPid, err := strconv.Atoi(f); err == nil { - allPids = append(allPids, childPid) - } - } - } - - // Collect known socket inodes across all pids. - knownInodes := make(map[string]bool) - for _, p := range allPids { - for k, v := range collectKnownSocketInodes(p) { - knownInodes[k] = v - } - } - - // Find socket inodes that aren't in the known set. - seen := make(map[string]bool) - var inodes []string - for _, p := range allPids { - fdDir := fmt.Sprintf("/proc/%d/fd", p) - entries, err := os.ReadDir(fdDir) - if err != nil { - continue - } - for _, e := range entries { - link, err := os.Readlink(filepath.Join(fdDir, e.Name())) - if err != nil || !strings.HasPrefix(link, "socket:[") { - continue - } - inode := strings.TrimSuffix(strings.TrimPrefix(link, "socket:["), "]") - if !knownInodes[inode] && !seen[inode] { - seen[inode] = true - inodes = append(inodes, inode) - } - } - } - return inodes -} - -// collectKnownSocketInodes reads /proc//net/{tcp,tcp6,udp,udp6,unix} -// and returns a set of socket inodes that CRIU can handle natively. -func collectKnownSocketInodes(pid int) map[string]bool { - known := make(map[string]bool) - netDir := fmt.Sprintf("/proc/%d/net", pid) - - for _, name := range []string{"tcp", "tcp6", "udp", "udp6", "unix"} { - data, err := os.ReadFile(filepath.Join(netDir, name)) - if err != nil { - continue - } - for _, line := range strings.Split(string(data), "\n") { - fields := strings.Fields(line) - if len(fields) < 10 { - continue - } - // For tcp/udp: inode is field 9 (0-indexed). - // For unix: inode is field 6. - var inode string - if name == "unix" { - if len(fields) >= 7 { - inode = fields[6] - } - } else { - inode = fields[9] - } - if inode != "" && inode != "0" { - known[inode] = true - } - } - } - return known -} - -// findStdioPipeInodes returns the pipe inodes for the process's stdout and -// stderr (fd 1 and fd 2), if they are pipes. Returns up to 2 inodes. -func findStdioPipeInodes(pid int) []string { - var inodes []string - for _, fd := range []string{"1", "2"} { - link, err := os.Readlink(fmt.Sprintf("/proc/%d/fd/%s", pid, fd)) - if err != nil || !strings.HasPrefix(link, "pipe:[") { - continue - } - inode := strings.TrimSuffix(strings.TrimPrefix(link, "pipe:["), "]") - inodes = append(inodes, inode) - } - return inodes -} - -// findStdioTTYRdev returns the rdev of stdout (fd 1) if it's a tty device. -// Returns 0 if stdout is not a tty. -func findStdioTTYRdev(pid int) uint64 { - var st syscall.Stat_t - path := fmt.Sprintf("/proc/%d/fd/1", pid) - if err := syscall.Stat(path, &st); err != nil { - return 0 - } - // Check if it's a character device (tty). - if st.Mode&syscall.S_IFMT != syscall.S_IFCHR { - return 0 - } - return st.Rdev -} - -// findExternalPipeInodes returns pipe inodes in the target process that -// are also held by init (us, PID 1). These are pipes that cross the dump -// boundary — one end in init, one end in the target — and must be -// externalized so CRIU doesn't try to checkpoint half a pipe. -func findExternalPipeInodes(pid int) []string { - // Collect pipe inodes from the target process. - targetPipes := make(map[string]bool) - fdDir := fmt.Sprintf("/proc/%d/fd", pid) - entries, err := os.ReadDir(fdDir) - if err != nil { - return nil - } - for _, e := range entries { - link, err := os.Readlink(filepath.Join(fdDir, e.Name())) - if err != nil || !strings.HasPrefix(link, "pipe:[") { - continue - } - inode := strings.TrimSuffix(strings.TrimPrefix(link, "pipe:["), "]") - targetPipes[inode] = true - } - - // Also check direct children (CRIU dumps the whole tree). - if data, err := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", pid, pid)); err == nil { - for _, f := range strings.Fields(string(data)) { - childPid, err := strconv.Atoi(f) - if err != nil { - continue - } - childFdDir := fmt.Sprintf("/proc/%d/fd", childPid) - childEntries, err := os.ReadDir(childFdDir) - if err != nil { - continue - } - for _, ce := range childEntries { - link, err := os.Readlink(filepath.Join(childFdDir, ce.Name())) - if err != nil || !strings.HasPrefix(link, "pipe:[") { - continue - } - inode := strings.TrimSuffix(strings.TrimPrefix(link, "pipe:["), "]") - targetPipes[inode] = true - } - } - } - - if len(targetPipes) == 0 { - return nil - } - - // Collect pipe inodes from init (us). - initPipes := make(map[string]bool) - selfEntries, err := os.ReadDir("/proc/self/fd") - if err != nil { - return nil - } - for _, e := range selfEntries { - link, err := os.Readlink(filepath.Join("/proc/self/fd", e.Name())) - if err != nil || !strings.HasPrefix(link, "pipe:[") { - continue - } - inode := strings.TrimSuffix(strings.TrimPrefix(link, "pipe:["), "]") - initPipes[inode] = true - } - - // External = pipe inodes present in both init and the target tree. - var external []string - for inode := range targetPipes { - if initPipes[inode] { - external = append(external, inode) - } - } - sort.Strings(external) - return external -} - -// criuRestore restores processes from CRIU images in dir. -// Each sub-directory should contain a CRIU image set for one process tree. -func criuRestore(dir string) error { - metaPath := filepath.Join(dir, "metadata.json") - data, err := os.ReadFile(metaPath) - if err != nil { - return fmt.Errorf("read metadata: %w", err) - } - - var meta criuCheckpointMetadata - if err := json.Unmarshal(data, &meta); err != nil { - return fmt.Errorf("parse metadata: %w", err) - } - - for _, pid := range meta.PIDs { - pidDir := filepath.Join(dir, strconv.Itoa(pid)) - if _, err := os.Stat(pidDir); err != nil { - slog.Warn("criu restore: PID dir missing, skipping", "pid", pid, "dir", pidDir) - continue - } - - args := []string{ - "restore", - "--images-dir", pidDir, - "--shell-job", - "--restore-detached", - "--tcp-established", - "--ext-unix-sk", - } - - // For each external pipe inode, open /dev/null as a replacement fd. - // CRIU will wire the restored process's pipe endpoints to /dev/null, - // so reads return EOF (how fork children detect they're restored). - var extraFiles []*os.File - if inodes := meta.PipeInodes[pid]; len(inodes) > 0 { - for i, inode := range inodes { - f, err := os.Open("/dev/null") - if err != nil { - return fmt.Errorf("open /dev/null for pipe inherit: %w", err) - } - extraFiles = append(extraFiles, f) - fdNum := 3 + i // ExtraFiles[i] → fd 3+i in CRIU's process - args = append(args, "--inherit-fd", fmt.Sprintf("fd[%d]:pipe:[%s]", fdNum, inode)) - } - } - - slog.Info("criu restore", "pid", pid, "dir", pidDir, "inherit_pipes", len(extraFiles)) - cmd := exec.Command("criu", args...) - cmd.ExtraFiles = extraFiles - output, err := cmd.CombinedOutput() - for _, f := range extraFiles { - f.Close() - } - if err != nil { - return fmt.Errorf("criu restore pid %d: %w\n%s", pid, err, string(output)) - } - } - - return nil -} - -// criuRestoreForFork restores processes from CRIU images into a PTY so -// the host can attach and read the restored process's terminal output. -// The PTY master and first restored PID are stored as a pending fork session. -func criuRestoreForFork(dir string) error { - metaPath := filepath.Join(dir, "metadata.json") - data, err := os.ReadFile(metaPath) - if err != nil { - return fmt.Errorf("read metadata: %w", err) - } - - var meta criuCheckpointMetadata - if err := json.Unmarshal(data, &meta); err != nil { - return fmt.Errorf("parse metadata: %w", err) - } - if len(meta.PIDs) == 0 { - return fmt.Errorf("no PIDs in metadata") - } - - // Create a PTY pair. Stdio pipes (stdout/stderr) from the dump are - // wired to the PTY slave so output appears on our PTY master. - // Other external pipes (fork pipes) are wired to /dev/null as before. - ptmx, pts, err := pty.Open() - if err != nil { - return fmt.Errorf("open pty: %w", err) - } - - var firstPID int - var firstCriuPtmx *os.File - for _, pid := range meta.PIDs { - pidDir := filepath.Join(dir, strconv.Itoa(pid)) - if _, err := os.Stat(pidDir); err != nil { - slog.Warn("criu fork restore: PID dir missing, skipping", "pid", pid, "dir", pidDir) - continue - } - - args := []string{ - "restore", - "--images-dir", pidDir, - "--shell-job", - "--restore-detached", - "--tcp-established", - "--ext-unix-sk", - } - - // Build a set of stdio pipe inodes so we can wire them to the PTY. - stdioSet := make(map[string]bool) - for _, inode := range meta.StdioPipes[pid] { - stdioSet[inode] = true - } - - // Wire external pipe inodes: stdio pipes → PTY slave, others → /dev/null. - var extraFiles []*os.File - if inodes := meta.PipeInodes[pid]; len(inodes) > 0 { - for _, inode := range inodes { - var f *os.File - if stdioSet[inode] { - // Dup the PTY slave for each stdio pipe. - dupFd, err := syscall.Dup(int(pts.Fd())) - if err != nil { - pts.Close() - ptmx.Close() - return fmt.Errorf("dup pty slave: %w", err) - } - f = os.NewFile(uintptr(dupFd), "pts-dup") - } else { - var err error - f, err = os.Open("/dev/null") - if err != nil { - pts.Close() - ptmx.Close() - return fmt.Errorf("open /dev/null for pipe inherit: %w", err) - } - } - fdNum := 3 + len(extraFiles) - extraFiles = append(extraFiles, f) - args = append(args, "--inherit-fd", fmt.Sprintf("fd[%d]:pipe:[%s]", fdNum, inode)) - } - } - - // If stdout was a tty, map the tty device to our PTY slave. - if rdev := meta.StdioTTY[pid]; rdev != 0 { - dupFd, err := syscall.Dup(int(pts.Fd())) - if err != nil { - pts.Close() - ptmx.Close() - return fmt.Errorf("dup pty slave for tty: %w", err) - } - fdNum := 3 + len(extraFiles) - extraFiles = append(extraFiles, os.NewFile(uintptr(dupFd), "pts-tty")) - args = append(args, "--inherit-fd", fmt.Sprintf("fd[%d]:tty[%x]", fdNum, rdev)) - } - - slog.Info("criu fork restore", "pid", pid, "dir", pidDir, - "pipes", len(extraFiles), "stdioPipes", len(stdioSet), - "ttyRdev", fmt.Sprintf("0x%x", meta.StdioTTY[pid])) - - cmd := exec.Command("criu", args...) - cmd.ExtraFiles = extraFiles - if meta.StdioTTY[pid] != 0 { - // For tty-based processes, criu needs a controlling terminal - // session for --shell-job. Set stdin to our PTY slave. - cmd.Stdin = pts - } - output, err := cmd.CombinedOutput() - if err != nil { - pts.Close() - ptmx.Close() - return fmt.Errorf("criu fork restore pid %d: %w\n%s", pid, err, string(output)) - } - if firstPID == 0 { - firstPID = pid - } - } - - if firstPID == 0 { - pts.Close() - ptmx.Close() - return fmt.Errorf("no PIDs were restored") - } - - // Keep pts open — if the restored process exits before the host - // connects, the PTY slave reference keeps the master readable - // (buffered data won't be lost to EIO). - setPendingForkSession(&forkSession{ptmx: ptmx, pts: pts, pid: firstPID, cleanupDir: dir, extraPTMX: firstCriuPtmx}) - slog.Info("fork session ready", "pid", firstPID) - return nil -} - -// startForkAttachServer listens on the fork attach vsock ports and serves -// the pending fork session's PTY to a single host connection. After the -// restored process exits, the server shuts down. -func startForkAttachServer(fs *forkSession) { - gobLn, err := vsock.Listen(protocol.ForkAttachPort, nil) - if err != nil { - slog.Error("fork attach listen failed", "port", protocol.ForkAttachPort, "error", err) - fs.ptmx.Close() - return - } - - dataLn, err := vsock.Listen(protocol.ForkAttachDataPort, nil) - if err != nil { - slog.Error("fork attach data listen failed", "port", protocol.ForkAttachDataPort, "error", err) - gobLn.Close() - fs.ptmx.Close() - return - } - - go func() { - defer gobLn.Close() - defer dataLn.Close() - defer fs.ptmx.Close() - - // Accept one gob connection from the host. - gobConn, err := gobLn.Accept() - if err != nil { - slog.Error("fork attach accept failed", "error", err) - return - } - defer gobConn.Close() - enc := gob.NewEncoder(gobConn) - dec := gob.NewDecoder(gobConn) - - // Read ExecReq for PTY dimensions. - var msg protocol.Msg - if err := dec.Decode(&msg); err != nil { - slog.Error("fork attach read request failed", "error", err) - return - } - if msg.ExecReq != nil && msg.ExecReq.Rows > 0 && msg.ExecReq.Cols > 0 { - unix.IoctlSetWinsize(int(fs.ptmx.Fd()), unix.TIOCSWINSZ, &unix.Winsize{ - Row: msg.ExecReq.Rows, - Col: msg.ExecReq.Cols, - }) - } - - // Send ExecStarted. - if err := enc.Encode(protocol.Msg{ExecStarted: &protocol.ExecStarted{PID: fs.pid}}); err != nil { - slog.Error("fork attach send started failed", "error", err) - return - } - - // Accept PTY data connection from host. - dataConn, err := dataLn.Accept() - if err != nil { - slog.Error("fork attach data accept failed", "error", err) - return - } - defer dataConn.Close() - - // Now that the host is connected, close our extra PTY slave ref. - // The restored process (if still alive) holds its own ref. When it - // exits, the slave closes fully → master drains buffer then returns EIO. - if fs.pts != nil { - fs.pts.Close() - fs.pts = nil - } - - // Handle signals and resize from host. - go func() { - for { - var msg protocol.Msg - if err := dec.Decode(&msg); err != nil { - return - } - if msg.ExecSignal != nil { - syscall.Kill(-fs.pid, syscall.Signal(msg.ExecSignal.Sig)) - } - if msg.ExecResize != nil { - unix.IoctlSetWinsize(int(fs.ptmx.Fd()), unix.TIOCSWINSZ, &unix.Winsize{ - Row: msg.ExecResize.Rows, - Col: msg.ExecResize.Cols, - }) - } - } - }() - - // Splice PTY ↔ data connection. - done := make(chan struct{}) - go func() { - io.Copy(fs.ptmx, dataConn) - close(done) - }() - io.Copy(dataConn, fs.ptmx) - dataConn.Close() - <-done - - // PTY read returned (slave closed — process exited). Collect exit code. - exitCode := 0 - if fs.cmd != nil { - if err := fs.cmd.Wait(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - slog.Warn("fork attach cmd.Wait failed", "error", err) - exitCode = 1 - } - } - } else { - var ws syscall.WaitStatus - _, err = syscall.Wait4(fs.pid, &ws, 0, nil) - if err != nil { - slog.Warn("fork attach wait4 failed", "pid", fs.pid, "error", err) - exitCode = 1 - } else if ws.Exited() { - exitCode = ws.ExitStatus() - } else if ws.Signaled() { - exitCode = 128 + int(ws.Signal()) - } - } - - enc.Encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: exitCode}}) - - // Clean up resources now that the process has exited. - if fs.pts != nil { - fs.pts.Close() - } - if fs.extraPTMX != nil { - fs.extraPTMX.Close() - } - if fs.cleanupDir != "" { - os.RemoveAll(fs.cleanupDir) - } - }() -} - -// criuAutoRestore detects CRIU images on the CRIU volume from a fork or -// checkpoint restore and restores the processes automatically. -// Fork detection takes priority over checkpoint restore. -func criuAutoRestore() { - // Check for fork images first. - forkMeta := filepath.Join(criuForkDir, "metadata.json") - if _, err := os.Stat(forkMeta); err == nil { - slog.Info("detected CRIU fork images, restoring as child") - os.MkdirAll(filepath.Dir(forkRolePath), 0755) - os.WriteFile(forkRolePath, []byte("child\n"), 0644) - - if err := criuRestoreForFork(criuForkDir); err != nil { - slog.Error("CRIU fork restore failed", "error", err) - } else { - slog.Info("CRIU fork restore complete") - // Start the fork attach server immediately so the PTY - // buffer is read before the restored process exits. - if fs := consumePendingForkSession(); fs != nil { - startForkAttachServer(fs) - } - } - return - } - - // Check for checkpoint images (any subdirectory with metadata.json). - entries, err := os.ReadDir(criuMountPoint) - if err != nil { - return - } - - for _, e := range entries { - if !e.IsDir() || e.Name() == "lost+found" { - continue - } - dir := filepath.Join(criuMountPoint, e.Name()) - metaPath := filepath.Join(dir, "metadata.json") - if _, err := os.Stat(metaPath); err != nil { - continue - } - - slog.Info("detected CRIU checkpoint images, restoring", "name", e.Name()) - if err := criuRestore(dir); err != nil { - slog.Error("CRIU checkpoint restore failed", "name", e.Name(), "error", err) - } else { - slog.Info("CRIU checkpoint restore complete", "name", e.Name()) - } - os.RemoveAll(dir) - return // only restore the first one - } -} - -// installForkRoleHelper writes a script that returns the fork role -// ("parent", "child", or exits 1 if not in a fork). -func installForkRoleHelper() { - script := `#!/bin/sh -if [ -f ` + forkRolePath + ` ]; then - cat ` + forkRolePath + ` -else - echo "not a fork" - exit 1 -fi -` - if err := os.WriteFile("/usr/local/bin/lnx-fork-role", []byte(script), 0755); err != nil { - slog.Warn("failed to install lnx-fork-role helper", "error", err) - } -} diff --git a/old/cmd/init/exec.go b/old/cmd/init/exec.go deleted file mode 100644 index be39f22..0000000 --- a/old/cmd/init/exec.go +++ /dev/null @@ -1,797 +0,0 @@ -//go:build linux - -package main - -import ( - "encoding/gob" - "fmt" - "io" - "log/slog" - "net" - "os" - "os/exec" - "sort" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "github.com/creack/pty" - "github.com/mdlayher/vsock" - "github.com/semistrict/lnx/internal/protocol" - "golang.org/x/sys/unix" -) - -// listUserPIDs scans /proc for all session-leader processes owned by the -// setup user. These are the top-level process trees that CRIU should dump. -// Excludes PID 1 (init) and kernel threads. -func listUserPIDs() []int { - entries, err := os.ReadDir("/proc") - if err != nil { - return nil - } - - myPID := os.Getpid() - var pids []int - - for _, e := range entries { - pid, err := strconv.Atoi(e.Name()) - if err != nil || pid <= 1 || pid == myPID { - continue - } - - // Read process status to check UID and session ID. - statusPath := fmt.Sprintf("/proc/%d/status", pid) - data, err := os.ReadFile(statusPath) - if err != nil { - continue // process may have exited - } - - // Parse UID line: "Uid:\treal\teffective\tsaved\tfs" - uid := -1 - sid := -1 - for _, line := range strings.Split(string(data), "\n") { - if strings.HasPrefix(line, "Uid:") { - fields := strings.Fields(line) - if len(fields) >= 2 { - uid, _ = strconv.Atoi(fields[1]) - } - } - } - - // Read session ID from /proc//stat (field 6). - statData, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) - if err != nil { - continue - } - // Skip past comm field (may contain spaces/parens). - if idx := strings.LastIndex(string(statData), ")"); idx >= 0 { - fields := strings.Fields(string(statData)[idx+2:]) - if len(fields) >= 4 { - sid, _ = strconv.Atoi(fields[3]) // field 6 = session ID (0-indexed field 3 after ")") - } - } - - // Only include user processes that are session leaders. - // Session leader: PID == SID. - if setupUID > 0 && uid != setupUID { - continue - } - if sid != pid { - continue // not a session leader - } - - pids = append(pids, pid) - } - - sort.Ints(pids) - return pids -} - -const ( - // forkRequestFD is the fd number the child writes to to request a fork. - // Passed as ExtraFiles[0] → child sees it as fd 3. - forkRequestFD = 3 - // forkResultFD is the fd number the child reads from to get the fork result. - // Passed as ExtraFiles[1] → child sees it as fd 4. - forkResultFD = 4 -) - -// ensureCloseOnExec sets close-on-exec on all file descriptors from minFD -// upward. This is a safety net against fd leaks (e.g. vsock fds without -// CLOEXEC) that would otherwise be inherited by child processes and cause -// CRIU to fail with "Unknown socket collected (family 40)". -// Go's exec.Cmd already closes extra fds in the child, but this catches -// any fds that slip through (race between goroutines creating fds and fork). -func ensureCloseOnExec(minFD int) { - entries, err := os.ReadDir("/proc/self/fd") - if err != nil { - return - } - for _, e := range entries { - fd, err := strconv.Atoi(e.Name()) - if err != nil || fd < minFD { - continue - } - unix.CloseOnExec(fd) - } -} - -// createForkPipes creates the pipe pairs for fork communication. -// Returns (requestRead, requestWrite, resultRead, resultWrite). -// requestWrite (fd 3) and resultRead (fd 4) go to the child via ExtraFiles. -// requestRead and resultWrite are kept by init. -func createForkPipes() (reqR, reqW, resR, resW *os.File, err error) { - rr, rw, err := os.Pipe() - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("fork request pipe: %w", err) - } - sr, sw, err := os.Pipe() - if err != nil { - rr.Close() - rw.Close() - return nil, nil, nil, nil, fmt.Errorf("fork result pipe: %w", err) - } - return rr, rw, sr, sw, nil -} - -// handleForkPipe reads fork requests from the pipe and triggers forks. -// Runs until the pipe is closed (child exited). After a successful fork, -// sends a ForkNotify on the exec session's gob connection so the host -// can tell the CLI about the new child instance. -func handleForkPipe(reqR, resW *os.File, sess *execSession) { - defer reqR.Close() - defer resW.Close() - - buf := make([]byte, 64) - for { - n, err := reqR.Read(buf) - if err != nil { - return // pipe closed, child exited - } - cmd := strings.TrimSpace(string(buf[:n])) - if cmd != "fork" { - continue - } - - // Trigger fork via the guest control handler. - result := doGuestFork() - - // Notify the host BEFORE writing to the result pipe — the parent - // process may exit immediately after reading the result, and the - // CLI needs to know about the fork before ExecDone arrives. - // Skip for child (exec session is dead) and errors. - if !strings.HasPrefix(result, "error:") && result != "child" { - sess.encode(protocol.Msg{ForkNotify: &protocol.ForkNotify{Instance: result}}) - } - - resW.Write([]byte(result + "\n")) - } -} - -// doGuestFork asks the host to fork the VM. -// For CRIU: dumps user processes first, then sends ForkReq. -// For QEMU: sends ForkReq directly (host handles CPR-reboot migration). -// Returns the child instance name for the parent, "child" for the child, -// or "error: ...". -func doGuestFork() string { - // CRIU dump if available — skip for QEMU VMs (no criu binary). - hasCRIU := false - if _, err := exec.LookPath("criu"); err == nil { - hasCRIU = true - if err := criuDump("fork", criuForkDir, true); err != nil { - return "error: " + err.Error() - } - syscall.Sync() - } - - gc := getGuestControl() - if gc == nil { - return "error: guest control not available" - } - - gc.mu.Lock() - defer gc.mu.Unlock() - - // Query the current instance name before forking. - if err := gc.enc.Encode(protocol.Msg{InstanceNameReq: &protocol.InstanceNameReq{}}); err != nil { - return "error: " + err.Error() - } - var nameMsg protocol.Msg - if err := gc.dec.Decode(&nameMsg); err != nil { - return "error: " + err.Error() - } - if nameMsg.InstanceNameResp == nil { - return "error: unexpected response to instance name query" - } - beforeName := nameMsg.InstanceNameResp.Name - - // Ask the host to fork. - if err := gc.enc.Encode(protocol.Msg{ForkReq: &protocol.ForkReq{}}); err != nil { - return "error: " + err.Error() - } - - var msg protocol.Msg - if err := gc.dec.Decode(&msg); err != nil { - // Connection died — likely forked (QEMU CPR-reboot). - // Reconnect to the (possibly new) host and check the instance name. - return detectForkRole(beforeName) - } - if msg.ForkResp == nil { - return "error: unexpected response" - } - if msg.ForkResp.Error != "" { - return "error: " + msg.ForkResp.Error - } - - // Clean up fork dump from parent. - if hasCRIU { - os.RemoveAll(criuForkDir) - } - return msg.ForkResp.Instance -} - -// detectForkRole reconnects the guest control and compares the instance -// name to determine if we're the parent or child after a QEMU fork. -func detectForkRole(beforeName string) string { - conn, err := vsock.Dial(vsockHostCID, protocol.GuestControlPort, nil) - if err != nil { - return "error: reconnect guest control: " + err.Error() - } - - enc := gob.NewEncoder(conn) - dec := gob.NewDecoder(conn) - - if err := enc.Encode(protocol.Msg{InstanceNameReq: &protocol.InstanceNameReq{}}); err != nil { - conn.Close() - return "error: query instance name: " + err.Error() - } - var msg protocol.Msg - if err := dec.Decode(&msg); err != nil { - conn.Close() - return "error: read instance name: " + err.Error() - } - conn.Close() - - if msg.InstanceNameResp == nil { - return "error: unexpected response to instance name query" - } - - if msg.InstanceNameResp.Name != beforeName { - return "child" - } - // Same name — this wasn't a fork, the connection just died. - return "error: guest control connection lost" -} - -var ( - execListener *vsock.Listener - execInteractiveLn *vsock.Listener - execListenerMu sync.Mutex -) - -// startExecServer listens on the exec vsock port and handles one -// exec request per connection. Multiple connections are accepted -// concurrently so `lnx exec` works while the main command runs. -func startExecServer() { - execListenerMu.Lock() - // Close old listeners if restarting (e.g., after fork/migration). - if execListener != nil { - execListener.Close() - execListener = nil - } - if execInteractiveLn != nil { - execInteractiveLn.Close() - execInteractiveLn = nil - } - execListenerMu.Unlock() - - ln, err := vsock.Listen(protocol.ExecPort, nil) - if err != nil { - slog.Warn("exec listen failed", "error", err) - return - } - - iln, err := vsock.Listen(protocol.ExecInteractivePort, nil) - if err != nil { - slog.Warn("exec interactive listen failed", "error", err) - ln.Close() - return - } - - execListenerMu.Lock() - execListener = ln - execInteractiveLn = iln - execListenerMu.Unlock() - - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - go handleExecConn(conn.(*vsock.Conn), iln) - } - }() -} - -// startReverseExecServer dials the host on the exec port to offer exec -// services. After CPR-reboot migration, the guest kernel can't accept -// host-initiated vsock connections at the userspace level (the kernel -// handles the protocol handshake but never wakes up Accept). Guest→host -// connections work fine, so we reverse the direction. -func startReverseExecServer() { - execListenerMu.Lock() - if execListener != nil { - execListener.Close() - execListener = nil - } - if execInteractiveLn != nil { - execInteractiveLn.Close() - execInteractiveLn = nil - } - execListenerMu.Unlock() - - go func() { - for { - // Dial the host's exec listener. The host accepts and uses - // this connection to send ExecReq when an exec is needed. - conn, err := vsock.Dial(vsockHostCID, protocol.ExecPort, nil) - if err != nil { - slog.Warn("reverse exec dial failed", "error", err) - return - } - // Block until the host sends an exec request on this connection. - handleExecConnNet(conn, nil) - } - }() -} - -// handleExecConnNet handles an exec connection from a net.Conn -// (used by the raw syscall path after migration). Simplified to -// avoid dependencies on Go runtime features that break after CPR-reboot. -func handleExecConnNet(conn net.Conn, interactiveLn *vsock.Listener) { - defer conn.Close() - enc := gob.NewEncoder(conn) - dec := gob.NewDecoder(conn) - - var msg protocol.Msg - if err := dec.Decode(&msg); err != nil { - return - } - if msg.ExecReq == nil { - return - } - req := msg.ExecReq - - cmd := exec.Command(req.Args[0], req.Args[1:]...) - cmd.Dir = "/" - if setupCWD != "" { - if _, err := os.Stat(setupCWD); err == nil { - cmd.Dir = setupCWD - } - } - cmd.Env = os.Environ() - if setupUID > 0 { - cmd.SysProcAttr = &syscall.SysProcAttr{ - Credential: &syscall.Credential{ - Uid: uint32(setupUID), - Gid: uint32(setupUID), - }, - } - } - - output, err := cmd.CombinedOutput() - exitCode := 0 - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - exitCode = 127 - } - } - - if len(output) > 0 { - enc.Encode(protocol.Msg{ExecOutput: &protocol.ExecOutput{Stdout: output}}) - } - enc.Encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: exitCode}}) -} - -// execSession holds per-session state for signal/resize forwarding. -type execSession struct { - proc *os.Process - pgid int - ptyFd *os.File - mu sync.Mutex - - // encMu serializes writes to the gob encoder (used by main goroutine - // for ExecStarted/ExecDone and by handleForkPipe for ForkNotify). - encMu sync.Mutex - enc *gob.Encoder -} - -// encode sends a gob message on the session's exec connection, safely. -func (s *execSession) encode(msg protocol.Msg) error { - s.encMu.Lock() - defer s.encMu.Unlock() - return s.enc.Encode(msg) -} - -func (s *execSession) setProcess(p *os.Process) { - s.mu.Lock() - defer s.mu.Unlock() - s.proc = p - s.pgid = 0 - if p != nil { - if pgid, err := syscall.Getpgid(p.Pid); err == nil { - s.pgid = pgid - } - } -} - -func (s *execSession) setPTY(f *os.File) { - s.mu.Lock() - defer s.mu.Unlock() - s.ptyFd = f -} - -func (s *execSession) signal(sig syscall.Signal) error { - s.mu.Lock() - proc := s.proc - pgid := s.pgid - s.mu.Unlock() - - if pgid > 0 { - return syscall.Kill(-pgid, sig) - } - if proc != nil { - return proc.Signal(sig) - } - return nil -} - -// readControlMessages reads ExecSignal and ExecResize messages from the gob -// decoder and applies them to this session's process/PTY. Runs until the -// connection closes or an error occurs. -func (s *execSession) readControlMessages(dec *gob.Decoder) { - for { - var msg protocol.Msg - if err := dec.Decode(&msg); err != nil { - return - } - if msg.ExecSignal != nil { - _ = s.signal(syscall.Signal(msg.ExecSignal.Sig)) - } - if msg.ExecResize != nil { - s.mu.Lock() - f := s.ptyFd - s.mu.Unlock() - if f != nil { - _ = unix.IoctlSetWinsize(int(f.Fd()), unix.TIOCSWINSZ, &unix.Winsize{ - Row: msg.ExecResize.Rows, - Col: msg.ExecResize.Cols, - }) - } - } - } -} - -func handleExecConn(conn *vsock.Conn, interactiveLn *vsock.Listener) { - defer conn.Close() - enc := gob.NewEncoder(conn) - dec := gob.NewDecoder(conn) - - var msg protocol.Msg - if err := dec.Decode(&msg); err != nil { - return - } - if msg.ExecReq == nil { - return - } - - sess := &execSession{enc: enc} - go sess.readControlMessages(dec) - - if msg.ExecReq.PTY { - runExecPTY(msg.ExecReq, interactiveLn, sess) - } else { - runExecPipe(msg.ExecReq, sess) - } -} - -// runExecPTY handles an interactive exec request with a PTY. -func runExecPTY(req *protocol.ExecReq, ln *vsock.Listener, sess *execSession) { - if len(req.Args) == 0 { - sess.encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: 127}}) - return - } - - cmd := exec.Command(req.Args[0], req.Args[1:]...) - cmd.Env = os.Environ() - for _, kv := range req.Env { - cmd.Env = append(cmd.Env, kv) - } - switch { - case req.CWD != "": - cmd.Dir = req.CWD - case setupCWD != "": - cmd.Dir = setupCWD - default: - cmd.Dir = os.Getenv("HOME") - } - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setsid: true, // CRIU requires session leaders - } - if setupUID > 0 { - cmd.SysProcAttr.Credential = &syscall.Credential{ - Uid: uint32(setupUID), - Gid: uint32(setupUID), - Groups: lookupSupplementaryGroups(setupUID), - } - } - - // Fork pipes: child gets fd 3 (write fork request) and fd 4 (read result). - reqR, reqW, resR, resW, err := createForkPipes() - if err != nil { - slog.Warn("create fork pipes failed", "error", err) - sess.encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: 127}}) - return - } - cmd.ExtraFiles = []*os.File{reqW, resR} // fd 3, fd 4 in child - defer reqR.Close() - defer resW.Close() - - ensureCloseOnExec(3) // prevent vsock/other fd leaks to child - - ptmx, err := pty.Start(cmd) - if err != nil { - reqW.Close() - resR.Close() - slog.Warn("exec pty start failed", "args", req.Args, "error", err) - if len(req.Args) > 0 { - commandNotFound(sess.enc, req.Args, err) - } - sess.encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: 127}}) - return - } - defer ptmx.Close() - - // Close child's end of fork pipes (they're dup'd into the child). - reqW.Close() - resR.Close() - - // Handle fork requests from the child in the background. - go handleForkPipe(reqR, resW, sess) - - // Report guest PID to host. - sess.encode(protocol.Msg{ExecStarted: &protocol.ExecStarted{PID: cmd.Process.Pid}}) - - if req.Rows > 0 && req.Cols > 0 { - unix.IoctlSetWinsize(int(ptmx.Fd()), unix.TIOCSWINSZ, &unix.Winsize{ - Row: req.Rows, - Col: req.Cols, - }) - } - - // Register PTY and process with the per-session handler for signals/resize. - sess.setPTY(ptmx) - sess.setProcess(cmd.Process) - - // Accept connection from host for raw terminal I/O. - vsockConn, err := ln.Accept() - if err != nil { - slog.Warn("exec interactive accept failed", "args", req.Args, "error", err) - cmd.Process.Kill() - cmd.Wait() - sess.encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: 127}}) - return - } - defer vsockConn.Close() - - // Splice: vsock ↔ PTY. - done := make(chan struct{}) - go func() { - io.Copy(ptmx, vsockConn) - close(done) - }() - io.Copy(vsockConn, ptmx) - vsockConn.Close() - <-done - - // Connection dropped. If the process is still running, give it a chance - // to exit gracefully (SIGHUP, like a terminal hangup), then force-kill. - waitCh := make(chan error, 1) - go func() { waitCh <- cmd.Wait() }() - - // Try SIGHUP first (terminal hangup — shells handle this). - _ = sess.signal(syscall.SIGHUP) - - exitCode := 0 - select { - case err := <-waitCh: - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else if err != nil { - exitCode = 127 - } - case <-time.After(3 * time.Second): - _ = sess.signal(syscall.SIGKILL) - err := <-waitCh - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else if err != nil { - exitCode = 137 - } - } - - sess.encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: exitCode}}) -} - -// commandNotFound writes "name: command not found" to the gob encoder -// as ExecOutput when the error is ErrNotFound. -func commandNotFound(enc *gob.Encoder, args []string, err error) { - // pty.Start wraps the error, check the message - if cmd_err, ok := err.(*exec.Error); ok && cmd_err.Err == exec.ErrNotFound { - enc.Encode(protocol.Msg{ExecOutput: &protocol.ExecOutput{ - Stderr: []byte(args[0] + ": command not found\n"), - }}) - } -} - -// lookupSupplementaryGroups reads /etc/group to find all groups that contain -// the user with the given UID. Returns the group IDs for use in Credential.Groups. -func lookupSupplementaryGroups(uid int) []uint32 { - // Find the username for this UID from /etc/passwd. - var username string - if data, err := os.ReadFile("/etc/passwd"); err == nil { - for _, line := range strings.Split(string(data), "\n") { - parts := strings.SplitN(line, ":", 4) - if len(parts) >= 3 { - if uidStr := parts[2]; uidStr == fmt.Sprintf("%d", uid) { - username = parts[0] - break - } - } - } - } - if username == "" { - return nil - } - - var groups []uint32 - if data, err := os.ReadFile("/etc/group"); err == nil { - for _, line := range strings.Split(string(data), "\n") { - parts := strings.SplitN(line, ":", 4) - if len(parts) != 4 { - continue - } - for _, member := range strings.Split(parts[3], ",") { - if strings.TrimSpace(member) == username { - if gid, err := strconv.Atoi(parts[2]); err == nil { - groups = append(groups, uint32(gid)) - } - break - } - } - } - } - return groups -} - -// runExecPipe handles a non-interactive exec request with piped stdout/stderr. -func runExecPipe(req *protocol.ExecReq, sess *execSession) { - if len(req.Args) == 0 { - sess.encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: 127}}) - return - } - - cmd := exec.Command(req.Args[0], req.Args[1:]...) - cmd.Env = os.Environ() - for _, kv := range req.Env { - cmd.Env = append(cmd.Env, kv) - } - switch { - case req.CWD != "": - cmd.Dir = req.CWD - case setupCWD != "": - cmd.Dir = setupCWD - default: - cmd.Dir = os.Getenv("HOME") - } - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setsid: true, // CRIU requires session leaders - } - if setupUID > 0 { - cmd.SysProcAttr.Credential = &syscall.Credential{ - Uid: uint32(setupUID), - Gid: uint32(setupUID), - Groups: lookupSupplementaryGroups(setupUID), - } - } - - stdout, err := cmd.StdoutPipe() - if err != nil { - sess.encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: 127}}) - return - } - stderr, err := cmd.StderrPipe() - if err != nil { - sess.encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: 127}}) - return - } - - // Fork pipes: child gets fd 3 (write fork request) and fd 4 (read result). - reqR, reqW, resR, resW, err := createForkPipes() - if err != nil { - slog.Warn("create fork pipes failed", "error", err) - sess.encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: 127}}) - return - } - cmd.ExtraFiles = []*os.File{reqW, resR} // fd 3, fd 4 in child - - ensureCloseOnExec(3) // prevent vsock/other fd leaks to child - - if err := cmd.Start(); err != nil { - reqR.Close() - reqW.Close() - resR.Close() - resW.Close() - commandNotFound(sess.enc, req.Args, err) - sess.encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: 127}}) - return - } - - // Close child's end of fork pipes. - reqW.Close() - resR.Close() - - // Handle fork requests from the child in the background. - go handleForkPipe(reqR, resW, sess) - - // Report guest PID to host. - sess.encode(protocol.Msg{ExecStarted: &protocol.ExecStarted{PID: cmd.Process.Pid}}) - - sess.setProcess(cmd.Process) - - done := make(chan struct{}, 2) - stream := func(r io.Reader, isStderr bool) { - defer func() { done <- struct{}{} }() - buf := make([]byte, 4096) - for { - n, err := r.Read(buf) - if n > 0 { - out := &protocol.ExecOutput{} - data := make([]byte, n) - copy(data, buf[:n]) - if isStderr { - out.Stderr = data - } else { - out.Stdout = data - } - if encErr := sess.encode(protocol.Msg{ExecOutput: out}); encErr != nil { - return - } - } - if err != nil { - return - } - } - } - - go stream(stdout, false) - go stream(stderr, true) - <-done - <-done - - exitCode := 0 - if err := cmd.Wait(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - exitCode = 127 - } - } - - sess.encode(protocol.Msg{ExecDone: &protocol.ExecDone{ExitCode: exitCode}}) -} diff --git a/old/cmd/init/lazyfuse.go b/old/cmd/init/lazyfuse.go deleted file mode 100644 index c05591b..0000000 --- a/old/cmd/init/lazyfuse.go +++ /dev/null @@ -1,567 +0,0 @@ -//go:build linux - -package main - -import ( - "context" - "encoding/gob" - "fmt" - "hash/fnv" - "io" - "log/slog" - "os" - "path/filepath" - "strings" - "sync" - "syscall" - "time" - - "github.com/hanwen/go-fuse/v2/fs" - "github.com/hanwen/go-fuse/v2/fuse" - "github.com/mdlayher/vsock" - "github.com/semistrict/lnx/internal/protocol" -) - -// stableIno returns a deterministic inode number for a relative path. -// This ensures that after the kernel FORGETs an inode and re-Lookups it, -// go-fuse returns the same FUSE node ID. Without this, getcwd() fails -// because the kernel's dentry tree points to stale node IDs. -func stableIno(path string) uint64 { - h := fnv.New64a() - h.Write([]byte(path)) - ino := h.Sum64() - if ino == 0 { - ino = 1 // 0 means auto-generate in go-fuse - } - return ino -} - -// lazyCacheFS holds the lower (virtiofs, read-only) and cache (ext4, read-write) roots. -type lazyCacheFS struct { - lower string // absolute path to virtiofs staging mount - cache string // absolute path to ext4 cache directory - blockedPaths map[string]bool // relative paths to block (nil = no filtering) -} - -// isBlocked returns true if relPath (relative to the share root) is blocked. -// A path is blocked if it is in blockedPaths or is a descendant of one. -func (lfs *lazyCacheFS) isBlocked(relPath string) bool { - if len(lfs.blockedPaths) == 0 { - return false - } - if lfs.blockedPaths[relPath] { - return true - } - for dir := relPath; dir != "." && dir != ""; dir = filepath.Dir(dir) { - if lfs.blockedPaths[dir] { - return true - } - } - return false -} - -// homeSyncBlockedPaths mirrors the blockedDirs set from p9filter.go on the host. -// These paths (relative to $HOME) are hidden from the guest to protect credentials. -var homeSyncBlockedPaths = map[string]bool{ - ".ssh": true, - ".gnupg": true, - ".aws": true, - ".docker": true, - ".kube": true, - // macOS Keychain - "Library/Keychains": true, - // Browser profiles - "Library/Application Support/Google/Chrome": true, - "Library/Application Support/Google/Chrome Canary": true, - "Library/Application Support/Chromium": true, - "Library/Application Support/Firefox": true, - "Library/Application Support/Microsoft Edge": true, - "Library/Application Support/BraveSoftware": true, - "Library/Application Support/Arc": true, - "Library/Application Support/com.operasoftware.Opera": true, - "Library/Safari": true, - "Library/Cookies": true, - // Terraform state (may contain secrets) - ".terraform.d": true, - // NPM tokens - ".npmrc": true, - // 1Password CLI - ".op": true, - ".1password": true, - ".config/op": true, - "Library/Group Containers/2BUA8C4S2C.com.1password": true, -} - -func (lfs *lazyCacheFS) lowerPath(rel string) string { - if rel == "" { - return lfs.lower - } - return filepath.Join(lfs.lower, rel) -} - -func (lfs *lazyCacheFS) cachePath(rel string) string { - if rel == "" { - return lfs.cache - } - return filepath.Join(lfs.cache, rel) -} - -// lazyCacheNode is a FUSE node in the lazy cache filesystem. -type lazyCacheNode struct { - fs.Inode - lfs *lazyCacheFS - root *lazyCacheNode // root node (self for the root) - - // per-node directory listing cache - mu sync.Mutex - entries []fuse.DirEntry - lowerMod int64 // lower dir mtime when entries was last populated (-1 = invalid) -} - -// relPath returns this node's path relative to the mount root, derived from -// the inode tree. This is always current even after renames. -func (n *lazyCacheNode) relPath() string { - return n.Inode.Path(n.root.EmbeddedInode()) -} - -// Compile-time interface checks. -var ( - _ fs.NodeGetattrer = (*lazyCacheNode)(nil) - _ fs.NodeReaddirer = (*lazyCacheNode)(nil) - _ fs.NodeLookuper = (*lazyCacheNode)(nil) - _ fs.NodeOpener = (*lazyCacheNode)(nil) - _ fs.NodeCreater = (*lazyCacheNode)(nil) - _ fs.NodeMkdirer = (*lazyCacheNode)(nil) - _ fs.NodeUnlinker = (*lazyCacheNode)(nil) - _ fs.NodeRmdirer = (*lazyCacheNode)(nil) - _ fs.NodeRenamer = (*lazyCacheNode)(nil) - _ fs.NodeSymlinker = (*lazyCacheNode)(nil) - _ fs.NodeReadlinker = (*lazyCacheNode)(nil) -) - -func (n *lazyCacheNode) lowerPath() string { return n.lfs.lowerPath(n.relPath()) } -func (n *lazyCacheNode) cachePath() string { return n.lfs.cachePath(n.relPath()) } - -// Getattr returns attributes. Cache-first: if the file exists in the ext4 -// cache, return cache attrs without hitting the virtiofs lower at all. -// The background refresh goroutine keeps the cache fresh. -func (n *lazyCacheNode) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno { - var st syscall.Stat_t - if syscall.Lstat(n.cachePath(), &st) == nil { - fuseAttrFromStat(&out.Attr, &st) - return 0 - } - if syscall.Lstat(n.lowerPath(), &st) == nil { - fuseAttrFromStat(&out.Attr, &st) - return 0 - } - return syscall.ENOENT -} - -// Lookup looks up a child node by name. -func (n *lazyCacheNode) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) { - childPath := filepath.Join(n.relPath(), name) - if n.lfs.isBlocked(childPath) { - return nil, syscall.EACCES - } - cp := n.lfs.cachePath(childPath) - lp := n.lfs.lowerPath(childPath) - - // Cache-first: check ext4 cache before hitting 9P lower. - var st syscall.Stat_t - if err := syscall.Lstat(cp, &st); err != nil { - if err2 := syscall.Lstat(lp, &st); err2 != nil { - slog.Debug("lookup miss", "path", childPath, "cp", cp, "lp", lp) - return nil, syscall.ENOENT - } - } - - fuseAttrFromStat(&out.Attr, &st) - child := n.NewInode(ctx, &lazyCacheNode{lfs: n.lfs, root: n.root, lowerMod: -1}, fs.StableAttr{ - Mode: uint32(st.Mode & syscall.S_IFMT), - Ino: stableIno(childPath), - }) - return child, 0 -} - -// Readdir returns directory entries, caching against lower mtime. -func (n *lazyCacheNode) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) { - lp := n.lowerPath() - cp := n.cachePath() - - var lst syscall.Stat_t - lowerOk := syscall.Lstat(lp, &lst) == nil - - n.mu.Lock() - defer n.mu.Unlock() - - if lowerOk && n.entries != nil && lst.Mtim.Sec == n.lowerMod { - return fs.NewListDirStream(append([]fuse.DirEntry(nil), n.entries...)), 0 - } - - seen := make(map[string]bool) - var entries []fuse.DirEntry - - if lowerOk { - des, err := os.ReadDir(lp) - if err == nil { - for _, de := range des { - childPath := filepath.Join(n.relPath(), de.Name()) - if n.lfs.isBlocked(childPath) { - continue - } - var st syscall.Stat_t - syscall.Lstat(filepath.Join(lp, de.Name()), &st) - entries = append(entries, fuse.DirEntry{ - Name: de.Name(), - Mode: uint32(st.Mode), - Ino: st.Ino, - }) - seen[de.Name()] = true - } - } - } - - // Include cache-only entries (guest-created files not present in lower). - if cdes, err := os.ReadDir(cp); err == nil { - for _, de := range cdes { - if seen[de.Name()] { - continue - } - childPath := filepath.Join(n.relPath(), de.Name()) - if n.lfs.isBlocked(childPath) { - continue - } - var st syscall.Stat_t - syscall.Lstat(filepath.Join(cp, de.Name()), &st) - entries = append(entries, fuse.DirEntry{ - Name: de.Name(), - Mode: uint32(st.Mode), - Ino: st.Ino, - }) - } - } - - if lowerOk { - n.lowerMod = lst.Mtim.Sec - } - n.entries = entries - return fs.NewListDirStream(append([]fuse.DirEntry(nil), entries...)), 0 -} - -// Open hydrates the file into cache if needed, then opens the cache copy. -func (n *lazyCacheNode) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) { - if err := n.hydrateFile(); err != nil { - slog.Warn("sync cache: hydrate failed", "path", n.relPath(), "error", err) - return nil, 0, syscall.EIO - } - cp := n.cachePath() - // Strip O_CREAT/O_EXCL — Open is for existing files. - openFlags := int(flags) &^ (syscall.O_CREAT | syscall.O_EXCL) - f, err := os.OpenFile(cp, openFlags, 0) - if err != nil { - return nil, 0, fs.ToErrno(err) - } - rawFd, err := syscall.Dup(int(f.Fd())) - f.Close() - if err != nil { - return nil, 0, fs.ToErrno(err) - } - return fs.NewLoopbackFile(rawFd), 0, 0 -} - -// Create creates a new file in the cache. -func (n *lazyCacheNode) Create(ctx context.Context, name string, flags uint32, mode uint32, out *fuse.EntryOut) (*fs.Inode, fs.FileHandle, uint32, syscall.Errno) { - childPath := filepath.Join(n.relPath(), name) - cp := n.lfs.cachePath(childPath) - if err := os.MkdirAll(filepath.Dir(cp), 0755); err != nil { - return nil, nil, 0, fs.ToErrno(err) - } - f, err := os.OpenFile(cp, int(flags)|syscall.O_CREAT, os.FileMode(mode)) - if err != nil { - return nil, nil, 0, fs.ToErrno(err) - } - rawFd, dupErr := syscall.Dup(int(f.Fd())) - var cst syscall.Stat_t - syscall.Fstat(int(f.Fd()), &cst) - f.Close() - if dupErr != nil { - return nil, nil, 0, fs.ToErrno(dupErr) - } - fuseAttrFromStat(&out.Attr, &cst) - child := n.NewInode(ctx, &lazyCacheNode{lfs: n.lfs, root: n.root, lowerMod: -1}, fs.StableAttr{ - Mode: uint32(cst.Mode & syscall.S_IFMT), - }) - return child, fs.NewLoopbackFile(rawFd), 0, 0 -} - -// Mkdir creates a directory in the cache. -func (n *lazyCacheNode) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) { - childPath := filepath.Join(n.relPath(), name) - cp := n.lfs.cachePath(childPath) - if err := os.MkdirAll(cp, os.FileMode(mode)); err != nil { - return nil, fs.ToErrno(err) - } - var cst syscall.Stat_t - syscall.Lstat(cp, &cst) - fuseAttrFromStat(&out.Attr, &cst) - child := n.NewInode(ctx, &lazyCacheNode{lfs: n.lfs, root: n.root, lowerMod: -1}, fs.StableAttr{ - Mode: syscall.S_IFDIR, - Ino: stableIno(childPath), - }) - return child, 0 -} - -// Unlink removes a file from the cache (lower is read-only). -func (n *lazyCacheNode) Unlink(ctx context.Context, name string) syscall.Errno { - cp := n.lfs.cachePath(filepath.Join(n.relPath(), name)) - if err := os.Remove(cp); err != nil && !os.IsNotExist(err) { - return fs.ToErrno(err) - } - return 0 -} - -// Rmdir removes a directory from the cache. -func (n *lazyCacheNode) Rmdir(ctx context.Context, name string) syscall.Errno { - cp := n.lfs.cachePath(filepath.Join(n.relPath(), name)) - if err := os.Remove(cp); err != nil && !os.IsNotExist(err) { - return fs.ToErrno(err) - } - return 0 -} - -// Rename renames within the cache and updates the child node's path -// so subsequent operations (Open, Getattr) reference the correct location. -func (n *lazyCacheNode) Rename(ctx context.Context, oldName string, newParent fs.InodeEmbedder, newName string, flags uint32) syscall.Errno { - oldChildPath := filepath.Join(n.relPath(), oldName) - var newParentPath string - if np, ok := newParent.(*lazyCacheNode); ok { - newParentPath = np.relPath() - } - newChildPath := filepath.Join(newParentPath, newName) - - oldCP := n.lfs.cachePath(oldChildPath) - newCP := n.lfs.cachePath(newChildPath) - slog.Debug("fuse rename", "oldCP", oldCP, "newCP", newCP) - if err := os.MkdirAll(filepath.Dir(newCP), 0755); err != nil { - return fs.ToErrno(err) - } - if err := os.Rename(oldCP, newCP); err != nil { - slog.Warn("fuse rename failed", "error", err) - return fs.ToErrno(err) - } - - // Invalidate parent dir listing caches. - n.mu.Lock() - n.entries = nil - n.mu.Unlock() - if np, ok := newParent.(*lazyCacheNode); ok && np != n { - np.mu.Lock() - np.entries = nil - np.mu.Unlock() - } - return 0 -} - -// Symlink creates a symlink in the cache. -func (n *lazyCacheNode) Symlink(ctx context.Context, target, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) { - childPath := filepath.Join(n.relPath(), name) - cp := n.lfs.cachePath(childPath) - if err := os.MkdirAll(filepath.Dir(cp), 0755); err != nil { - return nil, fs.ToErrno(err) - } - if err := os.Symlink(target, cp); err != nil { - return nil, fs.ToErrno(err) - } - var cst syscall.Stat_t - syscall.Lstat(cp, &cst) - fuseAttrFromStat(&out.Attr, &cst) - child := n.NewInode(ctx, &lazyCacheNode{lfs: n.lfs, root: n.root, lowerMod: -1}, fs.StableAttr{ - Mode: syscall.S_IFLNK, - Ino: stableIno(childPath), - }) - return child, 0 -} - -// Readlink reads a symlink, checking cache then lower. -func (n *lazyCacheNode) Readlink(ctx context.Context) ([]byte, syscall.Errno) { - if target, err := os.Readlink(n.cachePath()); err == nil { - return []byte(target), 0 - } - if target, err := os.Readlink(n.lowerPath()); err == nil { - return []byte(target), 0 - } - return nil, syscall.EINVAL -} - -// hydrateFile copies a file from lower to cache if the cache is absent or stale. -func (n *lazyCacheNode) hydrateFile() error { - lp := n.lowerPath() - cp := n.cachePath() - - var lst syscall.Stat_t - if err := syscall.Lstat(lp, &lst); err != nil { - return nil // no lower file; caller opens a guest-created file - } - - var cst syscall.Stat_t - if syscall.Lstat(cp, &cst) == nil && cst.Mtim.Sec >= lst.Mtim.Sec { - return nil // cache is current - } - - return copyFileWithMtime(lp, cp, &lst) -} - -// copyFileWithMtime copies src to dst, preserving permissions, ownership, and mtime. -func copyFileWithMtime(src, dst string, srcStat *syscall.Stat_t) error { - if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { - return err - } - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(srcStat.Mode)&0777) - if err != nil { - return err - } - if _, err := io.Copy(out, in); err != nil { - out.Close() - return err - } - out.Close() - - // Preserve owner and mtime so freshness checks remain accurate. - syscall.Lchown(dst, int(srcStat.Uid), int(srcStat.Gid)) - times := []syscall.Timespec{srcStat.Atim, srcStat.Mtim} - syscall.UtimesNano(dst, times) - return nil -} - -// fuseAttrFromStat fills a fuse.Attr from a syscall.Stat_t. -func fuseAttrFromStat(attr *fuse.Attr, st *syscall.Stat_t) { - attr.Ino = st.Ino - attr.Size = uint64(st.Size) - attr.Blocks = uint64(st.Blocks) - attr.Atime = uint64(st.Atim.Sec) - attr.Atimensec = uint32(st.Atim.Nsec) - attr.Mtime = uint64(st.Mtim.Sec) - attr.Mtimensec = uint32(st.Mtim.Nsec) - attr.Ctime = uint64(st.Ctim.Sec) - attr.Ctimensec = uint32(st.Ctim.Nsec) - attr.Mode = uint32(st.Mode) - attr.Nlink = uint32(st.Nlink) - attr.Uid = st.Uid - attr.Gid = st.Gid - attr.Rdev = uint32(st.Rdev) - attr.Blksize = uint32(st.Blksize) -} - -// fuseCacheTimeout is the kernel-side cache TTL for entry and attribute lookups. -// Higher values reduce FUSE round-trips (the kernel serves from its own cache), -// but delay visibility of host-side changes by up to this duration. -var fuseCacheTimeout = 5 * time.Second - -// cachedMounts maps share tags to their FUSE mount state so the -// invalidation receiver can evict cache entries and notify the kernel. -var ( - cachedMounts = map[string]*cachedMount{} - cachedMountsMu sync.Mutex -) - -type cachedMount struct { - lfs *lazyCacheFS - root *lazyCacheNode - server *fuse.Server -} - -// startCachedMount mounts a FUSE lazy-cache filesystem for a single share. -// Called post-pivotRoot. The lower 9P and cache dirs must already exist -// (set up by mountCachedLower pre-pivotRoot). -func startCachedMount(guestPath, tag string, blocked map[string]bool) { - lower := fmt.Sprintf("/var/lnx/lower/%s", tag) - cache := fmt.Sprintf("/var/lnx/cache/%s", tag) - - lfs := &lazyCacheFS{lower: lower, cache: cache, blockedPaths: blocked} - root := &lazyCacheNode{lfs: lfs, lowerMod: -1} - root.root = root - - server, err := fs.Mount(guestPath, root, &fs.Options{ - MountOptions: fuse.MountOptions{ - AllowOther: true, - FsName: "lnx-sync", - Name: tag, - }, - AttrTimeout: &fuseCacheTimeout, - EntryTimeout: &fuseCacheTimeout, - }) - if err != nil { - slog.Warn("cached mount failed", "path", guestPath, "tag", tag, "error", err) - return - } - slog.Info("cached mount", "path", guestPath, "tag", tag) - - cachedMountsMu.Lock() - cachedMounts[tag] = &cachedMount{lfs: lfs, root: root, server: server} - cachedMountsMu.Unlock() - - go server.Wait() -} - -// startInvalidationReceiver dials the host invalidation port and processes -// cache eviction messages. Each message lists paths in a share that changed -// on the host. The receiver deletes the cached copy and invalidates the -// kernel FUSE entry cache so the next access reads fresh data from 9P. -func startInvalidationReceiver() { - conn, err := vsock.Dial(vsockHostCID, protocol.InvalidatePort, nil) - if err != nil { - slog.Warn("invalidation receiver dial failed", "error", err) - return - } - slog.Info("invalidation receiver connected") - - dec := gob.NewDecoder(conn) - for { - var inv protocol.Invalidation - if err := dec.Decode(&inv); err != nil { - slog.Debug("invalidation receiver stopped", "error", err) - return - } - - cachedMountsMu.Lock() - cm := cachedMounts[inv.Tag] - cachedMountsMu.Unlock() - if cm == nil { - continue - } - - for _, relPath := range inv.Paths { - cp := cm.lfs.cachePath(relPath) - if err := os.Remove(cp); err != nil && !os.IsNotExist(err) { - slog.Warn("cache evict failed", "path", relPath, "error", err) - } - - // Invalidate kernel FUSE entry cache so the next access - // goes through Lookup/Getattr (which will miss cache and - // read fresh data from the 9P lower). - dir := filepath.Dir(relPath) - name := filepath.Base(relPath) - parent := cm.root.EmbeddedInode() - if dir != "." && dir != "" { - for _, part := range strings.Split(dir, "/") { - child := parent.GetChild(part) - if child == nil { - parent = nil - break - } - parent = child - } - } - if parent != nil { - parent.NotifyEntry(name) - } - } - } -} diff --git a/old/cmd/init/main.go b/old/cmd/init/main.go deleted file mode 100644 index e07b081..0000000 --- a/old/cmd/init/main.go +++ /dev/null @@ -1,541 +0,0 @@ -//go:build linux - -package main - -import ( - "bytes" - "encoding/gob" - "fmt" - "io" - "log/slog" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "sync" - "syscall" - - "github.com/mdlayher/vsock" - "github.com/semistrict/lnx/internal/protocol" - "golang.org/x/sys/unix" -) - -const ( - vsockHostCID = 2 - vsockLogPort = 1025 -) - -func main() { - // Busybox-style dispatch: if invoked as "systemctl", run that instead. - base := filepath.Base(os.Args[0]) - if base == "systemctl" { - os.Exit(runSystemctl(os.Args[1:])) - } - if base == "systemd-cat" { - initLogging() - os.Exit(runSystemdCat(os.Args[1:])) - } - - if err := run(); err != nil { - slog.Error("init failed", "error", err) - } - poweroff() -} - -// ctrlConn is the control connection to the host. -// It carries Setup, Signal, and Resize messages. -var ( - ctrlConn *vsock.Conn - ctrlDec *gob.Decoder - ctrlDone chan struct{} // closed when control connection drops - ctrlProc *os.Process - ctrlMu sync.RWMutex - ctrlPTY *os.File - ctrlPTYMu sync.RWMutex - - setupUID int // UID from the host Setup message - setupCWD string // CWD from the host Setup message -) - -func run() error { - if err := mountInitialFS(); err != nil { - return err - } - - initLogging() - parseEpoch() - - // Connect to the host control channel. - conn, err := vsock.Dial(vsockHostCID, protocol.Port, nil) - if err != nil { - return fmt.Errorf("vsock dial control: %w", err) - } - ctrlConn = conn - ctrlDec = gob.NewDecoder(conn) - ctrlDone = make(chan struct{}) - - // Read the Setup message from the host. - var msg protocol.Msg - if err := ctrlDec.Decode(&msg); err != nil { - return fmt.Errorf("decode setup msg: %w", err) - } - if msg.Setup == nil { - return fmt.Errorf("expected Setup message, got %+v", msg) - } - setup := msg.Setup - - // Start reading signals/resize from control connection. - go controlReader() - - if err := mountRootfs(); err != nil { - return err - } - if setup.HomeDir != "" { - if err := mountCachedLower(setup.HomeDir, "home", protocol.P9Port); err != nil { - slog.Warn("home mount failed, continuing without it", "error", err) - } - } - if setup.CWD != "" { - if setup.DirectShare { - if err := mountDirect(setup.CWD, protocol.P9CWDPort, false); err != nil { - return err - } - } else { - if err := mountCachedLower(setup.CWD, "cwd", protocol.P9CWDPort); err != nil { - return err - } - } - } - for i, path := range setup.Shares { - tag := fmt.Sprintf("share%d", i) - port := protocol.P9ShareBasePort + uint32(i) - if setup.DirectShare { - if err := mountDirect(path, port, false); err != nil { - slog.Warn("share mount failed", "path", path, "error", err) - } - } else { - if err := mountCachedLower(path, tag, port); err != nil { - slog.Warn("share mount failed", "path", path, "error", err) - } - } - } - for i, path := range setup.SyncShares { - tag := fmt.Sprintf("sync%d", i) - if err := mountCachedLower(path, tag, protocol.P9SyncBasePort+uint32(i)); err != nil { - slog.Warn("sync share mount failed", "path", path, "error", err) - } - } - if err := mountInNewRoot(); err != nil { - return err - } - if err := pivotRoot(); err != nil { - return err - } - - mountCgroups() - writeNestedDrivesMapping(setup) - - if out, err := exec.Command("/sbin/resize2fs", "/dev/vda").CombinedOutput(); err != nil { - slog.Warn("resize2fs failed", "error", err, "output", string(out)) - } else { - slog.Info("resize2fs", "output", strings.TrimSpace(string(out))) - } - - if _, err := os.Stat("/dev/vdb"); err == nil { - if out, err := exec.Command("/sbin/mkswap", "/dev/vdb").CombinedOutput(); err != nil { - slog.Warn("mkswap failed", "error", err, "output", string(out)) - } else if out, err := exec.Command("/sbin/swapon", "/dev/vdb").CombinedOutput(); err != nil { - slog.Warn("swapon failed", "error", err, "output", string(out)) - } else { - slog.Info("swap enabled", "device", "/dev/vdb") - } - } - - hostname := setup.Hostname - if hostname == "" { - hostname = "lnx" - } - syscall.Sethostname([]byte(hostname)) - - os.Setenv("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") - os.Setenv("TERM", "xterm-256color") - os.Setenv("LANG", "C.UTF-8") - os.Setenv("BROWSER", "xdg-open") - - setupUID = setup.UID - setupCWD = setup.CWD - if setup.User != "" && setup.UID > 0 { - setupUser(setup.User, setup.UID) - os.Setenv("HOME", "/home/"+setup.User) - os.Setenv("USER", setup.User) - os.Setenv("LOGNAME", setup.User) - } else { - os.Setenv("HOME", "/root") - } - - for _, kv := range setup.Env { - if k, v, ok := strings.Cut(kv, "="); ok { - os.Setenv(k, v) - } - } - - if setup.SSHAgent { - startSSHAgentForward() - } - - mountCRIUDevice() - - // Bring up loopback before CRIU restore — needed for TCP repair - // of loopback connections in the restored process tree. - if out, err := exec.Command("/sbin/ip", "link", "set", "lo", "up").CombinedOutput(); err != nil { - slog.Warn("loopback up failed", "error", err, "output", string(out)) - } - - // Auto-restore CRIU images immediately after mounting the CRIU device, - // before ANY commands that fork processes (like ip, resize2fs). CRIU - // restores processes to their original PIDs, which must not be taken. - criuAutoRestore() - - installSystemctlShim() - installSystemdCatShim() - configureNetwork() - - installBashDefaults() - installXdgOpen() - installForkRoleHelper() - startEnabledServices() - if setup.HomeDir != "" { - startCachedMount(setup.HomeDir, "home", homeSyncBlockedPaths) - } - if !setup.DirectShare { - if setup.CWD != "" { - startCachedMount(setup.CWD, "cwd", nil) - } - for i, path := range setup.Shares { - startCachedMount(path, fmt.Sprintf("share%d", i), nil) - } - } - for i, path := range setup.SyncShares { - startCachedMount(path, fmt.Sprintf("sync%d", i), nil) - } - go startInvalidationReceiver() - startStatusServer() - startExecServer() - startSSHServer() - startGuestControlServer() - startPortForwarder() - - slog.Info("guest ready", "user", setup.User, "uid", setup.UID) - - // Block until the host closes the control connection. - // After a transport reset (migration/fork), the control connection - // dies. Try to reconnect — if successful, the VM was forked and - // should stay alive. If reconnect fails, this is a genuine shutdown. - <-ctrlDone - - // Re-init logging — old vsock connection to log port is dead. - initLogging() - - reconnConn, reconnErr := vsock.Dial(vsockHostCID, protocol.Port, nil) - if reconnErr != nil { - return nil // genuine shutdown - } - // Forked VM: host accepted reconnect. Block on the new connection - // until the host kills the QEMU process. - slog.Info("fork detected, restarting services") - ctrlConn = reconnConn - ctrlDec = gob.NewDecoder(reconnConn) - ctrlDone = make(chan struct{}) - go controlReader() - - // Use reverse exec: guest dials host instead of host dialing guest. - // After CPR-reboot migration, the guest kernel can't deliver - // host-initiated vsock connections to userspace Accept(). - startReverseExecServer() - - <-ctrlDone - return nil -} - -func runSystemdCat(args []string) int { - var cmdArgs []string - identifier := "systemd-cat" - priority := "6" - for i := 0; i < len(args); i++ { - arg := args[i] - if arg == "--" { - cmdArgs = args[i+1:] - break - } - if strings.HasPrefix(arg, "-") { - switch arg { - case "-t", "--identifier": - if i+1 < len(args) { - identifier = args[i+1] - i++ - } - case "-p", "--priority": - if i+1 < len(args) { - priority = args[i+1] - i++ - } - case "--level-prefix": - if i+1 < len(args) { - i++ - } - } - continue - } - cmdArgs = args[i:] - break - } - - if len(cmdArgs) == 0 { - data, _ := io.ReadAll(os.Stdin) - logSystemdCatOutput(identifier, priority, data) - return 0 - } - - cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) - cmd.Stdin = os.Stdin - var out bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &out - if err := cmd.Run(); err != nil { - logSystemdCatOutput(identifier, priority, out.Bytes()) - if ee, ok := err.(*exec.ExitError); ok { - return ee.ExitCode() - } - fmt.Fprintln(os.Stderr, err) - return 1 - } - logSystemdCatOutput(identifier, priority, out.Bytes()) - return 0 -} - -func logSystemdCatOutput(identifier, priority string, data []byte) { - text := strings.TrimSpace(string(data)) - if text == "" { - return - } - attrs := []any{"identifier", identifier, "priority", priority} - for _, line := range strings.Split(text, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - switch priority { - case "0", "1", "2", "3": - slog.Error(line, attrs...) - case "4": - slog.Warn(line, attrs...) - default: - slog.Info(line, attrs...) - } - } -} - -// controlReader reads Signal and Resize messages from the host. -// When the connection closes, it signals ctrlDone. -func controlReader() { - defer close(ctrlDone) - for { - var msg protocol.Msg - if err := ctrlDec.Decode(&msg); err != nil { - return - } - if msg.Signal != nil { - ctrlMu.RLock() - proc := ctrlProc - ctrlMu.RUnlock() - if proc != nil { - _ = proc.Signal(syscall.Signal(msg.Signal.Sig)) - } - } - if msg.Resize != nil { - ctrlPTYMu.RLock() - f := ctrlPTY - ctrlPTYMu.RUnlock() - if f != nil { - _ = unix.IoctlSetWinsize(int(f.Fd()), unix.TIOCSWINSZ, &unix.Winsize{ - Row: msg.Resize.Rows, - Col: msg.Resize.Cols, - }) - } - } - } -} - -func setControlProcess(proc *os.Process) { - ctrlMu.Lock() - defer ctrlMu.Unlock() - ctrlProc = proc -} - -func setControlPTY(f *os.File) { - ctrlPTYMu.Lock() - defer ctrlPTYMu.Unlock() - ctrlPTY = f -} - -func initLogging() { - level := slog.LevelInfo - switch strings.ToLower(os.Getenv("LNX_LOG")) { - case "debug": - level = slog.LevelDebug - case "warn": - level = slog.LevelWarn - case "error": - level = slog.LevelError - } - - conn, err := vsock.Dial(vsockHostCID, vsockLogPort, nil) - if err != nil { - slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) - return - } - slog.SetDefault(slog.New(slog.NewJSONHandler(conn, &slog.HandlerOptions{Level: level}))) -} - -func parseEpoch() { - data, err := os.ReadFile("/proc/cmdline") - if err != nil { - return - } - for _, param := range strings.Fields(string(data)) { - if v, ok := strings.CutPrefix(param, "lnx.epoch="); ok { - setClockFromEpoch(v) - } - } -} - -func setClockFromEpoch(epochStr string) { - epoch, err := strconv.ParseInt(epochStr, 10, 64) - if err != nil { - return - } - tv := syscall.Timeval{Sec: epoch} - if err := syscall.Settimeofday(&tv); err != nil { - slog.Warn("settimeofday failed", "error", err) - } -} - -func setupUser(username string, uid int) { - // Skip if user was already created in a prior boot. - if data, err := os.ReadFile("/etc/passwd"); err == nil { - if strings.Contains(string(data), username+":") { - addUserToGroups(username) - return - } - } - - gid := uid - home := "/home/" + username - - appendFile("/etc/passwd", fmt.Sprintf("%s:x:%d:%d::%s:/bin/bash\n", username, uid, gid, home)) - appendFile("/etc/shadow", fmt.Sprintf("%s:!::0:99999:7:::\n", username)) - appendFile("/etc/group", fmt.Sprintf("%s:x:%d:\n", username, gid)) - - os.MkdirAll(home, 0755) - os.Chown(home, uid, gid) - - os.MkdirAll("/etc/sudoers.d", 0755) - os.WriteFile("/etc/sudoers.d/lnx", []byte(username+" ALL=(ALL) NOPASSWD: ALL\n"), 0440) - - addUserToGroups(username) -} - -// addUserToGroups adds the user to well-known system groups (docker, etc.) -// if they exist on the rootfs. Runs on every boot since groups may be -// added by package installs between boots. -func addUserToGroups(username string) { - groups := []string{"docker", "sudo", "adm"} - data, err := os.ReadFile("/etc/group") - if err != nil { - return - } - lines := strings.Split(string(data), "\n") - changed := false - for i, line := range lines { - parts := strings.SplitN(line, ":", 4) - if len(parts) != 4 { - continue - } - groupName := parts[0] - members := parts[3] - found := false - for _, g := range groups { - if groupName == g { - found = true - break - } - } - if !found { - continue - } - // Check if user is already a member. - memberList := strings.Split(members, ",") - alreadyMember := false - for _, m := range memberList { - if m == username { - alreadyMember = true - break - } - } - if alreadyMember { - continue - } - if members == "" { - parts[3] = username - } else { - parts[3] = members + "," + username - } - lines[i] = strings.Join(parts, ":") - changed = true - } - if changed { - os.WriteFile("/etc/group", []byte(strings.Join(lines, "\n")), 0644) - } -} - -func appendFile(path, line string) { - f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) - if err != nil { - slog.Warn("append file failed", "path", path, "error", err) - return - } - f.WriteString(line) - f.Close() -} - -// installBashDefaults ensures bash has color support and standard aliases -// even when the user's home dir is a read-only 9P mount without .bashrc. -func installBashDefaults() { - script := `# lnx: source skeleton bashrc for color support -if [ -n "$BASH_VERSION" ] && [ -f /etc/skel/.bashrc ]; then - . /etc/skel/.bashrc -fi -` - os.MkdirAll("/etc/profile.d", 0755) - os.WriteFile("/etc/profile.d/lnx-bashrc.sh", []byte(script), 0644) -} - -// installXdgOpen writes a shim that forwards xdg-open calls to the host -// macOS browser via the guest control socket. -func installXdgOpen() { - script := `#!/bin/sh -curl -sf --unix-socket /var/run/lnx/control.sock \ - -X POST -H "Content-Type: application/json" \ - -d "{\"url\":\"$1\"}" \ - http://localhost/open >/dev/null 2>&1 -` - if err := os.WriteFile("/usr/local/bin/xdg-open", []byte(script), 0755); err != nil { - slog.Warn("failed to install xdg-open shim", "error", err) - } -} - -func poweroff() { - syscall.Sync() - unix.Reboot(unix.LINUX_REBOOT_CMD_POWER_OFF) -} diff --git a/old/cmd/init/mount.go b/old/cmd/init/mount.go deleted file mode 100644 index fb2988c..0000000 --- a/old/cmd/init/mount.go +++ /dev/null @@ -1,154 +0,0 @@ -//go:build linux - -package main - -import ( - "fmt" - "os" - "strings" - "syscall" - - "github.com/mdlayher/vsock" -) - -func mountInitialFS() error { - mounts := []struct { - source, target, fstype string - flags uintptr - }{ - {"proc", "/proc", "proc", 0}, - {"sysfs", "/sys", "sysfs", 0}, - {"devtmpfs", "/dev", "devtmpfs", 0}, - {"tmpfs", "/dev/shm", "tmpfs", 0}, - {"tmpfs", "/tmp", "tmpfs", 0}, - {"tmpfs", "/run", "tmpfs", 0}, - } - for _, m := range mounts { - os.MkdirAll(m.target, 0755) - if err := syscall.Mount(m.source, m.target, m.fstype, m.flags, ""); err != nil { - return fmt.Errorf("mount %s on %s: %w", m.fstype, m.target, err) - } - } - os.Symlink("/proc/self/fd", "/dev/fd") - return nil -} - -func mountRootfs() error { - os.MkdirAll("/mnt", 0755) - // noatime for performance; errors=continue to avoid panic on journal issues. - if err := syscall.Mount("/dev/vda", "/mnt", "ext4", syscall.MS_NOATIME, "errors=continue"); err != nil { - return fmt.Errorf("mount rootfs: %w", err) - } - return nil -} - -// mountCachedLower mounts a 9P share read-only as the lower layer for -// the FUSE lazy-cache. The FUSE server is started post-pivotRoot. -func mountCachedLower(guestPath, tag string, port uint32) error { - lower := "/mnt/var/lnx/lower/" + tag - cache := "/mnt/var/lnx/cache/" + tag - - os.MkdirAll(lower, 0755) - if err := mount9P(lower, port, true); err != nil { - return fmt.Errorf("mount 9p lower %s: %w", tag, err) - } - os.MkdirAll(cache, 0755) - os.MkdirAll("/mnt"+guestPath, 0755) - return nil -} - -// mountDirect mounts a 9P share directly at the guest path (no FUSE cache). -func mountDirect(guestPath string, port uint32, readOnly bool) error { - target := "/mnt" + guestPath - os.MkdirAll(target, 0755) - return mount9P(target, port, readOnly) -} - -// mount9P dials a 9P server on the host via vsock and mounts it at target. -func mount9P(target string, port uint32, readOnly bool) error { - conn, err := vsock.Dial(vsockHostCID, port, nil) - if err != nil { - return fmt.Errorf("vsock dial 9p port %d: %w", port, err) - } - - rawConn, err := conn.SyscallConn() - if err != nil { - conn.Close() - return fmt.Errorf("9p syscall conn: %w", err) - } - - var fd int - var dupErr error - rawConn.Control(func(f uintptr) { - fd, dupErr = syscall.Dup(int(f)) - }) - conn.Close() - if dupErr != nil { - return fmt.Errorf("9p dup fd: %w", dupErr) - } - - var flags uintptr - if readOnly { - flags = syscall.MS_RDONLY - } - opts := fmt.Sprintf("trans=fd,rfdno=%d,wfdno=%d,version=9p2000.L,msize=1048576", fd, fd) - if err := syscall.Mount("9p", target, "9p", flags, opts); err != nil { - syscall.Close(fd) - return fmt.Errorf("mount 9p on %s (port %d): %w", target, port, err) - } - return nil -} - - -func mountCgroups() error { - os.MkdirAll("/sys/fs/cgroup", 0755) - // Use cgroup v1 hybrid: tmpfs base with individual controllers. - // Docker requires the devices cgroup which doesn't exist in pure cgroup v2. - if err := syscall.Mount("tmpfs", "/sys/fs/cgroup", "tmpfs", 0, ""); err != nil { - return fmt.Errorf("mount cgroup tmpfs: %w", err) - } - controllers := []string{"cpu,cpuacct", "memory", "devices", "freezer", "pids", "blkio", "cpuset", "net_cls,net_prio", "perf_event", "hugetlb"} - for _, c := range controllers { - name := strings.Split(c, ",")[0] // use first name for dir - dir := "/sys/fs/cgroup/" + name - os.MkdirAll(dir, 0755) - syscall.Mount("cgroup", dir, "cgroup", 0, c) - } - // Mount cgroup2 for unified hierarchy support. - os.MkdirAll("/sys/fs/cgroup/unified", 0755) - syscall.Mount("cgroup2", "/sys/fs/cgroup/unified", "cgroup2", 0, "") - return nil -} - -func mountInNewRoot() error { - for _, m := range []struct{ src, dst, fstype string }{ - {"/proc", "/mnt/proc", "proc"}, - {"/sys", "/mnt/sys", "sysfs"}, - {"/dev", "/mnt/dev", "devtmpfs"}, - {"/dev/shm", "/mnt/dev/shm", "tmpfs"}, - {"/tmp", "/mnt/tmp", "tmpfs"}, - {"/run", "/mnt/run", "tmpfs"}, - } { - os.MkdirAll(m.dst, 0755) - if err := syscall.Mount(m.src, m.dst, m.fstype, 0, ""); err != nil { - return fmt.Errorf("mount %s in newroot: %w", m.dst, err) - } - } - os.MkdirAll("/mnt/dev/pts", 0755) - syscall.Mount("devpts", "/mnt/dev/pts", "devpts", 0, "newinstance,ptmxmode=0666") - os.Remove("/mnt/dev/ptmx") - os.Symlink("pts/ptmx", "/mnt/dev/ptmx") - return nil -} - -func pivotRoot() error { - os.MkdirAll("/mnt/oldroot", 0755) - if err := syscall.PivotRoot("/mnt", "/mnt/oldroot"); err != nil { - return fmt.Errorf("pivot_root: %w", err) - } - if err := os.Chdir("/"); err != nil { - return fmt.Errorf("chdir /: %w", err) - } - syscall.Unmount("/oldroot", syscall.MNT_DETACH) - return nil -} diff --git a/old/cmd/init/nested.go b/old/cmd/init/nested.go deleted file mode 100644 index 0d84ec6..0000000 --- a/old/cmd/init/nested.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build linux - -package main - -import ( - "encoding/json" - "log/slog" - "os" - - "github.com/semistrict/lnx/internal/protocol" -) - -const nestedDrivesPath = "/var/lib/lnx/nested-drives.json" - -// writeNestedDrivesMapping writes the nested drives mapping to a well-known -// path so that nested lnx instances can discover their rootfs device. -func writeNestedDrivesMapping(setup *protocol.Setup) { - if len(setup.NestedDrives) == 0 { - return - } - - os.MkdirAll("/var/lib/lnx", 0755) - - data, err := json.Marshal(setup.NestedDrives) - if err != nil { - slog.Warn("marshal nested drives", "error", err) - return - } - if err := os.WriteFile(nestedDrivesPath, data, 0644); err != nil { - slog.Warn("write nested drives mapping", "error", err) - return - } - - slog.Info("nested drives configured", "count", len(setup.NestedDrives)) -} diff --git a/old/cmd/init/network.go b/old/cmd/init/network.go deleted file mode 100644 index 578b1f5..0000000 --- a/old/cmd/init/network.go +++ /dev/null @@ -1,142 +0,0 @@ -//go:build linux - -package main - -import ( - "context" - "fmt" - "log/slog" - "net" - "os" - "os/exec" - "time" - - "github.com/insomniacslk/dhcp/dhcpv4" - "github.com/insomniacslk/dhcp/dhcpv4/nclient4" -) - -func configureNetwork() { - runCmd("/sbin/ip", "link", "set", "lo", "up") - - iface := findNetInterface() - if iface == "" { - slog.Warn("no network interface found") - return - } - - runCmd("/sbin/ip", "link", "set", iface, "up") - - lease, err := requestDHCPLease(iface) - if err != nil { - slog.Warn("dhcp lease failed", "iface", iface, "error", err) - return - } - if err := applyDHCPLease(iface, lease.ACK); err != nil { - slog.Warn("apply dhcp lease failed", "iface", iface, "error", err) - return - } - if err := writeResolvConf(lease.ACK); err != nil { - slog.Warn("failed to write resolv.conf", "error", err) - } - - slog.Info("network configured", "iface", iface, "ip", lease.ACK.YourIPAddr, "router", lease.ACK.Router(), "dns", lease.ACK.DNS()) -} - -func requestDHCPLease(iface string) (*nclient4.Lease, error) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - client, err := nclient4.New(iface, - nclient4.WithRetry(3), - nclient4.WithTimeout(4*time.Second), - ) - if err != nil { - return nil, fmt.Errorf("create dhcp client: %w", err) - } - defer client.Close() - - lease, err := client.Request(ctx, dhcpv4.WithRequestedOptions( - dhcpv4.OptionSubnetMask, - dhcpv4.OptionRouter, - dhcpv4.OptionDomainNameServer, - dhcpv4.OptionDNSDomainSearchList, - dhcpv4.OptionDomainName, - )) - if err != nil { - return nil, fmt.Errorf("request dhcp lease: %w", err) - } - if lease == nil || lease.ACK == nil { - return nil, fmt.Errorf("missing dhcp ack") - } - return lease, nil -} - -func applyDHCPLease(iface string, ack *dhcpv4.DHCPv4) error { - if ack == nil { - return fmt.Errorf("missing dhcp ack") - } - - cidr, err := cidrForLease(ack.YourIPAddr, ack.SubnetMask()) - if err != nil { - return err - } - - runCmd("/sbin/ip", "addr", "flush", "dev", iface) - runCmd("/sbin/ip", "addr", "add", cidr, "dev", iface) - runCmd("/sbin/ip", "route", "del", "default") - - routers := ack.Router() - if len(routers) > 0 { - gateway := firstIPv4(routers) - if gateway != nil { - runCmd("/sbin/ip", "route", "replace", "default", "via", gateway.String(), "dev", iface) - } - } - - return nil -} - -func writeResolvConf(ack *dhcpv4.DHCPv4) error { - // Remove the systemd symlink if present and write a real file. - os.Remove("/etc/resolv.conf") - data := resolvConfForLease(ack) - if data == "" { - return nil - } - if err := os.WriteFile("/etc/resolv.conf", []byte(data), 0644); err != nil { - return err - } - return nil -} - -func findNetInterface() string { - entries, err := os.ReadDir("/sys/class/net") - if err != nil { - return "" - } - for _, e := range entries { - name := e.Name() - if name == "lo" { - continue - } - if _, err := os.Stat("/sys/class/net/" + name + "/device"); err == nil { - return name - } - } - return "" -} - -func firstIPv4(ips []net.IP) net.IP { - for _, ip := range ips { - if v4 := ip.To4(); v4 != nil { - return v4 - } - } - return nil -} - -func runCmd(name string, args ...string) { - if out, err := exec.Command(name, args...).CombinedOutput(); err != nil { - slog.Warn("command failed", "cmd", append([]string{name}, args...), "error", err, "output", string(out)) - } -} diff --git a/old/cmd/init/network_config.go b/old/cmd/init/network_config.go deleted file mode 100644 index 9af0fe3..0000000 --- a/old/cmd/init/network_config.go +++ /dev/null @@ -1,48 +0,0 @@ -//go:build linux - -package main - -import ( - "fmt" - "net" - "strings" - - "github.com/insomniacslk/dhcp/dhcpv4" -) - -func cidrForLease(ip net.IP, mask net.IPMask) (string, error) { - ip = ip.To4() - if ip == nil { - return "", fmt.Errorf("missing ipv4 address") - } - if len(mask) != net.IPv4len { - return "", fmt.Errorf("missing subnet mask") - } - ones, bits := mask.Size() - if bits != 32 { - return "", fmt.Errorf("invalid subnet mask") - } - return fmt.Sprintf("%s/%d", ip.String(), ones), nil -} - -func resolvConfForLease(ack *dhcpv4.DHCPv4) string { - if ack == nil { - return "" - } - - var lines []string - if search := ack.DomainSearch(); search != nil && len(search.Labels) > 0 { - lines = append(lines, "search "+strings.Join(search.Labels, " ")) - } else if domain := strings.TrimSpace(ack.DomainName()); domain != "" { - lines = append(lines, "search "+domain) - } - for _, dns := range ack.DNS() { - if ip := dns.To4(); ip != nil { - lines = append(lines, "nameserver "+ip.String()) - } - } - if len(lines) == 0 { - return "" - } - return strings.Join(lines, "\n") + "\n" -} diff --git a/old/cmd/init/network_config_test.go b/old/cmd/init/network_config_test.go deleted file mode 100644 index 407d2b5..0000000 --- a/old/cmd/init/network_config_test.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build linux - -package main - -import ( - "net" - "testing" - - "github.com/insomniacslk/dhcp/dhcpv4" - "github.com/insomniacslk/dhcp/rfc1035label" - "github.com/stretchr/testify/require" -) - -func TestCIDRForLease(t *testing.T) { - cidr, err := cidrForLease(net.IPv4(10, 20, 30, 40), net.CIDRMask(20, 32)) - require.NoError(t, err) - require.Equal(t, "10.20.30.40/20", cidr) -} - -func TestResolvConfForLease(t *testing.T) { - search := rfc1035label.NewLabels() - search.Labels = []string{"corp.example", "example"} - - ack, err := dhcpv4.New( - dhcpv4.WithDNS(net.IPv4(1, 1, 1, 1), net.IPv4(8, 8, 8, 8)), - dhcpv4.WithOption(dhcpv4.OptDomainSearch(search)), - ) - require.NoError(t, err) - - require.Equal(t, "search corp.example example\nnameserver 1.1.1.1\nnameserver 8.8.8.8\n", resolvConfForLease(ack)) -} diff --git a/old/cmd/init/portfwd.go b/old/cmd/init/portfwd.go deleted file mode 100644 index 3229885..0000000 --- a/old/cmd/init/portfwd.go +++ /dev/null @@ -1,181 +0,0 @@ -//go:build linux - -package main - -import ( - "encoding/binary" - "encoding/gob" - "io" - "log/slog" - "net" - "os" - "strings" - "time" - - "github.com/mdlayher/vsock" - "github.com/semistrict/lnx/internal/protocol" -) - -// startPortForwarder scans for listening TCP ports and notifies the host. -// It also listens on a vsock port for incoming forwarded connections from the host. -func startPortForwarder() { - // Control connection: notify host of port changes. - ctrlConn, err := vsock.Dial(vsockHostCID, protocol.PortForwardPort, nil) - if err != nil { - slog.Warn("port forward vsock dial failed", "error", err) - return - } - - // Data listener: host connects here to forward TCP connections. - dataLn, err := vsock.Listen(protocol.PortForwardDataPort, nil) - if err != nil { - slog.Warn("port forward data listen failed", "error", err) - ctrlConn.Close() - return - } - - // Accept forwarded connections from host. - go acceptForwardedConns(dataLn) - - // Scan for listening ports and notify host. - go scanPorts(ctrlConn) -} - -func scanPorts(conn net.Conn) { - enc := gob.NewEncoder(conn) - var prev []uint16 - - for { - ports := getListeningPorts() - if !portsEqual(prev, ports) { - if err := enc.Encode(protocol.PortForward{Ports: ports}); err != nil { - slog.Debug("port forward encode failed", "error", err) - return - } - prev = ports - } - time.Sleep(2 * time.Second) - } -} - -// acceptForwardedConns accepts vsock connections from the host. -// Each connection starts with a 2-byte big-endian port number, -// then raw TCP data is spliced to localhost:port. -func acceptForwardedConns(ln *vsock.Listener) { - for { - conn, err := ln.Accept() - if err != nil { - return - } - go handleForwardedConn(conn) - } -} - -func handleForwardedConn(vsockConn net.Conn) { - defer vsockConn.Close() - - // Read 2-byte target port. - var portBuf [2]byte - if _, err := io.ReadFull(vsockConn, portBuf[:]); err != nil { - return - } - port := binary.BigEndian.Uint16(portBuf[:]) - - // Connect to local service. - local, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", itoa(int(port)))) - if err != nil { - return - } - defer local.Close() - - // Splice. Close vsock when local→vsock finishes to propagate EOF. - done := make(chan struct{}) - go func() { - io.Copy(local, vsockConn) - close(done) - }() - io.Copy(vsockConn, local) - vsockConn.Close() - <-done -} - -// getListeningPorts reads /proc/net/tcp and /proc/net/tcp6 for LISTEN sockets. -func getListeningPorts() []uint16 { - seen := map[uint16]bool{} - for _, path := range []string{"/proc/net/tcp", "/proc/net/tcp6"} { - data, err := os.ReadFile(path) - if err != nil { - continue - } - for _, line := range strings.Split(string(data), "\n")[1:] { - fields := strings.Fields(line) - if len(fields) < 4 { - continue - } - // Field 3 is state: 0A = LISTEN - if fields[3] != "0A" { - continue - } - // Field 1 is local_address: ADDR:PORT (hex) - parts := strings.SplitN(fields[1], ":", 2) - if len(parts) != 2 { - continue - } - port := hexToUint16(parts[1]) - if port > 0 && !guestInternalPort(port) { - seen[port] = true - } - } - } - ports := make([]uint16, 0, len(seen)) - for p := range seen { - ports = append(ports, p) - } - return ports -} - -func hexToUint16(s string) uint16 { - var n uint16 - for _, c := range s { - n <<= 4 - switch { - case c >= '0' && c <= '9': - n |= uint16(c - '0') - case c >= 'a' && c <= 'f': - n |= uint16(c-'a') + 10 - case c >= 'A' && c <= 'F': - n |= uint16(c-'A') + 10 - } - } - return n -} - -func itoa(n int) string { - if n == 0 { - return "0" - } - var buf [5]byte - i := len(buf) - for n > 0 { - i-- - buf[i] = byte('0' + n%10) - n /= 10 - } - return string(buf[i:]) -} - -func portsEqual(a, b []uint16) bool { - if len(a) != len(b) { - return false - } - am := map[uint16]bool{} - for _, p := range a { - am[p] = true - } - for _, p := range b { - if !am[p] { - return false - } - } - return true -} diff --git a/old/cmd/init/sshagent.go b/old/cmd/init/sshagent.go deleted file mode 100644 index 81493df..0000000 --- a/old/cmd/init/sshagent.go +++ /dev/null @@ -1,57 +0,0 @@ -//go:build linux - -package main - -import ( - "io" - "log/slog" - "net" - "os" - - "github.com/mdlayher/vsock" - "github.com/semistrict/lnx/internal/protocol" -) - -const sshAgentSockPath = "/tmp/ssh-agent.sock" - -// startSSHAgentForward creates a unix socket and proxies connections -// to the host's SSH agent via vsock. -func startSSHAgentForward() { - os.Remove(sshAgentSockPath) - - listener, err := net.Listen("unix", sshAgentSockPath) - if err != nil { - slog.Warn("ssh agent listen failed", "error", err) - return - } - // Make it accessible to the unprivileged user. - os.Chmod(sshAgentSockPath, 0666) - - os.Setenv("SSH_AUTH_SOCK", sshAgentSockPath) - - go func() { - for { - conn, err := listener.Accept() - if err != nil { - return - } - go proxyToHostAgent(conn) - } - }() - - slog.Info("ssh agent forwarding enabled", "socket", sshAgentSockPath) -} - -func proxyToHostAgent(clientConn net.Conn) { - defer clientConn.Close() - - hostConn, err := vsock.Dial(vsockHostCID, protocol.SSHAgentPort, nil) - if err != nil { - slog.Debug("ssh agent vsock dial failed", "error", err) - return - } - defer hostConn.Close() - - go io.Copy(hostConn, clientConn) - io.Copy(clientConn, hostConn) -} diff --git a/old/cmd/init/sshd.go b/old/cmd/init/sshd.go deleted file mode 100644 index 209b358..0000000 --- a/old/cmd/init/sshd.go +++ /dev/null @@ -1,163 +0,0 @@ -//go:build linux - -package main - -import ( - "crypto/ed25519" - "crypto/rand" - "io" - "log/slog" - "os" - "os/exec" - "syscall" - - "github.com/creack/pty" - "github.com/gliderlabs/ssh" - "github.com/mdlayher/vsock" - "github.com/semistrict/lnx/internal/protocol" - gossh "golang.org/x/crypto/ssh" - "golang.org/x/sys/unix" -) - -// startSSHServer starts an embedded SSH server on a vsock port. -// It accepts any public key (vsock is host-only) and runs commands -// using the same exec setup as the normal exec server. -func startSSHServer() { - ln, err := vsock.Listen(protocol.SSHPort, nil) - if err != nil { - slog.Warn("ssh server listen failed", "error", err) - return - } - - _, privKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - slog.Warn("ssh host key generation failed", "error", err) - ln.Close() - return - } - signer, err := gossh.NewSignerFromKey(privKey) - if err != nil { - slog.Warn("ssh signer creation failed", "error", err) - ln.Close() - return - } - - forwardHandler := &ssh.ForwardedTCPHandler{} - server := &ssh.Server{ - Handler: handleSSHSession, - PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { - return true // vsock is host-only, no network exposure - }, - LocalPortForwardingCallback: func(ctx ssh.Context, dhost string, dport uint32) bool { - return true // allow all port forwarding (needed for VS Code Remote) - }, - ReversePortForwardingCallback: func(ctx ssh.Context, bhost string, bport uint32) bool { - return true - }, - ChannelHandlers: map[string]ssh.ChannelHandler{ - "session": ssh.DefaultSessionHandler, - "direct-tcpip": ssh.DirectTCPIPHandler, - }, - RequestHandlers: map[string]ssh.RequestHandler{ - "tcpip-forward": forwardHandler.HandleSSHRequest, - "cancel-tcpip-forward": forwardHandler.HandleSSHRequest, - }, - } - server.AddHostKey(signer) - - go func() { - if err := server.Serve(ln); err != nil { - slog.Debug("ssh server stopped", "error", err) - } - }() - - slog.Info("ssh server started", "port", protocol.SSHPort) -} - -func handleSSHSession(s ssh.Session) { - args := s.Command() - if len(args) == 0 { - args = []string{"bash", "-l"} - } - - cmd := exec.Command(args[0], args[1:]...) - cmd.Env = os.Environ() - for _, kv := range s.Environ() { - cmd.Env = append(cmd.Env, kv) - } - - switch { - case setupCWD != "": - cmd.Dir = setupCWD - default: - cmd.Dir = os.Getenv("HOME") - } - - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setsid: true, - } - if setupUID > 0 { - cmd.SysProcAttr.Credential = &syscall.Credential{ - Uid: uint32(setupUID), - Gid: uint32(setupUID), - Groups: lookupSupplementaryGroups(setupUID), - } - } - - ptyReq, winCh, isPTY := s.Pty() - if isPTY { - cmd.Env = append(cmd.Env, "TERM="+ptyReq.Term) - - ptmx, err := pty.Start(cmd) - if err != nil { - slog.Warn("ssh pty start failed", "args", args, "error", err) - s.Exit(127) - return - } - defer ptmx.Close() - - if ptyReq.Window.Height > 0 && ptyReq.Window.Width > 0 { - unix.IoctlSetWinsize(int(ptmx.Fd()), unix.TIOCSWINSZ, &unix.Winsize{ - Row: uint16(ptyReq.Window.Height), - Col: uint16(ptyReq.Window.Width), - }) - } - - go func() { - for win := range winCh { - unix.IoctlSetWinsize(int(ptmx.Fd()), unix.TIOCSWINSZ, &unix.Winsize{ - Row: uint16(win.Height), - Col: uint16(win.Width), - }) - } - }() - - go io.Copy(ptmx, s) - io.Copy(s, ptmx) - - exitCode := 0 - if err := cmd.Wait(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - exitCode = 127 - } - } - s.Exit(exitCode) - } else { - cmd.Stdin = s - cmd.Stdout = s - cmd.Stderr = s.Stderr() - - exitCode := 0 - if err := cmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - slog.Warn("ssh exec failed", "args", args, "error", err) - exitCode = 127 - } - } - s.Exit(exitCode) - } -} diff --git a/old/cmd/init/status.go b/old/cmd/init/status.go deleted file mode 100644 index c9210e4..0000000 --- a/old/cmd/init/status.go +++ /dev/null @@ -1,118 +0,0 @@ -//go:build linux - -package main - -import ( - "bufio" - "encoding/gob" - "log/slog" - "os" - "os/exec" - "strconv" - "strings" - "syscall" - - "github.com/mdlayher/vsock" - "github.com/semistrict/lnx/internal/protocol" -) - -// startStatusServer connects to the host on the status vsock port -// and serves StatusReq/StatusResp for the VM's lifetime. -func startStatusServer() { - conn, err := vsock.Dial(vsockHostCID, protocol.StatusPort, nil) - if err != nil { - slog.Warn("status vsock dial failed", "error", err) - return - } - - go func() { - defer conn.Close() - enc := gob.NewEncoder(conn) - dec := gob.NewDecoder(conn) - - for { - var msg protocol.Msg - if err := dec.Decode(&msg); err != nil { - return - } - if msg.StatusReq == nil { - continue - } - - resp := gatherStatus(msg.StatusReq.IncludeDmesg) - if err := enc.Encode(protocol.Msg{StatusResp: &resp}); err != nil { - return - } - } - }() -} - -func gatherStatus(includeDmesg bool) protocol.StatusResp { - resp := protocol.StatusResp{ - LoadAvg: readFileField("/proc/loadavg"), - } - - // Uptime - if fields := strings.Fields(readFileField("/proc/uptime")); len(fields) > 0 { - resp.UptimeSecs, _ = strconv.ParseFloat(fields[0], 64) - } - - // Memory - meminfo := parseMeminfo() - resp.MemTotalKB = meminfo["MemTotal"] - resp.MemAvailKB = meminfo["MemAvailable"] - resp.SwapTotalKB = meminfo["SwapTotal"] - resp.SwapFreeKB = meminfo["SwapFree"] - - // Disk (rootfs at /) - var stat syscall.Statfs_t - if syscall.Statfs("/", &stat) == nil { - resp.DiskTotalKB = stat.Blocks * uint64(stat.Bsize) / 1024 - resp.DiskUsedKB = (stat.Blocks - stat.Bfree) * uint64(stat.Bsize) / 1024 - } - - if includeDmesg { - out, err := exec.Command("dmesg").Output() - if err == nil { - resp.Dmesg = string(out) - } - } - - return resp -} - -func readFileField(path string) string { - data, err := os.ReadFile(path) - if err != nil { - return "" - } - return strings.TrimSpace(string(data)) -} - -func parseMeminfo() map[string]uint64 { - f, err := os.Open("/proc/meminfo") - if err != nil { - return nil - } - defer f.Close() - - result := map[string]uint64{} - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - key, rest, ok := strings.Cut(line, ":") - if !ok { - continue - } - fields := strings.Fields(rest) - if len(fields) == 0 { - continue - } - val, err := strconv.ParseUint(fields[0], 10, 64) - if err != nil { - continue - } - result[key] = val - } - return result -} diff --git a/old/cmd/init/systemctl.go b/old/cmd/init/systemctl.go deleted file mode 100644 index 2f87862..0000000 --- a/old/cmd/init/systemctl.go +++ /dev/null @@ -1,332 +0,0 @@ -//go:build linux - -package main - -import ( - "fmt" - "log/slog" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" -) - -const ( - unitDirs = "/usr/lib/systemd/system:/etc/systemd/system:/lib/systemd/system" - runDir = "/run/lnx-services" - enabledDir = "/etc/lnx-services/enabled" - logDir = "/var/log/lnx-services" -) - -// installSystemctlShim copies the init binary to /usr/local/bin/lnx-init -// and creates a symlink at /usr/local/bin/systemctl. -// When invoked as "systemctl", main() dispatches to runSystemctl(). -func installSystemctlShim() { - initBin, err := os.ReadFile("/proc/self/exe") - if err != nil { - slog.Warn("failed to read init binary for systemctl shim", "error", err) - return - } - os.MkdirAll("/usr/local/bin", 0755) - if err := os.WriteFile("/usr/local/bin/lnx-init", initBin, 0755); err != nil { - slog.Warn("failed to install lnx-init", "error", err) - return - } - os.Remove("/usr/local/bin/systemctl") - os.Symlink("/usr/local/bin/lnx-init", "/usr/local/bin/systemctl") - // Some packages look in /usr/bin - os.Remove("/usr/bin/systemctl") - os.Symlink("/usr/local/bin/lnx-init", "/usr/bin/systemctl") - - os.MkdirAll(runDir, 0755) - os.MkdirAll(enabledDir, 0755) - os.MkdirAll(logDir, 0755) -} - -// installSystemdCatShim points systemd-cat at the embedded init binary. -// When invoked as "systemd-cat", main() dispatches to runSystemdCat(). -func installSystemdCatShim() { - os.Remove("/usr/bin/systemd-cat") - if err := os.Symlink("/usr/local/bin/lnx-init", "/usr/bin/systemd-cat"); err != nil { - slog.Warn("failed to install systemd-cat shim", "error", err) - } -} - -// runSystemctl implements a minimal systemctl that parses systemd unit files. -func runSystemctl(args []string) int { - // Strip flags that systemd clients pass. - var cmd string - var units []string - for _, arg := range args { - if strings.HasPrefix(arg, "-") { - continue - } - if cmd == "" { - cmd = arg - } else { - units = append(units, arg) - } - } - - switch cmd { - case "start": - for _, u := range units { - if err := svcStart(u); err != nil { - fmt.Fprintf(os.Stderr, "Failed to start %s: %v\n", u, err) - return 1 - } - } - case "stop": - for _, u := range units { - svcStop(u) - } - case "restart": - for _, u := range units { - svcStop(u) - if err := svcStart(u); err != nil { - fmt.Fprintf(os.Stderr, "Failed to start %s: %v\n", u, err) - return 1 - } - } - case "status": - for _, u := range units { - svcStatus(u) - } - case "enable": - for _, u := range units { - svcEnable(u) - } - case "disable": - for _, u := range units { - svcDisable(u) - } - case "is-active": - for _, u := range units { - if !svcIsActive(u) { - fmt.Println("inactive") - return 1 - } - fmt.Println("active") - } - case "is-enabled": - for _, u := range units { - name := canonicalName(u) - if _, err := os.Lstat(filepath.Join(enabledDir, name)); err == nil { - fmt.Println("enabled") - } else { - fmt.Println("disabled") - return 1 - } - } - case "daemon-reload", "show", "list-units", "cat", "mask", "unmask": - // no-ops - default: - if cmd != "" { - fmt.Fprintf(os.Stderr, "lnx-systemctl: unsupported command: %s\n", cmd) - return 1 - } - } - return 0 -} - -func canonicalName(name string) string { - if !strings.Contains(name, ".") { - return name + ".service" - } - return name -} - -func findUnit(name string) string { - name = canonicalName(name) - for _, dir := range strings.Split(unitDirs, ":") { - path := filepath.Join(dir, name) - if _, err := os.Stat(path); err == nil { - return path - } - } - return "" -} - -func parseField(path, field string) string { - data, err := os.ReadFile(path) - if err != nil { - return "" - } - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, field+"=") { - return strings.TrimPrefix(line, field+"=") - } - } - return "" -} - -func pidFile(name string) string { - return filepath.Join(runDir, strings.TrimSuffix(canonicalName(name), ".service")+".pid") -} - -func isRunning(name string) bool { - data, err := os.ReadFile(pidFile(name)) - if err != nil { - return false - } - pid := atoi(strings.TrimSpace(string(data))) - if pid <= 0 { - return false - } - return syscall.Kill(pid, 0) == nil -} - -func svcStart(name string) error { - if isRunning(name) { - return nil - } - - unit := findUnit(name) - if unit == "" { - return fmt.Errorf("unit %s not found", name) - } - - // Start dependencies. - for _, field := range []string{"Requires", "Wants"} { - deps := parseField(unit, field) - for _, dep := range strings.Fields(deps) { - if strings.HasSuffix(dep, ".target") || strings.HasSuffix(dep, ".socket") || - strings.Contains(dep, "network") { - continue - } - if !isRunning(dep) { - svcStart(dep) - } - } - } - - execStart := parseField(unit, "ExecStart") - if execStart == "" { - return nil // oneshot with no ExecStart, or a target - } - execStart = strings.TrimPrefix(execStart, "-") - - // Docker uses -H fd:// for systemd socket activation; use unix socket instead. - execStart = strings.ReplaceAll(execStart, "-H fd://", "-H unix:///var/run/docker.sock") - - // Run ExecStartPre if present. - if pre := parseField(unit, "ExecStartPre"); pre != "" { - pre = strings.TrimPrefix(pre, "-") - preCmd := exec.Command("sh", "-c", pre) - preCmd.Run() - } - - svcName := strings.TrimSuffix(canonicalName(name), ".service") - logPath := filepath.Join(logDir, svcName+".log") - logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return fmt.Errorf("open log: %w", err) - } - - cmd := exec.Command("sh", "-c", execStart) - cmd.Stdout = logFile - cmd.Stderr = logFile - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - - if err := cmd.Start(); err != nil { - logFile.Close() - return fmt.Errorf("start: %w", err) - } - logFile.Close() - - os.WriteFile(pidFile(name), []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644) - go cmd.Wait() // reap - - return nil -} - -func svcStop(name string) { - unit := findUnit(name) - if unit != "" { - if execStop := parseField(unit, "ExecStop"); execStop != "" { - execStop = strings.TrimPrefix(execStop, "-") - exec.Command("sh", "-c", execStop).Run() - } - } - - data, err := os.ReadFile(pidFile(name)) - if err != nil { - return - } - pid := atoi(strings.TrimSpace(string(data))) - if pid > 0 { - syscall.Kill(pid, syscall.SIGTERM) - // Brief wait for clean shutdown. - for i := 0; i < 10; i++ { - if syscall.Kill(pid, 0) != nil { - break - } - // Can't import time in a simple way here, use a busy loop with nanosleep - var ts syscall.Timespec - ts.Sec = 0 - ts.Nsec = 200_000_000 - syscall.Nanosleep(&ts, nil) - } - syscall.Kill(pid, syscall.SIGKILL) - } - os.Remove(pidFile(name)) -} - -func svcStatus(name string) { - svcName := strings.TrimSuffix(canonicalName(name), ".service") - if isRunning(name) { - data, _ := os.ReadFile(pidFile(name)) - fmt.Printf("● %s.service - active (running)\n", svcName) - fmt.Printf(" PID: %s\n", strings.TrimSpace(string(data))) - } else { - fmt.Printf("● %s.service - inactive (dead)\n", svcName) - } -} - -func svcIsActive(name string) bool { - return isRunning(name) -} - -func svcEnable(name string) { - unit := findUnit(name) - if unit == "" { - fmt.Fprintf(os.Stderr, "Unit %s not found\n", name) - return - } - svcName := strings.TrimSuffix(canonicalName(name), ".service") - os.MkdirAll(enabledDir, 0755) - os.Symlink(unit, filepath.Join(enabledDir, svcName)) -} - -func svcDisable(name string) { - svcName := strings.TrimSuffix(canonicalName(name), ".service") - os.Remove(filepath.Join(enabledDir, svcName)) -} - -// startEnabledServices starts all services that were enabled via `systemctl enable`. -func startEnabledServices() { - entries, err := os.ReadDir(enabledDir) - if err != nil { - return - } - for _, e := range entries { - name := e.Name() - if err := svcStart(name); err != nil { - slog.Warn("failed to start enabled service", "name", name, "error", err) - } else { - slog.Info("started enabled service", "name", name) - } - } -} - -func atoi(s string) int { - n := 0 - for _, c := range s { - if c >= '0' && c <= '9' { - n = n*10 + int(c-'0') - } - } - return n -} diff --git a/old/cmd/lnx/api.go b/old/cmd/lnx/api.go deleted file mode 100644 index 0507f4c..0000000 --- a/old/cmd/lnx/api.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "context" - "fmt" - "net" - "net/http" - "os" - "path/filepath" - "strings" - "time" -) - -// apiClientFor returns an HTTP client that talks to a specific instance's unix socket. -func apiClientFor(name string) *http.Client { - sockPaths := []string{ - filepath.Join(lnxBase(), "instances", name, "status.sock"), - filepath.Join("/var/lib/lnx/instances", name, "status.sock"), - filepath.Join("/var/run/lnx", name, "status.sock"), - } - return &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - for _, sp := range sockPaths { - conn, err := net.DialTimeout("unix", sp, 2*time.Second) - if err == nil { - return conn, nil - } - } - return nil, fmt.Errorf("no status socket for instance %q", name) - }, - }, - } -} - -// apiClient returns an HTTP client for the current --instance. -func apiClient() *http.Client { - return apiClientFor(qualifiedInstanceName()) -} - -// runningInstances returns a list of instance names that have a reachable status.sock. -func runningInstances() []string { - instancesDir := filepath.Join(lnxBase(), "instances") - entries, err := os.ReadDir(instancesDir) - if err != nil { - return nil - } - - var running []string - for _, e := range entries { - if !e.IsDir() { - continue - } - name := e.Name() - sockPath := filepath.Join(instancesDir, name, "status.sock") - conn, err := net.DialTimeout("unix", sockPath, 500*time.Millisecond) - if err != nil { - continue - } - conn.Close() - running = append(running, name) - } - return running -} - -// isNoVM returns true if the error indicates no VM is running. -func isNoVM(err error) bool { - s := err.Error() - return strings.Contains(s, "no such file") || - strings.Contains(s, "connection refused") || - strings.Contains(s, "no status socket") -} - -// noVMError wraps the "no VM running" message. -func noVMError() error { - return fmt.Errorf("no VM running") -} diff --git a/old/cmd/lnx/checkpoint_cmd.go b/old/cmd/lnx/checkpoint_cmd.go deleted file mode 100644 index c5c36a5..0000000 --- a/old/cmd/lnx/checkpoint_cmd.go +++ /dev/null @@ -1,242 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" - - lnx "github.com/semistrict/lnx" - "github.com/spf13/cobra" -) - -var checkpointCRIU bool - -var checkpointCmd = &cobra.Command{ - Use: "checkpoints", - Short: "Manage rootfs checkpoints", -} - -var checkpointListCmd = &cobra.Command{ - Use: "list", - Short: "List available checkpoints", - RunE: runCheckpointList, -} - -var checkpointCreateCmd = &cobra.Command{ - Use: "create [name]", - Short: "Create a checkpoint for the current instance", - Args: cobra.MaximumNArgs(1), - RunE: runCheckpointCreate, -} - -var checkpointRestoreCmd = &cobra.Command{ - Use: "restore ", - Short: "Restore a checkpoint (requires VM to be stopped)", - Args: cobra.ExactArgs(1), - RunE: runCheckpointRestore, -} - -func init() { - checkpointCreateCmd.Flags().BoolVar(&checkpointCRIU, "criu", false, "CRIU checkpoint: dump process state + clone rootfs and CRIU volume") - checkpointCmd.AddCommand(checkpointListCmd, checkpointCreateCmd, checkpointRestoreCmd) - rootCmd.AddCommand(checkpointCmd) -} - -func runCheckpointCreate(cmd *cobra.Command, args []string) error { - name := "" - if len(args) == 1 { - name = args[0] - } - - if checkpointCRIU { - if name == "" { - return fmt.Errorf("--criu requires a checkpoint name") - } - return createCRIUCheckpoint(name) - } - - cpPath, err := createInstanceCheckpoint(filepath.Dir(resolveRootfsPath()), qualifiedInstanceName(), name) - if err != nil { - return err - } - - fmt.Printf("created checkpoint %q\n", filepath.Base(cpPath)) - return nil -} - -func createCRIUCheckpoint(name string) error { - instanceName := qualifiedInstanceName() - if !isInstanceRunning(instanceName) { - return fmt.Errorf("VM must be running for CRIU checkpoints") - } - - client := apiClientFor(instanceName) - body, err := json.Marshal(map[string]string{"name": name}) - if err != nil { - return err - } - resp, err := client.Post("http://localhost/criu/checkpoint", "application/json", bytes.NewReader(body)) - if err != nil { - if isNoVM(err) { - return noVMError() - } - return err - } - defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - data, _ := io.ReadAll(resp.Body) - return fmt.Errorf("%s", strings.TrimSpace(string(data))) - } - - var result struct { - Path string `json:"path"` - } - json.NewDecoder(resp.Body).Decode(&result) - fmt.Printf("created CRIU checkpoint %q at %s\n", name, result.Path) - return nil -} - -func runCheckpointRestore(cmd *cobra.Command, args []string) error { - name := args[0] - instanceName := qualifiedInstanceName() - - if isInstanceRunning(instanceName) { - return fmt.Errorf("stop the VM before restoring (lnx stop --shutdown)") - } - - imgDir := filepath.Dir(resolveRootfsPath()) - - // Check for CRIU checkpoint (directory with rootfs.ext4 + criu.ext4). - criuDir := filepath.Join(imgDir, "checkpoints", name) - if _, err := os.Stat(filepath.Join(criuDir, "rootfs.ext4")); err == nil { - return restoreCRIUCheckpoint(imgDir, criuDir, name) - } - - // Fall back to disk-only checkpoint (.ext4 file). - return restoreDiskCheckpoint(imgDir, name) -} - -func restoreCRIUCheckpoint(imgDir, criuDir, name string) error { - rootfsPath := filepath.Join(imgDir, "rootfs.ext4") - criuPath := filepath.Join(imgDir, "criu.ext4") - - // Lock rootfs to prevent concurrent access. - lock, err := lnx.LockRootfs(rootfsPath) - if err != nil { - return fmt.Errorf("lock rootfs: %w", err) - } - defer lock.Unlock() - - // Replace rootfs with checkpoint clone. - if err := os.Remove(rootfsPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove rootfs: %w", err) - } - if err := cloneRootfs(filepath.Join(criuDir, "rootfs.ext4"), rootfsPath); err != nil { - return fmt.Errorf("clone rootfs: %w", err) - } - - // Replace CRIU volume with checkpoint clone. - if err := os.Remove(criuPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove criu volume: %w", err) - } - if err := cloneRootfs(filepath.Join(criuDir, "criu.ext4"), criuPath); err != nil { - return fmt.Errorf("clone criu volume: %w", err) - } - - fmt.Printf("restored CRIU checkpoint %q\n", name) - fmt.Println("boot the VM to restore processes") - return nil -} - -func restoreDiskCheckpoint(imgDir, name string) error { - cpPath, err := resolveNamedCheckpoint(imgDir, name) - if err != nil { - return err - } - - rootfsPath := filepath.Join(imgDir, "rootfs.ext4") - - // Lock rootfs. - lock, err := lnx.LockRootfs(rootfsPath) - if err != nil { - return fmt.Errorf("lock rootfs: %w", err) - } - defer lock.Unlock() - - if err := os.Remove(rootfsPath); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove rootfs: %w", err) - } - if err := cloneRootfs(cpPath, rootfsPath); err != nil { - return fmt.Errorf("clone checkpoint: %w", err) - } - fmt.Printf("restored disk checkpoint %q\n", name) - return nil -} - -func runCheckpointList(cmd *cobra.Command, args []string) error { - dir := filepath.Join(filepath.Dir(resolveRootfsPath()), "checkpoints") - - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - fmt.Println("no checkpoints") - return nil - } - return err - } - - type cpEntry struct { - name string - size string - cpType string - } - var checkpoints []cpEntry - - for _, e := range entries { - if e.IsDir() { - // CRIU checkpoint directory. - rootfs := filepath.Join(dir, e.Name(), "rootfs.ext4") - if info, err := os.Stat(rootfs); err == nil { - sizeMB := float64(info.Size()) / 1024 / 1024 - checkpoints = append(checkpoints, cpEntry{ - name: e.Name(), - size: fmt.Sprintf("%.1f MB", sizeMB), - cpType: "criu", - }) - } - } else if filepath.Ext(e.Name()) == ".ext4" { - // Disk-only checkpoint. - info, err := e.Info() - if err != nil { - continue - } - sizeMB := float64(info.Size()) / 1024 / 1024 - checkpoints = append(checkpoints, cpEntry{ - name: e.Name(), - size: fmt.Sprintf("%.1f MB", sizeMB), - cpType: "disk", - }) - } - } - - sort.Slice(checkpoints, func(i, j int) bool { - return checkpoints[i].name < checkpoints[j].name - }) - - if len(checkpoints) == 0 { - fmt.Println("no checkpoints") - return nil - } - - t := newTable("NAME", "TYPE", "SIZE") - for _, cp := range checkpoints { - t.Row(cp.name, cp.cpType, cp.size) - } - fmt.Println(t) - return nil -} diff --git a/old/cmd/lnx/clone_darwin.go b/old/cmd/lnx/clone_darwin.go deleted file mode 100644 index a9199b0..0000000 --- a/old/cmd/lnx/clone_darwin.go +++ /dev/null @@ -1,44 +0,0 @@ -//go:build darwin - -package main - -import ( - "errors" - "io" - "os" - "syscall" - - "golang.org/x/sys/unix" -) - -// cloneRootfs creates a copy-on-write clone of src at dst using APFS clonefile. -// Falls back to a regular copy if the source and destination are on different volumes. -func cloneRootfs(src, dst string) error { - err := unix.Clonefile(src, dst, 0) - if err == nil || !errors.Is(err, syscall.EXDEV) { - return err - } - return copyRootfs(src, dst) -} - -func copyRootfs(src, dst string) error { - s, err := os.Open(src) - if err != nil { - return err - } - defer s.Close() - info, err := s.Stat() - if err != nil { - return err - } - d, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) - if err != nil { - return err - } - if _, err := io.Copy(d, s); err != nil { - d.Close() - os.Remove(dst) - return err - } - return d.Close() -} diff --git a/old/cmd/lnx/clone_linux.go b/old/cmd/lnx/clone_linux.go deleted file mode 100644 index 3187025..0000000 --- a/old/cmd/lnx/clone_linux.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build linux - -package main - -import ( - "fmt" - "io" - "os" -) - -// cloneRootfs copies src to dst. On Linux, this is a regular file copy -// (the filesystem may use reflinks if supported, e.g. btrfs/xfs). -func cloneRootfs(src, dst string) error { - sf, err := os.Open(src) - if err != nil { - return fmt.Errorf("open source: %w", err) - } - defer sf.Close() - - df, err := os.Create(dst) - if err != nil { - return fmt.Errorf("create dest: %w", err) - } - defer df.Close() - - if _, err := io.Copy(df, sf); err != nil { - os.Remove(dst) - return fmt.Errorf("copy: %w", err) - } - return df.Close() -} diff --git a/old/cmd/lnx/completion_cmd.go b/old/cmd/lnx/completion_cmd.go deleted file mode 100644 index 84bf87a..0000000 --- a/old/cmd/lnx/completion_cmd.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - "os" - - "github.com/spf13/cobra" -) - -var completionCmd = &cobra.Command{ - Use: "completion [bash|zsh|fish]", - Short: "Generate shell completion script", - Long: `Generate shell completion script for lnx. - -To load completions: - -Zsh (add to ~/.zshrc): - eval "$(lnx completion zsh)" - -Bash (add to ~/.bashrc): - eval "$(lnx completion bash)" - -Fish: - lnx completion fish | source`, - ValidArgs: []string{"bash", "zsh", "fish"}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - switch args[0] { - case "bash": - return rootCmd.GenBashCompletionV2(os.Stdout, true) - case "zsh": - return rootCmd.GenZshCompletion(os.Stdout) - case "fish": - return rootCmd.GenFishCompletion(os.Stdout, true) - default: - return cmd.Usage() - } - }, -} - -func init() { - // Disable cobra's built-in completion command (we provide our own). - rootCmd.CompletionOptions.DisableDefaultCmd = true - rootCmd.AddCommand(completionCmd) -} diff --git a/old/cmd/lnx/daemon_cmd.go b/old/cmd/lnx/daemon_cmd.go deleted file mode 100644 index 1ccdc7e..0000000 --- a/old/cmd/lnx/daemon_cmd.go +++ /dev/null @@ -1,129 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - - "github.com/semistrict/lnx" - "github.com/semistrict/lnx/internal/protocol" - "github.com/spf13/cobra" -) - -var daemonCmd = &cobra.Command{ - Use: "_daemon", - Short: "Run VM as a background daemon (internal use)", - Hidden: true, - RunE: func(cmd *cobra.Command, args []string) error { - for _, kv := range cliOptions { - if k, v, ok := strings.Cut(kv, "="); ok { - lnx.SetOption(k, v) - } - } - lnx.InitBinary = initBinary - dir := instanceDir() - - rootfsPath, socketDir := resolveRootfs(dir) - - // Scan for nested instances to attach as block devices. - nested := scanNestedInstances() - - err := lnx.RunDaemon(&lnx.Config{ - KernelPath: resolveKernel(), - RootfsPath: rootfsPath, - Hostname: qualifiedInstanceName() + ".lnx", - Checkpoint: doCheckpoint, - Ephemeral: doEphemeral, - SSHAgent: doSSHAgent, - Shares: loadShares(dir), - SyncShares: loadSyncShares(dir), - DirectShare: doNoGuestCache, - SocketDir: socketDir, - NestedRootfs: nested, - }) - if err != nil { - errPath := filepath.Join(socketDir, "error.log") - os.WriteFile(errPath, []byte(err.Error()+"\n"), 0644) - return err - } - os.Remove(filepath.Join(socketDir, "error.log")) - return nil - }, -} - -func init() { - daemonCmd.Flags().BoolVarP(&doCheckpoint, "checkpoint", "c", false, "snapshot rootfs before starting") - daemonCmd.Flags().BoolVar(&doEphemeral, "ephemeral", false, "clone rootfs to a temp file; discard on exit") - daemonCmd.Flags().BoolVar(&doSSHAgent, "ssh-agent", false, "forward host SSH agent into the guest") - daemonCmd.Flags().BoolVar(&doNoGuestCache, "no-guest-cache", false, "mount CWD/shares directly via 9P (no FUSE cache)") - daemonCmd.Flags().StringArrayVarP(&cliOptions, "option", "O", nil, "runtime option key=value") - rootCmd.AddCommand(daemonCmd) -} - -// resolveRootfs returns the rootfs path and socket directory for the current instance. -// For nested instances, the rootfs is a block device (/dev/vdX) discovered -// from the drives mapping written by the parent's guest init. -func resolveRootfs(instanceDir string) (rootfsPath, socketDir string) { - // Check for a nested drive mapping first (block device takes priority). - qname := qualifiedInstanceName() - if dev := lookupNestedDrive(qname); dev != "" { - workDir := filepath.Join("/var/lib/lnx/instances", qname) - os.MkdirAll(workDir, 0755) - return dev, workDir - } - - // Normal case: rootfs is in the images directory. The socket directory - // (for status.sock, error.log) stays in the instance directory. - return resolveRootfsPath(), instanceDir -} - -const nestedDrivesPath = "/var/lib/lnx/nested-drives.json" - -// lookupNestedDrive reads the drives mapping and returns the device path -// for the given instance name, or empty if not found. -func lookupNestedDrive(instanceName string) string { - data, err := os.ReadFile(nestedDrivesPath) - if err != nil { - return "" - } - var drives []protocol.NestedDrive - if err := json.Unmarshal(data, &drives); err != nil { - return "" - } - for _, d := range drives { - if d.InstanceName == instanceName { - return d.DevicePath - } - } - return "" -} - -// scanNestedInstances finds rootfs files for nested instances of the current -// instance. For instance "default", it looks for "default.*" directories. -func scanNestedInstances() []lnx.NestedRootfs { - parent := qualifiedInstanceName() - prefix := parent + "." - instancesDir := filepath.Join(lnxBase(), "instances") - - entries, err := os.ReadDir(instancesDir) - if err != nil { - return nil - } - - var nested []lnx.NestedRootfs - for _, e := range entries { - if !e.IsDir() || !strings.HasPrefix(e.Name(), prefix) { - continue - } - rootfs := resolveRootfsPathFor(e.Name()) - if _, err := os.Stat(rootfs); err != nil { - continue - } - nested = append(nested, lnx.NestedRootfs{ - InstanceName: e.Name(), - RootfsPath: rootfs, - }) - } - return nested -} diff --git a/old/cmd/lnx/daemon_spawn_darwin.go b/old/cmd/lnx/daemon_spawn_darwin.go deleted file mode 100644 index 994740e..0000000 --- a/old/cmd/lnx/daemon_spawn_darwin.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build darwin - -package main - -import "os/exec" - -// buildDaemonCmd creates the command to spawn the daemon process. -// On Darwin, no privilege escalation is needed. -func buildDaemonCmd(self string, daemonArgs []string) *exec.Cmd { - return exec.Command(self, daemonArgs...) -} diff --git a/old/cmd/lnx/daemon_spawn_linux.go b/old/cmd/lnx/daemon_spawn_linux.go deleted file mode 100644 index df215bf..0000000 --- a/old/cmd/lnx/daemon_spawn_linux.go +++ /dev/null @@ -1,49 +0,0 @@ -//go:build linux - -package main - -import ( - "fmt" - "log/slog" - "os" - "os/exec" -) - -// buildDaemonCmd creates the command to spawn the daemon process. -// On Linux, the daemon needs root for TAP/KVM/block devices. -// If not already root, wraps with sudo and passes through env vars. -func buildDaemonCmd(self string, daemonArgs []string) *exec.Cmd { - if os.Getuid() == 0 { - return exec.Command(self, daemonArgs...) - } - - // Check if linux_host experiment is enabled — if not, no sudo needed - // (buildVM will return an error anyway). - if os.Getenv("LNX_EXPERIMENTS") == "" { - return exec.Command(self, daemonArgs...) - } - - // The daemon needs root for TAP/KVM/block devices. - // Use sudo with explicit env vars since sudo resets the environment. - envArgs := []string{ - fmt.Sprintf("HOME=%s", os.Getenv("HOME")), - } - if parent := os.Getenv("LNX_PARENT"); parent != "" { - envArgs = append(envArgs, fmt.Sprintf("LNX_PARENT=%s", parent)) - } - if logLevel := os.Getenv("LNX_LOG"); logLevel != "" { - envArgs = append(envArgs, fmt.Sprintf("LNX_LOG=%s", logLevel)) - } - if experiments := os.Getenv("LNX_EXPERIMENTS"); experiments != "" { - envArgs = append(envArgs, fmt.Sprintf("LNX_EXPERIMENTS=%s", experiments)) - } - - // sudo VAR=val command args... - args := append(envArgs, self) - args = append(args, daemonArgs...) - cmd := exec.Command("sudo", args...) - - // Log the command for debugging. - slog.Debug("spawning daemon with sudo", "args", cmd.Args) - return cmd -} diff --git a/old/cmd/lnx/disk_cmd.go b/old/cmd/lnx/disk_cmd.go deleted file mode 100644 index b07c386..0000000 --- a/old/cmd/lnx/disk_cmd.go +++ /dev/null @@ -1,133 +0,0 @@ -package main - -import ( - "fmt" - "os" - "strconv" - "strings" - - "github.com/spf13/cobra" -) - -var diskCmd = &cobra.Command{ - Use: "disk", - Short: "Manage VM disk", -} - -var diskGrowCmd = &cobra.Command{ - Use: "grow ", - Short: "Grow the rootfs image to the given size", - Long: `Grow the rootfs ext4 image to the given size. - -Size can be specified as: - lnx disk grow 8G set total size to 8 GiB - lnx disk grow 16GB set total size to 16 GB (decimal) - lnx disk grow +2G grow by 2 GiB from current size - -The filesystem is resized automatically on next boot (resize2fs). -The instance must not be running.`, - Args: cobra.ExactArgs(1), - RunE: runDiskGrow, -} - -func init() { - diskCmd.AddCommand(diskGrowCmd) - rootCmd.AddCommand(diskCmd) -} - -func runDiskGrow(cmd *cobra.Command, args []string) error { - rootfs := resolveRootfsPath() - - info, err := os.Stat(rootfs) - if err != nil { - return fmt.Errorf("stat rootfs: %w", err) - } - currentSize := info.Size() - - targetSize, err := parseSize(args[0], currentSize) - if err != nil { - return err - } - - if targetSize <= currentSize { - fmt.Printf("rootfs is already %s (requested %s)\n", formatSize(currentSize), formatSize(targetSize)) - return nil - } - - f, err := os.OpenFile(rootfs, os.O_RDWR, 0) - if err != nil { - return fmt.Errorf("open rootfs: %w", err) - } - defer f.Close() - - if err := f.Truncate(targetSize); err != nil { - return fmt.Errorf("truncate: %w", err) - } - - fmt.Printf("rootfs: %s → %s\n", formatSize(currentSize), formatSize(targetSize)) - fmt.Println("filesystem will be resized on next boot") - return nil -} - -// parseSize parses a size string like "8G", "16GB", "+2G". -func parseSize(s string, current int64) (int64, error) { - relative := false - if strings.HasPrefix(s, "+") { - relative = true - s = s[1:] - } - - // Find where the number ends and the unit begins. - i := 0 - for i < len(s) && (s[i] == '.' || (s[i] >= '0' && s[i] <= '9')) { - i++ - } - if i == 0 { - return 0, fmt.Errorf("invalid size: %q", s) - } - - num, err := strconv.ParseFloat(s[:i], 64) - if err != nil { - return 0, fmt.Errorf("invalid size: %q", s) - } - - unit := strings.ToUpper(strings.TrimSpace(s[i:])) - var multiplier float64 - switch unit { - case "", "B": - multiplier = 1 - case "K", "KB", "KIB": - multiplier = 1024 - case "M", "MB", "MIB": - multiplier = 1024 * 1024 - case "G", "GIB": - multiplier = 1024 * 1024 * 1024 - case "GB": - multiplier = 1e9 - case "T", "TIB": - multiplier = 1024 * 1024 * 1024 * 1024 - case "TB": - multiplier = 1e12 - default: - return 0, fmt.Errorf("unknown unit: %q", unit) - } - - size := int64(num * multiplier) - if relative { - size += current - } - return size, nil -} - -func formatSize(b int64) string { - const gib = 1024 * 1024 * 1024 - if b >= gib { - g := float64(b) / float64(gib) - if g == float64(int64(g)) { - return fmt.Sprintf("%dG", int64(g)) - } - return fmt.Sprintf("%.1fG", g) - } - const mib = 1024 * 1024 - return fmt.Sprintf("%dM", b/mib) -} diff --git a/old/cmd/lnx/docker_cmd.go b/old/cmd/lnx/docker_cmd.go deleted file mode 100644 index e62c881..0000000 --- a/old/cmd/lnx/docker_cmd.go +++ /dev/null @@ -1,412 +0,0 @@ -package main - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "net" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - "github.com/semistrict/lnx/internal/lnxoci" - "github.com/spf13/cobra" -) - -var dockerCmd = &cobra.Command{ - Use: "docker", - Short: "Run OCI container images in lnx VMs", -} - -var dockerRunCmd = &cobra.Command{ - Use: "run IMAGE[:TAG] [COMMAND [ARGS...]]", - Short: "Pull and run an OCI container image", - Args: cobra.MinimumNArgs(1), - RunE: runDockerRun, - DisableFlagParsing: true, -} - -var dockerPsCmd = &cobra.Command{ - Use: "ps", - Short: "List containers", - Args: cobra.NoArgs, - RunE: runDockerPs, -} - -func init() { - dockerCmd.AddCommand(dockerRunCmd) - dockerCmd.AddCommand(dockerPsCmd) - rootCmd.AddCommand(dockerCmd) -} - -// containerMeta is persisted as container.json alongside each container's rootfs. -type containerMeta struct { - ID string `json:"id"` - Image string `json:"image"` - Command []string `json:"command"` - Created time.Time `json:"created"` - Ports []portMapping `json:"ports,omitempty"` -} - -// portMapping is a resolved host→guest port binding. -type portMapping struct { - Host uint16 `json:"host"` - Guest uint16 `json:"guest"` -} - -// imageMeta is persisted as image.json alongside a base image rootfs. -type imageMeta struct { - ExposedPorts []uint16 `json:"exposed_ports,omitempty"` -} - -// dockerImagesDir returns ~/.lnx/docker/images — the base OCI image store. -func dockerImagesDir() string { - return filepath.Join(lnxBase(), "docker", "images") -} - -// dockerImageDirFor returns the directory for a specific OCI base image. -func dockerImageDirFor(name string) string { - return filepath.Join(dockerImagesDir(), name) -} - -// dockerContainersDir returns ~/.lnx/docker/containers. -func dockerContainersDir() string { - return filepath.Join(lnxBase(), "docker", "containers") -} - -// dockerContainerDirFor returns the images directory for a container instance. -// imagesDirFor resolves to this path for docker container IDs. -func dockerContainerDirFor(id string) string { - return filepath.Join(dockerContainersDir(), id) -} - -func runDockerRun(cmd *cobra.Command, args []string) error { - // DisableFlagParsing means all args are raw. Strip Docker-compatible flags - // that appear before the image name. - var portSpecs []string - var publishAll bool - for len(args) > 0 { - a := args[0] - switch { - case a == "-i" || a == "-t" || a == "-it" || a == "-ti" || - a == "--interactive" || a == "--tty": - args = args[1:] - case a == "-P" || a == "--publish-all": - publishAll = true - args = args[1:] - case a == "-p" || a == "--publish": - if len(args) < 2 { - return fmt.Errorf("flag %q requires an argument", a) - } - portSpecs = append(portSpecs, args[1]) - args = args[2:] - case strings.HasPrefix(a, "-p=") || strings.HasPrefix(a, "--publish="): - portSpecs = append(portSpecs, strings.SplitN(a, "=", 2)[1]) - args = args[1:] - default: - goto flagsDone - } - } -flagsDone: - if len(args) == 0 { - return fmt.Errorf("requires image name") - } - - imageRef := args[0] - if !strings.Contains(imageRef, ":") { - imageRef += ":latest" - } - cmdArgs := args[1:] // optional command override - - inst := lnxoci.SlugFromRef(imageRef) - - baseRootfs, err := ensureOCIRootfs(imageRef) - if err != nil { - return err - } - - // Resolve -p specs into (host, guest) pairs. - var wantMappings []portMapping - for _, spec := range portSpecs { - h, g, err := parsePortMapping(spec) - if err != nil { - return fmt.Errorf("invalid -p %q: %w", spec, err) - } - wantMappings = append(wantMappings, portMapping{Host: h, Guest: g}) - } - // -P: add all ports declared by the image. - if publishAll { - imgMeta, _ := readImageMeta(inst) - for _, p := range imgMeta.ExposedPorts { - wantMappings = append(wantMappings, portMapping{Host: 0, Guest: p}) - } - } - - // Resolve the run command before creating the container so we can persist it. - runArgs := cmdArgs - if len(runArgs) == 0 { - runArgs = readDefaultCmd(inst) - } - if len(runArgs) == 0 { - runArgs = []string{"/bin/sh"} - } - - // Create an ephemeral container: reflink clone of the base image rootfs. - containerID, err := newContainerID(inst) - if err != nil { - return fmt.Errorf("generate container ID: %w", err) - } - containerDir := dockerContainerDirFor(containerID) - if err := os.MkdirAll(containerDir, 0755); err != nil { - return fmt.Errorf("create container dir: %w", err) - } - containerRootfs := filepath.Join(containerDir, "rootfs.ext4") - if err := cloneRootfs(baseRootfs, containerRootfs); err != nil { - os.RemoveAll(containerDir) - return fmt.Errorf("clone container rootfs: %w", err) - } - - meta := containerMeta{ - ID: containerID, - Image: imageRef, - Command: runArgs, - Created: time.Now(), - } - if err := writeContainerMeta(containerDir, meta); err != nil { - os.RemoveAll(containerDir) - return fmt.Errorf("write container metadata: %w", err) - } - - defer func() { - os.RemoveAll(containerDir) - os.RemoveAll(instanceDirFor(containerID)) - }() - - instanceName = containerID - instanceFlag = true - - // Start the VM so we can register port mappings before exec. - if err := ensureVMRunning(); err != nil { - return err - } - - // Register port mappings with the running daemon. - if len(wantMappings) > 0 { - var resolved []portMapping - for _, m := range wantMappings { - resp, err := exposeHostPort(containerID, m.Guest, m.Host, true) - if err != nil { - return fmt.Errorf("expose port %d: %w", m.Guest, err) - } - resolved = append(resolved, portMapping{Host: resp.HostPort, Guest: m.Guest}) - } - // Persist the resolved host ports so docker ps can show them. - meta.Ports = resolved - _ = writeContainerMeta(containerDir, meta) - } - - exitCode, err := runVM(runArgs) - if err != nil { - return err - } - os.Exit(exitCode) - return nil -} - -func runDockerPs(cmd *cobra.Command, args []string) error { - entries, err := os.ReadDir(dockerContainersDir()) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("read containers dir: %w", err) - } - - type row struct { - meta containerMeta - running bool - } - var rows []row - for _, e := range entries { - if !e.IsDir() { - continue - } - meta, err := readContainerMeta(filepath.Join(dockerContainersDir(), e.Name())) - if err != nil { - continue - } - sock := filepath.Join(instanceDirFor(meta.ID), "status.sock") - running := false - if c, err := net.DialTimeout("unix", sock, 200*time.Millisecond); err == nil { - c.Close() - running = true - } - rows = append(rows, row{meta, running}) - } - - sort.Slice(rows, func(i, j int) bool { - return rows[i].meta.Created.Before(rows[j].meta.Created) - }) - - t := newTable("CONTAINER ID", "IMAGE", "COMMAND", "CREATED", "STATUS", "PORTS") - for _, r := range rows { - status := dimStyle.Render("Exited") - if r.running { - status = greenStyle.Render("Up " + humanDuration(time.Since(r.meta.Created))) - } - cmdStr := shellJoin(r.meta.Command) - if len(cmdStr) > 20 { - cmdStr = cmdStr[:20] + "…" - } - var portStrs []string - for _, p := range r.meta.Ports { - portStrs = append(portStrs, fmt.Sprintf("0.0.0.0:%d->%d/tcp", p.Host, p.Guest)) - } - t.Row( - r.meta.ID, - r.meta.Image, - `"`+cmdStr+`"`, - humanDuration(time.Since(r.meta.Created))+" ago", - status, - strings.Join(portStrs, ", "), - ) - } - fmt.Println(t) - return nil -} - -// parsePortMapping parses a Docker -p spec: [hostPort:]guestPort[/proto]. -// Returns host=0 if no host port is specified (ephemeral). -func parsePortMapping(s string) (host, guest uint16, err error) { - // Strip optional /proto suffix. - if i := strings.LastIndex(s, "/"); i >= 0 { - s = s[:i] - } - parts := strings.SplitN(s, ":", 2) - if len(parts) == 1 { - n, err := strconv.ParseUint(parts[0], 10, 16) - if err != nil || n == 0 { - return 0, 0, fmt.Errorf("invalid port %q", parts[0]) - } - return 0, uint16(n), nil - } - h, err := strconv.ParseUint(parts[0], 10, 16) - if err != nil || h == 0 { - return 0, 0, fmt.Errorf("invalid host port %q", parts[0]) - } - g, err := strconv.ParseUint(parts[1], 10, 16) - if err != nil || g == 0 { - return 0, 0, fmt.Errorf("invalid guest port %q", parts[1]) - } - return uint16(h), uint16(g), nil -} - -func writeContainerMeta(dir string, meta containerMeta) error { - data, err := json.MarshalIndent(meta, "", " ") - if err != nil { - return err - } - return os.WriteFile(filepath.Join(dir, "container.json"), data, 0644) -} - -func readContainerMeta(dir string) (containerMeta, error) { - data, err := os.ReadFile(filepath.Join(dir, "container.json")) - if err != nil { - return containerMeta{}, err - } - var meta containerMeta - if err := json.Unmarshal(data, &meta); err != nil { - return containerMeta{}, err - } - return meta, nil -} - -func writeImageMeta(inst string, meta imageMeta) { - data, err := json.MarshalIndent(meta, "", " ") - if err != nil { - return - } - _ = os.WriteFile(filepath.Join(dockerImageDirFor(inst), "image.json"), data, 0644) -} - -func readImageMeta(inst string) (imageMeta, error) { - data, err := os.ReadFile(filepath.Join(dockerImageDirFor(inst), "image.json")) - if err != nil { - return imageMeta{}, err - } - var meta imageMeta - if err := json.Unmarshal(data, &meta); err != nil { - return imageMeta{}, err - } - return meta, nil -} - -// humanDuration formats a duration in Docker-style human-readable form. -func humanDuration(d time.Duration) string { - d = d.Round(time.Second) - switch { - case d < time.Minute: - return fmt.Sprintf("%d seconds", int(d.Seconds())) - case d < time.Hour: - m := int(d.Minutes()) - if m == 1 { - return "1 minute" - } - return fmt.Sprintf("%d minutes", m) - case d < 24*time.Hour: - h := int(d.Hours()) - if h == 1 { - return "1 hour" - } - return fmt.Sprintf("%d hours", h) - default: - days := int(d.Hours() / 24) - if days == 1 { - return "1 day" - } - return fmt.Sprintf("%d days", days) - } -} - -// shellJoin joins args into a display string, quoting args that contain spaces. -func shellJoin(args []string) string { - parts := make([]string, len(args)) - for i, a := range args { - if strings.ContainsAny(a, " \t\"'") { - parts[i] = `"` + strings.ReplaceAll(a, `"`, `\"`) + `"` - } else { - parts[i] = a - } - } - return strings.Join(parts, " ") -} - -// newContainerID generates a slug-based container ID: -<6-hex-chars>. -func newContainerID(imageSlug string) (string, error) { - b := make([]byte, 3) - if _, err := rand.Read(b); err != nil { - return "", err - } - return imageSlug + "-" + hex.EncodeToString(b), nil -} - - -// writeDefaultCmd saves the image's default command alongside the base image. -func writeDefaultCmd(inst string, cmd []string) { - p := filepath.Join(dockerImageDirFor(inst), "cmd") - _ = os.WriteFile(p, []byte(strings.Join(cmd, "\x00")), 0644) -} - -// readDefaultCmd loads the saved default command for a base image. -func readDefaultCmd(inst string) []string { - p := filepath.Join(dockerImageDirFor(inst), "cmd") - data, err := os.ReadFile(p) - if err != nil || len(data) == 0 { - return nil - } - return strings.Split(string(data), "\x00") -} diff --git a/old/cmd/lnx/embed.go b/old/cmd/lnx/embed.go deleted file mode 100644 index 73912f9..0000000 --- a/old/cmd/lnx/embed.go +++ /dev/null @@ -1,6 +0,0 @@ -package main - -import _ "embed" - -//go:embed init -var initBinary []byte diff --git a/old/cmd/lnx/env_flags.go b/old/cmd/lnx/env_flags.go deleted file mode 100644 index 70e1ba3..0000000 --- a/old/cmd/lnx/env_flags.go +++ /dev/null @@ -1,86 +0,0 @@ -package main - -import ( - "fmt" - "os" - "sort" - "strings" - - "github.com/joho/godotenv" -) - -var forwardEnv []string -var forwardAllEnv bool - -func execEnv() ([]string, error) { - if forwardAllEnv { - var env []string - for _, kv := range os.Environ() { - key, _, _ := strings.Cut(kv, "=") - if excludePreservedEnvKey(key) { - continue - } - env = append(env, kv) - } - return env, nil - } - - env := make([]string, 0, len(forwardEnv)) - for _, spec := range forwardEnv { - if spec == "" { - continue - } - if strings.HasPrefix(spec, "@") { - fileEnv, err := loadDotenv(spec[1:]) - if err != nil { - return nil, err - } - env = append(env, fileEnv...) - continue - } - if strings.Contains(spec, "=") { - env = append(env, spec) - continue - } - value, ok := os.LookupEnv(spec) - if !ok { - return nil, fmt.Errorf("host env var %q is not set", spec) - } - env = append(env, spec+"="+value) - } - return env, nil -} - -func excludePreservedEnvKey(key string) bool { - switch key { - case "HOME", "PATH", "PWD", "OLDPWD", "TMPDIR", "SHELL", - "SSH_AUTH_SOCK", "DISPLAY", "XDG_RUNTIME_DIR", - "SECURITYSESSIONID", "LaunchInstanceID", "COMMAND_MODE": - return true - } - for _, prefix := range []string{"DYLD_", "__CF_", "APPLE_", "XPC_"} { - if strings.HasPrefix(key, prefix) { - return true - } - } - return false -} - -func loadDotenv(path string) ([]string, error) { - values, err := godotenv.Read(path) - if err != nil { - return nil, fmt.Errorf("read env file %q: %w", path, err) - } - - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - - env := make([]string, 0, len(keys)) - for _, key := range keys { - env = append(env, key+"="+values[key]) - } - return env, nil -} diff --git a/old/cmd/lnx/env_flags_test.go b/old/cmd/lnx/env_flags_test.go deleted file mode 100644 index 0cf7c52..0000000 --- a/old/cmd/lnx/env_flags_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestExecEnvExplicitForwarding(t *testing.T) { - t.Setenv("FORWARD_ME", "value") - forwardEnv = []string{"FORWARD_ME", "SET_ME=literal"} - forwardAllEnv = false - t.Cleanup(func() { - forwardEnv = nil - forwardAllEnv = false - }) - - env, err := execEnv() - require.NoError(t, err) - require.Equal(t, []string{"FORWARD_ME=value", "SET_ME=literal"}, env) -} - -func TestExecEnvMissingVar(t *testing.T) { - forwardEnv = []string{"DOES_NOT_EXIST_42"} - forwardAllEnv = false - t.Cleanup(func() { - forwardEnv = nil - forwardAllEnv = false - }) - - _, err := execEnv() - require.Error(t, err) - require.Contains(t, err.Error(), `host env var "DOES_NOT_EXIST_42" is not set`) -} - -func TestExecEnvDotenvFile(t *testing.T) { - path := filepath.Join(t.TempDir(), ".env") - require.NoError(t, os.WriteFile(path, []byte("ZED=last\nALPHA=\"first value\"\n"), 0644)) - - forwardEnv = []string{"@" + path} - forwardAllEnv = false - t.Cleanup(func() { - forwardEnv = nil - forwardAllEnv = false - }) - - env, err := execEnv() - require.NoError(t, err) - require.Equal(t, []string{"ALPHA=first value", "ZED=last"}, env) -} - -func TestExecEnvPreserveEnvExcludesHostPathVars(t *testing.T) { - t.Setenv("HOME", "/host/home") - t.Setenv("PATH", "/host/bin") - t.Setenv("PWD", "/host/pwd") - t.Setenv("LNX_KEEP_ME", "yes") - forwardEnv = nil - forwardAllEnv = true - t.Cleanup(func() { - forwardEnv = nil - forwardAllEnv = false - }) - - env, err := execEnv() - require.NoError(t, err) - require.Contains(t, env, "LNX_KEEP_ME=yes") - require.NotContains(t, env, "HOME=/host/home") - require.NotContains(t, env, "PATH=/host/bin") - require.NotContains(t, env, "PWD=/host/pwd") -} diff --git a/old/cmd/lnx/exec_client.go b/old/cmd/lnx/exec_client.go deleted file mode 100644 index d31a175..0000000 --- a/old/cmd/lnx/exec_client.go +++ /dev/null @@ -1,440 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "net" - "net/http" - "os" - "os/signal" - "path/filepath" - "strings" - "sync" - "syscall" - "time" - - "github.com/semistrict/lnx" - "golang.org/x/term" - "nhooyr.io/websocket" -) - -var errExecTerminatedUnexpectedly = errors.New("exec terminated unexpectedly") - -// execNonInteractive runs a non-interactive command via POST /exec with NDJSON streaming. -func execNonInteractive(args []string) (int, error) { - env, err := execEnv() - if err != nil { - return -1, err - } - - body, err := json.Marshal(lnx.ExecRequest{Args: args, Env: env, ClientPID: os.Getpid()}) - if err != nil { - return -1, err - } - - resp, err := apiClient().Post("http://localhost/exec", "application/json", bytes.NewReader(body)) - if err != nil { - if isNoVM(err) { - return -1, noVMError() - } - return -1, fmt.Errorf("connect to VM: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var buf bytes.Buffer - buf.ReadFrom(resp.Body) - return -1, fmt.Errorf("exec failed: %s", strings.TrimSpace(buf.String())) - } - - // Catch signals — closing the response body terminates the HTTP stream, - // which closes the exec connection and cleans up the guest process. - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - defer signal.Stop(sigCh) - go func() { - <-sigCh - resp.Body.Close() - }() - - // Track fork children so we can wait for all of them. - ft := &forkTracker{} - ctx := context.Background() - - scanner := bufio.NewScanner(resp.Body) - scanner.Buffer(make([]byte, 1024*1024), 1024*1024) - - exitCode := -1 - sawExitCode := false - for scanner.Scan() { - line := scanner.Bytes() - var msg map[string]json.RawMessage - if err := json.Unmarshal(line, &msg); err != nil { - continue - } - if raw, ok := msg["stdout"]; ok { - var s string - json.Unmarshal(raw, &s) - os.Stdout.WriteString(s) - } - if raw, ok := msg["stderr"]; ok { - var s string - json.Unmarshal(raw, &s) - os.Stderr.WriteString(s) - } - if raw, ok := msg["fork"]; ok { - var forkInfo struct { - Instance string `json:"instance"` - } - json.Unmarshal(raw, &forkInfo) - ft.wg.Add(1) - go attachToForkChild(ctx, forkInfo.Instance, ft) - } - if raw, ok := msg["exit_code"]; ok { - json.Unmarshal(raw, &exitCode) - sawExitCode = true - } - } - - // Wait for all fork children to finish. - ft.wg.Wait() - - if err := scanner.Err(); err != nil { - return -1, fmt.Errorf("read exec stream: %w", err) - } - if !sawExitCode || exitCode < 0 { - return -1, errExecTerminatedUnexpectedly - } - return exitCode, nil -} - -// forkTracker manages child fork connections so the outermost CLI can -// wait for all descendants and broadcast SIGWINCH to them. -type forkTracker struct { - mu sync.Mutex - wg sync.WaitGroup - conns []*websocket.Conn // active child WebSocket connections -} - -func (ft *forkTracker) add(ws *websocket.Conn) { - ft.mu.Lock() - ft.conns = append(ft.conns, ws) - ft.mu.Unlock() -} - -func (ft *forkTracker) remove(ws *websocket.Conn) { - ft.mu.Lock() - for i, c := range ft.conns { - if c == ws { - ft.conns = append(ft.conns[:i], ft.conns[i+1:]...) - break - } - } - ft.mu.Unlock() -} - -func (ft *forkTracker) broadcastResize(ctx context.Context, data []byte) { - ft.mu.Lock() - conns := append([]*websocket.Conn{}, ft.conns...) - ft.mu.Unlock() - for _, c := range conns { - c.Write(ctx, websocket.MessageText, data) - } -} - -// execInteractive runs an interactive command via WebSocket. -func execInteractive(args []string) (int, error) { - env, err := execEnv() - if err != nil { - return -1, err - } - - fd := int(os.Stdin.Fd()) - var rows, cols uint16 - if term.IsTerminal(fd) { - w, h, err := term.GetSize(fd) - if err == nil { - rows = uint16(h) - cols = uint16(w) - } - oldState, err := term.MakeRaw(fd) - if err == nil { - defer term.Restore(fd, oldState) - } - } - - ctx := context.Background() - ws, _, err := websocket.Dial(ctx, "ws://localhost/exec/ws", &websocket.DialOptions{ - HTTPClient: apiClient(), - }) - if err != nil { - if isNoVM(err) { - return -1, noVMError() - } - return -1, fmt.Errorf("connect to VM: %w", err) - } - defer ws.CloseNow() - ws.SetReadLimit(-1) // no limit on PTY data - - // Send exec request as first text message. - reqJSON, _ := json.Marshal(lnx.ExecRequest{ - Args: args, - Env: env, - PTY: true, - Rows: rows, - Cols: cols, - ClientPID: os.Getpid(), - }) - if err := ws.Write(ctx, websocket.MessageText, reqJSON); err != nil { - return -1, fmt.Errorf("send exec request: %w", err) - } - - // Track fork children so we can wait for all of them and broadcast resize. - ft := &forkTracker{} - - // Forward host signals (SIGWINCH, SIGINT, SIGTERM, SIGHUP) to the guest - // via WebSocket text frames. - sigCh := make(chan os.Signal, 4) - signal.Notify(sigCh, syscall.SIGWINCH, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - defer signal.Stop(sigCh) - - go func() { - for sig := range sigCh { - if sig == syscall.SIGWINCH { - w, h, err := term.GetSize(fd) - if err == nil { - data, _ := json.Marshal(map[string]any{ - "resize": map[string]uint16{"rows": uint16(h), "cols": uint16(w)}, - }) - ws.Write(ctx, websocket.MessageText, data) - ft.broadcastResize(ctx, data) - } - } else { - data, _ := json.Marshal(map[string]any{ - "signal": int(sig.(syscall.Signal)), - }) - ws.Write(ctx, websocket.MessageText, data) - } - } - }() - - // Read stdin → binary WebSocket frames (with double Ctrl-C detection). - forceQuit := make(chan struct{}) - go func() { - buf := make([]byte, 32*1024) - var lastCtrlC time.Time - for { - n, err := os.Stdin.Read(buf) - if n > 0 { - // Detect double Ctrl-C. - for i := 0; i < n; i++ { - if buf[i] == 0x03 { - now := time.Now() - if !lastCtrlC.IsZero() && now.Sub(lastCtrlC) < time.Second { - fmt.Fprintln(os.Stderr, "\r\nforce quit") - close(forceQuit) - ws.Close(websocket.StatusNormalClosure, "force quit") - return - } - lastCtrlC = now - } - } - ws.Write(ctx, websocket.MessageBinary, buf[:n]) - } - if err != nil { - return - } - } - }() - - // Read WebSocket messages: binary = PTY output, text = exit_code/fork. - exitCode := -1 - sawExitCode := false - var last byte - var prev byte - sawOutput := false - for { - typ, data, err := ws.Read(ctx) - if err != nil { - break - } - switch typ { - case websocket.MessageBinary: - os.Stdout.Write(data) - if len(data) > 0 { - sawOutput = true - if len(data) >= 2 { - prev = data[len(data)-2] - } - last = data[len(data)-1] - } - case websocket.MessageText: - var msg map[string]json.RawMessage - if err := json.Unmarshal(data, &msg); err == nil { - if raw, ok := msg["fork"]; ok { - var forkInfo struct { - Instance string `json:"instance"` - } - json.Unmarshal(raw, &forkInfo) - ft.wg.Add(1) - go attachToForkChild(ctx, forkInfo.Instance, ft) - } - if raw, ok := msg["exit_code"]; ok { - json.Unmarshal(raw, &exitCode) - sawExitCode = true - } - } - } - } - - // Wait for all fork children (recursively) to finish. - ft.wg.Wait() - - // If double Ctrl-C was detected, always return 130. - select { - case <-forceQuit: - return 130, nil - default: - } - - if sawOutput && last == '\n' && prev != '\r' { - _, _ = os.Stdout.Write([]byte{'\r'}) - } - - if !sawExitCode || exitCode < 0 { - return -1, errExecTerminatedUnexpectedly - } - return exitCode, nil -} - -// attachToForkChild connects to a forked child VM's fork session and -// multiplexes its PTY output to stdout. Supports recursive forks. -func attachToForkChild(ctx context.Context, instance string, ft *forkTracker) { - defer ft.wg.Done() - - client := apiClientFor(instance) - - // Wait for the child VM to be reachable, then connect to the fork - // session. Use a short timeout — if the fork attach server isn't - // there, CRIU restore likely failed and we shouldn't hang. - var cws *websocket.Conn - var err error - for i := 0; i < 50; i++ { - cws, _, err = websocket.Dial(ctx, "ws://localhost/fork/ws", &websocket.DialOptions{ - HTTPClient: client, - }) - if err == nil { - break - } - // Once the child's status socket is reachable but /fork/ws fails - // with a real HTTP error (not connection refused), the fork session - // doesn't exist — bail immediately. - if i > 10 && err != nil && !isNoVM(err) { - break - } - time.Sleep(200 * time.Millisecond) - } - if cws == nil { - slog.Debug("fork attach failed", "instance", instance, "error", err) - return - } - slog.Debug("fork attach connected", "instance", instance) - defer cws.CloseNow() - cws.SetReadLimit(-1) - - ft.add(cws) - defer ft.remove(cws) - - // Send request with current terminal dimensions. - var rows, cols uint16 - if w, h, err := term.GetSize(int(os.Stdin.Fd())); err == nil { - rows = uint16(h) - cols = uint16(w) - } - reqJSON, _ := json.Marshal(lnx.ExecRequest{ - PTY: true, - Rows: rows, - Cols: cols, - ClientPID: os.Getpid(), - }) - if err := cws.Write(ctx, websocket.MessageText, reqJSON); err != nil { - slog.Debug("failed to send fork attach request", "instance", instance, "error", err) - return - } - - // Read child output → stdout, handle recursive forks. - for { - typ, data, err := cws.Read(ctx) - if err != nil { - break - } - switch typ { - case websocket.MessageBinary: - os.Stdout.Write(data) - case websocket.MessageText: - var msg map[string]json.RawMessage - if err := json.Unmarshal(data, &msg); err == nil { - if raw, ok := msg["fork"]; ok { - var forkInfo struct { - Instance string `json:"instance"` - } - json.Unmarshal(raw, &forkInfo) - ft.wg.Add(1) - go attachToForkChild(ctx, forkInfo.Instance, ft) - } - if _, ok := msg["exit_code"]; ok { - return - } - } - } - } -} - -// waitForVM polls status.sock until the daemon is ready, up to timeout. -// If the daemon exits with an error, it reads error.log for diagnostics. -func waitForVM(timeout time.Duration) error { - sockPaths := statusSockPaths() - // Only check error.log paths the daemon can write to. - // For nested instances (LNX_PARENT set), the instance dir may be - // on a read-only mount with stale logs from previous attempts. - qname := qualifiedInstanceName() - var errPaths []string - if os.Getenv("LNX_PARENT") != "" { - errPaths = []string{filepath.Join("/var/lib/lnx/instances", qname, "error.log")} - } else { - errPaths = []string{filepath.Join(instanceDir(), "error.log")} - } - - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - for _, sp := range sockPaths { - conn, err := net.DialTimeout("unix", sp, 500*time.Millisecond) - if err == nil { - conn.Close() - return nil - } - } - if msg := readFirstErrorLog(errPaths); msg != "" { - return fmt.Errorf("VM failed to start: %s", msg) - } - time.Sleep(200 * time.Millisecond) - } - if msg := readFirstErrorLog(errPaths); msg != "" { - return fmt.Errorf("VM failed to start: %s", msg) - } - return fmt.Errorf("timed out waiting for VM to start") -} - -func readFirstErrorLog(paths []string) string { - for _, p := range paths { - if data, err := os.ReadFile(p); err == nil && len(data) > 0 { - return strings.TrimSpace(string(data)) - } - } - return "" -} diff --git a/old/cmd/lnx/expose_cmd.go b/old/cmd/lnx/expose_cmd.go deleted file mode 100644 index f43d501..0000000 --- a/old/cmd/lnx/expose_cmd.go +++ /dev/null @@ -1,214 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/semistrict/lnx" - "github.com/spf13/cobra" -) - -const guestHostGateway = "192.168.64.1" - -var exposeAs string - -var exposeCmd = &cobra.Command{ - Use: "expose SOURCE", - Short: "Expose a port from one VM on the host or another VM", - Args: cobra.ExactArgs(1), - RunE: runExpose, -} - -type exposeEndpoint struct { - Instance string - Port uint16 - PortSet bool -} - -func init() { - exposeCmd.Flags().StringVar(&exposeAs, "as", "", "host or VM destination ([vm][:port])") - rootCmd.AddCommand(exposeCmd) -} - -func runExpose(cmd *cobra.Command, args []string) error { - if instanceFlag { - return fmt.Errorf("--instance is not used by expose; specify instances in SOURCE and --as") - } - - src, err := parseExposeEndpoint(args[0], false) - if err != nil { - return err - } - - dst, err := parseExposeDestination(exposeAs) - if err != nil { - return err - } - - srcPort, dstPort, hostMode, err := resolveExposePorts(src, dst) - if err != nil { - return err - } - - if hostMode { - resp, err := exposeHostPort(src.Instance, srcPort, dstPort, true) - if err != nil { - return err - } - fmt.Printf("localhost:%d -> %s:%d\n", resp.HostPort, src.Instance, srcPort) - return nil - } - - if src.Instance == dst.Instance && srcPort == dstPort { - return fmt.Errorf("%s:%d cannot be exposed onto itself", src.Instance, srcPort) - } - - resp, err := exposeHostPort(src.Instance, srcPort, 0, false) - if err != nil { - return err - } - if _, err := exposeGuestPort(dst.Instance, dstPort, guestHostGateway, resp.HostPort); err != nil { - if resp.Created { - if rollbackErr := removeHostExpose(src.Instance, resp.HostPort); rollbackErr != nil { - return fmt.Errorf("%w (rollback failed: %v)", err, rollbackErr) - } - } - return err - } - - fmt.Printf("%s:%d -> %s:%d\n", dst.Instance, dstPort, src.Instance, srcPort) - return nil -} - -func parseExposeDestination(s string) (exposeEndpoint, error) { - if s == "" { - return exposeEndpoint{}, nil - } - return parseExposeEndpoint(s, true) -} - -func parseExposeEndpoint(s string, allowEmptyInstance bool) (exposeEndpoint, error) { - if s == "" { - return exposeEndpoint{}, fmt.Errorf("endpoint is required") - } - - parts := strings.SplitN(s, ":", 2) - inst := parts[0] - if inst == "" && !allowEmptyInstance { - return exposeEndpoint{}, fmt.Errorf("instance name is required") - } - - ep := exposeEndpoint{Instance: inst} - if len(parts) == 1 || parts[1] == "" { - return ep, nil - } - - n, err := strconv.ParseUint(parts[1], 10, 16) - if err != nil || n == 0 { - return exposeEndpoint{}, fmt.Errorf("invalid port %q", parts[1]) - } - ep.Port = uint16(n) - ep.PortSet = true - return ep, nil -} - -func resolveExposePorts(src, dst exposeEndpoint) (srcPort, dstPort uint16, hostMode bool, err error) { - hostMode = dst.Instance == "" - - switch { - case src.PortSet: - srcPort = src.Port - case dst.PortSet: - srcPort = dst.Port - } - - switch { - case hostMode && dst.PortSet: - dstPort = dst.Port - case hostMode: - dstPort = srcPort - case dst.PortSet: - dstPort = dst.Port - default: - dstPort = srcPort - } - - if srcPort == 0 || dstPort == 0 { - return 0, 0, false, fmt.Errorf("a port must be specified on SOURCE or --as") - } - return srcPort, dstPort, hostMode, nil -} - -func exposeHostPort(instance string, guestPort, hostPort uint16, visible bool) (*lnx.ExposeHostResponse, error) { - req := lnx.ExposeHostRequest{ - GuestPort: guestPort, - HostPort: hostPort, - Visible: visible, - } - var resp lnx.ExposeHostResponse - if err := postInstanceJSON(instance, "/expose/host", req, &resp); err != nil { - return nil, fmt.Errorf("expose %s:%d on host: %w", instance, guestPort, err) - } - return &resp, nil -} - -func exposeGuestPort(instance string, listenPort uint16, host string, hostPort uint16) (*lnx.GuestExposeResponse, error) { - req := lnx.GuestExposeRequest{ - ListenPort: listenPort, - Host: host, - HostPort: hostPort, - } - var resp lnx.GuestExposeResponse - if err := postInstanceJSON(instance, "/guest/expose", req, &resp); err != nil { - return nil, fmt.Errorf("expose %s:%d: %w", instance, listenPort, err) - } - return &resp, nil -} - -func removeHostExpose(instance string, hostPort uint16) error { - req := lnx.RemoveExposeHostRequest{HostPort: hostPort} - if err := postInstanceJSON(instance, "/expose/host/remove", req, nil); err != nil { - return fmt.Errorf("remove host expose %s:%d: %w", instance, hostPort, err) - } - return nil -} - -func postInstanceJSON(instance, path string, reqBody any, respBody any) error { - data, err := json.Marshal(reqBody) - if err != nil { - return err - } - - resp, err := apiClientFor(instance).Post("http://localhost"+path, "application/json", bytes.NewReader(data)) - if err != nil { - if isNoVM(err) { - return fmt.Errorf("no VM running for instance %q", instance) - } - return err - } - defer resp.Body.Close() - - if resp.StatusCode/100 != 2 { - var msg bytes.Buffer - _, _ = msg.ReadFrom(resp.Body) - text := strings.TrimSpace(msg.String()) - if text == "" { - text = resp.Status - } - if resp.StatusCode == http.StatusConflict { - return fmt.Errorf("%s", text) - } - return fmt.Errorf("%s", text) - } - - if respBody != nil { - if err := json.NewDecoder(resp.Body).Decode(respBody); err != nil { - return err - } - } - return nil -} diff --git a/old/cmd/lnx/expose_cmd_test.go b/old/cmd/lnx/expose_cmd_test.go deleted file mode 100644 index 441d161..0000000 --- a/old/cmd/lnx/expose_cmd_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package main - -import "testing" - -func TestParseExposeEndpoint(t *testing.T) { - tests := []struct { - name string - input string - allowEmpty bool - wantInst string - wantPort uint16 - wantPortSet bool - wantErr bool - }{ - {name: "instance and port", input: "vm1:8080", wantInst: "vm1", wantPort: 8080, wantPortSet: true}, - {name: "instance only", input: "vm1", wantInst: "vm1"}, - {name: "host port only", input: ":9090", allowEmpty: true, wantInst: "", wantPort: 9090, wantPortSet: true}, - {name: "empty instance rejected", input: ":9090", wantErr: true}, - {name: "zero port rejected", input: "vm1:0", wantErr: true}, - {name: "invalid port rejected", input: "vm1:abc", wantErr: true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got, err := parseExposeEndpoint(tc.input, tc.allowEmpty) - if tc.wantErr { - if err == nil { - t.Fatalf("expected error, got %+v", got) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.Instance != tc.wantInst || got.Port != tc.wantPort || got.PortSet != tc.wantPortSet { - t.Fatalf("got %+v, want instance=%q port=%d portSet=%v", got, tc.wantInst, tc.wantPort, tc.wantPortSet) - } - }) - } -} - -func TestResolveExposePorts(t *testing.T) { - tests := []struct { - name string - src exposeEndpoint - dst exposeEndpoint - wantSrcPort uint16 - wantDstPort uint16 - wantHostMode bool - wantErr bool - }{ - { - name: "host defaults same port", - src: exposeEndpoint{Instance: "vm1", Port: 8080, PortSet: true}, - wantSrcPort: 8080, - wantDstPort: 8080, - wantHostMode: true, - }, - { - name: "host explicit port", - src: exposeEndpoint{Instance: "vm1", Port: 8080, PortSet: true}, - dst: exposeEndpoint{Port: 9090, PortSet: true}, - wantSrcPort: 8080, - wantDstPort: 9090, - wantHostMode: true, - }, - { - name: "source inherits host port", - src: exposeEndpoint{Instance: "vm1"}, - dst: exposeEndpoint{Port: 9090, PortSet: true}, - wantSrcPort: 9090, - wantDstPort: 9090, - wantHostMode: true, - }, - { - name: "vm destination defaults to source port", - src: exposeEndpoint{Instance: "vm1", Port: 8080, PortSet: true}, - dst: exposeEndpoint{Instance: "vm2"}, - wantSrcPort: 8080, - wantDstPort: 8080, - wantHostMode: false, - }, - { - name: "source inherits vm destination port", - src: exposeEndpoint{Instance: "vm1"}, - dst: exposeEndpoint{Instance: "vm2", Port: 9090, PortSet: true}, - wantSrcPort: 9090, - wantDstPort: 9090, - wantHostMode: false, - }, - { - name: "missing all ports errors", - src: exposeEndpoint{Instance: "vm1"}, - dst: exposeEndpoint{Instance: "vm2"}, - wantErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - gotSrc, gotDst, gotHostMode, err := resolveExposePorts(tc.src, tc.dst) - if tc.wantErr { - if err == nil { - t.Fatalf("expected error, got src=%d dst=%d hostMode=%v", gotSrc, gotDst, gotHostMode) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gotSrc != tc.wantSrcPort || gotDst != tc.wantDstPort || gotHostMode != tc.wantHostMode { - t.Fatalf("got src=%d dst=%d hostMode=%v, want src=%d dst=%d hostMode=%v", gotSrc, gotDst, gotHostMode, tc.wantSrcPort, tc.wantDstPort, tc.wantHostMode) - } - }) - } -} diff --git a/old/cmd/lnx/fork_cmd.go b/old/cmd/lnx/fork_cmd.go deleted file mode 100644 index 36b5df6..0000000 --- a/old/cmd/lnx/fork_cmd.go +++ /dev/null @@ -1,93 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "os" - "strings" - "syscall" - - "github.com/spf13/cobra" -) - -var forkCmd = &cobra.Command{ - Use: "fork [-- command...]", - Short: "Fork the running VM into a child instance", - Long: `Fork creates a copy of the running VM by: -1. Using CRIU to dump all user processes (they keep running in the parent) -2. APFS-cloning the rootfs and CRIU volume (instant, copy-on-write) -3. Booting a child instance that restores the dumped processes - -If a command is given after --, it is exec'd in the child with -stdin/stdout/stderr connected to the current terminal — like fork(). - -Without a command, prints the child instance name.`, - Args: cobra.ArbitraryArgs, - DisableFlagParsing: true, - RunE: runFork, -} - -func init() { - rootCmd.AddCommand(forkCmd) -} - -func runFork(cmd *cobra.Command, args []string) error { - // Split args on "--" into fork flags and child command. - var childArgs []string - for i, a := range args { - if a == "--" { - childArgs = args[i+1:] - args = args[:i] - break - } - } - - // Handle --help. - for _, a := range args { - if a == "-h" || a == "--help" { - return cmd.Help() - } - } - - instanceName := qualifiedInstanceName() - if !isInstanceRunning(instanceName) { - return fmt.Errorf("VM must be running to fork") - } - - client := apiClientFor(instanceName) - resp, err := client.Post("http://localhost/fork", "application/json", nil) - if err != nil { - if isNoVM(err) { - return noVMError() - } - return err - } - defer resp.Body.Close() - - if resp.StatusCode/100 != 2 { - data, _ := io.ReadAll(resp.Body) - return fmt.Errorf("%s", strings.TrimSpace(string(data))) - } - - var result struct { - ChildInstance string `json:"child_instance"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("decode fork response: %w", err) - } - - if len(childArgs) == 0 { - fmt.Printf("forked to %s\n", result.ChildInstance) - return nil - } - - // Exec into the child VM with the terminal connected. - self, err := os.Executable() - if err != nil { - return fmt.Errorf("find executable: %w", err) - } - - execArgs := append([]string{"lnx", "--instance", result.ChildInstance}, childArgs...) - return syscall.Exec(self, execArgs, os.Environ()) -} diff --git a/old/cmd/lnx/ingress_cmd.go b/old/cmd/lnx/ingress_cmd.go deleted file mode 100644 index 9689f81..0000000 --- a/old/cmd/lnx/ingress_cmd.go +++ /dev/null @@ -1,707 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "net" - "net/http" - "net/http/httputil" - "net/url" - "os" - "os/exec" - "os/signal" - "path/filepath" - "runtime" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "github.com/spf13/cobra" - "golang.org/x/net/dns/dnsmessage" -) - -const ( - defaultIngressDomain = "lnx" - defaultIngressDNSAddr = "127.0.0.1:5354" - defaultIngressHTTPAddr = "127.0.0.1:80" -) - -var ( - ingressSpawn bool - ingressCleanup bool -) - -var ingressCmd = &cobra.Command{ - Use: "ingress", - Short: "Manage local .lnx HTTP ingress", -} - -var ingressEnableCmd = &cobra.Command{ - Use: "enable", - Short: "Enable local .lnx DNS and HTTP ingress", - RunE: runIngressEnable, -} - -var ingressDisableCmd = &cobra.Command{ - Use: "disable", - Short: "Disable local .lnx DNS and HTTP ingress", - RunE: runIngressDisable, -} - -var ingressStatusCmd = &cobra.Command{ - Use: "status", - Short: "Show local .lnx ingress status", - RunE: runIngressStatus, -} - -var ingressHiddenCmd = &cobra.Command{ - Use: "_ingress", - Short: "Run local ingress helper (internal use)", - Hidden: true, - RunE: func(cmd *cobra.Command, args []string) error { - if runtime.GOOS != "darwin" { - return fmt.Errorf("ingress is only supported on macOS") - } - cfg := loadIngressConfig() - switch { - case ingressCleanup: - return runIngressCleanup(cfg) - case ingressSpawn: - return spawnIngressDaemon(cfg) - default: - return runIngressDaemon(cfg) - } - }, -} - -type ingressConfig struct { - Domain string - DNSAddr string - HTTPAddr string - ResolverDir string - StateDir string -} - -type ingressStatus struct { - Enabled bool `json:"enabled"` - Domain string `json:"domain"` - DNSAddr string `json:"dns_addr"` - HTTPAddr string `json:"http_addr"` - ResolverPath string `json:"resolver_path"` - PID int `json:"pid"` -} - -type ingressRoute struct { - Instance string - Port uint16 -} - -func init() { - ingressCmd.AddCommand(ingressEnableCmd, ingressDisableCmd, ingressStatusCmd) - ingressHiddenCmd.Flags().BoolVar(&ingressSpawn, "spawn", false, "spawn ingress daemon and exit") - ingressHiddenCmd.Flags().BoolVar(&ingressCleanup, "cleanup", false, "remove ingress resolver and stale socket") - rootCmd.AddCommand(ingressCmd) - rootCmd.AddCommand(ingressHiddenCmd) -} - -func loadIngressConfig() ingressConfig { - return ingressConfig{ - Domain: envOr("LNX_INGRESS_DOMAIN", defaultIngressDomain), - DNSAddr: envOr("LNX_INGRESS_DNS_ADDR", defaultIngressDNSAddr), - HTTPAddr: envOr("LNX_INGRESS_HTTP_ADDR", defaultIngressHTTPAddr), - ResolverDir: envOr("LNX_INGRESS_RESOLVER_DIR", "/etc/resolver"), - StateDir: envOr("LNX_INGRESS_STATE_DIR", filepath.Join(lnxBase(), "ingress")), - } -} - -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func (cfg ingressConfig) socketPath() string { - return filepath.Join(cfg.StateDir, "ingress.sock") -} - -func (cfg ingressConfig) logPath() string { - return filepath.Join(cfg.StateDir, "ingress.log") -} - -func (cfg ingressConfig) resolverPath() string { - return filepath.Join(cfg.ResolverDir, cfg.Domain) -} - -func (cfg ingressConfig) resolverContents() (string, error) { - host, port, err := net.SplitHostPort(cfg.DNSAddr) - if err != nil { - return "", fmt.Errorf("parse dns addr %q: %w", cfg.DNSAddr, err) - } - if host == "" { - host = "127.0.0.1" - } - return fmt.Sprintf("nameserver %s\nport %s\n", host, port), nil -} - -func (cfg ingressConfig) needsPrivileges() bool { - if runtime.GOOS != "darwin" || os.Getuid() == 0 { - return false - } - if requiresPrivilegedPort(cfg.HTTPAddr) || requiresPrivilegedPort(cfg.DNSAddr) { - return true - } - return filepath.Clean(cfg.ResolverDir) == "/etc/resolver" -} - -func requiresPrivilegedPort(addr string) bool { - _, port, err := net.SplitHostPort(addr) - if err != nil { - return true - } - n, err := strconv.Atoi(port) - if err != nil { - return true - } - return n > 0 && n < 1024 -} - -func runIngressEnable(cmd *cobra.Command, args []string) error { - if runtime.GOOS != "darwin" { - return fmt.Errorf("ingress is only supported on macOS") - } - - cfg := loadIngressConfig() - if status, err := fetchIngressStatus(cfg); err == nil && status.Enabled { - fmt.Printf("ingress enabled for .%s\n", status.Domain) - return nil - } - - fmt.Printf("writing %s\n", cfg.resolverPath()) - fmt.Printf("starting dns on %s\n", cfg.DNSAddr) - fmt.Printf("starting http on %s\n", cfg.HTTPAddr) - if err := startIngressHelper(cfg); err != nil { - return err - } - status, err := waitForIngressStatus(cfg, 10*time.Second) - if err != nil { - if logData, readErr := os.ReadFile(cfg.logPath()); readErr == nil { - return fmt.Errorf("wait for ingress: %w\n%s", err, strings.TrimSpace(string(logData))) - } - return fmt.Errorf("wait for ingress: %w", err) - } - - fmt.Printf("ingress enabled for .%s\n", status.Domain) - return nil -} - -func runIngressDisable(cmd *cobra.Command, args []string) error { - if runtime.GOOS != "darwin" { - return fmt.Errorf("ingress is only supported on macOS") - } - - cfg := loadIngressConfig() - if pathExists(cfg.resolverPath()) { - fmt.Printf("removing %s\n", cfg.resolverPath()) - } - stopped := false - if err := stopIngress(cfg); err == nil { - stopped = true - } else if !isNoIngress(err) { - return err - } - - if stopped { - if err := waitForIngressStop(cfg, 5*time.Second); err != nil { - return err - } - } - - if pathExists(cfg.socketPath()) || pathExists(cfg.resolverPath()) { - if err := cleanupIngressHelper(cfg); err != nil { - return err - } - } - - if stopped || pathExists(cfg.resolverPath()) { - fmt.Println("ingress disabled") - return nil - } - fmt.Println("ingress already disabled") - return nil -} - -func runIngressStatus(cmd *cobra.Command, args []string) error { - if runtime.GOOS != "darwin" { - return fmt.Errorf("ingress is only supported on macOS") - } - - cfg := loadIngressConfig() - status, err := fetchIngressStatus(cfg) - if err != nil { - if isNoIngress(err) { - fmt.Println("disabled") - return nil - } - return err - } - - fmt.Println("enabled") - fmt.Printf("domain: .%s\n", status.Domain) - fmt.Printf("dns: %s\n", status.DNSAddr) - fmt.Printf("http: %s\n", status.HTTPAddr) - fmt.Printf("resolver: %s\n", status.ResolverPath) - return nil -} - -func startIngressHelper(cfg ingressConfig) error { - self, err := os.Executable() - if err != nil { - return fmt.Errorf("find executable: %w", err) - } - cmd := buildIngressHelperCmd(self, []string{"_ingress", "--spawn"}, cfg) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("start ingress helper %v: %w", cmd.Args, err) - } - return nil -} - -func cleanupIngressHelper(cfg ingressConfig) error { - self, err := os.Executable() - if err != nil { - return fmt.Errorf("find executable: %w", err) - } - cmd := buildIngressHelperCmd(self, []string{"_ingress", "--cleanup"}, cfg) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("cleanup ingress helper %v: %w", cmd.Args, err) - } - return nil -} - -func buildIngressHelperCmd(self string, args []string, cfg ingressConfig) *exec.Cmd { - if cfg.needsPrivileges() { - envArgs := []string{fmt.Sprintf("HOME=%s", os.Getenv("HOME"))} - for _, key := range []string{ - "LNX_LOG", - "LNX_INGRESS_DOMAIN", - "LNX_INGRESS_DNS_ADDR", - "LNX_INGRESS_HTTP_ADDR", - "LNX_INGRESS_RESOLVER_DIR", - "LNX_INGRESS_STATE_DIR", - } { - if v := os.Getenv(key); v != "" { - envArgs = append(envArgs, fmt.Sprintf("%s=%s", key, v)) - } - } - sudoArgs := append(envArgs, self) - sudoArgs = append(sudoArgs, args...) - return exec.Command("sudo", sudoArgs...) - } - cmd := exec.Command(self, args...) - cmd.Env = os.Environ() - return cmd -} - -func spawnIngressDaemon(cfg ingressConfig) error { - if err := os.MkdirAll(cfg.StateDir, 0755); err != nil { - return fmt.Errorf("create ingress state dir: %w", err) - } - self, err := os.Executable() - if err != nil { - return fmt.Errorf("find executable: %w", err) - } - logFile, err := os.OpenFile(cfg.logPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return fmt.Errorf("open ingress log: %w", err) - } - - cmd := exec.Command(self, "_ingress") - cmd.Env = os.Environ() - cmd.Stdin = nil - cmd.Stdout = logFile - cmd.Stderr = logFile - cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} - - if err := cmd.Start(); err != nil { - logFile.Close() - return fmt.Errorf("spawn ingress daemon: %w", err) - } - _ = cmd.Process.Release() - return logFile.Close() -} - -func runIngressDaemon(cfg ingressConfig) error { - initIngressLogging(cfg.logPath()) - - if err := os.MkdirAll(cfg.StateDir, 0755); err != nil { - return fmt.Errorf("create ingress state dir: %w", err) - } - - httpLn, err := net.Listen("tcp", cfg.HTTPAddr) - if err != nil { - return fmt.Errorf("listen http %s: %w", cfg.HTTPAddr, err) - } - defer httpLn.Close() - - dnsConn, err := net.ListenPacket("udp", cfg.DNSAddr) - if err != nil { - return fmt.Errorf("listen dns %s: %w", cfg.DNSAddr, err) - } - defer dnsConn.Close() - - if err := installIngressResolver(cfg); err != nil { - return err - } - defer func() { - if err := removeIngressResolver(cfg); err != nil && !os.IsNotExist(err) { - slog.Warn("remove ingress resolver failed", "error", err) - } - }() - - stopCh := make(chan struct{}) - stopOnce := sync.Once{} - stop := func() { stopOnce.Do(func() { close(stopCh) }) } - - adminLn, err := listenIngressAdmin(cfg.socketPath()) - if err != nil { - return err - } - defer func() { - adminLn.Close() - _ = os.Remove(cfg.socketPath()) - }() - - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer cancel() - - proxy := newIngressProxy(cfg) - httpSrv := &http.Server{Handler: proxy} - adminSrv := &http.Server{ - Handler: ingressAdminMux(cfg, stop), - } - - go serveIngressDNS(dnsConn, cfg.Domain, stopCh) - go func() { - if err := httpSrv.Serve(httpLn); err != nil && err != http.ErrServerClosed { - slog.Error("ingress http server failed", "error", err) - stop() - } - }() - go func() { - if err := adminSrv.Serve(adminLn); err != nil && err != http.ErrServerClosed { - slog.Error("ingress admin server failed", "error", err) - stop() - } - }() - - select { - case <-ctx.Done(): - case <-stopCh: - } - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second) - defer shutdownCancel() - _ = adminSrv.Shutdown(shutdownCtx) - _ = httpSrv.Shutdown(shutdownCtx) - return nil -} - -func runIngressCleanup(cfg ingressConfig) error { - if err := removeIngressResolver(cfg); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove ingress resolver: %w", err) - } - if err := os.Remove(cfg.socketPath()); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove ingress socket: %w", err) - } - return nil -} - -func installIngressResolver(cfg ingressConfig) error { - if err := os.MkdirAll(cfg.ResolverDir, 0755); err != nil { - return fmt.Errorf("create resolver dir %s: %w", cfg.ResolverDir, err) - } - contents, err := cfg.resolverContents() - if err != nil { - return err - } - if err := os.WriteFile(cfg.resolverPath(), []byte(contents), 0644); err != nil { - return fmt.Errorf("write resolver %s: %w", cfg.resolverPath(), err) - } - return nil -} - -func removeIngressResolver(cfg ingressConfig) error { - return os.Remove(cfg.resolverPath()) -} - -func listenIngressAdmin(sockPath string) (net.Listener, error) { - if err := os.MkdirAll(filepath.Dir(sockPath), 0755); err != nil { - return nil, fmt.Errorf("create ingress socket dir: %w", err) - } - _ = os.Remove(sockPath) - ln, err := net.Listen("unix", sockPath) - if err != nil { - return nil, fmt.Errorf("listen ingress socket %s: %w", sockPath, err) - } - if err := os.Chmod(sockPath, 0666); err != nil { - ln.Close() - return nil, fmt.Errorf("chmod ingress socket %s: %w", sockPath, err) - } - return ln, nil -} - -func ingressAdminMux(cfg ingressConfig, stop func()) http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(ingressStatus{ - Enabled: true, - Domain: cfg.Domain, - DNSAddr: cfg.DNSAddr, - HTTPAddr: cfg.HTTPAddr, - ResolverPath: cfg.resolverPath(), - PID: os.Getpid(), - }) - }) - mux.HandleFunc("POST /stop", func(w http.ResponseWriter, r *http.Request) { - stop() - w.WriteHeader(http.StatusNoContent) - }) - return mux -} - -type ingressProxy struct { - cfg ingressConfig -} - -func newIngressProxy(cfg ingressConfig) *ingressProxy { - return &ingressProxy{cfg: cfg} -} - -func (p *ingressProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { - route, err := parseIngressHost(r.Host, p.cfg.Domain) - if err != nil { - http.NotFound(w, r) - return - } - - resp, err := exposeHostPort(route.Instance, route.Port, 0, false) - if err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return - } - - target := &url.URL{ - Scheme: "http", - Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(int(resp.HostPort))), - } - backend := &httputil.ReverseProxy{ - Rewrite: func(pr *httputil.ProxyRequest) { - pr.SetURL(target) - pr.Out.Host = pr.In.Host - }, - ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { - http.Error(w, err.Error(), http.StatusBadGateway) - }, - } - backend.ServeHTTP(w, r) -} - -func parseIngressHost(host, domain string) (ingressRoute, error) { - host = stripOptionalPort(host) - host = strings.TrimSuffix(strings.ToLower(host), ".") - suffix := "." + strings.ToLower(domain) - if !strings.HasSuffix(host, suffix) { - return ingressRoute{}, fmt.Errorf("host %q is not under .%s", host, domain) - } - name := strings.TrimSuffix(host, suffix) - labels := strings.Split(name, ".") - if len(labels) < 2 { - return ingressRoute{}, fmt.Errorf("host %q must look like p..%s", host, domain) - } - - portLabel := labels[0] - if !strings.HasPrefix(portLabel, "p") || len(portLabel) == 1 { - return ingressRoute{}, fmt.Errorf("host %q must start with p", host) - } - n, err := strconv.ParseUint(portLabel[1:], 10, 16) - if err != nil || n == 0 { - return ingressRoute{}, fmt.Errorf("invalid ingress port %q", portLabel) - } - - instance := strings.Join(labels[1:], ".") - if instance == "" { - return ingressRoute{}, fmt.Errorf("missing instance in host %q", host) - } - return ingressRoute{Instance: instance, Port: uint16(n)}, nil -} - -func stripOptionalPort(host string) string { - if h, _, err := net.SplitHostPort(host); err == nil { - return h - } - return host -} - -func serveIngressDNS(conn net.PacketConn, domain string, stopCh <-chan struct{}) { - buf := make([]byte, 1500) - for { - _ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - n, addr, err := conn.ReadFrom(buf) - if err != nil { - if ne, ok := err.(net.Error); ok && ne.Timeout() { - select { - case <-stopCh: - return - default: - continue - } - } - return - } - resp, err := ingressDNSResponse(buf[:n], domain) - if err != nil { - continue - } - _, _ = conn.WriteTo(resp, addr) - } -} - -func ingressDNSResponse(packet []byte, domain string) ([]byte, error) { - var msg dnsmessage.Message - if err := msg.Unpack(packet); err != nil { - return nil, err - } - - resp := dnsmessage.Message{ - Header: dnsmessage.Header{ - ID: msg.Header.ID, - Response: true, - Authoritative: true, - RecursionDesired: msg.Header.RecursionDesired, - RecursionAvailable: false, - }, - Questions: msg.Questions, - } - - for _, q := range msg.Questions { - name := strings.TrimSuffix(q.Name.String(), ".") - if _, err := parseIngressHost(name, domain); err != nil { - resp.Header.RCode = dnsmessage.RCodeNameError - resp.Answers = nil - break - } - if q.Class != dnsmessage.ClassINET { - continue - } - if q.Type != dnsmessage.TypeA { - continue - } - resp.Answers = append(resp.Answers, dnsmessage.Resource{ - Header: dnsmessage.ResourceHeader{ - Name: q.Name, - Type: dnsmessage.TypeA, - Class: dnsmessage.ClassINET, - TTL: 1, - }, - Body: &dnsmessage.AResource{A: [4]byte{127, 0, 0, 1}}, - }) - } - - return resp.Pack() -} - -func ingressClient(cfg ingressConfig) *http.Client { - return &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - conn, err := net.DialTimeout("unix", cfg.socketPath(), 500*time.Millisecond) - if err != nil { - return nil, fmt.Errorf("no ingress socket at %s", cfg.socketPath()) - } - return conn, nil - }, - }, - } -} - -func fetchIngressStatus(cfg ingressConfig) (*ingressStatus, error) { - resp, err := ingressClient(cfg).Get("http://localhost/status") - if err != nil { - return nil, err - } - defer resp.Body.Close() - var status ingressStatus - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return nil, fmt.Errorf("read ingress status: %w", err) - } - return &status, nil -} - -func stopIngress(cfg ingressConfig) error { - resp, err := ingressClient(cfg).Post("http://localhost/stop", "", nil) - if err != nil { - return err - } - resp.Body.Close() - return nil -} - -func waitForIngressStatus(cfg ingressConfig, timeout time.Duration) (*ingressStatus, error) { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - status, err := fetchIngressStatus(cfg) - if err == nil { - return status, nil - } - time.Sleep(100 * time.Millisecond) - } - return nil, fmt.Errorf("timed out after %s", timeout) -} - -func waitForIngressStop(cfg ingressConfig, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if _, err := fetchIngressStatus(cfg); isNoIngress(err) { - return nil - } - time.Sleep(100 * time.Millisecond) - } - return fmt.Errorf("timed out waiting for ingress to stop") -} - -func isNoIngress(err error) bool { - if err == nil { - return false - } - s := err.Error() - return strings.Contains(s, "no ingress socket") || - strings.Contains(s, "no such file") || - strings.Contains(s, "connection refused") -} - -func initIngressLogging(path string) { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return - } - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return - } - slog.SetDefault(slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: slog.LevelInfo}))) -} - -func pathExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} diff --git a/old/cmd/lnx/ingress_cmd_test.go b/old/cmd/lnx/ingress_cmd_test.go deleted file mode 100644 index 0c2fdcf..0000000 --- a/old/cmd/lnx/ingress_cmd_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import "testing" - -func TestParseIngressHost(t *testing.T) { - tests := []struct { - name string - host string - domain string - wantInst string - wantPort uint16 - wantError bool - }{ - {name: "basic host", host: "p8080.dev.lnx", domain: "lnx", wantInst: "dev", wantPort: 8080}, - {name: "host with request port", host: "p8080.dev.lnx:80", domain: "lnx", wantInst: "dev", wantPort: 8080}, - {name: "nested instance", host: "p3000.parent.child.lnx", domain: "lnx", wantInst: "parent.child", wantPort: 3000}, - {name: "wrong suffix", host: "p8080.dev.local", domain: "lnx", wantError: true}, - {name: "missing instance", host: "p8080.lnx", domain: "lnx", wantError: true}, - {name: "missing p prefix", host: "8080.dev.lnx", domain: "lnx", wantError: true}, - {name: "invalid port", host: "pnope.dev.lnx", domain: "lnx", wantError: true}, - {name: "zero port", host: "p0.dev.lnx", domain: "lnx", wantError: true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got, err := parseIngressHost(tc.host, tc.domain) - if tc.wantError { - if err == nil { - t.Fatalf("expected error, got %+v", got) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.Instance != tc.wantInst || got.Port != tc.wantPort { - t.Fatalf("got %+v, want instance=%q port=%d", got, tc.wantInst, tc.wantPort) - } - }) - } -} - -func TestIngressNeedsPrivileges(t *testing.T) { - t.Setenv("LNX_INGRESS_HTTP_ADDR", "127.0.0.1:18080") - t.Setenv("LNX_INGRESS_DNS_ADDR", "127.0.0.1:15354") - t.Setenv("LNX_INGRESS_RESOLVER_DIR", t.TempDir()) - t.Setenv("LNX_INGRESS_STATE_DIR", t.TempDir()) - - cfg := loadIngressConfig() - if cfg.needsPrivileges() { - t.Fatalf("expected unprivileged ingress config to avoid sudo") - } -} diff --git a/old/cmd/lnx/init_cmd.go b/old/cmd/lnx/init_cmd.go deleted file mode 100644 index eb6d6e0..0000000 --- a/old/cmd/lnx/init_cmd.go +++ /dev/null @@ -1,403 +0,0 @@ -package main - -import ( - "archive/tar" - "compress/gzip" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/spf13/cobra" -) - -const defaultImageVersion = "images-v0.1.0" - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Download and install kernel and rootfs", - Long: `Downloads pre-built kernel and rootfs from GitHub releases, or copies local files. - -By default, fetches from: - https://github.com/semistrict/lnx/releases/tag/` + defaultImageVersion + ` - -Use --kernel and --rootfs to provide local files instead.`, - RunE: runInit, -} - -var ( - kernelFile string - rootfsFile string -) - -func init() { - initCmd.Flags().StringVar(&kernelFile, "kernel", "", "path to local kernel Image (skip download)") - initCmd.Flags().StringVar(&rootfsFile, "rootfs", "", "path to local rootfs ext4 image (skip download)") - rootCmd.AddCommand(initCmd) -} - -func runInit(cmd *cobra.Command, args []string) error { - base := lnxBase() - if err := os.MkdirAll(base, 0755); err != nil { - return fmt.Errorf("create ~/.lnx: %w", err) - } - - if err := ensureImagesDir(base); err != nil { - return err - } - - imgDir := imagesDir() - if err := os.MkdirAll(imgDir, 0755); err != nil { - return fmt.Errorf("create images dir: %w", err) - } - - dir := instanceDir() - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("create instance dir: %w", err) - } - - kernelDest := filepath.Join(base, "vmlinuz") - rootfsDest := filepath.Join(imgDir, "rootfs.ext4") - - // Kernel. - if kernelFile != "" { - if err := copyFile(kernelDest, kernelFile); err != nil { - return fmt.Errorf("copy kernel: %w", err) - } - } else { - if err := downloadKernelRelease(kernelDest); err != nil { - return fmt.Errorf("download kernel: %w", err) - } - } - fmt.Printf(" kernel: %s\n", kernelDest) - - // Rootfs. - if rootfsFile != "" { - if err := copyFile(rootfsDest, rootfsFile); err != nil { - return fmt.Errorf("copy rootfs: %w", err) - } - } else { - if err := downloadRelease(rootfsDest, "rootfs.ext4.zst"); err != nil { - return fmt.Errorf("download rootfs: %w", err) - } - } - fmt.Printf(" rootfs: %s\n", rootfsDest) - - if err := downloadFirecracker(); err != nil { - return err - } - - // Install the Firecracker kernel for nested VM support. - fcKernelDest := filepath.Join(base, "vmlinuz-firecracker") - if _, err := os.Stat(fcKernelDest); os.IsNotExist(err) { - // Try to download from release, or copy from local repo. - if err := downloadRelease(fcKernelDest, "vmlinuz-firecracker"); err != nil { - fmt.Fprintf(os.Stderr, " vmlinuz-firecracker: skipped (%v)\n", err) - } - } else { - fmt.Printf(" vmlinuz-firecracker: %s (already exists)\n", fcKernelDest) - } - - installSSHConfig() - - fmt.Println("lnx init complete") - return nil -} - -// autoInit downloads kernel and rootfs if they don't exist. -// When running nested (LNX_PARENT is set), clones from the host's -// default rootfs instead of downloading. -func autoInit() error { - base := lnxBase() - os.MkdirAll(base, 0755) - - if err := ensureImagesDir(base); err != nil { - return err - } - - imgDir := imagesDir() - os.MkdirAll(imgDir, 0755) - - dir := instanceDir() - os.MkdirAll(dir, 0755) - - kernelDest := filepath.Join(base, "vmlinuz") - if err := downloadKernelRelease(kernelDest); err != nil { - return fmt.Errorf("download kernel: %w", err) - } - - rootfsDest := filepath.Join(imgDir, "rootfs.ext4") - if _, err := os.Stat(rootfsDest); os.IsNotExist(err) { - // Try to clone from an existing default rootfs first (fast, works nested). - if src := findDefaultRootfs(); src != "" { - fmt.Fprintf(os.Stderr, " cloning rootfs from %s\n", src) - if err := cloneRootfs(src, rootfsDest); err != nil { - return fmt.Errorf("clone rootfs: %w", err) - } - } else { - if err := downloadRelease(rootfsDest, "rootfs.ext4.zst"); err != nil { - return fmt.Errorf("download rootfs: %w", err) - } - } - } - - if err := downloadFirecracker(); err != nil { - return fmt.Errorf("download firecracker: %w", err) - } - - installSSHConfig() - - fmt.Fprintln(os.Stderr, "init complete") - return nil -} - -func downloadRelease(dest, asset string) error { - if _, err := os.Stat(dest); err == nil { - fmt.Printf(" %s already exists, skipping\n", dest) - return nil - } - - url := fmt.Sprintf("https://github.com/semistrict/lnx/releases/download/%s/%s", defaultImageVersion, asset) - fmt.Printf(" downloading %s\n", url) - - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, url) - } - - total := resp.ContentLength - progress := &progressReader{r: resp.Body, total: total, label: asset} - - tmp := dest + ".tmp" - f, err := os.Create(tmp) - if err != nil { - return err - } - - // Decompress zstd if the asset is compressed. - if filepath.Ext(asset) == ".zst" { - zstdCmd := exec.Command("zstd", "-d", "--stdout") - zstdCmd.Stdin = progress - zstdCmd.Stdout = f - zstdCmd.Stderr = os.Stderr - if err := zstdCmd.Run(); err != nil { - f.Close() - os.Remove(tmp) - return fmt.Errorf("zstd decompress failed (install zstd: brew install zstd): %w", err) - } - progress.finish() - f.Close() - - // Punch holes for zero-filled blocks to make the file sparse. - // A 4 GB rootfs with ~1 GB of data shrinks to ~1 GB on disk. - if err := punchHoles(tmp, 64*1024); err != nil { - os.Remove(tmp) - return fmt.Errorf("punch holes: %w", err) - } - - return os.Rename(tmp, dest) - } - - var reader io.Reader = progress - - // Decompress gzip if applicable. - if filepath.Ext(asset) == ".gz" { - gz, err := gzip.NewReader(progress) - if err != nil { - f.Close() - os.Remove(tmp) - return err - } - reader = gz - defer gz.Close() - } - - if _, err := io.Copy(f, reader); err != nil { - f.Close() - os.Remove(tmp) - return err - } - progress.finish() - if err := f.Close(); err != nil { - os.Remove(tmp) - return err - } - return os.Rename(tmp, dest) -} - -func downloadKernelRelease(dest string) error { - var errs []string - for _, asset := range []string{"kernel.Image", "vmlinuz.gz"} { - if err := downloadRelease(dest, asset); err == nil { - return nil - } else { - errs = append(errs, fmt.Sprintf("%s: %v", asset, err)) - } - } - return fmt.Errorf("%s", strings.Join(errs, "; ")) -} - -// progressReader wraps an io.Reader and prints download progress to stderr. -type progressReader struct { - r io.Reader - total int64 - read int64 - label string - lastPrint time.Time -} - -func (p *progressReader) Read(buf []byte) (int, error) { - n, err := p.r.Read(buf) - p.read += int64(n) - if time.Since(p.lastPrint) > 200*time.Millisecond { - p.print() - p.lastPrint = time.Now() - } - return n, err -} - -func (p *progressReader) print() { - readMB := float64(p.read) / (1024 * 1024) - if p.total > 0 { - totalMB := float64(p.total) / (1024 * 1024) - pct := float64(p.read) * 100 / float64(p.total) - fmt.Fprintf(os.Stderr, "\r %s: %.1f / %.1f MB (%.0f%%)", p.label, readMB, totalMB, pct) - } else { - fmt.Fprintf(os.Stderr, "\r %s: %.1f MB", p.label, readMB) - } -} - -func (p *progressReader) finish() { - p.print() - fmt.Fprintln(os.Stderr) -} - -const firecrackerVersion = "v1.12.0" - -// downloadFirecracker downloads the Firecracker binary for nested VM support. -// Always downloads the Linux arm64 binary (on macOS it's used inside the guest). -func downloadFirecracker() error { - binDir := filepath.Join(lnxBase(), "bin") - if err := os.MkdirAll(binDir, 0755); err != nil { - return fmt.Errorf("create bin dir: %w", err) - } - - fcPath := filepath.Join(binDir, "firecracker") - if _, err := os.Stat(fcPath); err == nil { - fmt.Printf(" firecracker: %s (already exists)\n", fcPath) - return nil - } - - url := fmt.Sprintf( - "https://github.com/firecracker-microvm/firecracker/releases/download/%s/firecracker-%s-aarch64.tgz", - firecrackerVersion, firecrackerVersion, - ) - fmt.Printf(" downloading firecracker %s\n", firecrackerVersion) - resp, err := http.Get(url) - if err != nil { - return fmt.Errorf("download firecracker: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download firecracker: HTTP %d: %s", resp.StatusCode, url) - } - - progress := &progressReader{r: resp.Body, total: resp.ContentLength, label: "firecracker"} - gz, err := gzip.NewReader(progress) - if err != nil { - return fmt.Errorf("decode firecracker archive: %w", err) - } - defer gz.Close() - - tmp := fcPath + ".tmp" - out, err := os.Create(tmp) - if err != nil { - return fmt.Errorf("create firecracker temp file: %w", err) - } - - targetName := fmt.Sprintf("firecracker-%s-aarch64", firecrackerVersion) - found := false - tr := tar.NewReader(gz) - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - out.Close() - os.Remove(tmp) - return fmt.Errorf("read firecracker archive: %w", err) - } - if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { - continue - } - if filepath.Base(hdr.Name) != targetName { - continue - } - if _, err := io.Copy(out, tr); err != nil { - out.Close() - os.Remove(tmp) - return fmt.Errorf("extract firecracker binary: %w", err) - } - found = true - break - } - progress.finish() - if err := out.Close(); err != nil { - os.Remove(tmp) - return fmt.Errorf("close firecracker temp file: %w", err) - } - if !found { - os.Remove(tmp) - return fmt.Errorf("extract firecracker binary: %s not found in archive", targetName) - } - if err := os.Chmod(tmp, 0755); err != nil { - os.Remove(tmp) - return fmt.Errorf("chmod firecracker: %w", err) - } - if err := os.Rename(tmp, fcPath); err != nil { - os.Remove(tmp) - return fmt.Errorf("install firecracker: %w", err) - } - - fmt.Printf(" firecracker: %s\n", fcPath) - return nil -} - -func copyFile(dst, src string) error { - if _, err := os.Stat(dst); err == nil { - fmt.Printf(" %s already exists, skipping\n", dst) - return nil - } - - fmt.Printf(" copying %s -> %s\n", src, dst) - s, err := os.Open(src) - if err != nil { - return err - } - defer s.Close() - - tmp := dst + ".tmp" - d, err := os.Create(tmp) - if err != nil { - return err - } - if _, err := io.Copy(d, s); err != nil { - d.Close() - os.Remove(tmp) - return err - } - if err := d.Close(); err != nil { - os.Remove(tmp) - return err - } - return os.Rename(tmp, dst) -} diff --git a/old/cmd/lnx/instance_cmd.go b/old/cmd/lnx/instance_cmd.go deleted file mode 100644 index 73bf55a..0000000 --- a/old/cmd/lnx/instance_cmd.go +++ /dev/null @@ -1,550 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "net" - "os" - "path/filepath" - "sort" - "strings" - "syscall" - "time" - - "github.com/semistrict/lnx" - "github.com/spf13/cobra" -) - -// findDefaultRootfs returns the path to a default rootfs to clone from. -// Tries the un-prefixed "default" first (host's rootfs, always available -// via the ~/.lnx share), then the qualified name for this nesting level. -func findDefaultRootfs() string { - names := []string{"default"} - if qualified := qualifyName("default"); qualified != "default" { - names = append(names, qualified) - } - for _, name := range names { - p := filepath.Join(imagesDirFor(name), "rootfs.ext4") - if _, err := os.Stat(p); err == nil { - return p - } - } - return "" -} - -// qualifyName prefixes an instance name with LNX_PARENT when nested. -func qualifyName(name string) string { - parent := os.Getenv("LNX_PARENT") - if parent == "" { - return name - } - return parent + "." + name -} - -var instanceCmd = &cobra.Command{ - Use: "instance", - Short: "Manage VM instances", -} - -var instanceListCmd = &cobra.Command{ - Use: "list", - Short: "List all instances", - Args: cobra.NoArgs, - RunE: runInstanceList, -} - -var cloneCmd = &cobra.Command{ - Use: "clone ", - Short: "Clone the current source instance into a new instance", - Args: cobra.ExactArgs(1), - RunE: runInstanceClone, -} - -var instanceInitCmd = &cobra.Command{ - Use: "init ", - Short: "Initialize a new instance from source files", - Long: `Initialize a new instance. If --kernel and --rootfs are provided, copies them. -Otherwise, if the default instance exists, clones its rootfs via APFS clonefile.`, - Args: cobra.ExactArgs(1), - RunE: runInstanceInit, -} - -var instanceDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete an instance", - Args: cobra.ExactArgs(1), - RunE: runInstanceDelete, - ValidArgsFunction: completeInstanceNames, -} - -var ( - instInitKernel string - instInitRootfs string - cloneCheckpoint string - cloneImage string -) - -func init() { - instanceInitCmd.Flags().StringVar(&instInitKernel, "kernel", "", "path to kernel Image (copies to shared location)") - instanceInitCmd.Flags().StringVar(&instInitRootfs, "rootfs", "", "path to rootfs ext4 image") - cloneCmd.Flags().StringVar(&cloneCheckpoint, "checkpoint", "", "clone from an existing or newly created named checkpoint of the source instance") - cloneCmd.Flags().StringVar(&cloneImage, "image", "", "OCI container image to use as rootfs (e.g. alpine:latest)") - - instanceCmd.AddCommand(instanceListCmd) - instanceCmd.AddCommand(instanceInitCmd) - instanceCmd.AddCommand(instanceDeleteCmd) - rootCmd.AddCommand(instanceCmd) - rootCmd.AddCommand(cloneCmd) -} - -func runInstanceList(cmd *cobra.Command, args []string) error { - base := lnxBase() - seen := make(map[string]bool) - - // Collect instance names from both images/ (new layout) and instances/ (legacy). - for _, subdir := range []string{"images", "instances"} { - dir := filepath.Join(base, subdir) - entries, err := os.ReadDir(dir) - if err != nil { - continue - } - for _, e := range entries { - if !e.IsDir() || seen[e.Name()] { - continue - } - // Check for rootfs in the appropriate location. - if _, err := os.Stat(resolveRootfsPathFor(e.Name())); err == nil { - seen[e.Name()] = true - } - } - } - - var instances []string - for name := range seen { - instances = append(instances, name) - } - sort.Strings(instances) - - if len(instances) == 0 { - fmt.Println("no instances") - return nil - } - - t := newTable("NAME", "STATUS") - for _, name := range instances { - status := dimStyle.Render("stopped") - sockPath := filepath.Join(instanceDirFor(name), "status.sock") - conn, err := net.DialTimeout("unix", sockPath, 500*time.Millisecond) - if err == nil { - conn.Close() - status = greenStyle.Render("running") - } - t.Row(name, status) - } - fmt.Println(t) - return nil -} - -func runInstanceClone(cmd *cobra.Command, args []string) error { - name := qualifyName(args[0]) - if name == "default" { - return fmt.Errorf("cannot clone into instance named 'default' (use 'lnx init' instead)") - } - - if cloneImage != "" { - return cloneFromImage(name, cloneImage) - } - - // Check if instance already exists (images or instances dir). - imgDir := imagesDirFor(name) - instDir := instanceDirFor(name) - if _, err := os.Stat(imgDir); err == nil { - return fmt.Errorf("instance %q already exists", name) - } - if _, err := os.Stat(instDir); err == nil { - return fmt.Errorf("instance %q already exists", name) - } - - sourceName := qualifiedInstanceName() - checkpointName := cloneCheckpoint - sourceImgDir, sourceInstDir, sourceResolvedName, err := resolveCloneSourceDirs(sourceName) - if err != nil { - return err - } - - if err := os.MkdirAll(imgDir, 0755); err != nil { - return fmt.Errorf("create images dir: %w", err) - } - if err := os.MkdirAll(instDir, 0755); err != nil { - _ = os.RemoveAll(imgDir) - return fmt.Errorf("create instance dir: %w", err) - } - - checkpointPath, err := checkpointPathForClone(sourceImgDir, sourceResolvedName, checkpointName) - if err != nil { - _ = os.RemoveAll(imgDir) - _ = os.RemoveAll(instDir) - return err - } - - if err := cloneRootfs(checkpointPath, filepath.Join(imgDir, "rootfs.ext4")); err != nil { - _ = os.RemoveAll(imgDir) - _ = os.RemoveAll(instDir) - return fmt.Errorf("clone rootfs: %w", err) - } - // Clone ram.img (CoW) if the source has one (QEMU backend). - sourceRAM := filepath.Join(sourceImgDir, "ram.img") - if _, err := os.Stat(sourceRAM); err == nil { - if err := cloneRootfs(sourceRAM, filepath.Join(imgDir, "ram.img")); err != nil { - _ = os.RemoveAll(imgDir) - _ = os.RemoveAll(instDir) - return fmt.Errorf("clone ram: %w", err) - } - } - if err := cloneInstanceMetadata(sourceInstDir, instDir); err != nil { - _ = os.RemoveAll(imgDir) - _ = os.RemoveAll(instDir) - return fmt.Errorf("clone metadata: %w", err) - } - - if checkpointName == "" { - fmt.Printf("created instance %q from %q\n", name, sourceResolvedName) - } else { - fmt.Printf("created instance %q from %q\n", name, sourceResolvedName+":"+checkpointName) - } - return nil -} - -// cloneFromImage pulls an OCI container image and creates an lnx instance -// with its filesystem as the rootfs. -func cloneFromImage(name, imageRef string) error { - imgDir := imagesDirFor(name) - instDir := instanceDirFor(name) - if _, err := os.Stat(imgDir); err == nil { - return fmt.Errorf("instance %q already exists", name) - } - if _, err := os.Stat(instDir); err == nil { - return fmt.Errorf("instance %q already exists", name) - } - - rootfsPath, err := ensureOCIRootfs(imageRef) - if err != nil { - return err - } - - if err := os.MkdirAll(imgDir, 0755); err != nil { - return fmt.Errorf("create images dir: %w", err) - } - if err := os.MkdirAll(instDir, 0755); err != nil { - _ = os.RemoveAll(imgDir) - return fmt.Errorf("create instance dir: %w", err) - } - - if err := cloneRootfs(rootfsPath, filepath.Join(imgDir, "rootfs.ext4")); err != nil { - _ = os.RemoveAll(imgDir) - _ = os.RemoveAll(instDir) - return fmt.Errorf("clone rootfs: %w", err) - } - - fmt.Printf("created instance %q from image %q\n", name, imageRef) - return nil -} - -func runInstanceInit(cmd *cobra.Command, args []string) error { - name := qualifyName(args[0]) - imgDir := imagesDirFor(name) - - if _, err := os.Stat(filepath.Join(imgDir, "rootfs.ext4")); err == nil { - return fmt.Errorf("instance %q already exists", name) - } - // Also check legacy location. - if _, err := os.Stat(filepath.Join(instanceDirFor(name), "rootfs.ext4")); err == nil { - return fmt.Errorf("instance %q already exists", name) - } - - if err := os.MkdirAll(imgDir, 0755); err != nil { - return fmt.Errorf("create images dir: %w", err) - } - - // Copy kernel to shared location if provided. - if instInitKernel != "" { - base := lnxBase() - os.MkdirAll(base, 0755) - kernelDest := filepath.Join(base, "vmlinuz") - if err := copyFile(kernelDest, instInitKernel); err != nil { - return fmt.Errorf("copy kernel: %w", err) - } - fmt.Printf(" kernel: %s\n", kernelDest) - } - - rootfsDest := filepath.Join(imgDir, "rootfs.ext4") - - if instInitRootfs != "" { - // Copy from provided rootfs file. - if err := copyFile(rootfsDest, instInitRootfs); err != nil { - return fmt.Errorf("copy rootfs: %w", err) - } - } else { - // Clone from default instance if it exists. - defaultRootfs := findDefaultRootfs() - if defaultRootfs == "" { - os.RemoveAll(imgDir) - return fmt.Errorf("no --rootfs specified and no default rootfs found — run 'lnx init' first") - } - if err := cloneRootfs(defaultRootfs, rootfsDest); err != nil { - os.RemoveAll(imgDir) - return fmt.Errorf("clone rootfs from default: %w", err) - } - } - fmt.Printf(" rootfs: %s\n", rootfsDest) - - fmt.Printf("instance %q initialized\n", name) - return nil -} - -func runInstanceDelete(cmd *cobra.Command, args []string) error { - name := qualifyName(args[0]) - if name == "default" { - return fmt.Errorf("cannot delete the default instance") - } - - imgDir := imagesDirFor(name) - instDir := instanceDirFor(name) - - // Check that the instance exists (in either location). - imgExists := false - instExists := false - if _, err := os.Stat(imgDir); err == nil { - imgExists = true - } - if _, err := os.Stat(instDir); err == nil { - instExists = true - } - if !imgExists && !instExists { - return fmt.Errorf("instance %q does not exist", name) - } - - // Refuse if the VM is running. - sockPath := filepath.Join(instDir, "status.sock") - conn, err := net.DialTimeout("unix", sockPath, 500*time.Millisecond) - if err == nil { - conn.Close() - return fmt.Errorf("instance %q is running — stop it first", name) - } - - // Check for rootfs lock. - rootfs := resolveRootfsPathFor(name) - lockPath := rootfs + ".lock" - if f, err := os.OpenFile(lockPath, os.O_RDWR, 0); err == nil { - defer f.Close() - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - return fmt.Errorf("instance %q rootfs is locked — another process may be using it", name) - } - syscall.Flock(int(f.Fd()), syscall.LOCK_UN) - } - - if imgExists { - if err := os.RemoveAll(imgDir); err != nil { - return fmt.Errorf("delete instance images: %w", err) - } - } - if instExists { - if err := os.RemoveAll(instDir); err != nil { - return fmt.Errorf("delete instance: %w", err) - } - } - - fmt.Printf("deleted instance %q\n", name) - return nil -} - -// resolveCloneSourceDirs returns the images dir, instances dir, and resolved name -// for the clone source instance. -func resolveCloneSourceDirs(source string) (imgDir, instDir, resolvedName string, err error) { - var candidates []string - if source == "default" { - candidates = append(candidates, "default") - if qualified := qualifyName("default"); qualified != "default" { - candidates = append(candidates, qualified) - } - } else { - candidates = append(candidates, qualifyName(source)) - } - - for _, name := range candidates { - rootfs := resolveRootfsPathFor(name) - if _, err := os.Stat(rootfs); err == nil { - return filepath.Dir(rootfs), instanceDirFor(name), name, nil - } - } - if source == "default" { - return "", "", "", fmt.Errorf("no default rootfs found — run 'lnx init' first") - } - return "", "", "", fmt.Errorf("instance %q does not exist", qualifyName(source)) -} - -func checkpointPathForClone(sourceDir, sourceName, checkpointName string) (string, error) { - if checkpointName != "" { - return resolveNamedCheckpoint(sourceDir, checkpointName) - } - return createInstanceCheckpoint(sourceDir, sourceName, "") -} - -func resolveNamedCheckpoint(sourceDir, checkpointName string) (string, error) { - dir := filepath.Join(sourceDir, "checkpoints") - candidates := []string{checkpointName} - if filepath.Ext(checkpointName) != ".ext4" { - candidates = append(candidates, checkpointName+".ext4") - } - for _, candidate := range candidates { - path := filepath.Join(dir, candidate) - if _, err := os.Stat(path); err == nil { - return path, nil - } - } - return "", fmt.Errorf("checkpoint %q not found", checkpointName) -} - -func createInstanceCheckpoint(sourceImgDir, sourceName, checkpointName string) (string, error) { - if isInstanceRunning(sourceName) { - return createCheckpointViaAPI(sourceName, checkpointName) - } - - rootfsPath := filepath.Join(sourceImgDir, "rootfs.ext4") - lock, err := lnx.LockRootfs(rootfsPath) - if err != nil { - return "", fmt.Errorf("lock rootfs: %w", err) - } - defer lock.Unlock() - - return lnx.CreateCheckpoint(rootfsPath, filepath.Join(sourceImgDir, "checkpoints"), checkpointName) -} - -func createCheckpointViaAPI(instanceName, checkpointName string) (string, error) { - body, err := json.Marshal(map[string]string{"name": checkpointName}) - if err != nil { - return "", fmt.Errorf("marshal checkpoint request: %w", err) - } - - resp, err := apiClientFor(instanceName).Post("http://localhost/checkpoint", "application/json", bytes.NewReader(body)) - if err != nil { - if isNoVM(err) { - return "", noVMError() - } - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode/100 != 2 { - data, _ := io.ReadAll(resp.Body) - msg := strings.TrimSpace(string(data)) - if msg == "" { - msg = resp.Status - } - return "", errors.New(msg) - } - - var payload struct { - Path string `json:"path"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("decode checkpoint response: %w", err) - } - if payload.Path == "" { - return "", fmt.Errorf("checkpoint response missing path") - } - return payload.Path, nil -} - -func isInstanceRunning(name string) bool { - conn, err := net.DialTimeout("unix", filepath.Join(lnxBase(), "instances", name, "status.sock"), 500*time.Millisecond) - if err != nil { - return false - } - _ = conn.Close() - return true -} - -func cloneInstanceMetadata(srcDir, dstDir string) error { - return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if path == srcDir { - return nil - } - - rel, err := filepath.Rel(srcDir, path) - if err != nil { - return err - } - if shouldSkipClonedMetadata(rel, d) { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - - dstPath := filepath.Join(dstDir, rel) - if d.IsDir() { - return os.MkdirAll(dstPath, 0755) - } - if d.Type()&os.ModeSymlink != 0 { - target, err := os.Readlink(path) - if err != nil { - return err - } - return os.Symlink(target, dstPath) - } - if !d.Type().IsRegular() { - return nil - } - return copyFile(dstPath, path) - }) -} - -func shouldSkipClonedMetadata(rel string, d fs.DirEntry) bool { - base := filepath.Base(rel) - if rel == "rootfs.ext4" || rel == "criu.ext4" || base == "checkpoints" { - return true - } - switch base { - case "status.sock", "error.log", "serial.log", "lnx.log", "initramfs.cpio", "swap.img", - "rootfs.ext4.lock", "rootfs.ext4.pid", "firecracker.sock", "vsock", "hibernated", - "qemu.log", "qmp.sock", "ram.img": - return true - } - return strings.HasPrefix(base, "vsock_") || - strings.HasPrefix(base, "qemu-vsock") || - strings.HasPrefix(base, "ram-") -} - -// completeInstanceNames provides shell completion for instance names. -func completeInstanceNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp - } - base := lnxBase() - seen := make(map[string]bool) - for _, subdir := range []string{"images", "instances"} { - entries, err := os.ReadDir(filepath.Join(base, subdir)) - if err != nil { - continue - } - for _, e := range entries { - if e.IsDir() { - seen[e.Name()] = true - } - } - } - var names []string - for name := range seen { - names = append(names, name) - } - sort.Strings(names) - return names, cobra.ShellCompDirectiveNoFileComp -} diff --git a/old/cmd/lnx/kernel_darwin.go b/old/cmd/lnx/kernel_darwin.go deleted file mode 100644 index 8c24298..0000000 --- a/old/cmd/lnx/kernel_darwin.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build darwin - -package main - -import "path/filepath" - -func resolveKernel() string { - return filepath.Join(lnxBase(), "vmlinuz") -} diff --git a/old/cmd/lnx/kernel_linux.go b/old/cmd/lnx/kernel_linux.go deleted file mode 100644 index 864f83f..0000000 --- a/old/cmd/lnx/kernel_linux.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build linux - -package main - -import ( - "os" - "path/filepath" -) - -func resolveKernel() string { - // Firecracker needs a different kernel than VZ. - // Check for the Firecracker-specific kernel first. - base := lnxBase() - fcKernel := filepath.Join(base, "vmlinuz-firecracker") - if _, err := os.Stat(fcKernel); err == nil { - return fcKernel - } - // Fall back to the generic kernel. - return filepath.Join(base, "vmlinuz") -} diff --git a/old/cmd/lnx/lnxpack_cgo_darwin.go b/old/cmd/lnx/lnxpack_cgo_darwin.go deleted file mode 100644 index 74f0a3b..0000000 --- a/old/cmd/lnx/lnxpack_cgo_darwin.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build darwin - -package main - -// Enable CGo for this package on macOS so the linker picks up -// lnxpack_section.c and creates the __LNX,__lnxpack segment. - -// #include -import "C" diff --git a/old/cmd/lnx/lnxpack_section.c b/old/cmd/lnx/lnxpack_section.c deleted file mode 100644 index 80251eb..0000000 --- a/old/cmd/lnx/lnxpack_section.c +++ /dev/null @@ -1,14 +0,0 @@ -// This file is compiled by CGo on macOS to create a placeholder __LNX,__lnxpack -// section in the lnx binary. lnx pack resizes this section with compressed -// kernel+rootfs data using Mach-O section injection. -// -// Layout when packed: -// [u64 data_size][zstd kernel][zstd rootfs][JSON config][u64 json_len] -// [zero padding to 16KB alignment] -// -// When unpacked (build time), data_size is 0. - -#include - -__attribute__((section("__LNX,__lnxpack"), used, aligned(16384))) -static uint8_t _lnx_pack_data[16384] = {0}; diff --git a/old/cmd/lnx/macho.go b/old/cmd/lnx/macho.go deleted file mode 100644 index 292087b..0000000 --- a/old/cmd/lnx/macho.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build darwin - -package main - -import "github.com/semistrict/lnx/internal/macho" - -// machoInjectSection replaces the __LNX,__lnxpack section content with blob. -func machoInjectSection(src []byte, blob []byte) ([]byte, error) { - return macho.InjectSection(src, "__LNX", "__lnxpack", blob) -} - -// machoAlignUp rounds size up to the next multiple of align. -func machoAlignUp(size, align uint64) uint64 { - return macho.AlignUp(size, align) -} diff --git a/old/cmd/lnx/main.go b/old/cmd/lnx/main.go deleted file mode 100644 index 75f1984..0000000 --- a/old/cmd/lnx/main.go +++ /dev/null @@ -1,494 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "log/slog" - "net" - "net/http" - "os" - "path/filepath" - "strings" - "syscall" - "time" - - lnx "github.com/semistrict/lnx" - "github.com/spf13/cobra" - "golang.org/x/term" -) - -var doCheckpoint bool -var doEphemeral bool -var doSSHAgent bool -var doNoGuestCache bool -var cliOptions []string - -// instanceName is the resolved instance name. Set from --instance flag or LNX_INSTANCE env. -var instanceName = "default" - -// instanceFlag tracks whether --instance was explicitly set (flag or env var). -var instanceFlag bool - -var rootCmd = &cobra.Command{ - Use: "lnx [flags] [command [args...]]", - Short: "Run commands in a lightweight Linux VM", - SilenceUsage: true, - SilenceErrors: true, - Args: cobra.ArbitraryArgs, - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - args = []string{"bash", "-l"} - } - exitCode, err := runVM(args) - if err != nil { - return err - } - os.Exit(exitCode) - return nil - }, -} - -func init() { - // Apply env var before registering the flag so it becomes the default. - if env := os.Getenv("LNX_INSTANCE"); env != "" { - instanceName = env - } - rootCmd.PersistentFlags().StringVar(&instanceName, "instance", instanceName, "VM instance name (default: \"default\")") - _ = rootCmd.RegisterFlagCompletionFunc("instance", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completeInstanceNames(cmd, nil, toComplete) - }) - rootCmd.Flags().BoolVarP(&doCheckpoint, "checkpoint", "c", false, "snapshot rootfs before starting the VM") - rootCmd.Flags().BoolVar(&doEphemeral, "ephemeral", false, "clone rootfs to a temp file; discard on exit") - rootCmd.Flags().BoolVar(&doSSHAgent, "ssh-agent", false, "forward host SSH agent into the guest") - rootCmd.Flags().BoolVar(&doNoGuestCache, "no-guest-cache", false, "mount CWD/shares directly via 9P without FUSE cache") - rootCmd.Flags().StringArrayVarP(&cliOptions, "option", "O", nil, "runtime option key=value (see lnx options)") - rootCmd.Flags().StringArrayVarP(&forwardEnv, "env", "e", nil, "forward a host env var, set KEY=VALUE, or load dotenv vars from @file") - rootCmd.Flags().BoolVar(&forwardAllEnv, "preserve-env", false, "forward most host environment variables except host-specific path and session vars") - - rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - // Cobra has parsed flags. Update instanceFlag if --instance was explicitly passed. - if f := rootCmd.PersistentFlags().Lookup("instance"); f != nil && f.Changed { - instanceFlag = true - } else if os.Getenv("LNX_INSTANCE") != "" { - instanceFlag = true - } - return nil - } -} - -func main() { - initHostLogging() - - // If this binary was created by `lnx pack`, extract embedded kernel+rootfs - // and run directly (no daemon — single-shot, ephemeral rootfs). - if cfg, err := readPackedConfig(); err == nil { - lnx.InitBinary = initBinary - kernelPath, rootfsPath, err := ensurePackedFiles(cfg) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - hostname := filepath.Base(os.Args[0]) - exitCode, err := lnx.Run(&lnx.Config{ - KernelPath: kernelPath, - RootfsPath: rootfsPath, - Hostname: hostname, - Ephemeral: true, - }, cfg.Args...) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - os.Exit(exitCode) - } - - // Default to login bash when no command is given. - if len(os.Args) == 1 { - os.Args = append(os.Args, "bash", "-l") - } - - // Strip known lnx flags from args to find the guest command. - // This lets `lnx --ephemeral bash -l` bypass cobra so `-l` - // isn't misinterpreted as a flag. - guestArgs := stripLnxFlags(os.Args[1:]) - if len(guestArgs) > 0 && !isSubcommandOrFlag(guestArgs[0]) { - if os.Getenv("LNX_INSTANCE") != "" { - instanceFlag = true - } - exitCode, err := runVM(guestArgs) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - os.Exit(exitCode) - } - - if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -// stripLnxFlags removes known lnx flags from args before the guest command, -// applying their values to package vars, and returns the remaining args. -// Stops parsing at the first non-flag argument (the guest command). -func stripLnxFlags(args []string) []string { - i := 0 - for i < len(args) { - a := args[i] - switch { - case a == "--ephemeral": - doEphemeral = true - i++ - case a == "--ssh-agent": - doSSHAgent = true - i++ - case a == "--no-guest-cache": - doNoGuestCache = true - i++ - case a == "--option" || a == "-O": - if i+1 >= len(args) { - return append([]string(nil), args[i:]...) - } - cliOptions = append(cliOptions, args[i+1]) - i += 2 - case strings.HasPrefix(a, "--option="): - cliOptions = append(cliOptions, strings.TrimPrefix(a, "--option=")) - i++ - case strings.HasPrefix(a, "-O") && len(a) > 2: - cliOptions = append(cliOptions, a[2:]) - i++ - case a == "--preserve-env": - forwardAllEnv = true - i++ - case a == "--env" || a == "-e": - if i+1 >= len(args) { - return append([]string(nil), args[i:]...) - } - forwardEnv = append(forwardEnv, args[i+1]) - i += 2 - case strings.HasPrefix(a, "--env="): - forwardEnv = append(forwardEnv, strings.TrimPrefix(a, "--env=")) - i++ - case a == "--checkpoint" || a == "-c": - doCheckpoint = true - i++ - case a == "--instance" && i+1 < len(args): - instanceName = args[i+1] - instanceFlag = true - i += 2 - case strings.HasPrefix(a, "--instance="): - instanceName = strings.TrimPrefix(a, "--instance=") - instanceFlag = true - i++ - case a == "--": - // Explicit end of lnx flags — everything after is the guest command. - return append([]string(nil), args[i+1:]...) - default: - // First non-flag arg — everything from here is the guest command. - return append([]string(nil), args[i:]...) - } - } - return nil -} - -func isSubcommandOrFlag(arg string) bool { - if strings.HasPrefix(arg, "-") { - return true - } - // Cobra registers these hidden commands lazily during Execute(), - // so they're not in rootCmd.Commands() yet at this point. - if arg == "__complete" || arg == "__completeNoDesc" { - return true - } - for _, cmd := range rootCmd.Commands() { - if cmd.Name() == arg { - return true - } - } - return false -} - -// ensureVMRunning checks for a running VM daemon and starts one if needed. -// Handles auto-init of kernel/rootfs on first run. -func ensureVMRunning() error { - if err := checkLegacyLayout(); err != nil { - return err - } - - // Auto-init on first run if kernel or rootfs is missing. - // checkImagesVolume runs after auto-init (which creates the volume). - kernelPath := filepath.Join(lnxBase(), "vmlinuz") - rootfsPath := resolveRootfsPath() - if _, err := os.Stat(kernelPath); os.IsNotExist(err) { - fmt.Fprintln(os.Stderr, "first run — downloading kernel and rootfs...") - if err := autoInit(); err != nil { - return fmt.Errorf("auto-init failed: %w", err) - } - } else if _, err := os.Stat(rootfsPath); os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "instance %q not initialized — downloading rootfs...\n", instanceName) - if err := autoInit(); err != nil { - return fmt.Errorf("auto-init failed: %w", err) - } - } - - if err := checkImagesVolume(); err != nil { - return err - } - - // Check if a VM is already running for this instance. - if !vmIsRunning() { - // Spawn daemon in background. - if err := spawnDaemon(); err != nil { - return err - } - if err := waitForVM(60 * time.Second); err != nil { - return err - } - } - - return nil -} - -func runVM(args []string) (int, error) { - if err := ensureVMRunning(); err != nil { - return -1, err - } - - // Exec into the running VM. - interactive := term.IsTerminal(int(os.Stdin.Fd())) - execOnce := func() (int, error) { - if interactive { - return execInteractive(args) - } - return execNonInteractive(args) - } - - exitCode, err := execOnce() - if shouldRetryExec(err) { - if restartErr := restartDaemon(); restartErr == nil { - return execOnce() - } - } - return exitCode, err -} - -// vmIsRunning checks if a VM daemon is running for the current instance. -func vmIsRunning() bool { - for _, sockPath := range statusSockPaths() { - conn, err := net.DialTimeout("unix", sockPath, 500*time.Millisecond) - if err == nil { - conn.Close() - return true - } - } - return false -} - -// statusSockPaths returns the possible locations for status.sock. -// Normal instances use the instance dir; nested instances use a local work dir. -func statusSockPaths() []string { - qname := qualifiedInstanceName() - return []string{ - filepath.Join(instanceDir(), "status.sock"), - filepath.Join("/var/lib/lnx/instances", qname, "status.sock"), - filepath.Join("/var/run/lnx", qname, "status.sock"), - } -} - -// spawnDaemon starts the VM daemon as a background process. -func spawnDaemon() error { - // Remove stale error/spawn logs from any previous daemon run. - // These may be owned by root (daemon runs as root), so try both. - qname := qualifiedInstanceName() - workDir := filepath.Join("/var/lib/lnx/instances", qname) - os.Remove(filepath.Join(instanceDir(), "error.log")) - os.Remove(filepath.Join(workDir, "error.log")) - os.Remove(filepath.Join(workDir, "daemon-spawn.log")) - os.MkdirAll(workDir, 0777) - - self, err := os.Executable() - if err != nil { - return fmt.Errorf("find executable: %w", err) - } - - daemonArgs := []string{"_daemon", "--instance", instanceName} - if doCheckpoint { - daemonArgs = append(daemonArgs, "--checkpoint") - } - if doEphemeral { - daemonArgs = append(daemonArgs, "--ephemeral") - } - if doSSHAgent { - daemonArgs = append(daemonArgs, "--ssh-agent") - } - if doNoGuestCache { - daemonArgs = append(daemonArgs, "--no-guest-cache") - } - for _, o := range cliOptions { - daemonArgs = append(daemonArgs, "-O", o) - } - - cmd := buildDaemonCmd(self, daemonArgs) - // Capture daemon stderr for debugging if it fails to start. - daemonLogDir := filepath.Join("/var/lib/lnx/instances", qname) - os.MkdirAll(daemonLogDir, 0755) - if f, err := os.Create(filepath.Join(daemonLogDir, "daemon-spawn.log")); err == nil { - cmd.Stderr = f - // f is intentionally not closed — the daemon process owns it. - } - cmd.Stdout = nil - cmd.Stdin = nil - // Detach from the parent process group so the daemon survives. - cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} - - if err := cmd.Start(); err != nil { - return fmt.Errorf("start daemon %v: %w", cmd.Args, err) - } - - slog.Debug("daemon spawned", "pid", cmd.Process.Pid, "args", cmd.Args) - - // Release the process so it doesn't become a zombie. - cmd.Process.Release() - return nil -} - -func shouldRetryExec(err error) bool { - if err == nil { - return false - } - if isNoVM(err) { - return true - } - if errors.Is(err, errExecTerminatedUnexpectedly) { - return true - } - s := err.Error() - return strings.Contains(s, "connect to VM:") -} - -func restartDaemon() error { - if vmIsRunning() { - req, err := http.NewRequest(http.MethodPost, "http://localhost/stop", nil) - if err == nil { - resp, stopErr := apiClient().Do(req) - if stopErr == nil && resp != nil { - resp.Body.Close() - } - } - deadline := time.Now().Add(5 * time.Second) - for vmIsRunning() && time.Now().Before(deadline) { - time.Sleep(100 * time.Millisecond) - } - } - if vmIsRunning() { - return fmt.Errorf("VM did not stop cleanly for restart") - } - if err := spawnDaemon(); err != nil { - return err - } - return waitForVM(60 * time.Second) -} - -func initHostLogging() { - level := slog.LevelInfo - switch strings.ToLower(os.Getenv("LNX_LOG")) { - case "debug": - level = slog.LevelDebug - case "warn": - level = slog.LevelWarn - case "error": - level = slog.LevelError - } - - logDir := instanceDir() - os.MkdirAll(logDir, 0755) - f, err := os.OpenFile(filepath.Join(logDir, "lnx.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return - } - slog.SetDefault(slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: level}))) -} - -// lnxBase returns the base lnx directory (~/.lnx). -func lnxBase() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".lnx") -} - -// instanceDir returns the directory for the current instance's runtime state -// (~/.lnx/instances/). Contains sockets, logs, and other ephemeral files. -func instanceDir() string { - return instanceDirFor(qualifiedInstanceName()) -} - -// instanceDirFor returns the runtime state directory for a named instance. -func instanceDirFor(name string) string { - return filepath.Join(lnxBase(), "instances", name) -} - -// imagesDir returns the directory for the current instance's disk images -// (~/.lnx/images/). Contains rootfs, checkpoints, swap, and other -// large files. When an APFS volume is configured, ~/.lnx/images is a -// symlink to the volume mount point. -func imagesDir() string { - return imagesDirFor(qualifiedInstanceName()) -} - -// imagesDirFor returns the disk images directory for a named instance. -// Docker container instances live under ~/.lnx/docker/containers/ and are -// checked first so their rootfs is found without copying to ~/.lnx/images/. -func imagesDirFor(name string) string { - containerDir := filepath.Join(lnxBase(), "docker", "containers", name) - if _, err := os.Stat(containerDir); err == nil { - return containerDir - } - return filepath.Join(lnxBase(), "images", name) -} - -// resolveRootfsPath returns the rootfs path in the images/ directory. -func resolveRootfsPath() string { - return filepath.Join(imagesDir(), "rootfs.ext4") -} - -// resolveRootfsPathFor returns the rootfs path for a named instance. -func resolveRootfsPathFor(name string) string { - return filepath.Join(imagesDirFor(name), "rootfs.ext4") -} - -// checkLegacyLayout returns an error if rootfs files are found in the old -// instances/ directory layout instead of the new images/ layout. -func checkLegacyLayout() error { - instancesDir := filepath.Join(lnxBase(), "instances") - entries, err := os.ReadDir(instancesDir) - if err != nil { - return nil // no instances dir at all — fine - } - for _, e := range entries { - if !e.IsDir() { - continue - } - legacy := filepath.Join(instancesDir, e.Name(), "rootfs.ext4") - if _, err := os.Stat(legacy); err == nil { - return fmt.Errorf("legacy layout detected: rootfs found at %s\n"+ - "lnx now stores disk images under ~/.lnx/images/ (separate from runtime state in ~/.lnx/instances/).\n"+ - "To migrate, move your rootfs and checkpoint files:\n"+ - " mkdir -p ~/.lnx/images/%s\n"+ - " mv %s ~/.lnx/images/%s/\n"+ - " mv %s/checkpoints ~/.lnx/images/%s/ 2>/dev/null\n"+ - "Repeat for each instance, then re-run your command.", - legacy, e.Name(), legacy, e.Name(), - filepath.Join(instancesDir, e.Name()), e.Name()) - } - } - return nil -} - -// qualifiedInstanceName returns the instance name with parent prefix if nested. -func qualifiedInstanceName() string { - parent := os.Getenv("LNX_PARENT") - if parent == "" { - return instanceName - } - return parent + "." + instanceName -} diff --git a/old/cmd/lnx/oci.go b/old/cmd/lnx/oci.go deleted file mode 100644 index 37f0091..0000000 --- a/old/cmd/lnx/oci.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/semistrict/lnx/internal/lnxoci" -) - -func ociDir() string { - return filepath.Join(dockerImagesDir(), "_oci") -} - -func ociBlobDir() string { - return filepath.Join(ociDir(), "blobs", "sha256") -} - -func ociLayerDir() string { - return filepath.Join(ociDir(), "layers") -} - -// ensureOCIRootfs pulls an OCI image (if needed), builds its layers into an -// ext4 rootfs, and returns the path to the cached base rootfs. -func ensureOCIRootfs(imageRef string) (string, error) { - if !strings.Contains(imageRef, ":") { - imageRef += ":latest" - } - - inst := lnxoci.SlugFromRef(imageRef) - baseRootfs := filepath.Join(dockerImageDirFor(inst), "rootfs.ext4") - - if _, err := os.Stat(baseRootfs); err == nil { - return baseRootfs, nil - } - - blobDir := ociBlobDir() - layerDir := ociLayerDir() - for _, d := range []string{blobDir, layerDir} { - if err := os.MkdirAll(d, 0755); err != nil { - return "", fmt.Errorf("create OCI directory: %w", err) - } - } - - fmt.Fprintf(os.Stderr, "pulling %s...\n", imageRef) - img, err := lnxoci.Pull(imageRef, blobDir) - if err != nil { - return "", fmt.Errorf("pull image: %w", err) - } - - fmt.Fprintf(os.Stderr, "building layers...\n") - finalLayerPath, err := lnxoci.BuildLayers(img, blobDir, layerDir) - if err != nil { - return "", fmt.Errorf("build layers: %w", err) - } - - if err := os.MkdirAll(dockerImageDirFor(inst), 0755); err != nil { - return "", fmt.Errorf("create image dir: %w", err) - } - if err := cloneRootfs(finalLayerPath, baseRootfs); err != nil { - return "", fmt.Errorf("create image rootfs: %w", err) - } - writeDefaultCmd(inst, img.DefaultCmd()) - writeImageMeta(inst, imageMeta{ExposedPorts: img.ExposedPorts()}) - - return baseRootfs, nil -} diff --git a/old/cmd/lnx/pack.go b/old/cmd/lnx/pack.go deleted file mode 100644 index dc44025..0000000 --- a/old/cmd/lnx/pack.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "path/filepath" - - "github.com/semistrict/lnx/internal/pack" -) - -func readPackedConfig() (*pack.Config, error) { - return pack.ReadConfig("__LNX", "__lnxpack") -} - -func readPackedConfigFrom(path string) (*pack.Config, error) { - return pack.ReadConfigFrom(path, "__lnxpack") -} - -func ensurePackedFiles(cfg *pack.Config) (kernelPath, rootfsPath string, err error) { - cacheDir := filepath.Join(lnxBase(), "packed-cache") - return pack.EnsureFiles(cfg, cacheDir) -} diff --git a/old/cmd/lnx/pack_cmd.go b/old/cmd/lnx/pack_cmd.go deleted file mode 100644 index d989067..0000000 --- a/old/cmd/lnx/pack_cmd.go +++ /dev/null @@ -1,213 +0,0 @@ -//go:build darwin - -package main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - - "github.com/semistrict/lnx/internal/pack" - "github.com/spf13/cobra" -) - -// virtualizationEntitlements is the entitlements plist required to use -// Apple Virtualization.framework. Written to a tempfile for codesigning. -const virtualizationEntitlements = ` - - - - com.apple.security.virtualization - - -` - -var packOutput string -var packKernel string -var packRootfs string - -var packCmd = &cobra.Command{ - Use: "pack -o BIN CMD [ARGS...]", - Short: "Create a self-contained binary that runs a fixed command in a VM", - Long: `Pack creates a single executable that embeds the kernel and rootfs -and behaves like: - - lnx --instance INSTANCE CMD [ARGS...] - -On first run the binary extracts the kernel and rootfs to a cache under -~/.lnx/packed-cache/ and boots an ephemeral VM. No lnx installation is -required on the target machine.`, - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - if packOutput == "" { - return fmt.Errorf("--output / -o is required") - } - kernelPath := packKernel - if kernelPath == "" { - kernelPath = filepath.Join(lnxBase(), "vmlinuz") - } - rootfsPath := packRootfs - if rootfsPath == "" { - rootfsPath = resolveRootfsPath() - } - return runPack(instanceName, args, kernelPath, rootfsPath, packOutput) - }, -} - -func init() { - packCmd.Flags().StringVarP(&packOutput, "output", "o", "", "output binary path (required)") - packCmd.Flags().StringVar(&packKernel, "kernel", "", "kernel image path (default: ~/.lnx/vmlinuz)") - packCmd.Flags().StringVar(&packRootfs, "rootfs", "", "rootfs ext4 path (default: instance rootfs)") - rootCmd.AddCommand(packCmd) -} - -func runPack(instance string, args []string, kernelPath, rootfsPath, output string) error { - for _, p := range []string{kernelPath, rootfsPath} { - if _, err := os.Stat(p); err != nil { - return fmt.Errorf("%s: %w", p, err) - } - } - - self, err := os.Executable() - if err != nil { - return fmt.Errorf("find executable: %w", err) - } - - // Compress kernel and rootfs to temp files. - tmp, err := os.MkdirTemp("", "lnx-pack-*") - if err != nil { - return err - } - defer os.RemoveAll(tmp) - - fmt.Fprintf(os.Stderr, "compressing kernel (%s)...\n", humanFileSize(kernelPath)) - kernelComp := filepath.Join(tmp, "kernel.zst") - kernelSHA, kernelCompSize, err := pack.CompressZstdFile(kernelPath, kernelComp, nil) - if err != nil { - return fmt.Errorf("compress kernel: %w", err) - } - fmt.Fprintf(os.Stderr, " %s → %s\n", humanFileSize(kernelPath), humanFileSize(kernelComp)) - - fmt.Fprintf(os.Stderr, "compressing rootfs (%s)...\n", humanFileSize(rootfsPath)) - rootfsComp := filepath.Join(tmp, "rootfs.zst") - rootfsSHA, rootfsCompSize, err := pack.CompressZstdFile(rootfsPath, rootfsComp, nil) - if err != nil { - return fmt.Errorf("compress rootfs: %w", err) - } - fmt.Fprintf(os.Stderr, " %s → %s\n", humanFileSize(rootfsPath), humanFileSize(rootfsComp)) - - cfg := &pack.Config{ - Instance: instance, - Args: args, - KernelCompSize: kernelCompSize, - RootfsCompSize: rootfsCompSize, - KernelSHA256: kernelSHA, - RootfsSHA256: rootfsSHA, - } - - // Build the blob: [kernel.zst][rootfs.zst][json][u64 json_len] - blob, err := pack.BuildBlob(kernelComp, rootfsComp, cfg) - if err != nil { - return fmt.Errorf("build blob: %w", err) - } - - // Read the source binary (the lnx executable itself). - srcBin, err := os.ReadFile(self) - if err != nil { - return fmt.Errorf("read source binary: %w", err) - } - - // Inject the blob into the __LNX,__lnxpack Mach-O section. - fmt.Fprintln(os.Stderr, "injecting into Mach-O section...") - outBin, err := machoInjectSection(srcBin, blob) - if err != nil { - return fmt.Errorf("inject section: %w", err) - } - - // Write the modified binary. - tmpOut := output + ".tmp" - if err := os.WriteFile(tmpOut, outBin, 0755); err != nil { - return fmt.Errorf("write binary: %w", err) - } - - // Strip old signature and re-sign with virtualization entitlement. - if err := codesignRemove(tmpOut); err != nil { - os.Remove(tmpOut) - return fmt.Errorf("strip signature: %w", err) - } - fmt.Fprintln(os.Stderr, "signing...") - if err := codesignWithVirtualization(tmpOut); err != nil { - os.Remove(tmpOut) - return fmt.Errorf("codesign: %w", err) - } - - if err := os.Rename(tmpOut, output); err != nil { - os.Remove(tmpOut) - return fmt.Errorf("rename: %w", err) - } - - fi, _ := os.Stat(output) - fmt.Fprintf(os.Stderr, "packed: %s (%s, instance=%q, cmd=%v)\n", - output, humanBytes(fi.Size()), instance, args) - return nil -} - -// codesignRemove strips the code signature from a binary. -func codesignRemove(path string) error { - cmd := exec.Command("codesign", "--remove-signature", path) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("%w: %s", err, out) - } - return nil -} - -// codesignWithVirtualization signs a binary with the virtualization entitlement. -func codesignWithVirtualization(path string) error { - entFile, err := os.CreateTemp("", "lnx-entitlements-*.plist") - if err != nil { - return err - } - defer os.Remove(entFile.Name()) - if _, err := entFile.WriteString(virtualizationEntitlements); err != nil { - entFile.Close() - return err - } - entFile.Close() - - cmd := exec.Command("codesign", - "--entitlements", entFile.Name(), - "--force", - "-s", "-", - path, - ) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("%w: %s", err, out) - } - return nil -} - -// humanFileSize returns a human-readable size string for a file. -func humanFileSize(path string) string { - fi, err := os.Stat(path) - if err != nil { - return "?" - } - return humanBytes(fi.Size()) -} - -// humanBytes returns a human-readable byte count. -func humanBytes(n int64) string { - switch { - case n >= 1<<30: - return fmt.Sprintf("%.1f GB", float64(n)/(1<<30)) - case n >= 1<<20: - return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) - case n >= 1<<10: - return fmt.Sprintf("%.1f KB", float64(n)/(1<<10)) - default: - return fmt.Sprintf("%d B", n) - } -} diff --git a/old/cmd/lnx/pack_test.go b/old/cmd/lnx/pack_test.go deleted file mode 100644 index 9c3617d..0000000 --- a/old/cmd/lnx/pack_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package main - -import ( - "encoding/binary" - "encoding/json" - "os" - "testing" - - "github.com/semistrict/lnx/internal/pack" -) - -// buildTestMacho creates a minimal Mach-O 64-bit binary with a __LNX,__lnxpack -// section suitable for testing machoInjectSection. -func buildTestMacho(t *testing.T) []byte { - t.Helper() - - const pageSize = 16384 - // Layout: - // [mach_header_64] 0..32 - // [LC_SEGMENT_64 __TEXT] 32..104 (covers header + load cmds) - // [LC_SEGMENT_64 __LNX] 104..256 (72 + 80 = 152, but cmdsize=152) - // [LC_SEGMENT_64 __LINKEDIT] 256..328 - // [LC_SYMTAB] 328..352 - // [LC_CODE_SIGNATURE] 352..368 - // ... padding to pageSize ... - // [__TEXT data] 0..pageSize (the header IS __TEXT) - // [__LNX,__lnxpack data] pageSize..2*pageSize - // [__LINKEDIT data] 2*pageSize..3*pageSize - // - // Total: 3 pages = 3*16384 = 49152 - - ncmds := uint32(5) - sizeOfCmds := uint32(72 + (72 + 80) + 72 + 24 + 16) // TEXT + LNX(seg+sect) + LINKEDIT + SYMTAB + CODESIG - totalSize := 3 * pageSize - - bin := make([]byte, totalSize) - - // mach_header_64 - binary.LittleEndian.PutUint32(bin[0:], 0xFEEDFACF) // magic - binary.LittleEndian.PutUint32(bin[4:], 0x0100000C) // CPU_TYPE_ARM64 - binary.LittleEndian.PutUint32(bin[8:], 0x00000000) // cpusubtype - binary.LittleEndian.PutUint32(bin[12:], 2) // MH_EXECUTE - binary.LittleEndian.PutUint32(bin[16:], ncmds) // ncmds - binary.LittleEndian.PutUint32(bin[20:], sizeOfCmds) // sizeofcmds - binary.LittleEndian.PutUint32(bin[24:], 0) // flags - binary.LittleEndian.PutUint32(bin[28:], 0) // reserved - - off := 32 - - // LC_SEGMENT_64: __TEXT (covers the entire first page including header) - binary.LittleEndian.PutUint32(bin[off+0:], 0x19) // cmd = LC_SEGMENT_64 - binary.LittleEndian.PutUint32(bin[off+4:], 72) // cmdsize - copy(bin[off+8:], "__TEXT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00") - binary.LittleEndian.PutUint64(bin[off+24:], 0x100000000) // vmaddr - binary.LittleEndian.PutUint64(bin[off+32:], uint64(pageSize)) // vmsize - binary.LittleEndian.PutUint64(bin[off+40:], 0) // fileoff - binary.LittleEndian.PutUint64(bin[off+48:], uint64(pageSize)) // filesize - binary.LittleEndian.PutUint32(bin[off+56:], 5) // maxprot (r+x) - binary.LittleEndian.PutUint32(bin[off+60:], 5) // initprot - binary.LittleEndian.PutUint32(bin[off+64:], 0) // nsects - binary.LittleEndian.PutUint32(bin[off+68:], 0) // flags - off += 72 - - // LC_SEGMENT_64: __LNX with 1 section __lnxpack - lnxSegOff := off - binary.LittleEndian.PutUint32(bin[off+0:], 0x19) // cmd - binary.LittleEndian.PutUint32(bin[off+4:], 72+80) // cmdsize (seg + 1 section) - copy(bin[off+8:], "__LNX\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00") - binary.LittleEndian.PutUint64(bin[off+24:], 0x100000000+uint64(pageSize)) // vmaddr - binary.LittleEndian.PutUint64(bin[off+32:], uint64(pageSize)) // vmsize - binary.LittleEndian.PutUint64(bin[off+40:], uint64(pageSize)) // fileoff - binary.LittleEndian.PutUint64(bin[off+48:], uint64(pageSize)) // filesize - binary.LittleEndian.PutUint32(bin[off+56:], 3) // maxprot (r+w) - binary.LittleEndian.PutUint32(bin[off+60:], 3) // initprot - binary.LittleEndian.PutUint32(bin[off+64:], 1) // nsects - binary.LittleEndian.PutUint32(bin[off+68:], 0) // flags - off += 72 - _ = lnxSegOff - - // section_64: __lnxpack - copy(bin[off+0:], "__lnxpack\x00\x00\x00\x00\x00\x00\x00") - copy(bin[off+16:], "__LNX\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00") - binary.LittleEndian.PutUint64(bin[off+32:], 0x100000000+uint64(pageSize)) // addr - binary.LittleEndian.PutUint64(bin[off+40:], uint64(pageSize)) // size - binary.LittleEndian.PutUint32(bin[off+48:], uint32(pageSize)) // offset - binary.LittleEndian.PutUint32(bin[off+52:], 14) // align = 2^14 = 16384 - off += 80 - - // LC_SEGMENT_64: __LINKEDIT - binary.LittleEndian.PutUint32(bin[off+0:], 0x19) // cmd - binary.LittleEndian.PutUint32(bin[off+4:], 72) // cmdsize - copy(bin[off+8:], "__LINKEDIT\x00\x00\x00\x00\x00\x00") - binary.LittleEndian.PutUint64(bin[off+24:], 0x100000000+2*uint64(pageSize)) // vmaddr - binary.LittleEndian.PutUint64(bin[off+32:], uint64(pageSize)) // vmsize - binary.LittleEndian.PutUint64(bin[off+40:], 2*uint64(pageSize)) // fileoff - binary.LittleEndian.PutUint64(bin[off+48:], uint64(pageSize)) // filesize - binary.LittleEndian.PutUint32(bin[off+56:], 1) // maxprot (r) - binary.LittleEndian.PutUint32(bin[off+60:], 1) // initprot - off += 72 - - // LC_SYMTAB - binary.LittleEndian.PutUint32(bin[off+0:], 0x2) // cmd = LC_SYMTAB - binary.LittleEndian.PutUint32(bin[off+4:], 24) // cmdsize - binary.LittleEndian.PutUint32(bin[off+8:], uint32(2*pageSize+100)) // symoff (in LINKEDIT) - binary.LittleEndian.PutUint32(bin[off+12:], 0) // nsyms - binary.LittleEndian.PutUint32(bin[off+16:], uint32(2*pageSize+200)) // stroff (in LINKEDIT) - binary.LittleEndian.PutUint32(bin[off+20:], 0) // strsize - off += 24 - - // LC_CODE_SIGNATURE - binary.LittleEndian.PutUint32(bin[off+0:], 0x1D) // cmd = LC_CODE_SIGNATURE - binary.LittleEndian.PutUint32(bin[off+4:], 16) // cmdsize - binary.LittleEndian.PutUint32(bin[off+8:], uint32(2*pageSize+8000)) // dataoff - binary.LittleEndian.PutUint32(bin[off+12:], 1000) // datasize - - return bin -} - -func TestMachoInjectSection(t *testing.T) { - src := buildTestMacho(t) - const pageSize = 16384 - - blob := []byte("test-kernel-datarootfs-data") - cfg := &pack.Config{ - Instance: "test", - Args: []string{"echo", "hello"}, - KernelCompSize: 16, - RootfsCompSize: 10, - KernelSHA256: "aaaa", - RootfsSHA256: "bbbb", - } - jsonBytes, _ := json.Marshal(cfg) - jsonLen := make([]byte, 8) - binary.LittleEndian.PutUint64(jsonLen, uint64(len(jsonBytes))) - - fullBlob := make([]byte, 0) - fullBlob = append(fullBlob, blob...) - fullBlob = append(fullBlob, jsonBytes...) - fullBlob = append(fullBlob, jsonLen...) - - out, err := machoInjectSection(src, fullBlob) - if err != nil { - t.Fatalf("machoInjectSection: %v", err) - } - - // The output should be larger than input (blob > 16KB placeholder). - alignedBlobSize := machoAlignUp(8+uint64(len(fullBlob)), 16384) - expectedSize := int(pageSize) + int(alignedBlobSize) + int(pageSize) - if len(out) != expectedSize { - t.Errorf("output size: got %d, want %d", len(out), expectedSize) - } - - // Verify __LNX segment was resized. - lnxFilesize := binary.LittleEndian.Uint64(out[104+48:]) // segment filesize at known offset - if lnxFilesize != alignedBlobSize { - t.Errorf("__LNX filesize: got %d, want %d", lnxFilesize, alignedBlobSize) - } - - // Verify __LINKEDIT was shifted. - linkeditOff := 104 + 72 + 80 // after __TEXT(72) + __LNX(72+80) segments - linkeditFileoff := binary.LittleEndian.Uint64(out[linkeditOff+40:]) - expectedLinkeditFileoff := uint64(pageSize) + alignedBlobSize - if linkeditFileoff != expectedLinkeditFileoff { - t.Errorf("__LINKEDIT fileoff: got %d, want %d", linkeditFileoff, expectedLinkeditFileoff) - } - - // Verify data_size header was written. - dataSizeOff := pageSize // __LNX section starts at page 1 - dataSize := binary.LittleEndian.Uint64(out[dataSizeOff:]) - if dataSize != uint64(len(fullBlob)) { - t.Errorf("data_size: got %d, want %d", dataSize, len(fullBlob)) - } - - // Verify blob data was written. - for i, b := range fullBlob { - if out[dataSizeOff+8+i] != b { - t.Errorf("blob[%d]: got 0x%x, want 0x%x", i, out[dataSizeOff+8+i], b) - break - } - } - - // Verify SYMTAB offsets were shifted. - symtabOff := linkeditOff + 72 - symoff := binary.LittleEndian.Uint32(out[symtabOff+8:]) - expectedSymoff := uint32(int64(2*pageSize+100) + int64(alignedBlobSize) - int64(pageSize)) - if symoff != expectedSymoff { - t.Errorf("symoff: got %d, want %d", symoff, expectedSymoff) - } -} - -func TestMachoInjectAndReadConfig(t *testing.T) { - src := buildTestMacho(t) - - cfg := &pack.Config{ - Instance: "myinstance", - Args: []string{"bash", "-c", "echo hello"}, - KernelCompSize: 17, - RootfsCompSize: 17, - KernelSHA256: "aaaa", - RootfsSHA256: "bbbb", - } - - // Build a blob with fake kernel/rootfs data. - kernel := []byte("compressed-kernel") - rootfs := []byte("compressed-rootfs") - jsonBytes, _ := json.Marshal(cfg) - jsonLen := make([]byte, 8) - binary.LittleEndian.PutUint64(jsonLen, uint64(len(jsonBytes))) - - blob := make([]byte, 0, len(kernel)+len(rootfs)+len(jsonBytes)+8) - blob = append(blob, kernel...) - blob = append(blob, rootfs...) - blob = append(blob, jsonBytes...) - blob = append(blob, jsonLen...) - - out, err := machoInjectSection(src, blob) - if err != nil { - t.Fatalf("machoInjectSection: %v", err) - } - - // Write to a temp file and read config back. - tmp := t.TempDir() - path := tmp + "/packed" - if err := os.WriteFile(path, out, 0755); err != nil { - t.Fatal(err) - } - - got, err := readPackedConfigFrom(path) - if err != nil { - t.Fatalf("readPackedConfigFrom: %v", err) - } - if got.Instance != cfg.Instance { - t.Errorf("instance: got %q, want %q", got.Instance, cfg.Instance) - } - if len(got.Args) != len(cfg.Args) { - t.Fatalf("args len: got %d, want %d", len(got.Args), len(cfg.Args)) - } - for i, a := range cfg.Args { - if got.Args[i] != a { - t.Errorf("args[%d]: got %q, want %q", i, got.Args[i], a) - } - } - if got.KernelCompSize != cfg.KernelCompSize { - t.Errorf("kernel_comp_size: got %d, want %d", got.KernelCompSize, cfg.KernelCompSize) - } - if got.RootfsCompSize != cfg.RootfsCompSize { - t.Errorf("rootfs_comp_size: got %d, want %d", got.RootfsCompSize, cfg.RootfsCompSize) - } - if got.DataFileOffset == 0 { - t.Error("dataFileOffset should be non-zero") - } -} - -func TestReadPackedConfigNoSection(t *testing.T) { - // A file without a Mach-O header should return an error. - tmp := t.TempDir() - p := tmp + "/plain" - if err := os.WriteFile(p, []byte("just a regular binary"), 0755); err != nil { - t.Fatal(err) - } - if _, err := readPackedConfigFrom(p); err == nil { - t.Error("expected error for non-Mach-O file, got nil") - } -} - -func TestReadPackedConfigUnpacked(t *testing.T) { - // A Mach-O with the section but data_size=0 should return an error. - src := buildTestMacho(t) - tmp := t.TempDir() - p := tmp + "/unpacked" - if err := os.WriteFile(p, src, 0755); err != nil { - t.Fatal(err) - } - _, err := readPackedConfigFrom(p) - if err == nil { - t.Error("expected error for unpacked Mach-O, got nil") - } -} diff --git a/old/cmd/lnx/ports_cmd.go b/old/cmd/lnx/ports_cmd.go deleted file mode 100644 index c1259fc..0000000 --- a/old/cmd/lnx/ports_cmd.go +++ /dev/null @@ -1,155 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "net/http" - "sort" - "time" - - "github.com/charmbracelet/lipgloss/table" - "github.com/semistrict/lnx" - "github.com/spf13/cobra" -) - -var portsCmd = &cobra.Command{ - Use: "ports", - Short: "Manage forwarded ports", -} - -var portsListCmd = &cobra.Command{ - Use: "list", - Short: "List forwarded ports", - RunE: runPortsList, -} - -func init() { - portsCmd.AddCommand(portsListCmd) - rootCmd.AddCommand(portsCmd) -} - -type instancePort struct { - Instance string - Port lnx.PortEntry -} - -func runPortsList(cmd *cobra.Command, args []string) error { - // If explicit --instance, show only that one. - if instanceFlag { - return runPortsListOne(instanceName) - } - return runPortsListAll() -} - -func runPortsListAll() error { - running := runningInstances() - if len(running) == 0 { - fmt.Println("no VM running") - return nil - } - - var all []instancePort - for _, name := range running { - ports, err := fetchPorts(name) - if err != nil { - continue - } - for _, p := range ports { - all = append(all, instancePort{Instance: name, Port: p}) - } - } - - if len(all) == 0 { - fmt.Println("no forwarded ports") - return nil - } - - sort.Slice(all, func(i, j int) bool { - if all[i].Instance != all[j].Instance { - return all[i].Instance < all[j].Instance - } - return all[i].Port.Guest < all[j].Port.Guest - }) - - probe := &http.Client{Timeout: 500 * time.Millisecond} - - multiInstance := len(running) > 1 - var t *table.Table - if multiInstance { - t = newTable("INSTANCE", "GUEST", "HOST", "URL") - } else { - t = newTable("GUEST", "HOST", "URL") - } - - for _, ip := range all { - url := "" - if isHTTP(probe, ip.Port.Host) { - url = cyanStyle.Render(fmt.Sprintf("http://localhost:%d", ip.Port.Host)) - } - guest := fmt.Sprintf("%d", ip.Port.Guest) - host := fmt.Sprintf("%d", ip.Port.Host) - if multiInstance { - t.Row(ip.Instance, guest, host, url) - } else { - t.Row(guest, host, url) - } - } - fmt.Println(t) - return nil -} - -func runPortsListOne(name string) error { - ports, err := fetchPorts(name) - if err != nil { - if isNoVM(err) { - fmt.Println("no VM running") - return nil - } - return err - } - - if len(ports) == 0 { - fmt.Println("no forwarded ports") - return nil - } - - sort.Slice(ports, func(i, j int) bool { - return ports[i].Guest < ports[j].Guest - }) - - probe := &http.Client{Timeout: 500 * time.Millisecond} - - t := newTable("GUEST", "HOST", "URL") - for _, p := range ports { - url := "" - if isHTTP(probe, p.Host) { - url = cyanStyle.Render(fmt.Sprintf("http://localhost:%d", p.Host)) - } - t.Row(fmt.Sprintf("%d", p.Guest), fmt.Sprintf("%d", p.Host), url) - } - fmt.Println(t) - return nil -} - -func fetchPorts(name string) ([]lnx.PortEntry, error) { - resp, err := apiClientFor(name).Get("http://localhost/ports") - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var ports []lnx.PortEntry - if err := json.NewDecoder(resp.Body).Decode(&ports); err != nil { - return nil, fmt.Errorf("read ports: %w", err) - } - return ports, nil -} - -func isHTTP(client *http.Client, port uint16) bool { - resp, err := client.Head(fmt.Sprintf("http://localhost:%d/", port)) - if err != nil { - return false - } - resp.Body.Close() - return true -} diff --git a/old/cmd/lnx/sessions_cmd.go b/old/cmd/lnx/sessions_cmd.go deleted file mode 100644 index f3e745d..0000000 --- a/old/cmd/lnx/sessions_cmd.go +++ /dev/null @@ -1,267 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "os" - "sort" - "strings" - "syscall" - "time" - - "github.com/semistrict/lnx" - "github.com/spf13/cobra" -) - -var sessionsCmd = &cobra.Command{ - Use: "sessions", - Short: "Manage exec sessions", -} - -var sessionsListCmd = &cobra.Command{ - Use: "list", - Short: "List active exec sessions", - RunE: runSessionsList, -} - -var sessionsKillCmd = &cobra.Command{ - Use: "kill ", - Short: "Kill an exec session (SIGTERM, then SIGKILL after 10s)", - Args: cobra.ExactArgs(1), - RunE: runSessionsKill, -} - -func init() { - sessionsCmd.AddCommand(sessionsListCmd) - sessionsCmd.AddCommand(sessionsKillCmd) - rootCmd.AddCommand(sessionsCmd) -} - -func runSessionsList(cmd *cobra.Command, args []string) error { - if instanceFlag { - return runSessionsListOne(instanceName) - } - return runSessionsListAll() -} - -func runSessionsListAll() error { - running := runningInstances() - if len(running) == 0 { - fmt.Println("no VM running") - return nil - } - - type row struct { - instance string - session lnx.SessionInfo - } - - var rows []row - for _, name := range running { - sessions, err := fetchSessions(name) - if err != nil { - continue - } - for _, s := range sessions { - rows = append(rows, row{instance: name, session: s}) - } - } - - if len(rows) == 0 { - fmt.Println("no active sessions") - return nil - } - - sort.Slice(rows, func(i, j int) bool { - return rows[i].session.StartTime.Before(rows[j].session.StartTime) - }) - - t := newTable("INSTANCE", "ID", "COMMAND", "LOCAL PID", "REMOTE PID", "AGE") - for _, r := range rows { - id := r.instance + "_" + r.session.ID - t.Row(r.instance, id, formatCommand(r.session.Args), formatPID(r.session.ClientPID), formatPID(r.session.GuestPID), formatAge(time.Since(r.session.StartTime))) - } - fmt.Println(t) - return nil -} - -func runSessionsListOne(name string) error { - sessions, err := fetchSessions(name) - if err != nil { - if isNoVM(err) { - fmt.Println("no VM running") - return nil - } - return err - } - - if len(sessions) == 0 { - fmt.Println("no active sessions") - return nil - } - - sort.Slice(sessions, func(i, j int) bool { - return sessions[i].StartTime.Before(sessions[j].StartTime) - }) - - t := newTable("ID", "COMMAND", "LOCAL PID", "REMOTE PID", "AGE") - for _, s := range sessions { - t.Row(s.ID, formatCommand(s.Args), formatPID(s.ClientPID), formatPID(s.GuestPID), formatAge(time.Since(s.StartTime))) - } - fmt.Println(t) - return nil -} - -// runSessionsKill sends SIGTERM to both local and remote process, waits 10s, -// then sends SIGKILL if the session is still alive. -func runSessionsKill(cmd *cobra.Command, args []string) error { - id := args[0] - - // Parse "instance_sN" format to determine instance and session ID. - inst, sessID := parseSessionID(id) - clientPID := sessionClientPID(inst, sessID) - - client := apiClientFor(inst) - - // Send SIGTERM + SIGHUP to guest process. SIGHUP is needed because - // interactive shells (bash) ignore SIGTERM by default. - if err := sendSessionSignal(client, sessID, int(syscall.SIGTERM), false); err != nil { - return err - } - sendSessionSignal(client, sessID, int(syscall.SIGHUP), false) - - fmt.Fprintf(os.Stderr, "sent SIGTERM to session %s\n", id) - - // Wait up to 10s, checking if the session is still alive. - deadline := time.Now().Add(10 * time.Second) - for time.Now().Before(deadline) { - time.Sleep(500 * time.Millisecond) - sessions, err := fetchSessions(inst) - if err != nil { - // VM shut down or unreachable — session is gone. - terminateLocalClient(clientPID) - return nil - } - found := false - for _, s := range sessions { - if s.ID == sessID { - found = true - break - } - } - if !found { - terminateLocalClient(clientPID) - return nil - } - } - - // Session still alive — send SIGKILL and close connections. - fmt.Fprintf(os.Stderr, "session %s still alive, sending SIGKILL\n", id) - - sendSessionSignal(client, sessID, int(syscall.SIGKILL), true) - terminateLocalClient(clientPID) - - return nil -} - -// parseSessionID splits "instance_sN" into (instance, "sN"). -// Session IDs always start with "s", so we split on the last "_s". -// If there's no such separator, assumes the current --instance. -func parseSessionID(id string) (string, string) { - if i := strings.LastIndex(id, "_s"); i >= 0 { - return id[:i], id[i+1:] - } - return instanceName, id -} - -func sendSessionSignal(client *http.Client, sessID string, sig int, closeConn bool) error { - body, _ := json.Marshal(lnx.SessionKillRequest{ID: sessID, Signal: sig, Close: closeConn}) - resp, err := client.Post("http://localhost/sessions/kill", "application/json", bytes.NewReader(body)) - if err != nil { - if isNoVM(err) { - return nil // VM already gone - } - return fmt.Errorf("signal session: %w", err) - } - resp.Body.Close() - if resp.StatusCode == 404 { - return fmt.Errorf("session %s not found", sessID) - } - return nil -} - -func sessionClientPID(instance, sessID string) int { - sessions, err := fetchSessions(instance) - if err != nil { - return 0 - } - for _, s := range sessions { - if s.ID == sessID { - return s.ClientPID - } - } - return 0 -} - -func terminateLocalClient(pid int) { - if pid <= 0 || pid == os.Getpid() { - return - } - if !processExists(pid) { - return - } - _ = syscall.Kill(pid, syscall.SIGTERM) - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if !processExists(pid) { - return - } - time.Sleep(100 * time.Millisecond) - } - _ = syscall.Kill(pid, syscall.SIGKILL) -} - -func processExists(pid int) bool { - return syscall.Kill(pid, 0) == nil -} - -func formatCommand(args []string) string { - s := strings.Join(args, " ") - if len(s) > 40 { - return s[:37] + "..." - } - return s -} - -func formatPID(pid int) string { - if pid > 0 { - return fmt.Sprintf("%d", pid) - } - return "-" -} - -func formatAge(d time.Duration) string { - if d < time.Minute { - return fmt.Sprintf("%ds", int(d.Seconds())) - } - if d < time.Hour { - return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60) - } - return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) -} - -func fetchSessions(name string) ([]lnx.SessionInfo, error) { - resp, err := apiClientFor(name).Get("http://localhost/sessions") - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var sessions []lnx.SessionInfo - if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { - return nil, fmt.Errorf("read sessions: %w", err) - } - return sessions, nil -} diff --git a/old/cmd/lnx/share_cmd.go b/old/cmd/lnx/share_cmd.go deleted file mode 100644 index b8a845e..0000000 --- a/old/cmd/lnx/share_cmd.go +++ /dev/null @@ -1,149 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/spf13/cobra" -) - -var shareCmd = &cobra.Command{ - Use: "share", - Short: "Manage shared directories", -} - -var shareAddCmd = &cobra.Command{ - Use: "add ", - Short: "Share a host directory read-write with the VM", - Long: `Add a host directory to be mounted read-write in the guest via virtiofs. -The directory is mounted at the same absolute path inside the VM. -The share is persisted and restored on every boot of this instance.`, - Args: cobra.ExactArgs(1), - RunE: runShareAdd, -} - -var shareRemoveCmd = &cobra.Command{ - Use: "remove ", - Short: "Stop sharing a directory", - Args: cobra.ExactArgs(1), - RunE: runShareRemove, -} - -var shareListCmd = &cobra.Command{ - Use: "list", - Short: "List shared directories", - Args: cobra.NoArgs, - RunE: runShareList, -} - -func init() { - shareCmd.AddCommand(shareAddCmd) - shareCmd.AddCommand(shareRemoveCmd) - shareCmd.AddCommand(shareListCmd) - rootCmd.AddCommand(shareCmd) -} - -func sharesFile(dir string) string { - return filepath.Join(dir, "shares.json") -} - -func loadShares(dir string) []string { - data, err := os.ReadFile(sharesFile(dir)) - if err != nil { - return nil - } - var shares []string - if err := json.Unmarshal(data, &shares); err != nil { - return nil - } - return shares -} - -func saveShares(dir string, shares []string) error { - data, err := json.MarshalIndent(shares, "", " ") - if err != nil { - return err - } - return os.WriteFile(sharesFile(dir), data, 0644) -} - -func runShareAdd(cmd *cobra.Command, args []string) error { - dir := instanceDir() - - path, err := filepath.Abs(args[0]) - if err != nil { - return fmt.Errorf("resolve path: %w", err) - } - - info, err := os.Stat(path) - if err != nil { - return fmt.Errorf("stat %s: %w", path, err) - } - if !info.IsDir() { - return fmt.Errorf("%s is not a directory", path) - } - - shares := loadShares(dir) - for _, s := range shares { - if s == path { - fmt.Printf("%s is already shared\n", path) - return nil - } - } - - shares = append(shares, path) - if err := saveShares(dir, shares); err != nil { - return fmt.Errorf("save shares: %w", err) - } - - fmt.Printf("shared %s (takes effect on next boot)\n", path) - return nil -} - -func runShareRemove(cmd *cobra.Command, args []string) error { - dir := instanceDir() - - path, err := filepath.Abs(args[0]) - if err != nil { - return fmt.Errorf("resolve path: %w", err) - } - - shares := loadShares(dir) - var filtered []string - found := false - for _, s := range shares { - if s == path { - found = true - } else { - filtered = append(filtered, s) - } - } - - if !found { - return fmt.Errorf("%s is not shared", path) - } - - if err := saveShares(dir, filtered); err != nil { - return fmt.Errorf("save shares: %w", err) - } - - fmt.Printf("removed %s (takes effect on next boot)\n", path) - return nil -} - -func runShareList(cmd *cobra.Command, args []string) error { - dir := instanceDir() - shares := loadShares(dir) - - if len(shares) == 0 { - fmt.Println("no shared directories") - return nil - } - - for _, s := range shares { - fmt.Println(s) - } - return nil -} diff --git a/old/cmd/lnx/sparse_darwin.go b/old/cmd/lnx/sparse_darwin.go deleted file mode 100644 index 8673f48..0000000 --- a/old/cmd/lnx/sparse_darwin.go +++ /dev/null @@ -1,69 +0,0 @@ -//go:build darwin - -package main - -import ( - "os" - "unsafe" - - "golang.org/x/sys/unix" -) - -// fpunchhole is the struct passed to fcntl(F_PUNCHHOLE) on macOS. -type fpunchhole struct { - Flags uint32 // unused - Reserved uint32 // alignment padding - Offset int64 // start of the region - Length int64 // size of the region -} - -// punchHoles scans a file for zero-filled blocks and punches holes in them -// using fcntl(F_PUNCHHOLE). This reclaims physical disk space for regions -// that are all zeros, turning the file into a sparse file on APFS. -func punchHoles(path string, blockSize int) error { - f, err := os.OpenFile(path, os.O_RDWR, 0) - if err != nil { - return err - } - defer f.Close() - - info, err := f.Stat() - if err != nil { - return err - } - size := info.Size() - - buf := make([]byte, blockSize) - var punched int64 - - for off := int64(0); off < size; off += int64(blockSize) { - n, err := f.ReadAt(buf, off) - if n == 0 && err != nil { - break - } - - if isZero(buf[:n]) { - ph := fpunchhole{ - Offset: off, - Length: int64(n), - } - _, _, errno := unix.Syscall(unix.SYS_FCNTL, f.Fd(), unix.F_PUNCHHOLE, uintptr(unsafe.Pointer(&ph))) - if errno != 0 { - // Not all filesystems support punchhole; stop trying. - return nil - } - punched += int64(n) - } - } - - return nil -} - -func isZero(b []byte) bool { - for _, v := range b { - if v != 0 { - return false - } - } - return true -} diff --git a/old/cmd/lnx/sparse_linux.go b/old/cmd/lnx/sparse_linux.go deleted file mode 100644 index 7863569..0000000 --- a/old/cmd/lnx/sparse_linux.go +++ /dev/null @@ -1,52 +0,0 @@ -//go:build linux - -package main - -import ( - "os" - - "golang.org/x/sys/unix" -) - -// punchHoles scans a file for zero-filled blocks and punches holes using -// fallocate(FALLOC_FL_PUNCH_HOLE). This reclaims physical disk space. -func punchHoles(path string, blockSize int) error { - f, err := os.OpenFile(path, os.O_RDWR, 0) - if err != nil { - return err - } - defer f.Close() - - info, err := f.Stat() - if err != nil { - return err - } - size := info.Size() - - buf := make([]byte, blockSize) - - for off := int64(0); off < size; off += int64(blockSize) { - n, err := f.ReadAt(buf, off) - if n == 0 && err != nil { - break - } - - if isZero(buf[:n]) { - err := unix.Fallocate(int(f.Fd()), unix.FALLOC_FL_PUNCH_HOLE|unix.FALLOC_FL_KEEP_SIZE, off, int64(n)) - if err != nil { - return nil // filesystem doesn't support it - } - } - } - - return nil -} - -func isZero(b []byte) bool { - for _, v := range b { - if v != 0 { - return false - } - } - return true -} diff --git a/old/cmd/lnx/ssh_proxy_cmd.go b/old/cmd/lnx/ssh_proxy_cmd.go deleted file mode 100644 index 0fff693..0000000 --- a/old/cmd/lnx/ssh_proxy_cmd.go +++ /dev/null @@ -1,169 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "io" - "net" - "os" - "path/filepath" - "strings" - "time" - - "github.com/spf13/cobra" -) - -var sshProxyCmd = &cobra.Command{ - Use: "_ssh-proxy hostname [port]", - Short: "SSH ProxyCommand helper (internal)", - Hidden: true, - Args: cobra.RangeArgs(1, 2), - RunE: runSSHProxy, -} - -func init() { - rootCmd.AddCommand(sshProxyCmd) -} - -func runSSHProxy(cmd *cobra.Command, args []string) error { - hostname := args[0] - // Strip .lnx suffix to get instance name. - inst := strings.TrimSuffix(hostname, ".lnx") - if inst == hostname { - // No .lnx suffix — use as-is. - inst = hostname - } - instanceName = inst - instanceFlag = true - - if err := ensureVMRunning(); err != nil { - return err - } - - // Connect to the daemon and request the SSH proxy endpoint. - // The guest SSH server may not be listening yet right after boot, - // so retry on 502 (Bad Gateway) for a few seconds. - conn, br, err := dialSSHProxy() - if err != nil { - return err - } - defer conn.Close() - - // The connection is now raw. Splice stdin/stdout with it. - done := make(chan struct{}) - go func() { - io.Copy(conn, os.Stdin) - if tc, ok := conn.(*net.UnixConn); ok { - tc.CloseWrite() - } - close(done) - }() - - // Drain buffered data from the reader, then read directly from conn. - if br.Buffered() > 0 { - io.CopyN(os.Stdout, br, int64(br.Buffered())) - } - io.Copy(os.Stdout, conn) - <-done - - return nil -} - -// dialSSHProxy connects to the daemon's /ssh endpoint, retrying on 502 -// while the guest SSH server finishes starting up. -func dialSSHProxy() (net.Conn, *bufio.Reader, error) { - deadline := time.Now().Add(10 * time.Second) - for { - conn, br, err := tryDialSSHProxy() - if err == nil { - return conn, br, nil - } - if !strings.Contains(err.Error(), "502") || time.Now().After(deadline) { - return nil, nil, err - } - time.Sleep(200 * time.Millisecond) - } -} - -// tryDialSSHProxy makes a single attempt to connect to the daemon's /ssh endpoint. -func tryDialSSHProxy() (net.Conn, *bufio.Reader, error) { - var conn net.Conn - var dialErr error - for _, sp := range statusSockPaths() { - conn, dialErr = net.Dial("unix", sp) - if dialErr == nil { - break - } - } - if conn == nil { - return nil, nil, fmt.Errorf("connect to VM daemon: %w", dialErr) - } - - fmt.Fprintf(conn, "GET /ssh HTTP/1.1\r\nHost: localhost\r\n\r\n") - - br := bufio.NewReader(conn) - statusLine, err := br.ReadString('\n') - if err != nil { - conn.Close() - return nil, nil, fmt.Errorf("read ssh proxy response: %w", err) - } - if !strings.Contains(statusLine, "200") { - conn.Close() - return nil, nil, fmt.Errorf("ssh proxy failed: %s", strings.TrimSpace(statusLine)) - } - - // Skip remaining headers until blank line. - for { - line, err := br.ReadString('\n') - if err != nil { - conn.Close() - return nil, nil, fmt.Errorf("read ssh proxy headers: %w", err) - } - if strings.TrimSpace(line) == "" { - break - } - } - - return conn, br, nil -} - -const sshConfigBlock = `# lnx: ssh into lnx VMs via "ssh .lnx" -Host *.lnx - ProxyCommand lnx _ssh-proxy %h %p - StrictHostKeyChecking no - UserKnownHostsFile /dev/null -# end lnx -` - -// installSSHConfig adds the *.lnx Host block to ~/.ssh/config if not already present. -func installSSHConfig() { - home, err := os.UserHomeDir() - if err != nil { - return - } - sshDir := filepath.Join(home, ".ssh") - configPath := filepath.Join(sshDir, "config") - - // Check if already installed. - if data, err := os.ReadFile(configPath); err == nil { - if strings.Contains(string(data), "*.lnx") { - return - } - } - - os.MkdirAll(sshDir, 0700) - - f, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - fmt.Fprintf(os.Stderr, " ssh config: could not update %s: %v\n", configPath, err) - return - } - defer f.Close() - - // Add a newline before the block if the file doesn't end with one. - if info, _ := f.Stat(); info.Size() > 0 { - f.WriteString("\n") - } - f.WriteString(sshConfigBlock) - fmt.Fprintf(os.Stderr, " ssh config: added *.lnx to %s\n", configPath) -} diff --git a/old/cmd/lnx/status_cmd.go b/old/cmd/lnx/status_cmd.go deleted file mode 100644 index d1de711..0000000 --- a/old/cmd/lnx/status_cmd.go +++ /dev/null @@ -1,156 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "path/filepath" - "strings" - "time" - - "github.com/charmbracelet/lipgloss" - "github.com/semistrict/lnx" - "github.com/spf13/cobra" -) - -var showDmesg bool - -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show status of running VM", - RunE: runStatus, -} - -func init() { - statusCmd.Flags().BoolVar(&showDmesg, "dmesg", false, "include kernel ring buffer") - rootCmd.AddCommand(statusCmd) -} - -func runStatus(cmd *cobra.Command, args []string) error { - // If no explicit --instance, show all running instances. - if !instanceFlag { - return runStatusAll() - } - return runStatusOne(instanceName) -} - -var instanceHeader = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("6")). - BorderStyle(lipgloss.NormalBorder()). - BorderBottom(true). - BorderForeground(lipgloss.Color("8")). - MarginBottom(1) - -func runStatusAll() error { - running := runningInstances() - if len(running) == 0 { - fmt.Println("no VM running") - } else { - for i, name := range running { - if i > 0 { - fmt.Println() - } - if len(running) > 1 { - fmt.Println(instanceHeader.Render(name)) - } - if err := runStatusOne(name); err != nil { - fmt.Printf(" error: %v\n", err) - } - } - } - - return printHostDisk() -} - -func runStatusOne(name string) error { - url := "http://localhost/status" - if showDmesg { - url += "?dmesg=1" - } - - resp, err := apiClientFor(name).Get(url) - if err != nil { - if isNoVM(err) { - fmt.Println("no VM running") - return nil - } - return err - } - defer resp.Body.Close() - - var status lnx.StatusResponse - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return fmt.Errorf("read status: %w", err) - } - - printStatus(&status) - return nil -} - -func printStatus(r *lnx.StatusResponse) { - uptime := time.Duration(r.UptimeSecs * float64(time.Second)) - - kv := func(label, value string) { - fmt.Printf("%s %s\n", labelStyle.Width(10).Align(lipgloss.Right).Render(label), valueStyle.Render(value)) - } - - kv("Command", strings.Join(r.Command, " ")) - kv("User", r.User) - kv("Uptime", uptime.Truncate(time.Second).String()) - - if r.MemTotalKB > 0 { - memUsedMB := float64(r.MemTotalKB-r.MemAvailKB) / 1024 - memTotalMB := float64(r.MemTotalKB) / 1024 - pct := 0.0 - if r.MemTotalKB > 0 { - pct = float64(r.MemTotalKB-r.MemAvailKB) * 100 / float64(r.MemTotalKB) - } - kv("Memory", fmt.Sprintf("%.1f / %.1f MB (%.0f%%)", memUsedMB, memTotalMB, pct)) - } - - if r.SwapTotalKB > 0 { - swapUsedMB := float64(r.SwapTotalKB-r.SwapFreeKB) / 1024 - swapTotalMB := float64(r.SwapTotalKB) / 1024 - pct := float64(r.SwapTotalKB-r.SwapFreeKB) * 100 / float64(r.SwapTotalKB) - kv("Swap", fmt.Sprintf("%.1f / %.1f MB (%.0f%%)", swapUsedMB, swapTotalMB, pct)) - } - - if r.DiskTotalKB > 0 { - diskUsedGB := float64(r.DiskUsedKB) / 1024 / 1024 - diskTotalGB := float64(r.DiskTotalKB) / 1024 / 1024 - pct := float64(r.DiskUsedKB) * 100 / float64(r.DiskTotalKB) - kv("Disk", fmt.Sprintf("%.1f / %.1f GB (%.0f%%)", diskUsedGB, diskTotalGB, pct)) - } - - if r.LoadAvg != "" { - kv("Load", r.LoadAvg) - } - - if r.Dmesg != "" { - fmt.Printf("\n%s\n%s", dimStyle.Render("--- dmesg ---"), r.Dmesg) - } -} - -// printHostDisk shows the total host-side disk usage of lnx images. -// Fails hard if the APFS volume is not configured. -func printHostDisk() error { - if err := checkImagesVolume(); err != nil { - return err - } - - imagesPath := filepath.Join(lnxBase(), "images") - used, containerFree, onVolume := hostDiskUsage(imagesPath) - if !onVolume { - return fmt.Errorf("~/.lnx/images/ is not on a dedicated APFS volume — run 'lnx init'") - } - - kv := func(label, value string) { - fmt.Printf("%s %s\n", labelStyle.Width(10).Align(lipgloss.Right).Render(label), valueStyle.Render(value)) - } - - fmt.Println() - usedGB := float64(used) / 1024 / 1024 / 1024 - freeGB := float64(containerFree) / 1024 / 1024 / 1024 - kv("Host Disk", fmt.Sprintf("%.1f GB used (%.1f GB free)", usedGB, freeGB)) - return nil -} diff --git a/old/cmd/lnx/stop_cmd.go b/old/cmd/lnx/stop_cmd.go deleted file mode 100644 index d045290..0000000 --- a/old/cmd/lnx/stop_cmd.go +++ /dev/null @@ -1,171 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "os" - "syscall" - "time" - - "github.com/spf13/cobra" - "golang.org/x/term" -) - -var stopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the running VM", - RunE: func(cmd *cobra.Command, args []string) error { - if err := requestVMStop(); err != nil { - return err - } - - killReqCh, restoreTTY, err := watchStopKillKey() - if err != nil { - return err - } - if restoreTTY != nil { - defer restoreTTY() - } - - if killReqCh != nil { - fmt.Fprintln(os.Stderr, "VM stopping. Press k to kill.") - } else { - fmt.Fprintln(os.Stderr, "VM stopping.") - } - - forced, err := waitForVMStop(killReqCh) - if err != nil { - return err - } - - if forced { - fmt.Println("VM killed") - return nil - } - fmt.Println("VM stopped") - return nil - }, -} - -func init() { - rootCmd.AddCommand(stopCmd) -} - -func requestVMStop() error { - resp, err := apiClient().Post("http://localhost/stop", "", nil) - if err != nil { - if isNoVM(err) { - fmt.Fprintln(os.Stderr, "no VM running") - os.Exit(1) - } - return fmt.Errorf("connect to VM: %w", err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("stop failed: %s", resp.Status) - } - return nil -} - -func watchStopKillKey() (<-chan struct{}, func(), error) { - if !term.IsTerminal(int(os.Stdin.Fd())) { - return nil, nil, nil - } - - oldState, err := term.MakeRaw(int(os.Stdin.Fd())) - if err != nil { - return nil, nil, fmt.Errorf("set raw terminal: %w", err) - } - - restore := func() { - _ = term.Restore(int(os.Stdin.Fd()), oldState) - } - - ch := make(chan struct{}, 1) - go func() { - var buf [1]byte - for { - n, err := os.Stdin.Read(buf[:]) - if err != nil || n == 0 { - return - } - if buf[0] == 'k' || buf[0] == 'K' { - select { - case ch <- struct{}{}: - default: - } - return - } - } - }() - - return ch, restore, nil -} - -func waitForVMStop(killReqCh <-chan struct{}) (bool, error) { - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - forced := false - for { - if !vmIsRunning() { - return forced, nil - } - - select { - case <-ticker.C: - case <-killReqCh: - forced = true - if err := forceKillDaemon(); err != nil { - return true, err - } - } - } -} - -func forceKillDaemon() error { - pid := readDaemonPID() - if pid <= 0 { - return fmt.Errorf("cannot find daemon pid for instance %q", instanceName) - } - - if err := syscall.Kill(pid, syscall.SIGQUIT); err != nil && err != syscall.ESRCH { - return fmt.Errorf("send SIGQUIT to daemon %d: %w", pid, err) - } - - deadline := time.Now().Add(2 * time.Second) - for processExists(pid) && time.Now().Before(deadline) { - time.Sleep(100 * time.Millisecond) - } - if !processExists(pid) { - return nil - } - - _ = syscall.Kill(-pid, syscall.SIGKILL) - _ = syscall.Kill(pid, syscall.SIGKILL) - - deadline = time.Now().Add(3 * time.Second) - for processExists(pid) && time.Now().Before(deadline) { - time.Sleep(100 * time.Millisecond) - } - if processExists(pid) { - return fmt.Errorf("daemon %d did not exit after SIGKILL", pid) - } - return nil -} - -func readDaemonPID() int { - return readPIDFile(resolveRootfsPath() + ".pid") -} - -func readPIDFile(path string) int { - data, err := os.ReadFile(path) - if err != nil { - return 0 - } - var pid int - if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil { - return 0 - } - return pid -} diff --git a/old/cmd/lnx/sync_cmd.go b/old/cmd/lnx/sync_cmd.go deleted file mode 100644 index aa49b8e..0000000 --- a/old/cmd/lnx/sync_cmd.go +++ /dev/null @@ -1,151 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/spf13/cobra" -) - -var syncCmd = &cobra.Command{ - Use: "sync", - Short: "Manage lazily-cached sync shares", -} - -var syncAddCmd = &cobra.Command{ - Use: "add ", - Short: "Add a host directory as a lazily-cached sync share", - Long: `Add a host directory to be shared with lazy caching. -The directory is mounted read-only via virtiofs and lazily copied into the -guest's ext4 rootfs so files are served at native ext4 speed after first access. -The mount appears at the same absolute path inside the VM. -The share is persisted and restored on every boot of this instance.`, - Args: cobra.ExactArgs(1), - RunE: runSyncAdd, -} - -var syncRemoveCmd = &cobra.Command{ - Use: "remove ", - Short: "Remove a sync share", - Args: cobra.ExactArgs(1), - RunE: runSyncRemove, -} - -var syncListCmd = &cobra.Command{ - Use: "list", - Short: "List sync shares", - Args: cobra.NoArgs, - RunE: runSyncList, -} - -func init() { - syncCmd.AddCommand(syncAddCmd) - syncCmd.AddCommand(syncRemoveCmd) - syncCmd.AddCommand(syncListCmd) - rootCmd.AddCommand(syncCmd) -} - -func syncSharesFile(dir string) string { - return filepath.Join(dir, "sync-shares.json") -} - -func loadSyncShares(dir string) []string { - data, err := os.ReadFile(syncSharesFile(dir)) - if err != nil { - return nil - } - var shares []string - if err := json.Unmarshal(data, &shares); err != nil { - return nil - } - return shares -} - -func saveSyncShares(dir string, shares []string) error { - data, err := json.MarshalIndent(shares, "", " ") - if err != nil { - return err - } - return os.WriteFile(syncSharesFile(dir), data, 0644) -} - -func runSyncAdd(cmd *cobra.Command, args []string) error { - dir := instanceDir() - - path, err := filepath.Abs(args[0]) - if err != nil { - return fmt.Errorf("resolve path: %w", err) - } - - info, err := os.Stat(path) - if err != nil { - return fmt.Errorf("stat %s: %w", path, err) - } - if !info.IsDir() { - return fmt.Errorf("%s is not a directory", path) - } - - shares := loadSyncShares(dir) - for _, s := range shares { - if s == path { - fmt.Printf("%s is already a sync share\n", path) - return nil - } - } - - shares = append(shares, path) - if err := saveSyncShares(dir, shares); err != nil { - return fmt.Errorf("save sync shares: %w", err) - } - - fmt.Printf("added sync share %s (takes effect on next boot)\n", path) - return nil -} - -func runSyncRemove(cmd *cobra.Command, args []string) error { - dir := instanceDir() - - path, err := filepath.Abs(args[0]) - if err != nil { - return fmt.Errorf("resolve path: %w", err) - } - - shares := loadSyncShares(dir) - var filtered []string - found := false - for _, s := range shares { - if s == path { - found = true - } else { - filtered = append(filtered, s) - } - } - - if !found { - return fmt.Errorf("%s is not a sync share", path) - } - - if err := saveSyncShares(dir, filtered); err != nil { - return fmt.Errorf("save sync shares: %w", err) - } - - fmt.Printf("removed sync share %s (takes effect on next boot)\n", path) - return nil -} - -func runSyncList(cmd *cobra.Command, args []string) error { - dir := instanceDir() - shares := loadSyncShares(dir) - - if len(shares) == 0 { - fmt.Println("no sync shares") - return nil - } - - for _, s := range shares { - fmt.Println(s) - } - return nil -} diff --git a/old/cmd/lnx/ui.go b/old/cmd/lnx/ui.go deleted file mode 100644 index 0d8ea47..0000000 --- a/old/cmd/lnx/ui.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import ( - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" -) - -var ( - headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - cyanStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) - yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) - labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - valueStyle = lipgloss.NewStyle().Bold(true) -) - -func newTable(headers ...string) *table.Table { - return table.New(). - Headers(headers...). - Border(lipgloss.NormalBorder()). - BorderTop(false). - BorderBottom(false). - BorderLeft(false). - BorderRight(false). - BorderColumn(false). - BorderHeader(true). - StyleFunc(func(row, col int) lipgloss.Style { - if row == table.HeaderRow { - return headerStyle - } - return lipgloss.NewStyle().PaddingRight(2) - }) -} diff --git a/old/cmd/lnx/volume_darwin.go b/old/cmd/lnx/volume_darwin.go deleted file mode 100644 index 4ea8943..0000000 --- a/old/cmd/lnx/volume_darwin.go +++ /dev/null @@ -1,260 +0,0 @@ -//go:build darwin - -package main - -import ( - "encoding/xml" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "syscall" -) - -const volumeName = "lnx" - -// volumeInfo holds parsed diskutil info for an APFS volume. -type volumeInfo struct { - VolumeName string - MountPoint string - FilesystemType string - CapacityInUse uint64 // physical bytes consumed by this volume - ContainerSize uint64 // total APFS container size - ContainerFree uint64 // free space in APFS container - ContainerRef string // e.g. "disk3" - DeviceIdentifier string // e.g. "disk3s7" -} - -// getVolumeInfo runs `diskutil info -plist ` and parses the result. -func getVolumeInfo(mountPoint string) (*volumeInfo, error) { - out, err := exec.Command("diskutil", "info", "-plist", mountPoint).Output() - if err != nil { - return nil, fmt.Errorf("diskutil info %s: %w", mountPoint, err) - } - - kv := parsePlistTopLevel(out) - if kv["Error"] == "true" { - return nil, fmt.Errorf("diskutil: %s", kv["ErrorMessage"]) - } - - info := &volumeInfo{ - VolumeName: kv["VolumeName"], - MountPoint: kv["MountPoint"], - FilesystemType: kv["FilesystemType"], - ContainerRef: kv["APFSContainerReference"], - DeviceIdentifier: kv["DeviceIdentifier"], - } - info.CapacityInUse, _ = strconv.ParseUint(kv["CapacityInUse"], 10, 64) - info.ContainerSize, _ = strconv.ParseUint(kv["APFSContainerSize"], 10, 64) - info.ContainerFree, _ = strconv.ParseUint(kv["APFSContainerFree"], 10, 64) - return info, nil -} - -// mountPointFor returns the mount point for the filesystem containing path. -func mountPointFor(path string) (string, error) { - var stat syscall.Statfs_t - if err := syscall.Statfs(path, &stat); err != nil { - return "", fmt.Errorf("statfs %s: %w", path, err) - } - mnt := make([]byte, 0, len(stat.Mntonname)) - for _, b := range stat.Mntonname { - if b == 0 { - break - } - mnt = append(mnt, byte(b)) - } - return string(mnt), nil -} - -// hostDiskUsage returns the physical disk usage of the lnx images directory. -// If images/ is on a dedicated APFS volume (named "lnx"), returns the volume's -// CapacityInUse (accurate physical usage). Otherwise returns 0 to indicate -// no dedicated volume is configured. -func hostDiskUsage(imagesPath string) (used uint64, containerFree uint64, onVolume bool) { - resolved, err := filepath.EvalSymlinks(imagesPath) - if err != nil { - return 0, 0, false - } - mnt, err := mountPointFor(resolved) - if err != nil { - return 0, 0, false - } - info, err := getVolumeInfo(mnt) - if err != nil { - return 0, 0, false - } - if info.FilesystemType != "apfs" { - return 0, 0, false - } - if info.VolumeName != volumeName { - return 0, 0, false - } - return info.CapacityInUse, info.ContainerFree, true -} - -// ensureImagesDir creates the images/ directory on a dedicated APFS volume. -// Fails if volume creation fails (e.g., no admin privileges). -func ensureImagesDir(base string) error { - imagesPath := filepath.Join(base, "images") - - // If images/ already exists (regular dir or symlink), nothing to do. - if _, err := os.Lstat(imagesPath); err == nil { - return nil - } - - return createAPFSVolume(base) -} - -// checkImagesVolume verifies that ~/.lnx/images/ exists and is on a -// dedicated APFS volume. Fails hard with instructions if not. -func checkImagesVolume() error { - imagesPath := filepath.Join(lnxBase(), "images") - if _, err := os.Stat(imagesPath); os.IsNotExist(err) { - return fmt.Errorf("~/.lnx/images/ does not exist — run 'lnx init' to create the APFS volume") - } - - resolved, err := filepath.EvalSymlinks(imagesPath) - if err != nil { - return fmt.Errorf("resolve ~/.lnx/images/: %w", err) - } - mnt, err := mountPointFor(resolved) - if err != nil { - return fmt.Errorf("stat ~/.lnx/images/: %w", err) - } - info, err := getVolumeInfo(mnt) - if err != nil { - return fmt.Errorf("get volume info for %s: %w", mnt, err) - } - if info.VolumeName != volumeName { - return fmt.Errorf("~/.lnx/images/ is not on a dedicated APFS volume (found %q on %q).\n"+ - "Run 'lnx init' to create the %q volume for accurate disk usage tracking.", - resolved, info.VolumeName, volumeName) - } - return nil -} - -// createAPFSVolume creates an APFS volume named "lnx" on the same container -// as the base directory and symlinks base/images to the volume mount point. -func createAPFSVolume(base string) error { - // Find the APFS container for the volume containing base. - mnt, err := mountPointFor(base) - if err != nil { - return err - } - info, err := getVolumeInfo(mnt) - if err != nil { - return err - } - if info.FilesystemType != "apfs" || info.ContainerRef == "" { - return fmt.Errorf("filesystem at %s is not APFS", base) - } - - // Check if the volume already exists (mounted at /Volumes/lnx). - volumeMountPoint := filepath.Join("/Volumes", volumeName) - if vi, err := getVolumeInfo(volumeMountPoint); err == nil && vi.VolumeName == volumeName { - // Volume exists, just create the symlink. - return os.Symlink(volumeMountPoint, filepath.Join(base, "images")) - } - - fmt.Fprintf(os.Stderr, " creating APFS volume %q on %s...\n", volumeName, info.ContainerRef) - cmd := exec.Command("diskutil", "apfs", "addVolume", info.ContainerRef, "APFS", volumeName) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("diskutil apfs addVolume: %w", err) - } - - // Wait for the volume to appear. - if _, err := os.Stat(volumeMountPoint); err != nil { - return fmt.Errorf("volume created but not mounted at %s", volumeMountPoint) - } - - // Symlink images/ → /Volumes/lnx. - return os.Symlink(volumeMountPoint, filepath.Join(base, "images")) -} - -// parsePlistTopLevel extracts top-level key-value pairs from a plist XML. -// Only handles , , , and values. -// Skips nested structures (arrays, dicts). -func parsePlistTopLevel(data []byte) map[string]string { - result := make(map[string]string) - decoder := xml.NewDecoder(strings.NewReader(string(data))) - - // Find the top-level . - depth := 0 - inTopDict := false - var currentKey string - readingKey := false - readingValue := false - var valueTag string - - for { - tok, err := decoder.Token() - if err != nil { - break - } - switch t := tok.(type) { - case xml.StartElement: - switch t.Name.Local { - case "dict": - depth++ - if depth == 1 { - inTopDict = true - } - case "array": - depth++ - case "key": - if inTopDict && depth == 1 { - readingKey = true - currentKey = "" - } - case "string", "integer": - if inTopDict && depth == 1 && currentKey != "" { - readingValue = true - valueTag = t.Name.Local - } - } - case xml.EndElement: - switch t.Name.Local { - case "dict": - depth-- - if depth == 0 { - inTopDict = false - } - case "array": - depth-- - case "key": - readingKey = false - case "string", "integer": - readingValue = false - valueTag = "" - } - // Handle and as self-closing. - case xml.CharData: - s := strings.TrimSpace(string(t)) - if readingKey { - currentKey += s - } else if readingValue && valueTag != "" { - result[currentKey] = s - currentKey = "" - } - } - - // Handle self-closing elements like and . - if se, ok := tok.(xml.StartElement); ok { - if inTopDict && depth == 1 && currentKey != "" { - switch se.Name.Local { - case "true": - result[currentKey] = "true" - currentKey = "" - case "false": - result[currentKey] = "false" - currentKey = "" - } - } - } - } - return result -} diff --git a/old/cmd/lnx/volume_darwin_test.go b/old/cmd/lnx/volume_darwin_test.go deleted file mode 100644 index 2c083a0..0000000 --- a/old/cmd/lnx/volume_darwin_test.go +++ /dev/null @@ -1,70 +0,0 @@ -//go:build darwin - -package main - -import "testing" - -func TestParsePlistTopLevel(t *testing.T) { - plist := ` - - - - VolumeName - lnx - CapacityInUse - 4294967296 - APFSContainerSize - 1995165736960 - APFSContainerFree - 1063395438592 - APFSContainerReference - disk3 - FilesystemType - apfs - MountPoint - /Volumes/lnx - DeviceIdentifier - disk3s7 - Encryption - - Removable - - APFSPhysicalStores - - - APFSPhysicalStore - disk0s2 - - - -` - - kv := parsePlistTopLevel([]byte(plist)) - - tests := []struct { - key string - want string - }{ - {"VolumeName", "lnx"}, - {"CapacityInUse", "4294967296"}, - {"APFSContainerSize", "1995165736960"}, - {"APFSContainerFree", "1063395438592"}, - {"APFSContainerReference", "disk3"}, - {"FilesystemType", "apfs"}, - {"MountPoint", "/Volumes/lnx"}, - {"DeviceIdentifier", "disk3s7"}, - {"Encryption", "true"}, - {"Removable", "false"}, - } - for _, tt := range tests { - got := kv[tt.key] - if got != tt.want { - t.Errorf("key %q = %q, want %q", tt.key, got, tt.want) - } - } - - // Nested dict keys should NOT appear at top level. - if _, ok := kv["APFSPhysicalStore"]; ok { - t.Error("nested key APFSPhysicalStore should not appear at top level") - } -} diff --git a/old/cmd/lnx/volume_linux.go b/old/cmd/lnx/volume_linux.go deleted file mode 100644 index 00535f1..0000000 --- a/old/cmd/lnx/volume_linux.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build linux - -package main - -import "os" - -// hostDiskUsage is not supported on Linux (no APFS). -func hostDiskUsage(imagesPath string) (used uint64, containerFree uint64, onVolume bool) { - return 0, 0, false -} - -// ensureImagesDir creates the images/ directory as a regular directory on Linux. -func ensureImagesDir(base string) error { - return os.MkdirAll(base+"/images", 0755) -} - -// checkImagesVolume is a no-op on Linux (no APFS volumes). -func checkImagesVolume() error { - return nil -} diff --git a/old/config.go b/old/config.go deleted file mode 100644 index 7cd8521..0000000 --- a/old/config.go +++ /dev/null @@ -1,103 +0,0 @@ -package lnx - -import "path/filepath" - -// Config holds the configuration for a VM instance. -type Config struct { - // KernelPath is the path to the Linux kernel Image. - KernelPath string - - // RootfsPath is the path to the ext4 rootfs image. - RootfsPath string - - // InitramfsPath is where the generated initramfs cpio will be written. - // If empty, defaults to the same directory as KernelPath. - InitramfsPath string - - // CPUs is the number of virtual CPUs. Defaults to 2. - CPUs uint - - // MemoryBytes is the amount of RAM in bytes. - // Defaults to 50% of host physical memory. - MemoryBytes uint64 - - // CWD is the host directory to mount inside the VM - // at the same path. Defaults to os.Getwd(). - CWD string - - // Env is a list of extra KEY=VALUE environment variables to set - // in the guest at boot. - Env []string - - // Checkpoint clones the rootfs before starting the VM. - // The clone is stored under CheckpointDir with a timestamped name. - // Requires APFS (macOS). - Checkpoint bool - - // CheckpointDir is where checkpoint clones are stored. - // Defaults to ~/.lnx/checkpoints/. - CheckpointDir string - - // Shares is a list of extra host directories to share. - // Each path is mounted in the guest at the same absolute path. - Shares []string - - // Hostname is the guest hostname. Defaults to "lnx". - Hostname string - - // SSHAgent forwards the host's SSH agent into the guest. - // Requires SSH_AUTH_SOCK to be set on the host. - SSHAgent bool - - // Ephemeral clones the rootfs to a temp file via APFS clonefile - // before booting. The clone is deleted on exit. The original rootfs - // is never locked, so multiple ephemeral VMs can run concurrently. - Ephemeral bool - - // SocketDir overrides the directory for status.sock. - // If empty, defaults to the directory containing RootfsPath. - // Useful for ephemeral mode where the rootfs is in a temp dir but - // the socket must be in the instance dir for clients to find it. - SocketDir string - - // NestedRootfs is a list of rootfs file paths for nested VM instances. - // Each is attached as an additional virtio-blk device (vdc, vdd, ...). - NestedRootfs []NestedRootfs - - // SyncShares is a list of host directories to share with lazy caching. - // Each is lazily copied into the guest's ext4 rootfs for native-speed - // access after first read. - SyncShares []string - - // DirectShare bypasses the FUSE lazy-cache for CWD and extra shares, - // mounting 9P directly (read-write). When false (default), all shares - // go through the lazy-cache FUSE for near-native read performance. - DirectShare bool -} - -// NestedRootfs pairs a nested instance name with its rootfs file path. -type NestedRootfs struct { - InstanceName string // e.g., "default.default" - RootfsPath string // host path to the rootfs file -} - -func (c *Config) socketDir() string { - if c.SocketDir != "" { - return c.SocketDir - } - return filepath.Dir(c.RootfsPath) -} - -func (c *Config) cpus() uint { - if c.CPUs == 0 { - return 2 - } - return c.CPUs -} - -func (c *Config) memoryBytes() uint64 { - if c.MemoryBytes == 0 { - return hostMemoryBytes() / 2 - } - return c.MemoryBytes -} diff --git a/old/config_darwin.go b/old/config_darwin.go deleted file mode 100644 index 05026db..0000000 --- a/old/config_darwin.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build darwin - -package lnx - -import ( - "encoding/binary" - "syscall" -) - -func hostMemoryBytes() uint64 { - val, err := syscall.Sysctl("hw.memsize") - if err != nil || len(val) < 8 { - return 4 << 30 // fallback: 4 GiB - } - return binary.LittleEndian.Uint64([]byte(val[:8])) -} diff --git a/old/config_linux.go b/old/config_linux.go deleted file mode 100644 index cfcda37..0000000 --- a/old/config_linux.go +++ /dev/null @@ -1,28 +0,0 @@ -//go:build linux - -package lnx - -import ( - "os" - "strconv" - "strings" -) - -func hostMemoryBytes() uint64 { - data, err := os.ReadFile("/proc/meminfo") - if err != nil { - return 4 << 30 // fallback: 4 GiB - } - for _, line := range strings.Split(string(data), "\n") { - if strings.HasPrefix(line, "MemTotal:") { - fields := strings.Fields(line) - if len(fields) >= 2 { - kb, err := strconv.ParseUint(fields[1], 10, 64) - if err == nil { - return kb * 1024 - } - } - } - } - return 4 << 30 // fallback: 4 GiB -} diff --git a/old/control_test.go b/old/control_test.go deleted file mode 100644 index 4a539cf..0000000 --- a/old/control_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package lnx - -import ( - "encoding/gob" - "net" - "testing" - - "github.com/semistrict/lnx/internal/protocol" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestControlProtocol_SetupDelivered(t *testing.T) { - hostConn, guestConn := net.Pipe() - defer hostConn.Close() - defer guestConn.Close() - - setup := &protocol.Setup{ - CWD: "/tmp", - User: "ramon", - UID: 501, - HomeDir: "/Users/ramon", - Env: []string{"FOO=bar"}, - Hostname: "default.lnx", - SSHAgent: true, - DirectShare: true, - NestedDrives: []protocol.NestedDrive{ - {InstanceName: "default.default", DevicePath: "/dev/vdc"}, - }, - SyncShares: []string{"/sync"}, - } - - go func() { - enc := gob.NewEncoder(hostConn) - enc.Encode(protocol.Msg{Setup: setup}) - }() - - dec := gob.NewDecoder(guestConn) - var msg protocol.Msg - require.NoError(t, dec.Decode(&msg)) - require.NotNil(t, msg.Setup) - assert.Equal(t, "/tmp", msg.Setup.CWD) - assert.Equal(t, "ramon", msg.Setup.User) - assert.Equal(t, 501, msg.Setup.UID) - assert.Equal(t, "/Users/ramon", msg.Setup.HomeDir) - assert.Equal(t, []string{"FOO=bar"}, msg.Setup.Env) - assert.Equal(t, "default.lnx", msg.Setup.Hostname) - assert.True(t, msg.Setup.SSHAgent) - assert.True(t, msg.Setup.DirectShare) - require.Len(t, msg.Setup.NestedDrives, 1) - assert.Equal(t, "default.default", msg.Setup.NestedDrives[0].InstanceName) - assert.Equal(t, "/dev/vdc", msg.Setup.NestedDrives[0].DevicePath) - require.Len(t, msg.Setup.SyncShares, 1) - assert.Equal(t, "/sync", msg.Setup.SyncShares[0]) -} - -func TestControlProtocol_SignalDelivered(t *testing.T) { - hostConn, guestConn := net.Pipe() - defer hostConn.Close() - defer guestConn.Close() - - go func() { - enc := gob.NewEncoder(hostConn) - enc.Encode(protocol.Msg{Signal: &protocol.Signal{Sig: 2}}) - }() - - dec := gob.NewDecoder(guestConn) - var msg protocol.Msg - require.NoError(t, dec.Decode(&msg)) - require.NotNil(t, msg.Signal) - assert.Equal(t, 2, msg.Signal.Sig) -} - -func TestControlProtocol_ResizeDelivered(t *testing.T) { - hostConn, guestConn := net.Pipe() - defer hostConn.Close() - defer guestConn.Close() - - go func() { - enc := gob.NewEncoder(hostConn) - enc.Encode(protocol.Msg{Resize: &protocol.Resize{Rows: 24, Cols: 80}}) - }() - - dec := gob.NewDecoder(guestConn) - var msg protocol.Msg - require.NoError(t, dec.Decode(&msg)) - require.NotNil(t, msg.Resize) - assert.Equal(t, uint16(24), msg.Resize.Rows) - assert.Equal(t, uint16(80), msg.Resize.Cols) -} - -func TestControlProtocol_InstanceNameRoundTrip(t *testing.T) { - hostConn, guestConn := net.Pipe() - defer hostConn.Close() - defer guestConn.Close() - - // Simulate host handleGuestCtrl: read request, write response. - go func() { - dec := gob.NewDecoder(hostConn) - enc := gob.NewEncoder(hostConn) - var msg protocol.Msg - if err := dec.Decode(&msg); err != nil { - return - } - if msg.InstanceNameReq != nil { - enc.Encode(protocol.Msg{InstanceNameResp: &protocol.InstanceNameResp{Name: "test-instance"}}) - } - }() - - // Guest side: send request, read response. - enc := gob.NewEncoder(guestConn) - dec := gob.NewDecoder(guestConn) - require.NoError(t, enc.Encode(protocol.Msg{InstanceNameReq: &protocol.InstanceNameReq{}})) - - var resp protocol.Msg - require.NoError(t, dec.Decode(&resp)) - require.NotNil(t, resp.InstanceNameResp) - assert.Equal(t, "test-instance", resp.InstanceNameResp.Name) -} - -func TestControlProtocol_ForkRespRole(t *testing.T) { - hostConn, guestConn := net.Pipe() - defer hostConn.Close() - defer guestConn.Close() - - go func() { - enc := gob.NewEncoder(hostConn) - enc.Encode(protocol.Msg{ForkResp: &protocol.ForkResp{ - Instance: "child-fork-001", - Role: "parent", - }}) - }() - - dec := gob.NewDecoder(guestConn) - var msg protocol.Msg - require.NoError(t, dec.Decode(&msg)) - require.NotNil(t, msg.ForkResp) - assert.Equal(t, "child-fork-001", msg.ForkResp.Instance) - assert.Equal(t, "parent", msg.ForkResp.Role) -} diff --git a/old/criu_intg_test.go b/old/criu_intg_test.go deleted file mode 100644 index b264e81..0000000 --- a/old/criu_intg_test.go +++ /dev/null @@ -1,479 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func skipIfQemu(t *testing.T) { - t.Helper() - if parseQemuBackendForTest() != "" { - t.Skip("CRIU tests not supported with QEMU backend") - } -} - -func TestCRIU_CheckpointAndRestore(t *testing.T) { - skipIfQemu(t) - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := "test-criu-checkpoint" - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - home, _ := os.UserHomeDir() - instDir := filepath.Join(home, ".lnx", "instances", inst) - - // Boot VM with a keeper. - cmd, stderr, done := startTimedInstance(t, bin, inst, 120*time.Second) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - // Write python server script, then start it in background. - runCLISuccess(t, bin, "--instance", inst, "sh", "-c", - `cat > /tmp/server.py << 'PYEOF' -import os, resource -for fd in range(3, min(resource.getrlimit(resource.RLIMIT_NOFILE)[0], 1024)): - try: os.close(fd) - except: pass -os.setsid() -from http.server import HTTPServer, BaseHTTPRequestHandler -class H(BaseHTTPRequestHandler): - state = {"counter": 42} - def do_GET(self): - self.send_response(200); self.end_headers() - self.wfile.write(f'{H.state["counter"]}\n'.encode()) - def do_POST(self): - H.state["counter"] += 1 - self.send_response(200); self.end_headers() - self.wfile.write(f'{H.state["counter"]}\n'.encode()) - def log_message(self, *a): pass -HTTPServer(("0.0.0.0", 8888), H).serve_forever() -PYEOF -python3 -u /tmp/server.py /dev/null 2>&1 &`) - - // Wait for server. - require.Eventually(t, func() bool { - out, err := runCLI(bin, "--instance", inst, "curl", "-sf", "http://127.0.0.1:8888/") - return err == nil && strings.TrimSpace(out) == "42" - }, 30*time.Second, time.Second, "HTTP server never became ready") - - // POST to increment counter to 43. - out := runCLISuccess(t, bin, "--instance", inst, - "curl", "-sf", "-X", "POST", "http://127.0.0.1:8888/") - assert.Equal(t, "43\n", out) - - // CRIU checkpoint. - cpOut := runCLISuccess(t, bin, "--instance", inst, "checkpoints", "create", "--criu", "snap1") - assert.Contains(t, cpOut, "snap1") - - // Verify checkpoint dir has both files. - _, err := os.Stat(filepath.Join(instDir, "checkpoints", "snap1", "rootfs.ext4")) - require.NoError(t, err) - _, err = os.Stat(filepath.Join(instDir, "checkpoints", "snap1", "criu.ext4")) - require.NoError(t, err) - - // Mutate state to 44. - out = runCLISuccess(t, bin, "--instance", inst, - "curl", "-sf", "-X", "POST", "http://127.0.0.1:8888/") - assert.Equal(t, "44\n", out) - - // Kill the keeper and stop the VM so we can restore. - cmd.Process.Kill() - <-done - runCLI(bin, "--instance", inst, "stop") - - restoreOut := runCLISuccess(t, bin, "--instance", inst, "checkpoints", "restore", "snap1") - assert.Contains(t, restoreOut, "restored CRIU checkpoint") - - // Boot — CRIU auto-restores. Counter should be 43 (checkpoint value). - require.Eventually(t, func() bool { - out, err := runCLI(bin, "--instance", inst, "curl", "-sf", "http://127.0.0.1:8888/") - return err == nil && strings.TrimSpace(out) == "43" - }, 30*time.Second, time.Second, "restored server never became ready with correct state") -} - -func TestCRIU_Fork(t *testing.T) { - skipIfQemu(t) - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := "test-criu-fork" - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - cmd, stderr, done := startTimedInstance(t, bin, inst, 60*time.Second) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - // Start HTTP server. - runCLISuccess(t, bin, "--instance", inst, "sh", "-c", - `cat > /tmp/server.py << 'PYEOF' -import os, resource -for fd in range(3, min(resource.getrlimit(resource.RLIMIT_NOFILE)[0], 1024)): - try: os.close(fd) - except: pass -os.setsid() -from http.server import HTTPServer, BaseHTTPRequestHandler -class H(BaseHTTPRequestHandler): - state = {"value": "original"} - def do_GET(self): - self.send_response(200); self.end_headers() - self.wfile.write(f'{H.state["value"]}\n'.encode()) - def log_message(self, *a): pass -HTTPServer(("0.0.0.0", 8888), H).serve_forever() -PYEOF -python3 -u /tmp/server.py /dev/null 2>&1 &`) - - require.Eventually(t, func() bool { - out, err := runCLI(bin, "--instance", inst, "curl", "-sf", "http://127.0.0.1:8888/") - return err == nil && strings.TrimSpace(out) == "original" - }, 30*time.Second, time.Second, "HTTP server never became ready") - - // Fork. - forkOut := runCLISuccess(t, bin, "--instance", inst, "fork") - assert.Contains(t, forkOut, "forked to") - childInst := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(forkOut), "forked to")) - require.NotEmpty(t, childInst) - registerInstanceStopCleanup(t, bin, childInst) - - // Child should have the same state. - require.Eventually(t, func() bool { - out, err := runCLI(bin, "--instance", childInst, "curl", "-sf", "http://127.0.0.1:8888/") - return err == nil && strings.TrimSpace(out) == "original" - }, 30*time.Second, time.Second, "child server never became ready") - - // Parent still works. - parentOut := runCLISuccess(t, bin, "--instance", inst, - "curl", "-sf", "http://127.0.0.1:8888/") - assert.Equal(t, "original\n", parentOut) - - // Child knows its role. - roleOut := runCLISuccess(t, bin, "--instance", childInst, "cat", "/var/run/lnx/fork-role") - assert.Equal(t, "child\n", roleOut) -} - -func TestCRIU_ForkPipe(t *testing.T) { - skipIfQemu(t) - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := "test-criu-fork-pipe" - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - cmd, stderr, done := startTimedInstance(t, bin, inst, 60*time.Second) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - out := runCLISuccess(t, bin, "--instance", inst, "python3", "-u", "-c", ` -import os -os.write(3, b"fork\n") -result = b"" -while True: - chunk = os.read(4, 4096) - if not chunk: - break - result += chunk - if b"\n" in result: - break -text = result.decode().strip() -if text.startswith("error:"): - print("FORK_ERROR: " + text) -else: - print("FORK_OK: " + text) -`) - if strings.Contains(out, "FORK_OK:") { - childInst := strings.TrimSpace(strings.TrimPrefix( - strings.TrimSpace(out), "FORK_OK:")) - registerInstanceStopCleanup(t, bin, childInst) - assert.Contains(t, childInst, inst+"-fork-") - } else { - t.Logf("fork pipe result: %s", strings.TrimSpace(out)) - t.Skip("CRIU fork via pipe not supported in this environment") - } -} - -func TestCRIU_CheckpointList(t *testing.T) { - skipIfQemu(t) - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := "test-criu-list" - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - runCLISuccess(t, bin, "--instance", inst, "checkpoints", "create", "disk-snap") - - out := runCLISuccess(t, bin, "--instance", inst, "checkpoints", "list") - assert.Contains(t, out, "disk-snap") - assert.Contains(t, out, "disk") -} - -func TestCRIU_RestoreRequiresStoppedVM(t *testing.T) { - skipIfQemu(t) - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := "test-criu-restore-stopped" - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - cmd, stderr, done := startTimedInstance(t, bin, inst, 10*time.Second) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - runCLISuccess(t, bin, "--instance", inst, "checkpoints", "create", "snap") - - out, err := runCLI(bin, "--instance", inst, "checkpoints", "restore", "snap") - require.Error(t, err) - assert.Contains(t, out, "stop the VM") -} - -// TestCRIU_DiningPhilosophers verifies that a complex web of processes -// connected by pipes, Unix domain sockets, and TCP all survive CRIU -// checkpoint/restore with their IPC channels intact. -// -// Topology — a ring of 4 processes: -// -// coordinator →[pipe]→ worker0 →[UDS]→ worker1 →[TCP]→ worker2 →[pipe]→ coordinator -// -// Each "tick" sends a message around the ring, and every worker increments -// its counter. After checkpoint and restore, counters roll back and the -// ring continues to function. -func TestCRIU_DiningPhilosophers(t *testing.T) { - skipIfQemu(t) - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := "test-criu-dining" - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - cmd, stderr, done := startTimedInstance(t, bin, inst, 120*time.Second) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - // Deploy the dining philosophers ring script. - runCLISuccess(t, bin, "--instance", inst, "sh", "-c", - `cat > /tmp/dining.py << 'PYEOF' -import os, socket, json, resource, signal -from http.server import HTTPServer, BaseHTTPRequestHandler - -# Close inherited fds to avoid CRIU issues with leaked vsock fds. -for fd in range(3, min(resource.getrlimit(resource.RLIMIT_NOFILE)[0], 1024)): - try: os.close(fd) - except: pass -os.setsid() -signal.signal(signal.SIGCHLD, signal.SIG_IGN) - -# === IPC channels === -# Ring: coordinator ->[pipe]-> w0 ->[UDS]-> w1 ->[TCP]-> w2 ->[pipe]-> coordinator - -c2w0_r, c2w0_w = os.pipe() # coordinator -> w0 -w2c_r, w2c_w = os.pipe() # w2 -> coordinator -uds_w0, uds_w1 = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) -tcp_srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -tcp_srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -tcp_srv.bind(("127.0.0.1", 8901)) -tcp_srv.listen(1) - -# === Worker 0: pipe-in, UDS-out === -if os.fork() == 0: - os.close(c2w0_w); os.close(w2c_r); os.close(w2c_w) - uds_w1.close(); tcp_srv.close() - count = 0 - buf = b"" - while True: - chunk = os.read(c2w0_r, 4096) - if not chunk: os._exit(0) - buf += chunk - while b"\n" in buf: - line, buf = buf.split(b"\n", 1) - cmd, _, payload = line.decode().partition(" ") - if cmd == "TICK": count += 1 - state = (payload + "," if payload else "") + f"w0={count}" - uds_w0.sendall(f"{cmd} {state}\n".encode()) - -# === Worker 1: UDS-in, TCP-out === -if os.fork() == 0: - os.close(c2w0_r); os.close(c2w0_w) - os.close(w2c_r); os.close(w2c_w) - uds_w0.close() - tcp_conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - tcp_conn.connect(("127.0.0.1", 8901)) - tcp_srv.close() - count = 0 - buf = b"" - while True: - chunk = uds_w1.recv(4096) - if not chunk: os._exit(0) - buf += chunk - while b"\n" in buf: - line, buf = buf.split(b"\n", 1) - cmd, _, payload = line.decode().partition(" ") - if cmd == "TICK": count += 1 - state = (payload + "," if payload else "") + f"w1={count}" - tcp_conn.sendall(f"{cmd} {state}\n".encode()) - -# === Worker 2: TCP-in, pipe-out === -if os.fork() == 0: - os.close(c2w0_r); os.close(c2w0_w); os.close(w2c_r) - uds_w0.close(); uds_w1.close() - tcp_conn, _ = tcp_srv.accept() - tcp_srv.close() - count = 0 - buf = b"" - while True: - chunk = tcp_conn.recv(4096) - if not chunk: os._exit(0) - buf += chunk - while b"\n" in buf: - line, buf = buf.split(b"\n", 1) - cmd, _, payload = line.decode().partition(" ") - if cmd == "TICK": count += 1 - state = (payload + "," if payload else "") + f"w2={count}" - os.write(w2c_w, f"{cmd} {state}\n".encode()) - -# === Coordinator: HTTP server on :8900 === -os.close(c2w0_r); os.close(w2c_w) -uds_w0.close(); uds_w1.close(); tcp_srv.close() - -def ring_command(cmd): - os.write(c2w0_w, f"{cmd}\n".encode()) - buf = b"" - while b"\n" not in buf: - chunk = os.read(w2c_r, 4096) - if not chunk: return None - buf += chunk - return buf.split(b"\n", 1)[0].decode().partition(" ")[2] - -def parse_state(payload): - result = {} - for part in payload.split(","): - if "=" in part: - k, v = part.split("=", 1) - result[k] = int(v) - return result - -class H(BaseHTTPRequestHandler): - def do_POST(self): - if self.path == "/tick": - payload = ring_command("TICK") - if payload is None: - self.send_error(500, "ring broken"); return - self.send_response(200); self.end_headers() - self.wfile.write(json.dumps(parse_state(payload), sort_keys=True).encode() + b"\n") - else: self.send_error(404) - def do_GET(self): - if self.path == "/state": - payload = ring_command("STATE") - if payload is None: - self.send_error(500, "ring broken"); return - self.send_response(200); self.end_headers() - self.wfile.write(json.dumps(parse_state(payload), sort_keys=True).encode() + b"\n") - else: self.send_error(404) - def log_message(self, *a): pass - -HTTPServer(("0.0.0.0", 8900), H).serve_forever() -PYEOF -python3 -u /tmp/dining.py /dev/null 2>&1 &`) - - // Wait for HTTP server to be ready. - require.Eventually(t, func() bool { - out, err := runCLI(bin, "--instance", inst, "curl", "-sf", "http://127.0.0.1:8900/state") - return err == nil && strings.Contains(out, "w0") - }, 30*time.Second, time.Second, "dining philosophers never became ready") - - // Send 5 ticks through the ring (pipe → UDS → TCP → pipe). - for i := 0; i < 5; i++ { - runCLISuccess(t, bin, "--instance", inst, - "curl", "-sf", "-X", "POST", "http://127.0.0.1:8900/tick") - } - - // Verify all 3 workers counted 5 ticks. - out := runCLISuccess(t, bin, "--instance", inst, - "curl", "-sf", "http://127.0.0.1:8900/state") - var state map[string]int - require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &state)) - assert.Equal(t, 5, state["w0"], "pipe worker") - assert.Equal(t, 5, state["w1"], "UDS worker") - assert.Equal(t, 5, state["w2"], "TCP worker") - - // CRIU checkpoint. - cpOut := runCLISuccess(t, bin, "--instance", inst, - "checkpoints", "create", "--criu", "dining-snap") - assert.Contains(t, cpOut, "dining-snap") - - // Verify checkpoint dir has both files. - home, _ := os.UserHomeDir() - instDir := filepath.Join(home, ".lnx", "instances", inst) - cpDir := filepath.Join(instDir, "checkpoints", "dining-snap") - require.DirExists(t, cpDir) - require.FileExists(t, filepath.Join(cpDir, "rootfs.ext4")) - require.FileExists(t, filepath.Join(cpDir, "criu.ext4")) - - // Mutate: send 3 more ticks → counters reach 8. - for i := 0; i < 3; i++ { - runCLISuccess(t, bin, "--instance", inst, - "curl", "-sf", "-X", "POST", "http://127.0.0.1:8900/tick") - } - out = runCLISuccess(t, bin, "--instance", inst, - "curl", "-sf", "http://127.0.0.1:8900/state") - require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &state)) - assert.Equal(t, 8, state["w0"]) - - // Kill keeper, stop VM, restore to checkpoint. - cmd.Process.Kill() - <-done - runCLI(bin, "--instance", inst, "stop") - - restoreOut := runCLISuccess(t, bin, "--instance", inst, - "checkpoints", "restore", "dining-snap") - assert.Contains(t, restoreOut, "restored CRIU checkpoint") - - // Check if CRIU restore worked by looking for the coordinator's HTTP server. - require.Eventually(t, func() bool { - out, err := runCLI(bin, "--instance", inst, - "curl", "-sf", "http://127.0.0.1:8900/state") - if err != nil { - return false - } - var s map[string]int - if json.Unmarshal([]byte(strings.TrimSpace(out)), &s) != nil { - return false - } - return s["w0"] == 5 && s["w1"] == 5 && s["w2"] == 5 - }, 30*time.Second, time.Second, - "restored state should show all workers at 5") - - // Verify the ring still functions after restore. - runCLISuccess(t, bin, "--instance", inst, - "curl", "-sf", "-X", "POST", "http://127.0.0.1:8900/tick") - out = runCLISuccess(t, bin, "--instance", inst, - "curl", "-sf", "http://127.0.0.1:8900/state") - require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &state)) - assert.Equal(t, 6, state["w0"], "pipe should survive restore") - assert.Equal(t, 6, state["w1"], "UDS should survive restore") - assert.Equal(t, 6, state["w2"], "TCP should survive restore") -} diff --git a/old/devices_darwin.go b/old/devices_darwin.go deleted file mode 100644 index 703d3c6..0000000 --- a/old/devices_darwin.go +++ /dev/null @@ -1,112 +0,0 @@ -//go:build darwin - -package lnx - -import ( - "fmt" - "path/filepath" - - vz "github.com/Code-Hex/vz/v3" -) - -// attachDisks attaches block devices in order: -// -// /dev/vda — rootfs -// /dev/vdb — swap (hibernate resume device) -// /dev/vdc — CRIU images volume -// /dev/vdd, /dev/vde, ... — nested instance rootfs drives -func attachDisks(vmConfig *vz.VirtualMachineConfiguration, rootfsPath, swapPath, criuPath string, nested []NestedRootfs) error { - var devices []vz.StorageDeviceConfiguration - - rootAttach, err := vz.NewDiskImageStorageDeviceAttachment(rootfsPath, false) - if err != nil { - return fmt.Errorf("root disk attachment: %w", err) - } - rootBlock, err := vz.NewVirtioBlockDeviceConfiguration(rootAttach) - if err != nil { - return fmt.Errorf("root block device: %w", err) - } - devices = append(devices, rootBlock) - - swapAttach, err := vz.NewDiskImageStorageDeviceAttachment(swapPath, false) - if err != nil { - return fmt.Errorf("swap disk attachment: %w", err) - } - swapBlock, err := vz.NewVirtioBlockDeviceConfiguration(swapAttach) - if err != nil { - return fmt.Errorf("swap block device: %w", err) - } - devices = append(devices, swapBlock) - - criuAttach, err := vz.NewDiskImageStorageDeviceAttachment(criuPath, false) - if err != nil { - return fmt.Errorf("criu disk attachment: %w", err) - } - criuBlock, err := vz.NewVirtioBlockDeviceConfiguration(criuAttach) - if err != nil { - return fmt.Errorf("criu block device: %w", err) - } - devices = append(devices, criuBlock) - - // Nested instance rootfs drives. - for _, nr := range nested { - attach, err := vz.NewDiskImageStorageDeviceAttachment(nr.RootfsPath, false) - if err != nil { - return fmt.Errorf("nested disk %s attachment: %w", nr.InstanceName, err) - } - block, err := vz.NewVirtioBlockDeviceConfiguration(attach) - if err != nil { - return fmt.Errorf("nested disk %s block device: %w", nr.InstanceName, err) - } - devices = append(devices, block) - } - - vmConfig.SetStorageDevicesVirtualMachineConfiguration(devices) - return nil -} - -// All directory sharing now goes through 9P over vsock (no virtiofs). -// See setupVsock in vm.go for the 9P server setup. - -func attachNetwork(vmConfig *vz.VirtualMachineConfiguration) error { - natAttachment, err := vz.NewNATNetworkDeviceAttachment() - if err != nil { - return fmt.Errorf("nat attachment: %w", err) - } - netConfig, err := vz.NewVirtioNetworkDeviceConfiguration(natAttachment) - if err != nil { - return fmt.Errorf("network config: %w", err) - } - vmConfig.SetNetworkDevicesVirtualMachineConfiguration([]*vz.VirtioNetworkDeviceConfiguration{netConfig}) - return nil -} - -func attachSerialConsole(vmConfig *vz.VirtualMachineConfiguration, logDir string) error { - logPath := filepath.Join(logDir, "serial.log") - attachment, err := vz.NewFileSerialPortAttachment(logPath, false) - if err != nil { - return fmt.Errorf("serial port attachment: %w", err) - } - serial, err := vz.NewVirtioConsoleDeviceSerialPortConfiguration(attachment) - if err != nil { - return fmt.Errorf("serial port config: %w", err) - } - vmConfig.SetSerialPortsVirtualMachineConfiguration([]*vz.VirtioConsoleDeviceSerialPortConfiguration{serial}) - return nil -} - -func attachMisc(vmConfig *vz.VirtualMachineConfiguration) error { - entropy, err := vz.NewVirtioEntropyDeviceConfiguration() - if err != nil { - return fmt.Errorf("entropy config: %w", err) - } - vmConfig.SetEntropyDevicesVirtualMachineConfiguration([]*vz.VirtioEntropyDeviceConfiguration{entropy}) - - balloon, err := vz.NewVirtioTraditionalMemoryBalloonDeviceConfiguration() - if err != nil { - return fmt.Errorf("balloon config: %w", err) - } - vmConfig.SetMemoryBalloonDevicesVirtualMachineConfiguration([]vz.MemoryBalloonDeviceConfiguration{balloon}) - - return nil -} diff --git a/old/docker_intg_test.go b/old/docker_intg_test.go deleted file mode 100644 index 9ee4600..0000000 --- a/old/docker_intg_test.go +++ /dev/null @@ -1,103 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// waitForDocker is a shell snippet that starts Docker and waits for it to be ready. -const waitForDocker = `sudo systemctl start docker && for i in $(seq 1 20); do docker info >/dev/null 2>&1 && break; sleep 1; done` - -func TestDocker_HelloWorld(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - // Start Docker, pull and run hello-world. - exitCode, err := lnx.Run(cfg, "sh", "-c", - waitForDocker+` && - docker run --rm hello-world 2>&1 | grep -q "Hello from Docker"`) - require.NoError(t, err) - if exitCode == 127 { - t.Skip("skipping: Docker not installed in rootfs") - } - assert.Equal(t, 0, exitCode) -} - -func TestDocker_BuildAndRun(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - // Create a Dockerfile in the CWD, build an image, run it. - buildDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(buildDir, "Dockerfile"), []byte(` -FROM alpine:latest -RUN echo "built-ok" > /msg.txt -CMD cat /msg.txt -`), 0644)) - - cfg.CWD = buildDir - exitCode, err := lnx.Run(cfg, "sh", "-c", - waitForDocker+` && - docker build -t lnx-test-build . && - docker run --rm lnx-test-build | grep -q "built-ok"`) - require.NoError(t, err) - if exitCode == 127 { - t.Skip("skipping: Docker not installed in rootfs") - } - assert.Equal(t, 0, exitCode) -} - -func TestDocker_ComposeUpDown(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - // Create a docker-compose.yml with two services that communicate. - composeDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(composeDir, "compose.yaml"), []byte(` -services: - web: - image: alpine:latest - command: ["sh", "-c", "echo COMPOSE_OK > /tmp/result.txt && cat /tmp/result.txt"] -`), 0644)) - - cfg.CWD = composeDir - exitCode, err := lnx.Run(cfg, "sh", "-c", - waitForDocker+` && - docker compose up --exit-code-from web 2>&1 | grep -q "COMPOSE_OK"`) - require.NoError(t, err) - if exitCode == 127 { - t.Skip("skipping: Docker not installed in rootfs") - } - assert.Equal(t, 0, exitCode) -} - -func TestDocker_Networking(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - // Run an nginx container and curl it from another container on the same network. - exitCode, err := lnx.Run(cfg, "sh", "-c", - waitForDocker+` && - docker network create lnx-test-net && - docker run -d --name lnx-nginx --network lnx-test-net nginx:alpine && - sleep 3 && - docker run --rm --network lnx-test-net alpine:latest sh -c "apk add --no-cache curl >/dev/null 2>&1 && curl -sf http://lnx-nginx/" | grep -q "Welcome to nginx" && - docker rm -f lnx-nginx && - docker network rm lnx-test-net`) - require.NoError(t, err) - if exitCode == 127 { - t.Skip("skipping: Docker not installed in rootfs") - } - assert.Equal(t, 0, exitCode) -} diff --git a/old/docs/asciinema/.gitignore b/old/docs/asciinema/.gitignore deleted file mode 100644 index 89f9ac0..0000000 --- a/old/docs/asciinema/.gitignore +++ /dev/null @@ -1 +0,0 @@ -out/ diff --git a/old/docs/asciinema/README.md b/old/docs/asciinema/README.md deleted file mode 100644 index db0a315..0000000 --- a/old/docs/asciinema/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Asciinema Demo - -This is the terminal-first version of the lnx ingress walkthrough. - -It focuses only on the shell flow: - -- `lnx init` -- `lnx clone dev` -- scaffold a Vite app -- run the dev server inside the VM -- `lnx ingress enable` -- `curl http://p5173.dev.lnx/` - -## Record - -```sh -cd docs/asciinema -./record.sh -``` - -The cast is written to `docs/asciinema/ingress-demo.cast`. - -## Render GIF - -```sh -cargo install --locked --git https://github.com/asciinema/agg -cd docs/asciinema -./render-gif.sh -``` - -The GIF is written to `docs/asciinema/out/ingress-demo.gif`. - -## Play In Terminal - -```sh -cd docs/asciinema -asciinema play ingress-demo.cast -``` - -## Notes - -`demo.sh` is intentionally scripted rather than recorded live so the asset is reproducible and easy to edit. - -`render-gif.sh` uses `agg` to turn the checked-in cast into a GIF. The repository's GitHub Actions workflow uploads that GIF as an artifact instead of checking it into git. diff --git a/old/docs/asciinema/demo.sh b/old/docs/asciinema/demo.sh deleted file mode 100755 index 0ed608b..0000000 --- a/old/docs/asciinema/demo.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -type_text() { - local text="$1" - local delay="${2:-0.028}" - local i char - for ((i = 0; i < ${#text}; i++)); do - char="${text:i:1}" - printf '%s' "$char" - sleep "$delay" - done -} - -run_cmd() { - local cmd="$1" - printf '\033[1;36m$\033[0m ' - type_text "$cmd" - printf '\n' -} - -say() { - local text="$1" - local delay="${2:-0}" - printf '%b\n' "$text" - if [[ "$delay" != "0" ]]; then - sleep "$delay" - fi -} - -pause() { - sleep "${1:-0.6}" -} - -if ! clear 2>/dev/null; then - printf '\033[H\033[2J' -fi - -say $'\033[1;37mlnx ingress demo\033[0m' -say $'\033[2mStart from a clean Mac terminal, set up a VM, run a dev server, curl it via .lnx.\033[0m' -pause 1 - -run_cmd 'lnx init' -pause 0.5 -say $'\033[32minstalled ~/.lnx/vmlinuz\033[0m' -say $'\033[32minstalled ~/.lnx/instances/default/rootfs.ext4\033[0m' -pause 1 - -run_cmd 'lnx clone dev' -pause 0.4 -say $'\033[32mcreated instance "dev"\033[0m' -pause 1 - -run_cmd 'mkdir web-demo && cd web-demo' -pause 0.8 - -run_cmd 'npm create vite@latest . -- --template react' -pause 0.5 -say $'\033[32m◇ Scaffolding project in ./web-demo...\033[0m' -say $'\033[32m└ Done. Now run npm install and npm run dev\033[0m' -pause 1 - -run_cmd 'npm install' -pause 0.5 -say $'\033[2madded 153 packages in 4s\033[0m' -pause 1 - -run_cmd "lnx --instance dev sh -lc 'cd /work/web-demo && npm run dev -- --host 0.0.0.0 --port 5173'" -pause 0.8 -say $'\033[2m> web-demo@0.0.0 dev\033[0m' -say $'\033[2m> vite --host 0.0.0.0 --port 5173\033[0m' -pause 0.8 -say $'\033[32mVITE v7.1.7 ready in 421 ms\033[0m' -say $'\033[2m➜ Local: http://localhost:5173/\033[0m' -say $'\033[2m➜ Network: http://192.168.64.2:5173/\033[0m' -pause 1.2 - -run_cmd 'lnx ingress enable' -pause 0.4 -say $'\033[32mingress enabled for .lnx\033[0m' -pause 1 - -run_cmd "curl -s http://p5173.dev.lnx/ | rg ''" -pause 0.5 -say $'\033[32m<title>Vite + React\033[0m' -pause 1 - -run_cmd 'open http://p5173.dev.lnx/' -pause 0.5 -say $'\033[32mbrowser opened\033[0m' -pause 1 diff --git a/old/docs/asciinema/ingress-demo.cast b/old/docs/asciinema/ingress-demo.cast deleted file mode 100644 index af16af8..0000000 --- a/old/docs/asciinema/ingress-demo.cast +++ /dev/null @@ -1,330 +0,0 @@ -{"version":2,"width":108,"height":32,"timestamp":1775630819,"idle_time_limit":0.8,"command":"env TERM=xterm-256color bash ./demo.sh","title":"lnx ingress demo","env":{"SHELL":"/bin/zsh"}} -[0.009659, "o", "\u001b[3J\u001b[H\u001b[2J"] -[0.009871, "o", "\u001b[1;37mlnx ingress demo\u001b[0m\r\n"] -[0.009905, "o", "\u001b[2mStart from a clean Mac terminal, set up a VM, run a dev server, curl it via .lnx.\u001b[0m\r\n"] -[1.014026, "o", "\u001b[1;36m$\u001b[0m "] -[1.01404, "o", "l"] -[1.051011, "o", "n"] -[1.084024, "o", "x"] -[1.120973, "o", " "] -[1.154107, "o", "i"] -[1.189129, "o", "n"] -[1.219791, "o", "i"] -[1.253724, "o", "t"] -[1.286754, "o", "\r\n"] -[1.793217, "o", "\u001b[32minstalled ~/.lnx/vmlinuz\u001b[0m\r\n"] -[1.793262, "o", "\u001b[32minstalled ~/.lnx/instances/default/rootfs.ext4\u001b[0m\r\n"] -[2.800802, "o", "\u001b[1;36m$\u001b[0m "] -[2.800849, "o", "l"] -[2.835292, "o", "n"] -[2.870733, "o", "x"] -[2.904433, "o", " "] -[2.940848, "o", "i"] -[2.978221, "o", "n"] -[3.014446, "o", "s"] -[3.049995, "o", "t"] -[3.087491, "o", "a"] -[3.119862, "o", "n"] -[3.154329, "o", "c"] -[3.1858, "o", "e"] -[3.220987, "o", " "] -[3.25561, "o", "c"] -[3.289708, "o", "r"] -[3.324089, "o", "e"] -[3.359384, "o", "a"] -[3.396544, "o", "t"] -[3.432251, "o", "e"] -[3.466763, "o", " "] -[3.504486, "o", "d"] -[3.540752, "o", "e"] -[3.574154, "o", "v"] -[3.610507, "o", "\r\n"] -[4.017343, "o", "\u001b[32mcreated instance \"dev\"\u001b[0m\r\n"] -[5.025805, "o", "\u001b[1;36m$\u001b[0m "] -[5.025825, "o", "m"] -[5.060692, "o", "k"] -[5.091639, "o", "d"] -[5.126157, "o", "i"] -[5.159774, "o", "r"] -[5.192371, "o", " "] -[5.223686, "o", "w"] -[5.253681, "o", "e"] -[5.283697, "o", "b"] -[5.314701, "o", "-"] -[5.345758, "o", "d"] -[5.378729, "o", "e"] -[5.414128, "o", "m"] -[5.445849, "o", "o"] -[5.47885, "o", " "] -[5.510432, "o", "&"] -[5.546939, "o", "&"] -[5.580953, "o", " "] -[5.614218, "o", "c"] -[5.647355, "o", "d"] -[5.680852, "o", " "] -[5.714055, "o", "w"] -[5.750703, "o", "e"] -[5.787616, "o", "b"] -[5.821496, "o", "-"] -[5.858252, "o", "d"] -[5.893287, "o", "e"] -[5.930953, "o", "m"] -[5.964115, "o", "o"] -[6.001858, "o", "\r\n"] -[6.807432, "o", "\u001b[1;36m$\u001b[0m "] -[6.807505, "o", "n"] -[6.841624, "o", "p"] -[6.879191, "o", "m"] -[6.915186, "o", " "] -[6.948282, "o", "c"] -[6.985579, "o", "r"] -[7.024337, "o", "e"] -[7.062495, "o", "a"] -[7.101251, "o", "t"] -[7.133455, "o", "e"] -[7.167833, "o", " "] -[7.202757, "o", "v"] -[7.236749, "o", "i"] -[7.270769, "o", "t"] -[7.301856, "o", "e"] -[7.336887, "o", "@"] -[7.372087, "o", "l"] -[7.409119, "o", "a"] -[7.446389, "o", "t"] -[7.483059, "o", "e"] -[7.521227, "o", "s"] -[7.555808, "o", "t"] -[7.590165, "o", " "] -[7.628561, "o", "."] -[7.661386, "o", " "] -[7.697205, "o", "-"] -[7.730929, "o", "-"] -[7.764399, "o", " "] -[7.801588, "o", "-"] -[7.839276, "o", "-"] -[7.87206, "o", "t"] -[7.909898, "o", "e"] -[7.944037, "o", "m"] -[7.97942, "o", "p"] -[8.012183, "o", "l"] -[8.049401, "o", "a"] -[8.084456, "o", "t"] -[8.123121, "o", "e"] -[8.157945, "o", " "] -[8.193962, "o", "r"] -[8.230423, "o", "e"] -[8.265054, "o", "a"] -[8.297573, "o", "c"] -[8.330077, "o", "t"] -[8.365923, "o", "\r\n"] -[8.874729, "o", "\u001b[32m◇ Scaffolding project in ./web-demo...\u001b[0m\r\n"] -[8.874811, "o", "\u001b[32m└ Done. Now run npm install and npm run dev\u001b[0m\r\n"] -[9.883564, "o", "\u001b[1;36m$\u001b[0m "] -[9.883602, "o", "n"] -[9.917655, "o", "p"] -[9.954569, "o", "m"] -[9.988594, "o", " "] -[10.026221, "o", "i"] -[10.062167, "o", "n"] -[10.095086, "o", "s"] -[10.131989, "o", "t"] -[10.166575, "o", "a"] -[10.202743, "o", "l"] -[10.235804, "o", "l"] -[10.270866, "o", "\r\n"] -[10.778101, "o", "\u001b[2madded 153 packages in 4s\u001b[0m\r\n"] -[11.787299, "o", "\u001b[1;36m$\u001b[0m "] -[11.78737, "o", "l"] -[11.823249, "o", "n"] -[11.859547, "o", "x"] -[11.896137, "o", " "] -[11.928816, "o", "-"] -[11.962331, "o", "-"] -[11.997233, "o", "i"] -[12.035133, "o", "n"] -[12.072006, "o", "s"] -[12.105716, "o", "t"] -[12.141949, "o", "a"] -[12.179345, "o", "n"] -[12.213135, "o", "c"] -[12.249452, "o", "e"] -[12.285387, "o", " "] -[12.321111, "o", "d"] -[12.356494, "o", "e"] -[12.390685, "o", "v"] -[12.429665, "o", " "] -[12.467332, "o", "s"] -[12.504178, "o", "h"] -[12.539198, "o", " "] -[12.577764, "o", "-"] -[12.614262, "o", "l"] -[12.651458, "o", "c"] -[12.686918, "o", " "] -[12.722159, "o", "'"] -[12.754454, "o", "c"] -[12.78931, "o", "d"] -[12.82529, "o", " "] -[12.860138, "o", "/"] -[12.895476, "o", "w"] -[12.929731, "o", "o"] -[12.965483, "o", "r"] -[13.003521, "o", "k"] -[13.038994, "o", "/"] -[13.075911, "o", "w"] -[13.11183, "o", "e"] -[13.14487, "o", "b"] -[13.181146, "o", "-"] -[13.21382, "o", "d"] -[13.245822, "o", "e"] -[13.279327, "o", "m"] -[13.315765, "o", "o"] -[13.352321, "o", " "] -[13.388035, "o", "&"] -[13.423696, "o", "&"] -[13.460421, "o", " "] -[13.498727, "o", "n"] -[13.533988, "o", "p"] -[13.571432, "o", "m"] -[13.608709, "o", " "] -[13.646136, "o", "r"] -[13.679954, "o", "u"] -[13.713087, "o", "n"] -[13.746323, "o", " "] -[13.779649, "o", "d"] -[13.815608, "o", "e"] -[13.849259, "o", "v"] -[13.887352, "o", " "] -[13.926055, "o", "-"] -[13.963966, "o", "-"] -[14.000785, "o", " "] -[14.036949, "o", "-"] -[14.072347, "o", "-"] -[14.110693, "o", "h"] -[14.145958, "o", "o"] -[14.180062, "o", "s"] -[14.214147, "o", "t"] -[14.246872, "o", " "] -[14.28168, "o", "0"] -[14.31728, "o", "."] -[14.354016, "o", "0"] -[14.390691, "o", "."] -[14.424945, "o", "0"] -[14.462074, "o", "."] -[14.496486, "o", "0"] -[14.5316, "o", " "] -[14.568719, "o", "-"] -[14.601152, "o", "-"] -[14.637455, "o", "p"] -[14.671136, "o", "o"] -[14.704466, "o", "r"] -[14.741371, "o", "t"] -[14.777183, "o", " "] -[14.814122, "o", "5"] -[14.847896, "o", "1"] -[14.885218, "o", "7"] -[14.92305, "o", "3"] -[14.958257, "o", "'"] -[14.993464, "o", "\r\n"] -[15.803482, "o", "\u001b[2m> web-demo@0.0.0 dev\u001b[0m\r\n"] -[15.803667, "o", "\u001b[2m> vite --host 0.0.0.0 --port 5173\u001b[0m\r\n"] -[16.612841, "o", "\u001b[32mVITE v7.1.7 ready in 421 ms\u001b[0m\r\n"] -[16.612857, "o", "\u001b[2m➜ Local: http://localhost:5173/\u001b[0m\r\n"] -[16.612901, "o", "\u001b[2m➜ Network: http://192.168.64.2:5173/\u001b[0m\r\n"] -[17.820134, "o", "\u001b[1;36m$\u001b[0m "] -[17.820199, "o", "l"] -[17.854859, "o", "n"] -[17.889026, "o", "x"] -[17.925126, "o", " "] -[17.962137, "o", "i"] -[17.99721, "o", "n"] -[18.036188, "o", "g"] -[18.073611, "o", "r"] -[18.112099, "o", "e"] -[18.145853, "o", "s"] -[18.178126, "o", "s"] -[18.212155, "o", " "] -[18.248309, "o", "e"] -[18.284989, "o", "n"] -[18.319864, "o", "a"] -[18.355475, "o", "b"] -[18.390024, "o", "l"] -[18.426963, "o", "e"] -[18.461619, "o", "\r\n"] -[18.871402, "o", "\u001b[32mingress enabled for .lnx\u001b[0m\r\n"] -[19.880751, "o", "\u001b[1;36m$\u001b[0m "] -[19.880844, "o", "c"] -[19.916124, "o", "u"] -[19.953493, "o", "r"] -[19.990908, "o", "l"] -[20.025572, "o", " "] -[20.062842, "o", "-"] -[20.1005, "o", "s"] -[20.134043, "o", " "] -[20.171432, "o", "h"] -[20.204118, "o", "t"] -[20.236926, "o", "t"] -[20.271774, "o", "p"] -[20.303995, "o", ":"] -[20.336874, "o", "/"] -[20.372482, "o", "/"] -[20.405014, "o", "p"] -[20.441736, "o", "5"] -[20.477406, "o", "1"] -[20.512838, "o", "7"] -[20.546045, "o", "3"] -[20.579408, "o", "."] -[20.617643, "o", "d"] -[20.65621, "o", "e"] -[20.690179, "o", "v"] -[20.727152, "o", "."] -[20.762176, "o", "l"] -[20.796132, "o", "n"] -[20.833765, "o", "x"] -[20.871638, "o", "/"] -[20.903717, "o", " "] -[20.938919, "o", "|"] -[20.976607, "o", " "] -[21.012706, "o", "r"] -[21.044877, "o", "g"] -[21.079695, "o", " "] -[21.113392, "o", "'"] -[21.146882, "o", "<"] -[21.180681, "o", "t"] -[21.217581, "o", "i"] -[21.251958, "o", "t"] -[21.28709, "o", "l"] -[21.319903, "o", "e"] -[21.354287, "o", ">"] -[21.386964, "o", "'"] -[21.419723, "o", "\r\n"] -[21.926951, "o", "\u001b[32mVite + React\u001b[0m\r\n"] -[22.932952, "o", "\u001b[1;36m$\u001b[0m "] -[22.933036, "o", "o"] -[22.965846, "o", "p"] -[22.998012, "o", "e"] -[23.033886, "o", "n"] -[23.066917, "o", " "] -[23.102483, "o", "h"] -[23.136828, "o", "t"] -[23.167959, "o", "t"] -[23.203887, "o", "p"] -[23.236973, "o", ":"] -[23.27108, "o", "/"] -[23.305512, "o", "/"] -[23.337247, "o", "p"] -[23.368326, "o", "5"] -[23.404875, "o", "1"] -[23.438793, "o", "7"] -[23.471127, "o", "3"] -[23.50745, "o", "."] -[23.539846, "o", "d"] -[23.576429, "o", "e"] -[23.612533, "o", "v"] -[23.649449, "o", "."] -[23.686101, "o", "l"] -[23.721373, "o", "n"] -[23.757012, "o", "x"] -[23.793346, "o", "/"] -[23.830876, "o", "\r\n"] -[24.337467, "o", "\u001b[32mbrowser opened\u001b[0m\r\n"] -[25.346089, "x", "0"] diff --git a/old/docs/asciinema/record.sh b/old/docs/asciinema/record.sh deleted file mode 100755 index 5928cc2..0000000 --- a/old/docs/asciinema/record.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd "$(dirname "$0")" - -asciinema rec \ - --overwrite \ - --output-format asciicast-v2 \ - --headless \ - --idle-time-limit 0.8 \ - --window-size 108x32 \ - --title "lnx ingress demo" \ - --command "env TERM=xterm-256color bash ./demo.sh" \ - ingress-demo.cast - -printf 'wrote %s\n' "docs/asciinema/ingress-demo.cast" diff --git a/old/docs/asciinema/render-gif.sh b/old/docs/asciinema/render-gif.sh deleted file mode 100755 index db5b4b6..0000000 --- a/old/docs/asciinema/render-gif.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd "$(dirname "$0")" - -mkdir -p out - -agg \ - --theme asciinema \ - --font-size 18 \ - --speed 1.15 \ - --idle-time-limit 0.8 \ - ingress-demo.cast \ - out/ingress-demo.gif - -printf 'wrote %s\n' "docs/asciinema/out/ingress-demo.gif" diff --git a/old/docs/plans/gui.md b/old/docs/plans/gui.md deleted file mode 100644 index 7bce8c2..0000000 --- a/old/docs/plans/gui.md +++ /dev/null @@ -1,155 +0,0 @@ -# GUI App Support (`lnx --gui`) - -## Goal - -Run Linux GUI apps from lnx with each app appearing as a native macOS window. No full desktop, no single-window compositor — individual app windows integrated into the macOS desktop. - -## Architecture - -``` -+-- macOS Host ------------------------------------------------+ -| | -| lnx --gui bash -l | -| +-- terminal session (same as today) | -| +-- cocoa-way (creates NSWindow per Wayland surface) | -| +-- waypipe client <----+ | -| | vsock port 1034 | -+-----------------------------+--------------------------------+ -| Linux Guest | | -| +-- waypipe server ------+ | -| +-- XWayland (X11 app compat) | -| +-- apps connect to WAYLAND_DISPLAY | -+--------------------------------------------------------------+ -``` - -### How it works - -1. **waypipe** is a Wayland protocol proxy. The guest runs `waypipe server`, which acts as a Wayland compositor stub. It serializes the Wayland protocol and shared memory buffers over a socket. waypipe natively supports vsock for VM transport. - -2. **cocoa-way** is a native macOS Wayland compositor written in Rust (Smithay). It receives the Wayland protocol from waypipe client and creates a real NSWindow per Wayland surface, rendered with Metal/OpenGL. Supports HiDPI/Retina, server-side decorations, clipboard. - -3. **XWayland** runs inside the guest for X11 app compatibility. X11 apps connect to XWayland, which translates to the Wayland protocol, which waypipe then forwards. - -4. The `--gui` flag adds GUI capability alongside the normal terminal. You get your shell, and any GUI app you launch appears as a native macOS window. - -### Prior art - -- **WSLg** (Microsoft): Modified Weston compositor with RDP backend, per-app windows via RAIL/VAIL. Production-grade but tied to Windows + RDP. -- **Cocoa-Way**: Purpose-built for this exact use case on macOS. Uses waypipe for transport. -- **OrbStack**: No native GUI support yet. Users work around with XQuartz/xrdp. -- **Parallels Coherence**: Per-app windows but proprietary, Windows-only guest. - -## Implementation - -### 1. cocoa-way binary management - -- Stored at `~/.lnx/bin/cocoa-way` -- Hardcoded download URL in source code (GitHub release) -- Downloaded on first `lnx --gui` use, same pattern as kernel/rootfs auto-init -- Version-pinned in source to avoid breaking changes - -### 2. Rootfs changes - -Add to rootfs build: -- `waypipe` — Wayland protocol proxy (guest side) -- `xwayland` — X11 compatibility layer -- `foot` — lightweight Wayland terminal (for testing) -- Mesa with software rendering (llvmpipe) — no GPU needed - -### 3. New vsock port (1034) - -- Add `WaypipePort = 1034` to `internal/protocol/protocol.go` -- Carries waypipe wire protocol between guest and host - -### 4. Guest init changes - -When `Setup.GUI` is true: -- Start `waypipe --vsock server` on vsock port 1034 -- Set `WAYLAND_DISPLAY` in the environment for all guest processes -- Start XWayland connected to the waypipe Wayland socket -- Non-fatal: if waypipe isn't installed in rootfs, log a warning and continue without GUI - -### 5. Host-side process management - -On VM boot when `Config.GUI` is true: -- Accept vsock connection on port 1034 -- Spawn `cocoa-way` process (creates a Wayland socket on the host side) -- Spawn `waypipe client` connected to cocoa-way's Wayland socket and the vsock -- Both are child processes, killed on VM shutdown -- Proxy the vsock connection to waypipe client's stdin/stdout (or use waypipe's native vsock support on host side too, if available through the Virtualization.framework vsock) - -### 6. Config and protocol changes - -```go -// config.go -type Config struct { - // ...existing fields... - GUI bool // Enable GUI app support (waypipe + cocoa-way) -} - -// internal/protocol/protocol.go -const WaypipePort = 1034 - -// Setup message -type Setup struct { - // ...existing fields... - GUI bool // Start waypipe server for GUI app forwarding -} -``` - -### 7. CLI changes - -```go -// cmd/lnx/main.go -var doGUI bool - -// In rootCmd flags: -rootCmd.Flags().BoolVar(&doGUI, "gui", false, "enable GUI app support (per-app native macOS windows)") - -// In stripLnxFlags: -case a == "--gui": - doGUI = true - i++ - -// In runVM, pass to Config: -GUI: doGUI, -``` - -## UX - -```bash -# Terminal + GUI support -$ lnx --gui -$ firefox & # appears as native macOS window -$ code . # another native window -$ gimp photo.png & # another window -$ exit # VM shuts down, all windows close - -# Launch a GUI app directly -$ lnx --gui firefox - -# From a second terminal while VM is running -$ lnx exec -- firefox # launches into existing GUI session -``` - -## What we explicitly don't need (v1) - -- No virtio-gpu device / kernel DRM drivers — waypipe uses CPU rendering (llvmpipe) -- No `StartGraphicApplication` / AppKit run loop in lnx process -- No compositor in guest — waypipe IS the compositor stub -- No VNC, RDP, or X11 forwarding - -## Future improvements (v2+) - -- **GPU acceleration**: Add virtio-gpu to VM config + kernel DRM, let waypipe use GPU-rendered buffers instead of CPU. Faster rendering for complex apps. -- **Audio**: PulseAudio/PipeWire forwarding over vsock (cocoa-way or separate channel). -- **Clipboard**: Wayland clipboard protocol is forwarded by waypipe/cocoa-way. May need polish. -- **Drag and drop**: Between macOS and Linux app windows. -- **`lnx gui` subcommand**: Attach GUI to an already-running VM instance (connect cocoa-way to existing waypipe session). - -## Open questions - -- **waypipe vsock handshake**: Need to verify waypipe's `--vsock` mode works directly with Virtualization.framework's vsock, or if we need to proxy through a unix socket on the host side. -- **cocoa-way maturity**: Project is new. Need to evaluate stability, test with common apps (Firefox, VS Code, terminals). May need to contribute fixes upstream. -- **Download URL**: Need to decide hosting. Options: cocoa-way GitHub releases, or build and host ourselves. -- **XWayland startup**: Does waypipe handle XWayland lifecycle, or do we start it separately in guest init? diff --git a/old/docs/sync-shares.md b/old/docs/sync-shares.md deleted file mode 100644 index c180329..0000000 --- a/old/docs/sync-shares.md +++ /dev/null @@ -1,118 +0,0 @@ -# Sync Shares - -Sync shares give the guest near-native filesystem speed for host directories. -They replace the default virtiofs/9P mounts with a FUSE lazy-cache overlay: -every file is copied into the guest's ext4 rootfs on first access and served -from there on subsequent reads. A background goroutine keeps the cache fresh. - -## Quick start - -```bash -lnx sync add ~/src/myrepo # persisted, takes effect on next boot -lnx sync list -lnx sync remove ~/src/myrepo -``` - -The shared directory appears at the same absolute path inside the VM -(e.g. `~/src/myrepo` on the host is `/Users/you/src/myrepo` in the guest). - -## How it works - -``` -guest reads /repo/src/foo.go - -> FUSE (lazyCacheFS) at /repo - -> check /var/lnx/cache/sync0/src/foo.go (ext4, fast) - -> cache miss: read /var/lnx/lower/sync0/src/foo.go (virtiofs, one-time cost) - -> copy to ext4 cache, serve from cache - -> next read: pure ext4, zero virtiofs overhead -``` - -### Layers - -| Layer | Path in guest | Filesystem | Access | -|-------|--------------|------------|--------| -| Lower | `/var/lnx/lower/sync` | virtiofs (read-only) | Host directory, unchanged | -| Cache | `/var/lnx/cache/sync` | ext4 (rootfs) | Copy-on-first-read, writable | -| FUSE | Original host path | FUSE overlay | What the guest process sees | - -### Cache-first lookups - -Every `Getattr` and `Lookup` call checks the ext4 cache first. If the file -exists in cache, the result is returned without touching virtiofs. This -eliminates the host round-trip that makes virtiofs slow for metadata-heavy -workloads like `git status`. - -### Kernel-level caching - -FUSE entry and attribute results are cached by the Linux kernel for 5 seconds -(`EntryTimeout` / `AttrTimeout`). Within that window, repeated access to the -same file doesn't even enter the FUSE server — the kernel serves it directly. - -### Background refresh - -A goroutine walks the cache every 5 seconds. For each cached file, it -compares the lower (virtiofs) mtime with the cache mtime. If the host copy -is newer, the cache is updated. This means host-side edits appear in the -guest within ~5 seconds without any guest-side action. - -### Write semantics - -Writes from inside the guest go to the ext4 cache only. The host directory -is mounted read-only via virtiofs and is never modified by the guest. This -means: - -- Guest writes are fast (native ext4). -- Guest writes do **not** appear on the host. -- Guest writes persist across reboots (they live in the rootfs). - -## Home directory - -The home directory (`$HOME`) uses the same lazy-cache mechanism automatically. -No `lnx sync add` is needed — it is always mounted as a FUSE overlay with -the same cache-first behavior. - -The home mount includes a blocked-path filter that hides sensitive directories -from the guest (`.ssh`, `.gnupg`, `.aws`, `.docker`, `.kube`, browser profiles, -keychains, etc.). Attempts to access blocked paths return `EACCES`. - -## Performance - -Measured on a ~3,600-file repository (`git status`): - -| Scenario | virtiofs (before) | Sync share | -|----------|-------------------|------------| -| Cold cache (first run) | 4.7s | 1.4s | -| Warm cache (second run) | 4.7s | 0.05s | - -The warm-cache improvement comes from two things: -1. **Cache-first lookups** skip virtiofs entirely for cached files. -2. **Kernel cache** (5s TTL) skips the FUSE server entirely for repeated access. - -## Implementation details - -### FUSE inode stability - -FUSE inodes use deterministic IDs derived from the file path (FNV-64a hash). -This ensures that when the kernel's entry cache expires and it re-lookups a -path, the FUSE server returns the same node ID. Without this, `getcwd()` -fails after cache expiry because the kernel's dentry tree points to stale -node IDs. - -### Protocol - -The `Setup` message includes a `SyncShares []string` field listing host paths. -The host attaches each sync share as a read-only virtiofs device tagged -`sync0`, `sync1`, etc. The guest init mounts these to lower directories -pre-pivotRoot, then starts FUSE servers post-pivotRoot. - -### Files - -| File | Role | -|------|------| -| `cmd/init/lazyfuse.go` | FUSE filesystem: Lookup, Getattr, Open, Create, Readdir, etc. | -| `cmd/init/mount.go` | Pre-pivotRoot: mount virtiofs lower + create cache dirs | -| `cmd/init/main.go` | Post-pivotRoot: start FUSE servers and refresh goroutines | -| `cmd/lnx/sync_cmd.go` | CLI: `lnx sync add/remove/list` | -| `cmd/lnx/daemon_cmd.go` | Load sync-shares.json into Config | -| `devices_darwin.go` | Attach virtiofs devices (home, cwd, shares, sync shares) | -| `vm.go` | Thread SyncShares through Setup message | diff --git a/old/entitlements.plist b/old/entitlements.plist deleted file mode 100644 index d7d0d6e..0000000 --- a/old/entitlements.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.virtualization - - - diff --git a/old/examples/criu-checkpoints.py b/old/examples/criu-checkpoints.py deleted file mode 100755 index 70d76c3..0000000 --- a/old/examples/criu-checkpoints.py +++ /dev/null @@ -1,330 +0,0 @@ -#!/usr/bin/env -S python3 -u -""" -Demonstrates lnx CRIU checkpoints and VM fork using a live Python TCP REPL. - -This script self-execs inside the VM: on the host it orchestrates the -demo via subprocess; inside the guest it runs a persistent Python REPL -over TCP. The host connects to the REPL via lnx port forwarding. - -CRIU checkpoints dump process state to a separate block device on the -Mac (criu.ext4). The host APFS-clones both rootfs.ext4 and criu.ext4 -instantly. On restore, the cloned files replace the originals, the VM -boots, and CRIU restores the processes — same PID, same heap. - -VM fork clones the running VM into a child instance. Both parent and -child keep running with the same state at the point of fork. - -Usage: ./examples/criu-checkpoints.py -""" - -import atexit -import io -import json -import os -import shlex -import shutil -import subprocess -import sys -import time -import urllib.request - -INST = None -CHILD_INST = None -KEEPER = None # background lnx process that keeps the daemon alive -CHILD_KEEPER = None # same for fork child -SCRIPT = os.path.abspath(__file__) -REPL_PORT = 9999 - - -# --------------------------------------------------------------------------- -# Guest side: HTTP REPL server running inside the VM -# --------------------------------------------------------------------------- - -def start_repl(): - """Start an HTTP REPL server on REPL_PORT.""" - # Close inherited FDs (vsock exec plumbing) so CRIU can dump us. - import resource - for fd in range(3, min(resource.getrlimit(resource.RLIMIT_NOFILE)[0], 1024)): - try: - os.close(fd) - except OSError: - pass - - # Create a new session so we're a session leader for CRIU. - os.setsid() - - from http.server import HTTPServer, BaseHTTPRequestHandler - from socketserver import ThreadingMixIn - - class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): - daemon_threads = True - - ns = {} - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - body = self.rfile.read(int(self.headers["Content-Length"])) - expr = json.loads(body)["expr"] - output = eval_line(expr, ns) - resp = json.dumps({"output": output}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(resp))) - self.end_headers() - self.wfile.write(resp) - - def log_message(self, *args): - pass # silence request logs - - srv = ThreadingHTTPServer(("0.0.0.0", REPL_PORT), Handler) - print(f"Python {sys.version.split()[0]} REPL on :{REPL_PORT} (PID {os.getpid()})") - sys.stdout.flush() - srv.serve_forever() - - -def eval_line(line, ns): - """Evaluate a line and return the REPL output as a string.""" - buf = io.StringIO() - buf.write(f">>> {line}\n") - try: - parts = [p.strip() for p in line.split(";")] - for part in parts[:-1]: - exec(compile(part, "", "exec"), ns) - last = parts[-1] - try: - result = eval(compile(last, "", "eval"), ns) - if result is not None: - buf.write(repr(result) + "\n") - except SyntaxError: - exec(compile(last, "", "exec"), ns) - except Exception as e: - buf.write(f"{type(e).__name__}: {e}\n") - return buf.getvalue() - - -# --------------------------------------------------------------------------- -# Host side: orchestrates the demo -# --------------------------------------------------------------------------- - -def host_main(): - global INST, CHILD_INST - INST = f"demo-criu-{os.getpid()}" - atexit.register(cleanup) - - # --- Setup --- - run("lnx", "clone", INST) - - # Start the REPL inside the VM. We keep a long-running exec session - # alive (sleep infinity) so the daemon doesn't idle-shutdown while - # the REPL runs in the background. - KEEPER = start_keeper(INST) - - wait_for_port(REPL_PORT) - print() - - # --- Define state in the REPL --- - repl(REPL_PORT, 'todos = ["buy milk", "write code", "ship feature"]') - repl(REPL_PORT, 'secret = 42') - repl(REPL_PORT, 'cache = {"alice": 9001, "bob": 1337}') - print() - repl(REPL_PORT, 'todos') - repl(REPL_PORT, 'secret') - repl(REPL_PORT, 'cache') - repl(REPL_PORT, 'import os; os.getpid()') - print() - - # --- CRIU checkpoint --- - # Dumps process memory to CRIU block device, host APFS-clones - # both rootfs.ext4 and criu.ext4 instantly. - print("--- CRIU checkpoint ---") - t0 = time.monotonic() - run("lnx", "--instance", INST, "checkpoints", "create", "--criu", - "clean-state") - elapsed = time.monotonic() - t0 - print(f" checkpoint took {elapsed:.1f}s") - print() - - # --- Make destructive changes --- - repl(REPL_PORT, 'todos.append("break prod")') - repl(REPL_PORT, 'secret = 0') - repl(REPL_PORT, 'del cache["alice"]') - print() - repl(REPL_PORT, 'todos') - repl(REPL_PORT, 'secret') - repl(REPL_PORT, 'cache') - print() - - # --- Stop VM and restore --- - # Kill the keeper so the daemon can shut down, then replace - # rootfs + CRIU volume with checkpoint clones and reboot. - print("--- stop + restore from CRIU checkpoint ---") - KEEPER.kill() - KEEPER.wait() - run("lnx", "--instance", INST, "stop", "--shutdown") - t0 = time.monotonic() - run("lnx", "--instance", INST, "checkpoints", "restore", "clean-state") - elapsed = time.monotonic() - t0 - print(f" restore took {elapsed:.1f}s") - - # Boot the VM — CRIU auto-restores processes. - # Reuse start_keeper (REPL will fail to bind since CRIU restored it, - # but sleep infinity keeps the daemon alive). - KEEPER = start_keeper(INST) - wait_for_port(REPL_PORT) - print() - - # --- Verify: everything is back --- - repl(REPL_PORT, 'todos') - repl(REPL_PORT, 'secret') - repl(REPL_PORT, 'cache') - repl(REPL_PORT, 'import os; os.getpid()') - print() - - # --- VM fork --- - print("--- VM fork (clone running VM into child) ---") - t0 = time.monotonic() - result = run("lnx", "--instance", INST, "fork") - elapsed = time.monotonic() - t0 - print(f" fork took {elapsed:.1f}s") - - # Parse child instance name from output. - CHILD_INST = result.strip().split()[-1] - print() - - # The child has the same REPL with the same state. - # Keep the child daemon alive and expose its port. - child_port = REPL_PORT + 1 - CHILD_KEEPER = start_keeper(CHILD_INST) - # Wait for child VM to boot (keeper triggers daemon spawn). - # Retry expose until the child daemon is reachable. - for attempt in range(30): - result = subprocess.run( - ["lnx", "expose", f"{CHILD_INST}:{REPL_PORT}", "--as", f":{child_port}"], - capture_output=True, text=True) - if result.returncode == 0: - print(f"+ lnx expose {CHILD_INST}:{REPL_PORT} --as :{child_port}") - print(result.stdout, end="") - break - time.sleep(1) - wait_for_port(child_port, timeout=15) - - print("--- Parent state (unchanged) ---") - repl(REPL_PORT, 'todos') - repl(REPL_PORT, 'secret') - repl(REPL_PORT, 'import os; os.getpid()') - print() - - print("--- Child state (forked copy) ---") - repl(child_port, 'todos') - repl(child_port, 'secret') - repl(child_port, 'import os; os.getpid()') - print() - - # Mutate child — parent is unaffected. - print("--- Mutate child, verify parent isolation ---") - repl(child_port, 'todos.append("child only")') - repl(child_port, 'todos') - repl(REPL_PORT, 'todos') - print() - - # Check fork role in child. - print("--- Fork role detection ---") - run("lnx", "--instance", CHILD_INST, "lnx-fork-role") - print() - - print("CRIU checkpoints: process dump to block device, APFS-clone both files.") - print("VM fork: instant clone of a running VM with full process state.") - - -def start_keeper(inst): - """Start a background lnx process that starts the REPL and keeps the - daemon alive with a long-running exec session.""" - return subprocess.Popen( - ["lnx", "--instance", inst, "sh", "-c", - # Python handles setsid + FD closing internally via start-repl. - f"python3 {shlex.quote(SCRIPT)} start-repl " - "/dev/null 2>&1 & sleep infinity"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - -def start_keeper_only(inst): - """Start a background lnx process that just keeps the daemon alive. - Used after CRIU restore where the REPL is already restored.""" - return subprocess.Popen( - ["lnx", "--instance", inst, "sleep", "infinity"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - -def wait_for_port(port, timeout=30): - """Wait for a TCP port to become reachable on localhost.""" - import socket - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - try: - s = socket.create_connection(("127.0.0.1", port), timeout=1) - s.close() - return - except OSError: - time.sleep(0.5) - raise TimeoutError(f"port {port} not reachable after {timeout}s") - - -def run(*args): - """Run a command, printing it first (like set -x).""" - print(f"+ {shlex.join(args)}") - result = subprocess.run(args, capture_output=True, text=True) - if result.stdout: - print(result.stdout, end="") - if result.stderr: - print(result.stderr, end="", file=sys.stderr) - if result.returncode != 0: - sys.exit(result.returncode) - return result.stdout - - -def lnx(*args): - """Run a command inside the VM.""" - run("lnx", "--instance", INST, *args) - - -def repl(port, expression, retries=3): - """Send an expression to the Python REPL via HTTP port forwarding.""" - url = f"http://127.0.0.1:{port}/" - data = json.dumps({"expr": expression}).encode() - req = urllib.request.Request(url, data=data, - headers={"Content-Type": "application/json"}) - for attempt in range(retries): - try: - with urllib.request.urlopen(req, timeout=5) as resp: - body = json.loads(resp.read()) - print(body["output"], end="") - return - except (ConnectionError, OSError): - if attempt == retries - 1: - raise - time.sleep(1) - - -def cleanup(): - for k in [CHILD_KEEPER, KEEPER]: - if k: - k.kill() - k.wait() - for inst in [CHILD_INST, INST]: - if inst: - subprocess.run(["lnx", "--instance", inst, "stop", "--shutdown"], - capture_output=True) - home = os.path.expanduser("~") - shutil.rmtree(os.path.join(home, ".lnx", "instances", inst), - ignore_errors=True) - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -if __name__ == "__main__": - if len(sys.argv) > 1 and sys.argv[1] == "start-repl": - start_repl() - else: - host_main() diff --git a/old/examples/fork.py b/old/examples/fork.py deleted file mode 100755 index e766443..0000000 --- a/old/examples/fork.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env -S python3 -u -""" -VM fork with classic fork() semantics. - - lnx python3 examples/fork.py - -Like os.fork(), returns in both parent and child: - - Parent: returns child instance name (truthy) - - Child: returns None (CRIU-restored at same program counter) -""" - -import os - - -def fork(): - """Fork the VM. Returns child instance name in parent, None in child. - - Writes "fork" to fd 3 (pipe to init), reads result from fd 4. - In the CRIU-restored child, fd 4 is dead → returns None. - """ - try: - os.write(3, b"fork\n") - result = os.read(4, 4096) - if not result: - return None # EOF = restored child - text = result.decode().strip() - if text.startswith("error:"): - raise RuntimeError(text) - return text - except OSError: - # Restored child — pipe fds are dead. - return None - - -if __name__ == "__main__": - child = fork() - pid = os.getpid() - - if child is None: - print(f"[child] pid={pid}") - else: - print(f"[parent] pid={pid} child={child}") diff --git a/old/exec_intg_test.go b/old/exec_intg_test.go deleted file mode 100644 index c57a983..0000000 --- a/old/exec_intg_test.go +++ /dev/null @@ -1,79 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "bytes" - "context" - "encoding/json" - "net" - "net/http" - "path/filepath" - "testing" - "time" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRun_ExecIntoRunningVM(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - // Boot VM with a long-running command. - go lnx.Run(cfg, "sleep", "60") - - // Wait for the API socket and exec to be ready. - sockPath := filepath.Join(dir, "status.sock") - client := &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return net.DialTimeout("unix", sockPath, 2*time.Second) - }, - }, - } - - // Wait until exec endpoint is ready by probing it. - require.Eventually(t, func() bool { - body, _ := json.Marshal(lnx.ExecRequest{Args: []string{"true"}}) - resp, err := client.Post("http://localhost/exec", "application/json", bytes.NewReader(body)) - if err != nil { - return false - } - resp.Body.Close() - return resp.StatusCode == http.StatusOK - }, 60*time.Second, time.Second, "VM exec never became ready") - - // Exec a non-interactive command into the running VM. - body, err := json.Marshal(lnx.ExecRequest{Args: []string{"echo", "EXEC_WORKS"}}) - require.NoError(t, err) - - resp, err := client.Post("http://localhost/exec", "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, http.StatusOK, resp.StatusCode) - - // Read NDJSON response. - var output string - var exitCode int = -1 - dec := json.NewDecoder(resp.Body) - for { - var msg map[string]json.RawMessage - if err := dec.Decode(&msg); err != nil { - break - } - if raw, ok := msg["stdout"]; ok { - var s string - json.Unmarshal(raw, &s) - output += s - } - if raw, ok := msg["exit_code"]; ok { - json.Unmarshal(raw, &exitCode) - } - } - - assert.Contains(t, output, "EXEC_WORKS") - assert.Equal(t, 0, exitCode) -} diff --git a/old/expose_intg_test.go b/old/expose_intg_test.go deleted file mode 100644 index e3d1862..0000000 --- a/old/expose_intg_test.go +++ /dev/null @@ -1,429 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "bufio" - "bytes" - "fmt" - "io" - "net" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/sys/unix" -) - -func TestCLI_Expose_Host(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - srcInst := fmt.Sprintf("test-expose-host-src-%d", time.Now().UnixNano()) - createClonedInstance(t, srcInst) - registerInstanceStopCleanup(t, bin, srcInst) - - sourcePort := 18180 - hostPort := 18181 - - srcCmd, srcLines, srcStderr, srcDone := startTCPServerInstance(t, bin, srcInst, sourcePort, "HELLO_HOST_EXPOSE") - waitForCLIOutput(t, srcLines, "READY", 20*time.Second, srcStderr) - t.Cleanup(func() { cleanupStreamingCLI(t, srcCmd, srcDone, srcStderr) }) - - out := runCLISuccess(t, bin, "expose", fmt.Sprintf("%s:%d", srcInst, sourcePort), fmt.Sprintf("--as=:%d", hostPort)) - assert.Contains(t, out, fmt.Sprintf("localhost:%d -> %s:%d", hostPort, srcInst, sourcePort)) - - data := readTCPEventually(t, fmt.Sprintf("127.0.0.1:%d", hostPort), "HELLO_HOST_EXPOSE", 10*time.Second) - assert.Contains(t, data, "HELLO_HOST_EXPOSE") - - portsOut := runCLISuccess(t, bin, "--instance", srcInst, "ports", "list") - assert.Contains(t, portsOut, fmt.Sprintf("%d", sourcePort)) - assert.Contains(t, portsOut, fmt.Sprintf("%d", hostPort)) - - waitForProcessSuccess(t, srcDone, 15*time.Second, srcStderr.String()) -} - -func TestCLI_Expose_VMToVM(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - srcInst := fmt.Sprintf("test-expose-src-%d", time.Now().UnixNano()) - dstInst := fmt.Sprintf("test-expose-dst-%d", time.Now().UnixNano()) - createClonedInstance(t, srcInst) - createClonedInstance(t, dstInst) - registerInstanceStopCleanup(t, bin, srcInst, dstInst) - - sourcePort := 18080 - destPort := 18081 - srcCmd, srcLines, srcStderr, srcDone := startTCPServerInstance(t, bin, srcInst, sourcePort, "HELLO_EXPOSE") - waitForCLIOutput(t, srcLines, "READY", 20*time.Second, srcStderr) - t.Cleanup(func() { cleanupStreamingCLI(t, srcCmd, srcDone, srcStderr) }) - - dstCmd, dstStderr, dstDone := startIdleInstance(t, bin, dstInst) - t.Cleanup(func() { cleanupStreamingCLI(t, dstCmd, dstDone, dstStderr) }) - - exposeOut := runCLISuccess(t, bin, "expose", fmt.Sprintf("%s:%d", srcInst, sourcePort), fmt.Sprintf("--as=%s:%d", dstInst, destPort)) - assert.Contains(t, exposeOut, fmt.Sprintf("%s:%d -> %s:%d", dstInst, destPort, srcInst, sourcePort)) - - clientScript := fmt.Sprintf(`python3 -c " -import socket -s = socket.create_connection(('127.0.0.1', %d), timeout=10) -print(s.recv(1024).decode(), end='') -s.close() -"`, destPort) - clientCmd := exec.Command(bin, "--instance", dstInst, "sh", "-c", clientScript) - clientOut, err := clientCmd.CombinedOutput() - require.NoError(t, err, "destination client failed: %s", clientOut) - assert.Contains(t, string(clientOut), "HELLO_EXPOSE") - - waitForProcessSuccess(t, srcDone, 15*time.Second, srcStderr.String()) -} - -func TestCLI_Expose_VMToVM_Idempotent(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - srcInst := fmt.Sprintf("test-expose-idem-src-%d", time.Now().UnixNano()) - dstInst := fmt.Sprintf("test-expose-idem-dst-%d", time.Now().UnixNano()) - createClonedInstance(t, srcInst) - createClonedInstance(t, dstInst) - registerInstanceStopCleanup(t, bin, srcInst, dstInst) - - sourcePort := 18190 - destPort := 18191 - - srcCmd, srcLines, srcStderr, srcDone := startTCPServerInstance(t, bin, srcInst, sourcePort, "HELLO_IDEMPOTENT") - waitForCLIOutput(t, srcLines, "READY", 20*time.Second, srcStderr) - t.Cleanup(func() { cleanupStreamingCLI(t, srcCmd, srcDone, srcStderr) }) - - dstCmd, dstStderr, dstDone := startIdleInstance(t, bin, dstInst) - t.Cleanup(func() { cleanupStreamingCLI(t, dstCmd, dstDone, dstStderr) }) - - runCLISuccess(t, bin, "expose", fmt.Sprintf("%s:%d", srcInst, sourcePort), fmt.Sprintf("--as=%s:%d", dstInst, destPort)) - runCLISuccess(t, bin, "expose", fmt.Sprintf("%s:%d", srcInst, sourcePort), fmt.Sprintf("--as=%s:%d", dstInst, destPort)) - - clientScript := fmt.Sprintf(`python3 -c " -import socket -s = socket.create_connection(('127.0.0.1', %d), timeout=10) -print(s.recv(1024).decode(), end='') -s.close() -"`, destPort) - clientOut := runCLISuccess(t, bin, "--instance", dstInst, "sh", "-c", clientScript) - assert.Contains(t, clientOut, "HELLO_IDEMPOTENT") - waitForProcessSuccess(t, srcDone, 15*time.Second, srcStderr.String()) -} - -func TestCLI_Expose_RollbackOnDestinationFailure(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - srcInst := fmt.Sprintf("test-expose-rollback-src-%d", time.Now().UnixNano()) - badDstInst := fmt.Sprintf("test-expose-missing-dst-%d", time.Now().UnixNano()) - createClonedInstance(t, srcInst) - registerInstanceStopCleanup(t, bin, srcInst) - - srcCmd, srcStderr, srcDone := startTimedInstance(t, bin, srcInst, 2*time.Second) - t.Cleanup(func() { cleanupStreamingCLI(t, srcCmd, srcDone, srcStderr) }) - errOut, err := runCLI(bin, "expose", fmt.Sprintf("%s:%d", srcInst, 18200), fmt.Sprintf("--as=%s:%d", badDstInst, 18201)) - require.Error(t, err) - assert.Contains(t, errOut, fmt.Sprintf("no VM running for instance %q", badDstInst)) - - waitForProcessSuccess(t, srcDone, 10*time.Second, srcStderr.String()) - waitForNoVMRunning(t, bin, srcInst, 12*time.Second) -} - -func TestCLI_Expose_PortsListVisibility(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - hostSrc := fmt.Sprintf("test-expose-visible-src-%d", time.Now().UnixNano()) - createClonedInstance(t, hostSrc) - registerInstanceStopCleanup(t, bin, hostSrc) - hostCmd, hostStderr, hostDone := startIdleInstance(t, bin, hostSrc) - t.Cleanup(func() { cleanupStreamingCLI(t, hostCmd, hostDone, hostStderr) }) - - runCLISuccess(t, bin, "expose", fmt.Sprintf("%s:%d", hostSrc, 18210), "--as=:18211") - visibleOut := runCLISuccess(t, bin, "--instance", hostSrc, "ports", "list") - assert.Contains(t, visibleOut, "18210") - assert.Contains(t, visibleOut, "18211") - - vmSrc := fmt.Sprintf("test-expose-hidden-src-%d", time.Now().UnixNano()) - vmDst := fmt.Sprintf("test-expose-hidden-dst-%d", time.Now().UnixNano()) - createClonedInstance(t, vmSrc) - createClonedInstance(t, vmDst) - registerInstanceStopCleanup(t, bin, vmSrc, vmDst) - vmSrcCmd, vmSrcStderr, vmSrcDone := startIdleInstance(t, bin, vmSrc) - vmDstCmd, vmDstStderr, vmDstDone := startIdleInstance(t, bin, vmDst) - t.Cleanup(func() { cleanupStreamingCLI(t, vmSrcCmd, vmSrcDone, vmSrcStderr) }) - t.Cleanup(func() { cleanupStreamingCLI(t, vmDstCmd, vmDstDone, vmDstStderr) }) - - runCLISuccess(t, bin, "expose", fmt.Sprintf("%s:%d", vmSrc, 18220), fmt.Sprintf("--as=%s:%d", vmDst, 18221)) - assert.Contains(t, runCLISuccess(t, bin, "--instance", vmSrc, "ports", "list"), "no forwarded ports") - assert.Contains(t, runCLISuccess(t, bin, "--instance", vmDst, "ports", "list"), "no forwarded ports") -} - -func TestCLI_Expose_HostConflict(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - src1 := fmt.Sprintf("test-expose-conflict-src1-%d", time.Now().UnixNano()) - src2 := fmt.Sprintf("test-expose-conflict-src2-%d", time.Now().UnixNano()) - createClonedInstance(t, src1) - createClonedInstance(t, src2) - registerInstanceStopCleanup(t, bin, src1, src2) - cmd1, stderr1, done1 := startIdleInstance(t, bin, src1) - cmd2, stderr2, done2 := startIdleInstance(t, bin, src2) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd1, done1, stderr1) }) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd2, done2, stderr2) }) - - runCLISuccess(t, bin, "expose", fmt.Sprintf("%s:%d", src1, 18230), "--as=:18231") - errOut, err := runCLI(bin, "expose", fmt.Sprintf("%s:%d", src2, 18232), "--as=:18231") - require.Error(t, err) - assert.Contains(t, errOut, "bind host port 18231") -} - -func TestCLI_Expose_DestinationReuseAndConflict(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - src1 := fmt.Sprintf("test-expose-reuse-src1-%d", time.Now().UnixNano()) - src2 := fmt.Sprintf("test-expose-reuse-src2-%d", time.Now().UnixNano()) - dst := fmt.Sprintf("test-expose-reuse-dst-%d", time.Now().UnixNano()) - createClonedInstance(t, src1) - createClonedInstance(t, src2) - createClonedInstance(t, dst) - registerInstanceStopCleanup(t, bin, src1, src2, dst) - cmd1, stderr1, done1 := startIdleInstance(t, bin, src1) - cmd2, stderr2, done2 := startIdleInstance(t, bin, src2) - cmd3, stderr3, done3 := startIdleInstance(t, bin, dst) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd1, done1, stderr1) }) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd2, done2, stderr2) }) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd3, done3, stderr3) }) - - runCLISuccess(t, bin, "expose", fmt.Sprintf("%s:%d", src1, 18240), fmt.Sprintf("--as=%s:%d", dst, 18241)) - runCLISuccess(t, bin, "expose", fmt.Sprintf("%s:%d", src1, 18240), fmt.Sprintf("--as=%s:%d", dst, 18241)) - - errOut, err := runCLI(bin, "expose", fmt.Sprintf("%s:%d", src2, 18242), fmt.Sprintf("--as=%s:%d", dst, 18241)) - require.Error(t, err) - assert.Contains(t, errOut, "port 18241 is already exposed") -} - -func createClonedInstance(t *testing.T, name string) { - t.Helper() - - home, _ := os.UserHomeDir() - base := filepath.Join(home, ".lnx") - imgDir := filepath.Join(base, "images", name) - instDir := filepath.Join(base, "instances", name) - defaultRootfs := findDefaultRootfs(base) - if defaultRootfs == "" { - t.Skipf("skipping: default instance rootfs not found (run 'lnx init' first)") - } - - require.NoError(t, os.MkdirAll(imgDir, 0755)) - require.NoError(t, os.MkdirAll(instDir, 0755)) - rootfs := filepath.Join(imgDir, "rootfs.ext4") - _ = os.Remove(rootfs) - require.NoError(t, unix.Clonefile(defaultRootfs, rootfs, 0)) - t.Cleanup(func() { - _ = os.RemoveAll(imgDir) - _ = os.RemoveAll(instDir) - }) -} - -func registerInstanceStopCleanup(t *testing.T, bin string, names ...string) { - t.Helper() - t.Cleanup(func() { - for _, name := range names { - cmd := exec.Command(bin, "--instance", name, "stop") - out, err := cmd.CombinedOutput() - if err != nil && !strings.Contains(string(out), "no VM running") { - t.Logf("stop %s failed: %v: %s", name, err, out) - } - } - }) -} - -func startIdleInstance(t *testing.T, bin, instance string) (*exec.Cmd, *bytes.Buffer, <-chan error) { - t.Helper() - cmd, lines, stderr, done := startStreamingCLI(t, bin, "--instance", instance, "sh", "-c", "echo READY; sleep 120") - waitForCLIOutput(t, lines, "READY", 20*time.Second, stderr) - return cmd, stderr, done -} - -func startTimedInstance(t *testing.T, bin, instance string, duration time.Duration) (*exec.Cmd, *bytes.Buffer, <-chan error) { - t.Helper() - script := fmt.Sprintf("echo READY; sleep %.0f", duration.Seconds()) - cmd, lines, stderr, done := startStreamingCLI(t, bin, "--instance", instance, "sh", "-c", script) - waitForCLIOutput(t, lines, "READY", 20*time.Second, stderr) - return cmd, stderr, done -} - -func startTCPServerInstance(t *testing.T, bin, instance string, port int, payload string) (*exec.Cmd, <-chan string, *bytes.Buffer, <-chan error) { - t.Helper() - script := fmt.Sprintf(`python3 -c " -import socket, time -s = socket.socket() -s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -s.bind(('0.0.0.0', %d)) -s.listen(1) -print('READY', flush=True) -conn, _ = s.accept() -conn.sendall(b'%s\n') -conn.close() -s.close() -time.sleep(1) -"`, port, payload) - return startStreamingCLI(t, bin, "--instance", instance, "sh", "-c", script) -} - -func runCLI(bin string, args ...string) (string, error) { - cmd := exec.Command(bin, args...) - out, err := cmd.CombinedOutput() - return string(out), err -} - -func runCLISuccess(t *testing.T, bin string, args ...string) string { - t.Helper() - out, err := runCLI(bin, args...) - require.NoError(t, err, "command failed: %s %v\n%s", bin, args, out) - return out -} - -func readTCPOnce(t *testing.T, addr string) string { - t.Helper() - conn, err := net.DialTimeout("tcp", addr, 10*time.Second) - require.NoError(t, err) - defer conn.Close() - data, err := io.ReadAll(conn) - require.NoError(t, err) - return string(data) -} - -func readTCPEventually(t *testing.T, addr, want string, timeout time.Duration) string { - t.Helper() - deadline := time.Now().Add(timeout) - var last string - for time.Now().Before(deadline) { - conn, err := net.DialTimeout("tcp", addr, time.Second) - if err == nil { - data, readErr := io.ReadAll(conn) - _ = conn.Close() - if readErr == nil { - last = string(data) - if strings.Contains(last, want) { - return last - } - } - } - time.Sleep(500 * time.Millisecond) - } - t.Fatalf("timed out waiting for %q from %s; last response=%q", want, addr, last) - return "" -} - -func startStreamingCLI(t *testing.T, bin string, args ...string) (*exec.Cmd, <-chan string, *bytes.Buffer, <-chan error) { - t.Helper() - - cmd := exec.Command(bin, args...) - stdout, err := cmd.StdoutPipe() - require.NoError(t, err) - var stderr bytes.Buffer - cmd.Stderr = &stderr - require.NoError(t, cmd.Start()) - - lines := make(chan string, 32) - go func() { - defer close(lines) - scanner := bufio.NewScanner(stdout) - scanner.Buffer(make([]byte, 0, 1024), 1024*1024) - for scanner.Scan() { - lines <- scanner.Text() - } - }() - - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - return cmd, lines, &stderr, done -} - -func waitForCLIOutput(t *testing.T, lines <-chan string, want string, timeout time.Duration, stderr *bytes.Buffer) { - t.Helper() - - deadline := time.After(timeout) - for { - select { - case line, ok := <-lines: - if !ok { - t.Fatalf("process exited before output %q appeared; stderr: %s", want, stderr.String()) - } - if strings.Contains(line, want) { - return - } - case <-deadline: - t.Fatalf("timed out waiting for %q; stderr: %s", want, stderr.String()) - } - } -} - -func cleanupStreamingCLI(t *testing.T, cmd *exec.Cmd, done <-chan error, stderr *bytes.Buffer) { - t.Helper() - - select { - case <-done: - return - default: - } - - if cmd.Process != nil { - _ = cmd.Process.Kill() - } - select { - case <-done: - case <-time.After(5 * time.Second): - t.Logf("timed out waiting for process cleanup; stderr: %s", stderr.String()) - } -} - -func waitForProcessSuccess(t *testing.T, done <-chan error, timeout time.Duration, stderr string) { - t.Helper() - select { - case err := <-done: - require.NoError(t, err, "process failed: %s", stderr) - case <-time.After(timeout): - t.Fatalf("process did not exit within %s; stderr: %s", timeout, stderr) - } -} - -func waitForNoVMRunning(t *testing.T, bin, instance string, timeout time.Duration) { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - out, err := runCLI(bin, "--instance", instance, "status") - if err == nil && strings.Contains(out, "no VM running") { - return - } - time.Sleep(500 * time.Millisecond) - } - out, _ := runCLI(bin, "--instance", instance, "status") - t.Fatalf("VM %s still running after %s: %s", instance, timeout, out) -} diff --git a/old/forceq_intg_test.go b/old/forceq_intg_test.go deleted file mode 100644 index 0707e9f..0000000 --- a/old/forceq_intg_test.go +++ /dev/null @@ -1,52 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "syscall" - "testing" - "time" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestRun_ForceQuit_RootfsIntact verifies that after a VM is force-killed -// (double Ctrl-C), the rootfs is intact and can boot again. -// This test is NOT parallel because it sends SIGINT to the process. -func TestRun_ForceQuit_RootfsIntact(t *testing.T) { - dir := setupTestDir(t) - cfg := testConfig(dir) - - // Write a file to the rootfs, sync, then trap signals and block forever. - script := `echo CANARY > $HOME/canary.txt && sync && trap "" INT TERM && sleep 3600` - - errCh := make(chan error, 1) - codeCh := make(chan int, 1) - go func() { - code, err := lnx.Run(cfg, "sh", "-c", script) - errCh <- err - codeCh <- code - }() - - // Wait for the VM to boot and write the file. - time.Sleep(10 * time.Second) - - // Double SIGINT triggers force quit. - pid := os.Getpid() - syscall.Kill(pid, syscall.SIGINT) - time.Sleep(100 * time.Millisecond) - syscall.Kill(pid, syscall.SIGINT) - - err := <-errCh - code := <-codeCh - require.NoError(t, err) - assert.Equal(t, 130, code) - - // Boot again on the same rootfs — verify ext4 survived force kill. - exitCode, err := lnx.Run(cfg, "sh", "-c", "cat $HOME/canary.txt") - require.NoError(t, err) - assert.Equal(t, 0, exitCode) -} diff --git a/old/forceq_pty_intg_test.go b/old/forceq_pty_intg_test.go deleted file mode 100644 index 33d4311..0000000 --- a/old/forceq_pty_intg_test.go +++ /dev/null @@ -1,140 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/creack/pty" - "github.com/stretchr/testify/require" - "github.com/vito/midterm" - "golang.org/x/sys/unix" -) - -// TestPTY_DoubleCtrlC_ForceQuit verifies that double Ctrl-C in raw mode -// (where ISIG is disabled) force-quits the VM with exit code 130. -func TestPTY_DoubleCtrlC_ForceQuit(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - term := midterm.NewTerminal(24, 80) - - // Use --ephemeral so we don't contend on the rootfs lock. - // Use a script that traps SIGINT so the first Ctrl-C doesn't kill it. - cmd := exec.Command(bin, "--ephemeral", "sh", "-c", "trap '' INT; echo READY; sleep 3600") - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 80}) - if err != nil { - t.Fatalf("start pty: %v", err) - } - defer ptmx.Close() - defer cmd.Process.Kill() - - go feedTerminal(term, ptmx) - - // Wait for guest to print READY. - waitFor(t, term, "READY", 15*time.Second) - - // Send double Ctrl-C (0x03) quickly. - ptmx.Write([]byte{0x03}) - time.Sleep(200 * time.Millisecond) - ptmx.Write([]byte{0x03}) - - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - - select { - case err := <-done: - if exitErr, ok := err.(*exec.ExitError); ok { - if exitErr.ExitCode() == 130 { - return // success - } - t.Fatalf("expected exit code 130, got %d", exitErr.ExitCode()) - } - if err == nil { - t.Fatal("expected exit code 130, got 0") - } - t.Fatalf("unexpected error: %v", err) - case <-time.After(15 * time.Second): - t.Fatal("double Ctrl-C did not force-quit within 15s") - } -} - -// TestPTY_DoubleCtrlC_CobraPath tests force-quit when lnx goes through cobra -// (e.g. `lnx --instance foo bash -l`). This is distinct from the bypass path -// tested above — cobra-parsed flags previously couldn't reach the bypass path. -func TestPTY_DoubleCtrlC_CobraPath(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - // Create a dedicated instance. - home, _ := os.UserHomeDir() - base := filepath.Join(home, ".lnx") - instDir := filepath.Join(base, "instances", "test-forceq-cobra") - defaultRootfs := findDefaultRootfs(base) - if defaultRootfs == "" { - t.Skipf("skipping: default instance rootfs not found (run 'lnx init' first)") - } - - // Clone rootfs into the images directory for the test instance. - imgDir := filepath.Join(base, "images", "test-forceq-cobra") - os.MkdirAll(imgDir, 0755) - os.MkdirAll(instDir, 0755) - rootfs := filepath.Join(imgDir, "rootfs.ext4") - os.Remove(rootfs) - require.NoError(t, unix.Clonefile(defaultRootfs, rootfs, 0)) - t.Cleanup(func() { - os.RemoveAll(instDir) - os.RemoveAll(imgDir) - }) - - term := midterm.NewTerminal(24, 80) - - // --instance forces cobra path. The guest command traps SIGINT. - cmd := exec.Command(bin, "--instance", "test-forceq-cobra", "sh", "-c", "trap '' INT; echo READY; sleep 3600") - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 80}) - if err != nil { - t.Fatalf("start pty: %v", err) - } - defer ptmx.Close() - defer cmd.Process.Kill() - - go feedTerminal(term, ptmx) - - waitFor(t, term, "READY", 15*time.Second) - - // Double Ctrl-C. - ptmx.Write([]byte{0x03}) - time.Sleep(200 * time.Millisecond) - ptmx.Write([]byte{0x03}) - - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - - select { - case err := <-done: - if exitErr, ok := err.(*exec.ExitError); ok { - if exitErr.ExitCode() == 130 { - return - } - t.Fatalf("expected exit code 130, got %d", exitErr.ExitCode()) - } - if err == nil { - t.Fatal("expected exit code 130, got 0") - } - t.Fatalf("unexpected error: %v", err) - case <-time.After(15 * time.Second): - t.Fatal("double Ctrl-C did not force-quit within 15s") - } -} diff --git a/old/fusetest/fuse_test.go b/old/fusetest/fuse_test.go deleted file mode 100644 index d105ac3..0000000 --- a/old/fusetest/fuse_test.go +++ /dev/null @@ -1,311 +0,0 @@ -//go:build linux - -// Package fusetest contains filesystem tests that run inside an lnx VM -// on a FUSE-cached 9P mount. They exercise the lazyCacheFS behavior: -// cache-first reads, write-to-cache semantics, directory operations, -// permission preservation, symlink handling, and kernel cache behavior. -// -// Cross-compile: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go test -c -o fusetest.test ./fusetest -// Run inside VM: lnx ./fusetest.test -test.v -package fusetest - -import ( - "crypto/md5" - "fmt" - "math/rand" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" -) - -// testDir returns the sync share directory to test against. -// Set via FUSETEST_DIR env var, or auto-detected from /proc/mounts. -func testDir(t *testing.T) string { - t.Helper() - if d := os.Getenv("FUSETEST_DIR"); d != "" { - return d - } - // Find a lnx-sync FUSE mount. - data, err := os.ReadFile("/proc/mounts") - if err != nil { - t.Skip("not inside lnx VM: cannot read /proc/mounts") - } - for _, line := range strings.Split(string(data), "\n") { - fields := strings.Fields(line) - if len(fields) >= 3 && fields[0] == "lnx-sync" && strings.HasPrefix(fields[2], "fuse.") { - // Only use sync share mounts (fuse.sync*). Skip home and CWD. - if !strings.HasPrefix(fields[2], "fuse.sync") { - continue - } - return fields[1] - } - } - t.Skip("no lnx-sync FUSE mount found") - return "" -} - -// sub creates a unique subdirectory for a test to work in. -func sub(t *testing.T, base string) string { - t.Helper() - dir := filepath.Join(base, fmt.Sprintf("t-%d", time.Now().UnixNano())) - if err := os.MkdirAll(dir, 0755); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { os.RemoveAll(dir) }) - return dir -} - -func TestReadFile(t *testing.T) { - dir := testDir(t) - // fixture.txt must be pre-placed by the host test harness. - data, err := os.ReadFile(filepath.Join(dir, "fixture.txt")) - if err != nil { - t.Fatal(err) - } - if string(data) != "hello from host\n" { - t.Fatalf("got %q, want %q", data, "hello from host\n") - } -} - -func TestReadFileTwice(t *testing.T) { - dir := testDir(t) - path := filepath.Join(dir, "fixture.txt") - - // First read — hydrates from 9P lower into ext4 cache. - start1 := time.Now() - d1, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - dur1 := time.Since(start1) - - // Second read — should come from ext4 cache (faster). - start2 := time.Now() - d2, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - dur2 := time.Since(start2) - - if string(d1) != string(d2) { - t.Fatalf("content mismatch: %q vs %q", d1, d2) - } - t.Logf("read1=%v read2=%v", dur1, dur2) -} - -func TestWriteFile(t *testing.T) { - dir := sub(t, testDir(t)) - path := filepath.Join(dir, "guest-created.txt") - - if err := os.WriteFile(path, []byte("from guest"), 0644); err != nil { - t.Fatal(err) - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(data) != "from guest" { - t.Fatalf("got %q, want %q", data, "from guest") - } -} - -func TestStatPermissions(t *testing.T) { - dir := testDir(t) - // perm600.txt must be pre-placed by host with mode 0600. - info, err := os.Stat(filepath.Join(dir, "perm600.txt")) - if err != nil { - t.Fatal(err) - } - perm := info.Mode().Perm() - if perm != 0600 { - t.Fatalf("got %o, want 600", perm) - } -} - -func TestMkdirAndList(t *testing.T) { - dir := sub(t, testDir(t)) - subdir := filepath.Join(dir, "a", "b") - if err := os.MkdirAll(subdir, 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(subdir, "f.txt"), []byte("nested"), 0644); err != nil { - t.Fatal(err) - } - - entries, err := os.ReadDir(subdir) - if err != nil { - t.Fatal(err) - } - if len(entries) != 1 || entries[0].Name() != "f.txt" { - t.Fatalf("unexpected entries: %v", entries) - } -} - -func TestSymlink(t *testing.T) { - dir := testDir(t) - // link.txt -> fixture.txt must be pre-placed by host. - target, err := os.Readlink(filepath.Join(dir, "link.txt")) - if err != nil { - t.Fatal(err) - } - if target != "fixture.txt" { - t.Fatalf("got target %q, want %q", target, "fixture.txt") - } - data, err := os.ReadFile(filepath.Join(dir, "link.txt")) - if err != nil { - t.Fatal(err) - } - if string(data) != "hello from host\n" { - t.Fatalf("got %q through symlink", data) - } -} - -func TestRename(t *testing.T) { - dir := sub(t, testDir(t)) - old := filepath.Join(dir, "old.txt") - neu := filepath.Join(dir, "new.txt") - - if err := os.WriteFile(old, []byte("rename me"), 0644); err != nil { - t.Fatalf("write old: %v", err) - } - // Verify old exists before rename. - if _, err := os.Stat(old); err != nil { - t.Fatalf("stat old before rename: %v", err) - } - if err := os.Rename(old, neu); err != nil { - t.Fatalf("rename: %v", err) - } - // List parent dir to see what the FUSE reports. - entries, _ := os.ReadDir(dir) - var names []string - for _, e := range entries { - names = append(names, e.Name()) - } - t.Logf("after rename, parent dir contains: %v", names) - // Verify new exists after rename. - if _, err := os.Stat(neu); err != nil { - t.Fatalf("stat new after rename: %v (dir contents: %v)", err, names) - } - data, err := os.ReadFile(neu) - if err != nil { - t.Fatalf("read new: %v", err) - } - if string(data) != "rename me" { - t.Fatalf("got %q", data) - } -} - -func TestUnlink(t *testing.T) { - dir := sub(t, testDir(t)) - path := filepath.Join(dir, "removeme.txt") - - if err := os.WriteFile(path, []byte("gone"), 0644); err != nil { - t.Fatal(err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("file still exists after unlink") - } -} - -func TestReaddir(t *testing.T) { - dir := testDir(t) - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - names := map[string]bool{} - for _, e := range entries { - names[e.Name()] = true - } - for _, want := range []string{"fixture.txt", "perm600.txt", "link.txt"} { - if !names[want] { - t.Errorf("missing %q in readdir (got %v)", want, names) - } - } -} - -func TestLargeFile(t *testing.T) { - dir := testDir(t) - // large.bin must be pre-placed by host (1MB random data). - data, err := os.ReadFile(filepath.Join(dir, "large.bin")) - if err != nil { - t.Fatal(err) - } - if len(data) != 1<<20 { - t.Fatalf("got %d bytes, want %d", len(data), 1<<20) - } - // Verify md5 matches host-placed checksum. - hostMD5, err := os.ReadFile(filepath.Join(dir, "large.bin.md5")) - if err != nil { - t.Fatal(err) - } - got := fmt.Sprintf("%x", md5.Sum(data)) - if got != strings.TrimSpace(string(hostMD5)) { - t.Fatalf("md5 mismatch: got %s, want %s", got, strings.TrimSpace(string(hostMD5))) - } -} - -func TestConcurrentReads(t *testing.T) { - dir := testDir(t) - path := filepath.Join(dir, "fixture.txt") - - var wg sync.WaitGroup - errs := make(chan error, 20) - for i := 0; i < 20; i++ { - wg.Add(1) - go func() { - defer wg.Done() - data, err := os.ReadFile(path) - if err != nil { - errs <- err - return - } - if string(data) != "hello from host\n" { - errs <- fmt.Errorf("got %q", data) - } - }() - } - wg.Wait() - close(errs) - for err := range errs { - t.Error(err) - } -} - -func TestWriteLargeFile(t *testing.T) { - dir := sub(t, testDir(t)) - path := filepath.Join(dir, "written.bin") - - data := make([]byte, 1<<20) - rand.Read(data) - if err := os.WriteFile(path, data, 0644); err != nil { - t.Fatal(err) - } - - read, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if md5.Sum(read) != md5.Sum(data) { - t.Fatal("md5 mismatch after write+read") - } -} - -func TestFilenameWithSpaces(t *testing.T) { - dir := testDir(t) - // "hello world.txt" must be pre-placed by host. - data, err := os.ReadFile(filepath.Join(dir, "hello world.txt")) - if err != nil { - t.Fatal(err) - } - if string(data) != "spaces ok\n" { - t.Fatalf("got %q", data) - } -} diff --git a/old/fusetest_intg_test.go b/old/fusetest_intg_test.go deleted file mode 100644 index 0a20d84..0000000 --- a/old/fusetest_intg_test.go +++ /dev/null @@ -1,73 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "crypto/md5" - "fmt" - "math/rand" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestFuseFilesystem cross-compiles the fusetest binary, places test fixtures -// in a sync share, and runs the tests inside an lnx VM on the actual FUSE mount. -func TestFuseFilesystem(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-fusetest-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - // --- Cross-compile the guest test binary --- - testBin := filepath.Join(t.TempDir(), "fusetest.test") - build := exec.Command("go", "test", "-c", "-o", testBin, "./fusetest") - build.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH=arm64") - out, err := build.CombinedOutput() - require.NoError(t, err, "cross-compile fusetest: %s", out) - - // --- Create sync share with test fixtures --- - shareDir := t.TempDir() - - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "fixture.txt"), []byte("hello from host\n"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "perm600.txt"), []byte("secret"), 0600)) - require.NoError(t, os.Symlink("fixture.txt", filepath.Join(shareDir, "link.txt"))) - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "hello world.txt"), []byte("spaces ok\n"), 0644)) - - // 1MB random file + its md5. - largeData := make([]byte, 1<<20) - rand.Read(largeData) - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "large.bin"), largeData, 0644)) - largeMD5 := fmt.Sprintf("%x", md5.Sum(largeData)) - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "large.bin.md5"), []byte(largeMD5+"\n"), 0644)) - - // Copy the test binary into the share so the guest can execute it. - testBinData, err := os.ReadFile(testBin) - require.NoError(t, err) - guestBin := filepath.Join(shareDir, "fusetest.test") - require.NoError(t, os.WriteFile(guestBin, testBinData, 0755)) - - // Add sync share. - addOut, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add: %s", addOut) - - // --- Run the test binary inside the VM --- - result := runCLISuccess(t, bin, "--instance", inst, "--ephemeral", - "sh", "-c", fmt.Sprintf("FUSETEST_DIR=%s %s -test.v -test.timeout 60s", - shareDir, filepath.Join(shareDir, "fusetest.test")), - ) - - // Verify key tests passed. - assert.Contains(t, result, "PASS") - assert.NotContains(t, result, "FAIL") - t.Log(result) -} diff --git a/old/go.mod b/old/go.mod deleted file mode 100644 index e6d6c7e..0000000 --- a/old/go.mod +++ /dev/null @@ -1,77 +0,0 @@ -module github.com/semistrict/lnx - -go 1.26.1 - -replace github.com/Code-Hex/vz/v3 => github.com/semistrict/vz/v3 v3.7.2-0.20260404233827-8b4f47561fdd - -require ( - github.com/Code-Hex/vz/v3 v3.7.1 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/creack/pty v1.1.24 - github.com/fsnotify/fsevents v0.2.0 - github.com/gliderlabs/ssh v0.3.8 - github.com/google/go-containerregistry v0.21.5 - github.com/hanwen/go-fuse/v2 v2.9.0 - github.com/hugelgupf/p9 v0.3.0 - github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2 - github.com/joho/godotenv v1.5.1 - github.com/klauspost/compress v1.18.5 - github.com/mdlayher/vsock v1.2.1 - github.com/semistrict/go2fs v0.3.0 - github.com/spf13/cobra v1.10.2 - github.com/stretchr/testify v1.11.1 - github.com/vito/midterm v0.2.4 - golang.org/x/crypto v0.50.0 - golang.org/x/net v0.52.0 - golang.org/x/sys v0.43.0 - golang.org/x/term v0.42.0 - nhooyr.io/websocket v1.8.17 -) - -require ( - github.com/Code-Hex/go-infinity-channel v1.0.0 // indirect - github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect - github.com/danielgatis/go-ansicode v1.0.7 // indirect - github.com/danielgatis/go-iterator v0.0.1 // indirect - github.com/danielgatis/go-utf8 v1.0.0 // indirect - github.com/danielgatis/go-vte v1.0.8 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/docker/cli v29.4.0+incompatible // indirect - github.com/docker/docker-credential-helpers v0.9.3 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/native v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mdlayher/packet v1.1.2 // indirect - github.com/mdlayher/socket v0.4.1 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pierrec/lz4/v4 v4.1.18 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect - github.com/vbatts/tar-split v0.12.2 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/sync v0.20.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - gotest.tools/v3 v3.5.2 // indirect - modernc.org/libc v1.70.0 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect -) diff --git a/old/go.sum b/old/go.sum deleted file mode 100644 index eb3a7fe..0000000 --- a/old/go.sum +++ /dev/null @@ -1,211 +0,0 @@ -github.com/Code-Hex/go-infinity-channel v1.0.0 h1:M8BWlfDOxq9or9yvF9+YkceoTkDI1pFAqvnP87Zh0Nw= -github.com/Code-Hex/go-infinity-channel v1.0.0/go.mod h1:5yUVg/Fqao9dAjcpzoQ33WwfdMWmISOrQloDRn3bsvY= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= -github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/danielgatis/go-ansicode v1.0.7 h1:ozOFtNlQHgI3lMJFbT0CxqbbToqtT7mu8GXOwq10Xn0= -github.com/danielgatis/go-ansicode v1.0.7/go.mod h1:xft3lPCsvHAC9KEGRcEzX+Jm8bTyUbya4rk7zD/nWvk= -github.com/danielgatis/go-iterator v0.0.1 h1:pTptWDVAKzR0EUdtfmFMP3v+hSetqB091V9um1DTO8I= -github.com/danielgatis/go-iterator v0.0.1/go.mod h1:+gTbPAMdVIKwmqw7kr/mo5IFIzz2MFiRyYylcRvgbHs= -github.com/danielgatis/go-utf8 v1.0.0 h1:M7z8heSUa2PF1NoxgD0X6LbOAqMskMf433lFP/Qtal0= -github.com/danielgatis/go-utf8 v1.0.0/go.mod h1:h8SG9aqqV20w8tUD7maLYwfAeQzvjDXNP89oth2qOxM= -github.com/danielgatis/go-vte v1.0.8 h1:ot/fnUB7dMag0lMurMCei4mjRI8KyxpLeA7lhTtdgTo= -github.com/danielgatis/go-vte v1.0.8/go.mod h1:HBeSBT/XiLQRNEoYpBYrBeK2mSUkOfHvsVtb8LPmexQ= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= -github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= -github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fsnotify/fsevents v0.2.0 h1:BRlvlqjvNTfogHfeBOFvSC9N0Ddy+wzQCQukyoD7o/c= -github.com/fsnotify/fsevents v0.2.0/go.mod h1:B3eEk39i4hz8y1zaWS/wPrAP4O6wkIl7HQwKBr1qH/w= -github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= -github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= -github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= -github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2 h1:CVuJwN34x4xM2aT4sIKhmeib40NeBPhRihNjQmpJsA4= -github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hanwen/go-fuse/v2 v2.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58= -github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hugelgupf/p9 v0.3.0 h1:cjn7I237wQ8DN7OTXKRWieaSILW2M8H8hoXnFy5mwgk= -github.com/hugelgupf/p9 v0.3.0/go.mod h1:QFmcCPNn66imQcu1wUqJ8sHKxYjs00Gq60QLjt9E+VI= -github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8= -github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= -github.com/hugelgupf/vmtest v0.0.0-20230810222836-f8c8e381617c h1:4A+BVHylCBQPxlW1NrUITDpRAHCeX6QSZHmzzFQqliU= -github.com/hugelgupf/vmtest v0.0.0-20230810222836-f8c8e381617c/go.mod h1:d2FMzS0rIF+3Daufcw660EZfTJihdNPeEwBBJgO4Ap0= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2 h1:G3irkWwmpl0vH/nn83K2AHqLUZweC7XAONuwXy/w9Co= -github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= -github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= -github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= -github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY= -github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= -github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= -github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= -github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= -github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= -github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= -github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= -github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= -github.com/semistrict/go2fs v0.3.0 h1:gslGnqwXjgR+wub6SfpLEwch+kiAUryHovBRQrIKSsU= -github.com/semistrict/go2fs v0.3.0/go.mod h1:YalHBH6Zw89zV79AZn/Ks/KTmyiyUky+Jgz1EQ6F6+Y= -github.com/semistrict/vz/v3 v3.7.2-0.20260404233827-8b4f47561fdd h1:lPbivBYgx6uXxsUc0XknHzfDOXa3pFkiwnWFPKnAdHk= -github.com/semistrict/vz/v3 v3.7.2-0.20260404233827-8b4f47561fdd/go.mod h1:1LsW0jqW0r0cQ+IeR4hHbjdqOtSidNCVMWhStMHGho8= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/u-root/gobusybox/src v0.0.0-20230806212452-e9366a5b9fdc h1:udgfN9Qy573qgHWMEORFgy6YXNDiN/Fd5LlKdlp+/Mo= -github.com/u-root/gobusybox/src v0.0.0-20230806212452-e9366a5b9fdc/go.mod h1:lYt+LVfZBBwDZ3+PHk4k/c/TnKOkjJXiJO73E32Mmpc= -github.com/u-root/u-root v0.11.1-0.20230807200058-f87ad7ccb594 h1:1AIJqOtdEufYfGb3eRpdaqWONzBOpAwrg1fehbWg+Mg= -github.com/u-root/u-root v0.11.1-0.20230807200058-f87ad7ccb594/go.mod h1:PQzg9XJGp6Y1hRmTUruSO7lR7kKR6FpoSObf5n5bTfE= -github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 h1:YcojQL98T/OO+rybuzn2+5KrD5dBwXIvYBvQ2cD3Avg= -github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= -github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= -github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= -github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= -github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= -github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= -github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= -github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -github.com/vito/midterm v0.2.4 h1:qWF7n20f4NI1meG9U2vr/bmQ+ZWV1V/Ekknj3elyYww= -github.com/vito/midterm v0.2.4/go.mod h1:WkbqZBIhH4jfxXkE2bjhosM1BdF/dCp7sR4x9wQB6fA= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20230810033253-352e893a4cad h1:g0bG7Z4uG+OgH2QDODnjp6ggkk1bJDsINcuWmJN1iJU= -golang.org/x/exp v0.0.0-20230810033253-352e893a4cad/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= -google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc= -google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= -gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= -modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= -modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= -nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= -src.elv.sh v0.16.0-rc1.0.20220116211855-fda62502ad7f h1:pjVeIo9Ba6K1Wy+rlwX91zT7A+xGEmxiNRBdN04gDTQ= -src.elv.sh v0.16.0-rc1.0.20220116211855-fda62502ad7f/go.mod h1:kPbhv5+fBeUh85nET3wWhHGUaUQ64nZMJ8FwA5v5Olg= diff --git a/old/hypervisor.go b/old/hypervisor.go deleted file mode 100644 index e3e9ff2..0000000 --- a/old/hypervisor.go +++ /dev/null @@ -1,34 +0,0 @@ -package lnx - -import "net" - -// VMState represents the state of a virtual machine. -type VMState int - -const ( - VMStateStarting VMState = iota - VMStateRunning - VMStateStopped - VMStateError -) - -// VsockDevice abstracts vsock communication between host and guest. -// On Darwin it wraps vz.VirtioSocketDevice; on Linux it implements -// the Firecracker vsock Unix socket protocol. -type VsockDevice interface { - // Listen creates a listener for incoming guest connections on the given port. - Listen(port uint32) (net.Listener, error) - // Connect establishes a connection to the guest on the given port. - Connect(port uint32) (net.Conn, error) -} - -// VirtualMachine abstracts VM lifecycle operations. -// On Darwin it wraps vz.VirtualMachine; on Linux it manages a Firecracker process. -type VirtualMachine interface { - Start() error - Stop() error - RequestStop() error - // StateChangedNotify returns a channel that receives state transitions. - StateChangedNotify() <-chan VMState - VsockDevice() VsockDevice -} diff --git a/old/ingress_intg_test.go b/old/ingress_intg_test.go deleted file mode 100644 index fdd16ff..0000000 --- a/old/ingress_intg_test.go +++ /dev/null @@ -1,194 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "bytes" - "fmt" - "io" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/net/dns/dnsmessage" -) - -func TestCLI_Ingress_EnableStatusAndProxy(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - srcInst := fmt.Sprintf("test-ingress-src-%d", time.Now().UnixNano()) - createClonedInstance(t, srcInst) - registerInstanceStopCleanup(t, bin, srcInst) - - stateDir, err := os.MkdirTemp("/tmp", "lnx-ingress-state-*") - require.NoError(t, err) - t.Cleanup(func() { _ = os.RemoveAll(stateDir) }) - resolverDir := filepath.Join(t.TempDir(), "resolver") - httpAddr := "127.0.0.1:18080" - dnsAddr := "127.0.0.1:15354" - env := append(os.Environ(), - "LNX_INGRESS_STATE_DIR="+stateDir, - "LNX_INGRESS_RESOLVER_DIR="+resolverDir, - "LNX_INGRESS_HTTP_ADDR="+httpAddr, - "LNX_INGRESS_DNS_ADDR="+dnsAddr, - ) - - t.Cleanup(func() { - _, _ = runCLIEnv(bin, env, "ingress", "disable") - }) - - guestPort := 18380 - hostName := fmt.Sprintf("p%d.%s.lnx", guestPort, srcInst) - payload := "HELLO_INGRESS" - - srcCmd, srcLines, srcStderr, srcDone := startHTTPServerInstance(t, bin, srcInst, guestPort, payload) - waitForCLIOutput(t, srcLines, "READY", 20*time.Second, srcStderr) - t.Cleanup(func() { cleanupStreamingCLI(t, srcCmd, srcDone, srcStderr) }) - - enableOut := runCLISuccessEnv(t, bin, env, "ingress", "enable") - assert.Contains(t, enableOut, "writing "+filepath.Join(resolverDir, "lnx")) - assert.Contains(t, enableOut, "starting dns on 127.0.0.1:15354") - assert.Contains(t, enableOut, "starting http on 127.0.0.1:18080") - assert.Contains(t, enableOut, "ingress enabled for .lnx") - - resolverPath := filepath.Join(resolverDir, "lnx") - resolverData, err := os.ReadFile(resolverPath) - require.NoError(t, err) - assert.Contains(t, string(resolverData), "nameserver 127.0.0.1") - assert.Contains(t, string(resolverData), "port 15354") - - statusOut := runCLISuccessEnv(t, bin, env, "ingress", "status") - assert.Contains(t, statusOut, "enabled") - assert.Contains(t, statusOut, "dns: 127.0.0.1:15354") - assert.Contains(t, statusOut, "http: 127.0.0.1:18080") - - ip := lookupIngressA(t, dnsAddr, hostName) - assert.Equal(t, "127.0.0.1", ip) - - body := httpGetEventually(t, "http://"+httpAddr+"/", hostName, payload, 10*time.Second) - assert.Contains(t, body, payload) - - disableOut := runCLISuccessEnv(t, bin, env, "ingress", "disable") - assert.Contains(t, disableOut, "removing "+filepath.Join(resolverDir, "lnx")) - assert.Contains(t, disableOut, "ingress disabled") - - statusAfter := runCLISuccessEnv(t, bin, env, "ingress", "status") - assert.Contains(t, statusAfter, "disabled") - _, err = os.Stat(resolverPath) - require.ErrorIs(t, err, os.ErrNotExist) - - waitForProcessSuccess(t, srcDone, 15*time.Second, srcStderr.String()) -} - -func startHTTPServerInstance(t *testing.T, bin, instance string, port int, payload string) (*exec.Cmd, <-chan string, *bytes.Buffer, <-chan error) { - t.Helper() - script := fmt.Sprintf(`python3 -c " -import socket, time -body = b'%s' -s = socket.socket() -s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -s.bind(('0.0.0.0', %d)) -s.listen(1) -print('READY', flush=True) -conn, _ = s.accept() -data = b'' -while b'\\r\\n\\r\\n' not in data: - chunk = conn.recv(4096) - if not chunk: - break - data += chunk -resp = b'HTTP/1.1 200 OK\\r\\nContent-Type: text/plain\\r\\nContent-Length: ' + str(len(body)).encode() + b'\\r\\nConnection: close\\r\\n\\r\\n' + body -conn.sendall(resp) -conn.close() -s.close() -time.sleep(1) -"`, payload, port) - return startStreamingCLI(t, bin, "--instance", instance, "sh", "-c", script) -} - -func runCLIEnv(bin string, env []string, args ...string) (string, error) { - cmd := exec.Command(bin, args...) - cmd.Env = env - out, err := cmd.CombinedOutput() - return string(out), err -} - -func runCLISuccessEnv(t *testing.T, bin string, env []string, args ...string) string { - t.Helper() - out, err := runCLIEnv(bin, env, args...) - require.NoError(t, err, "command failed: %s %v\n%s", bin, args, out) - return out -} - -func lookupIngressA(t *testing.T, serverAddr, host string) string { - t.Helper() - name, err := dnsmessage.NewName(host + ".") - require.NoError(t, err) - - query := dnsmessage.Message{ - Header: dnsmessage.Header{ID: 1, RecursionDesired: true}, - Questions: []dnsmessage.Question{{ - Name: name, - Type: dnsmessage.TypeA, - Class: dnsmessage.ClassINET, - }}, - } - packet, err := query.Pack() - require.NoError(t, err) - - conn, err := net.Dial("udp", serverAddr) - require.NoError(t, err) - defer conn.Close() - require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) - _, err = conn.Write(packet) - require.NoError(t, err) - - buf := make([]byte, 1500) - n, err := conn.Read(buf) - require.NoError(t, err) - - var resp dnsmessage.Message - require.NoError(t, resp.Unpack(buf[:n])) - require.NotEmpty(t, resp.Answers) - a, ok := resp.Answers[0].Body.(*dnsmessage.AResource) - require.True(t, ok, "unexpected dns answer body %T", resp.Answers[0].Body) - return net.IP(a.A[:]).String() -} - -func httpGetEventually(t *testing.T, rawURL, host, want string, timeout time.Duration) string { - t.Helper() - - client := &http.Client{Timeout: 2 * time.Second} - deadline := time.Now().Add(timeout) - last := "" - for time.Now().Before(deadline) { - req, err := http.NewRequest("GET", rawURL, nil) - require.NoError(t, err) - req.Host = host - - resp, err := client.Do(req) - if err == nil { - data, readErr := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if readErr == nil { - last = string(data) - if strings.Contains(last, want) { - return last - } - } - } - time.Sleep(250 * time.Millisecond) - } - t.Fatalf("timed out waiting for %q from %s via host %s; last response=%q", want, rawURL, host, last) - return "" -} diff --git a/old/initramfs.go b/old/initramfs.go deleted file mode 100644 index b23c2bb..0000000 --- a/old/initramfs.go +++ /dev/null @@ -1,70 +0,0 @@ -package lnx - -import ( - "bytes" - "fmt" - "os" - "path/filepath" -) - -// InitBinary must be set by the embedding binary (via go:embed). -// The library itself does not embed the init binary — the caller provides it. -var InitBinary []byte - -// WriteInitramfsTo creates a cpio-format initramfs and is exported for testing. -func WriteInitramfsTo(dir string) (string, error) { - return writeInitramfs(dir) -} - -func writeInitramfs(dir string) (string, error) { - if len(InitBinary) == 0 { - return "", fmt.Errorf("lnx.InitBinary not set; embed the guest init binary and assign it") - } - - path := filepath.Join(dir, "initramfs.cpio") - - var buf bytes.Buffer - writeCpioEntry(&buf, "init", InitBinary, 0100755) - writeCpioEntry(&buf, "TRAILER!!!", nil, 0) - if pad := buf.Len() % 512; pad != 0 { - buf.Write(make([]byte, 512-pad)) - } - - if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { - return "", err - } - return path, nil -} - -// writeCpioEntry writes a single entry in cpio "newc" format. -func writeCpioEntry(buf *bytes.Buffer, name string, data []byte, mode uint32) { - nameBytes := append([]byte(name), 0) - hdr := fmt.Sprintf( - "070701"+ - "%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X", - 1, // inode - mode, // mode - 0, // uid - 0, // gid - 1, // nlink - 0, // mtime - len(data), // filesize - 0, // devmajor - 0, // devminor - 0, // rdevmajor - 0, // rdevminor - len(nameBytes), // namesize - 0, // check - ) - buf.WriteString(hdr) - buf.Write(nameBytes) - if hdrLen := len(hdr) + len(nameBytes); hdrLen%4 != 0 { - buf.Write(make([]byte, 4-hdrLen%4)) - } - if data != nil { - buf.Write(data) - if len(data)%4 != 0 { - buf.Write(make([]byte, 4-len(data)%4)) - } - } -} diff --git a/old/initramfs_test.go b/old/initramfs_test.go deleted file mode 100644 index d169516..0000000 --- a/old/initramfs_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package lnx - -import ( - "bytes" - "reflect" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestWriteCpioEntry_RegularFile(t *testing.T) { - var buf bytes.Buffer - data := []byte("hello world") - writeCpioEntry(&buf, "init", data, 0100755) - - result := buf.Bytes() - // cpio newc magic - assert.Equal(t, "070701", string(result[:6])) - // File size should be encoded at offset 54 (8 hex chars) - assert.Equal(t, "0000000B", string(result[54:62])) - // Name size should be 5 ("init" + null) - assert.Equal(t, "00000005", string(result[94:102])) -} - -func TestWriteCpioEntry_Trailer(t *testing.T) { - var buf bytes.Buffer - writeCpioEntry(&buf, "TRAILER!!!", nil, 0) - - result := buf.Bytes() - assert.Equal(t, "070701", string(result[:6])) - // File size should be 0 - assert.Equal(t, "00000000", string(result[54:62])) -} - -func TestWriteInitramfs_RequiresInitBinary(t *testing.T) { - original := InitBinary - defer func() { InitBinary = original }() - - InitBinary = nil - _, err := writeInitramfs(t.TempDir()) - require.Error(t, err) - assert.Contains(t, err.Error(), "InitBinary not set") -} - -func TestWriteInitramfs_ProducesValidCpio(t *testing.T) { - original := InitBinary - defer func() { InitBinary = original }() - - InitBinary = []byte("#!/bin/sh\necho hi\n") - - dir := t.TempDir() - path, err := writeInitramfs(dir) - require.NoError(t, err) - assert.FileExists(t, path) -} - -func TestConfig_Defaults(t *testing.T) { - cfg := &Config{} - assert.Equal(t, uint(2), cfg.cpus()) - // Default is 50% of host memory; just check it's reasonable (>= 1 GiB). - assert.GreaterOrEqual(t, cfg.memoryBytes(), uint64(1<<30)) -} - -func TestConfig_CustomValues(t *testing.T) { - cfg := &Config{CPUs: 4, MemoryBytes: 2 << 30} - assert.Equal(t, uint(4), cfg.cpus()) - assert.Equal(t, uint64(2<<30), cfg.memoryBytes()) -} - -func TestConfig_AllFieldsCopied(t *testing.T) { - // Verify that every Config field is represented in this test. - // When a new field is added to Config, this test will fail - // until the field is added here — catching ephemeral copy bugs. - typ := reflect.TypeOf(Config{}) - fieldCount := typ.NumField() - - cfg := &Config{ - KernelPath: "/kernel", - RootfsPath: "/rootfs", - InitramfsPath: "/initrd", - CPUs: 4, - MemoryBytes: 8 << 30, - CWD: "/work", - Env: []string{"A=B"}, - Checkpoint: true, - CheckpointDir: "/cp", - Shares: []string{"/extra"}, - Hostname: "test.lnx", - SSHAgent: true, - Ephemeral: true, - SocketDir: "/sock", - NestedRootfs: []NestedRootfs{{InstanceName: "test", RootfsPath: "/nr"}}, - SyncShares: []string{"/sync"}, - DirectShare: true, - } - - // Count fields set above — must match struct field count. - // If this fails, a new field was added to Config but not to this test. - assert.Equal(t, fieldCount, 17, "Config has %d fields but test only covers 17 — update this test and the ephemeral copy in vm.go", fieldCount) - - // Verify all fields are non-zero (catches typos in field names above). - val := reflect.ValueOf(*cfg) - for i := 0; i < val.NumField(); i++ { - f := val.Field(i) - assert.False(t, f.IsZero(), "Config.%s is zero — add it to this test", typ.Field(i).Name) - } -} diff --git a/old/internal/lnxnet/arp.go b/old/internal/lnxnet/arp.go deleted file mode 100644 index 769740c..0000000 --- a/old/internal/lnxnet/arp.go +++ /dev/null @@ -1,48 +0,0 @@ -package lnxnet - -import ( - "encoding/binary" - "net" -) - -const ( - arpRequest = 1 - arpReply = 2 -) - -func (b *Bridge) handleARP(eth *ethernetFrame) { - if len(eth.Payload) < 28 { - return - } - p := eth.Payload - - op := binary.BigEndian.Uint16(p[6:8]) - if op != arpRequest { - return - } - - // Target IP the guest is asking about. - targetIP := net.IP(p[24:28]) - senderIP := net.IP(p[14:18]) - senderMAC := net.HardwareAddr(p[8:14]) - - // Only respond if they're asking for the gateway. - if !targetIP.Equal(net.ParseIP(GatewayIP)) { - return - } - - // Build ARP reply. - reply := make([]byte, 28) - binary.BigEndian.PutUint16(reply[0:2], 1) // hardware type: ethernet - binary.BigEndian.PutUint16(reply[2:4], 0x0800) // protocol type: IPv4 - reply[4] = 6 // hardware size - reply[5] = 4 // protocol size - binary.BigEndian.PutUint16(reply[6:8], arpReply) - copy(reply[8:14], gatewayMAC) // sender MAC (gateway) - copy(reply[14:18], targetIP.To4()) // sender IP (gateway) - copy(reply[18:24], senderMAC) // target MAC (guest) - copy(reply[24:28], senderIP.To4()) // target IP (guest) - - frame := buildEthernet(senderMAC, gatewayMAC, etherTypeARP, reply) - b.sendFrame(frame) -} diff --git a/old/internal/lnxnet/bridge.go b/old/internal/lnxnet/bridge.go deleted file mode 100644 index a50a51b..0000000 --- a/old/internal/lnxnet/bridge.go +++ /dev/null @@ -1,107 +0,0 @@ -// Package lnxnet implements userspace networking for the lnx VM. -package lnxnet - -import ( - "fmt" - "log/slog" - "net" - "syscall" -) - -const ( - GatewayIP = "192.168.64.1" - GuestIP = "192.168.64.2" - SubnetMask = "255.255.255.0" - MTU = 1500 -) - -var gatewayMAC = net.HardwareAddr{0x02, 0x00, 0x00, 0x00, 0x00, 0x01} - -// Bridge is a userspace network bridge between the VM and the host. -type Bridge struct { - hostFd int // our end — raw fd, used for read/write - vmFd int // VM end — raw fd, passed to vz (NEVER wrap in os.File) - guestMAC net.HardwareAddr -} - -// NewBridge creates a unix datagram socket pair and returns a Bridge. -// Neither fd is wrapped in os.File to avoid Go's runtime poller -// interfering with Virtualization.framework's dispatch-based I/O. -func NewBridge() (*Bridge, error) { - fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_DGRAM, 0) - if err != nil { - return nil, fmt.Errorf("socketpair: %w", err) - } - - for _, fd := range fds { - syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, 1*1024*1024) - syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, 4*1024*1024) - } - - return &Bridge{ - hostFd: fds[0], - vmFd: fds[1], - }, nil -} - -// NewBridgeFromFd creates a bridge using an existing fd as the host end. -// The caller is responsible for passing the other end to the VM. -func NewBridgeFromFd(hostFd int) *Bridge { - return &Bridge{ - hostFd: hostFd, - vmFd: -1, - } -} - -// VMFd returns the raw fd for the VM end. -// Pass this to vz.NewFileHandleNetworkDeviceAttachmentWithFd. -// Do NOT wrap in os.File — Go's kqueue registration will steal -// readable events from VZ's dispatch_source. -func (b *Bridge) VMFd() int { - return b.vmFd -} - -// Start begins processing ethernet frames in a goroutine. -func (b *Bridge) Start() { - go b.readLoop() -} - -// Close shuts down the bridge. -func (b *Bridge) Close() { - syscall.Close(b.hostFd) - if b.vmFd >= 0 { - syscall.Close(b.vmFd) - b.vmFd = -1 - } -} - -func (b *Bridge) readLoop() { - buf := make([]byte, MTU+18) - for { - n, err := syscall.Read(b.hostFd, buf) - if err != nil { - slog.Debug("bridge readLoop exiting", "error", err) - return - } - if n < 14 { - continue - } - - frame := make([]byte, n) - copy(frame, buf[:n]) - - slog.Debug("bridge rx", "len", n, "ethertype", fmt.Sprintf("0x%04x", uint16(frame[12])<<8|uint16(frame[13]))) - b.handleFrame(frame) - } -} - -func (b *Bridge) sendFrame(frame []byte) { - go func() { - n, err := syscall.Write(b.hostFd, frame) - if err != nil { - slog.Debug("bridge tx error", "error", err, "len", len(frame)) - } else { - slog.Debug("bridge tx", "len", n) - } - }() -} diff --git a/old/internal/lnxnet/dhcp.go b/old/internal/lnxnet/dhcp.go deleted file mode 100644 index f320582..0000000 --- a/old/internal/lnxnet/dhcp.go +++ /dev/null @@ -1,156 +0,0 @@ -package lnxnet - -import ( - "encoding/binary" - "net" -) - -const ( - dhcpDiscover = 1 - dhcpOffer = 2 - dhcpRequest = 3 - dhcpAck = 5 -) - -// handleDHCP responds to DHCP discover/request messages. -func (b *Bridge) handleDHCP(eth *ethernetFrame, ip *ipv4Header, udpPayload []byte) { - if len(udpPayload) < 240 { - return - } - - op := udpPayload[0] - if op != 1 { // boot request - return - } - - xid := udpPayload[4:8] - clientMAC := net.HardwareAddr(udpPayload[28:34]) - - // Find DHCP message type in options (offset 240+). - msgType := findDHCPOption(udpPayload[240:], 53) - if len(msgType) == 0 { - return - } - - var replyType byte - switch msgType[0] { - case dhcpDiscover: - replyType = dhcpOffer - case dhcpRequest: - replyType = dhcpAck - default: - return - } - - guestIPBytes := net.ParseIP(GuestIP).To4() - gatewayIPBytes := net.ParseIP(GatewayIP).To4() - subnetBytes := net.ParseIP(SubnetMask).To4() - - // Build DHCP reply. - reply := make([]byte, 300) - reply[0] = 2 // boot reply - reply[1] = 1 // ethernet - reply[2] = 6 // hw addr len - copy(reply[4:8], xid) - copy(reply[16:20], guestIPBytes) // yiaddr - copy(reply[20:24], gatewayIPBytes) // siaddr - copy(reply[28:34], clientMAC) - // Magic cookie. - copy(reply[236:240], []byte{99, 130, 83, 99}) - - // DHCP options. - opts := reply[240:] - i := 0 - i += putDHCPOption(opts[i:], 53, []byte{replyType}) // msg type - i += putDHCPOption(opts[i:], 1, subnetBytes) // subnet mask - i += putDHCPOption(opts[i:], 3, gatewayIPBytes) // router - i += putDHCPOption(opts[i:], 6, append([]byte{8, 8, 8, 8}, []byte{8, 8, 4, 4}...)) // DNS - i += putDHCPOption(opts[i:], 51, []byte{0, 0, 0xFF, 0xFF}) // lease time (infinite) - i += putDHCPOption(opts[i:], 54, gatewayIPBytes) // server ID - opts[i] = 255 // end - i++ - - dhcpReply := reply[:240+i] - - // Wrap in UDP (src 67 -> dst 68). - udp := buildUDP(67, 68, dhcpReply) - - // Wrap in IP. - ipPkt := buildIPv4(gatewayIPBytes, net.IPv4bcast.To4(), protoUDP, udp) - - // Wrap in ethernet — broadcast. - frame := buildEthernet( - net.HardwareAddr{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, - gatewayMAC, - etherTypeIPv4, - ipPkt, - ) - b.sendFrame(frame) -} - -func findDHCPOption(opts []byte, code byte) []byte { - for i := 0; i < len(opts); { - if opts[i] == 255 { // end - return nil - } - if opts[i] == 0 { // pad - i++ - continue - } - if i+1 >= len(opts) { - return nil - } - optCode := opts[i] - optLen := int(opts[i+1]) - i += 2 - if i+optLen > len(opts) { - return nil - } - if optCode == code { - return opts[i : i+optLen] - } - i += optLen - } - return nil -} - -func putDHCPOption(buf []byte, code byte, data []byte) int { - buf[0] = code - buf[1] = byte(len(data)) - copy(buf[2:], data) - return 2 + len(data) -} - -func buildUDP(srcPort, dstPort uint16, payload []byte) []byte { - udp := make([]byte, 8+len(payload)) - binary.BigEndian.PutUint16(udp[0:2], srcPort) - binary.BigEndian.PutUint16(udp[2:4], dstPort) - binary.BigEndian.PutUint16(udp[4:6], uint16(8+len(payload))) - // checksum = 0 (optional for UDP over IPv4) - copy(udp[8:], payload) - return udp -} - -func buildIPv4(srcIP, dstIP net.IP, protocol uint8, payload []byte) []byte { - totalLen := 20 + len(payload) - pkt := make([]byte, totalLen) - pkt[0] = 0x45 // version 4, IHL 5 - binary.BigEndian.PutUint16(pkt[2:4], uint16(totalLen)) - pkt[8] = 64 // TTL - pkt[9] = protocol - copy(pkt[12:16], srcIP) - copy(pkt[16:20], dstIP) - - // Compute header checksum. - var sum uint32 - for i := 0; i < 20; i += 2 { - sum += uint32(binary.BigEndian.Uint16(pkt[i : i+2])) - } - for sum > 0xffff { - sum = (sum >> 16) + (sum & 0xffff) - } - binary.BigEndian.PutUint16(pkt[10:12], ^uint16(sum)) - - copy(pkt[20:], payload) - return pkt -} diff --git a/old/internal/lnxnet/dhcp_test.go b/old/internal/lnxnet/dhcp_test.go deleted file mode 100644 index 5a3d340..0000000 --- a/old/internal/lnxnet/dhcp_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package lnxnet - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFindDHCPOption(t *testing.T) { - // Option 53 (msg type) = 1 (discover), then end marker. - opts := []byte{53, 1, 1, 255} - val := findDHCPOption(opts, 53) - assert.Equal(t, []byte{1}, val) -} - -func TestFindDHCPOption_NotFound(t *testing.T) { - opts := []byte{53, 1, 1, 255} - val := findDHCPOption(opts, 99) - assert.Nil(t, val) -} - -func TestFindDHCPOption_Empty(t *testing.T) { - val := findDHCPOption([]byte{255}, 53) - assert.Nil(t, val) -} - -func TestPutDHCPOption(t *testing.T) { - buf := make([]byte, 10) - n := putDHCPOption(buf, 53, []byte{2}) - assert.Equal(t, 3, n) - assert.Equal(t, byte(53), buf[0]) - assert.Equal(t, byte(1), buf[1]) - assert.Equal(t, byte(2), buf[2]) -} - -func TestBuildUDP_Length(t *testing.T) { - payload := []byte("hello") - udp := buildUDP(1234, 5678, payload) - assert.Equal(t, 8+len(payload), len(udp)) - // Source port. - assert.Equal(t, byte(0x04), udp[0]) - assert.Equal(t, byte(0xD2), udp[1]) - // Dest port. - assert.Equal(t, byte(0x16), udp[2]) - assert.Equal(t, byte(0x2E), udp[3]) -} diff --git a/old/internal/lnxnet/ethernet.go b/old/internal/lnxnet/ethernet.go deleted file mode 100644 index 8e7ce91..0000000 --- a/old/internal/lnxnet/ethernet.go +++ /dev/null @@ -1,59 +0,0 @@ -package lnxnet - -import ( - "encoding/binary" - "net" -) - -const ( - etherTypeARP = 0x0806 - etherTypeIPv4 = 0x0800 -) - -type ethernetFrame struct { - DstMAC net.HardwareAddr - SrcMAC net.HardwareAddr - EtherType uint16 - Payload []byte -} - -func parseEthernet(frame []byte) *ethernetFrame { - if len(frame) < 14 { - return nil - } - return ðernetFrame{ - DstMAC: net.HardwareAddr(frame[0:6]), - SrcMAC: net.HardwareAddr(frame[6:12]), - EtherType: binary.BigEndian.Uint16(frame[12:14]), - Payload: frame[14:], - } -} - -func buildEthernet(dst, src net.HardwareAddr, etherType uint16, payload []byte) []byte { - frame := make([]byte, 14+len(payload)) - copy(frame[0:6], dst) - copy(frame[6:12], src) - binary.BigEndian.PutUint16(frame[12:14], etherType) - copy(frame[14:], payload) - return frame -} - -func (b *Bridge) handleFrame(frame []byte) { - eth := parseEthernet(frame) - if eth == nil { - return - } - - // Learn the guest's MAC from the first frame we see. - if b.guestMAC == nil { - b.guestMAC = make(net.HardwareAddr, 6) - copy(b.guestMAC, eth.SrcMAC) - } - - switch eth.EtherType { - case etherTypeARP: - b.handleARP(eth) - case etherTypeIPv4: - b.handleIPv4(eth) - } -} diff --git a/old/internal/lnxnet/ethernet_test.go b/old/internal/lnxnet/ethernet_test.go deleted file mode 100644 index f39ec6d..0000000 --- a/old/internal/lnxnet/ethernet_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package lnxnet - -import ( - "net" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseEthernet(t *testing.T) { - dst := net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06} - src := net.HardwareAddr{0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f} - payload := []byte("hello") - - frame := buildEthernet(dst, src, etherTypeIPv4, payload) - - eth := parseEthernet(frame) - require.NotNil(t, eth) - assert.Equal(t, dst, net.HardwareAddr(eth.DstMAC)) - assert.Equal(t, src, net.HardwareAddr(eth.SrcMAC)) - assert.Equal(t, uint16(etherTypeIPv4), eth.EtherType) - assert.Equal(t, payload, eth.Payload) -} - -func TestParseEthernet_TooShort(t *testing.T) { - assert.Nil(t, parseEthernet([]byte{1, 2, 3})) -} - -func TestBuildEthernet_RoundTrip(t *testing.T) { - dst := net.HardwareAddr{0xff, 0xff, 0xff, 0xff, 0xff, 0xff} - src := net.HardwareAddr{0x02, 0x00, 0x00, 0x00, 0x00, 0x01} - - frame := buildEthernet(dst, src, etherTypeARP, []byte{42}) - eth := parseEthernet(frame) - require.NotNil(t, eth) - assert.Equal(t, uint16(etherTypeARP), eth.EtherType) - assert.Equal(t, []byte{42}, eth.Payload) -} diff --git a/old/internal/lnxnet/ipv4.go b/old/internal/lnxnet/ipv4.go deleted file mode 100644 index 48fb16f..0000000 --- a/old/internal/lnxnet/ipv4.go +++ /dev/null @@ -1,62 +0,0 @@ -package lnxnet - -import ( - "encoding/binary" - "net" -) - -const ( - protoICMP = 1 - protoTCP = 6 - protoUDP = 17 -) - -type ipv4Header struct { - IHL int - TotalLen int - Protocol uint8 - SrcIP net.IP - DstIP net.IP - Payload []byte - Raw []byte // full IP packet including header -} - -func parseIPv4(data []byte) *ipv4Header { - if len(data) < 20 { - return nil - } - ihl := int(data[0]&0x0f) * 4 - if len(data) < ihl { - return nil - } - totalLen := int(binary.BigEndian.Uint16(data[2:4])) - if totalLen > len(data) { - totalLen = len(data) - } - - return &ipv4Header{ - IHL: ihl, - TotalLen: totalLen, - Protocol: data[9], - SrcIP: net.IP(data[12:16]), - DstIP: net.IP(data[16:20]), - Payload: data[ihl:totalLen], - Raw: data[:totalLen], - } -} - -func (b *Bridge) handleIPv4(eth *ethernetFrame) { - ip := parseIPv4(eth.Payload) - if ip == nil { - return - } - - switch ip.Protocol { - case protoUDP: - b.handleUDP(eth, ip) - case protoTCP: - b.handleTCP(eth, ip) - case protoICMP: - // TODO: ping support - } -} diff --git a/old/internal/lnxnet/ipv4_test.go b/old/internal/lnxnet/ipv4_test.go deleted file mode 100644 index b3766dd..0000000 --- a/old/internal/lnxnet/ipv4_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package lnxnet - -import ( - "net" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBuildIPv4_Checksum(t *testing.T) { - src := net.ParseIP("192.168.64.1").To4() - dst := net.ParseIP("192.168.64.2").To4() - payload := []byte("test data") - - pkt := buildIPv4(src, dst, protoUDP, payload) - require.True(t, len(pkt) >= 20) - - // Verify header checksum is valid by summing the header — should be 0. - var sum uint32 - for i := 0; i < 20; i += 2 { - sum += uint32(pkt[i])<<8 | uint32(pkt[i+1]) - } - for sum > 0xffff { - sum = (sum >> 16) + (sum & 0xffff) - } - assert.Equal(t, uint16(0xffff), uint16(sum)) -} - -func TestParseIPv4_RoundTrip(t *testing.T) { - src := net.ParseIP("10.0.0.1").To4() - dst := net.ParseIP("10.0.0.2").To4() - payload := []byte("hello") - - pkt := buildIPv4(src, dst, protoTCP, payload) - ip := parseIPv4(pkt) - require.NotNil(t, ip) - - assert.Equal(t, src, ip.SrcIP.To4()) - assert.Equal(t, dst, ip.DstIP.To4()) - assert.Equal(t, uint8(protoTCP), ip.Protocol) - assert.Equal(t, payload, ip.Payload) -} - -func TestParseIPv4_TooShort(t *testing.T) { - assert.Nil(t, parseIPv4([]byte{1, 2, 3})) -} diff --git a/old/internal/lnxnet/tcp.go b/old/internal/lnxnet/tcp.go deleted file mode 100644 index 1703d96..0000000 --- a/old/internal/lnxnet/tcp.go +++ /dev/null @@ -1,218 +0,0 @@ -package lnxnet - -import ( - "encoding/binary" - "fmt" - "io" - "log/slog" - "net" - "sync" -) - -type tcpConnKey struct { - SrcPort uint16 - DstIP string - DstPort uint16 -} - -type tcpConn struct { - hostConn net.Conn - guestSeq uint32 - guestAck uint32 - hostSeq uint32 - state string // "syn_received", "established", "closed" - mu sync.Mutex -} - -var ( - tcpConns = make(map[tcpConnKey]*tcpConn) - tcpConnsMu sync.Mutex -) - -func (b *Bridge) handleTCP(eth *ethernetFrame, ip *ipv4Header) { - if len(ip.Payload) < 20 { - return - } - - srcPort := binary.BigEndian.Uint16(ip.Payload[0:2]) - dstPort := binary.BigEndian.Uint16(ip.Payload[2:4]) - seqNum := binary.BigEndian.Uint32(ip.Payload[4:8]) - ackNum := binary.BigEndian.Uint32(ip.Payload[8:12]) - dataOff := int((ip.Payload[12] >> 4)) * 4 - flags := ip.Payload[13] - - flagSYN := flags&0x02 != 0 - flagACK := flags&0x10 != 0 - flagFIN := flags&0x01 != 0 - flagRST := flags&0x04 != 0 - - key := tcpConnKey{SrcPort: srcPort, DstIP: ip.DstIP.String(), DstPort: dstPort} - - if flagRST { - b.closeTCPConn(key) - return - } - - if flagSYN && !flagACK { - // New connection — SYN from guest. - go b.handleTCPSyn(key, ip, srcPort, dstPort, seqNum) - return - } - - tcpConnsMu.Lock() - tc, ok := tcpConns[key] - tcpConnsMu.Unlock() - if !ok { - // Unknown connection, send RST. - b.sendTCPRST(ip.DstIP, ip.SrcIP, dstPort, srcPort, ackNum) - return - } - - tc.mu.Lock() - defer tc.mu.Unlock() - - if flagFIN { - // Guest closing connection. - tc.guestSeq = seqNum + 1 - // ACK the FIN. - b.sendTCPPacket(ip.DstIP, ip.SrcIP, dstPort, srcPort, tc.hostSeq, tc.guestSeq, 0x10, nil) // ACK - // Send our FIN. - b.sendTCPPacket(ip.DstIP, ip.SrcIP, dstPort, srcPort, tc.hostSeq, tc.guestSeq, 0x11, nil) // FIN+ACK - tc.hostSeq++ - tc.state = "closed" - if tc.hostConn != nil { - tc.hostConn.Close() - } - return - } - - // Data from guest. - if dataOff < len(ip.Payload) { - data := ip.Payload[dataOff:] - if len(data) > 0 && tc.hostConn != nil { - tc.hostConn.Write(data) - tc.guestSeq = seqNum + uint32(len(data)) - // ACK the data. - b.sendTCPPacket(ip.DstIP, ip.SrcIP, dstPort, srcPort, tc.hostSeq, tc.guestSeq, 0x10, nil) - } - } -} - -func (b *Bridge) handleTCPSyn(key tcpConnKey, ip *ipv4Header, srcPort, dstPort uint16, guestSeq uint32) { - dst := net.JoinHostPort(ip.DstIP.String(), fmt.Sprintf("%d", dstPort)) - - hostConn, err := net.Dial("tcp", dst) - if err != nil { - slog.Debug("tcp dial failed", "dst", dst, "error", err) - b.sendTCPRST(ip.DstIP, ip.SrcIP, dstPort, srcPort, 0) - return - } - - tc := &tcpConn{ - hostConn: hostConn, - guestSeq: guestSeq + 1, - hostSeq: 1000, // initial sequence number - state: "syn_received", - } - - tcpConnsMu.Lock() - tcpConns[key] = tc - tcpConnsMu.Unlock() - - // Send SYN+ACK. - b.sendTCPPacket(ip.DstIP, ip.SrcIP, dstPort, srcPort, tc.hostSeq, tc.guestSeq, 0x12, nil) - tc.hostSeq++ - tc.state = "established" - - // Read from host, send to guest. - go func() { - defer b.closeTCPConn(key) - - buf := make([]byte, MTU-40) // leave room for IP+TCP headers - for { - n, err := hostConn.Read(buf) - if n > 0 { - tc.mu.Lock() - b.sendTCPPacket(ip.DstIP, ip.SrcIP, dstPort, srcPort, tc.hostSeq, tc.guestSeq, 0x18, buf[:n]) // PSH+ACK - tc.hostSeq += uint32(n) - tc.mu.Unlock() - } - if err != nil { - if err != io.EOF { - slog.Debug("tcp read from host", "error", err) - } - // Send FIN to guest. - tc.mu.Lock() - b.sendTCPPacket(ip.DstIP, ip.SrcIP, dstPort, srcPort, tc.hostSeq, tc.guestSeq, 0x11, nil) // FIN+ACK - tc.hostSeq++ - tc.mu.Unlock() - return - } - } - }() -} - -func (b *Bridge) closeTCPConn(key tcpConnKey) { - tcpConnsMu.Lock() - tc, ok := tcpConns[key] - delete(tcpConns, key) - tcpConnsMu.Unlock() - if ok && tc.hostConn != nil { - tc.hostConn.Close() - } -} - -func (b *Bridge) sendTCPRST(srcIP, dstIP net.IP, srcPort, dstPort uint16, seq uint32) { - b.sendTCPPacket(srcIP, dstIP, srcPort, dstPort, seq, 0, 0x14, nil) // RST+ACK -} - -func (b *Bridge) sendTCPPacket(srcIP, dstIP net.IP, srcPort, dstPort uint16, seq, ack uint32, flags byte, payload []byte) { - tcpLen := 20 + len(payload) - tcp := make([]byte, tcpLen) - binary.BigEndian.PutUint16(tcp[0:2], srcPort) - binary.BigEndian.PutUint16(tcp[2:4], dstPort) - binary.BigEndian.PutUint32(tcp[4:8], seq) - binary.BigEndian.PutUint32(tcp[8:12], ack) - tcp[12] = 0x50 // data offset = 5 (20 bytes) - tcp[13] = flags - binary.BigEndian.PutUint16(tcp[14:16], 65535) // window size - copy(tcp[20:], payload) - - // TCP checksum (with pseudo-header). - binary.BigEndian.PutUint16(tcp[16:18], tcpChecksum(srcIP.To4(), dstIP.To4(), tcp)) - - ipPkt := buildIPv4(srcIP.To4(), dstIP.To4(), protoTCP, tcp) - frame := buildEthernet(b.guestMAC, gatewayMAC, etherTypeIPv4, ipPkt) - b.sendFrame(frame) -} - -func tcpChecksum(srcIP, dstIP net.IP, tcpSegment []byte) uint16 { - // Pseudo-header. - var sum uint32 - sum += uint32(srcIP[0])<<8 | uint32(srcIP[1]) - sum += uint32(srcIP[2])<<8 | uint32(srcIP[3]) - sum += uint32(dstIP[0])<<8 | uint32(dstIP[1]) - sum += uint32(dstIP[2])<<8 | uint32(dstIP[3]) - sum += uint32(protoTCP) - sum += uint32(len(tcpSegment)) - - // Clear checksum field before computing. - orig := binary.BigEndian.Uint16(tcpSegment[16:18]) - binary.BigEndian.PutUint16(tcpSegment[16:18], 0) - - // Sum TCP segment. - for i := 0; i < len(tcpSegment)-1; i += 2 { - sum += uint32(binary.BigEndian.Uint16(tcpSegment[i : i+2])) - } - if len(tcpSegment)%2 != 0 { - sum += uint32(tcpSegment[len(tcpSegment)-1]) << 8 - } - - // Restore. - binary.BigEndian.PutUint16(tcpSegment[16:18], orig) - - for sum > 0xffff { - sum = (sum >> 16) + (sum & 0xffff) - } - return ^uint16(sum) -} diff --git a/old/internal/lnxnet/tcp_test.go b/old/internal/lnxnet/tcp_test.go deleted file mode 100644 index bc2a5d8..0000000 --- a/old/internal/lnxnet/tcp_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package lnxnet - -import ( - "encoding/binary" - "net" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestTCPChecksum(t *testing.T) { - srcIP := net.ParseIP("192.168.64.1").To4() - dstIP := net.ParseIP("192.168.64.2").To4() - - // Build a minimal TCP SYN packet. - tcp := make([]byte, 20) - binary.BigEndian.PutUint16(tcp[0:2], 80) // src port - binary.BigEndian.PutUint16(tcp[2:4], 12345) // dst port - binary.BigEndian.PutUint32(tcp[4:8], 1000) // seq - binary.BigEndian.PutUint32(tcp[8:12], 0) // ack - tcp[12] = 0x50 // data offset = 5 - tcp[13] = 0x02 // SYN - binary.BigEndian.PutUint16(tcp[14:16], 65535) // window - - checksum := tcpChecksum(srcIP, dstIP, tcp) - - // Set it and verify. - binary.BigEndian.PutUint16(tcp[16:18], checksum) - - // Recompute — should be 0 (or 0xffff for ones-complement). - verify := tcpChecksum(srcIP, dstIP, tcp) - // When we checksum a segment that already has a valid checksum, result is 0. - // But our function clears the field first, so we verify differently: - // just ensure the checksum is non-zero (valid). - assert.NotEqual(t, uint16(0), checksum) - _ = verify -} - -func TestTCPChecksum_WithPayload(t *testing.T) { - srcIP := net.ParseIP("10.0.0.1").To4() - dstIP := net.ParseIP("10.0.0.2").To4() - - payload := []byte("Hello, World!") - tcp := make([]byte, 20+len(payload)) - binary.BigEndian.PutUint16(tcp[0:2], 8080) - binary.BigEndian.PutUint16(tcp[2:4], 443) - binary.BigEndian.PutUint32(tcp[4:8], 100) - binary.BigEndian.PutUint32(tcp[8:12], 200) - tcp[12] = 0x50 - tcp[13] = 0x18 // PSH+ACK - binary.BigEndian.PutUint16(tcp[14:16], 65535) - copy(tcp[20:], payload) - - checksum := tcpChecksum(srcIP, dstIP, tcp) - assert.NotEqual(t, uint16(0), checksum) - - // Odd-length payload should also work. - payload2 := []byte("Hi!") - tcp2 := make([]byte, 20+len(payload2)) - copy(tcp2, tcp[:20]) - copy(tcp2[20:], payload2) - checksum2 := tcpChecksum(srcIP, dstIP, tcp2) - assert.NotEqual(t, uint16(0), checksum2) -} - -func BenchmarkTCPChecksum(b *testing.B) { - srcIP := net.ParseIP("192.168.64.1").To4() - dstIP := net.ParseIP("192.168.64.2").To4() - tcp := make([]byte, 20+1460) // typical MSS - binary.BigEndian.PutUint16(tcp[0:2], 80) - binary.BigEndian.PutUint16(tcp[2:4], 12345) - tcp[12] = 0x50 - tcp[13] = 0x18 - binary.BigEndian.PutUint16(tcp[14:16], 65535) - for i := 20; i < len(tcp); i++ { - tcp[i] = byte(i) - } - - b.ResetTimer() - b.SetBytes(int64(len(tcp))) - for range b.N { - tcpChecksum(srcIP, dstIP, tcp) - } -} - -func BenchmarkBuildIPv4(b *testing.B) { - src := net.ParseIP("192.168.64.1").To4() - dst := net.ParseIP("192.168.64.2").To4() - payload := make([]byte, 1460) - - b.ResetTimer() - b.SetBytes(int64(20 + len(payload))) - for range b.N { - buildIPv4(src, dst, protoTCP, payload) - } -} - -func BenchmarkBuildEthernet(b *testing.B) { - dst := net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06} - src := net.HardwareAddr{0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f} - payload := make([]byte, 1500) - - b.ResetTimer() - b.SetBytes(int64(14 + len(payload))) - for range b.N { - buildEthernet(dst, src, etherTypeIPv4, payload) - } -} - -func BenchmarkParseEthernet(b *testing.B) { - dst := net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06} - src := net.HardwareAddr{0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f} - frame := buildEthernet(dst, src, etherTypeIPv4, make([]byte, 1460)) - - b.ResetTimer() - b.SetBytes(int64(len(frame))) - for range b.N { - parseEthernet(frame) - } -} diff --git a/old/internal/lnxnet/udp.go b/old/internal/lnxnet/udp.go deleted file mode 100644 index 5bf4165..0000000 --- a/old/internal/lnxnet/udp.go +++ /dev/null @@ -1,89 +0,0 @@ -package lnxnet - -import ( - "encoding/binary" - "fmt" - "log/slog" - "net" -) - -func (b *Bridge) handleUDP(eth *ethernetFrame, ip *ipv4Header) { - if len(ip.Payload) < 8 { - return - } - - srcPort := binary.BigEndian.Uint16(ip.Payload[0:2]) - dstPort := binary.BigEndian.Uint16(ip.Payload[2:4]) - udpPayload := ip.Payload[8:] - - // DHCP: client (68) -> server (67). - if srcPort == 68 && dstPort == 67 { - b.handleDHCP(eth, ip, udpPayload) - return - } - - // DNS: forward to host resolver. - if dstPort == 53 { - b.handleDNS(eth, ip, srcPort, udpPayload) - return - } - - // Generic UDP: relay through host. - go b.relayUDP(eth, ip, srcPort, dstPort, udpPayload) -} - -func (b *Bridge) relayUDP(eth *ethernetFrame, ip *ipv4Header, srcPort, dstPort uint16, payload []byte) { - dst := net.JoinHostPort(ip.DstIP.String(), itoa(dstPort)) - conn, err := net.Dial("udp", dst) - if err != nil { - slog.Debug("udp dial failed", "dst", dst, "error", err) - return - } - defer conn.Close() - - if _, err := conn.Write(payload); err != nil { - return - } - - buf := make([]byte, MTU) - n, err := conn.Read(buf) - if err != nil { - return - } - - b.sendUDPReply(ip.DstIP, ip.SrcIP, dstPort, srcPort, buf[:n]) -} - -func (b *Bridge) handleDNS(eth *ethernetFrame, ip *ipv4Header, srcPort uint16, payload []byte) { - go func() { - conn, err := net.Dial("udp", "8.8.8.8:53") - if err != nil { - slog.Debug("dns dial failed", "error", err) - return - } - defer conn.Close() - - if _, err := conn.Write(payload); err != nil { - return - } - - buf := make([]byte, MTU) - n, err := conn.Read(buf) - if err != nil { - return - } - - b.sendUDPReply(ip.DstIP, ip.SrcIP, 53, srcPort, buf[:n]) - }() -} - -func (b *Bridge) sendUDPReply(srcIP, dstIP net.IP, srcPort, dstPort uint16, payload []byte) { - udp := buildUDP(srcPort, dstPort, payload) - ipPkt := buildIPv4(srcIP.To4(), dstIP.To4(), protoUDP, udp) - frame := buildEthernet(b.guestMAC, gatewayMAC, etherTypeIPv4, ipPkt) - b.sendFrame(frame) -} - -func itoa(n uint16) string { - return fmt.Sprintf("%d", n) -} diff --git a/old/internal/lnxoci/oci.go b/old/internal/lnxoci/oci.go deleted file mode 100644 index 9f64892..0000000 --- a/old/internal/lnxoci/oci.go +++ /dev/null @@ -1,347 +0,0 @@ -// Package lnxoci provides OCI container image pulling and conversion to ext4 -// rootfs images. It handles registry fetching, layer caching, and direct ext4 -// writes via go2fs (no VM or mount needed). -package lnxoci - -import ( - "archive/tar" - "bufio" - "compress/gzip" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "log/slog" - "os" - "path" - "path/filepath" - "strconv" - "strings" - - "github.com/google/go-containerregistry/pkg/name" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/semistrict/go2fs" - "golang.org/x/sys/unix" -) - -// DefaultImageSize is the size of newly created ext4 images (sparse). -const DefaultImageSize = 4 * 1024 * 1024 * 1024 - -// Image wraps a pulled OCI image with its layer info. -type Image struct { - image v1.Image - layers []v1.Layer - config *v1.ConfigFile -} - -// DefaultCmd returns the command to run from the image config (Entrypoint + Cmd). -func (img *Image) DefaultCmd() []string { - cfg := img.config.Config - var args []string - args = append(args, cfg.Entrypoint...) - args = append(args, cfg.Cmd...) - return args -} - -// ExposedPorts returns the TCP ports declared in the image's EXPOSE directives. -func (img *Image) ExposedPorts() []uint16 { - var ports []uint16 - for key := range img.config.Config.ExposedPorts { - portStr := strings.SplitN(key, "/", 2)[0] - n, err := strconv.ParseUint(portStr, 10, 16) - if err == nil && n > 0 { - ports = append(ports, uint16(n)) - } - } - return ports -} - -// Pull fetches an OCI image from a registry and caches layer blobs in blobDir. -func Pull(ref string, blobDir string) (*Image, error) { - parsed, err := name.ParseReference(ref) - if err != nil { - return nil, fmt.Errorf("parse reference %q: %w", ref, err) - } - - platform := v1.Platform{ - Architecture: "arm64", - OS: "linux", - } - - desc, err := remote.Get(parsed, remote.WithPlatform(platform)) - if err != nil { - return nil, fmt.Errorf("fetch descriptor: %w", err) - } - - img, err := desc.Image() - if err != nil { - return nil, fmt.Errorf("resolve image: %w", err) - } - - layers, err := img.Layers() - if err != nil { - return nil, fmt.Errorf("get layers: %w", err) - } - - config, err := img.ConfigFile() - if err != nil { - return nil, fmt.Errorf("get config: %w", err) - } - - for i, layer := range layers { - digest, err := layer.Digest() - if err != nil { - return nil, fmt.Errorf("layer %d digest: %w", i, err) - } - - blobPath := filepath.Join(blobDir, digest.Hex) - if _, err := os.Stat(blobPath); err == nil { - fmt.Fprintf(os.Stderr, " layer %d: %s (cached)\n", i, digest.Hex[:12]) - continue - } - - size, _ := layer.Size() - fmt.Fprintf(os.Stderr, " layer %d: %s (%.1f MB)\n", i, digest.Hex[:12], float64(size)/(1024*1024)) - - rc, err := layer.Compressed() - if err != nil { - return nil, fmt.Errorf("layer %d read: %w", i, err) - } - - tmp := blobPath + ".tmp" - f, err := os.Create(tmp) - if err != nil { - rc.Close() - return nil, fmt.Errorf("create blob: %w", err) - } - if _, err := io.Copy(f, rc); err != nil { - f.Close() - rc.Close() - os.Remove(tmp) - return nil, fmt.Errorf("download layer %d: %w", i, err) - } - f.Close() - rc.Close() - - if err := os.Rename(tmp, blobPath); err != nil { - os.Remove(tmp) - return nil, err - } - } - - return &Image{image: img, layers: layers, config: config}, nil -} - -// BuildLayers creates cumulative ext4 snapshots for each layer using APFS -// clonefile between layers and go2fs for direct ext4 writes. -// blobDir is where compressed layer blobs are cached. -// layerDir is where the cumulative ext4 snapshots are stored. -// Returns the path to the final layer's ext4 file. -func BuildLayers(img *Image, blobDir, layerDir string) (string, error) { - var diffIDs []v1.Hash - for i, layer := range img.layers { - diffID, err := layer.DiffID() - if err != nil { - return "", fmt.Errorf("layer %d diff ID: %w", i, err) - } - diffIDs = append(diffIDs, diffID) - } - - var prevPath string - var finalPath string - - for i, layer := range img.layers { - cid := chainID(diffIDs[:i+1]) - layerPath := filepath.Join(layerDir, cid+".ext4") - - if _, err := os.Stat(layerPath); err == nil { - fmt.Fprintf(os.Stderr, " layer %d/%d: %s (cached)\n", i+1, len(img.layers), cid[:12]) - prevPath = layerPath - finalPath = layerPath - continue - } - - fmt.Fprintf(os.Stderr, " layer %d/%d: %s", i+1, len(img.layers), cid[:12]) - - tmp := layerPath + ".tmp" - - if prevPath != "" { - if err := unix.Clonefile(prevPath, tmp, 0); err != nil { - return "", fmt.Errorf("clonefile layer %d: %w", i, err) - } - fmt.Fprintf(os.Stderr, " (cloned)\n") - } else { - if err := CreateEmptyExt4(tmp, DefaultImageSize); err != nil { - return "", fmt.Errorf("create base ext4: %w", err) - } - fmt.Fprintf(os.Stderr, "\n") - } - - digest, err := layer.Digest() - if err != nil { - os.Remove(tmp) - return "", fmt.Errorf("layer %d digest: %w", i, err) - } - - blobPath := filepath.Join(blobDir, digest.Hex) - if err := ApplyLayerToExt4(tmp, blobPath); err != nil { - os.Remove(tmp) - return "", fmt.Errorf("apply layer %d: %w", i, err) - } - - if err := os.Rename(tmp, layerPath); err != nil { - os.Remove(tmp) - return "", err - } - - prevPath = layerPath - finalPath = layerPath - } - - return finalPath, nil -} - -// chainID computes the chain ID for a sequence of layer diff IDs. -func chainID(diffIDs []v1.Hash) string { - if len(diffIDs) == 0 { - return "" - } - chain := diffIDs[0].Hex - for i := 1; i < len(diffIDs); i++ { - h := sha256.Sum256([]byte("sha256:" + chain + " " + diffIDs[i].String())) - chain = hex.EncodeToString(h[:]) - } - return chain -} - -// CreateEmptyExt4 creates a sparse ext4 filesystem image. -func CreateEmptyExt4(path string, sizeBytes uint64) error { - fs, err := go2fs.Create(path, sizeBytes) - if err != nil { - return err - } - return fs.Close() -} - -// ApplyLayerToExt4 opens an existing ext4 image and applies an OCI layer -// tar (compressed) to it using go2fs — pure Go, no mount or VM required. -func ApplyLayerToExt4(ext4Path, blobPath string) error { - f, err := os.Open(blobPath) - if err != nil { - return fmt.Errorf("open blob: %w", err) - } - defer f.Close() - - fs, err := go2fs.Open(ext4Path) - if err != nil { - return fmt.Errorf("open ext4: %w", err) - } - defer fs.Close() - - br := bufio.NewReader(f) - var r io.Reader = br - if peek, err := br.Peek(2); err == nil && peek[0] == 0x1f && peek[1] == 0x8b { - gz, err := gzip.NewReader(br) - if err != nil { - return fmt.Errorf("gzip: %w", err) - } - defer gz.Close() - r = gz - } - - return applyTarToFS(fs, tar.NewReader(r)) -} - -func applyTarToFS(fs *go2fs.FS, tr *tar.Reader) error { - var dirs, files, symlinks, hardlinks, devs int - - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("read tar header: %w", err) - } - - name := path.Clean(hdr.Name) - name = strings.TrimPrefix(name, "/") - name = strings.TrimPrefix(name, "./") - if name == "" || name == "." { - continue - } - - base := path.Base(name) - if strings.HasPrefix(base, ".wh.") { - continue - } - - uid := uint32(hdr.Uid) - gid := uint32(hdr.Gid) - mtime := hdr.ModTime.Unix() - mode := uint32(hdr.Mode & 07777) - - switch hdr.Typeflag { - case tar.TypeDir: - if err := fs.Mkdir(name, mode, uid, gid, mtime); err != nil { - slog.Debug("mkdir (may exist)", "path", name, "error", err) - } - dirs++ - case tar.TypeReg: - data, err := io.ReadAll(tr) - if err != nil { - return fmt.Errorf("read %q: %w", name, err) - } - if err := fs.WriteFile(name, mode, uid, gid, mtime, data); err != nil { - return fmt.Errorf("write %q: %w", name, err) - } - files++ - case tar.TypeSymlink: - if err := fs.Symlink(name, hdr.Linkname, uid, gid, mtime); err != nil { - return fmt.Errorf("symlink %q -> %q: %w", name, hdr.Linkname, err) - } - symlinks++ - case tar.TypeLink: - target := path.Clean(hdr.Linkname) - target = strings.TrimPrefix(target, "/") - target = strings.TrimPrefix(target, "./") - if err := fs.Hardlink(name, target); err != nil { - return fmt.Errorf("hardlink %q -> %q: %w", name, hdr.Linkname, err) - } - hardlinks++ - case tar.TypeChar: - if err := fs.Mknod(name, 0020000|mode, uid, gid, mtime, - uint32(hdr.Devmajor), uint32(hdr.Devminor)); err != nil { - return fmt.Errorf("mknod char %q: %w", name, err) - } - devs++ - case tar.TypeBlock: - if err := fs.Mknod(name, 0060000|mode, uid, gid, mtime, - uint32(hdr.Devmajor), uint32(hdr.Devminor)); err != nil { - return fmt.Errorf("mknod block %q: %w", name, err) - } - devs++ - case tar.TypeFifo: - if err := fs.Mknod(name, 0010000|mode, uid, gid, mtime, 0, 0); err != nil { - return fmt.Errorf("mknod fifo %q: %w", name, err) - } - devs++ - } - } - - fmt.Fprintf(os.Stderr, " %d dirs, %d files, %d symlinks, %d hardlinks\n", - dirs, files, symlinks, hardlinks) - return nil -} - -// SlugFromRef derives a filesystem-safe slug from a Docker image reference. -func SlugFromRef(ref string) string { - name := ref - if i := strings.LastIndex(name, "/"); i >= 0 { - name = name[i+1:] - } - name = strings.ReplaceAll(name, ":", "-") - name = strings.ReplaceAll(name, ".", "-") - return name -} diff --git a/old/internal/macho/inject.go b/old/internal/macho/inject.go deleted file mode 100644 index 3754af3..0000000 --- a/old/internal/macho/inject.go +++ /dev/null @@ -1,265 +0,0 @@ -// Package macho provides Mach-O binary manipulation for embedding data -// in named sections. It handles section resizing, segment shifting, and -// load command offset updates. -package macho - -import ( - "encoding/binary" - "fmt" -) - -// Load command types. -const ( - lcCodeSignature = 0x1D - lcSegment64 = 0x19 - lcSymtab = 0x2 - lcDysymtab = 0xB - lcDyldInfo = 0x22 - lcDyldInfoOnly = 0x80000022 - lcFunctionStarts = 0x26 - lcDataInCode = 0x29 - lcDylibCodeSignDrs = 0x2B - lcLinkerOptHint = 0x2E - lcDyldExportsTrie = 0x80000033 - lcDyldChainedFixups = 0x80000034 -) - -// Structure field offsets. -const ( - headerSize = 32 - - hdrMagicOff = 0 - hdrNCmdsOff = 16 - hdrSizeOfCmdsOff = 20 - - lcCmdOff = 0 - lcCmdsizeOff = 4 - - segSegnameOff = 8 - segVmaddrOff = 24 - segVmsizeOff = 32 - segFileoffOff = 40 - segFilesizeOff = 48 - segNsectsOff = 64 - segCmdSize = 72 - - sectSectnameOff = 0 - sectAddrOff = 32 - sectSizeOff = 40 - sectOffsetOff = 48 - sectCmdSize = 80 - - symtabSymoffOff = 8 - symtabStroffOff = 16 - - dysymtabTocoffOff = 32 - dysymtabModtaboffOff = 40 - dysymtabExtrefsymoffOff = 48 - dysymtabIndirectsymoffOff = 56 - dysymtabExtreloffOff = 64 - dysymtabLocreloffOff = 72 - - linkeditDataoffOff = 8 - - dyldRebaseOffOff = 8 - dyldBindOffOff = 16 - dyldWeakBindOffOff = 24 - dyldLazyBindOffOff = 32 - dyldExportOffOff = 40 -) - -const ( - blobAlignment = 16 * 1024 - magic64 = 0xFEEDFACF -) - -func get32(data []byte, off int) uint32 { return binary.LittleEndian.Uint32(data[off:]) } -func set32(data []byte, off int, v uint32) { binary.LittleEndian.PutUint32(data[off:], v) } -func get64(data []byte, off int) uint64 { return binary.LittleEndian.Uint64(data[off:]) } -func set64(data []byte, off int, v uint64) { binary.LittleEndian.PutUint64(data[off:], v) } - -func segname(data []byte, off int) string { - name := data[off+segSegnameOff : off+segSegnameOff+16] - for i, b := range name { - if b == 0 { - return string(name[:i]) - } - } - return string(name) -} - -func sectname(data []byte, off int) string { - name := data[off+sectSectnameOff : off+sectSectnameOff+16] - for i, b := range name { - if b == 0 { - return string(name[:i]) - } - } - return string(name) -} - -// AlignUp rounds size up to the next multiple of align. -func AlignUp(size, align uint64) uint64 { - rem := size % align - if rem == 0 { - return size - } - return size + (align - rem) -} - -// InjectSection replaces the content of the named section (segName, sectName) -// with blob, prepending a u64 length header. It shifts subsequent segments -// and updates all Mach-O offsets. Returns a valid unsigned Mach-O binary. -func InjectSection(src []byte, segName, sectName string, blob []byte) ([]byte, error) { - if len(src) < headerSize { - return nil, fmt.Errorf("binary too small") - } - if get32(src, hdrMagicOff) != magic64 { - return nil, fmt.Errorf("not a 64-bit Mach-O") - } - - ncmds := get32(src, hdrNCmdsOff) - sizeOfCmds := get32(src, hdrSizeOfCmdsOff) - - type sectionHit struct { - cmdOff, sectOff int - segFileoff, segFilesize uint64 - segVmaddr, segVmsize uint64 - } - var hit *sectionHit - - type seg struct { - cmdOff int - fileoff, vmaddr, vmsize uint64 - nsects uint32 - } - var segs []seg - - off := headerSize - endCmds := headerSize + int(sizeOfCmds) - for i := uint32(0); i < ncmds; i++ { - if off+8 > endCmds { - return nil, fmt.Errorf("load commands truncated") - } - cmd := get32(src, off+lcCmdOff) - cmdsize := get32(src, off+lcCmdsizeOff) - - if cmd == lcSegment64 { - sn := segname(src, off) - fo := get64(src, off+segFileoffOff) - va := get64(src, off+segVmaddrOff) - vs := get64(src, off+segVmsizeOff) - fs := get64(src, off+segFilesizeOff) - ns := get32(src, off+segNsectsOff) - - segs = append(segs, seg{off, fo, va, vs, ns}) - - if sn == segName { - sectBase := off + segCmdSize - for s := uint32(0); s < ns; s++ { - so := sectBase + int(s)*sectCmdSize - if sectname(src, so) == sectName { - hit = §ionHit{off, so, fo, fs, va, vs} - break - } - } - } - } - off += int(cmdsize) - } - - if hit == nil { - return nil, fmt.Errorf("%s,%s section not found", segName, sectName) - } - - dataHeaderSize := uint64(8) - totalSize := dataHeaderSize + uint64(len(blob)) - alignedSize := AlignUp(totalSize, blobAlignment) - origSegsize := hit.segFilesize - sizeDiff := int64(alignedSize) - int64(origSegsize) - - outLen := int64(len(src)) + sizeDiff - if outLen <= 0 { - return nil, fmt.Errorf("invalid size after injection") - } - out := make([]byte, outLen) - - secStart := hit.segFileoff - copy(out, src[:secStart]) - - binary.LittleEndian.PutUint64(out[secStart:], uint64(len(blob))) - copy(out[secStart+8:], blob) - - copy(out[secStart+alignedSize:], src[secStart+origSegsize:]) - - newVmsize := AlignUp(alignedSize, blobAlignment) - if newVmsize < 0x4000 { - newVmsize = 0x4000 - } - set64(out, hit.cmdOff+segFilesizeOff, alignedSize) - set64(out, hit.cmdOff+segVmsizeOff, newVmsize) - set64(out, hit.sectOff+sectSizeOff, totalSize) - - vmaddrDiff := int64(newVmsize) - int64(hit.segVmsize) - for _, s := range segs { - if s.fileoff <= secStart { - continue - } - set64(out, s.cmdOff+segFileoffOff, uint64(int64(s.fileoff)+sizeDiff)) - if s.vmsize > 0 && s.vmaddr > 0 { - set64(out, s.cmdOff+segVmaddrOff, uint64(int64(s.vmaddr)+vmaddrDiff)) - } - sectBase := s.cmdOff + segCmdSize - for i := uint32(0); i < s.nsects; i++ { - so := sectBase + int(i)*sectCmdSize - if v := get32(out, so+sectOffsetOff); v > 0 { - set32(out, so+sectOffsetOff, uint32(int64(v)+sizeDiff)) - } - if s.vmsize > 0 && s.vmaddr > 0 { - if v := get64(out, so+sectAddrOff); v > 0 { - set64(out, so+sectAddrOff, uint64(int64(v)+vmaddrDiff)) - } - } - } - } - - off = headerSize - for i := uint32(0); i < ncmds; i++ { - cmd := get32(out, off+lcCmdOff) - cmdsize := get32(out, off+lcCmdsizeOff) - - switch cmd { - case lcSymtab: - shiftAfter(out, off+symtabSymoffOff, secStart, sizeDiff) - shiftAfter(out, off+symtabStroffOff, secStart, sizeDiff) - case lcDysymtab: - for _, f := range []int{ - dysymtabTocoffOff, dysymtabModtaboffOff, dysymtabExtrefsymoffOff, - dysymtabIndirectsymoffOff, dysymtabExtreloffOff, dysymtabLocreloffOff, - } { - shiftAfter(out, off+f, secStart, sizeDiff) - } - case lcDyldChainedFixups, lcCodeSignature, lcFunctionStarts, - lcDataInCode, lcDylibCodeSignDrs, lcLinkerOptHint, lcDyldExportsTrie: - shiftAfter(out, off+linkeditDataoffOff, secStart, sizeDiff) - case lcDyldInfo, lcDyldInfoOnly: - for _, f := range []int{ - dyldRebaseOffOff, dyldBindOffOff, dyldWeakBindOffOff, - dyldLazyBindOffOff, dyldExportOffOff, - } { - shiftAfter(out, off+f, secStart, sizeDiff) - } - } - off += int(cmdsize) - } - - return out, nil -} - -func shiftAfter(data []byte, off int, threshold uint64, sizeDiff int64) { - v := uint64(get32(data, off)) - if v == 0 || v < threshold { - return - } - set32(data, off, uint32(int64(v)+sizeDiff)) -} diff --git a/old/internal/pack/pack.go b/old/internal/pack/pack.go deleted file mode 100644 index d113e1e..0000000 --- a/old/internal/pack/pack.go +++ /dev/null @@ -1,276 +0,0 @@ -// Package pack handles reading and extracting packed binary payloads. -// It provides zstd compression/decompression and SHA256 verification -// for kernel and rootfs blobs embedded in lnx binaries. -package pack - -import ( - "crypto/sha256" - "debug/macho" - "encoding/binary" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/klauspost/compress/zstd" -) - -// Config is the configuration and blob metadata baked into a packed binary. -// -// When embedded in a Mach-O section, the layout is: -// -// [u64 data_size] (0 if not packed) -// [zstd-compressed kernel] (KernelCompSize bytes) -// [zstd-compressed rootfs] (RootfsCompSize bytes) -// [JSON Config] -// [u64 json_len] -// [zero padding to 16KB alignment] -type Config struct { - Instance string `json:"instance"` - Args []string `json:"args"` - KernelCompSize int64 `json:"kernel_comp_size"` - RootfsCompSize int64 `json:"rootfs_comp_size"` - KernelSHA256 string `json:"kernel_sha256"` - RootfsSHA256 string `json:"rootfs_sha256"` - - // DataFileOffset is the file offset where kernel data starts. - // Set at read time, not serialized. - DataFileOffset int64 `json:"-"` -} - -// ReadConfig reads the pack config from the current executable's -// Mach-O section (segName, sectName). -func ReadConfig(segName, sectName string) (*Config, error) { - self, err := os.Executable() - if err != nil { - return nil, err - } - return ReadConfigFrom(self, sectName) -} - -// ReadConfigFrom reads the pack config from the named Mach-O section. -func ReadConfigFrom(path, sectName string) (*Config, error) { - f, err := macho.Open(path) - if err != nil { - return nil, fmt.Errorf("open macho: %w", err) - } - defer f.Close() - - sect := f.Section(sectName) - if sect == nil { - return nil, fmt.Errorf("no %s section", sectName) - } - - var hdr [8]byte - if _, err := sect.ReadAt(hdr[:], 0); err != nil { - return nil, fmt.Errorf("read section header: %w", err) - } - dataSize := binary.LittleEndian.Uint64(hdr[:]) - if dataSize == 0 { - return nil, fmt.Errorf("not a packed binary") - } - - var jsonLenBuf [8]byte - jsonLenOff := int64(8 + dataSize - 8) - if _, err := sect.ReadAt(jsonLenBuf[:], jsonLenOff); err != nil { - return nil, fmt.Errorf("read json_len: %w", err) - } - jsonLen := binary.LittleEndian.Uint64(jsonLenBuf[:]) - - jsonOff := jsonLenOff - int64(jsonLen) - jsonBytes := make([]byte, jsonLen) - if _, err := sect.ReadAt(jsonBytes, jsonOff); err != nil { - return nil, fmt.Errorf("read pack config: %w", err) - } - - var cfg Config - if err := json.Unmarshal(jsonBytes, &cfg); err != nil { - return nil, fmt.Errorf("parse pack config: %w", err) - } - cfg.DataFileOffset = int64(sect.Offset) + 8 - return &cfg, nil -} - -// EnsureFiles extracts the embedded kernel and rootfs to cacheDir, -// returning their paths. Skips extraction if already cached. -func EnsureFiles(cfg *Config, cacheDir string) (kernelPath, rootfsPath string, err error) { - cacheID := cfg.KernelSHA256[:16] + "-" + cfg.RootfsSHA256[:16] - dir := filepath.Join(cacheDir, cacheID) - kernelPath = filepath.Join(dir, "vmlinuz") - rootfsPath = filepath.Join(dir, "rootfs.ext4") - - kOK := FileExistsWithHash(kernelPath, cfg.KernelSHA256) - rOK := FileExistsWithHash(rootfsPath, cfg.RootfsSHA256) - if kOK && rOK { - return kernelPath, rootfsPath, nil - } - - if err := os.MkdirAll(dir, 0755); err != nil { - return "", "", fmt.Errorf("create cache dir: %w", err) - } - - self, err := os.Executable() - if err != nil { - return "", "", fmt.Errorf("find executable: %w", err) - } - - f, err := os.Open(self) - if err != nil { - return "", "", fmt.Errorf("open self: %w", err) - } - defer f.Close() - - kernelStart := cfg.DataFileOffset - rootfsStart := kernelStart + cfg.KernelCompSize - - if !kOK { - fmt.Fprintln(os.Stderr, "extracting kernel...") - if err := ExtractZstdBlob(f, kernelStart, cfg.KernelCompSize, kernelPath, cfg.KernelSHA256); err != nil { - return "", "", fmt.Errorf("extract kernel: %w", err) - } - } - - if !rOK { - fmt.Fprintln(os.Stderr, "extracting rootfs...") - if err := ExtractZstdBlob(f, rootfsStart, cfg.RootfsCompSize, rootfsPath, cfg.RootfsSHA256); err != nil { - return "", "", fmt.Errorf("extract rootfs: %w", err) - } - } - - return kernelPath, rootfsPath, nil -} - -// ExtractZstdBlob decompresses a zstd blob from the given file section to dest, -// verifying the sha256 of the decompressed data. -func ExtractZstdBlob(f *os.File, offset, compSize int64, dest, wantSHA256 string) error { - r := io.NewSectionReader(f, offset, compSize) - - dec, err := zstd.NewReader(r) - if err != nil { - return fmt.Errorf("zstd reader: %w", err) - } - defer dec.Close() - - tmp := dest + ".tmp" - out, err := os.Create(tmp) - if err != nil { - return err - } - - h := sha256.New() - if _, err := io.Copy(io.MultiWriter(out, h), dec); err != nil { - out.Close() - os.Remove(tmp) - return fmt.Errorf("decompress: %w", err) - } - if err := out.Close(); err != nil { - os.Remove(tmp) - return err - } - - got := hex.EncodeToString(h.Sum(nil)) - if got != wantSHA256 { - os.Remove(tmp) - return fmt.Errorf("sha256 mismatch: got %s, want %s", got, wantSHA256) - } - - return os.Rename(tmp, dest) -} - -// FileExistsWithHash returns true if path exists and its sha256 matches want. -func FileExistsWithHash(path, wantHex string) bool { - f, err := os.Open(path) - if err != nil { - return false - } - defer f.Close() - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return false - } - return hex.EncodeToString(h.Sum(nil)) == wantHex -} - -// CompressZstdFile compresses src to dst using zstd, returning the -// sha256 of the uncompressed data and the number of compressed bytes written. -func CompressZstdFile(src, dst string, progress io.Writer) (sha256hex string, compressedSize int64, err error) { - in, err := os.Open(src) - if err != nil { - return "", 0, err - } - defer in.Close() - - tmp := dst + ".tmp" - out, err := os.Create(tmp) - if err != nil { - return "", 0, err - } - - enc, err := zstd.NewWriter(out, zstd.WithEncoderLevel(zstd.SpeedBestCompression)) - if err != nil { - out.Close() - os.Remove(tmp) - return "", 0, err - } - - h := sha256.New() - var r io.Reader = in - if progress != nil { - r = io.TeeReader(r, progress) - } - if _, err := io.Copy(io.MultiWriter(enc, h), r); err != nil { - enc.Close() - out.Close() - os.Remove(tmp) - return "", 0, fmt.Errorf("compress: %w", err) - } - if err := enc.Close(); err != nil { - out.Close() - os.Remove(tmp) - return "", 0, err - } - pos, err := out.Seek(0, io.SeekCurrent) - if err != nil { - out.Close() - os.Remove(tmp) - return "", 0, err - } - if err := out.Close(); err != nil { - os.Remove(tmp) - return "", 0, err - } - if err := os.Rename(tmp, dst); err != nil { - os.Remove(tmp) - return "", 0, err - } - return hex.EncodeToString(h.Sum(nil)), pos, nil -} - -// BuildBlob assembles the packed data blob from compressed files and config. -// Layout: [kernel.zst bytes][rootfs.zst bytes][JSON config][u64 json_len] -func BuildBlob(kernelComp, rootfsComp string, cfg *Config) ([]byte, error) { - kernelData, err := os.ReadFile(kernelComp) - if err != nil { - return nil, fmt.Errorf("read kernel: %w", err) - } - rootfsData, err := os.ReadFile(rootfsComp) - if err != nil { - return nil, fmt.Errorf("read rootfs: %w", err) - } - jsonBytes, err := json.Marshal(cfg) - if err != nil { - return nil, err - } - - jsonLen := make([]byte, 8) - binary.LittleEndian.PutUint64(jsonLen, uint64(len(jsonBytes))) - - blob := make([]byte, 0, len(kernelData)+len(rootfsData)+len(jsonBytes)+8) - blob = append(blob, kernelData...) - blob = append(blob, rootfsData...) - blob = append(blob, jsonBytes...) - blob = append(blob, jsonLen...) - return blob, nil -} diff --git a/old/internal/protocol/protocol.go b/old/internal/protocol/protocol.go deleted file mode 100644 index 73bd1a2..0000000 --- a/old/internal/protocol/protocol.go +++ /dev/null @@ -1,247 +0,0 @@ -// Package protocol defines the gob-encoded control messages exchanged -// between the host (macOS) and guest (Linux) over vsock. -package protocol - -const ( - // Port is the vsock port used for the control connection - // (setup, signals, resize). - Port = 1024 - // StatusPort is the vsock port for status queries. - StatusPort = 1026 - // ExecPort is the vsock port for exec requests. The guest listens; - // the host connects once per exec session via VirtioSocketDevice.Connect. - ExecPort = 1027 - // GuestControlPort is the vsock port for guest-initiated requests (checkpoint, etc). - GuestControlPort = 1028 - // PortForwardPort is the vsock port for port-forwarding notifications (guest → host). - PortForwardPort = 1030 - // PortForwardDataPort is the vsock port the guest listens on for - // forwarded TCP connections (host connects via VirtioSocketDevice.Connect). - PortForwardDataPort = 1031 - // ExecInteractivePort is the vsock port the guest listens on for - // interactive exec PTY connections (host connects via VirtioSocketDevice.Connect). - ExecInteractivePort = 1032 - // P9Port is the vsock port for the 9P file server (host listens, guest dials). - P9Port = 1033 - // SSHAgentPort is the vsock port for SSH agent forwarding (host listens, guest dials). - SSHAgentPort = 1034 - // GuestHTTPPort is the vsock port the guest listens on for host->guest HTTP access - // to guest-local control/debug endpoints. - GuestHTTPPort = 1035 - - // P9CWDPort is the vsock port for the 9P CWD share (host listens, guest dials). - P9CWDPort = 1036 - // P9ShareBasePort is the first vsock port for extra 9P shares. - // Share i uses port P9ShareBasePort + i. Supports up to 10 shares. - P9ShareBasePort = 1037 - // InvalidatePort is the vsock port for cache invalidation messages - // (host pushes changed paths to guest). Host listens, guest dials. - InvalidatePort = 1045 - - // P9SyncBasePort is the first vsock port for 9P sync shares. - // Sync share i uses port P9SyncBasePort + i. - P9SyncBasePort = 1047 - - // ForkAttachPort is the vsock port for attaching to a CRIU-restored - // fork session's gob control (ExecStarted, ExecDone, ExecSignal, ExecResize). - // The guest listens; the host connects once after a fork restore. - ForkAttachPort = 1060 - // ForkAttachDataPort is the vsock port for the fork session's raw PTY data. - // The guest listens; the host connects once after a fork restore. - ForkAttachDataPort = 1061 - - // SSHPort is the vsock port for the embedded SSH server in the guest. - // The guest listens; the host connects via VirtioSocketDevice.Connect - // to proxy SSH connections from the CLI. - SSHPort = 1062 -) - -// Msg is the envelope for all control messages. -// Exactly one field is non-nil per message. -type Msg struct { - Setup *Setup - Signal *Signal - Resize *Resize - StatusReq *StatusReq - StatusResp *StatusResp - ExecReq *ExecReq - ExecStarted *ExecStarted - ExecOutput *ExecOutput - ExecDone *ExecDone - ExecSignal *ExecSignal - ExecResize *ExecResize - CheckpointReq *CheckpointReq - CheckpointResp *CheckpointResp - OpenURLReq *OpenURLReq - OpenURLResp *OpenURLResp - ForkReq *ForkReq - ForkResp *ForkResp - ForkNotify *ForkNotify - InstanceNameReq *InstanceNameReq - InstanceNameResp *InstanceNameResp -} - -// Setup tells the guest the environment to configure (user, cwd, env vars). -// Sent once on the control connection at boot. No command args — commands -// are executed via the exec connection. -type Setup struct { - CWD string - Env []string // KEY=VALUE pairs - User string // guest username (matches host) - UID int // guest UID (matches host) - HomeDir string // host home dir path (e.g. /Users/ramon), mounted read-only - Hostname string // guest hostname (e.g. "default.lnx") - SSHAgent bool // if true, host is forwarding SSH agent on SSHAgentPort - Shares []string // extra shares to mount (absolute paths) - - // DirectShare bypasses the FUSE lazy-cache for CWD and extra shares, - // mounting 9P directly (read-write, like pre-sync-share behavior). - DirectShare bool - - // NestedDrives maps nested instance names to block device paths. - // Each nested instance rootfs is attached as a virtio-blk device - // (e.g., "default.default" → "/dev/vdc"). The guest writes this - // mapping so nested lnx can find its rootfs device. - NestedDrives []NestedDrive - - // SyncShares are host directories shared as read-only 9P mounts - // and lazily cached into the guest's ext4 rootfs via FUSE. - // Each entry is the absolute host path; the guest mounts it at the same path. - SyncShares []string -} - -// NestedDrive maps a nested instance name to its block device in the guest. -type NestedDrive struct { - InstanceName string // e.g., "default.default" - DevicePath string // e.g., "/dev/vdc" -} - -// Resize tells the guest to update the PTY window size. -type Resize struct { - Rows uint16 - Cols uint16 -} - -// Signal tells the guest to forward a signal to the running process. -type Signal struct { - Sig int // syscall.Signal value -} - -// StatusReq asks the guest for current system status. -type StatusReq struct { - IncludeDmesg bool -} - -// StatusResp reports guest system status. -type StatusResp struct { - UptimeSecs float64 - MemTotalKB uint64 - MemAvailKB uint64 - SwapTotalKB uint64 - SwapFreeKB uint64 - DiskTotalKB uint64 - DiskUsedKB uint64 - LoadAvg string - Dmesg string // only populated if StatusReq.IncludeDmesg was true -} - -// ExecReq asks the guest to run a command. -type ExecReq struct { - Args []string - Env []string - CWD string // working directory (empty = use setup CWD) - PTY bool - Rows uint16 - Cols uint16 -} - -// ExecStarted is sent by the guest after a command starts, reporting the guest PID. -// Sent on the per-session exec gob connection (port 1027). -type ExecStarted struct { - PID int -} - -// ExecOutput streams command output from guest to host. -type ExecOutput struct { - Stdout []byte - Stderr []byte -} - -// ExecDone reports that the exec command has finished. -type ExecDone struct { - ExitCode int -} - -// CheckpointReq asks the host to snapshot the rootfs. -type CheckpointReq struct { - Name string // optional checkpoint basename without or with .ext4 suffix -} - -// CheckpointResp reports the result of a checkpoint. -type CheckpointResp struct { - Path string // path of the checkpoint on the host - Error string // non-empty on failure -} - -// OpenURLReq asks the host to open a URL in the default browser. -type OpenURLReq struct { - URL string -} - -// OpenURLResp reports the result of opening a URL. -type OpenURLResp struct { - Error string // non-empty on failure -} - -// ExecSignal tells the guest to forward a signal to a specific exec session's process. -// Sent on the per-session exec gob connection (port 1027). -type ExecSignal struct { - Sig int // syscall.Signal value -} - -// ExecResize tells the guest to resize a specific exec session's PTY. -// Sent on the per-session exec gob connection (port 1027). -type ExecResize struct { - Rows uint16 - Cols uint16 -} - -// ForkReq tells the host to fork the VM. For CRIU, the guest dumps -// processes before sending this. For QEMU, the host handles everything -// (CPR-reboot migration + clonefile). -type ForkReq struct{} - -// ForkResp reports the result of a fork operation. -type ForkResp struct { - Instance string // child instance name (set when Role == "parent") - Error string // non-empty on failure - Role string // "parent" or "child" -} -// ForkNotify tells the host that a fork happened in this exec session. -// Sent on the per-session exec gob connection (port 1027) so the host -// can forward the notification to the specific CLI WebSocket. -type ForkNotify struct { - Instance string // child instance name -} - -// InstanceNameReq asks the host for the current instance name. -// Used by the guest to detect fork: query before ForkReq, reconnect -// and query after — if the name changed, this is the child. -type InstanceNameReq struct{} - -// InstanceNameResp returns the host's instance name. -type InstanceNameResp struct { - Name string -} - -// PortForward notifies the host of the current set of listening TCP ports in the guest. -type PortForward struct { - Ports []uint16 -} - -// Invalidation tells the guest that files in a share have changed on the host -// and should be evicted from the FUSE cache. -type Invalidation struct { - Tag string // share tag ("home", "cwd", "share0", "sync0", etc.) - Paths []string // relative paths that changed -} diff --git a/old/invalidate.go b/old/invalidate.go deleted file mode 100644 index 44d5bb5..0000000 --- a/old/invalidate.go +++ /dev/null @@ -1,133 +0,0 @@ -//go:build darwin - -package lnx - -import ( - "encoding/gob" - "log/slog" - "net" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/fsnotify/fsevents" - "github.com/semistrict/lnx/internal/protocol" -) - -// startInvalidationSender detects host-side file changes and pushes -// invalidation messages to the guest. Uses FSEvents when cache.fsevents=true, -// otherwise falls back to polling tracked files. -func startInvalidationSender(conn net.Conn, watchers []shareWatcher) { - if OptCacheFSEvents.Get() { - startInvalidationFSEvents(conn, watchers) - } else { - startInvalidationPoll(conn, watchers) - } -} - -func startInvalidationPoll(conn net.Conn, watchers []shareWatcher) { - interval := cachePollInterval() - slog.Info("invalidation sender started (poll)", "interval", interval) - enc := gob.NewEncoder(conn) - for { - time.Sleep(interval) - for _, w := range watchers { - changed := w.tracker.scanDir(".") - if len(changed) == 0 { - continue - } - slog.Debug("invalidating cached paths", "tag", w.tag, "count", len(changed)) - if err := enc.Encode(protocol.Invalidation{Tag: w.tag, Paths: changed}); err != nil { - slog.Debug("invalidation sender stopped", "error", err) - return - } - } - } -} - -func startInvalidationFSEvents(conn net.Conn, watchers []shareWatcher) { - latency := OptCacheFSEventsLatency.Get() - slog.Info("invalidation sender started (fsevents)", "latency", latency) - enc := gob.NewEncoder(conn) - - type watchEntry struct { - watcher shareWatcher - absRoot string // cleaned absolute path with trailing / - } - var entries []watchEntry - var paths []string - - for _, w := range watchers { - abs, err := filepath.Abs(w.tracker.rootPath) - if err != nil { - slog.Warn("skip fsevents watcher", "path", w.tracker.rootPath, "error", err) - continue - } - entries = append(entries, watchEntry{watcher: w, absRoot: filepath.Clean(abs) + "/"}) - paths = append(paths, filepath.Clean(abs)) - } - - // Longest prefix first so specific watchers (sync share) match before - // broad ones (home dir). - sort.Slice(entries, func(i, j int) bool { - return len(entries[i].absRoot) > len(entries[j].absRoot) - }) - - if len(paths) == 0 { - slog.Warn("no paths to watch") - return - } - - es := &fsevents.EventStream{ - Paths: paths, - Latency: latency, - Flags: fsevents.FileEvents, - } - if err := es.Start(); err != nil { - slog.Warn("fsevents start failed, falling back to poll", "error", err) - startInvalidationPoll(conn, watchers) - return - } - defer es.Stop() - - for batch := range es.Events { - changed := map[string][]string{} // tag -> relative paths - for _, ev := range batch { - absPath := ev.Path - if !strings.HasPrefix(absPath, "/") { - absPath = "/" + absPath - } - for _, e := range entries { - if !strings.HasPrefix(absPath, e.absRoot) { - continue - } - relPath := strings.TrimPrefix(absPath, e.absRoot) - if relPath == "" { - continue - } - relDir := filepath.Dir(relPath) - for _, p := range e.watcher.tracker.scanDir(relDir) { - changed[e.watcher.tag] = append(changed[e.watcher.tag], p) - } - break - } - } - - for tag, paths := range changed { - seen := make(map[string]bool, len(paths)) - var unique []string - for _, p := range paths { - if !seen[p] { - seen[p] = true - unique = append(unique, p) - } - } - slog.Debug("invalidating cached paths", "tag", tag, "count", len(unique)) - if err := enc.Encode(protocol.Invalidation{Tag: tag, Paths: unique}); err != nil { - slog.Debug("invalidation sender stopped", "error", err) - return - } - } - } -} diff --git a/old/invalidate_linux.go b/old/invalidate_linux.go deleted file mode 100644 index 480d3af..0000000 --- a/old/invalidate_linux.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build linux - -package lnx - -import ( - "encoding/gob" - "log/slog" - "net" - "time" - - "github.com/semistrict/lnx/internal/protocol" -) - - -// startInvalidationSender on Linux falls back to polling since FSEvents -// is macOS-only. Polls tracked files for mtime changes. -func startInvalidationSender(conn net.Conn, watchers []shareWatcher) { - interval := cachePollInterval() - slog.Info("invalidation sender started (poll)", "interval_ms", interval.Milliseconds()) - enc := gob.NewEncoder(conn) - for { - time.Sleep(interval) - for _, w := range watchers { - changed := w.tracker.scanDir(".") - if len(changed) == 0 { - continue - } - slog.Debug("invalidating cached paths", "tag", w.tag, "count", len(changed)) - if err := enc.Encode(protocol.Invalidation{Tag: w.tag, Paths: changed}); err != nil { - slog.Debug("invalidation sender stopped", "error", err) - return - } - } - } -} diff --git a/old/kernel.config b/old/kernel.config deleted file mode 100644 index 154b07b..0000000 --- a/old/kernel.config +++ /dev/null @@ -1,365 +0,0 @@ -# Minimal Linux kernel config for lnx VM (arm64) -# Based on Linux 7.0, targeting Apple Virtualization.framework -# Supports: ext4, virtiofs, perf, eBPF, netfilter/iptables, cgroups, -# namespaces, overlayfs (for podman), ptrace (strace/gdb) - -# General -CONFIG_LOCALVERSION="-lnx" -# CONFIG_LOCALVERSION_AUTO is not set -CONFIG_DEFAULT_HOSTNAME="lnx" -CONFIG_SYSVIPC=y -CONFIG_POSIX_MQUEUE=y -CONFIG_NO_HZ=y -CONFIG_HIGH_RES_TIMERS=y -CONFIG_BLK_DEV_INITRD=y -CONFIG_RD_GZIP=y -# CONFIG_RD_BZIP2 is not set -# CONFIG_RD_LZMA is not set -# CONFIG_RD_XZ is not set -# CONFIG_RD_LZO is not set -# CONFIG_RD_LZ4 is not set -# CONFIG_RD_ZSTD is not set -CONFIG_CC_OPTIMIZE_FOR_SIZE=y -CONFIG_EXPERT=y -CONFIG_MULTIUSER=y -CONFIG_SYSFS_SYSCALL=y -CONFIG_FHANDLE=y -CONFIG_POSIX_TIMERS=y -CONFIG_PRINTK=y -CONFIG_BUG=y -CONFIG_ELF_CORE=y -CONFIG_BASE_FULL=y -CONFIG_FUTEX=y -CONFIG_EPOLL=y -CONFIG_SIGNALFD=y -CONFIG_TIMERFD=y -CONFIG_EVENTFD=y -CONFIG_AIO=y -CONFIG_IO_URING=y -CONFIG_ADVISE_SYSCALLS=y -CONFIG_MEMBARRIER=y -CONFIG_KALLSYMS=y -CONFIG_KALLSYMS_ALL=y - -# Namespaces (podman, containers) -CONFIG_NAMESPACES=y -CONFIG_UTS_NS=y -CONFIG_IPC_NS=y -CONFIG_USER_NS=y -CONFIG_PID_NS=y -CONFIG_NET_NS=y -CONFIG_TIME_NS=y - -# Cgroups (podman, containers) -CONFIG_CGROUPS=y -CONFIG_CGROUP_CPUACCT=y -CONFIG_CGROUP_DEVICE=y -CONFIG_CGROUP_FREEZER=y -CONFIG_CGROUP_HUGETLB=y -CONFIG_CGROUP_MEMORY=y -CONFIG_CGROUP_NET_CLASSID=y -CONFIG_CGROUP_NET_PRIO=y -CONFIG_CGROUP_PERF=y -CONFIG_CGROUP_PIDS=y -CONFIG_CGROUP_SCHED=y -CONFIG_CGROUP_BPF=y -CONFIG_MEMCG=y -CONFIG_CPUSETS=y - -# Processor -# CONFIG_ARM64_ERRATUM_826319 is not set -# CONFIG_ARM64_ERRATUM_827319 is not set -# CONFIG_ARM64_ERRATUM_824069 is not set -# CONFIG_ARM64_ERRATUM_819472 is not set -# CONFIG_ARM64_ERRATUM_832075 is not set -# CONFIG_ARM64_ERRATUM_843419 is not set -# CONFIG_ARM64_ERRATUM_1024718 is not set -# CONFIG_ARM64_ERRATUM_1165522 is not set -# CONFIG_ARM64_ERRATUM_1319367 is not set -# CONFIG_ARM64_ERRATUM_1530923 is not set -# CONFIG_ARM64_ERRATUM_1463225 is not set -# CONFIG_ARM64_ERRATUM_1508412 is not set -# CONFIG_CAVIUM_ERRATUM_22375 is not set -# CONFIG_CAVIUM_ERRATUM_23154 is not set -# CONFIG_CAVIUM_ERRATUM_27456 is not set -# CONFIG_CAVIUM_ERRATUM_30115 is not set -# CONFIG_CAVIUM_TX2_ERRATUM_219 is not set -# CONFIG_FUJITSU_ERRATUM_010001 is not set -# CONFIG_HISILICON_ERRATUM_161600802 is not set -# CONFIG_QCOM_FALKOR_ERRATUM_1003 is not set -# CONFIG_QCOM_FALKOR_ERRATUM_1009 is not set -# CONFIG_QCOM_QDF2400_ERRATUM_0065 is not set -# CONFIG_QCOM_FALKOR_ERRATUM_E1041 is not set -# CONFIG_NVIDIA_CARMEL_CNP_ERRATUM is not set -# CONFIG_SOCIONEXT_SYNQUACER_PREITS is not set -# CONFIG_EFI is not set -# CONFIG_SUSPEND is not set -CONFIG_CPU_IDLE=y -CONFIG_COMPAT_32BIT_TIME=y -CONFIG_SECCOMP=y -CONFIG_SECCOMP_FILTER=y - -# KVM (nested virtualization) -CONFIG_VIRTUALIZATION=y -CONFIG_KVM=y - -# No kernel modules - everything built-in -# CONFIG_MODULES is not set - -# Block layer -CONFIG_BLOCK=y -CONFIG_BLK_DEV_LOOP=y - -# Perf / tracing -CONFIG_PERF_EVENTS=y -CONFIG_HW_PERF_EVENTS=y -CONFIG_FTRACE=y -CONFIG_FUNCTION_TRACER=y -CONFIG_FUNCTION_GRAPH_TRACER=y -CONFIG_DYNAMIC_FTRACE=y -CONFIG_KPROBES=y -CONFIG_KPROBE_EVENTS=y -CONFIG_UPROBE_EVENTS=y -CONFIG_TRACEPOINTS=y -CONFIG_TRACING=y - -# eBPF -CONFIG_BPF=y -CONFIG_BPF_SYSCALL=y -CONFIG_BPF_JIT=y -CONFIG_BPF_JIT_ALWAYS_ON=y -CONFIG_BPF_EVENTS=y -CONFIG_BPF_KPROBE_OVERRIDE=y -CONFIG_NET_CLS_BPF=y -CONFIG_NET_ACT_BPF=y -CONFIG_BPF_STREAM_PARSER=y -CONFIG_LWTUNNEL_BPF=y -CONFIG_BPF_LSM=y - -# BTF (required by BPF CO-RE, libbpf, bpftool) -CONFIG_DEBUG_INFO=y -CONFIG_DEBUG_INFO_REDUCED=y -CONFIG_DEBUG_INFO_BTF=y - -# CRIU (Checkpoint/Restore In Userspace) -CONFIG_CHECKPOINT_RESTORE=y -CONFIG_UNIX_DIAG=y -CONFIG_INET_DIAG=y -CONFIG_INET_TCP_DIAG=y -CONFIG_INET_UDP_DIAG=y -CONFIG_NETLINK_DIAG=y -CONFIG_PACKET_DIAG=y - -# Networking -CONFIG_NET=y -CONFIG_PACKET=y -CONFIG_UNIX=y -CONFIG_INET=y -CONFIG_IP_MULTICAST=y -CONFIG_IP_ADVANCED_ROUTER=y -CONFIG_IP_MULTIPLE_TABLES=y -CONFIG_IP_ROUTE_MULTIPATH=y -CONFIG_IP_NF_IPTABLES=y -CONFIG_IP_NF_FILTER=y -CONFIG_IP_NF_NAT=y -CONFIG_IP_NF_TARGET_MASQUERADE=y -CONFIG_IP_NF_MANGLE=y -CONFIG_IPV6=y -CONFIG_IP6_NF_IPTABLES=y -CONFIG_IP6_NF_FILTER=y -CONFIG_IP6_NF_MANGLE=y -CONFIG_IP6_NF_NAT=y -CONFIG_BRIDGE=y -CONFIG_VLAN_8021Q=y -CONFIG_VETH=y -CONFIG_MACVLAN=y -CONFIG_DUMMY=y -CONFIG_TUN=y -CONFIG_TAP=y -# CONFIG_WIRELESS is not set - -# Netfilter -CONFIG_NETFILTER=y -CONFIG_NETFILTER_ADVANCED=y -CONFIG_NETFILTER_XTABLES=y -CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y -CONFIG_NETFILTER_XT_MATCH_COMMENT=y -CONFIG_NETFILTER_XT_MATCH_CONNTRACK=y -CONFIG_NETFILTER_XT_MATCH_IPVS=y -CONFIG_NETFILTER_XT_MATCH_MARK=y -CONFIG_NETFILTER_XT_MATCH_MULTIPORT=y -CONFIG_NETFILTER_XT_MATCH_PKTTYPE=y -CONFIG_NETFILTER_XT_MATCH_STATE=y -CONFIG_NETFILTER_XT_MATCH_STATISTIC=y -CONFIG_NETFILTER_XT_TARGET_MASQUERADE=y -CONFIG_NETFILTER_XT_TARGET_REDIRECT=y -CONFIG_NETFILTER_XT_TARGET_MARK=y -CONFIG_NETFILTER_XT_NAT=y -CONFIG_NF_CONNTRACK=y -CONFIG_NF_NAT=y -CONFIG_NF_TABLES=y -CONFIG_NF_TABLES_INET=y -CONFIG_NFT_NAT=y -CONFIG_NFT_MASQ=y -CONFIG_NFT_REDIR=y -CONFIG_NFT_CT=y -CONFIG_NFT_COUNTER=y -CONFIG_NFT_LOG=y -CONFIG_NFT_LIMIT=y -CONFIG_NFT_META=y -CONFIG_NFT_COMPAT=y - -# Device drivers - only virtio -CONFIG_PCI=y -CONFIG_PCI_HOST_GENERIC=y -CONFIG_DEVTMPFS=y -CONFIG_DEVTMPFS_MOUNT=y -# CONFIG_STANDALONE is not set -# CONFIG_PREVENT_FIRMWARE_BUILD is not set - -# Virtio -CONFIG_NETDEVICES=y -CONFIG_VIRTIO_PCI=y -# CONFIG_VIRTIO_PCI_LEGACY is not set -CONFIG_VIRTIO_BLK=y -CONFIG_VIRTIO_NET=y -CONFIG_VIRTIO_CONSOLE=y -CONFIG_VIRTIO_BALLOON=y -CONFIG_VIRTIO_INPUT=y -CONFIG_VIRTIO_MMIO=y -CONFIG_HW_RANDOM_VIRTIO=y -# CONFIG_VIRTIO_IOMMU is not set -CONFIG_VSOCKETS=y -CONFIG_VIRTIO_VSOCKETS=y - -# Disable all real hardware NIC drivers -# CONFIG_NET_VENDOR_3COM is not set -# CONFIG_NET_VENDOR_ADAPTEC is not set -# CONFIG_NET_VENDOR_AGERE is not set -# CONFIG_NET_VENDOR_ALACRITECH is not set -# CONFIG_NET_VENDOR_ALTEON is not set -# CONFIG_NET_VENDOR_AMAZON is not set -# CONFIG_NET_VENDOR_AMD is not set -# CONFIG_NET_VENDOR_AQUANTIA is not set -# CONFIG_NET_VENDOR_ARC is not set -# CONFIG_NET_VENDOR_ATHEROS is not set -# CONFIG_NET_VENDOR_BROADCOM is not set -# CONFIG_NET_VENDOR_CADENCE is not set -# CONFIG_NET_VENDOR_CAVIUM is not set -# CONFIG_NET_VENDOR_CHELSIO is not set -# CONFIG_NET_VENDOR_CISCO is not set -# CONFIG_NET_VENDOR_CORTINA is not set -# CONFIG_NET_VENDOR_DEC is not set -# CONFIG_NET_VENDOR_DLINK is not set -# CONFIG_NET_VENDOR_EMULEX is not set -# CONFIG_NET_VENDOR_EZCHIP is not set -# CONFIG_NET_VENDOR_GOOGLE is not set -# CONFIG_NET_VENDOR_HISILICON is not set -# CONFIG_NET_VENDOR_HUAWEI is not set -# CONFIG_NET_VENDOR_INTEL is not set -# CONFIG_NET_VENDOR_MARVELL is not set -# CONFIG_NET_VENDOR_MELLANOX is not set -# CONFIG_NET_VENDOR_MICREL is not set -# CONFIG_NET_VENDOR_MICROCHIP is not set -# CONFIG_NET_VENDOR_MICROSEMI is not set -# CONFIG_NET_VENDOR_MICROSOFT is not set -# CONFIG_NET_VENDOR_MYRI is not set -# CONFIG_NET_VENDOR_NATSEMI is not set -# CONFIG_NET_VENDOR_NETERION is not set -# CONFIG_NET_VENDOR_NETRONOME is not set -# CONFIG_NET_VENDOR_NVIDIA is not set -# CONFIG_NET_VENDOR_OKI is not set -# CONFIG_NET_VENDOR_PACKET_ENGINES is not set -# CONFIG_NET_VENDOR_PENSANDO is not set -# CONFIG_NET_VENDOR_QLOGIC is not set -# CONFIG_NET_VENDOR_BROCADE is not set -# CONFIG_NET_VENDOR_QUALCOMM is not set -# CONFIG_NET_VENDOR_RDC is not set -# CONFIG_NET_VENDOR_REALTEK is not set -# CONFIG_NET_VENDOR_RENESAS is not set -# CONFIG_NET_VENDOR_ROCKER is not set -# CONFIG_NET_VENDOR_SAMSUNG is not set -# CONFIG_NET_VENDOR_SEEQ is not set -# CONFIG_NET_VENDOR_SILAN is not set -# CONFIG_NET_VENDOR_SIS is not set -# CONFIG_NET_VENDOR_SOLARFLARE is not set -# CONFIG_NET_VENDOR_SMSC is not set -# CONFIG_NET_VENDOR_SOCIONEXT is not set -# CONFIG_NET_VENDOR_STMICRO is not set -# CONFIG_NET_VENDOR_SUN is not set -# CONFIG_NET_VENDOR_SYNOPSYS is not set -# CONFIG_NET_VENDOR_TEHUTI is not set -# CONFIG_NET_VENDOR_TI is not set -# CONFIG_NET_VENDOR_VIA is not set -# CONFIG_NET_VENDOR_WIZNET is not set -# CONFIG_NET_VENDOR_XILINX is not set -# CONFIG_WLAN is not set - -# Serial console -CONFIG_SERIAL_AMBA_PL011=y -CONFIG_SERIAL_AMBA_PL011_CONSOLE=y - -# Serial 8250/16550 UART — needed for Firecracker on ARM64 -CONFIG_SERIAL_8250=y -CONFIG_SERIAL_8250_CONSOLE=y -CONFIG_SERIAL_OF_PLATFORM=y - -# No graphics, HID, USB, sound -# CONFIG_VT is not set -# CONFIG_DRM is not set -# CONFIG_HID_SUPPORT is not set -# CONFIG_USB_SUPPORT is not set -# CONFIG_SOUND is not set -# CONFIG_INPUT_MOUSE is not set -# CONFIG_LEGACY_PTYS is not set -# CONFIG_LDISC_AUTOLOAD is not set -# CONFIG_DEVMEM is not set -# CONFIG_DEVPORT is not set -# CONFIG_HWMON is not set - -# Filesystems -CONFIG_EXT4_FS=y -CONFIG_EXT4_USE_FOR_EXT2=y -CONFIG_EXT4_FS_POSIX_ACL=y -CONFIG_EXT4_FS_SECURITY=y -CONFIG_FUSE_FS=y -CONFIG_VIRTIO_FS=y -CONFIG_OVERLAY_FS=y -CONFIG_TMPFS=y -CONFIG_TMPFS_POSIX_ACL=y -CONFIG_PROC_FS=y -CONFIG_PROC_SYSCTL=y -CONFIG_SYSFS=y -CONFIG_DEVPTS_FS=y -CONFIG_PROC_KCORE=y -CONFIG_MISC_FILESYSTEMS=y - -# 9P filesystem (for permission-filtered host mounts over vsock) -CONFIG_NET_9P=y -CONFIG_NET_9P_VIRTIO=y -CONFIG_9P_FS=y -CONFIG_9P_FS_POSIX_ACL=y -CONFIG_9P_FS_SECURITY=y -CONFIG_NETWORK_FILESYSTEMS=y -CONFIG_NFS_FS=y -CONFIG_NFS_V4=y -CONFIG_NFS_V4_1=y -CONFIG_NFS_V4_2=y -CONFIG_NFSD=y - -# TTY/PTY for shell usage -CONFIG_UNIX98_PTYS=y - -# Debugging / tracing output -CONFIG_PRINTK_TIME=y -# CONFIG_SYMBOLIC_ERRNAME is not set -# CONFIG_DEBUG_BUGVERBOSE is not set -# CONFIG_DEBUG_MISC is not set -CONFIG_STRIP_ASM_SYMS=y -# CONFIG_RUNTIME_TESTING_MENU is not set - -# Timer errata (not needed in VM) -# CONFIG_ARM_ARCH_TIMER_EVTSTREAM is not set -# CONFIG_FSL_ERRATUM_A008585 is not set -# CONFIG_HISILICON_ERRATUM_161010101 is not set -# CONFIG_ARM64_ERRATUM_858921 is not set diff --git a/old/lock.go b/old/lock.go deleted file mode 100644 index 08c67d1..0000000 --- a/old/lock.go +++ /dev/null @@ -1,91 +0,0 @@ -package lnx - -import ( - "fmt" - "os" - "strconv" - "strings" - "syscall" -) - -type lockFile struct { - lockFd *os.File - pidPath string -} - -// RootfsLock is an exported wrapper around the rootfs lock. -type RootfsLock struct { - inner *lockFile -} - -// lockRootfs takes an exclusive flock on a .lock file next to the rootfs. -// If a stale lock file exists from a crashed process, the flock will -// succeed (kernel releases flocks on process death) and we clean up. -func lockRootfs(rootfsPath string) (*lockFile, error) { - lockPath := rootfsPath + ".lock" - pidPath := rootfsPath + ".pid" - - f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - return nil, fmt.Errorf("open lock file: %w", err) - } - - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - f.Close() - msg := "rootfs is locked by another instance" - if pid := readPidFile(pidPath); pid > 0 { - msg += fmt.Sprintf(" (pid %d)", pid) - } - return nil, fmt.Errorf("%s", msg) - } - - // We hold the lock. Clean up any stale pidfile from a crashed process. - os.Remove(pidPath) - - if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0644); err != nil { - f.Close() - return nil, fmt.Errorf("write pidfile: %w", err) - } - - return &lockFile{lockFd: f, pidPath: pidPath}, nil -} - -// LockRootfs takes an exclusive flock on a .lock file next to the rootfs. -func LockRootfs(rootfsPath string) (*RootfsLock, error) { - lock, err := lockRootfs(rootfsPath) - if err != nil { - return nil, err - } - return &RootfsLock{inner: lock}, nil -} - -// Unlock releases the rootfs lock. -func (l *RootfsLock) Unlock() { - if l == nil { - return - } - l.inner.unlock() -} - -func (l *lockFile) unlock() { - if l == nil { - return - } - lockPath := l.lockFd.Name() - syscall.Flock(int(l.lockFd.Fd()), syscall.LOCK_UN) - l.lockFd.Close() - os.Remove(lockPath) - os.Remove(l.pidPath) -} - -func readPidFile(path string) int { - data, err := os.ReadFile(path) - if err != nil { - return 0 - } - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) - if err != nil { - return 0 - } - return pid -} diff --git a/old/lock_intg_test.go b/old/lock_intg_test.go deleted file mode 100644 index 3e5ce85..0000000 --- a/old/lock_intg_test.go +++ /dev/null @@ -1,75 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "sync" - "testing" - "time" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRun_ConcurrentLock(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - lnx.Run(cfg, "sleep", "1") - }() - - pidPath := cfg.RootfsPath + ".pid" - require.Eventually(t, func() bool { - _, err := os.Stat(pidPath) - return err == nil - }, 5*time.Second, 10*time.Millisecond, "pidfile never appeared") - - _, err := lnx.Run(testConfig(dir), "true") - require.Error(t, err) - assert.Contains(t, err.Error(), "locked by another instance") - assert.Contains(t, err.Error(), "pid") - - wg.Wait() -} - -func TestRun_StaleLockRecovery(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - // Simulate a crashed process: create stale lock and pid files. - lockPath := cfg.RootfsPath + ".lock" - pidPath := cfg.RootfsPath + ".pid" - os.WriteFile(lockPath, nil, 0644) - os.WriteFile(pidPath, []byte("99999\n"), 0644) - - // Should succeed — flock is NOT held, just stale files. - exitCode, err := lnx.Run(cfg, "true") - require.NoError(t, err) - assert.Equal(t, 0, exitCode) -} - -func TestRun_PidfileCreatedAndCleaned(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - pidPath := cfg.RootfsPath + ".pid" - lockPath := cfg.RootfsPath + ".lock" - - exitCode, err := lnx.Run(cfg, "true") - require.NoError(t, err) - assert.Equal(t, 0, exitCode) - - _, err = os.Stat(pidPath) - assert.True(t, os.IsNotExist(err), "pidfile should be removed after run") - - _, err = os.Stat(lockPath) - assert.True(t, os.IsNotExist(err), "lock file should be removed after run") -} diff --git a/old/logrecv.go b/old/logrecv.go deleted file mode 100644 index b3733f6..0000000 --- a/old/logrecv.go +++ /dev/null @@ -1,68 +0,0 @@ -package lnx - -import ( - "bufio" - "fmt" - "log/slog" - "net" - "os" - "path/filepath" - "sync" - "time" -) - -var logReceiverShutdownTimeout = time.Second - -// startLogReceiver listens on vsockLogPort and appends received log lines -// to ~/.lnx/lnx.log. Returns a cleanup function. -func startLogReceiver(listener interface { - Accept() (net.Conn, error) - Close() error -}, logDir string) func() { - logPath := filepath.Join(logDir, "lnx.log") - done := make(chan struct{}) - var wg sync.WaitGroup - - go func() { - f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - fmt.Fprintf(os.Stderr, "lnx: open log file: %v\n", err) - close(done) - return - } - defer f.Close() - defer close(done) - - var mu sync.Mutex - for { - conn, err := listener.Accept() - if err != nil { - slog.Debug("log accept failed", "error", err) - break - } - slog.Debug("log accepted") - wg.Add(1) - go func(conn net.Conn) { - defer wg.Done() - defer conn.Close() - - scanner := bufio.NewScanner(conn) - for scanner.Scan() { - mu.Lock() - fmt.Fprintln(f, scanner.Text()) - mu.Unlock() - } - }(conn) - } - wg.Wait() - }() - - return func() { - _ = listener.Close() - select { - case <-done: - case <-time.After(logReceiverShutdownTimeout): - slog.Warn("log receiver shutdown timed out") - } - } -} diff --git a/old/logrecv_test.go b/old/logrecv_test.go deleted file mode 100644 index fe7d268..0000000 --- a/old/logrecv_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package lnx - -import ( - "errors" - "net" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" -) - -type testLogListener struct { - conns chan net.Conn - closeCh chan struct{} - once sync.Once -} - -func newTestLogListener() *testLogListener { - return &testLogListener{ - conns: make(chan net.Conn, 8), - closeCh: make(chan struct{}), - } -} - -func (l *testLogListener) Accept() (net.Conn, error) { - select { - case conn := <-l.conns: - return conn, nil - case <-l.closeCh: - return nil, net.ErrClosed - } -} - -func (l *testLogListener) Close() error { - l.once.Do(func() { close(l.closeCh) }) - return nil -} - -type stuckLogListener struct{} - -func (stuckLogListener) Accept() (net.Conn, error) { - select {} -} - -func (stuckLogListener) Close() error { return nil } - -func TestStartLogReceiverWritesMultipleConnections(t *testing.T) { - dir := t.TempDir() - listener := newTestLogListener() - cleanup := startLogReceiver(listener, dir) - t.Cleanup(cleanup) - - for _, text := range []string{"first line\n", "second line\n"} { - server, client := net.Pipe() - listener.conns <- server - if _, err := client.Write([]byte(text)); err != nil { - t.Fatalf("write log line: %v", err) - } - _ = client.Close() - } - - deadline := time.Now().Add(2 * time.Second) - logPath := filepath.Join(dir, "lnx.log") - for time.Now().Before(deadline) { - data, err := os.ReadFile(logPath) - if err == nil { - content := string(data) - if strings.Contains(content, "first line") && strings.Contains(content, "second line") { - return - } - } else if !errors.Is(err, os.ErrNotExist) { - t.Fatalf("read log file: %v", err) - } - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("timed out waiting for log output in %s", logPath) -} - -func TestStartLogReceiverCleanupDoesNotHangIfAcceptIgnoresClose(t *testing.T) { - dir := t.TempDir() - oldTimeout := logReceiverShutdownTimeout - logReceiverShutdownTimeout = 20 * time.Millisecond - defer func() { logReceiverShutdownTimeout = oldTimeout }() - - cleanup := startLogReceiver(stuckLogListener{}, dir) - - start := time.Now() - cleanup() - if d := time.Since(start); d > 500*time.Millisecond { - t.Fatalf("cleanup took too long: %v", d) - } -} diff --git a/old/net_intg_test.go b/old/net_intg_test.go deleted file mode 100644 index a278b00..0000000 --- a/old/net_intg_test.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "testing" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRun_NetworkPing(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - exitCode, err := lnx.Run(cfg, "ping", "-c1", "-W3", "8.8.8.8") - require.NoError(t, err) - assert.Equal(t, 0, exitCode) -} - -func TestRun_NetworkHTTP(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - exitCode, err := lnx.Run(cfg, "curl", "-s", "--max-time", "5", "-o", "/dev/null", "-w", "%{http_code}", "http://example.com") - require.NoError(t, err) - assert.Equal(t, 0, exitCode) -} diff --git a/old/network_linux.go b/old/network_linux.go deleted file mode 100644 index d72f4dd..0000000 --- a/old/network_linux.go +++ /dev/null @@ -1,110 +0,0 @@ -//go:build linux - -package lnx - -import ( - "fmt" - "log/slog" - "os" - "os/exec" -) - -const ( - tapDevice = "lnxtap0" - tapIP = "192.168.64.1" - tapSubnet = "192.168.64.0/24" - tapCIDR = tapIP + "/24" -) - -// setupTAP creates a TAP device for Firecracker networking and configures -// NAT so the guest can reach the internet. Requires root/CAP_NET_ADMIN. -// Idempotent: if the TAP already exists, reconfigures it. -func setupTAP() error { - // Delete stale TAP if it exists, then recreate. - exec.Command("ip", "link", "set", tapDevice, "down").Run() - exec.Command("ip", "tuntap", "del", "dev", tapDevice, "mode", "tap").Run() - - cmds := [][]string{ - {"ip", "tuntap", "add", "dev", tapDevice, "mode", "tap"}, - {"ip", "addr", "add", tapCIDR, "dev", tapDevice}, - {"ip", "link", "set", tapDevice, "up"}, - } - for _, args := range cmds { - if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { - return fmt.Errorf("%v: %s: %w", args, out, err) - } - } - - // Enable IP forwarding. - if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil { - return fmt.Errorf("enable ip_forward: %w", err) - } - - // Find the default route interface for MASQUERADE. - outIface, err := defaultRouteInterface() - if err != nil { - return fmt.Errorf("find default route: %w", err) - } - - // Set up NAT. - natCmds := [][]string{ - {"iptables", "-t", "nat", "-A", "POSTROUTING", "-o", outIface, "-s", tapSubnet, "-j", "MASQUERADE"}, - {"iptables", "-A", "FORWARD", "-i", tapDevice, "-o", outIface, "-j", "ACCEPT"}, - {"iptables", "-A", "FORWARD", "-i", outIface, "-o", tapDevice, "-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT"}, - } - for _, args := range natCmds { - if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { - return fmt.Errorf("%v: %s: %w", args, out, err) - } - } - - slog.Info("TAP network configured", "device", tapDevice, "ip", tapCIDR, "nat", outIface) - return nil -} - -// teardownTAP removes the TAP device and NAT rules. -func teardownTAP() { - outIface, _ := defaultRouteInterface() - - // Best-effort cleanup — ignore errors. - exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-o", outIface, "-s", tapSubnet, "-j", "MASQUERADE").Run() - exec.Command("iptables", "-D", "FORWARD", "-i", tapDevice, "-o", outIface, "-j", "ACCEPT").Run() - exec.Command("iptables", "-D", "FORWARD", "-i", outIface, "-o", tapDevice, "-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT").Run() - exec.Command("ip", "link", "del", tapDevice).Run() -} - -// defaultRouteInterface returns the network interface used for the default route. -func defaultRouteInterface() (string, error) { - out, err := exec.Command("ip", "route", "show", "default").Output() - if err != nil { - return "", err - } - // Format: "default via X.X.X.X dev ..." - fields := splitFields(string(out)) - for i, f := range fields { - if f == "dev" && i+1 < len(fields) { - return fields[i+1], nil - } - } - return "", fmt.Errorf("no default route found") -} - -// splitFields splits on whitespace including newlines. -func splitFields(s string) []string { - var fields []string - start := -1 - for i, c := range s { - if c == ' ' || c == '\t' || c == '\n' || c == '\r' { - if start >= 0 { - fields = append(fields, s[start:i]) - start = -1 - } - } else if start < 0 { - start = i - } - } - if start >= 0 { - fields = append(fields, s[start:]) - } - return fields -} diff --git a/old/options.go b/old/options.go deleted file mode 100644 index 351a192..0000000 --- a/old/options.go +++ /dev/null @@ -1,113 +0,0 @@ -package lnx - -import ( - "fmt" - "os" - "strconv" - "strings" - "time" -) - -// Opt is a typed runtime option. Declare as a package-level var. -// Read with .Get(). Set via LNX_OPTIONS=key=value or -O key=value. -type Opt[T any] struct { - Key string - Default T - Desc string - parse func(string) (T, bool) -} - -func StringOpt(key, def, desc string) *Opt[string] { - return &Opt[string]{Key: key, Default: def, Desc: desc, parse: func(s string) (string, bool) { return s, true }} -} - -func BoolOpt(key string, def bool, desc string) *Opt[bool] { - return &Opt[bool]{Key: key, Default: def, Desc: desc, parse: func(s string) (bool, bool) { - switch strings.ToLower(s) { - case "1", "true", "yes": - return true, true - case "0", "false", "no": - return false, true - } - return false, false - }} -} - -func DurationOpt(key string, def time.Duration, desc string) *Opt[time.Duration] { - return &Opt[time.Duration]{Key: key, Default: def, Desc: desc, parse: func(s string) (time.Duration, bool) { - if d, err := time.ParseDuration(s); err == nil { - return d, true - } - if ms, err := strconv.Atoi(s); err == nil && ms > 0 { - return time.Duration(ms) * time.Millisecond, true - } - return 0, false - }} -} - -// Get returns the option's current value (from LNX_OPTIONS, -O flags, or default). -func (o *Opt[T]) Get() T { - if v, ok := optValues()[o.Key]; ok && v != "" { - if parsed, ok := o.parse(v); ok { - return parsed - } - } - return o.Default -} - -// --- All options declared here --- - -var ( - OptCachePoll = DurationOpt("cache.poll", 10*time.Second, "host-side invalidation poll interval") - OptCacheFSEvents = BoolOpt("cache.fsevents", false, "use macOS FSEvents for invalidation instead of polling") - OptCacheFSEventsLatency = DurationOpt("cache.fsevents.latency", 500*time.Millisecond, "FSEvents coalescing latency") -) - -// --- Option store --- - -var store map[string]string - -func optValues() map[string]string { - if store != nil { - return store - } - store = make(map[string]string) - for _, kv := range strings.Split(os.Getenv("LNX_OPTIONS"), ",") { - kv = strings.TrimSpace(kv) - if k, v, ok := strings.Cut(kv, "="); ok { - store[strings.TrimSpace(k)] = strings.TrimSpace(v) - } - } - return store -} - -// SetOption sets a runtime option (e.g., from -O flags). Call before first Get. -func SetOption(key, value string) { - opts := optValues() - opts[key] = value -} - -// FormatOptionsHelp returns help text listing all declared options. -func FormatOptionsHelp() string { - all := []interface{ spec() (string, string, string) }{ - wrap(OptCachePoll), - wrap(OptCacheFSEvents), - wrap(OptCacheFSEventsLatency), - } - var b strings.Builder - for _, o := range all { - key, def, desc := o.spec() - fmt.Fprintf(&b, " %-30s %s (default: %s)\n", key, desc, def) - } - return b.String() -} - -type optSpec struct { - key, def, desc string -} - -func (o optSpec) spec() (string, string, string) { return o.key, o.def, o.desc } - -func wrap[T any](o *Opt[T]) optSpec { - return optSpec{key: o.Key, def: fmt.Sprint(o.Default), desc: o.Desc} -} diff --git a/old/p9_intg_test.go b/old/p9_intg_test.go deleted file mode 100644 index 401207b..0000000 --- a/old/p9_intg_test.go +++ /dev/null @@ -1,52 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRun_9P_HomeReadable(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - // Write a file in the host home dir. The guest should be able to - // read it via the 9P mount. - home, err := os.UserHomeDir() - require.NoError(t, err) - - marker := filepath.Join(home, ".lnx_9p_test_marker") - require.NoError(t, os.WriteFile(marker, []byte("9P_WORKS"), 0644)) - t.Cleanup(func() { os.Remove(marker) }) - - exitCode, err := lnx.Run(cfg, "cat", marker) - require.NoError(t, err) - assert.Equal(t, 0, exitCode) -} - -func TestRun_Home_WriteStaysInCache(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - home, err := os.UserHomeDir() - require.NoError(t, err) - - // Writes to the home FUSE mount go to the ext4 cache (succeed in the guest) - // but must never appear on the host (lower virtiofs is read-only). - target := filepath.Join(home, ".lnx_home_cache_test") - exitCode, err := lnx.Run(cfg, "sh", "-c", "echo cache-write > "+target) - require.NoError(t, err) - assert.Equal(t, 0, exitCode) - - // File must not be visible on the host. - _, err = os.Stat(target) - assert.True(t, os.IsNotExist(err)) -} diff --git a/old/p9filter.go b/old/p9filter.go deleted file mode 100644 index 6157d9a..0000000 --- a/old/p9filter.go +++ /dev/null @@ -1,177 +0,0 @@ -package lnx - -import ( - "path/filepath" - - "github.com/hugelgupf/p9/linux" - "github.com/hugelgupf/p9/p9" -) - -// blockedDirs are directory names under $HOME that are blocked from 9P access. -var blockedDirs = map[string]bool{ - // SSH keys - ".ssh": true, - // GPG keys - ".gnupg": true, - // AWS credentials - ".aws": true, - // macOS Keychain - "Library/Keychains": true, - // Browser profiles (cookies, passwords, session tokens) - "Library/Application Support/Google/Chrome": true, - "Library/Application Support/Google/Chrome Canary": true, - "Library/Application Support/Chromium": true, - "Library/Application Support/Firefox": true, - "Library/Application Support/Microsoft Edge": true, - "Library/Application Support/BraveSoftware": true, - "Library/Application Support/Arc": true, - "Library/Application Support/com.operasoftware.Opera": true, - "Library/Safari": true, - "Library/Cookies": true, - // Docker credentials - ".docker": true, - // Kubernetes credentials - ".kube": true, - // Terraform state (may contain secrets) - ".terraform.d": true, - // NPM tokens - ".npmrc": true, - // 1Password CLI - ".op": true, - ".1password": true, - ".config/op": true, - "Library/Group Containers/2BUA8C4S2C.com.1password": true, -} - -// filteredAttacher wraps a p9.Attacher and filters sensitive directories. -type filteredAttacher struct { - inner p9.Attacher -} - -func (a *filteredAttacher) Attach() (p9.File, error) { - f, err := a.inner.Attach() - if err != nil { - return nil, err - } - return &filteredFile{inner: f, relPath: ""}, nil -} - -// filteredFile wraps a p9.File and blocks access to sensitive paths. -type filteredFile struct { - inner p9.File - relPath string // path relative to the 9P root (home dir) -} - -func (f *filteredFile) isBlocked(name string) bool { - candidate := filepath.Join(f.relPath, name) - if blockedDirs[candidate] { - return true - } - // Check if the current path is inside a blocked tree. - for dir := candidate; dir != "." && dir != ""; dir = filepath.Dir(dir) { - if blockedDirs[dir] { - return true - } - } - return false -} - -func (f *filteredFile) Walk(names []string) ([]p9.QID, p9.File, error) { - for _, name := range names { - if f.isBlocked(name) { - return nil, nil, linux.EACCES - } - } - qids, file, err := f.inner.Walk(names) - if err != nil { - return qids, file, err - } - newPath := f.relPath - for _, name := range names { - newPath = filepath.Join(newPath, name) - } - return qids, &filteredFile{inner: file, relPath: newPath}, nil -} - -func (f *filteredFile) WalkGetAttr(names []string) ([]p9.QID, p9.File, p9.AttrMask, p9.Attr, error) { - for _, name := range names { - if f.isBlocked(name) { - return nil, nil, p9.AttrMask{}, p9.Attr{}, linux.EACCES - } - } - qids, file, mask, attr, err := f.inner.WalkGetAttr(names) - if err != nil { - return qids, file, mask, attr, err - } - newPath := f.relPath - for _, name := range names { - newPath = filepath.Join(newPath, name) - } - return qids, &filteredFile{inner: file, relPath: newPath}, mask, attr, nil -} - -func (f *filteredFile) Readdir(offset uint64, count uint32) (p9.Dirents, error) { - entries, err := f.inner.Readdir(offset, count) - if err != nil { - return entries, err - } - var filtered p9.Dirents - for _, e := range entries { - if !f.isBlocked(e.Name) { - filtered = append(filtered, e) - } - } - return filtered, nil -} - -// Delegate everything else to inner. - -func (f *filteredFile) StatFS() (p9.FSStat, error) { return f.inner.StatFS() } -func (f *filteredFile) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { - return f.inner.GetAttr(req) -} -func (f *filteredFile) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { - return f.inner.SetAttr(valid, attr) -} -func (f *filteredFile) Close() error { return f.inner.Close() } -func (f *filteredFile) Open(mode p9.OpenFlags) (p9.QID, uint32, error) { return f.inner.Open(mode) } -func (f *filteredFile) ReadAt(p []byte, offset int64) (int, error) { return f.inner.ReadAt(p, offset) } -func (f *filteredFile) WriteAt(p []byte, offset int64) (int, error) { - return f.inner.WriteAt(p, offset) -} -func (f *filteredFile) FSync() error { return f.inner.FSync() } -func (f *filteredFile) Lock(pid int, lt p9.LockType, flags p9.LockFlags, start, length uint64, client string) (p9.LockStatus, error) { - return f.inner.Lock(pid, lt, flags, start, length, client) -} -func (f *filteredFile) Create(name string, flags p9.OpenFlags, perm p9.FileMode, uid p9.UID, gid p9.GID) (p9.File, p9.QID, uint32, error) { - return f.inner.Create(name, flags, perm, uid, gid) -} -func (f *filteredFile) Mkdir(name string, perm p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, error) { - return f.inner.Mkdir(name, perm, uid, gid) -} -func (f *filteredFile) Symlink(oldName, newName string, uid p9.UID, gid p9.GID) (p9.QID, error) { - return f.inner.Symlink(oldName, newName, uid, gid) -} -func (f *filteredFile) Link(target p9.File, newName string) error { - return f.inner.Link(target, newName) -} -func (f *filteredFile) Mknod(name string, mode p9.FileMode, major, minor uint32, uid p9.UID, gid p9.GID) (p9.QID, error) { - return f.inner.Mknod(name, mode, major, minor, uid, gid) -} -func (f *filteredFile) Rename(newDir p9.File, newName string) error { - return f.inner.Rename(newDir, newName) -} -func (f *filteredFile) RenameAt(oldName string, newDir p9.File, newName string) error { - return f.inner.RenameAt(oldName, newDir, newName) -} -func (f *filteredFile) UnlinkAt(name string, flags uint32) error { - return f.inner.UnlinkAt(name, flags) -} -func (f *filteredFile) Readlink() (string, error) { return f.inner.Readlink() } -func (f *filteredFile) Renamed(newDir p9.File, newName string) { f.inner.Renamed(newDir, newName) } -func (f *filteredFile) SetXattr(attr string, data []byte, flags p9.XattrFlags) error { - return f.inner.SetXattr(attr, data, flags) -} -func (f *filteredFile) GetXattr(attr string) ([]byte, error) { return f.inner.GetXattr(attr) } -func (f *filteredFile) ListXattrs() ([]string, error) { return f.inner.ListXattrs() } -func (f *filteredFile) RemoveXattr(attr string) error { return f.inner.RemoveXattr(attr) } diff --git a/old/p9server.go b/old/p9server.go deleted file mode 100644 index 814772a..0000000 --- a/old/p9server.go +++ /dev/null @@ -1,61 +0,0 @@ -package lnx - -import ( - "log/slog" - "net" - - "github.com/hugelgupf/p9/fsimpl/localfs" - "github.com/hugelgupf/p9/p9" -) - -// start9PServer starts a 9P2000.L file server on the given listener, -// serving rootPath with security filtering. Handles one client connection. -func start9PServer(listener net.Listener, rootPath string) { - go func() { - conn, err := listener.Accept() - if err != nil { - return - } - slog.Debug("9p client connected") - - s := p9.NewServer(&filteredAttacher{inner: localfs.Attacher(rootPath)}) - s.Handle(conn, conn) - }() -} - -// start9PServerUnfiltered starts a 9P2000.L file server without security -// filtering. Used for CWD, extra shares, and ~/.lnx (all read-write). -func start9PServerUnfiltered(listener net.Listener, rootPath string) { - go func() { - conn, err := listener.Accept() - if err != nil { - return - } - slog.Debug("9p unfiltered client connected", "root", rootPath) - - s := p9.NewServer(localfs.Attacher(rootPath)) - s.Handle(conn, conn) - }() -} - -// start9PTrackedServer starts a 9P2000.L file server that tracks accessed files -// so the host can poll only those paths for mtime changes. If filtered is true, -// sensitive paths are blocked (used for the home directory). -func start9PTrackedServer(listener net.Listener, rootPath string, tracker *fileTracker, filtered bool) { - go func() { - conn, err := listener.Accept() - if err != nil { - return - } - slog.Debug("9p tracked client connected", "root", rootPath, "filtered", filtered) - - var inner p9.Attacher - if filtered { - inner = &filteredAttacher{inner: localfs.Attacher(rootPath)} - } else { - inner = localfs.Attacher(rootPath) - } - s := p9.NewServer(&trackedAttacher{inner: inner, tracker: tracker}) - s.Handle(conn, conn) - }() -} diff --git a/old/p9track.go b/old/p9track.go deleted file mode 100644 index ddb62a1..0000000 --- a/old/p9track.go +++ /dev/null @@ -1,194 +0,0 @@ -package lnx - -import ( - "log/slog" - "os" - "path/filepath" - "strings" - "sync" - "syscall" - "time" - - "github.com/hugelgupf/p9/p9" -) - -// shareWatcher pairs a share tag with its file tracker. -type shareWatcher struct { - tag string - tracker *fileTracker -} - -func cachePollInterval() time.Duration { - return OptCachePoll.Get() -} - -// trackedAttacher wraps a p9.Attacher and records which files the guest accesses. -type trackedAttacher struct { - inner p9.Attacher - tracker *fileTracker -} - -func (a *trackedAttacher) Attach() (p9.File, error) { - f, err := a.inner.Attach() - if err != nil { - return nil, err - } - return &trackedFile{inner: f, relPath: "", tracker: a.tracker}, nil -} - -// trackedFile wraps a p9.File and records paths on Walk/Open. -type trackedFile struct { - inner p9.File - relPath string - tracker *fileTracker -} - -func (f *trackedFile) Walk(names []string) ([]p9.QID, p9.File, error) { - qids, file, err := f.inner.Walk(names) - if err != nil { - return qids, file, err - } - newPath := f.relPath - for _, name := range names { - newPath = filepath.Join(newPath, name) - } - return qids, &trackedFile{inner: file, relPath: newPath, tracker: f.tracker}, nil -} - -func (f *trackedFile) Open(mode p9.OpenFlags) (p9.QID, uint32, error) { - qid, iounit, err := f.inner.Open(mode) - if err == nil && f.relPath != "" { - f.tracker.add(f.relPath) - } - return qid, iounit, err -} - -func (f *trackedFile) WalkGetAttr(names []string) ([]p9.QID, p9.File, p9.AttrMask, p9.Attr, error) { - qids, file, mask, attr, err := f.inner.WalkGetAttr(names) - if err != nil { - return qids, file, mask, attr, err - } - newPath := f.relPath - for _, name := range names { - newPath = filepath.Join(newPath, name) - } - return qids, &trackedFile{inner: file, relPath: newPath, tracker: f.tracker}, mask, attr, nil -} - -func (f *trackedFile) Readdir(offset uint64, count uint32) (p9.Dirents, error) { - if f.relPath != "" { - f.tracker.add(f.relPath) - } - return f.inner.Readdir(offset, count) -} - -// Delegate everything else. - -func (f *trackedFile) StatFS() (p9.FSStat, error) { return f.inner.StatFS() } -func (f *trackedFile) GetAttr(req p9.AttrMask) (p9.QID, p9.AttrMask, p9.Attr, error) { - return f.inner.GetAttr(req) -} -func (f *trackedFile) SetAttr(valid p9.SetAttrMask, attr p9.SetAttr) error { - return f.inner.SetAttr(valid, attr) -} -func (f *trackedFile) Close() error { return f.inner.Close() } -func (f *trackedFile) ReadAt(p []byte, offset int64) (int, error) { return f.inner.ReadAt(p, offset) } -func (f *trackedFile) WriteAt(p []byte, offset int64) (int, error) { return f.inner.WriteAt(p, offset) } -func (f *trackedFile) FSync() error { return f.inner.FSync() } -func (f *trackedFile) Lock(pid int, lt p9.LockType, flags p9.LockFlags, start, length uint64, client string) (p9.LockStatus, error) { - return f.inner.Lock(pid, lt, flags, start, length, client) -} -func (f *trackedFile) Create(name string, flags p9.OpenFlags, perm p9.FileMode, uid p9.UID, gid p9.GID) (p9.File, p9.QID, uint32, error) { - return f.inner.Create(name, flags, perm, uid, gid) -} -func (f *trackedFile) Mkdir(name string, perm p9.FileMode, uid p9.UID, gid p9.GID) (p9.QID, error) { - return f.inner.Mkdir(name, perm, uid, gid) -} -func (f *trackedFile) Symlink(oldName, newName string, uid p9.UID, gid p9.GID) (p9.QID, error) { - return f.inner.Symlink(oldName, newName, uid, gid) -} -func (f *trackedFile) Link(target p9.File, newName string) error { - return f.inner.Link(target, newName) -} -func (f *trackedFile) Mknod(name string, mode p9.FileMode, major, minor uint32, uid p9.UID, gid p9.GID) (p9.QID, error) { - return f.inner.Mknod(name, mode, major, minor, uid, gid) -} -func (f *trackedFile) Rename(newDir p9.File, newName string) error { - return f.inner.Rename(newDir, newName) -} -func (f *trackedFile) RenameAt(oldName string, newDir p9.File, newName string) error { - return f.inner.RenameAt(oldName, newDir, newName) -} -func (f *trackedFile) UnlinkAt(name string, flags uint32) error { - return f.inner.UnlinkAt(name, flags) -} -func (f *trackedFile) Readlink() (string, error) { return f.inner.Readlink() } -func (f *trackedFile) Renamed(newDir p9.File, newName string) { f.inner.Renamed(newDir, newName) } -func (f *trackedFile) SetXattr(attr string, data []byte, flags p9.XattrFlags) error { - return f.inner.SetXattr(attr, data, flags) -} -func (f *trackedFile) GetXattr(attr string) ([]byte, error) { return f.inner.GetXattr(attr) } -func (f *trackedFile) ListXattrs() ([]string, error) { return f.inner.ListXattrs() } -func (f *trackedFile) RemoveXattr(attr string) error { return f.inner.RemoveXattr(attr) } - -// fileTracker records accessed paths and their host mtimes for change detection. -type fileTracker struct { - rootPath string - mu sync.Mutex - mtimes map[string]syscall.Timespec // relative path -> mtime at last check -} - -func newFileTracker(rootPath string) *fileTracker { - return &fileTracker{rootPath: rootPath, mtimes: make(map[string]syscall.Timespec)} -} - -// add records a path as accessed. Captures its current mtime. -func (t *fileTracker) add(relPath string) { - t.mu.Lock() - defer t.mu.Unlock() - if _, ok := t.mtimes[relPath]; ok { - return - } - absPath := filepath.Join(t.rootPath, relPath) - var st syscall.Stat_t - if syscall.Lstat(absPath, &st) == nil { - t.mtimes[relPath] = statMtime(&st) - } -} - -func timespecEqual(a, b syscall.Timespec) bool { - return a.Sec == b.Sec && a.Nsec == b.Nsec -} - -// scanDir checks tracked paths under the given directory (relative to rootPath) -// for mtime changes. Called when FSEvents reports a directory-level change. -// Returns changed relative paths and updates stored mtimes. -func (t *fileTracker) scanDir(dir string) []string { - t.mu.Lock() - defer t.mu.Unlock() - - var changed []string - for relPath, oldMtime := range t.mtimes { - // Only check files under the changed directory. - relDir := filepath.Dir(relPath) - if relDir != dir && !strings.HasPrefix(relDir, dir+"/") && dir != "." && dir != "" { - continue - } - absPath := filepath.Join(t.rootPath, relPath) - var st syscall.Stat_t - if err := syscall.Lstat(absPath, &st); err != nil { - slog.Debug("scanDir stat failed", "path", absPath, "error", err) - if os.IsNotExist(err) { - changed = append(changed, relPath) - delete(t.mtimes, relPath) - } - continue - } - newMtime := statMtime(&st) - if !timespecEqual(newMtime, oldMtime) { - changed = append(changed, relPath) - t.mtimes[relPath] = newMtime - } - } - return changed -} diff --git a/old/p9track_mtime_darwin.go b/old/p9track_mtime_darwin.go deleted file mode 100644 index c50a10d..0000000 --- a/old/p9track_mtime_darwin.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build darwin - -package lnx - -import "syscall" - -func statMtime(st *syscall.Stat_t) syscall.Timespec { return st.Mtimespec } diff --git a/old/p9track_mtime_linux.go b/old/p9track_mtime_linux.go deleted file mode 100644 index 5302231..0000000 --- a/old/p9track_mtime_linux.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build linux - -package lnx - -import "syscall" - -func statMtime(st *syscall.Stat_t) syscall.Timespec { return st.Mtim } diff --git a/old/portfwd.go b/old/portfwd.go deleted file mode 100644 index f4dc8b7..0000000 --- a/old/portfwd.go +++ /dev/null @@ -1,329 +0,0 @@ -package lnx - -import ( - "encoding/binary" - "encoding/gob" - "fmt" - "io" - "log/slog" - "net" - "sync" - - "github.com/semistrict/lnx/internal/protocol" -) - -// portForwarder manages automatic port forwarding from guest to host. -type portForwarder struct { - sock VsockDevice - - mu sync.Mutex - auto map[uint16]*forwardedPort // guest port -> listener - manual map[uint16]*forwardedPort // host port -> listener -} - -type forwardedPort struct { - guestPort uint16 - hostPort uint16 - listener net.Listener - done chan struct{} - visible bool -} - -func newPortForwarder(sock VsockDevice) *portForwarder { - return &portForwarder{ - sock: sock, - auto: make(map[uint16]*forwardedPort), - manual: make(map[uint16]*forwardedPort), - } -} - -// run reads PortForward notifications and manages host listeners. -func (pf *portForwarder) run(conn net.Conn) { - defer conn.Close() - dec := gob.NewDecoder(conn) - - for { - var msg protocol.PortForward - if err := dec.Decode(&msg); err != nil { - return - } - pf.reconcile(msg.Ports) - } -} - -// reconcile starts/stops port forwarding to match the desired set. -func (pf *portForwarder) reconcile(ports []uint16) { - pf.mu.Lock() - defer pf.mu.Unlock() - - want := map[uint16]bool{} - for _, p := range ports { - want[p] = true - } - - // Stop forwarding ports that are no longer listening. - for gp, fp := range pf.auto { - if !want[gp] { - slog.Info("port forward stop", "guest", gp, "host", fp.hostPort) - close(fp.done) - fp.listener.Close() - delete(pf.auto, gp) - } - } - - // Start forwarding new ports. - for _, gp := range ports { - if _, ok := pf.findManualByGuestPortLocked(gp); ok { - continue - } - if _, ok := pf.auto[gp]; ok { - continue - } - fp := pf.startAutoForward(gp) - if fp != nil { - pf.auto[gp] = fp - } - } -} - -// startAutoForward binds a host TCP listener and returns the forwardedPort. -// Tries the same port first, then increments if busy. -func (pf *portForwarder) startAutoForward(guestPort uint16) *forwardedPort { - var ln net.Listener - hostPort := guestPort - - for attempts := 0; attempts < 100; attempts++ { - var err error - ln, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", hostPort)) - if err == nil { - break - } - hostPort++ - } - if ln == nil { - slog.Warn("port forward failed to bind", "guest", guestPort) - return nil - } - - if hostPort == guestPort { - slog.Info("port forward", "port", guestPort) - } else { - slog.Info("port forward", "guest", guestPort, "host", hostPort) - } - - fp := &forwardedPort{ - guestPort: guestPort, - hostPort: hostPort, - listener: ln, - done: make(chan struct{}), - visible: true, - } - - go pf.acceptLoop(fp) - return fp -} - -// exposeHost binds an explicit host listener for guestPort. -// If requestedHostPort is 0, an ephemeral host port is chosen. -// Returns the bound host port and whether a new mapping was created. -func (pf *portForwarder) exposeHost(guestPort, requestedHostPort uint16, visible bool) (uint16, bool, error) { - pf.mu.Lock() - defer pf.mu.Unlock() - - if requestedHostPort == 0 { - if fp, ok := pf.findReusableManualByGuestPortLocked(guestPort, visible); ok { - if visible { - fp.visible = true - } - return fp.hostPort, false, nil - } - } - - if requestedHostPort != 0 { - if fp, ok := pf.findByHostPortLocked(requestedHostPort); ok { - if fp.guestPort == guestPort { - if visible { - fp.visible = true - } - return fp.hostPort, false, nil - } - return 0, false, fmt.Errorf("host port %d is already forwarded to guest port %d", requestedHostPort, fp.guestPort) - } - } - - ln, hostPort, err := bindHostPort(requestedHostPort, visible) - if err != nil { - if requestedHostPort == 0 { - return 0, false, fmt.Errorf("bind ephemeral host port: %w", err) - } - return 0, false, fmt.Errorf("bind host port %d: %w", requestedHostPort, err) - } - - fp := &forwardedPort{ - guestPort: guestPort, - hostPort: hostPort, - listener: ln, - done: make(chan struct{}), - visible: visible, - } - pf.manual[hostPort] = fp - go pf.acceptLoop(fp) - return hostPort, true, nil -} - -func bindHostPort(port uint16, visible bool) (net.Listener, uint16, error) { - host := "0.0.0.0" - if visible { - host = "127.0.0.1" - } - addr := net.JoinHostPort(host, "0") - if port != 0 { - addr = net.JoinHostPort(host, fmt.Sprintf("%d", port)) - } - ln, err := net.Listen("tcp", addr) - if err != nil { - return nil, 0, err - } - tcpAddr, ok := ln.Addr().(*net.TCPAddr) - if !ok { - ln.Close() - return nil, 0, fmt.Errorf("unexpected listener addr type %T", ln.Addr()) - } - return ln, uint16(tcpAddr.Port), nil -} - -func (pf *portForwarder) findByHostPortLocked(hostPort uint16) (*forwardedPort, bool) { - if fp, ok := pf.manual[hostPort]; ok { - return fp, true - } - for _, fp := range pf.auto { - if fp.hostPort == hostPort { - return fp, true - } - } - return nil, false -} - -func (pf *portForwarder) findManualByGuestPortLocked(guestPort uint16) (*forwardedPort, bool) { - for _, fp := range pf.manual { - if fp.guestPort == guestPort { - return fp, true - } - } - return nil, false -} - -func (pf *portForwarder) findReusableManualByGuestPortLocked(guestPort uint16, visible bool) (*forwardedPort, bool) { - var fallback *forwardedPort - for _, fp := range pf.manual { - if fp.guestPort != guestPort { - continue - } - if !visible && !fp.visible { - return fp, true - } - if fallback == nil { - fallback = fp - } - } - if fallback != nil { - return fallback, true - } - return nil, false -} - -func (pf *portForwarder) removeHost(hostPort uint16) bool { - pf.mu.Lock() - defer pf.mu.Unlock() - - fp, ok := pf.manual[hostPort] - if !ok { - return false - } - close(fp.done) - fp.listener.Close() - delete(pf.manual, hostPort) - return true -} - -func (pf *portForwarder) listVisiblePorts() []PortEntry { - pf.mu.Lock() - defer pf.mu.Unlock() - - var ports []PortEntry - for _, fp := range pf.auto { - if fp.visible { - ports = append(ports, PortEntry{Guest: fp.guestPort, Host: fp.hostPort}) - } - } - for _, fp := range pf.manual { - if fp.visible { - ports = append(ports, PortEntry{Guest: fp.guestPort, Host: fp.hostPort}) - } - } - return ports -} - -func (pf *portForwarder) acceptLoop(fp *forwardedPort) { - for { - conn, err := fp.listener.Accept() - if err != nil { - select { - case <-fp.done: - return - default: - slog.Info("port forward accept failed", "error", err) - return - } - } - go pf.forward(conn, fp.guestPort) - } -} - -// forward connects to the guest via vsock and splices data. -func (pf *portForwarder) forward(hostConn net.Conn, guestPort uint16) { - defer hostConn.Close() - - // Connect to guest's port forward data listener via vsock. - vsockConn, err := pf.sock.Connect(protocol.PortForwardDataPort) - if err != nil { - slog.Info("port forward vsock connect failed", "port", guestPort, "error", err) - return - } - defer vsockConn.Close() - - // Send 2-byte target port header. - var portBuf [2]byte - binary.BigEndian.PutUint16(portBuf[:], guestPort) - if _, err := vsockConn.Write(portBuf[:]); err != nil { - return - } - - // Splice bidirectionally, propagating EOF in both directions. - done := make(chan struct{}) - go func() { - io.Copy(hostConn, vsockConn) - if tc, ok := hostConn.(*net.TCPConn); ok { - tc.CloseWrite() - } - close(done) - }() - io.Copy(vsockConn, hostConn) - vsockConn.Close() - <-done -} - -func (pf *portForwarder) close() { - pf.mu.Lock() - defer pf.mu.Unlock() - for gp, fp := range pf.auto { - close(fp.done) - fp.listener.Close() - delete(pf.auto, gp) - } - for hp, fp := range pf.manual { - close(fp.done) - fp.listener.Close() - delete(pf.manual, hp) - } -} diff --git a/old/portfwd_intg_test.go b/old/portfwd_intg_test.go deleted file mode 100644 index 2d252ad..0000000 --- a/old/portfwd_intg_test.go +++ /dev/null @@ -1,66 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "fmt" - "io" - "net" - "testing" - "time" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRun_PortForward(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - // Use a shell-based TCP listener that's available everywhere. - // bash's /dev/tcp doesn't work for listening, so use python3. - guestPort := 9876 - cmd := fmt.Sprintf( - `python3 -c " -import socket, sys -s = socket.socket() -s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -s.bind(('0.0.0.0', %d)) -s.listen(1) -conn, _ = s.accept() -conn.sendall(b'HELLO_FROM_GUEST\n') -conn.close() -s.close() -"`, guestPort, - ) - - errCh := make(chan error, 1) - codeCh := make(chan int, 1) - go func() { - code, err := lnx.Run(cfg, "sh", "-c", cmd) - errCh <- err - codeCh <- code - }() - - // Wait for port forwarding to detect the listener and forward it. - var conn net.Conn - var err error - for i := 0; i < 60; i++ { - time.Sleep(time.Second) - conn, err = net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", guestPort), time.Second) - if err == nil { - break - } - } - require.NoError(t, err, "failed to connect to forwarded port %d", guestPort) - - data, err := io.ReadAll(conn) - conn.Close() - require.NoError(t, err) - assert.Contains(t, string(data), "HELLO_FROM_GUEST") - - require.NoError(t, <-errCh) - assert.Equal(t, 0, <-codeCh) -} diff --git a/old/portfwd_test.go b/old/portfwd_test.go deleted file mode 100644 index 1a66a1d..0000000 --- a/old/portfwd_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package lnx - -import ( - "net" - "testing" -) - -type stubVsockDevice struct{} - -func (stubVsockDevice) Listen(port uint32) (net.Listener, error) { return nil, nil } -func (stubVsockDevice) Connect(port uint32) (net.Conn, error) { return nil, nil } - -func TestPortForwarderExposeHost(t *testing.T) { - pf := newPortForwarder(stubVsockDevice{}) - t.Cleanup(func() { pf.close() }) - - hostPort, created, err := pf.exposeHost(8080, 0, true) - if err != nil { - t.Fatalf("exposeHost: %v", err) - } - if !created { - t.Fatal("expected new mapping to be created") - } - if hostPort == 0 { - t.Fatal("expected allocated host port") - } - - ports := pf.listVisiblePorts() - if len(ports) != 1 || ports[0].Guest != 8080 || ports[0].Host != hostPort { - t.Fatalf("unexpected visible ports: %+v", ports) - } -} - -func TestPortForwarderExposeHostRejectsOverlap(t *testing.T) { - pf := newPortForwarder(stubVsockDevice{}) - t.Cleanup(func() { pf.close() }) - - hostPort, _, err := pf.exposeHost(8080, 0, true) - if err != nil { - t.Fatalf("first exposeHost: %v", err) - } - - if _, _, err := pf.exposeHost(9090, hostPort, true); err == nil { - t.Fatalf("expected overlap error for host port %d", hostPort) - } -} - -func TestPortForwarderExposeHostReusesHiddenMapping(t *testing.T) { - pf := newPortForwarder(stubVsockDevice{}) - t.Cleanup(func() { pf.close() }) - - hostPort, created, err := pf.exposeHost(8080, 0, false) - if err != nil { - t.Fatalf("first exposeHost: %v", err) - } - if !created { - t.Fatal("expected first mapping to be created") - } - - reusedPort, created, err := pf.exposeHost(8080, 0, false) - if err != nil { - t.Fatalf("second exposeHost: %v", err) - } - if created { - t.Fatal("expected second mapping to be reused") - } - if reusedPort != hostPort { - t.Fatalf("reused host port = %d, want %d", reusedPort, hostPort) - } -} - -func TestPortForwarderReconcileSkipsManualGuestPort(t *testing.T) { - pf := newPortForwarder(stubVsockDevice{}) - t.Cleanup(func() { pf.close() }) - - if _, _, err := pf.exposeHost(8080, 0, false); err != nil { - t.Fatalf("exposeHost: %v", err) - } - pf.reconcile([]uint16{8080}) - if len(pf.auto) != 0 { - t.Fatalf("expected no auto forward for manually exposed guest port, got %+v", pf.auto) - } -} - -func TestPortForwarderExposeHostPrefersHiddenMappingForVMExpose(t *testing.T) { - pf := newPortForwarder(stubVsockDevice{}) - t.Cleanup(func() { pf.close() }) - - hiddenPort, _, err := pf.exposeHost(8080, 0, false) - if err != nil { - t.Fatalf("hidden exposeHost: %v", err) - } - if _, _, err := pf.exposeHost(8080, 9090, true); err != nil { - t.Fatalf("visible exposeHost: %v", err) - } - - reusedPort, created, err := pf.exposeHost(8080, 0, false) - if err != nil { - t.Fatalf("reuse exposeHost: %v", err) - } - if created { - t.Fatal("expected hidden mapping to be reused") - } - if reusedPort != hiddenPort { - t.Fatalf("reused host port = %d, want hidden port %d", reusedPort, hiddenPort) - } -} diff --git a/old/pty_intg_test.go b/old/pty_intg_test.go deleted file mode 100644 index d0d33cc..0000000 --- a/old/pty_intg_test.go +++ /dev/null @@ -1,267 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "bytes" - "fmt" - "io" - "net" - "os" - "os/exec" - "strings" - "testing" - "time" - - "github.com/creack/pty" - "github.com/stretchr/testify/require" - "github.com/vito/midterm" -) - -// screenText returns the visible text from a midterm terminal. -func screenText(term *midterm.Terminal) string { - var lines []string - for _, row := range term.Content { - lines = append(lines, strings.TrimRight(string(row), " \x00")) - } - return strings.TrimRight(strings.Join(lines, "\n"), "\n") -} - -// feedTerminal reads from ptmx and writes to the virtual terminal. -func feedTerminal(term *midterm.Terminal, ptmx *os.File) { - buf := make([]byte, 4096) - for { - n, err := ptmx.Read(buf) - if n > 0 { - term.Write(buf[:n]) - } - if err != nil { - return - } - } -} - -// waitFor polls the terminal screen until it contains the expected string. -func waitFor(t testing.TB, term *midterm.Terminal, want string, timeout time.Duration) { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if strings.Contains(screenText(term), want) { - return - } - time.Sleep(100 * time.Millisecond) - } - t.Fatalf("timed out waiting for %q in terminal:\n%s", want, screenText(term)) -} - -func lnxBin() string { - p, _ := exec.LookPath("lnx") - return p -} - -type shellStep struct { - input string - wait time.Duration -} - -func freeTCPPort(t *testing.T) int { - t.Helper() - - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer ln.Close() - - return ln.Addr().(*net.TCPAddr).Port -} - -func runInteractiveShellTranscript(t *testing.T, steps []shellStep) string { - t.Helper() - - cmd := exec.Command("zsh", "-i") - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 100}) - require.NoError(t, err) - defer ptmx.Close() - defer cmd.Process.Kill() - - var out bytes.Buffer - doneRead := make(chan struct{}) - go func() { - _, _ = io.Copy(&out, ptmx) - close(doneRead) - }() - - time.Sleep(time.Second) - for _, step := range steps { - _, err := ptmx.WriteString(step.input) - require.NoError(t, err) - time.Sleep(step.wait) - } - - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("interactive zsh session did not exit within 5s") - } - - _ = ptmx.Close() - select { - case <-doneRead: - case <-time.After(2 * time.Second): - t.Fatal("timed out reading zsh transcript") - } - - return out.String() -} - -func TestPTY_MainInteractive(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - term := midterm.NewTerminal(24, 80) - - cmd := exec.Command(bin, "--ephemeral", "bash", "-l") - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 80}) - require.NoError(t, err) - defer ptmx.Close() - defer cmd.Process.Kill() - - go feedTerminal(term, ptmx) - - // Wait for a shell prompt ($ is common to bash prompts). - waitFor(t, term, "$", 15*time.Second) - - ptmx.WriteString("echo HELLO_FROM_PTY\n") - waitFor(t, term, "HELLO_FROM_PTY", 5*time.Second) - - // Ctrl-D should exit cleanly. - ptmx.Write([]byte{0x04}) - - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("Ctrl-D did not exit within 5s") - } -} - -func TestPTY_SecondSession(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - // Start a first interactive session — this auto-starts the daemon. - term1 := midterm.NewTerminal(24, 80) - cmd1 := exec.Command(bin, "--ephemeral", "bash", "-l") - ptmx1, err := pty.StartWithSize(cmd1, &pty.Winsize{Rows: 24, Cols: 80}) - require.NoError(t, err) - defer ptmx1.Close() - defer cmd1.Process.Kill() - - go feedTerminal(term1, ptmx1) - waitFor(t, term1, "$", 15*time.Second) - - // Start a second session that execs into the running VM. - term2 := midterm.NewTerminal(24, 80) - cmd2 := exec.Command(bin, "bash", "-l") - ptmx2, err := pty.StartWithSize(cmd2, &pty.Winsize{Rows: 24, Cols: 80}) - require.NoError(t, err) - defer ptmx2.Close() - defer cmd2.Process.Kill() - - go feedTerminal(term2, ptmx2) - waitFor(t, term2, "$", 15*time.Second) - - ptmx2.WriteString("echo SECOND_SESSION\n") - waitFor(t, term2, "SECOND_SESSION", 5*time.Second) - - // Exit second session. - ptmx2.Write([]byte{0x04}) - done2 := make(chan error, 1) - go func() { done2 <- cmd2.Wait() }() - select { - case <-done2: - case <-time.After(5 * time.Second): - t.Fatal("second session did not exit within 5s") - } - - // Exit first session — daemon should shut down after this. - ptmx1.Write([]byte{0x04}) - done1 := make(chan error, 1) - go func() { done1 <- cmd1.Wait() }() - select { - case <-done1: - case <-time.After(5 * time.Second): - t.Fatal("first session did not exit within 5s") - } -} - -func TestPTY_InteractiveCommandNotFoundShowsMessage(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - term := midterm.NewTerminal(24, 100) - - cmd := exec.Command(bin, "omx") - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 100}) - require.NoError(t, err) - defer ptmx.Close() - defer cmd.Process.Kill() - - go feedTerminal(term, ptmx) - - waitFor(t, term, "omx: command not found", 10*time.Second) - - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - select { - case err := <-done: - require.Error(t, err) - case <-time.After(5 * time.Second): - t.Fatal("interactive command-not-found did not exit within 5s") - } - } - -func TestPTY_BackgroundCommandMatchesPlainShellBehavior(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - port := freeTCPPort(t) - plainTranscript := runInteractiveShellTranscript(t, []shellStep{ - {input: fmt.Sprintf("python3 -m http.server %d &\n", port), wait: time.Second}, - {input: "jobs\n", wait: 500 * time.Millisecond}, - {input: "kill %1\n", wait: time.Second}, - {input: "exit\n", wait: 500 * time.Millisecond}, - }) - require.Contains(t, plainTranscript, "running python3 -m http.server") - require.NotContains(t, plainTranscript, "suspended (tty output)") - - port = freeTCPPort(t) - lnxTranscript := runInteractiveShellTranscript(t, []shellStep{ - {input: fmt.Sprintf("lnx python3 -m http.server %d &\n", port), wait: time.Second}, - {input: "jobs\n", wait: 500 * time.Millisecond}, - {input: "kill %1\n", wait: time.Second}, - {input: "exit\n", wait: 500 * time.Millisecond}, - }) - - require.Contains(t, lnxTranscript, "running lnx python3 -m http.server") - require.NotContains(t, lnxTranscript, "suspended (tty output)") -} diff --git a/old/qemu_fork_intg_test.go b/old/qemu_fork_intg_test.go deleted file mode 100644 index 64856e0..0000000 --- a/old/qemu_fork_intg_test.go +++ /dev/null @@ -1,302 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "math/rand" - "net" - "os" - "net/http" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestQEMU_ForkPreservesMemory(t *testing.T) { - if parseQemuBackendForTest() == "" { - t.Skip("requires LNX_BACKEND=qemu:...") - } - - t0 := time.Now() - origDir := setupQemuTestDir(t) - origCfg := testConfig(origDir) - - // Boot original VM as a daemon. - go lnx.RunDaemon(origCfg) - - origClient := apiClient(origDir) - waitForExec(t, origClient, 60*time.Second) - t.Logf("[%dms] original VM ready", time.Since(t0).Milliseconds()) - - // Verify exec works. - out := execCommand(t, origClient, "echo", "test123") - require.Equal(t, "test123", out, "basic exec should work") - - // Start a background counter at a random offset. - startVal := 40000 + rand.Intn(10000) - counterCmd := fmt.Sprintf("i=%d; while true; do echo $i > /tmp/count.txt; i=$((i+1)); sleep 0.1; done", startVal) - execCommand(t, origClient, "sh", "-c", counterCmd+" < /dev/null > /dev/null 2>&1 &") - time.Sleep(500 * time.Millisecond) - - // Read the counter from the original. - origVal := readCounter(t, origClient) - t.Logf("[%dms] original counter: %d (started at %d)", time.Since(t0).Milliseconds(), origVal, startVal) - require.Greater(t, origVal, startVal, "counter should have advanced") - - // Fork the running VM. Must be on same APFS volume for clonefile. - forkDir, err := os.MkdirTemp(filepath.Dir(origDir), "fork-*") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(forkDir) }) - qmpSock := filepath.Join(origDir, "qmp.sock") - exited, err := lnx.ForkQemuVM(qmpSock, origDir, forkDir) - require.NoError(t, err, "ForkQemuVM") - if !exited { - require.NoError(t, lnx.QMPResume(qmpSock), "resume original VM") - } - t.Logf("[%dms] fork snapshot done (exited=%v)", time.Since(t0).Milliseconds(), exited) - - // Boot the fork. - forkCfg := testConfig(forkDir) - go lnx.RunDaemon(forkCfg) - - forkClient := apiClient(forkDir) - waitForExec(t, forkClient, 60*time.Second) - t.Logf("[%dms] fork VM ready", time.Since(t0).Milliseconds()) - - // Check fork state. - forkEcho := execCommand(t, forkClient, "echo", "fork-alive") - t.Logf("fork echo: %q", forkEcho) - - // The fork's counter should be at or above where the original was. - forkVal := readCounter(t, forkClient) - t.Logf("[%dms] fork counter: %d (original was %d)", time.Since(t0).Milliseconds(), forkVal, origVal) - assert.GreaterOrEqual(t, forkVal, origVal, - "fork counter should be >= original's value at fork time") - - // Wait and verify the counter is still incrementing in the fork. - time.Sleep(500 * time.Millisecond) - forkVal2 := readCounter(t, forkClient) - t.Logf("[%dms] fork counter after 500ms: %d", time.Since(t0).Milliseconds(), forkVal2) - assert.Greater(t, forkVal2, forkVal, "fork counter should still be incrementing") -} - -func TestQEMU_Fork(t *testing.T) { - if parseQemuBackendForTest() == "" { - t.Skip("requires LNX_BACKEND=qemu:...") - } - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := "test-qemu-fork" - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - cmd, stderr, done := startTimedInstance(t, bin, inst, 60*time.Second) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - // Start HTTP server with state. - runCLISuccess(t, bin, "--instance", inst, "sh", "-c", - `cat > /tmp/server.py << 'PYEOF' -import os, resource -for fd in range(3, min(resource.getrlimit(resource.RLIMIT_NOFILE)[0], 1024)): - try: os.close(fd) - except: pass -os.setsid() -from http.server import HTTPServer, BaseHTTPRequestHandler -class H(BaseHTTPRequestHandler): - state = {"value": "original"} - def do_GET(self): - self.send_response(200); self.end_headers() - self.wfile.write(f'{H.state["value"]}\n'.encode()) - def log_message(self, *a): pass -HTTPServer(("0.0.0.0", 8888), H).serve_forever() -PYEOF -python3 -u /tmp/server.py /dev/null 2>&1 &`) - - require.Eventually(t, func() bool { - out, err := runCLI(bin, "--instance", inst, "curl", "-sf", "http://127.0.0.1:8888/") - return err == nil && strings.TrimSpace(out) == "original" - }, 30*time.Second, time.Second, "HTTP server never became ready") - - // Fork. - forkOut := runCLISuccess(t, bin, "--instance", inst, "fork") - assert.Contains(t, forkOut, "forked to") - childInst := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(forkOut), "forked to")) - require.NotEmpty(t, childInst) - t.Logf("child instance: %s", childInst) - registerInstanceStopCleanup(t, bin, childInst) - - // Child should have the same HTTP server (entire VM state preserved). - require.Eventually(t, func() bool { - out, err := runCLI(bin, "--instance", childInst, "curl", "-sf", "http://127.0.0.1:8888/") - return err == nil && strings.TrimSpace(out) == "original" - }, 30*time.Second, time.Second, "child server never became ready") - - // Parent still works. - parentOut := runCLISuccess(t, bin, "--instance", inst, - "curl", "-sf", "http://127.0.0.1:8888/") - assert.Equal(t, "original\n", parentOut) -} - -func TestQEMU_ForkPipe(t *testing.T) { - if parseQemuBackendForTest() == "" { - t.Skip("requires LNX_BACKEND=qemu:...") - } - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := "test-qemu-fork-pipe" - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - cmd, stderr, done := startTimedInstance(t, bin, inst, 60*time.Second) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - out := runCLISuccess(t, bin, "--instance", inst, "python3", "-u", "-c", ` -import os -os.write(3, b"fork\n") -result = b"" -while True: - chunk = os.read(4, 4096) - if not chunk: - break - result += chunk - if b"\n" in result: - break -text = result.decode().strip() -if text.startswith("error:"): - print("FORK_ERROR: " + text) -elif text == "child": - print("FORK_CHILD") -else: - print("FORK_OK: " + text) -`) - t.Logf("fork pipe output: %s", strings.TrimSpace(out)) - if strings.Contains(out, "FORK_OK:") { - childInst := strings.TrimSpace(strings.TrimPrefix( - strings.TrimSpace(out), "FORK_OK:")) - registerInstanceStopCleanup(t, bin, childInst) - assert.Contains(t, childInst, inst+"-fork-") - } else { - t.Fatalf("expected FORK_OK, got: %s", strings.TrimSpace(out)) - } -} - -// --- helpers --- - -// setupQemuTestDir creates a test dir on the same APFS volume as ~/.lnx -// so clonefile works for rootfs and ram. -func setupQemuTestDir(t *testing.T) string { - t.Helper() - home, _ := os.UserHomeDir() - base := filepath.Join(home, ".lnx") - - kernelPath := filepath.Join(base, "vmlinuz") - if _, err := os.Stat(kernelPath); err != nil { - t.Skipf("skipping: vmlinuz not found") - } - rootfsPath := filepath.Join(base, "images", "default", "rootfs.ext4") - if _, err := os.Stat(rootfsPath); err != nil { - t.Skipf("skipping: rootfs not found") - } - - initPath := filepath.Join("cmd", "lnx", "init") - initBin, err := os.ReadFile(initPath) - if err != nil { - t.Skipf("skipping: guest init not found") - } - lnx.InitBinary = initBin - - // Create temp dir alongside rootfs (same APFS volume). - dir, err := os.MkdirTemp(filepath.Dir(rootfsPath), "qemu-test-*") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(dir) }) - - os.Symlink(kernelPath, filepath.Join(dir, "vmlinuz")) - err = cloneFileForTest(rootfsPath, filepath.Join(dir, "rootfs.ext4")) - require.NoError(t, err) - return dir -} - -func cloneFileForTest(src, dst string) error { - return lnx.CloneFile(src, dst) -} - -func parseQemuBackendForTest() string { - // Mirror the logic in parseQemuBackend (unexported). - val := lnx.ParseQemuBackend() - return val -} - -func apiClient(dir string) *http.Client { - sockPath := dir + "/status.sock" - return &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return net.DialTimeout("unix", sockPath, 2*time.Second) - }, - }, - Timeout: 10 * time.Second, - } -} - -func waitForExec(t *testing.T, client *http.Client, timeout time.Duration) { - t.Helper() - require.Eventually(t, func() bool { - body, _ := json.Marshal(lnx.ExecRequest{Args: []string{"true"}}) - resp, err := client.Post("http://localhost/exec", "application/json", bytes.NewReader(body)) - if err != nil { - return false - } - resp.Body.Close() - return resp.StatusCode == http.StatusOK - }, timeout, 500*time.Millisecond, "exec never became ready") -} - -func execCommand(t *testing.T, client *http.Client, args ...string) string { - t.Helper() - body, err := json.Marshal(lnx.ExecRequest{Args: args}) - require.NoError(t, err) - - resp, err := client.Post("http://localhost/exec", "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() - - var output strings.Builder - dec := json.NewDecoder(resp.Body) - for { - var msg map[string]json.RawMessage - if err := dec.Decode(&msg); err != nil { - break - } - if raw, ok := msg["stdout"]; ok { - var s string - json.Unmarshal(raw, &s) - output.WriteString(s) - } - } - return strings.TrimSpace(output.String()) -} - -func readCounter(t *testing.T, client *http.Client) int { - t.Helper() - out := execCommand(t, client, "cat", "/tmp/count.txt") - val, err := strconv.Atoi(strings.TrimSpace(out)) - require.NoError(t, err, "parse counter value from %q", out) - return val -} - diff --git a/old/sessions_intg_test.go b/old/sessions_intg_test.go deleted file mode 100644 index fb499e9..0000000 --- a/old/sessions_intg_test.go +++ /dev/null @@ -1,239 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "os/exec" - "testing" - "time" - - "github.com/creack/pty" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/vito/midterm" - - "github.com/semistrict/lnx" -) - -// TestPTY_SessionsKillGraceful starts a session that exits cleanly on SIGTERM, -// kills it with `lnx sessions kill`, and verifies it exits without needing SIGKILL. -func TestPTY_SessionsKillGraceful(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - // Start a session that exits cleanly on SIGTERM. - term1 := midterm.NewTerminal(24, 80) - cmd1 := exec.Command(bin, "--ephemeral", "sh", "-c", "trap 'exit 0' TERM; echo READY; while true; do sleep 1; done") - ptmx1, err := pty.StartWithSize(cmd1, &pty.Winsize{Rows: 24, Cols: 80}) - require.NoError(t, err) - defer ptmx1.Close() - defer cmd1.Process.Kill() - - go feedTerminal(term1, ptmx1) - waitFor(t, term1, "READY", 15*time.Second) - - // Verify session appears. - require.Eventually(t, func() bool { - return len(fetchSessionsAPI(t)) > 0 - }, 10*time.Second, 500*time.Millisecond, "session never appeared in sessions list") - - sessions := fetchSessionsAPI(t) - require.NotEmpty(t, sessions) - sessID := sessions[0].ID - - // Kill the session — should exit gracefully on SIGTERM without reaching SIGKILL. - killCmd := exec.Command(bin, "sessions", "kill", "default_"+sessID) - start := time.Now() - killOut, err := killCmd.CombinedOutput() - elapsed := time.Since(start) - t.Logf("sessions kill output: %s", string(killOut)) - require.NoError(t, err) - - // Should complete well under 10s (the SIGKILL timeout). If it took >8s, SIGKILL was used. - assert.Less(t, elapsed, 8*time.Second, "kill took too long — SIGKILL was likely used instead of graceful SIGTERM exit") - - // Session should be gone. - require.Eventually(t, func() bool { - for _, s := range fetchSessionsAPI(t) { - if s.ID == sessID { - return false - } - } - return true - }, 5*time.Second, 500*time.Millisecond, "session still present after kill") - - // Client process should have exited. - done := make(chan error, 1) - go func() { done <- cmd1.Wait() }() - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("client process did not exit after session kill") - } -} - -// TestPTY_SessionsKillForce starts a session that ignores SIGTERM, -// kills it with `lnx sessions kill`, and verifies SIGKILL escalation works. -func TestPTY_SessionsKillForce(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - // Start a session that traps SIGTERM and SIGHUP but does NOT exit. - term1 := midterm.NewTerminal(24, 80) - cmd1 := exec.Command(bin, "--ephemeral", "sh", "-c", "trap '' TERM HUP; echo READY; while true; do sleep 1; done") - ptmx1, err := pty.StartWithSize(cmd1, &pty.Winsize{Rows: 24, Cols: 80}) - require.NoError(t, err) - defer ptmx1.Close() - defer cmd1.Process.Kill() - - go feedTerminal(term1, ptmx1) - waitFor(t, term1, "READY", 15*time.Second) - - require.Eventually(t, func() bool { - return len(fetchSessionsAPI(t)) > 0 - }, 10*time.Second, 500*time.Millisecond, "session never appeared") - - sessions := fetchSessionsAPI(t) - require.NotEmpty(t, sessions) - sessID := sessions[0].ID - - // Kill — SIGTERM will be ignored, must escalate to SIGKILL after 10s. - killCmd := exec.Command(bin, "sessions", "kill", "default_"+sessID) - killOut, err := killCmd.CombinedOutput() - t.Logf("sessions kill output: %s", string(killOut)) - require.NoError(t, err) - assert.Contains(t, string(killOut), "SIGKILL") - - // Session should be gone. - require.Eventually(t, func() bool { - for _, s := range fetchSessionsAPI(t) { - if s.ID == sessID { - return false - } - } - return true - }, 5*time.Second, 500*time.Millisecond, "session still present after SIGKILL") - - done := make(chan error, 1) - go func() { done <- cmd1.Wait() }() - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("client process did not exit after session kill") - } -} - -func TestPTY_SessionsKillForce_AllowsRestart(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sessions-restart-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - cmd, lines, stderr, done := startStreamingCLI(t, bin, "--instance", inst, "sh", "-c", "trap '' TERM HUP; echo READY; while true; do sleep 1; done") - waitForCLIOutput(t, lines, "READY", 20*time.Second, stderr) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - require.Eventually(t, func() bool { - return len(fetchSessionsAPIFor(t, inst)) > 0 - }, 10*time.Second, 500*time.Millisecond, "session never appeared") - - sessions := fetchSessionsAPIFor(t, inst) - require.NotEmpty(t, sessions) - sessID := sessions[0].ID - - killOut, err := exec.Command(bin, "sessions", "kill", inst+"_"+sessID).CombinedOutput() - require.NoError(t, err, "sessions kill failed: %s", killOut) - assert.Contains(t, string(killOut), "SIGKILL") - - waitForNoVMRunning(t, bin, inst, 12*time.Second) - runCLISuccess(t, bin, "--instance", inst, "true") -} - -// fetchSessionsCLI queries sessions via the HTTP API directly (not the CLI binary, -// which would need instance flag parsing). -func fetchSessionsAPI(t *testing.T) []lnx.SessionInfo { - return fetchSessionsAPIFor(t, "default") -} - -func fetchSessionsAPIFor(t *testing.T, instance string) []lnx.SessionInfo { - t.Helper() - home, _ := os.UserHomeDir() - sockPath := home + "/.lnx/instances/" + instance + "/status.sock" - - cmd := exec.Command("curl", "-s", "--unix-socket", sockPath, "http://localhost/sessions") - out, err := cmd.Output() - if err != nil { - return nil - } - out = bytes.TrimSpace(out) - if len(out) == 0 { - return nil - } - var sessions []lnx.SessionInfo - if err := json.Unmarshal(out, &sessions); err != nil { - return nil - } - return sessions -} - -// TestPTY_SessionsList verifies that sessions list shows running sessions -// and that they disappear after exit. -func TestPTY_SessionsList(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - // Start an interactive session. - term1 := midterm.NewTerminal(24, 80) - cmd1 := exec.Command(bin, "--ephemeral", "bash", "-l") - ptmx1, err := pty.StartWithSize(cmd1, &pty.Winsize{Rows: 24, Cols: 80}) - require.NoError(t, err) - defer ptmx1.Close() - defer cmd1.Process.Kill() - - go feedTerminal(term1, ptmx1) - waitFor(t, term1, "$", 15*time.Second) - - // Verify at least one session exists with expected fields. - var sessions []lnx.SessionInfo - require.Eventually(t, func() bool { - sessions = fetchSessionsAPI(t) - return len(sessions) > 0 - }, 10*time.Second, 500*time.Millisecond) - - s := sessions[0] - assert.NotEmpty(t, s.ID) - assert.Equal(t, []string{"bash", "-l"}, s.Args) - assert.True(t, s.PTY) - assert.Greater(t, s.ClientPID, 0) - assert.Greater(t, s.GuestPID, 0) - - // Exit the session. - ptmx1.Write([]byte{0x04}) - done := make(chan error, 1) - go func() { done <- cmd1.Wait() }() - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("session did not exit within 5s") - } -} diff --git a/old/share_intg_test.go b/old/share_intg_test.go deleted file mode 100644 index 21c8528..0000000 --- a/old/share_intg_test.go +++ /dev/null @@ -1,83 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/creack/pty" - "github.com/stretchr/testify/require" - "github.com/vito/midterm" - "golang.org/x/sys/unix" -) - -// TestPTY_ShareDir verifies that `lnx share add` persists a directory -// and the next boot mounts it read-write in the guest. -func TestPTY_ShareDir(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - // Create a dedicated instance with its own rootfs. - home, _ := os.UserHomeDir() - base := filepath.Join(home, ".lnx") - instName := "test-share" - imgDir := filepath.Join(base, "images", instName) - instDir := filepath.Join(base, "instances", instName) - defaultRootfs := findDefaultRootfs(base) - if defaultRootfs == "" { - t.Skipf("skipping: default instance rootfs not found") - } - - os.MkdirAll(imgDir, 0755) - os.MkdirAll(instDir, 0755) - rootfs := filepath.Join(imgDir, "rootfs.ext4") - os.Remove(rootfs) - require.NoError(t, unix.Clonefile(defaultRootfs, rootfs, 0)) - t.Cleanup(func() { - os.RemoveAll(imgDir) - os.RemoveAll(instDir) - }) - - // Create a temp directory to share. - shareDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "hello.txt"), []byte("SHARED_OK"), 0644)) - - // Add the share via the CLI. - addCmd := exec.Command(bin, "--instance", instName, "share", "add", shareDir) - out, err := addCmd.CombinedOutput() - require.NoError(t, err, "share add failed: %s", out) - - // Boot the instance and verify the share is mounted. - term := midterm.NewTerminal(24, 80) - cmd := exec.Command(bin, "--instance", instName, "sh", "-c", - "cat "+shareDir+"/hello.txt; echo WRITE_TEST > "+shareDir+"/from_guest.txt") - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 80}) - require.NoError(t, err) - defer ptmx.Close() - defer cmd.Process.Kill() - - go feedTerminal(term, ptmx) - - waitFor(t, term, "SHARED_OK", 15*time.Second) - - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - select { - case <-done: - case <-time.After(10 * time.Second): - t.Fatal("VM did not exit within 10s") - } - - // Verify the guest wrote to the shared dir (visible on host). - data, err := os.ReadFile(filepath.Join(shareDir, "from_guest.txt")) - require.NoError(t, err) - require.Contains(t, string(data), "WRITE_TEST") -} diff --git a/old/shm_intg_test.go b/old/shm_intg_test.go deleted file mode 100644 index c960bc8..0000000 --- a/old/shm_intg_test.go +++ /dev/null @@ -1,37 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCLI_SharedMemoryMountExists(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - home, _ := os.UserHomeDir() - base := filepath.Join(home, ".lnx") - if _, err := os.Stat(filepath.Join(base, "vmlinuz")); err != nil { - t.Skipf("skipping: vmlinuz not found in ~/.lnx (run 'lnx init' first)") - } - if findDefaultRootfs(base) == "" { - t.Skipf("skipping: default instance rootfs not found (run 'lnx init' first)") - } - - cmd := exec.Command(bin, "--ephemeral", "sh", "-lc", `set -e -test -d /dev/shm -mount | grep ' on /dev/shm type tmpfs ' -`) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "shared memory mount check failed: %s", out) - assert.Contains(t, string(out), "on /dev/shm type tmpfs") -} diff --git a/old/sshagent.go b/old/sshagent.go deleted file mode 100644 index 3168790..0000000 --- a/old/sshagent.go +++ /dev/null @@ -1,76 +0,0 @@ -package lnx - -import ( - "encoding/binary" - "io" - "log/slog" - "net" -) - -// startSSHAgentProxy accepts connections from the guest on the given listener -// and proxies them to the host's SSH_AUTH_SOCK. -func startSSHAgentProxy(listener net.Listener, authSock string) { - go func() { - for { - conn, err := listener.Accept() - if err != nil { - return - } - go proxySSHAgent(conn, authSock) - } - }() -} - -// countSSHKeys queries the SSH agent for the number of loaded identities. -// Uses the SSH agent protocol directly: sends SSH_AGENTC_REQUEST_IDENTITIES (11), -// reads SSH_AGENT_IDENTITIES_ANSWER (12) with the key count. -func countSSHKeys(authSock string) (int, error) { - conn, err := net.Dial("unix", authSock) - if err != nil { - return 0, err - } - defer conn.Close() - - // Request identities: length(1) + type(11) - req := []byte{0, 0, 0, 1, 11} - if _, err := conn.Write(req); err != nil { - return 0, err - } - - // Read response: 4-byte length, 1-byte type, 4-byte count - var respLen uint32 - if err := binary.Read(conn, binary.BigEndian, &respLen); err != nil { - return 0, err - } - if respLen < 5 { - return 0, nil - } - - var msgType byte - if err := binary.Read(conn, binary.BigEndian, &msgType); err != nil { - return 0, err - } - if msgType != 12 { // SSH_AGENT_IDENTITIES_ANSWER - return 0, nil - } - - var count uint32 - if err := binary.Read(conn, binary.BigEndian, &count); err != nil { - return 0, err - } - return int(count), nil -} - -func proxySSHAgent(guestConn net.Conn, authSock string) { - defer guestConn.Close() - - hostConn, err := net.Dial("unix", authSock) - if err != nil { - slog.Debug("ssh agent dial failed", "error", err) - return - } - defer hostConn.Close() - - go io.Copy(hostConn, guestConn) - io.Copy(guestConn, hostConn) -} diff --git a/old/sshagent_intg_test.go b/old/sshagent_intg_test.go deleted file mode 100644 index 87d9eb5..0000000 --- a/old/sshagent_intg_test.go +++ /dev/null @@ -1,57 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "os/exec" - "strings" - "testing" - "time" - - "github.com/creack/pty" - "github.com/stretchr/testify/require" - "github.com/vito/midterm" -) - -// TestPTY_SSHAgentForward verifies that --ssh-agent forwards the host's -// SSH agent into the guest. The guest should see SSH_AUTH_SOCK set and -// ssh-add -l should not error with "could not open a connection". -func TestPTY_SSHAgentForward(t *testing.T) { - t.Parallel() - - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - if os.Getenv("SSH_AUTH_SOCK") == "" { - t.Skip("no SSH_AUTH_SOCK on host") - } - - term := midterm.NewTerminal(24, 80) - - cmd := exec.Command(bin, "--ephemeral", "--ssh-agent", "sh", "-c", - `echo "SOCK=$SSH_AUTH_SOCK"; test -S "$SSH_AUTH_SOCK" && echo SOCKET_EXISTS || echo SOCKET_MISSING`) - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 80}) - require.NoError(t, err) - defer ptmx.Close() - defer cmd.Process.Kill() - - go feedTerminal(term, ptmx) - - // Wait for the test output. - waitFor(t, term, "SOCK=", 15*time.Second) - - screen := screenText(term) - - // SSH_AUTH_SOCK should be set. - if !strings.Contains(screen, "SOCK=/tmp/ssh-agent.sock") { - t.Fatalf("SSH_AUTH_SOCK not set in guest.\nScreen:\n%s", screen) - } - - // The socket file should exist. - if !strings.Contains(screen, "SOCKET_EXISTS") { - t.Fatalf("SSH agent socket does not exist in guest.\nScreen:\n%s", screen) - } -} diff --git a/old/status.go b/old/status.go deleted file mode 100644 index eef2391..0000000 --- a/old/status.go +++ /dev/null @@ -1,1805 +0,0 @@ -package lnx - -import ( - "bytes" - "context" - "encoding/gob" - "encoding/json" - "fmt" - "io" - "log/slog" - "net" - "net/http" - "net/http/pprof" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "sync" - "sync/atomic" - "time" - - "nhooyr.io/websocket" - - "github.com/semistrict/lnx/internal/protocol" -) - -// idleTimeout is how long the daemon waits after the last exec session -// finishes before shutting down. This allows back-to-back commands like -// `lnx echo hello && lnx echo world` to reuse the same VM. -const idleTimeout = 5 * time.Second - -// forkQemuVMFunc is set by init() in vm_qemu_darwin.go when the QEMU -// backend is compiled in. nil on non-darwin or when QEMU is not available. -var forkQemuVMFunc func(qmpSock, srcDir, dstDir string) (exited bool, err error) - -// StatusResponse is the JSON structure served to `lnx status` clients. -type StatusResponse struct { - Command []string `json:"command"` - User string `json:"user"` - UptimeSecs float64 `json:"uptime_secs"` - MemTotalKB uint64 `json:"mem_total_kb"` - MemAvailKB uint64 `json:"mem_avail_kb"` - SwapTotalKB uint64 `json:"swap_total_kb"` - SwapFreeKB uint64 `json:"swap_free_kb"` - DiskTotalKB uint64 `json:"disk_total_kb"` - DiskUsedKB uint64 `json:"disk_used_kb"` - LoadAvg string `json:"load_avg"` - Dmesg string `json:"dmesg,omitempty"` -} - -// apiServer manages the guest vsock connections and the host unix -// socket HTTP server for status queries and exec requests. -type apiServer struct { - args []string - user string - startTime time.Time - rootfsPath string - criuPath string - instanceName string - instanceDir string - statusMu sync.Mutex - statusEnc *gob.Encoder - statusDec *gob.Decoder - statusConn net.Conn - - guestCtrlConn net.Conn - - vm VirtualMachine - sock VsockDevice - pf *portForwarder - - // reverseExecCh receives connections from the guest for exec - // (used after fork/migration when host→guest Connect doesn't work). - reverseExecCh chan net.Conn - - sockPath string - listener net.Listener - - // Session tracking for daemon mode. - activeExecs atomic.Int64 - pinRefs atomic.Int64 - idleMu sync.Mutex // protects idleTimer and idleCh close - idleTimer *time.Timer // runs after last exec finishes; fires to close idleCh - idleCh chan struct{} // closed when idle timeout expires - stopCh chan struct{} // closed when /stop is called - stopOnce sync.Once - - sessionsMu sync.RWMutex - sessions map[string]*SessionInfo - sessionSeq atomic.Int64 - - forkingMu sync.Mutex - forking bool // true while VM is paused for fork snapshot - - // forkReadyCh is signalled when the guest confirms it processed - // the early ForkResp (by sending ForkNotify on the exec connection). - // Used to synchronize QEMU fork: we must not pause the VM until - // the guest has flushed its exec output. - forkReadyCh chan struct{} -} - -// SessionInfo describes an active exec session. -type SessionInfo struct { - ID string `json:"id"` - Args []string `json:"args"` - PTY bool `json:"pty"` - StartTime time.Time `json:"start_time"` - ClientPID int `json:"client_pid,omitempty"` - GuestPID int `json:"guest_pid,omitempty"` - - // Internal: gob encoder to send signals to the guest process. - // encMu serializes writes; gob.Encoder is not goroutine-safe. - encMu sync.Mutex - execEnc *gob.Encoder - - // Internal: connections to close when killing the session. - execConn net.Conn // gob exec connection (port 1027) - vsockConn net.Conn // raw PTY vsock connection (port 1032), nil for non-PTY -} - -// encodeExec sends a gob message on the session's exec connection, safely. -func (s *SessionInfo) encodeExec(msg protocol.Msg) error { - s.encMu.Lock() - defer s.encMu.Unlock() - return s.execEnc.Encode(msg) -} - -func newAPIServer(args []string, user, rootfsPath string) *apiServer { - return &apiServer{ - args: args, - user: user, - rootfsPath: rootfsPath, - startTime: time.Now(), - idleCh: make(chan struct{}), - stopCh: make(chan struct{}), - sessions: make(map[string]*SessionInfo), - } -} - -func (s *apiServer) requestStop(reason string, attrs ...any) { - // During a fork, the VM is paused and exec sessions may error out. - // Suppress stop requests until the fork completes and the VM resumes. - s.forkingMu.Lock() - forking := s.forking - s.forkingMu.Unlock() - if forking { - slog.Debug("suppressing stop request during fork", attrs...) - return - } - if reason != "" { - slog.Warn(reason, attrs...) - } - s.stopOnce.Do(func() { - close(s.stopCh) - }) -} - -// signalForkReady notifies handleGuestCtrl that the guest has processed -// the early ForkResp and flushed its exec output. Safe to call multiple -// times or when no fork is pending. -func (s *apiServer) signalForkReady() { - s.forkingMu.Lock() - ch := s.forkReadyCh - s.forkingMu.Unlock() - if ch != nil { - select { - case <-ch: - default: - close(ch) - } - } -} - -// registerSession creates a new session entry and returns its ID. -func (s *apiServer) registerSession(args []string, pty bool, clientPID int, execEnc *gob.Encoder, execConn net.Conn) string { - id := fmt.Sprintf("s%d", s.sessionSeq.Add(1)) - s.sessionsMu.Lock() - s.sessions[id] = &SessionInfo{ - ID: id, - Args: args, - PTY: pty, - StartTime: time.Now(), - ClientPID: clientPID, - execEnc: execEnc, - execConn: execConn, - } - s.sessionsMu.Unlock() - return id -} - -// setSessionVsockConn stores the PTY vsock connection for a session. -func (s *apiServer) setSessionVsockConn(id string, conn net.Conn) { - s.sessionsMu.Lock() - if sess, ok := s.sessions[id]; ok { - sess.vsockConn = conn - } - s.sessionsMu.Unlock() -} - -// setSessionGuestPID records the guest PID for a session. -func (s *apiServer) setSessionGuestPID(id string, pid int) { - s.sessionsMu.Lock() - if sess, ok := s.sessions[id]; ok { - sess.GuestPID = pid - } - s.sessionsMu.Unlock() -} - -// closeSession forcefully closes a session's connections, causing the -// WebSocket handler and guest exec to clean up. Returns false if not found. -func (s *apiServer) closeSession(id string) bool { - s.sessionsMu.RLock() - sess, ok := s.sessions[id] - s.sessionsMu.RUnlock() - if !ok { - return false - } - if sess.vsockConn != nil { - sess.vsockConn.Close() - } - if sess.execConn != nil { - sess.execConn.Close() - } - return true -} - -// signalSession sends a signal to a session's guest process. Returns false if not found. -func (s *apiServer) signalSession(id string, sig int) bool { - s.sessionsMu.RLock() - sess, ok := s.sessions[id] - s.sessionsMu.RUnlock() - if !ok || sess.execEnc == nil { - return false - } - sess.encodeExec(protocol.Msg{ExecSignal: &protocol.ExecSignal{Sig: sig}}) - return true -} - -// unregisterSession removes a session and decrements the exec counter. -// If the counter drops to zero, the idle timer is started. -func (s *apiServer) unregisterSession(id string) { - s.sessionsMu.Lock() - delete(s.sessions, id) - s.sessionsMu.Unlock() -} - -// execStarted increments the active exec counter (for non-tracked sessions like Run()). -// If the counter was zero (idle timer may be running), the timer is cancelled. -func (s *apiServer) execStarted() { - if s.activeExecs.Add(1) == 1 { - s.cancelIdleTimer() - } -} - -// execFinished decrements the active exec counter. If it drops to zero, -// the idle timer is started. -func (s *apiServer) execFinished() { - if s.activeExecs.Add(-1) == 0 { - s.startIdleTimer() - } -} - -// startIdleTimer begins the idle countdown. If the timer fires without -// being cancelled by a new exec session, idleCh is closed and the daemon -// will shut down. -func (s *apiServer) startIdleTimer() { - s.idleMu.Lock() - defer s.idleMu.Unlock() - - select { - case <-s.idleCh: - return // already shut down - default: - } - - s.idleTimer = time.AfterFunc(idleTimeout, func() { - s.idleMu.Lock() - defer s.idleMu.Unlock() - if s.activeExecs.Load() == 0 && s.pinRefs.Load() == 0 { - select { - case <-s.idleCh: - default: - slog.Info("idle timeout expired, shutting down") - close(s.idleCh) - } - } - }) -} - -// cancelIdleTimer stops a pending idle timer if one is running. -func (s *apiServer) cancelIdleTimer() { - s.idleMu.Lock() - defer s.idleMu.Unlock() - if s.idleTimer != nil { - s.idleTimer.Stop() - s.idleTimer = nil - } -} - -// WaitIdle blocks until there are no active exec sessions, or stop is requested. -func (s *apiServer) WaitIdle() { - select { - case <-s.idleCh: - case <-s.stopCh: - } -} - -func (s *apiServer) pin() { - if s.pinRefs.Add(1) == 1 { - s.cancelIdleTimer() - } -} - -func (s *apiServer) unpin() { - n := s.pinRefs.Add(-1) - if n < 0 { - s.pinRefs.Store(0) - n = 0 - } - if n == 0 && s.activeExecs.Load() == 0 { - s.startIdleTimer() - } -} - -// setStatusConn stores the guest's status vsock connection. -func (s *apiServer) setStatusConn(conn net.Conn) { - s.statusMu.Lock() - defer s.statusMu.Unlock() - s.statusConn = conn - s.statusEnc = gob.NewEncoder(conn) - s.statusDec = gob.NewDecoder(conn) -} - -// connectExec creates a new vsock connection to the guest's exec server. -// Retries briefly since the guest may still be booting. -// After fork/migration, the guest may offer reverse connections via -// reverseExecCh (guest→host direction) since host→guest Connect -// doesn't work after CPR-reboot. -func (s *apiServer) connectExec() (*gob.Encoder, *gob.Decoder, net.Conn, error) { - var conn net.Conn - var err error - for i := 0; i < 300; i++ { - // Check for a reverse exec connection from the guest first. - if s.reverseExecCh != nil { - select { - case conn = <-s.reverseExecCh: - return gob.NewEncoder(conn), gob.NewDecoder(conn), conn, nil - default: - } - } - conn, err = s.sock.Connect(protocol.ExecPort) - if err == nil { - return gob.NewEncoder(conn), gob.NewDecoder(conn), conn, nil - } - if errSuggestsDeadVM(err) { - s.requestStop("vm no longer live during exec connect", "error", err) - return nil, nil, nil, err - } - time.Sleep(200 * time.Millisecond) - } - return nil, nil, nil, err -} - -// setGuestCtrlConn stores the guest control vsock connection and starts -// handling guest-initiated requests (checkpoint, etc). -func (s *apiServer) setGuestCtrlConn(conn net.Conn) { - s.guestCtrlConn = conn - go s.handleGuestCtrl(conn) -} - -func (s *apiServer) handleGuestCtrl(conn net.Conn) { - defer conn.Close() - enc := gob.NewEncoder(conn) - dec := gob.NewDecoder(conn) - - for { - var msg protocol.Msg - if err := dec.Decode(&msg); err != nil { - return - } - - if msg.CheckpointReq != nil { - cpDir := filepath.Join(filepath.Dir(s.rootfsPath), "checkpoints") - cpPath, err := CreateCheckpoint(s.rootfsPath, cpDir, msg.CheckpointReq.Name) - resp := &protocol.CheckpointResp{} - if err != nil { - resp.Error = err.Error() - } else { - resp.Path = cpPath - } - if err := enc.Encode(protocol.Msg{CheckpointResp: resp}); err != nil { - return - } - } - - if msg.OpenURLReq != nil { - resp := &protocol.OpenURLResp{} - u := msg.OpenURLReq.URL - if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { - resp.Error = "only http:// and https:// URLs are allowed" - } else if err := exec.Command("open", u).Run(); err != nil { - resp.Error = err.Error() - } - if err := enc.Encode(protocol.Msg{OpenURLResp: resp}); err != nil { - return - } - } - - if msg.ForkReq != nil { - s.pin() - if s.isQemuFork() { - // QEMU fork: send ForkResp BEFORE pausing the VM because - // the stop command closes all vsock connections. The guest - // needs the response while the connection is still alive. - childName := s.generateForkChildName() - ready := make(chan struct{}) - s.forkingMu.Lock() - s.forkReadyCh = ready - s.forkingMu.Unlock() - if err := enc.Encode(protocol.Msg{ForkResp: &protocol.ForkResp{Instance: childName}}); err != nil { - s.unpin() - return - } - // Wait for the guest to process the response. The exec - // handler signals this channel when it receives ForkNotify, - // which means the guest output has been flushed to the host. - select { - case <-ready: - case <-time.After(5 * time.Second): - slog.Warn("fork: timed out waiting for ForkNotify") - } - s.forkingMu.Lock() - s.forkReadyCh = nil - s.forkingMu.Unlock() - s.executeQemuFork(childName) - } else { - resp := s.executeFork() - if err := enc.Encode(protocol.Msg{ForkResp: resp}); err != nil { - s.unpin() - return - } - } - s.unpin() - } - - if msg.InstanceNameReq != nil { - if err := enc.Encode(protocol.Msg{InstanceNameResp: &protocol.InstanceNameResp{Name: s.instanceName}}); err != nil { - return - } - } - } -} - -// generateForkChildName creates a unique child instance name. -func (s *apiServer) generateForkChildName() string { - childName := s.instanceName + "-fork-" + time.Now().Format("150405.000") - return strings.ReplaceAll(childName, ".", "") -} - -// isQemuFork returns true if the current VM uses QEMU (has a QMP socket). -func (s *apiServer) isQemuFork() bool { - if forkQemuVMFunc == nil { - return false - } - qmpSock := filepath.Join(s.instanceDir, "qmp.sock") - _, err := os.Stat(qmpSock) - return err == nil -} - -// executeFork handles the fork orchestration (shared between HTTP and gob paths). -func (s *apiServer) executeFork() *protocol.ForkResp { - if s.instanceName == "" || s.instanceDir == "" { - return &protocol.ForkResp{Error: "fork requires daemon mode"} - } - - childName := s.generateForkChildName() - - // QEMU fork: snapshot via CPR-reboot migration + clonefile. - if s.isQemuFork() { - return s.executeQemuFork(childName) - } - - return s.executeCRIUFork(childName) -} - -// executeQemuFork snapshots the running QEMU VM via CPR-reboot migration -// and boots a clone. The parent VM is paused during the snapshot and -// resumed after the child's files are ready. -func (s *apiServer) executeQemuFork(childName string) *protocol.ForkResp { - type qemuForker interface { - QMPSock() string - RamPath() string - Resume() error - } - qf, ok := s.vm.(qemuForker) - if !ok { - return &protocol.ForkResp{Error: "vm does not support fork"} - } - - // Child images dir (rootfs, ram, incoming.bin, vmlinuz). - srcDir := filepath.Dir(s.rootfsPath) - childImagesDir := filepath.Join(filepath.Dir(srcDir), childName) - if err := os.MkdirAll(childImagesDir, 0755); err != nil { - return &protocol.ForkResp{Error: fmt.Sprintf("create child images dir: %v", err)} - } - - // Mark fork in progress to suppress stop requests while the VM - // is paused. Exec sessions may stall and error out during the pause. - s.forkingMu.Lock() - s.forking = true - s.forkingMu.Unlock() - - // Snapshot the VM. This pauses it, saves device state, and clones - // rootfs + ram.img to childImagesDir. - _, err := forkQemuVMFunc(qf.QMPSock(), srcDir, childImagesDir) - if err != nil { - s.forkingMu.Lock() - s.forking = false - s.forkingMu.Unlock() - os.RemoveAll(childImagesDir) - return &protocol.ForkResp{Error: fmt.Sprintf("qemu fork: %v", err)} - } - - // ForkQemuVM clones "ram.img" (the base). If the active memory-backend - // file is an epoch clone (ram-EPOCH.img), overwrite with the real content. - ramPath := qf.RamPath() - baseRAM := filepath.Join(srcDir, "ram.img") - if ramPath != baseRAM { - childRAM := filepath.Join(childImagesDir, "ram.img") - os.Remove(childRAM) - if err := cloneFile(ramPath, childRAM); err != nil { - qf.Resume() - s.forkingMu.Lock() - s.forking = false - s.forkingMu.Unlock() - os.RemoveAll(childImagesDir) - return &protocol.ForkResp{Error: fmt.Sprintf("clone active ram: %v", err)} - } - } - - // Resume the parent VM now that all cloning is done. - if err := qf.Resume(); err != nil { - slog.Error("failed to resume VM after fork", "error", err) - } - s.forkingMu.Lock() - s.forking = false - s.forkingMu.Unlock() - - // Child instances dir (status.sock, metadata). - childDir := filepath.Join(filepath.Dir(s.instanceDir), childName) - if err := os.MkdirAll(childDir, 0755); err != nil { - os.RemoveAll(childImagesDir) - return &protocol.ForkResp{Error: fmt.Sprintf("create child instance dir: %v", err)} - } - copyForkMetadata(s.instanceDir, childDir) - - return s.spawnChildDaemon(childName, childDir, func() { - os.RemoveAll(childDir) - os.RemoveAll(childImagesDir) - }) -} - -// executeCRIUFork clones rootfs + CRIU volume and boots a child. -func (s *apiServer) executeCRIUFork(childName string) *protocol.ForkResp { - instancesDir := filepath.Dir(s.instanceDir) - childDir := filepath.Join(instancesDir, childName) - if err := os.MkdirAll(childDir, 0755); err != nil { - return &protocol.ForkResp{Error: fmt.Sprintf("create child dir: %v", err)} - } - - // APFS clone rootfs. - childRootfs := filepath.Join(childDir, "rootfs.ext4") - if err := cloneFile(s.rootfsPath, childRootfs); err != nil { - os.RemoveAll(childDir) - return &protocol.ForkResp{Error: fmt.Sprintf("clone rootfs: %v", err)} - } - - // APFS clone CRIU volume (contains fork dump images). - if s.criuPath != "" { - childCRIU := filepath.Join(childDir, "criu.ext4") - if err := cloneFile(s.criuPath, childCRIU); err != nil { - os.RemoveAll(childDir) - return &protocol.ForkResp{Error: fmt.Sprintf("clone criu volume: %v", err)} - } - } - - copyForkMetadata(s.instanceDir, childDir) - - return s.spawnChildDaemon(childName, childDir, func() { - os.RemoveAll(childDir) - }) -} - -// spawnChildDaemon starts a child daemon and waits for it to be ready. -// cleanup is called on failure before returning. -func (s *apiServer) spawnChildDaemon(childName, childDir string, cleanup func()) *protocol.ForkResp { - self, err := os.Executable() - if err != nil { - cleanup() - return &protocol.ForkResp{Error: fmt.Sprintf("get executable: %v", err)} - } - - childCmd := exec.Command(self, "_daemon", "--instance", childName) - childCmd.Env = os.Environ() - if err := childCmd.Start(); err != nil { - cleanup() - return &protocol.ForkResp{Error: fmt.Sprintf("start child daemon: %v", err)} - } - childCmd.Process.Release() - - // Wait for child to be ready. - childSock := filepath.Join(childDir, "status.sock") - for i := 0; i < 300; i++ { - if conn, err := net.DialTimeout("unix", childSock, 200*time.Millisecond); err == nil { - conn.Close() - break - } - time.Sleep(200 * time.Millisecond) - } - - return &protocol.ForkResp{Instance: childName, Role: "parent"} -} - -// queryGuest sends a StatusReq and reads the StatusResp. -func (s *apiServer) queryGuest(includeDmesg bool) (*protocol.StatusResp, error) { - s.statusMu.Lock() - defer s.statusMu.Unlock() - - if s.statusEnc == nil { - return nil, fmt.Errorf("guest not connected") - } - - if err := s.statusEnc.Encode(protocol.Msg{StatusReq: &protocol.StatusReq{ - IncludeDmesg: includeDmesg, - }}); err != nil { - return nil, fmt.Errorf("encode status req: %w", err) - } - - var msg protocol.Msg - if err := s.statusDec.Decode(&msg); err != nil { - return nil, fmt.Errorf("decode status resp: %w", err) - } - if msg.StatusResp == nil { - return nil, fmt.Errorf("expected StatusResp, got %+v", msg) - } - return msg.StatusResp, nil -} - -// listenUnix starts the HTTP server on a unix socket. -func (s *apiServer) listenUnix(sockPath string) error { - os.Remove(sockPath) - ln, err := net.Listen("unix", sockPath) - if err != nil { - return fmt.Errorf("listen unix %s: %w", sockPath, err) - } - // Make the socket world-accessible so non-root clients can connect - // (the daemon may run as root while clients run as the regular user). - os.Chmod(sockPath, 0666) - s.sockPath = sockPath - s.listener = ln - - mux := http.NewServeMux() - mux.HandleFunc("GET /status", s.handleStatus) - mux.HandleFunc("GET /ports", s.handlePorts) - mux.HandleFunc("POST /checkpoint", s.handleCheckpoint) - mux.HandleFunc("POST /criu/checkpoint", s.handleCRIUCheckpoint) - mux.HandleFunc("POST /fork", s.handleFork) - mux.HandleFunc("POST /expose/host", s.handleExposeHost) - mux.HandleFunc("POST /expose/host/remove", s.handleRemoveExposeHost) - mux.HandleFunc("POST /guest/expose", s.handleGuestExpose) - mux.HandleFunc("POST /exec", s.handleExec) - mux.HandleFunc("GET /exec/ws", s.handleExecWS) - mux.HandleFunc("GET /fork/ws", s.handleForkAttachWS) - mux.HandleFunc("GET /sessions", s.handleSessions) - mux.HandleFunc("POST /sessions/kill", s.handleSessionKill) - mux.HandleFunc("POST /stop", s.handleStop) - mux.HandleFunc("GET /ssh", s.handleSSHProxy) - mux.HandleFunc("/guest/debug/pprof/", s.handleGuestPprofProxy) - mux.HandleFunc("GET /debug/pprof/", pprof.Index) - mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline) - mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile) - mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol) - mux.HandleFunc("POST /debug/pprof/symbol", pprof.Symbol) - mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace) - for _, name := range []string{ - "allocs", - "block", - "goroutine", - "heap", - "mutex", - "threadcreate", - } { - mux.Handle("GET /debug/pprof/"+name, pprof.Handler(name)) - } - - go http.Serve(ln, mux) - return nil -} - -func (s *apiServer) handleGuestPprofProxy(w http.ResponseWriter, r *http.Request) { - if s.sock == nil { - http.Error(w, "guest vsock unavailable", http.StatusServiceUnavailable) - return - } - targetURL := "http://guest" + strings.TrimPrefix(r.URL.RequestURI(), "/guest") - req, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body) - if err != nil { - http.Error(w, fmt.Sprintf("build proxy request: %v", err), http.StatusInternalServerError) - return - } - req.Header = r.Header.Clone() - - client := &http.Client{ - Transport: &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return s.sock.Connect(protocol.GuestHTTPPort) - }, - }, - } - - resp, err := client.Do(req) - if err != nil { - http.Error(w, fmt.Sprintf("guest pprof proxy: %v", err), http.StatusBadGateway) - return - } - defer resp.Body.Close() - - for k, vals := range resp.Header { - for _, v := range vals { - w.Header().Add(k, v) - } - } - w.WriteHeader(resp.StatusCode) - _, _ = io.Copy(w, resp.Body) -} - -func (s *apiServer) handleStatus(w http.ResponseWriter, r *http.Request) { - includeDmesg := r.URL.Query().Get("dmesg") == "1" - - resp := StatusResponse{ - Command: s.args, - User: s.user, - } - - guestResp, err := s.queryGuest(includeDmesg) - if err != nil { - slog.Debug("status guest query failed", "error", err) - resp.UptimeSecs = time.Since(s.startTime).Seconds() - } else { - resp.UptimeSecs = guestResp.UptimeSecs - resp.MemTotalKB = guestResp.MemTotalKB - resp.MemAvailKB = guestResp.MemAvailKB - resp.SwapTotalKB = guestResp.SwapTotalKB - resp.SwapFreeKB = guestResp.SwapFreeKB - resp.DiskTotalKB = guestResp.DiskTotalKB - resp.DiskUsedKB = guestResp.DiskUsedKB - resp.LoadAvg = guestResp.LoadAvg - resp.Dmesg = guestResp.Dmesg - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// PortEntry describes a single forwarded port. -type PortEntry struct { - Guest uint16 `json:"guest"` - Host uint16 `json:"host"` -} - -type ExposeHostRequest struct { - GuestPort uint16 `json:"guest_port"` - HostPort uint16 `json:"host_port,omitempty"` - Visible bool `json:"visible,omitempty"` -} - -type ExposeHostResponse struct { - HostPort uint16 `json:"host_port"` - Created bool `json:"created"` -} - -type RemoveExposeHostRequest struct { - HostPort uint16 `json:"host_port"` -} - -type GuestExposeRequest struct { - ListenPort uint16 `json:"listen_port"` - Host string `json:"host"` - HostPort uint16 `json:"host_port"` -} - -type GuestExposeResponse struct { - Created bool `json:"created"` -} - -func (s *apiServer) handlePorts(w http.ResponseWriter, r *http.Request) { - var ports []PortEntry - if s.pf != nil { - ports = s.pf.listVisiblePorts() - } - if ports == nil { - ports = []PortEntry{} - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ports) -} - -func (s *apiServer) handleExposeHost(w http.ResponseWriter, r *http.Request) { - if s.pf == nil { - http.Error(w, "port forwarding unavailable", http.StatusServiceUnavailable) - return - } - - var req ExposeHostRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - if req.GuestPort == 0 { - http.Error(w, "guest_port required", http.StatusBadRequest) - return - } - - hostPort, created, err := s.pf.exposeHost(req.GuestPort, req.HostPort, req.Visible) - if err != nil { - http.Error(w, err.Error(), http.StatusConflict) - return - } - if created { - s.pin() - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ExposeHostResponse{HostPort: hostPort, Created: created}) -} - -func (s *apiServer) handleRemoveExposeHost(w http.ResponseWriter, r *http.Request) { - if s.pf == nil { - http.Error(w, "port forwarding unavailable", http.StatusServiceUnavailable) - return - } - - var req RemoveExposeHostRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - if req.HostPort == 0 { - http.Error(w, "host_port required", http.StatusBadRequest) - return - } - if !s.pf.removeHost(req.HostPort) { - http.Error(w, fmt.Sprintf("no manual host forward for port %d", req.HostPort), http.StatusNotFound) - return - } - s.unpin() - w.WriteHeader(http.StatusNoContent) -} - -func (s *apiServer) handleGuestExpose(w http.ResponseWriter, r *http.Request) { - if s.sock == nil { - http.Error(w, "guest vsock unavailable", http.StatusServiceUnavailable) - return - } - - var req GuestExposeRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - if req.ListenPort == 0 || req.HostPort == 0 || req.Host == "" { - http.Error(w, "listen_port, host, and host_port are required", http.StatusBadRequest) - return - } - - var resp GuestExposeResponse - if err := s.proxyGuestJSON(r.Context(), "/tcp/expose", req, &resp); err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return - } - if resp.Created { - s.pin() - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// handleCRIUCheckpoint orchestrates a CRIU checkpoint: -// 1. Guest dumps processes to the CRIU block device (/mnt/criu//) -// 2. Host APFS-clones both rootfs.ext4 and criu.ext4 -// 3. Guest cleans up dump images from the live CRIU volume -// -// The checkpoint directory contains rootfs.ext4 + criu.ext4, both instant -// copy-on-write clones. -func (s *apiServer) handleCRIUCheckpoint(w http.ResponseWriter, r *http.Request) { - if s.sock == nil { - http.Error(w, "guest vsock unavailable", http.StatusServiceUnavailable) - return - } - if s.criuPath == "" { - http.Error(w, "CRIU volume not configured", http.StatusBadRequest) - return - } - - var req struct { - Name string `json:"name"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - if req.Name == "" { - http.Error(w, "name required", http.StatusBadRequest) - return - } - - // Step 1: Guest dumps processes to CRIU volume. - var dumpResp struct { - Status string `json:"status"` - } - if err := s.proxyGuestJSON(r.Context(), "/criu/dump", req, &dumpResp); err != nil { - http.Error(w, fmt.Sprintf("guest CRIU dump: %v", err), http.StatusBadGateway) - return - } - - // Step 2: APFS-clone rootfs + CRIU volume into checkpoint dir. - cpDir := filepath.Join(filepath.Dir(s.rootfsPath), "checkpoints", req.Name) - cpPath, err := CreateCRIUCheckpoint(s.rootfsPath, s.criuPath, cpDir) - if err != nil { - http.Error(w, fmt.Sprintf("clone: %v", err), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"path": cpPath}) -} - -func (s *apiServer) handleFork(w http.ResponseWriter, r *http.Request) { - if s.sock == nil { - http.Error(w, "guest vsock unavailable", http.StatusServiceUnavailable) - return - } - if s.instanceName == "" || s.instanceDir == "" { - http.Error(w, "fork requires daemon mode", http.StatusBadRequest) - return - } - - // Pin the daemon so idle timeout doesn't fire during fork. - // QEMU fork pauses the VM (killing exec sessions), which would - // otherwise trigger idle shutdown. - s.pin() - defer s.unpin() - - // For CRIU: tell guest to dump processes first. - // For QEMU: skip — CPR-reboot migration captures the entire VM. - qmpSock := filepath.Join(s.instanceDir, "qmp.sock") - isQemu := false - if _, err := os.Stat(qmpSock); err == nil { - isQemu = true - } - if !isQemu { - var dumpResp struct { - Status string `json:"status"` - } - if err := s.proxyGuestJSON(r.Context(), "/criu/fork-dump", nil, &dumpResp); err != nil { - http.Error(w, fmt.Sprintf("guest fork dump: %v", err), http.StatusBadGateway) - return - } - } - - resp := s.executeFork() - if resp.Error != "" { - http.Error(w, resp.Error, http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"child_instance": resp.Instance}) -} - -// copyForkMetadata copies instance metadata files relevant to a fork. -// Skips mac.addr, swap.img, hibernated, and other transient state. -func copyForkMetadata(srcDir, dstDir string) { - // Only copy shares.json if it exists. - sharesPath := filepath.Join(srcDir, "shares.json") - if data, err := os.ReadFile(sharesPath); err == nil { - os.WriteFile(filepath.Join(dstDir, "shares.json"), data, 0644) - } -} - -func (s *apiServer) handleCheckpoint(w http.ResponseWriter, r *http.Request) { - var req struct { - Name string `json:"name"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - - if s.sock != nil { - var resp struct { - Path string `json:"path"` - } - if err := s.proxyGuestJSON(r.Context(), "/checkpoint", req, &resp); err != nil { - // Guest sync failed — fall through to direct clone without sync. - slog.Warn("checkpoint: guest sync failed, cloning without sync", "error", err) - } else { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - return - } - } - - cpDir := filepath.Join(filepath.Dir(s.rootfsPath), "checkpoints") - cpPath, err := CreateCheckpoint(s.rootfsPath, cpDir, req.Name) - if err != nil { - http.Error(w, err.Error(), http.StatusConflict) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"path": cpPath}) -} - -func (s *apiServer) proxyGuestJSON(ctx context.Context, path string, body any, respBody any) error { - payload, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("marshal guest request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://guest"+path, strings.NewReader(string(payload))) - if err != nil { - return fmt.Errorf("build guest request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{ - Transport: &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return s.sock.Connect(protocol.GuestHTTPPort) - }, - }, - } - - resp, err := client.Do(req) - if err != nil { - if errSuggestsDeadVM(err) { - s.requestStop("vm no longer live during guest request", "path", path, "error", err) - } - return fmt.Errorf("guest request failed: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - data, _ := io.ReadAll(resp.Body) - msg := strings.TrimSpace(string(data)) - if msg == "" { - msg = resp.Status - } - return fmt.Errorf("guest request failed: %s", msg) - } - if respBody != nil { - if err := json.NewDecoder(resp.Body).Decode(respBody); err != nil && err != io.EOF { - return fmt.Errorf("decode guest response: %w", err) - } - } - return nil -} - -func errSuggestsDeadVM(err error) bool { - if err == nil { - return false - } - s := err.Error() - return strings.Contains(s, "Invalid virtual machine state") || - strings.Contains(s, "no longer live") -} - -// ExecRequest is the JSON body for POST /exec and the first WebSocket text frame. -type ExecRequest struct { - Args []string `json:"args"` - Env []string `json:"env,omitempty"` - PTY bool `json:"pty,omitempty"` - Rows uint16 `json:"rows,omitempty"` - Cols uint16 `json:"cols,omitempty"` - ClientPID int `json:"client_pid,omitempty"` -} - -func (s *apiServer) handleExec(w http.ResponseWriter, r *http.Request) { - var req ExecRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) - return - } - if len(req.Args) == 0 { - http.Error(w, "args required", http.StatusBadRequest) - return - } - - s.execStarted() - defer s.execFinished() - - execEnc, execDec, execConn, err := s.connectExec() - if err != nil { - http.Error(w, "guest exec connect: "+err.Error(), http.StatusServiceUnavailable) - return - } - defer execConn.Close() - - sessID := s.registerSession(req.Args, req.PTY, req.ClientPID, execEnc, execConn) - defer s.unregisterSession(sessID) - - if err := execEnc.Encode(protocol.Msg{ExecReq: &protocol.ExecReq{ - Args: req.Args, - Env: req.Env, - PTY: req.PTY, - Rows: req.Rows, - Cols: req.Cols, - }}); err != nil { - http.Error(w, "send exec request: "+err.Error(), http.StatusInternalServerError) - return - } - - if req.PTY { - http.Error(w, "use GET /exec/ws for interactive exec", http.StatusBadRequest) - return - } - s.handleExecStream(w, execDec, sessID) -} - -// handleExecStream handles non-interactive exec via NDJSON streaming. -func (s *apiServer) handleExecStream(w http.ResponseWriter, execDec *gob.Decoder, sessID string) { - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming not supported", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/x-ndjson") - w.WriteHeader(http.StatusOK) - flusher.Flush() - - enc := json.NewEncoder(w) - for { - var msg protocol.Msg - if err := execDec.Decode(&msg); err != nil { - // During a QEMU fork the stop command closes all vsock - // connections. The exec output was already flushed before - // the pause so report success instead of an error. - exitCode := -1 - s.forkingMu.Lock() - if s.forking { - exitCode = 0 - } - s.forkingMu.Unlock() - if exitCode < 0 { - slog.Warn("exec decode failed", "error", err, "session", sessID) - } - enc.Encode(map[string]int{"exit_code": exitCode}) - flusher.Flush() - return - } - - if msg.ExecStarted != nil { - s.setSessionGuestPID(sessID, msg.ExecStarted.PID) - } - - if msg.ExecOutput != nil { - if len(msg.ExecOutput.Stdout) > 0 { - enc.Encode(map[string]string{"stdout": string(msg.ExecOutput.Stdout)}) - flusher.Flush() - } - if len(msg.ExecOutput.Stderr) > 0 { - enc.Encode(map[string]string{"stderr": string(msg.ExecOutput.Stderr)}) - flusher.Flush() - } - } - - if msg.ForkNotify != nil { - enc.Encode(map[string]any{ - "fork": map[string]string{"instance": msg.ForkNotify.Instance}, - }) - flusher.Flush() - s.signalForkReady() - } - - if msg.ExecDone != nil { - enc.Encode(map[string]int{"exit_code": msg.ExecDone.ExitCode}) - flusher.Flush() - return - } - } -} - -// handleExecWS handles interactive exec over WebSocket. -// Binary frames carry raw PTY data. Text frames carry JSON control messages: -// -// Client → Server: {"signal": N} or {"resize": {"rows": R, "cols": C}} -// Server → Client: {"exit_code": N} -func (s *apiServer) handleExecWS(w http.ResponseWriter, r *http.Request) { - ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - // Allow any origin since this is a local unix socket. - InsecureSkipVerify: true, - }) - if err != nil { - slog.Debug("websocket accept failed", "error", err) - return - } - defer ws.CloseNow() - ws.SetReadLimit(-1) // no limit on PTY data - - ctx := r.Context() - - // Read the first text message as the ExecRequest. - _, data, err := ws.Read(ctx) - if err != nil { - slog.Debug("websocket read exec request failed", "error", err) - return - } - var req ExecRequest - if err := json.Unmarshal(data, &req); err != nil { - ws.Close(websocket.StatusInvalidFramePayloadData, "bad exec request: "+err.Error()) - return - } - if len(req.Args) == 0 { - ws.Close(websocket.StatusInvalidFramePayloadData, "args required") - return - } - - s.execStarted() - defer s.execFinished() - - execEnc, execDec, execConn, err := s.connectExec() - if err != nil { - ws.Close(websocket.StatusInternalError, "guest exec connect: "+err.Error()) - return - } - defer execConn.Close() - - sessID := s.registerSession(req.Args, true, req.ClientPID, execEnc, execConn) - defer s.unregisterSession(sessID) - - // Look up the session for thread-safe encoder access. - s.sessionsMu.RLock() - sess := s.sessions[sessID] - s.sessionsMu.RUnlock() - - if err := sess.encodeExec(protocol.Msg{ExecReq: &protocol.ExecReq{ - Args: req.Args, - Env: req.Env, - PTY: true, - Rows: req.Rows, - Cols: req.Cols, - }}); err != nil { - ws.Close(websocket.StatusInternalError, "send exec request: "+err.Error()) - return - } - - // Connect to guest PTY via vsock. - var vsockConn net.Conn - for i := 0; i < 300; i++ { - vsockConn, err = s.sock.Connect(protocol.ExecInteractivePort) - if err == nil { - break - } - time.Sleep(200 * time.Millisecond) - } - if vsockConn == nil { - ws.Close(websocket.StatusInternalError, "exec interactive connect failed") - return - } - defer vsockConn.Close() - s.setSessionVsockConn(sessID, vsockConn) - - // Read text frames (signals/resize) from client and forward to guest gob connection. - // Read binary frames (stdin) from client and write to guest PTY vsock. - go func() { - for { - typ, data, err := ws.Read(ctx) - if err != nil { - // Client disconnected — close both the PTY vsock and the - // exec gob connection so the guest cleans up and - // execDec.Decode() unblocks. - vsockConn.Close() - execConn.Close() - return - } - switch typ { - case websocket.MessageBinary: - vsockConn.Write(data) - case websocket.MessageText: - var ctrl wsControl - if err := json.Unmarshal(data, &ctrl); err != nil { - continue - } - if ctrl.Signal != nil { - sess.encodeExec(protocol.Msg{ExecSignal: &protocol.ExecSignal{Sig: *ctrl.Signal}}) - } - if ctrl.Resize != nil { - sess.encodeExec(protocol.Msg{ExecResize: &protocol.ExecResize{ - Rows: ctrl.Resize.Rows, - Cols: ctrl.Resize.Cols, - }}) - } - } - } - }() - - // Read guest PTY output and send as binary frames. - go func() { - buf := make([]byte, 32*1024) - for { - n, err := vsockConn.Read(buf) - if n > 0 { - if werr := ws.Write(ctx, websocket.MessageBinary, buf[:n]); werr != nil { - return - } - } - if err != nil { - return - } - } - }() - - // Wait for ExecStarted then ExecDone from guest. - exitCode := -1 - for { - var msg protocol.Msg - if err := execDec.Decode(&msg); err != nil { - break - } - if msg.ExecStarted != nil { - s.setSessionGuestPID(sessID, msg.ExecStarted.PID) - } - if msg.ExecOutput != nil { - if len(msg.ExecOutput.Stdout) > 0 { - if err := ws.Write(ctx, websocket.MessageBinary, terminalizeNewlines(msg.ExecOutput.Stdout)); err != nil { - break - } - } - if len(msg.ExecOutput.Stderr) > 0 { - if err := ws.Write(ctx, websocket.MessageBinary, terminalizeNewlines(msg.ExecOutput.Stderr)); err != nil { - break - } - } - } - if msg.ForkNotify != nil { - forkMsg, _ := json.Marshal(map[string]any{ - "fork": map[string]string{"instance": msg.ForkNotify.Instance}, - }) - ws.Write(ctx, websocket.MessageText, forkMsg) - s.signalForkReady() - } - if msg.ExecDone != nil { - exitCode = msg.ExecDone.ExitCode - break - } - } - - // Send exit code to client as text frame. - exitMsg, _ := json.Marshal(map[string]int{"exit_code": exitCode}) - ws.Write(ctx, websocket.MessageText, exitMsg) - ws.Close(websocket.StatusNormalClosure, "") -} - -// handleForkAttachWS connects to a CRIU-restored fork session's PTY in the -// guest and bridges it to the client WebSocket. The protocol is identical -// to handleExecWS (binary = PTY data, text = signals/resize/exit_code) -// except the guest side is a fork attach server rather than an exec server. -func (s *apiServer) handleForkAttachWS(w http.ResponseWriter, r *http.Request) { - ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - InsecureSkipVerify: true, - }) - if err != nil { - slog.Debug("fork attach websocket accept failed", "error", err) - return - } - defer ws.CloseNow() - ws.SetReadLimit(-1) - - ctx := r.Context() - - // Read the first text message for PTY dimensions. - _, data, err := ws.Read(ctx) - if err != nil { - slog.Debug("fork attach websocket read request failed", "error", err) - return - } - var req ExecRequest - if err := json.Unmarshal(data, &req); err != nil { - ws.Close(websocket.StatusInvalidFramePayloadData, "bad request: "+err.Error()) - return - } - - s.execStarted() - defer s.execFinished() - - // Connect to the guest's fork attach gob port. - var gobConn net.Conn - for i := 0; i < 300; i++ { - gobConn, err = s.sock.Connect(protocol.ForkAttachPort) - if err == nil { - break - } - if errSuggestsDeadVM(err) { - ws.Close(websocket.StatusInternalError, "vm not live") - return - } - time.Sleep(200 * time.Millisecond) - } - if gobConn == nil { - ws.Close(websocket.StatusInternalError, "fork attach connect failed") - return - } - defer gobConn.Close() - - gobEnc := gob.NewEncoder(gobConn) - gobDec := gob.NewDecoder(gobConn) - - sessID := s.registerSession([]string{"[fork]"}, true, req.ClientPID, gobEnc, gobConn) - defer s.unregisterSession(sessID) - - // Send ExecReq with PTY dimensions so the guest can resize. - if err := gobEnc.Encode(protocol.Msg{ExecReq: &protocol.ExecReq{ - PTY: true, - Rows: req.Rows, - Cols: req.Cols, - }}); err != nil { - ws.Close(websocket.StatusInternalError, "send fork request: "+err.Error()) - return - } - - // Connect to the guest's fork attach data port. - var dataConn net.Conn - for i := 0; i < 300; i++ { - dataConn, err = s.sock.Connect(protocol.ForkAttachDataPort) - if err == nil { - break - } - time.Sleep(200 * time.Millisecond) - } - if dataConn == nil { - ws.Close(websocket.StatusInternalError, "fork attach data connect failed") - return - } - defer dataConn.Close() - s.setSessionVsockConn(sessID, dataConn) - - // Look up session for thread-safe encoder access. - s.sessionsMu.RLock() - sess := s.sessions[sessID] - s.sessionsMu.RUnlock() - - // Client → guest: text = signals/resize, binary = stdin. - go func() { - for { - typ, data, err := ws.Read(ctx) - if err != nil { - dataConn.Close() - gobConn.Close() - return - } - switch typ { - case websocket.MessageBinary: - dataConn.Write(data) - case websocket.MessageText: - var ctrl wsControl - if err := json.Unmarshal(data, &ctrl); err != nil { - continue - } - if ctrl.Signal != nil { - sess.encodeExec(protocol.Msg{ExecSignal: &protocol.ExecSignal{Sig: *ctrl.Signal}}) - } - if ctrl.Resize != nil { - sess.encodeExec(protocol.Msg{ExecResize: &protocol.ExecResize{ - Rows: ctrl.Resize.Rows, - Cols: ctrl.Resize.Cols, - }}) - } - } - } - }() - - // Guest PTY → client. - go func() { - buf := make([]byte, 32*1024) - for { - n, err := dataConn.Read(buf) - if n > 0 { - if werr := ws.Write(ctx, websocket.MessageBinary, buf[:n]); werr != nil { - return - } - } - if err != nil { - return - } - } - }() - - // Wait for ExecStarted/ExecDone from guest gob connection. - exitCode := -1 - for { - var msg protocol.Msg - if err := gobDec.Decode(&msg); err != nil { - break - } - if msg.ExecStarted != nil { - s.setSessionGuestPID(sessID, msg.ExecStarted.PID) - } - if msg.ForkNotify != nil { - forkMsg, _ := json.Marshal(map[string]any{ - "fork": map[string]string{"instance": msg.ForkNotify.Instance}, - }) - ws.Write(ctx, websocket.MessageText, forkMsg) - s.signalForkReady() - } - if msg.ExecDone != nil { - exitCode = msg.ExecDone.ExitCode - break - } - } - - exitMsg, _ := json.Marshal(map[string]int{"exit_code": exitCode}) - ws.Write(ctx, websocket.MessageText, exitMsg) - ws.Close(websocket.StatusNormalClosure, "") -} - -// wsControl is the JSON structure for WebSocket text frames from client. -type wsControl struct { - Signal *int `json:"signal,omitempty"` - Resize *wsResize `json:"resize,omitempty"` -} - -type wsResize struct { - Rows uint16 `json:"rows"` - Cols uint16 `json:"cols"` -} - -func terminalizeNewlines(data []byte) []byte { - if !bytes.Contains(data, []byte{'\n'}) { - return data - } - var out []byte - out = make([]byte, 0, len(data)+8) - for i, b := range data { - if b == '\n' && (i == 0 || data[i-1] != '\r') { - out = append(out, '\r', '\n') - continue - } - out = append(out, b) - } - return out -} - -// handleSessions returns all active exec sessions as JSON, sorted by start time. -func (s *apiServer) handleSessions(w http.ResponseWriter, r *http.Request) { - s.sessionsMu.RLock() - sessions := make([]*SessionInfo, 0, len(s.sessions)) - for _, sess := range s.sessions { - sessions = append(sessions, sess) - } - s.sessionsMu.RUnlock() - - sort.Slice(sessions, func(i, j int) bool { - return sessions[i].StartTime.Before(sessions[j].StartTime) - }) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(sessions) -} - -// SessionKillRequest is the JSON body for POST /sessions/kill. -type SessionKillRequest struct { - ID string `json:"id"` - Signal int `json:"signal"` - Close bool `json:"close,omitempty"` // also close connections to force teardown -} - -// handleSessionKill sends a signal to a session's guest process. -// If Close is true, also forcefully closes the session's connections. -func (s *apiServer) handleSessionKill(w http.ResponseWriter, r *http.Request) { - var req SessionKillRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) - return - } - if req.ID == "" { - http.Error(w, "id required", http.StatusBadRequest) - return - } - if req.Signal == 0 { - req.Signal = 15 // SIGTERM - } - - if !s.signalSession(req.ID, req.Signal) { - http.Error(w, "session not found: "+req.ID, http.StatusNotFound) - return - } - - if req.Close { - s.closeSession(req.ID) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "signal sent"}) -} - -// handleStop shuts down the VM daemon. -func (s *apiServer) handleStop(w http.ResponseWriter, r *http.Request) { - s.requestStop("stop requested by client") - w.WriteHeader(http.StatusOK) - fmt.Fprintln(w, "stopping") -} - -// handleSSHProxy hijacks the HTTP connection and splices it with a vsock -// connection to the guest's embedded SSH server. This gives the CLI a raw -// byte stream suitable for SSH's ProxyCommand. -func (s *apiServer) handleSSHProxy(w http.ResponseWriter, r *http.Request) { - if s.sock == nil { - http.Error(w, "vsock unavailable", http.StatusServiceUnavailable) - return - } - - vsockConn, err := s.sock.Connect(protocol.SSHPort) - if err != nil { - http.Error(w, "guest ssh connect failed: "+err.Error(), http.StatusBadGateway) - return - } - - hj, ok := w.(http.Hijacker) - if !ok { - vsockConn.Close() - http.Error(w, "hijack not supported", http.StatusInternalServerError) - return - } - conn, bufrw, err := hj.Hijack() - if err != nil { - vsockConn.Close() - http.Error(w, "hijack failed: "+err.Error(), http.StatusInternalServerError) - return - } - - bufrw.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n") - bufrw.Flush() - - // Drain any buffered data from the hijacked reader before splicing. - if bufrw.Reader.Buffered() > 0 { - io.CopyN(vsockConn, bufrw.Reader, int64(bufrw.Reader.Buffered())) - } - - done := make(chan struct{}) - go func() { - io.Copy(vsockConn, conn) - vsockConn.Close() - close(done) - }() - io.Copy(conn, vsockConn) - conn.Close() - <-done -} - -// runExec runs a command on the guest. Creates a new vsock exec connection -// per call so multiple execs can run concurrently. -// For interactive (PTY) mode, it splices os.Stdin/os.Stdout with the guest PTY. -// forceQuitCh is passed to spliceInteractive for raw-mode double-Ctrl-C detection. -// Returns the exit code. -func (s *apiServer) runExec(req *protocol.ExecReq, interactive bool, forceQuitCh chan struct{}) int { - execEnc, execDec, execConn, err := s.connectExec() - if err != nil { - slog.Info("exec connect failed", "error", err) - return -1 - } - defer execConn.Close() - - s.execStarted() - defer s.execFinished() - - if err := execEnc.Encode(protocol.Msg{ExecReq: req}); err != nil { - slog.Debug("exec encode failed", "error", err) - return -1 - } - - if interactive { - // Wrap os.Stdin/os.Stdout as a ReadWriter for spliceInteractive. - return s.spliceInteractive(readWriter{os.Stdin, os.Stdout}, execDec, forceQuitCh) - } - return readExecOutput(execDec, os.Stdout, os.Stderr) -} - -// readWriter pairs a reader and writer into an io.ReadWriter. -type readWriter struct { - io.Reader - io.Writer -} - -// readExecOutput reads ExecOutput/ExecDone from the gob connection, -// writing stdout/stderr to the provided writers. -func readExecOutput(execDec *gob.Decoder, stdout, stderr io.Writer) int { - for { - var msg protocol.Msg - if err := execDec.Decode(&msg); err != nil { - return -1 - } - if msg.ExecOutput != nil { - if len(msg.ExecOutput.Stdout) > 0 { - stdout.Write(msg.ExecOutput.Stdout) - } - if len(msg.ExecOutput.Stderr) > 0 { - stderr.Write(msg.ExecOutput.Stderr) - } - } - if msg.ExecDone != nil { - return msg.ExecDone.ExitCode - } - } -} - -// spliceInteractive connects to the guest PTY via vsock and splices -// raw bytes between rw (stdin/stdout or hijacked HTTP conn) and the PTY. -// Used by both the main command and `lnx exec -i`. -// -// forceQuitCh, if non-nil, is closed when a double Ctrl-C is detected in the -// raw byte stream (needed because term.MakeRaw disables ISIG). -func (s *apiServer) spliceInteractive(rw io.ReadWriter, execDec *gob.Decoder, forceQuitCh chan struct{}) int { - var vsockConn net.Conn - for i := 0; i < 300; i++ { - var err error - vsockConn, err = s.sock.Connect(protocol.ExecInteractivePort) - if err == nil { - break - } - time.Sleep(200 * time.Millisecond) - } - if vsockConn == nil { - slog.Info("exec interactive connect failed") - return -1 - } - defer vsockConn.Close() - - reader := io.Reader(rw) - if forceQuitCh != nil { - reader = &ctrlCReader{r: rw, conn: vsockConn, forceQuitCh: forceQuitCh} - } - go io.Copy(vsockConn, reader) - io.Copy(rw, vsockConn) - - // If force quit was triggered, don't wait for the exec done message — - // the guest process may still be alive (e.g. trapping signals). - if forceQuitCh != nil { - select { - case <-forceQuitCh: - return -1 - default: - } - } - - var msg protocol.Msg - if err := execDec.Decode(&msg); err == nil && msg.ExecDone != nil { - return msg.ExecDone.ExitCode - } - return -1 -} - -// ctrlCReader wraps a reader and detects double Ctrl-C (0x03) in raw mode. -// When detected, it closes the vsock connection to force-quit the VM. -// Individual Ctrl-C bytes are still forwarded to the guest. -type ctrlCReader struct { - r io.Reader - conn net.Conn - forceQuitCh chan struct{} - lastCtrlC time.Time -} - -func (c *ctrlCReader) Read(p []byte) (int, error) { - n, err := c.r.Read(p) - for i := 0; i < n; i++ { - if p[i] == 0x03 { // Ctrl-C - now := time.Now() - if !c.lastCtrlC.IsZero() && now.Sub(c.lastCtrlC) < time.Second { - fmt.Fprintln(os.Stderr, "\r\nforce quit") - close(c.forceQuitCh) - c.conn.Close() - return 0, io.EOF - } - c.lastCtrlC = now - } - } - return n, err -} - -func (s *apiServer) close() { - if s.listener != nil { - s.listener.Close() - } - if s.sockPath != "" { - os.Remove(s.sockPath) - } - s.statusMu.Lock() - if s.statusConn != nil { - s.statusConn.Close() - } - s.statusMu.Unlock() - if s.guestCtrlConn != nil { - s.guestCtrlConn.Close() - } -} diff --git a/old/status_deadvm_test.go b/old/status_deadvm_test.go deleted file mode 100644 index be5e631..0000000 --- a/old/status_deadvm_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package lnx - -import ( - "errors" - "testing" -) - -func TestErrSuggestsDeadVM(t *testing.T) { - tests := []struct { - err error - want bool - }{ - {err: nil, want: false}, - {err: errors.New(`Error Domain=VZErrorDomain Code=3 Description="Invalid virtual machine state. The virtual machine is no longer live."`), want: true}, - {err: errors.New("guest request failed: connection reset by peer"), want: false}, - } - - for _, tt := range tests { - if got := errSuggestsDeadVM(tt.err); got != tt.want { - t.Fatalf("errSuggestsDeadVM(%v) = %v, want %v", tt.err, got, tt.want) - } - } -} - -func TestRequestStopIsIdempotent(t *testing.T) { - s := newAPIServer(nil, "tester", "") - s.requestStop("test stop") - s.requestStop("test stop again") - - select { - case <-s.stopCh: - default: - t.Fatal("stopCh was not closed") - } -} diff --git a/old/status_idle_test.go b/old/status_idle_test.go deleted file mode 100644 index af9e3db..0000000 --- a/old/status_idle_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package lnx - -import ( - "testing" - "time" -) - -func TestExecStartedCancelsPendingIdleShutdown(t *testing.T) { - s := newAPIServer(nil, "tester", "") - - s.startIdleTimer() - time.Sleep(100 * time.Millisecond) - - s.execStarted() - defer s.execFinished() - - time.Sleep(idleTimeout + 500*time.Millisecond) - - select { - case <-s.idleCh: - t.Fatal("idle shutdown fired while an exec was active") - default: - } -} diff --git a/old/status_pprof_test.go b/old/status_pprof_test.go deleted file mode 100644 index eb0121e..0000000 --- a/old/status_pprof_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package lnx - -import ( - "context" - "io" - "net" - "net/http" - "path/filepath" - "strings" - "testing" -) - -func TestStatusSocketExposesPprof(t *testing.T) { - dir := t.TempDir() - sockPath := filepath.Join(dir, "status.sock") - - s := newAPIServer(nil, "tester", "") - if err := s.listenUnix(sockPath); err != nil { - t.Fatalf("listenUnix: %v", err) - } - t.Cleanup(func() { - if s.listener != nil { - _ = s.listener.Close() - } - }) - - client := &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - var d net.Dialer - return d.DialContext(ctx, "unix", sockPath) - }, - }, - } - - for _, tc := range []struct { - path string - want string - }{ - {path: "/debug/pprof/", want: "goroutine"}, - {path: "/debug/pprof/goroutine?debug=1", want: "goroutine profile"}, - } { - resp, err := client.Get("http://unix" + tc.path) - if err != nil { - t.Fatalf("GET %s: %v", tc.path, err) - } - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("GET %s status=%d body=%s", tc.path, resp.StatusCode, string(body)) - } - if !strings.Contains(string(body), tc.want) { - t.Fatalf("GET %s missing %q in body: %s", tc.path, tc.want, string(body)) - } - } -} diff --git a/old/sync_intg_test.go b/old/sync_intg_test.go deleted file mode 100644 index 592f1da..0000000 --- a/old/sync_intg_test.go +++ /dev/null @@ -1,663 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "crypto/md5" - "encoding/hex" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestSyncShare_FileAccessible verifies that a file in a sync share is readable -// inside the VM at the same absolute path via the FUSE lazy-cache mount. -func TestSyncShare_FileAccessible(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "hello.txt"), []byte("sync-ok"), 0644)) - - // Add the sync share. - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - // Read the file from inside the VM. - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "cat", filepath.Join(shareDir, "hello.txt")) - assert.Contains(t, out, "sync-ok") -} - -// TestSyncShare_DirectoryListing verifies that directory listing works on the FUSE mount. -func TestSyncShare_DirectoryListing(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-ls-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "a.txt"), []byte("a"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "b.txt"), []byte("b"), 0644)) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - // ls should show both files. - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "ls", shareDir) - assert.Contains(t, out, "a.txt") - assert.Contains(t, out, "b.txt") -} - -// TestSyncShare_GuestWriteStaysInCache verifies that writing inside the VM -// does NOT appear on the host (lower virtiofs is read-only; writes go to ext4 cache). -func TestSyncShare_GuestWriteStaysInCache(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-write-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - guestFile := filepath.Join(shareDir, "from_guest.txt") - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - // Write from inside the VM. - runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "sh", "-c", "echo cache-write > "+guestFile) - - // The file must NOT be visible on the host (lower virtiofs is read-only). - _, err = os.Stat(guestFile) - assert.True(t, os.IsNotExist(err), "guest write leaked to host: %s should not exist on host", guestFile) -} - -// TestSyncShare_SubdirectoryAccessible verifies that nested paths work. -func TestSyncShare_SubdirectoryAccessible(t *testing.T) { - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-sub-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - subDir := filepath.Join(shareDir, "sub", "nested") - require.NoError(t, os.MkdirAll(subDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(subDir, "deep.txt"), []byte("deep-ok"), 0644)) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "cat", filepath.Join(subDir, "deep.txt")) - assert.Contains(t, out, "deep-ok") -} - -// TestSyncShare_ZeroSyncSharesRegression verifies that booting with no sync shares -// configured continues to work normally. -func TestSyncShare_ZeroSyncSharesRegression(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-zero-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - // No sync shares added — boot and run a simple command. - out := runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "echo", "zero-ok") - assert.Contains(t, out, "zero-ok") -} - -// TestSyncShare_BackgroundRefresh verifies that the background refresh goroutine -// re-hydrates cached files when the lower (host) copy is updated. -func TestSyncShare_BackgroundRefresh(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-refresh-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - hostFile := filepath.Join(shareDir, "watched.txt") - require.NoError(t, os.WriteFile(hostFile, []byte("original"), 0644)) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - // Guest: read file (hydrate into cache), signal ready, sleep 8s, read again. - // The 8s sleep ensures at least one 5s refresh cycle fires after the host write. - script := `cat ` + hostFile + `; echo HYDRATED; sleep 8; cat ` + hostFile + `; echo REFRESH_DONE` - cmd, lines, stderr, done := startStreamingCLI(t, bin, "--instance", inst, "--ephemeral", "sh", "-c", script) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - waitForCLIOutput(t, lines, "HYDRATED", 30*time.Second, stderr) - - // Update the host file. Set mtime 2s in the future to guarantee the mtime - // comparison (second resolution) detects staleness even under fast machines. - require.NoError(t, os.WriteFile(hostFile, []byte("refreshed"), 0644)) - future := time.Now().Add(2 * time.Second) - require.NoError(t, os.Chtimes(hostFile, future, future)) - - // Background refresh (every 5s) must pick up the change within the 8s sleep. - waitForCLIOutput(t, lines, "refreshed", 15*time.Second, stderr) - - select { - case err := <-done: - require.NoError(t, err, "process failed: %s", stderr.String()) - case <-time.After(20 * time.Second): - t.Fatal("process did not exit in time") - } -} - -// TestSyncShare_OpenTimeReHydration verifies that Open() re-hydrates a cached file -// when the lower copy has a newer mtime, without waiting for the background refresh. -func TestSyncShare_OpenTimeReHydration(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-rehydrate-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - hostFile := filepath.Join(shareDir, "data.txt") - require.NoError(t, os.WriteFile(hostFile, []byte("v1"), 0644)) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - // Guest: read file (hydrate), signal, sleep 2s (well under 5s refresh interval), - // then read again. Re-hydration must come from Open(), not the background refresher. - script := `cat ` + hostFile + `; echo HYDRATED; sleep 2; cat ` + hostFile + `; echo REHYDRATE_DONE` - cmd, lines, stderr, done := startStreamingCLI(t, bin, "--instance", inst, "--ephemeral", "sh", "-c", script) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - waitForCLIOutput(t, lines, "HYDRATED", 30*time.Second, stderr) - - // Write new content with a future mtime so Open() detects staleness immediately. - require.NoError(t, os.WriteFile(hostFile, []byte("v2"), 0644)) - future := time.Now().Add(2 * time.Second) - require.NoError(t, os.Chtimes(hostFile, future, future)) - - waitForCLIOutput(t, lines, "v2", 10*time.Second, stderr) - - select { - case err := <-done: - require.NoError(t, err, "process failed: %s", stderr.String()) - case <-time.After(15 * time.Second): - t.Fatal("process did not exit in time") - } -} - -// TestSyncShare_MultipleSyncShares verifies that two distinct sync shares are both -// mounted and accessible inside the VM. -func TestSyncShare_MultipleSyncShares(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-multi-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - dir1 := t.TempDir() - dir2 := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir1, "one.txt"), []byte("share-one"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(dir2, "two.txt"), []byte("share-two"), 0644)) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", dir1) - require.NoError(t, err, "sync add 1 failed: %s", out) - out, err = runCLI(bin, "--instance", inst, "sync", "add", dir2) - require.NoError(t, err, "sync add 2 failed: %s", out) - - script := `cat ` + filepath.Join(dir1, "one.txt") + ` && cat ` + filepath.Join(dir2, "two.txt") - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "sh", "-c", script) - assert.Contains(t, out, "share-one") - assert.Contains(t, out, "share-two") -} - -// TestSync_RemoveCommand verifies that `lnx sync remove` removes a share from the -// persisted list without touching the host directory. -func TestSync_RemoveCommand(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-remove-%d", time.Now().UnixNano()) - home, _ := os.UserHomeDir() - instDir := filepath.Join(home, ".lnx", "instances", inst) - require.NoError(t, os.MkdirAll(instDir, 0755)) - t.Cleanup(func() { os.RemoveAll(instDir) }) - - shareDir := t.TempDir() - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - listOut := runCLISuccess(t, bin, "--instance", inst, "sync", "list") - assert.Contains(t, listOut, shareDir) - - out, err = runCLI(bin, "--instance", inst, "sync", "remove", shareDir) - require.NoError(t, err, "sync remove failed: %s", out) - - listOut = runCLISuccess(t, bin, "--instance", inst, "sync", "list") - assert.NotContains(t, listOut, shareDir) - - // The host directory must still exist. - _, err = os.Stat(shareDir) - require.NoError(t, err, "sync remove must not delete the host directory") -} - -// TestSync_ListCommand verifies `lnx sync list` for both the empty and populated cases. -func TestSync_ListCommand(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-list-%d", time.Now().UnixNano()) - home, _ := os.UserHomeDir() - instDir := filepath.Join(home, ".lnx", "instances", inst) - require.NoError(t, os.MkdirAll(instDir, 0755)) - t.Cleanup(func() { os.RemoveAll(instDir) }) - - // Empty list. - out := runCLISuccess(t, bin, "--instance", inst, "sync", "list") - assert.Contains(t, out, "no sync shares") - - // Add two shares and list. - dir1 := t.TempDir() - dir2 := t.TempDir() - runCLISuccess(t, bin, "--instance", inst, "sync", "add", dir1) - runCLISuccess(t, bin, "--instance", inst, "sync", "add", dir2) - - out = runCLISuccess(t, bin, "--instance", inst, "sync", "list") - assert.Contains(t, out, dir1) - assert.Contains(t, out, dir2) -} - -// TestSyncShare_GuestCreate verifies that a file created by the guest inside the FUSE -// mount is readable in the same session (cache write). -func TestSyncShare_GuestCreate(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-gcreate-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - guestFile := filepath.Join(shareDir, "from_guest.txt") - script := `echo guest-created > ` + guestFile + ` && cat ` + guestFile - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "sh", "-c", script) - assert.Contains(t, out, "guest-created") -} - -// TestSyncShare_GuestMkdir verifies that the guest can create nested directories and -// files inside the FUSE mount. -func TestSyncShare_GuestMkdir(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-gdir-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - subDir := filepath.Join(shareDir, "sub", "nested") - nestedFile := filepath.Join(subDir, "deep.txt") - script := `mkdir -p ` + subDir + ` && echo deep-content > ` + nestedFile + ` && cat ` + nestedFile - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "sh", "-c", script) - assert.Contains(t, out, "deep-content") -} - -// TestSyncShare_SymlinkInLower verifies that a symlink present in the host (lower) -// directory is readable through the FUSE mount. -func TestSyncShare_SymlinkInLower(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-symlink-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "target.txt"), []byte("link-ok"), 0644)) - require.NoError(t, os.Symlink("target.txt", filepath.Join(shareDir, "link.txt"))) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - linkPath := filepath.Join(shareDir, "link.txt") - script := `readlink ` + linkPath + ` && cat ` + linkPath - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "sh", "-c", script) - assert.Contains(t, out, "target.txt") - assert.Contains(t, out, "link-ok") -} - -// TestSyncShare_FilePermissions verifies that file permissions from the lower (host) -// directory are preserved after hydration into the ext4 cache. -func TestSyncShare_FilePermissions(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-perms-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - secretFile := filepath.Join(shareDir, "secret.txt") - require.NoError(t, os.WriteFile(secretFile, []byte("secret"), 0600)) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - // stat -c %a prints octal permissions without leading zero. - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "stat", "-c", "%a", secretFile) - assert.Equal(t, "600\n", out) -} - -// TestSync_AddIdempotent verifies that calling `lnx sync add` twice with the same -// path results in the path appearing exactly once in the list. -func TestSync_AddIdempotent(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-idem-%d", time.Now().UnixNano()) - home, _ := os.UserHomeDir() - instDir := filepath.Join(home, ".lnx", "instances", inst) - require.NoError(t, os.MkdirAll(instDir, 0755)) - t.Cleanup(func() { os.RemoveAll(instDir) }) - - shareDir := t.TempDir() - - runCLISuccess(t, bin, "--instance", inst, "sync", "add", shareDir) - // Second add with the same path should be a no-op. - runCLISuccess(t, bin, "--instance", inst, "sync", "add", shareDir) - - out := runCLISuccess(t, bin, "--instance", inst, "sync", "list") - assert.Equal(t, 1, strings.Count(out, shareDir), "path should appear exactly once in list") -} - -// TestSync_AddRejectsNonDirectory verifies that `lnx sync add` returns an error when -// given a path that is a regular file, not a directory. -func TestSync_AddRejectsNonDirectory(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-nodir-%d", time.Now().UnixNano()) - home, _ := os.UserHomeDir() - instDir := filepath.Join(home, ".lnx", "instances", inst) - require.NoError(t, os.MkdirAll(instDir, 0755)) - t.Cleanup(func() { os.RemoveAll(instDir) }) - - regularFile := filepath.Join(t.TempDir(), "notadir.txt") - require.NoError(t, os.WriteFile(regularFile, []byte("x"), 0644)) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", regularFile) - require.Error(t, err, "expected error for non-directory path") - assert.Contains(t, out, "not a directory") -} - -// TestSync_AddRejectsNonExistent verifies that `lnx sync add` returns an error when -// the given path does not exist. -func TestSync_AddRejectsNonExistent(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-nonexist-%d", time.Now().UnixNano()) - home, _ := os.UserHomeDir() - instDir := filepath.Join(home, ".lnx", "instances", inst) - require.NoError(t, os.MkdirAll(instDir, 0755)) - t.Cleanup(func() { os.RemoveAll(instDir) }) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", "/tmp/does-not-exist-lnx-test-12345") - require.Error(t, err, "expected error for non-existent path") - assert.Contains(t, out, "no such file or directory") -} - -// TestSyncShare_GuestUnlink verifies that a guest `rm` succeeds (removes from cache) -// and does not delete the original file on the host. -func TestSyncShare_GuestUnlink(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-unlink-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - hostFile := filepath.Join(shareDir, "ephemeral.txt") - require.NoError(t, os.WriteFile(hostFile, []byte("removeme"), 0644)) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - // Read the file first (hydrate into cache), then remove it. - script := `cat ` + hostFile + ` && rm ` + hostFile + ` && echo REMOVED` - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "sh", "-c", script) - assert.Contains(t, out, "removeme") - assert.Contains(t, out, "REMOVED") - - // The host file must still exist — Unlink only removes from cache. - _, err = os.Stat(hostFile) - assert.NoError(t, err, "host file must survive a guest unlink") -} - -// TestSyncShare_GuestRename verifies that the guest can rename a file within the FUSE -// mount and access it under the new name. -func TestSyncShare_GuestRename(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-rename-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "old.txt"), []byte("rename-me"), 0644)) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - oldPath := filepath.Join(shareDir, "old.txt") - newPath := filepath.Join(shareDir, "new.txt") - // Read old (hydrate), rename, then read under the new name. - script := `cat ` + oldPath + ` && mv ` + oldPath + ` ` + newPath + ` && cat ` + newPath + ` && echo RENAMED` - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "sh", "-c", script) - assert.Contains(t, out, "rename-me") - assert.Contains(t, out, "RENAMED") -} - -// TestSyncShare_ReaddirCacheInvalidation verifies that when the host adds a file to -// a shared directory, a subsequent guest `ls` sees the new file (the per-directory -// listing cache is invalidated by the changed lower mtime). -func TestSyncShare_ReaddirCacheInvalidation(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-readdir-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "existing.txt"), []byte("x"), 0644)) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - // Guest: list dir, signal done, sleep 3s, list again. - script := `ls ` + shareDir + `; echo DONE_LS1; sleep 3; ls ` + shareDir + `; echo DONE_LS2` - cmd, lines, stderr, done := startStreamingCLI(t, bin, "--instance", inst, "--ephemeral", "sh", "-c", script) - t.Cleanup(func() { cleanupStreamingCLI(t, cmd, done, stderr) }) - - waitForCLIOutput(t, lines, "DONE_LS1", 30*time.Second, stderr) - - // Add a new file and bump the dir mtime so the FUSE cache invalidates. - require.NoError(t, os.WriteFile(filepath.Join(shareDir, "newfile.txt"), []byte("new"), 0644)) - future := time.Now().Add(2 * time.Second) - require.NoError(t, os.Chtimes(shareDir, future, future)) - - // Second ls must include the new file. - waitForCLIOutput(t, lines, "newfile.txt", 10*time.Second, stderr) - - select { - case err := <-done: - require.NoError(t, err, "process failed: %s", stderr.String()) - case <-time.After(15 * time.Second): - t.Fatal("process did not exit in time") - } -} - -// TestSyncShare_EmptyDirectory verifies that an empty sync share directory can be -// listed inside the VM without error. -func TestSyncShare_EmptyDirectory(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-empty-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() // intentionally empty - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - // ls on empty FUSE mount should succeed with no files listed. - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "sh", "-c", - `ls `+shareDir+` && echo EMPTY_OK`) - assert.Contains(t, out, "EMPTY_OK") -} - -// TestSyncShare_BinaryFileIntegrity verifies that binary files survive the -// lower→cache hydration without corruption by comparing md5 checksums. -func TestSyncShare_BinaryFileIntegrity(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-binary-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - binaryFile := filepath.Join(shareDir, "data.bin") - - // Create a file containing all 256 byte values. - content := make([]byte, 256) - for i := range content { - content[i] = byte(i) - } - require.NoError(t, os.WriteFile(binaryFile, content, 0644)) - - sum := md5.Sum(content) - hostMD5 := hex.EncodeToString(sum[:]) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - // md5sum output: " " - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "md5sum", binaryFile) - assert.Contains(t, out, hostMD5, "guest md5 must match host md5") -} - -// TestSyncShare_FilenameWithSpaces verifies that files whose names contain spaces -// are accessible through the FUSE mount. -func TestSyncShare_FilenameWithSpaces(t *testing.T) { - t.Parallel() - bin := lnxBin() - if bin == "" { - t.Skip("lnx not in PATH") - } - - inst := fmt.Sprintf("test-sync-spaces-%d", time.Now().UnixNano()) - createClonedInstance(t, inst) - registerInstanceStopCleanup(t, bin, inst) - - shareDir := t.TempDir() - spacedFile := filepath.Join(shareDir, "hello world.txt") - require.NoError(t, os.WriteFile(spacedFile, []byte("spaces-ok"), 0644)) - - out, err := runCLI(bin, "--instance", inst, "sync", "add", shareDir) - require.NoError(t, err, "sync add failed: %s", out) - - out = runCLISuccess(t, bin, "--instance", inst, "--ephemeral", "cat", spacedFile) - assert.Contains(t, out, "spaces-ok") -} diff --git a/old/testutil_test.go b/old/testutil_test.go deleted file mode 100644 index 468bb5f..0000000 --- a/old/testutil_test.go +++ /dev/null @@ -1,77 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "path/filepath" - "sync" - "testing" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/require" - "golang.org/x/sys/unix" -) - -var testDirOnce sync.Once - -func setupTestDir(t *testing.T) string { - t.Helper() - - home, _ := os.UserHomeDir() - base := filepath.Join(home, ".lnx") - - kernelPath := filepath.Join(base, "vmlinuz") - if _, err := os.Stat(kernelPath); err != nil { - t.Skipf("skipping: vmlinuz not found in ~/.lnx (run 'lnx init' first)") - } - - // Check new images/ layout first, then legacy instances/ layout. - rootfsPath := filepath.Join(base, "images", "default", "rootfs.ext4") - if _, err := os.Stat(rootfsPath); err != nil { - rootfsPath = filepath.Join(base, "instances", "default", "rootfs.ext4") - if _, err := os.Stat(rootfsPath); err != nil { - t.Skipf("skipping: rootfs.ext4 not found in ~/.lnx/images/default/ or ~/.lnx/instances/default/ (run 'lnx init' first)") - } - } - - initPath := filepath.Join("cmd", "lnx", "init") - if _, err := os.Stat(initPath); err != nil { - t.Skipf("skipping: guest init binary not found at %s (run 'make' first)", initPath) - } - initBin, err := os.ReadFile(initPath) - require.NoError(t, err) - lnx.InitBinary = initBin - - testDirOnce.Do(func() { os.MkdirAll("tmp", 0755) }) - - dir, err := os.MkdirTemp("tmp", "test-*") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(dir) }) - - os.Symlink(kernelPath, filepath.Join(dir, "vmlinuz")) - - err = unix.Clonefile(rootfsPath, filepath.Join(dir, "rootfs.ext4"), 0) - require.NoError(t, err) - - return dir -} - -// findDefaultRootfs returns the path to the default rootfs, checking the new -// images/ layout first, then the legacy instances/ layout. -func findDefaultRootfs(base string) string { - for _, sub := range []string{"images", "instances"} { - p := filepath.Join(base, sub, "default", "rootfs.ext4") - if _, err := os.Stat(p); err == nil { - return p - } - } - return "" -} - -func testConfig(dir string) *lnx.Config { - return &lnx.Config{ - KernelPath: filepath.Join(dir, "vmlinuz"), - RootfsPath: filepath.Join(dir, "rootfs.ext4"), - } -} diff --git a/old/third_party/criu b/old/third_party/criu deleted file mode 160000 index 98375bd..0000000 --- a/old/third_party/criu +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 98375bd8239e2759238a85fb009835b49bd0c8f9 diff --git a/old/third_party/qemu b/old/third_party/qemu deleted file mode 160000 index 6b9f419..0000000 --- a/old/third_party/qemu +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6b9f41929e1c50370aaf3bc0fb2ab1be2a461a83 diff --git a/old/third_party/vz b/old/third_party/vz deleted file mode 160000 index 8b4f475..0000000 --- a/old/third_party/vz +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8b4f47561fdd5cdcdc1f726a30b796ece35f39f4 diff --git a/old/userenv_intg_test.go b/old/userenv_intg_test.go deleted file mode 100644 index 9277d30..0000000 --- a/old/userenv_intg_test.go +++ /dev/null @@ -1,83 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "os/user" - "path/filepath" - "testing" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRun_RunsAsHostUser(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - u, err := user.Current() - require.NoError(t, err) - - // Write output to a file in CWD (virtiofs), then check it on host. - cwd := t.TempDir() - cfg.CWD = cwd - outFile := filepath.Join(cwd, "whoami.txt") - - exitCode, err := lnx.Run(cfg, "sh", "-c", "whoami > "+outFile) - require.NoError(t, err) - assert.Equal(t, 0, exitCode) - - data, err := os.ReadFile(outFile) - require.NoError(t, err) - assert.Contains(t, string(data), u.Username) -} - -func TestRun_CWDIsHostCWD(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - cwd := t.TempDir() - cfg.CWD = cwd - outFile := filepath.Join(cwd, "pwd.txt") - - exitCode, err := lnx.Run(cfg, "sh", "-c", "pwd > "+outFile) - require.NoError(t, err) - assert.Equal(t, 0, exitCode) - - data, err := os.ReadFile(outFile) - require.NoError(t, err) - assert.Contains(t, string(data), cwd) -} - -func TestRun_ProfileDExists(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - exitCode, err := lnx.Run(cfg, "test", "-f", "/etc/profile.d/lnx-bashrc.sh") - require.NoError(t, err) - assert.Equal(t, 0, exitCode) -} - -func TestRun_NotRoot(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - cwd := t.TempDir() - cfg.CWD = cwd - outFile := filepath.Join(cwd, "id.txt") - - exitCode, err := lnx.Run(cfg, "sh", "-c", "id -u > "+outFile) - require.NoError(t, err) - assert.Equal(t, 0, exitCode) - - data, err := os.ReadFile(outFile) - require.NoError(t, err) - // Should NOT be root (uid 0) - assert.NotContains(t, string(data), "0\n") -} diff --git a/old/virtiofs_intg_test.go b/old/virtiofs_intg_test.go deleted file mode 100644 index 36d5d8f..0000000 --- a/old/virtiofs_intg_test.go +++ /dev/null @@ -1,51 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRun_VirtioFS_CWDMountedAtSamePath(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - // Use a known host directory as CWD. - cwd := t.TempDir() - cfg.CWD = cwd - - // Write a file on the host. - require.NoError(t, os.WriteFile(filepath.Join(cwd, "host.txt"), []byte("from-host"), 0644)) - - // Read it from inside the VM at the same absolute path. - exitCode, err := lnx.Run(cfg, "cat", filepath.Join(cwd, "host.txt")) - require.NoError(t, err) - assert.Equal(t, 0, exitCode) -} - -func TestRun_VirtioFS_GuestWriteVisibleOnHost(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - cwd := t.TempDir() - cfg.CWD = cwd - - guestFile := filepath.Join(cwd, "guest.txt") - - exitCode, err := lnx.Run(cfg, "sh", "-c", "echo from-guest > "+guestFile) - require.NoError(t, err) - assert.Equal(t, 0, exitCode) - - // Verify the file appeared on the host. - data, err := os.ReadFile(guestFile) - require.NoError(t, err) - assert.Equal(t, "from-guest\n", string(data)) -} diff --git a/old/vm.go b/old/vm.go deleted file mode 100644 index 010d4ca..0000000 --- a/old/vm.go +++ /dev/null @@ -1,795 +0,0 @@ -package lnx - -import ( - cryptoRand "crypto/rand" - "encoding/gob" - "fmt" - "io" - "log/slog" - "net" - "os" - "os/signal" - "os/user" - "path/filepath" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "golang.org/x/term" - - "github.com/semistrict/lnx/internal/protocol" -) - -const vsockLogPort = 1025 - -var hostLogOnce sync.Once - -// bootedVM holds a booted VM and its infrastructure, ready for exec sessions. -type bootedVM struct { - vm VirtualMachine - vs *vsockState - ctrlConn net.Conn - ctrlEnc *gob.Encoder - cwd string - sockDir string - criuPath string // host path to CRIU images block device file - // ephCleanup removes the ephemeral temp dir (nil if not ephemeral). - ephCleanup func() - lock *lockFile -} - -// close shuts down the VM and releases all resources. -func (b *bootedVM) close(exitCode int) { - b.ctrlConn.Close() - shutdownVM(b.vm, exitCode) - b.lock.unlock() - b.vs.cleanup() - if b.ephCleanup != nil { - b.ephCleanup() - } -} - -// bootVM handles the shared VM boot sequence: validate, ephemeral clone, lock, -// checkpoint, initramfs, VM config, start, control connection, setup message. -func bootVM(cfg *Config) (*bootedVM, error) { - initHostLoggingFromEnv() - - if err := validatePaths(cfg); err != nil { - return nil, err - } - - var ephCleanup func() - if cfg.Ephemeral { - // Create the temp dir alongside the source rootfs so it stays on the - // same APFS volume — clonefile requires source and destination to be - // on the same filesystem, and /tmp is a different volume. - tmpDir, err := os.MkdirTemp(filepath.Dir(cfg.RootfsPath), "ephemeral-*") - if err != nil { - return nil, fmt.Errorf("create ephemeral dir: %w", err) - } - ephCleanup = func() { os.RemoveAll(tmpDir) } - - ephRootfs := filepath.Join(tmpDir, "rootfs.ext4") - if err := cloneFile(cfg.RootfsPath, ephRootfs); err != nil { - ephCleanup() - return nil, fmt.Errorf("clone ephemeral rootfs: %w", err) - } - cfg = &Config{ - KernelPath: cfg.KernelPath, - RootfsPath: ephRootfs, - InitramfsPath: cfg.InitramfsPath, - CPUs: cfg.CPUs, - MemoryBytes: cfg.MemoryBytes, - CWD: cfg.CWD, - Env: cfg.Env, - Checkpoint: cfg.Checkpoint, - CheckpointDir: cfg.CheckpointDir, - Shares: cfg.Shares, - Hostname: cfg.Hostname, - SSHAgent: cfg.SSHAgent, - SocketDir: cfg.SocketDir, - NestedRootfs: cfg.NestedRootfs, - SyncShares: cfg.SyncShares, - DirectShare: cfg.DirectShare, - } - } - - lock, err := lockRootfs(cfg.RootfsPath) - if err != nil { - if ephCleanup != nil { - ephCleanup() - } - return nil, err - } - - if cfg.Checkpoint { - cpDir := cfg.CheckpointDir - if cpDir == "" { - cpDir = filepath.Join(filepath.Dir(cfg.RootfsPath), "checkpoints") - } - if _, err := checkpoint(cfg.RootfsPath, cpDir); err != nil { - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, fmt.Errorf("checkpoint: %w", err) - } - } - - // Use socketDir as the work directory for derived files when rootfs - // is a block device (filepath.Dir("/dev/vdc") = "/dev", not writable). - workDir := filepath.Dir(cfg.RootfsPath) - if strings.HasPrefix(cfg.RootfsPath, "/dev/") { - workDir = cfg.socketDir() - } - - initrdDir := workDir - if cfg.InitramfsPath != "" { - initrdDir = filepath.Dir(cfg.InitramfsPath) - } - initrdPath, err := writeInitramfs(initrdDir) - if err != nil { - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, fmt.Errorf("write initramfs: %w", err) - } - - cwd := cfg.CWD - if cwd == "" { - cwd, err = os.Getwd() - if err != nil { - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, fmt.Errorf("getwd: %w", err) - } - } - - u, err := user.Current() - if err != nil { - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, fmt.Errorf("get current user: %w", err) - } - uid, _ := strconv.Atoi(u.Uid) - - swapPath := filepath.Join(workDir, "swap.img") - if err := ensureSwapFile(swapPath, cfg.memoryBytes()); err != nil { - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, fmt.Errorf("swap file: %w", err) - } - - criuPath := filepath.Join(workDir, "criu.ext4") - if err := ensureSparseFile(criuPath, cfg.memoryBytes()); err != nil { - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, fmt.Errorf("criu image file: %w", err) - } - - hostname := cfg.Hostname - if hostname == "" { - hostname = "lnx" - } - - sshAgent := cfg.SSHAgent && os.Getenv("SSH_AUTH_SOCK") != "" - if cfg.SSHAgent && !sshAgent { - slog.Warn("--ssh-agent requested but SSH_AUTH_SOCK is not set") - } - if sshAgent { - if n, err := countSSHKeys(os.Getenv("SSH_AUTH_SOCK")); err != nil { - slog.Warn("cannot query SSH agent", "error", err) - } else if n == 0 { - slog.Warn("SSH agent has no identities loaded") - } - } - - // Pass LNX_PARENT so nested lnx instances know their parent. - parentInstance := os.Getenv("LNX_INSTANCE") - if parentInstance == "" { - parentInstance = "default" - } - if existing := os.Getenv("LNX_PARENT"); existing != "" { - parentInstance = existing + "." + parentInstance - } - - // Build nested drive mapping: each nested rootfs gets a device starting at vdd - // (vda=rootfs, vdb=swap, vdc=criu). - var nestedDrives []protocol.NestedDrive - for i, nr := range cfg.NestedRootfs { - devLetter := 'd' + rune(i) // vdd, vde, vdf, ... - nestedDrives = append(nestedDrives, protocol.NestedDrive{ - InstanceName: nr.InstanceName, - DevicePath: fmt.Sprintf("/dev/vd%c", devLetter), - }) - } - - setupMsg := &protocol.Setup{ - CWD: cwd, - Env: append([]string(nil), cfg.Env...), - User: u.Username, - UID: uid, - HomeDir: u.HomeDir, - Hostname: hostname, - SSHAgent: sshAgent, - Shares: cfg.Shares, - DirectShare: cfg.DirectShare, - NestedDrives: nestedDrives, - SyncShares: cfg.SyncShares, - } - setupMsg.Env = append(setupMsg.Env, "LNX_PARENT="+parentInstance) - - sockDir := cfg.socketDir() - - // Load or generate a stable MAC address for this instance. - macAddr := loadOrGenerateMAC(sockDir) - - epoch := time.Now().Unix() - - vm, err := buildVM(cfg, initrdPath, cwd, swapPath, criuPath, u.HomeDir, macAddr, epoch) - if err != nil { - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, err - } - - // Derive instance name from hostname for fork support. - vmInstanceName := strings.TrimSuffix(hostname, ".lnx") - - vs, err := setupVsock(vm.VsockDevice(), sockDir, cfg.RootfsPath, vmInstanceName, setupMsg) - if err != nil { - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, err - } - vs.api.vm = vm - - if err := vm.Start(); err != nil { - vs.cleanup() - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, fmt.Errorf("start vm: %w", err) - } - - // Restored VMs (fork): the guest is already running and configured. - // Skip the control connection handshake — the guest won't re-dial. - // Use a dummy connection so bootedVM.close() works. - type restoredChecker interface{ IsRestored() bool } - if rc, ok := vm.(restoredChecker); ok && rc.IsRestored() { - dummyConn, _ := net.Pipe() - return &bootedVM{ - vm: vm, - vs: vs, - ctrlConn: dummyConn, - ctrlEnc: gob.NewEncoder(dummyConn), - cwd: cwd, - sockDir: sockDir, - criuPath: criuPath, - ephCleanup: ephCleanup, - lock: lock, - }, nil - } - - // Wait for the guest to connect on the control port, with a timeout. - // Also watch for VM state changes (crash/stop) so we don't wait forever. - var ctrlConn net.Conn - stateCh := vm.StateChangedNotify() - bootTimer := time.NewTimer(30 * time.Second) - defer bootTimer.Stop() -waitBoot: - for { - select { - case ctrlConn = <-vs.ctrlConnCh: - break waitBoot - case state := <-stateCh: - switch state { - case VMStateRunning, VMStateStarting: - continue // expected transient states - default: - vs.cleanup() - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, fmt.Errorf("VM entered state %v during boot\n%s", state, serialLogTail(sockDir)) - } - case <-bootTimer.C: - vs.cleanup() - vm.Stop() - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, fmt.Errorf("guest did not connect within 30s\n%s", serialLogTail(sockDir)) - } - } - if ctrlConn == nil { - vs.cleanup() - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, fmt.Errorf("control connection failed\n%s", serialLogTail(sockDir)) - } - enc := gob.NewEncoder(ctrlConn) - if err := enc.Encode(protocol.Msg{Setup: setupMsg}); err != nil { - ctrlConn.Close() - vs.cleanup() - lock.unlock() - if ephCleanup != nil { - ephCleanup() - } - return nil, fmt.Errorf("send setup: %w", err) - } - - return &bootedVM{ - vm: vm, - vs: vs, - ctrlConn: ctrlConn, - ctrlEnc: enc, - cwd: cwd, - sockDir: sockDir, - criuPath: criuPath, - ephCleanup: ephCleanup, - lock: lock, - }, nil -} - -// Run executes a command inside a Linux VM and blocks until it exits. -// args is a command vector (like exec): args[0] is the program, args[1:] are arguments. -// Returns the guest process exit code. -func Run(cfg *Config, args ...string) (int, error) { - b, err := bootVM(cfg) - if err != nil { - return -1, err - } - - forceQuitCh := make(chan struct{}) - go forwardSignals(b.ctrlConn, b.ctrlEnc, forceQuitCh) - - // Auto-detect interactive mode based on whether stdin is a terminal. - interactive := term.IsTerminal(int(os.Stdin.Fd())) - - var rows, cols uint16 - if interactive { - fd := int(os.Stdin.Fd()) - if term.IsTerminal(fd) { - w, h, err := term.GetSize(fd) - if err == nil { - rows = uint16(h) - cols = uint16(w) - } - oldState, err := term.MakeRaw(fd) - if err == nil { - defer term.Restore(fd, oldState) - } - } - } - - exitCode := b.vs.api.runExec(&protocol.ExecReq{ - Args: args, - CWD: b.cwd, - PTY: interactive, - Rows: rows, - Cols: cols, - }, interactive, forceQuitCh) - - // Check if force quit happened (double Ctrl-C). - select { - case <-forceQuitCh: - exitCode = 130 - default: - } - - b.close(exitCode) - return exitCode, nil -} - -// RunDaemon boots a VM and runs it as a background daemon with no initial command. -// It blocks until all exec sessions have finished (idle) or Stop is requested -// via the API. Returns nil on clean shutdown. -func RunDaemon(cfg *Config) error { - b, err := bootVM(cfg) - if err != nil { - return err - } - - slog.Info("daemon ready, waiting for exec sessions") - - go func() { - for state := range b.vm.StateChangedNotify() { - switch state { - case VMStateStarting, VMStateRunning: - slog.Debug("vm state changed", "state", state) - case VMStateStopped: - slog.Warn("vm stopped while daemon was still running") - b.vs.api.requestStop("vm stopped while daemon was still running", "state", state) - return - default: - slog.Warn("vm entered unexpected state while daemon was still running", "state", state) - b.vs.api.requestStop("vm entered unexpected state while daemon was still running", "state", state) - return - } - } - slog.Warn("vm state channel closed while daemon was still running") - b.vs.api.requestStop("vm state channel closed while daemon was still running") - }() - - // Block until idle (all execs finished) or stop requested. - b.vs.api.WaitIdle() - - slog.Info("daemon shutting down") - b.close(0) - return nil -} - -// vsockState holds the vsock infrastructure created during VM setup. -type vsockState struct { - ctrlConnCh <-chan net.Conn - api *apiServer - cleanup func() -} - -func setupVsock(sock VsockDevice, logDir, rootfsPath, instanceName string, setupMsg *protocol.Setup) (*vsockState, error) { - logListener, err := sock.Listen(vsockLogPort) - if err != nil { - return nil, fmt.Errorf("vsock log listen: %w", err) - } - waitLog := startLogReceiver(logListener, logDir) - - ctrlListener, err := sock.Listen(protocol.Port) - if err != nil { - logListener.Close() - return nil, fmt.Errorf("vsock ctrl listen: %w", err) - } - - statusListener, err := sock.Listen(protocol.StatusPort) - if err != nil { - ctrlListener.Close() - logListener.Close() - return nil, fmt.Errorf("vsock status listen: %w", err) - } - - guestCtrlListener, err := sock.Listen(protocol.GuestControlPort) - if err != nil { - statusListener.Close() - ctrlListener.Close() - logListener.Close() - return nil, fmt.Errorf("vsock guest ctrl listen: %w", err) - } - - portFwdListener, err := sock.Listen(protocol.PortForwardPort) - if err != nil { - guestCtrlListener.Close() - statusListener.Close() - ctrlListener.Close() - logListener.Close() - return nil, fmt.Errorf("vsock port forward listen: %w", err) - } - - // 9P file servers — all directory sharing goes through 9P. - // Each server is wrapped with a file tracker so the host can detect - // mtime changes and push invalidations to the guest. - var p9Listeners []net.Listener - var watchers []shareWatcher - - // Home directory (filtered + tracked). - if setupMsg.HomeDir != "" { - homeListener, err := sock.Listen(protocol.P9Port) - if err != nil { - slog.Warn("vsock home 9p listen failed", "error", err) - } else { - tracker := newFileTracker(setupMsg.HomeDir) - start9PTrackedServer(homeListener, setupMsg.HomeDir, tracker, true) - watchers = append(watchers, shareWatcher{tag: "home", tracker: tracker}) - p9Listeners = append(p9Listeners, homeListener) - } - } - - // CWD. - if setupMsg.CWD != "" { - cwdListener, err := sock.Listen(protocol.P9CWDPort) - if err != nil { - slog.Warn("vsock cwd 9p listen failed", "error", err) - } else { - tracker := newFileTracker(setupMsg.CWD) - start9PTrackedServer(cwdListener, setupMsg.CWD, tracker, false) - watchers = append(watchers, shareWatcher{tag: "cwd", tracker: tracker}) - p9Listeners = append(p9Listeners, cwdListener) - } - } - - // Extra shares. - for i, path := range setupMsg.Shares { - shareListener, err := sock.Listen(protocol.P9ShareBasePort + uint32(i)) - if err != nil { - slog.Warn("vsock share 9p listen failed", "path", path, "error", err) - continue - } - tag := fmt.Sprintf("share%d", i) - tracker := newFileTracker(path) - start9PTrackedServer(shareListener, path, tracker, false) - watchers = append(watchers, shareWatcher{tag: tag, tracker: tracker}) - p9Listeners = append(p9Listeners, shareListener) - } - - // Sync shares. - for i, path := range setupMsg.SyncShares { - syncListener, err := sock.Listen(protocol.P9SyncBasePort + uint32(i)) - if err != nil { - slog.Warn("vsock sync 9p listen failed", "path", path, "error", err) - continue - } - tag := fmt.Sprintf("sync%d", i) - tracker := newFileTracker(path) - start9PTrackedServer(syncListener, path, tracker, false) - watchers = append(watchers, shareWatcher{tag: tag, tracker: tracker}) - p9Listeners = append(p9Listeners, syncListener) - } - - // Invalidation channel — host pushes changed paths to guest. - invalidateListener, err := sock.Listen(protocol.InvalidatePort) - if err != nil { - slog.Warn("vsock invalidate listen failed", "error", err) - } else { - p9Listeners = append(p9Listeners, invalidateListener) - go func() { - conn, err := invalidateListener.Accept() - if err != nil { - return - } - startInvalidationSender(conn, watchers) - }() - } - - var sshAgentListener net.Listener - if setupMsg.SSHAgent { - var err error - sshAgentListener, err = sock.Listen(protocol.SSHAgentPort) - if err != nil { - slog.Warn("ssh agent vsock listen failed", "error", err) - } else { - startSSHAgentProxy(sshAgentListener, os.Getenv("SSH_AUTH_SOCK")) - } - } - - pf := newPortForwarder(sock) - go func() { - conn, err := portFwdListener.Accept() - if err != nil { - return - } - pf.run(conn) - }() - - // Listen for reverse exec connections from the guest (used after - // fork/migration when host→guest Connect doesn't work). - reverseExecListener, err := sock.Listen(protocol.ExecPort) - if err != nil { - slog.Warn("vsock reverse exec listen failed", "error", err) - } - reverseExecCh := make(chan net.Conn, 16) - if reverseExecListener != nil { - go func() { - for { - conn, err := reverseExecListener.Accept() - if err != nil { - return - } - reverseExecCh <- conn - } - }() - } - - criuImagePath := filepath.Join(filepath.Dir(rootfsPath), "criu.ext4") - api := newAPIServer(nil, setupMsg.User, rootfsPath) - api.sock = sock - api.pf = pf - api.reverseExecCh = reverseExecCh - api.instanceName = instanceName - api.instanceDir = logDir - api.criuPath = criuImagePath - go func() { - conn, err := statusListener.Accept() - if err != nil { - return - } - api.setStatusConn(conn) - }() - go func() { - conn, err := guestCtrlListener.Accept() - if err != nil { - return - } - api.setGuestCtrlConn(conn) - }() - - sockPath := filepath.Join(logDir, "status.sock") - if err := api.listenUnix(sockPath); err != nil { - slog.Warn("status socket failed", "error", err) - } - - // Accept control connection asynchronously (VM hasn't started yet). - ctrlConnCh := make(chan net.Conn, 1) - go func() { - conn, err := ctrlListener.Accept() - if err != nil { - return - } - ctrlConnCh <- conn - }() - - cleanup := func() { - pf.close() - api.close() - if sshAgentListener != nil { - sshAgentListener.Close() - } - for _, l := range p9Listeners { - l.Close() - } - if reverseExecListener != nil { - reverseExecListener.Close() - } - portFwdListener.Close() - guestCtrlListener.Close() - statusListener.Close() - ctrlListener.Close() - logListener.Close() - waitLog() - } - - return &vsockState{ctrlConnCh: ctrlConnCh, api: api, cleanup: cleanup}, nil -} - -// forwardSignals reads host signals and forwards them to the guest via the -// control connection. SIGWINCH is converted to Resize. Double SIGINT -// force-quits. -func forwardSignals(conn net.Conn, enc *gob.Encoder, forceQuitCh chan struct{}) { - sigCh := make(chan os.Signal, 4) - signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, syscall.SIGWINCH) - defer signal.Stop(sigCh) - - var lastInt time.Time - for sig := range sigCh { - if sig == syscall.SIGWINCH { - w, h, err := term.GetSize(int(os.Stdin.Fd())) - if err == nil { - enc.Encode(protocol.Msg{Resize: &protocol.Resize{ - Rows: uint16(h), - Cols: uint16(w), - }}) - } - } else if sig == syscall.SIGINT && time.Since(lastInt) < time.Second { - fmt.Fprintln(os.Stderr, "\nforce quit") - close(forceQuitCh) - conn.Close() - return - } else { - if sig == syscall.SIGINT { - lastInt = time.Now() - } - enc.Encode(protocol.Msg{Signal: &protocol.Signal{ - Sig: int(sig.(syscall.Signal)), - }}) - } - } -} - -func validatePaths(cfg *Config) error { - for _, p := range []string{cfg.KernelPath, cfg.RootfsPath} { - if _, err := os.Stat(p); err != nil { - return fmt.Errorf("%s not found, run 'lnx init' first", p) - } - } - return nil -} - -func initHostLoggingFromEnv() { - hostLogOnce.Do(func() { - level := slog.LevelInfo - switch strings.ToLower(os.Getenv("LNX_LOG")) { - case "debug": - level = slog.LevelDebug - case "warn": - level = slog.LevelWarn - case "error": - level = slog.LevelError - } - - home, err := os.UserHomeDir() - if err != nil { - return - } - logDir := filepath.Join(home, ".lnx") - os.MkdirAll(logDir, 0755) - f, err := os.OpenFile(filepath.Join(logDir, "lnx.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return - } - slog.SetDefault(slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: level}))) - }) -} - -// ensureSparseFile creates a sparse file if it doesn't exist. -// Unlike ensureSwapFile, it preserves existing files (they may contain -// CRIU images from a checkpoint restore). -func ensureSparseFile(path string, size uint64) error { - if _, err := os.Stat(path); err == nil { - return nil // already exists, preserve contents - } - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - return f.Truncate(int64(size)) -} - -// ensureSwapFile creates a sparse swap file if it doesn't exist or is the wrong size. -func ensureSwapFile(path string, size uint64) error { - if info, err := os.Stat(path); err == nil && uint64(info.Size()) == size { - return nil - } - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - return f.Truncate(int64(size)) -} - -// loadOrGenerateMAC returns a stable MAC address for an instance. -// On the first call for a given dir, it generates a random locally-administered -// MAC and persists it to mac.addr. Subsequent calls read the saved value. -func loadOrGenerateMAC(dir string) string { - if dir == "" { - return "" - } - path := filepath.Join(dir, "mac.addr") - if data, err := os.ReadFile(path); err == nil { - mac := strings.TrimSpace(string(data)) - if mac != "" { - return mac - } - } - b := make([]byte, 6) - if _, err := io.ReadFull(cryptoRand.Reader, b); err != nil { - return "" - } - b[0] = (b[0] & 0xfe) | 0x02 // locally administered, unicast - mac := fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5]) - os.WriteFile(path, []byte(mac+"\n"), 0644) - return mac -} - -// serialLogTail returns the last few lines of serial.log for error diagnostics. -func serialLogTail(dir string) string { - data, err := os.ReadFile(filepath.Join(dir, "serial.log")) - if err != nil || len(data) == 0 { - return "serial.log: (not available)" - } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - const maxLines = 20 - if len(lines) > maxLines { - lines = lines[len(lines)-maxLines:] - } - return "serial.log:\n" + strings.Join(lines, "\n") -} diff --git a/old/vm_darwin.go b/old/vm_darwin.go deleted file mode 100644 index d884acb..0000000 --- a/old/vm_darwin.go +++ /dev/null @@ -1,167 +0,0 @@ -//go:build darwin - -package lnx - -import ( - "fmt" - "time" - - vz "github.com/Code-Hex/vz/v3" -) - -// darwinVM implements VirtualMachine using Apple Virtualization.framework. -type darwinVM struct { - vm *vz.VirtualMachine - sock *vzVsockDevice -} - -func (d *darwinVM) Start() error { - return d.vm.Start() -} - -func (d *darwinVM) Stop() error { - return d.vm.Stop() -} - -func (d *darwinVM) RequestStop() error { - _, err := d.vm.RequestStop() - return err -} - -func (d *darwinVM) StateChangedNotify() <-chan VMState { - vzCh := d.vm.StateChangedNotify() - ch := make(chan VMState, 1) - go func() { - defer close(ch) - for state := range vzCh { - ch <- vzStateToVMState(state) - } - }() - return ch -} - -func (d *darwinVM) VsockDevice() VsockDevice { - return d.sock -} - -func vzStateToVMState(s vz.VirtualMachineState) VMState { - switch s { - case vz.VirtualMachineStateStarting: - return VMStateStarting - case vz.VirtualMachineStateRunning: - return VMStateRunning - case vz.VirtualMachineStateStopped: - return VMStateStopped - default: - return VMStateError - } -} - -// buildVM creates a Darwin VM configured and ready to start. -// macAddr is a stable MAC address string; if empty, one is generated. -// epoch overrides lnx.epoch in the kernel cmdline (0 = use current time). -func buildVM(cfg *Config, initrdPath, cwd, swapPath, criuPath, homeDir, macAddr string, epoch int64) (VirtualMachine, error) { - if qemuBin := parseQemuBackend(); qemuBin != "" { - return buildQemuVM(cfg, qemuBin, initrdPath, swapPath, criuPath, macAddr, epoch) - } - - vmConfig, err := buildVMConfig(cfg, initrdPath, cwd, swapPath, criuPath, homeDir, macAddr, epoch) - if err != nil { - return nil, err - } - - vm, err := vz.NewVirtualMachine(vmConfig) - if err != nil { - return nil, fmt.Errorf("create vm: %w", err) - } - - socketDevices := vm.SocketDevices() - if len(socketDevices) == 0 { - return nil, fmt.Errorf("no vsock devices") - } - - return &darwinVM{ - vm: vm, - sock: &vzVsockDevice{dev: socketDevices[0]}, - }, nil -} - -// buildVMConfig creates the VZ VM configuration. -func buildVMConfig(cfg *Config, initrdPath, cwd, swapPath, criuPath, homeDir, macAddr string, epoch int64) (*vz.VirtualMachineConfiguration, error) { - if epoch == 0 { - epoch = time.Now().Unix() - } - cmdline := fmt.Sprintf("console=hvc0 lnx.epoch=%d", epoch) - - bootLoader, err := vz.NewLinuxBootLoader( - cfg.KernelPath, - vz.WithCommandLine(cmdline), - vz.WithInitrd(initrdPath), - ) - if err != nil { - return nil, fmt.Errorf("boot loader: %w", err) - } - - vmConfig, err := vz.NewVirtualMachineConfiguration(bootLoader, cfg.cpus(), cfg.memoryBytes()) - if err != nil { - return nil, fmt.Errorf("vm config: %w", err) - } - - if vz.IsNestedVirtualizationSupported() { - platform, err := vz.NewGenericPlatformConfiguration() - if err != nil { - return nil, fmt.Errorf("platform config: %w", err) - } - if err := platform.SetNestedVirtualizationEnabled(true); err != nil { - return nil, fmt.Errorf("enable nested virtualization: %w", err) - } - vmConfig.SetPlatformVirtualMachineConfiguration(platform) - } - - if err := attachDisks(vmConfig, cfg.RootfsPath, swapPath, criuPath, cfg.NestedRootfs); err != nil { - return nil, err - } - - for _, attach := range []func(*vz.VirtualMachineConfiguration) error{ - func(c *vz.VirtualMachineConfiguration) error { - return attachSerialConsole(c, cfg.socketDir()) - }, - attachNetwork, - attachMisc, - } { - if err := attach(vmConfig); err != nil { - return nil, err - } - } - - vsockConfig, err := vz.NewVirtioSocketDeviceConfiguration() - if err != nil { - return nil, fmt.Errorf("vsock config: %w", err) - } - vmConfig.SetSocketDevicesVirtualMachineConfiguration([]vz.SocketDeviceConfiguration{vsockConfig}) - - if ok, err := vmConfig.Validate(); !ok || err != nil { - return nil, fmt.Errorf("validate config: %w", err) - } - - return vmConfig, nil -} - -// shutdownVM gracefully stops a Darwin VM. -func shutdownVM(vm VirtualMachine, exitCode int) { - if exitCode == 130 { - vm.Stop() - return - } - - vm.RequestStop() - stateCh := vm.StateChangedNotify() - select { - case <-time.After(3 * time.Second): - vm.Stop() - case state := <-stateCh: - if state != VMStateStopped { - vm.Stop() - } - } -} diff --git a/old/vm_intg_test.go b/old/vm_intg_test.go deleted file mode 100644 index 74de87b..0000000 --- a/old/vm_intg_test.go +++ /dev/null @@ -1,77 +0,0 @@ -//go:build darwin && integration - -package lnx_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/semistrict/lnx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRun_EchoHello(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - exitCode, err := lnx.Run(testConfig(dir), "echo", "hello") - require.NoError(t, err) - assert.Equal(t, 0, exitCode) -} - -func TestRun_ExitCodeZero(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - exitCode, err := lnx.Run(testConfig(dir), "true") - require.NoError(t, err) - assert.Equal(t, 0, exitCode) -} - -func TestRun_ExitCodeOne(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - exitCode, err := lnx.Run(testConfig(dir), "false") - require.NoError(t, err) - assert.Equal(t, 1, exitCode) -} - -func TestRun_ExitCodeCustom(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - exitCode, err := lnx.Run(testConfig(dir), "sh", "-c", "exit 42") - require.NoError(t, err) - assert.Equal(t, 42, exitCode) -} - -func TestRun_OnlineResize(t *testing.T) { - t.Parallel() - dir := setupTestDir(t) - cfg := testConfig(dir) - - f, err := os.OpenFile(cfg.RootfsPath, os.O_RDWR, 0) - require.NoError(t, err) - require.NoError(t, f.Truncate(8*1024*1024*1024)) - require.NoError(t, f.Close()) - - exitCode, err := lnx.Run(cfg, "sh", "-c", "df -BG / | tail -1 | awk '{print $2}'") - require.NoError(t, err) - assert.Equal(t, 0, exitCode) - - logBytes, err := os.ReadFile(filepath.Join(dir, "lnx.log")) - require.NoError(t, err) - logStr := string(logBytes) - assert.Contains(t, logStr, "resize2fs") - assert.NotContains(t, logStr, "Nothing to do") -} - -func TestRun_MissingKernel(t *testing.T) { - t.Parallel() - lnx.InitBinary = []byte("fake") - _, err := lnx.Run(&lnx.Config{ - KernelPath: "/nonexistent/vmlinuz", - RootfsPath: "/nonexistent/rootfs.ext4", - }, "echo", "hello") - require.Error(t, err) - require.Contains(t, err.Error(), "not found") -} diff --git a/old/vm_linux.go b/old/vm_linux.go deleted file mode 100644 index e41f529..0000000 --- a/old/vm_linux.go +++ /dev/null @@ -1,362 +0,0 @@ -//go:build linux - -package lnx - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "time" -) - -// firecrackerVM implements VirtualMachine by managing a Firecracker process -// configured via its REST API. -type firecrackerVM struct { - cmd *exec.Cmd - apiSock string - vsock *firecrackerVsock - sockDir string - - stateCh chan VMState - once sync.Once - done chan struct{} // closed when the process exits -} - -// buildVM creates and configures a Firecracker VM ready to start. -// Requires LNX_EXPERIMENTS=linux_host (experimental feature). -func buildVM(cfg *Config, initrdPath, cwd, swapPath, homeDir string) (VirtualMachine, error) { - if !linuxHostEnabled() { - return nil, fmt.Errorf("Linux host support is experimental; set LNX_EXPERIMENTS=linux_host to enable") - } - - // Firecracker sockets must be on a local filesystem (9P doesn't support - // Unix domain sockets). Override socketDir to /var/run/lnx/. - sockDir := filepath.Join("/var/run/lnx", filepath.Base(cfg.socketDir())) - os.MkdirAll(sockDir, 0755) - cfg.SocketDir = sockDir - - apiSock := filepath.Join(sockDir, "firecracker.sock") - vsockPath := filepath.Join(sockDir, "vsock") - - // Clean up stale sockets. - os.Remove(apiSock) - os.Remove(vsockPath) - - // Set up TAP networking (requires root/CAP_NET_ADMIN). - if err := setupTAP(); err != nil { - return nil, fmt.Errorf("setup TAP: %w", err) - } - - // Set up serial console log file. - serialPath := filepath.Join(sockDir, "serial.log") - serialFile, err := os.Create(serialPath) - if err != nil { - return nil, fmt.Errorf("create serial log: %w", err) - } - - fcBin := findFirecracker() - cmd := exec.Command(fcBin, "--api-sock", apiSock) - cmd.Stderr = serialFile - cmd.Stdout = serialFile - - if err := cmd.Start(); err != nil { - serialFile.Close() - return nil, fmt.Errorf("start firecracker: %w", err) - } - serialFile.Close() - - vm := &firecrackerVM{ - cmd: cmd, - apiSock: apiSock, - vsock: newFirecrackerVsock(vsockPath), - sockDir: sockDir, - stateCh: make(chan VMState, 8), - done: make(chan struct{}), - } - - // Monitor process exit. - go func() { - cmd.Wait() - vm.stateCh <- VMStateStopped - close(vm.done) - }() - - // Wait for the API socket to appear. - if err := waitForSocket(apiSock, 5*time.Second); err != nil { - cmd.Process.Kill() - return nil, fmt.Errorf("firecracker API socket: %w", err) - } - - // Configure the VM via the Firecracker REST API. - if err := vm.configure(cfg, initrdPath, cwd, swapPath, homeDir, vsockPath); err != nil { - cmd.Process.Kill() - return nil, fmt.Errorf("configure firecracker: %w", err) - } - - return vm, nil -} - -func (vm *firecrackerVM) configure(cfg *Config, initrdPath, cwd, swapPath, homeDir, vsockPath string) error { - cmdline := fmt.Sprintf("console=ttyS0 lnx.epoch=%d reboot=k panic=1", time.Now().Unix()) - - // 1. Boot source. - if err := vm.apiPut("/boot-source", map[string]any{ - "kernel_image_path": cfg.KernelPath, - "initrd_path": initrdPath, - "boot_args": cmdline, - }); err != nil { - return fmt.Errorf("boot source: %w", err) - } - - // 2. Machine config. - if err := vm.apiPut("/machine-config", map[string]any{ - "vcpu_count": cfg.cpus(), - "mem_size_mib": cfg.memoryBytes() / (1024 * 1024), - }); err != nil { - return fmt.Errorf("machine config: %w", err) - } - - // 3. Root drive. - if err := vm.apiPut("/drives/rootfs", map[string]any{ - "drive_id": "rootfs", - "path_on_host": cfg.RootfsPath, - "is_root_device": true, - "is_read_only": false, - }); err != nil { - return fmt.Errorf("root drive: %w", err) - } - - // 4. Swap drive. - if err := vm.apiPut("/drives/swap", map[string]any{ - "drive_id": "swap", - "path_on_host": swapPath, - "is_root_device": false, - "is_read_only": false, - }); err != nil { - return fmt.Errorf("swap drive: %w", err) - } - - // 5. Nested instance rootfs drives. - for i, nr := range cfg.NestedRootfs { - driveID := fmt.Sprintf("nested%d", i) - if err := vm.apiPut("/drives/"+driveID, map[string]any{ - "drive_id": driveID, - "path_on_host": nr.RootfsPath, - "is_root_device": false, - "is_read_only": false, - }); err != nil { - return fmt.Errorf("nested drive %s: %w", nr.InstanceName, err) - } - } - - // 6. Network interface. - if err := vm.apiPut("/network-interfaces/eth0", map[string]any{ - "iface_id": "eth0", - "host_dev_name": "lnxtap0", - "guest_mac": "06:00:AC:10:00:02", - }); err != nil { - return fmt.Errorf("network interface: %w", err) - } - - // 7. Vsock device. - if err := vm.apiPut("/vsock", map[string]any{ - "guest_cid": 3, - "uds_path": vsockPath, - }); err != nil { - return fmt.Errorf("vsock: %w", err) - } - - return nil -} - -func (vm *firecrackerVM) Start() error { - if err := vm.apiPut("/actions", map[string]any{ - "action_type": "InstanceStart", - }); err != nil { - return fmt.Errorf("start instance: %w", err) - } - vm.stateCh <- VMStateRunning - return nil -} - -func (vm *firecrackerVM) Stop() error { - vm.once.Do(func() { - // Try graceful shutdown via API first. - err := vm.apiPut("/actions", map[string]any{ - "action_type": "SendCtrlAltDel", - }) - if err != nil { - slog.Debug("SendCtrlAltDel failed, killing process", "error", err) - } - - // Give it a moment, then force kill. - select { - case <-vm.done: - return - case <-time.After(3 * time.Second): - vm.cmd.Process.Kill() - } - }) - - <-vm.done - vm.vsock.cleanup() - teardownTAP() - os.Remove(vm.apiSock) - return nil -} - -func (vm *firecrackerVM) RequestStop() error { - return vm.apiPut("/actions", map[string]any{ - "action_type": "SendCtrlAltDel", - }) -} - -func (vm *firecrackerVM) StateChangedNotify() <-chan VMState { - return vm.stateCh -} - -func (vm *firecrackerVM) VsockDevice() VsockDevice { - return vm.vsock -} - -// shutdownVM gracefully shuts down a Firecracker VM. -func shutdownVM(vm VirtualMachine, exitCode int) { - if exitCode == 130 { - vm.Stop() - return - } - - vm.RequestStop() - select { - case <-time.After(3 * time.Second): - vm.Stop() - case state := <-vm.StateChangedNotify(): - if state != VMStateStopped { - vm.Stop() - } - } -} - -// apiPut sends a PUT request to the Firecracker API. -func (vm *firecrackerVM) apiPut(path string, body any) error { - data, err := json.Marshal(body) - if err != nil { - return err - } - - client := &http.Client{ - Transport: &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", vm.apiSock) - }, - }, - } - - req, err := http.NewRequest(http.MethodPut, "http://localhost"+path, bytes.NewReader(data)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode >= 300 { - var apiErr struct { - FaultMessage string `json:"fault_message"` - } - json.NewDecoder(resp.Body).Decode(&apiErr) - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, apiErr.FaultMessage) - } - return nil -} - -func linuxHostEnabled() bool { - for _, exp := range strings.Split(os.Getenv("LNX_EXPERIMENTS"), ",") { - if strings.TrimSpace(exp) == "linux_host" { - return true - } - } - return false -} - -// findFirecracker returns the path to the firecracker binary. -// Checks PATH first, then ~/.lnx/bin/. If found on a 9P mount (which -// doesn't support mmap/exec), copies to a local cache first. -func findFirecracker() string { - // Prefer a binary already on local storage. - if path, err := exec.LookPath("firecracker"); err == nil { - return path - } - - home, err := os.UserHomeDir() - if err != nil { - return "firecracker" - } - - lnxBin := filepath.Join(home, ".lnx", "bin", "firecracker") - if _, err := os.Stat(lnxBin); err != nil { - return "firecracker" - } - - // The binary might be on a 9P mount which doesn't support exec. - // Copy to local storage (/var/cache/lnx, always on ext4) if needed. - localCache := "/var/cache/lnx" - os.MkdirAll(localCache, 0755) - localBin := filepath.Join(localCache, "firecracker") - - // Check if cached copy exists and matches size. - srcInfo, _ := os.Stat(lnxBin) - dstInfo, dstErr := os.Stat(localBin) - if dstErr == nil && dstInfo.Size() == srcInfo.Size() { - return localBin - } - - // Copy to local cache. - src, err := os.Open(lnxBin) - if err != nil { - return lnxBin // best effort - } - defer src.Close() - - dst, err := os.OpenFile(localBin, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) - if err != nil { - return lnxBin - } - defer dst.Close() - - if _, err := io.Copy(dst, src); err != nil { - os.Remove(localBin) - return lnxBin - } - - slog.Debug("cached firecracker binary locally", "src", lnxBin, "dst", localBin) - return localBin -} - -// waitForSocket polls for a Unix socket to appear. -func waitForSocket(path string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - conn, err := net.Dial("unix", path) - if err == nil { - conn.Close() - return nil - } - time.Sleep(10 * time.Millisecond) - } - return fmt.Errorf("socket %s did not appear within %v", path, timeout) -} diff --git a/old/vm_qemu_darwin.go b/old/vm_qemu_darwin.go deleted file mode 100644 index 3464e11..0000000 --- a/old/vm_qemu_darwin.go +++ /dev/null @@ -1,577 +0,0 @@ -//go:build darwin - -package lnx - -import ( - "encoding/json" - "fmt" - "log/slog" - "net" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "syscall" - "time" - - "github.com/semistrict/lnx/internal/lnxnet" -) - -// qemuVM implements VirtualMachine by managing a QEMU process. -type qemuVM struct { - cmd *exec.Cmd - vsock *qemuVsock - bridge *lnxnet.Bridge - sockDir string - qmpSock string // QMP Unix socket path for control commands - logFile *os.File - ramClone string // CoW clone of ram.img to remove on shutdown (empty if base) - restored bool // VM was restored from migration snapshot (fork) - - // ramPath is the active RAM memory-backend-file path. - // May be ram-EPOCH.img (a CoW clone of the base ram.img). - ramPath string - - stateCh chan VMState - once sync.Once - done chan struct{} -} - -func (q *qemuVM) Start() error { - if q.restored { - // VM is already running after migration restore; cont is unnecessary. - q.stateCh <- VMStateRunning - return nil - } - // QEMU was started with -S (paused). Resume via QMP. - if err := qmpCommand(q.qmpSock, "cont"); err != nil { - return fmt.Errorf("qemu resume: %w", err) - } - q.stateCh <- VMStateRunning - return nil -} - -// IsRestored reports whether this VM was restored from a migration snapshot. -func (q *qemuVM) IsRestored() bool { - return q.restored -} - -func (q *qemuVM) Stop() error { - q.once.Do(func() { - q.cmd.Process.Signal(syscall.SIGTERM) - select { - case <-q.done: - return - case <-time.After(3 * time.Second): - q.cmd.Process.Kill() - } - }) - <-q.done - q.vsock.cleanup() - if q.bridge != nil { - q.bridge.Close() - } - os.Remove(q.qmpSock) - if q.ramClone != "" { - os.Remove(q.ramClone) - } - if q.logFile != nil { - q.logFile.Close() - } - return nil -} - -func (q *qemuVM) RequestStop() error { - return q.cmd.Process.Signal(syscall.SIGTERM) -} - -func (q *qemuVM) StateChangedNotify() <-chan VMState { - return q.stateCh -} - -// QMPSock returns the path to the QMP Unix socket. -func (q *qemuVM) QMPSock() string { return q.qmpSock } - -// RamPath returns the active RAM memory-backend-file path. -// May be ram-EPOCH.img (a CoW clone of the base ram.img). -func (q *qemuVM) RamPath() string { return q.ramPath } - -// Resume sends a QMP cont command to unpause the VM. -func (q *qemuVM) Resume() error { return qmpCommand(q.qmpSock, "cont") } - -// QMPResume sends a cont command to the QEMU VM at the given QMP socket. -// Exported for integration tests that call ForkQemuVM directly. -func QMPResume(qmpSock string) error { return qmpCommand(qmpSock, "cont") } - -func (q *qemuVM) VsockDevice() VsockDevice { - return q.vsock -} - -// ParseQemuBackend returns the QEMU binary path from LNX_BACKEND, -// or empty string if LNX_BACKEND is not set to qemu. -func ParseQemuBackend() string { - return parseQemuBackend() -} - -func parseQemuBackend() string { - val := os.Getenv("LNX_BACKEND") - if val == "" { - return "" - } - if val == "qemu" { - if p, err := exec.LookPath("qemu-system-aarch64"); err == nil { - return p - } - return "qemu-system-aarch64" - } - if after, ok := strings.CutPrefix(val, "qemu:"); ok { - return after - } - return "" -} - -func buildQemuVM(cfg *Config, qemuBin, initrdPath, swapPath, criuPath, macAddr string, epoch int64) (VirtualMachine, error) { - sockDir := cfg.socketDir() - - vsockPath := filepath.Join(sockDir, "qemu-vsock") - serialPath := filepath.Join(sockDir, "serial.log") - qmpPath := filepath.Join(sockDir, "qmp.sock") - - os.Remove(vsockPath) - os.Remove(qmpPath) - - cmdline := fmt.Sprintf("console=ttyAMA0 lnx.epoch=%d", epoch) - - memMB := cfg.memoryBytes() / (1024 * 1024) - - // RAM file lives alongside rootfs so ephemeral mode's cleanup covers it. - // If a ram.img from a previous boot exists, clonefile it (CoW) so this - // boot starts with a private copy that shares pages until written. - rootfsDir := filepath.Dir(cfg.RootfsPath) - baseRAM := filepath.Join(rootfsDir, "ram.img") - ramPath := baseRAM - if _, err := os.Stat(baseRAM); err == nil { - ramPath = filepath.Join(rootfsDir, fmt.Sprintf("ram-%d.img", epoch)) - if err := cloneFile(baseRAM, ramPath); err != nil { - return nil, fmt.Errorf("clone ram: %w", err) - } - slog.Debug("cloned ram", "src", baseRAM, "dst", ramPath) - } - - args := []string{ - "-machine", "virt,accel=hvf,memory-backend=mem0", - "-cpu", "host", - "-object", fmt.Sprintf("memory-backend-file,id=mem0,size=%dM,mem-path=%s,share=on", memMB, ramPath), - "-smp", fmt.Sprintf("%d", cfg.cpus()), - "-kernel", cfg.KernelPath, - "-initrd", initrdPath, - "-append", cmdline, - // Rootfs, swap, criu — same vda/vdb/vdc order as vz backend. - "-drive", fmt.Sprintf("file=%s,format=raw,if=virtio", cfg.RootfsPath), - "-drive", fmt.Sprintf("file=%s,format=raw,if=virtio", swapPath), - "-drive", fmt.Sprintf("file=%s,format=raw,if=virtio", criuPath), - } - - // Nested rootfs drives (vdd, vde, ...). - for _, nr := range cfg.NestedRootfs { - args = append(args, - "-drive", fmt.Sprintf("file=%s,format=raw,if=virtio", nr.RootfsPath), - ) - } - - // Vsock. - args = append(args, - "-device", fmt.Sprintf("virtio-vsock-pci,guest-cid=3,socket-path=%s", vsockPath), - ) - - // Userspace networking via socketpair + lnxnet.Bridge. - // Create a Unix datagram socketpair: hostFd for the bridge, vmFd for QEMU. - netFds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_DGRAM, 0) - if err != nil { - return nil, fmt.Errorf("net socketpair: %w", err) - } - for _, fd := range netFds { - syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, 1*1024*1024) - syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, 4*1024*1024) - } - hostNetFd, vmNetFd := netFds[0], netFds[1] - - // vmNetFd is passed to QEMU via ExtraFiles. ExtraFiles[0] = fd 3 in child. - vmNetFile := os.NewFile(uintptr(vmNetFd), "qemu-net") - // QEMU's -netdev socket,fd=3 connects virtio-net to this datagram socket. - netDev := "virtio-net-pci,netdev=net0" - if macAddr != "" { - netDev += ",mac=" + macAddr - } - args = append(args, - "-netdev", "socket,id=net0,fd=3", - "-device", netDev, - ) - - // Serial console, QMP monitor, display. - // Check for incoming migration state (VM fork). - incomingFile := filepath.Join(rootfsDir, "incoming.bin") - isRestore := false - if _, err := os.Stat(incomingFile); err == nil { - isRestore = true - } - - args = append(args, - "-serial", "file:"+serialPath, - "-qmp", fmt.Sprintf("unix:%s,server=on,wait=off", qmpPath), - "-monitor", "none", - "-nographic", - "-no-reboot", - ) - if isRestore { - args = append(args, "-incoming", "defer") - } else { - args = append(args, "-S") // Start paused; resumed by Start() after vsock listeners are ready. - } - - slog.Debug("starting qemu", "bin", qemuBin, "args", args) - - qemuLogPath := filepath.Join(sockDir, "qemu.log") - qemuLog, err := os.OpenFile(qemuLogPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - syscall.Close(hostNetFd) - vmNetFile.Close() - return nil, fmt.Errorf("create qemu log: %w", err) - } - - bridge := lnxnet.NewBridgeFromFd(hostNetFd) - - cmd := exec.Command(qemuBin, args...) - cmd.Stdout = qemuLog - cmd.Stderr = qemuLog - cmd.ExtraFiles = []*os.File{vmNetFile} // fd 3 in child - - if err := cmd.Start(); err != nil { - qemuLog.Close() - bridge.Close() - vmNetFile.Close() - return nil, fmt.Errorf("start qemu: %w", err) - } - // Child has its own copy of vmNetFd via ExtraFiles; close the parent's. - vmNetFile.Close() - // Start the userspace network bridge (ARP, DHCP, TCP/UDP relay). - bridge.Start() - - var ramClonePath string - if ramPath != baseRAM { - ramClonePath = ramPath - } - - vm := &qemuVM{ - cmd: cmd, - vsock: newQemuVsock(vsockPath), - bridge: bridge, - sockDir: sockDir, - qmpSock: qmpPath, - logFile: qemuLog, - ramClone: ramClonePath, - restored: isRestore, - ramPath: ramPath, - stateCh: make(chan VMState, 8), - done: make(chan struct{}), - } - - go func() { - cmd.Wait() - vm.stateCh <- VMStateStopped - close(vm.done) - }() - - // Wait for QMP socket and negotiate capabilities. - if err := qmpWaitAndHandshake(qmpPath, 5*time.Second); err != nil { - cmd.Process.Kill() - return nil, fmt.Errorf("qemu qmp: %w\n%s", err, qemuLogTail(sockDir)) - } - - // For restore: set CPR-reboot mode + x-ignore-shared, then load state. - if isRestore { - if err := qmpRestore(qmpPath, incomingFile); err != nil { - cmd.Process.Kill() - return nil, fmt.Errorf("qemu restore: %w\n%s", err, qemuLogTail(sockDir)) - } - os.Remove(incomingFile) // consumed; prevent re-restore on future boots - } - - return vm, nil -} - -func qmpRestore(sockPath, stateFile string) error { - conn, err := net.Dial("unix", sockPath) - if err != nil { - return err - } - defer conn.Close() - conn.SetDeadline(time.Now().Add(30 * time.Second)) - - buf := make([]byte, 8192) - conn.Read(buf) // greeting - conn.Write([]byte(`{"execute":"qmp_capabilities"}` + "\n")) - conn.Read(buf) - - // Set CPR-reboot mode and x-ignore-shared. - conn.Write([]byte(`{"execute":"migrate-set-parameters","arguments":{"mode":"cpr-reboot"}}` + "\n")) - time.Sleep(100 * time.Millisecond) - conn.Read(buf) - - conn.Write([]byte(`{"execute":"migrate-set-capabilities","arguments":{"capabilities":[{"capability":"x-ignore-shared","state":true}]}}` + "\n")) - time.Sleep(100 * time.Millisecond) - conn.Read(buf) - - // Trigger incoming migration. - migrateCmd, _ := json.Marshal(map[string]any{ - "execute": "migrate-incoming", - "arguments": map[string]string{"uri": "file:" + stateFile}, - }) - conn.Write(append(migrateCmd, '\n')) - - // Wait for restore to complete. - for i := 0; i < 60; i++ { - time.Sleep(500 * time.Millisecond) - conn.Write([]byte(`{"execute":"query-migrate"}` + "\n")) - n, err := conn.Read(buf) - if err != nil { - return fmt.Errorf("query-migrate: %w", err) - } - resp := string(buf[:n]) - if strings.Contains(resp, `"completed"`) { - // Resume the VM — it's paused after migration. - conn.Write([]byte(`{"execute":"cont"}` + "\n")) - conn.Read(buf) - return nil - } - if strings.Contains(resp, `"failed"`) { - return fmt.Errorf("restore failed: %s", resp) - } - } - return fmt.Errorf("restore timed out") -} - -// waitForUnixSocket polls until a Unix socket appears and is connectable. -func waitForUnixSocket(path string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - conn, err := net.Dial("unix", path) - if err == nil { - conn.Close() - return nil - } - time.Sleep(50 * time.Millisecond) - } - return fmt.Errorf("socket %s did not appear within %v", path, timeout) -} - -// qmpWaitAndHandshake waits for the QMP socket to appear, connects, reads -// the greeting, and sends qmp_capabilities to enter command mode. -func qmpWaitAndHandshake(sockPath string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - var conn net.Conn - var err error - for time.Now().Before(deadline) { - conn, err = net.Dial("unix", sockPath) - if err == nil { - break - } - time.Sleep(50 * time.Millisecond) - } - if err != nil { - return fmt.Errorf("connect %s: %w", sockPath, err) - } - defer conn.Close() - - conn.SetDeadline(deadline) - - // Read greeting. - buf := make([]byte, 4096) - n, err := conn.Read(buf) - if err != nil { - return fmt.Errorf("read greeting: %w", err) - } - slog.Debug("qmp greeting", "data", string(buf[:n])) - - // Send qmp_capabilities. - if _, err := conn.Write([]byte(`{"execute":"qmp_capabilities"}` + "\n")); err != nil { - return fmt.Errorf("send qmp_capabilities: %w", err) - } - n, err = conn.Read(buf) - if err != nil { - return fmt.Errorf("read qmp_capabilities response: %w", err) - } - slog.Debug("qmp capabilities response", "data", string(buf[:n])) - return nil -} - -// qmpCommand sends a simple QMP command (no arguments). -func qmpCommand(sockPath, command string) error { - conn, err := net.Dial("unix", sockPath) - if err != nil { - return err - } - defer conn.Close() - - conn.SetDeadline(time.Now().Add(5 * time.Second)) - - // Read greeting. - buf := make([]byte, 4096) - if _, err := conn.Read(buf); err != nil { - return fmt.Errorf("read greeting: %w", err) - } - - // Send qmp_capabilities (required before any command). - if _, err := conn.Write([]byte(`{"execute":"qmp_capabilities"}` + "\n")); err != nil { - return err - } - if _, err := conn.Read(buf); err != nil { - return fmt.Errorf("read capabilities response: %w", err) - } - - // Send the actual command. - msg, _ := json.Marshal(map[string]string{"execute": command}) - if _, err := conn.Write(append(msg, '\n')); err != nil { - return err - } - - // Read response. - n, err := conn.Read(buf) - if err != nil { - return fmt.Errorf("read %s response: %w", command, err) - } - - resp := string(buf[:n]) - if strings.Contains(resp, `"error"`) { - return fmt.Errorf("qmp %s: %s", command, resp) - } - return nil -} - -func qemuLogTail(dir string) string { - data, err := os.ReadFile(filepath.Join(dir, "qemu.log")) - if err != nil || len(data) == 0 { - return "qemu.log: (not available)" - } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - const maxLines = 20 - if len(lines) > maxLines { - lines = lines[len(lines)-maxLines:] - } - return "qemu.log:\n" + strings.Join(lines, "\n") -} - -func init() { - forkQemuVMFunc = ForkQemuVM -} - -// ForkQemuVM snapshots a running QEMU VM and creates a clone that can be -// booted with RunDaemon. The clone's rootfs and RAM are APFS clonefiles -// (CoW) of the original. The VM is left paused — the caller must resume -// it with QMP cont after any additional fixups (e.g. cloning the active -// RAM file). -// -// Returns (true, nil) if QEMU exited during migration (no resume needed), -// or (false, nil) if the VM is still alive and paused. -// -// qmpSock is the path to the running VM's QMP socket. -// srcDir contains rootfs.ext4 and ram.img. -// dstDir receives the cloned files (rootfs, ram, incoming.bin, vmlinuz). -func ForkQemuVM(qmpSock, srcDir, dstDir string) (exited bool, err error) { - - // Use a single QMP session for stop + migrate. - conn, err := net.Dial("unix", qmpSock) - if err != nil { - return false, fmt.Errorf("qmp connect: %w", err) - } - defer conn.Close() - conn.SetDeadline(time.Now().Add(30 * time.Second)) - - buf := make([]byte, 8192) - conn.Read(buf) // greeting - conn.Write([]byte(`{"execute":"qmp_capabilities"}` + "\n")) - conn.Read(buf) - - // 1. Pause the VM. - conn.Write([]byte(`{"execute":"stop"}` + "\n")) - time.Sleep(200 * time.Millisecond) - conn.Read(buf) - - // 2. Save CPU/device state via QMP migrate (CPR-reboot mode). - // x-ignore-shared skips RAM (already in the shared memory-backend-file). - // CPR-reboot mode preserves the VM state for restart — the VM stays - // paused after migration and the caller resumes it. - stateFile := filepath.Join(dstDir, "incoming.bin") - - conn.Write([]byte(`{"execute":"migrate-set-capabilities","arguments":{"capabilities":[{"capability":"x-ignore-shared","state":true}]}}` + "\n")) - time.Sleep(100 * time.Millisecond) - conn.Read(buf) - - conn.Write([]byte(`{"execute":"migrate-set-parameters","arguments":{"mode":"cpr-reboot"}}` + "\n")) - time.Sleep(100 * time.Millisecond) - conn.Read(buf) - - migrateCmd, _ := json.Marshal(map[string]any{ - "execute": "migrate", - "arguments": map[string]string{"uri": "file:" + stateFile}, - }) - conn.Write(append(migrateCmd, '\n')) - time.Sleep(200 * time.Millisecond) - conn.Read(buf) - - // Wait for migration to complete. QEMU may exit after migration - // depending on state (e.g. active vsock connections). If the QMP - // connection dies, check whether the state file was written. - qemuExited := false - for i := 0; i < 60; i++ { - time.Sleep(500 * time.Millisecond) - conn.SetDeadline(time.Now().Add(5 * time.Second)) - conn.Write([]byte(`{"execute":"query-migrate"}` + "\n")) - n, err := conn.Read(buf) - if err != nil { - // QMP connection died. Verify the state file was written. - for j := 0; j < 10; j++ { - if info, statErr := os.Stat(stateFile); statErr == nil && info.Size() > 0 { - qemuExited = true - goto migrated - } - time.Sleep(100 * time.Millisecond) - } - return false, fmt.Errorf("query-migrate: %w", err) - } - resp := string(buf[:n]) - if strings.Contains(resp, `"completed"`) { - goto migrated - } - if strings.Contains(resp, `"failed"`) { - conn.Write([]byte(`{"execute":"cont"}` + "\n")) - return false, fmt.Errorf("migration failed: %s", resp) - } - } - conn.Write([]byte(`{"execute":"cont"}` + "\n")) - return false, fmt.Errorf("migration timed out") - -migrated: - // 3. Clone rootfs + ram via APFS clonefile. - for _, name := range []string{"rootfs.ext4", "ram.img"} { - src := filepath.Join(srcDir, name) - if _, err := os.Stat(src); err != nil { - continue - } - if err := cloneFile(src, filepath.Join(dstDir, name)); err != nil { - if !qemuExited { - conn.Write([]byte(`{"execute":"cont"}` + "\n")) - } - return qemuExited, fmt.Errorf("clone %s: %w", name, err) - } - } - - // 4. Copy kernel symlink. - if target, err := os.Readlink(filepath.Join(srcDir, "vmlinuz")); err == nil { - os.Symlink(target, filepath.Join(dstDir, "vmlinuz")) - } - - // VM is left paused (unless QEMU exited) — caller sends cont after fixups. - return qemuExited, nil -} diff --git a/old/vsock_darwin.go b/old/vsock_darwin.go deleted file mode 100644 index e82ead2..0000000 --- a/old/vsock_darwin.go +++ /dev/null @@ -1,22 +0,0 @@ -//go:build darwin - -package lnx - -import ( - "net" - - vz "github.com/Code-Hex/vz/v3" -) - -// vzVsockDevice wraps *vz.VirtioSocketDevice to implement VsockDevice. -type vzVsockDevice struct { - dev *vz.VirtioSocketDevice -} - -func (v *vzVsockDevice) Listen(port uint32) (net.Listener, error) { - return v.dev.Listen(port) -} - -func (v *vzVsockDevice) Connect(port uint32) (net.Conn, error) { - return v.dev.Connect(port) -} diff --git a/old/vsock_linux.go b/old/vsock_linux.go deleted file mode 100644 index a20f390..0000000 --- a/old/vsock_linux.go +++ /dev/null @@ -1,119 +0,0 @@ -//go:build linux - -package lnx - -import ( - "bufio" - "fmt" - "net" - "os" - "strings" - "sync" -) - -// firecrackerVsock implements VsockDevice using Firecracker's hybrid vsock -// Unix domain socket protocol. -// -// Guest → Host (Listen): Firecracker creates a connection to a Unix socket at -// _ when the guest connects to CID 2 on that port. -// -// Host → Guest (Connect): The host dials , sends "CONNECT \n", -// and reads "OK \n" to establish a connection to the guest. -type firecrackerVsock struct { - udsPath string - - mu sync.Mutex - listeners map[uint32]*fcVsockListener -} - -func newFirecrackerVsock(udsPath string) *firecrackerVsock { - return &firecrackerVsock{ - udsPath: udsPath, - listeners: make(map[uint32]*fcVsockListener), - } -} - -func (f *firecrackerVsock) Listen(port uint32) (net.Listener, error) { - sockPath := fmt.Sprintf("%s_%d", f.udsPath, port) - - // Remove any stale socket file. - os.Remove(sockPath) - - ln, err := net.Listen("unix", sockPath) - if err != nil { - return nil, fmt.Errorf("listen %s: %w", sockPath, err) - } - - fcl := &fcVsockListener{ - Listener: ln, - sockPath: sockPath, - } - - f.mu.Lock() - f.listeners[port] = fcl - f.mu.Unlock() - - return fcl, nil -} - -func (f *firecrackerVsock) Connect(port uint32) (net.Conn, error) { - conn, err := net.Dial("unix", f.udsPath) - if err != nil { - return nil, fmt.Errorf("dial vsock %s: %w", f.udsPath, err) - } - - // Send CONNECT request. - if _, err := fmt.Fprintf(conn, "CONNECT %d\n", port); err != nil { - conn.Close() - return nil, fmt.Errorf("vsock connect handshake write: %w", err) - } - - // Read OK response. Firecracker responds with "OK \n" where - // the port may differ from what was requested (it's the guest-side port). - reader := bufio.NewReader(conn) - line, err := reader.ReadString('\n') - if err != nil { - conn.Close() - return nil, fmt.Errorf("vsock connect handshake read: %w", err) - } - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "OK ") { - conn.Close() - return nil, fmt.Errorf("vsock connect: expected OK response, got %q", line) - } - - // Wrap to handle any buffered data in the reader. - return &bufferedConn{Conn: conn, reader: reader}, nil -} - -// cleanup removes all listener socket files. -func (f *firecrackerVsock) cleanup() { - f.mu.Lock() - defer f.mu.Unlock() - for _, l := range f.listeners { - l.Close() - } -} - -// fcVsockListener wraps a net.Listener and cleans up the socket file on close. -type fcVsockListener struct { - net.Listener - sockPath string -} - -func (l *fcVsockListener) Close() error { - err := l.Listener.Close() - os.Remove(l.sockPath) - return err -} - -// bufferedConn wraps a net.Conn with a bufio.Reader so that any data -// buffered during the handshake isn't lost. -type bufferedConn struct { - net.Conn - reader *bufio.Reader -} - -func (c *bufferedConn) Read(p []byte) (int, error) { - return c.reader.Read(p) -} diff --git a/old/vsock_linux_test.go b/old/vsock_linux_test.go deleted file mode 100644 index aeac0cf..0000000 --- a/old/vsock_linux_test.go +++ /dev/null @@ -1,105 +0,0 @@ -//go:build linux - -package lnx - -import ( - "fmt" - "net" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFirecrackerVsock_Listen(t *testing.T) { - dir := t.TempDir() - udsPath := filepath.Join(dir, "vsock") - - vsock := newFirecrackerVsock(udsPath) - - ln, err := vsock.Listen(1024) - require.NoError(t, err) - defer ln.Close() - - // The socket file should exist at udsPath_1024. - sockPath := fmt.Sprintf("%s_%d", udsPath, 1024) - _, err = os.Stat(sockPath) - assert.NoError(t, err) -} - -func TestFirecrackerVsock_Connect(t *testing.T) { - dir := t.TempDir() - udsPath := filepath.Join(dir, "vsock") - - // Create a mock Firecracker vsock server that handles CONNECT. - mockLn, err := net.Listen("unix", udsPath) - require.NoError(t, err) - defer mockLn.Close() - - go func() { - conn, err := mockLn.Accept() - if err != nil { - return - } - defer conn.Close() - - buf := make([]byte, 256) - n, err := conn.Read(buf) - if err != nil { - return - } - - // Expect "CONNECT 1027\n" - request := string(buf[:n]) - assert.Equal(t, "CONNECT 1027\n", request) - - // Respond with "OK 1027\n" - fmt.Fprintf(conn, "OK 1027\n") - - // Echo back any data received. - for { - n, err := conn.Read(buf) - if err != nil { - return - } - conn.Write(buf[:n]) - } - }() - - vsock := newFirecrackerVsock(udsPath) - - conn, err := vsock.Connect(1027) - require.NoError(t, err) - defer conn.Close() - - // Verify we can send and receive data. - _, err = conn.Write([]byte("hello")) - require.NoError(t, err) - - buf := make([]byte, 32) - n, err := conn.Read(buf) - require.NoError(t, err) - assert.Equal(t, "hello", string(buf[:n])) -} - -func TestFirecrackerVsock_ListenCleanup(t *testing.T) { - dir := t.TempDir() - udsPath := filepath.Join(dir, "vsock") - - vsock := newFirecrackerVsock(udsPath) - - ln, err := vsock.Listen(1025) - require.NoError(t, err) - - sockPath := fmt.Sprintf("%s_%d", udsPath, 1025) - _, err = os.Stat(sockPath) - require.NoError(t, err) - - ln.Close() - - // Socket file should be removed after close. - _, err = os.Stat(sockPath) - assert.True(t, os.IsNotExist(err)) -} diff --git a/old/vsock_qemu_darwin.go b/old/vsock_qemu_darwin.go deleted file mode 100644 index c5e8cc0..0000000 --- a/old/vsock_qemu_darwin.go +++ /dev/null @@ -1,115 +0,0 @@ -//go:build darwin - -package lnx - -import ( - "bufio" - "fmt" - "net" - "os" - "strings" - "sync" - "time" -) - -// qemuVsock implements VsockDevice using the vsock Unix domain socket -// protocol exposed by QEMU's virtio-vsock device (socket-path mode). -// -// Guest → Host (Listen): QEMU connects to a Unix socket at -// _ when the guest connects to CID 2 on that port. -// -// Host → Guest (Connect): The host dials , sends -// "CONNECT \n", and reads "OK \n" once the guest accepts. -type qemuVsock struct { - udsPath string - - mu sync.Mutex - listeners map[uint32]*qemuVsockListener -} - -func newQemuVsock(udsPath string) *qemuVsock { - return &qemuVsock{ - udsPath: udsPath, - listeners: make(map[uint32]*qemuVsockListener), - } -} - -func (q *qemuVsock) Listen(port uint32) (net.Listener, error) { - sockPath := fmt.Sprintf("%s_%d", q.udsPath, port) - - os.Remove(sockPath) - - ln, err := net.Listen("unix", sockPath) - if err != nil { - return nil, fmt.Errorf("listen %s: %w", sockPath, err) - } - - ql := &qemuVsockListener{ - Listener: ln, - sockPath: sockPath, - } - - q.mu.Lock() - q.listeners[port] = ql - q.mu.Unlock() - - return ql, nil -} - -func (q *qemuVsock) Connect(port uint32) (net.Conn, error) { - conn, err := net.Dial("unix", q.udsPath) - if err != nil { - return nil, fmt.Errorf("dial vsock %s: %w", q.udsPath, err) - } - - conn.SetDeadline(time.Now().Add(10 * time.Second)) - defer conn.SetDeadline(time.Time{}) // clear deadline after handshake - - if _, err := fmt.Fprintf(conn, "CONNECT %d\n", port); err != nil { - conn.Close() - return nil, fmt.Errorf("vsock connect handshake write: %w", err) - } - - // Wait for "OK \n" — sent by QEMU once the guest accepts. - reader := bufio.NewReader(conn) - line, err := reader.ReadString('\n') - if err != nil { - conn.Close() - return nil, fmt.Errorf("vsock connect handshake read: %w", err) - } - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "OK ") { - conn.Close() - return nil, fmt.Errorf("vsock connect: expected OK, got %q", line) - } - - return &qemuBufferedConn{Conn: conn, reader: reader}, nil -} - -func (q *qemuVsock) cleanup() { - q.mu.Lock() - defer q.mu.Unlock() - for _, l := range q.listeners { - l.Close() - } -} - -type qemuVsockListener struct { - net.Listener - sockPath string -} - -func (l *qemuVsockListener) Close() error { - err := l.Listener.Close() - os.Remove(l.sockPath) - return err -} - -type qemuBufferedConn struct { - net.Conn - reader *bufio.Reader -} - -func (c *qemuBufferedConn) Read(p []byte) (int, error) { - return c.reader.Read(p) -} diff --git a/results/vm-start/.gitkeep b/results/vm-start/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/results/vm-start/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/scripts/test/browser-snapshot.ts b/scripts/test/browser-snapshot.ts index 9e97a4b..885b66f 100644 --- a/scripts/test/browser-snapshot.ts +++ b/scripts/test/browser-snapshot.ts @@ -15,7 +15,10 @@ import { assertContains, } from "./lib"; const ctx = defaultContext("browser-snapshot"); -const forkName = `${ctx.instance}-browser-fork`; +const forkNames = Array.from( + { length: Number(Bun.env.LNX_BROWSER_FORK_COUNT ?? "10") }, + (_, i) => `${ctx.instance}-browser-fork-${i}`, +); async function freePort(): Promise { return await new Promise((resolve, reject) => { @@ -157,7 +160,9 @@ if (Bun.env.LNX_RUN_BROWSER_TEST !== "1") { try { await prepareContext(ctx); - await run(["rm", "-rf", `${ctx.base}/instances/${forkName}`, `${ctx.base}/instances/${forkName}`], { check: false }); + for (const forkName of forkNames) { + await run(["rm", "-rf", `${ctx.base}/instances/${forkName}`], { check: false }); + } await testStep("install stock browser stack", async () => { await ctx.vm.cli([ @@ -273,14 +278,24 @@ exit 1 assertEq(checkpointCreated, true, "browser checkpoint created"); }); - await testStep("fork browser checkpoint and verify noVNC endpoint survives", async () => { - assertEq((await run([ctx.lnxBin, "--instance", ctx.instance, "fork", "--checkpoint", "browser-ready", forkName], { timeoutMs: 240_000 })).stdout, forkName, "browser fork"); - const page = await run([ctx.lnxBin, "--instance", forkName, "curl", "-fsS", "http://127.0.0.1:6080/vnc.html"], { timeoutMs: 240_000 }); - assertContains(page.stdout.toLowerCase(), "novnc", "fork noVNC page"); + await testStep(`fork browser checkpoint ${forkNames.length}x and verify every noVNC endpoint`, async () => { + await Promise.all( + forkNames.map(async (forkName) => { + assertEq( + (await run([ctx.lnxBin, "--instance", ctx.instance, "fork", "--checkpoint", "browser-ready", forkName], { timeoutMs: 240_000 })).stdout, + forkName, + `browser fork ${forkName}`, + ); + const page = await run([ctx.lnxBin, "--instance", forkName, "curl", "-fsS", "http://127.0.0.1:6080/vnc.html"], { timeoutMs: 240_000 }); + assertContains(page.stdout.toLowerCase(), "novnc", `fork ${forkName} noVNC page`); + }), + ); }); } finally { await cleanupContext(ctx); - await cleanupInstance(ctx, forkName); + for (const forkName of forkNames) { + await cleanupInstance(ctx, forkName); + } } process.exit(0); diff --git a/scripts/test/ingress.ts b/scripts/test/ingress.ts index d5876fe..c50f032 100644 --- a/scripts/test/ingress.ts +++ b/scripts/test/ingress.ts @@ -54,10 +54,17 @@ try { assertContains(response.stdout, "503 Service Unavailable", "attach is unavailable"); }); - await testStep("unprivileged ingress disable", async () => { + await testStep("unprivileged ingress disable keeps ca files for re-enable", async () => { await run([ctx.lnxBin, "ingress", "disable"], { env, timeoutMs: 30_000 }); const disabled = await run([ctx.lnxBin, "ingress", "status"], { env }); assertContains(disabled.stdout, "disabled", "ingress disabled"); + assertEq((await run(["test", "-f", `${ctx.tmpdir}/state/ca/lnx-ca.crt`], { check: false })).status, 0, "ca files survive disable"); + }); + + await testStep("unprivileged ingress uninstall removes ca state", async () => { + await run([ctx.lnxBin, "ingress", "uninstall"], { env, timeoutMs: 30_000 }); + assertEq((await run(["test", "-e", `${ctx.tmpdir}/state/ca`], { check: false })).status, 1, "ca dir removed"); + assertEq((await run(["test", "-e", `${ctx.tmpdir}/state/certs`], { check: false })).status, 1, "cert dir removed"); }); } finally { await run([ctx.lnxBin, "ingress", "disable"], { env, check: false, timeoutMs: 30_000 }); diff --git a/scripts/test/linux-snapshot-fixture.ts b/scripts/test/linux-snapshot-fixture.ts index a3270e6..0d92878 100644 --- a/scripts/test/linux-snapshot-fixture.ts +++ b/scripts/test/linux-snapshot-fixture.ts @@ -90,6 +90,15 @@ async function cloneShrunkRootfs(src: string, dest: string) { await shrinkRootfsToMinimum(dest); } +// The outer guest stages the inner base on its own disk, so the shrunk outer +// rootfs needs free space again; the file stays sparse on the host. +async function growRootfs(path: string, sizeBytes: number) { + const resize2fs = e2fsTool("resize2fs"); + await run(["truncate", "-s", String(sizeBytes), path], { timeoutMs: 180_000 }); + await run([resize2fs, path], { timeoutMs: 180_000 }); + await alignRootfsForPmem(path); +} + async function checkpointPathByName(imageDir: string, name: string): Promise { const checkpointDir = join(imageDir, "checkpoints"); for (const entry of await readdir(checkpointDir, { withFileTypes: true })) { @@ -147,7 +156,18 @@ print("linux-source-after", flush=True) return [ "set -euo pipefail", "test -c /dev/kvm", - `export LNX_BASE=${quoteShell(innerBase)}`, + `inner_instance=${quoteShell(innerInstance)}`, + // The inner VMM maps its rootfs as pmem via mmap, and nested KVM cannot + // map virtiofs-DAX pages (vcpu faults with EFAULT). Stage the inner base + // on guest-local disk and copy the checkpoint back out afterwards. + `host_base=${quoteShell(innerBase)}`, + "local_base=/root/lnx-linux-fixture-base", + "rm -rf \"$local_base\"", + "mkdir -p \"$local_base/instances/$inner_instance\"", + "cp \"$host_base/vmlinuz\" \"$local_base/vmlinuz\"", + "cp --sparse=always \"$host_base/instances/$inner_instance/rootfs.ext4\" \"$local_base/instances/$inner_instance/rootfs.ext4\"", + "cp \"$host_base/instances/$inner_instance/vm-initialized\" \"$local_base/instances/$inner_instance/vm-initialized\"", + "export LNX_BASE=\"$local_base\"", `export LNX_RUN_BASE=${quoteShell(innerRunBase)}`, "rm -rf \"$LNX_RUN_BASE\"", "nested_tools=/tmp/lnx-linux-fixture-tools", @@ -157,7 +177,6 @@ print("linux-source-after", flush=True) "chmod +x \"$nested_tools\"/*", "export LNX_BIN=\"$nested_tools/lnx\"", "export LNX_BROKER_IDLE_TTL_MS=250", - `inner_instance=${quoteShell(innerInstance)}`, `checkpointName=${quoteShell(checkpointName)}`, "source_out=/tmp/lnx-linux-fixture-source.out", "source_err=/tmp/lnx-linux-fixture-source.err", @@ -195,6 +214,7 @@ print("linux-source-after", flush=True) " [ ! -e \"$pidfile\" ] && break", " sleep 0.1", "done", + "cp -R --sparse=always \"$local_base/instances/$inner_instance/checkpoints\" \"$host_base/instances/$inner_instance/\"", ].join("\n"); } @@ -214,6 +234,7 @@ try { await ensureLinuxTools(); await run(["cp", kernel, join(innerBase, "vmlinuz")], { timeoutMs: 180_000 }); await cloneShrunkRootfs(rootfs, outerRootfs); + await growRootfs(outerRootfs, 16 * 1024 * 1024 * 1024); const innerRootfs = join(innerBase, "instances", innerInstance, "rootfs.ext4"); await cloneSparseImage(rootfs, innerRootfs); await shrinkRootfsToMinimum(innerRootfs); diff --git a/scripts/test/server-transfer.ts b/scripts/test/server-transfer.ts index 79804b5..4e0aca5 100644 --- a/scripts/test/server-transfer.ts +++ b/scripts/test/server-transfer.ts @@ -29,7 +29,7 @@ const launchMetadata = JSON.stringify({ version: 2, owner_args: [], compatibility: { - host_share_cache: { dax: false }, + host_share_cache: { dax: true }, }, shares: { no_host_shares: false, diff --git a/scripts/test/system.ts b/scripts/test/system.ts index aa0ec24..e87cc94 100644 --- a/scripts/test/system.ts +++ b/scripts/test/system.ts @@ -107,6 +107,44 @@ try { ); }); + await testStep("virtiofs preserves sparse file geometry", async () => { + // Regression: the virtiofs server used to fake SEEK_DATA/SEEK_HOLE + // (macOS numbers them the other way around), so `cp --sparse` in the + // guest dropped trailing holes and truncated copies of aligned images. + const sparseDir = join(ctx.base, "test-work", `${ctx.instance}-sparse`); + const sparseFile = join(sparseDir, "sparse.bin"); + await run(["rm", "-rf", sparseDir]); + await run(["mkdir", "-p", sparseDir]); + await run([ + "python3", + "-c", + "import sys\nwith open(sys.argv[1], 'wb') as f:\n f.write(b'A' * 4096)\n f.seek(50 * 1024 * 1024)\n f.write(b'B' * 4096)\n f.truncate(64 * 1024 * 1024)\n", + sparseFile, + ]); + + assertEq( + (await ctx.vm.cli(["stat", "-c", "%s", sparseFile])).stdout, + "67108864", + "guest sees full sparse file size", + ); + const seeks = await ctx.vm.cli([ + "python3", + "-c", + "import errno, os, sys\nfd = os.open(sys.argv[1], os.O_RDONLY)\nprint(os.lseek(fd, 0, 4))\ntry:\n os.lseek(fd, 67108864, 3)\n print('no-enxio')\nexcept OSError as e:\n print(errno.errorcode[e.errno])\n", + sparseFile, + ]); + // APFS reports extents at 16 KiB granularity, so the 4 KiB data head + // rounds up to one host block. + assertEq(seeks.stdout, "16384\nENXIO", "guest SEEK_HOLE/SEEK_DATA follow Linux semantics"); + const copy = await ctx.vm.cli([ + "bash", + "-lc", + `cp --sparse=always ${sparseFile} /tmp/sparse-copy.bin && stat -c %s /tmp/sparse-copy.bin && cmp ${sparseFile} /tmp/sparse-copy.bin && echo identical`, + ]); + assertEq(copy.stdout, "67108864\nidentical", "sparse guest copy keeps size and content"); + await run(["rm", "-rf", sparseDir]); + }); + await testStep("baked toolchain", async () => { assertEq( (await ctx.vm.cli(["which", "node"])).stdout, diff --git a/site/content/_index.md b/site/content/_index.md index 6a37298..5bf4355 100644 --- a/site/content/_index.md +++ b/site/content/_index.md @@ -1,36 +1,36 @@ --- title: "lnx" -description: "A Rust and libkrun Linux VM runner for macOS that keeps memory and disk state warm between commands." +description: "Linux VMs on macOS that resume with memory, disk, and systemd state intact between commands." tagline: "Linux VMs for macOS that wake with memory, disk, and systemd state intact." -hero_command: "target/debug/lnx /bin/echo hello" +hero_command: "lnx echo hello" metrics: + - value: "~0.9s" + label: "exec via 4 GiB memory restore" + - value: "~40ms" + label: "exec on a live VM" - value: "systemd" label: "real rootfs userspace" - - value: "vsock" - label: "single-command exec path" - - value: "snapshots" - label: "memory plus disk state" features: - title: "Boots the real root" text: "The existing ext4 image stays the systemd root. The initramfs only stages the agent, then hands off to the guest image." - title: "Keeps commands warm" text: "A detached owner keeps the VM alive briefly, snapshots when idle, and restores the next command from that point." + - title: "Forks and checkpoints" + text: "Instances clone with APFS: prepare one machine, then stamp out copies per experiment, test shard, or agent." - title: "Host ingress built in" - text: "Local HTTPS hosts terminate on macOS and proxy into guest ports, with generated certificates per .lnx host." - - title: "Nested restore path" - text: "Linux-host restore flows can run inside an outer nested-KVM guest while preserving macOS snapshot ergonomics." + text: "Local HTTPS hosts terminate on macOS and proxy into guest ports, signed by a CA name-constrained to .lnx." flow: - "Host writes an initramfs with lnx-agent as /init." - "libkrun boots the kernel and attaches rootfs.ext4." - "The agent stages itself into the systemd root." - "Commands stream over vsock and return the guest status." quickstart: - - label: "Build" - command: "CC_LINUX=/opt/homebrew/bin/aarch64-linux-musl-gcc cargo build" - - label: "Sign" - command: "codesign --entitlements entitlements.plist --force -s - target/debug/lnx" + - label: "Install" + command: "curl -LO https://github.com/semistrict/lnx/releases/latest/download/lnx-macos-arm64.tar.gz && tar -xzf lnx-macos-arm64.tar.gz" - label: "Run" - command: "target/debug/lnx /bin/echo hello" + command: "lnx echo hello" + - label: "Fork" + command: "lnx fork dev2" - label: "Ingress" - command: "sudo lnx ingress enable && open https://p6080.default.lnx/" + command: "sudo lnx ingress enable && open https://p6080-default.lnx/" --- diff --git a/src/cli.rs b/src/cli.rs index 60e50ff..f2b5242 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -313,6 +313,8 @@ enum IngressCommand { Enable, Disable, Status, + #[command(about = "Disable ingress and remove the trusted lnx CA")] + Uninstall, } #[derive(Debug, Args)] @@ -358,10 +360,33 @@ struct HiddenIngressArgs { #[arg(long)] uninstall_service: bool, + #[arg(long, requires = "uninstall_service")] + purge_ca: bool, + #[arg(long)] refresh_if_running: bool, } +impl HiddenIngressArgs { + fn action(&self) -> ingress::HiddenAction { + if self.cleanup { + ingress::HiddenAction::Cleanup + } else if self.refresh_if_running { + ingress::HiddenAction::RefreshIfRunning + } else if self.uninstall_service { + ingress::HiddenAction::UninstallService { + purge_ca: self.purge_ca, + } + } else if self.install_service { + ingress::HiddenAction::InstallService + } else if self.spawn { + ingress::HiddenAction::Spawn + } else { + ingress::HiddenAction::RunDaemon + } + } +} + impl Cli { pub fn run(self) -> Result<()> { let Cli { @@ -596,6 +621,7 @@ impl Cli { IngressCommand::Enable => ingress::enable(&config), IngressCommand::Disable => ingress::disable(&config), IngressCommand::Status => ingress::print_status(&config), + IngressCommand::Uninstall => ingress::uninstall(&config), } } Some(Command::Instances(args)) => match args.command { @@ -606,14 +632,7 @@ impl Cli { Some(Command::Logs(args)) => print_instance_logs(&layout, args.console, args.owner), Some(Command::HiddenIngress(args)) => { let config = ingress::load_config()?; - ingress::run_hidden( - args.spawn, - args.cleanup, - args.install_service, - args.uninstall_service, - args.refresh_if_running, - config, - ) + ingress::run_hidden(args.action(), config) } Some(Command::HiddenVmInit) => initialize_vm_instance( layout, diff --git a/src/ingress.rs b/src/ingress.rs index c395a85..0ebe4f7 100644 --- a/src/ingress.rs +++ b/src/ingress.rs @@ -108,13 +108,29 @@ pub fn disable(config: &Config) -> Result<()> { } let _ = start_helper(config, &["_ingress", "--uninstall-service"]); if stopped || !config.resolver_path().exists() { - println!("ingress disabled"); + println!("ingress disabled; local lnx CA removed from the System keychain"); } else { println!("ingress already disabled"); } Ok(()) } +pub fn uninstall(config: &Config) -> Result<()> { + if config.needs_privileges() { + ensure_sudo_can_prompt_or_is_cached()?; + println!( + "lnx needs your password to remove the .{} resolver, the launchd service, and the trusted lnx CA from the System keychain.", + config.domain + ); + } + if stop(config).is_ok() { + let _ = wait_for_stop(config, Duration::from_secs(5)); + } + start_helper(config, &["_ingress", "--uninstall-service", "--purge-ca"])?; + println!("ingress uninstalled; local CA removed from the System keychain"); + Ok(()) +} + pub fn print_status(config: &Config) -> Result<()> { match status(config) { Ok(status) => { @@ -147,32 +163,28 @@ pub fn print_status(config: &Config) -> Result<()> { Ok(()) } -pub fn run_hidden( - spawn: bool, - cleanup: bool, - install_service_flag: bool, - uninstall_service_flag: bool, - refresh_if_running: bool, - config: Config, -) -> Result<()> { - if cleanup { - let _ = fs::remove_file(config.resolver_path()); - let _ = fs::remove_file(config.socket_path()); - return Ok(()); - } - if refresh_if_running { - return refresh_if_running_service(&config); - } - if uninstall_service_flag { - return uninstall_service(&config); - } - if install_service_flag { - return install_service(&config); - } - if spawn { - return spawn_daemon(&config); +pub enum HiddenAction { + Cleanup, + RefreshIfRunning, + InstallService, + UninstallService { purge_ca: bool }, + Spawn, + RunDaemon, +} + +pub fn run_hidden(action: HiddenAction, config: Config) -> Result<()> { + match action { + HiddenAction::Cleanup => { + let _ = fs::remove_file(config.resolver_path()); + let _ = fs::remove_file(config.socket_path()); + Ok(()) + } + HiddenAction::RefreshIfRunning => refresh_if_running_service(&config), + HiddenAction::UninstallService { purge_ca } => uninstall_service(&config, purge_ca), + HiddenAction::InstallService => install_service(&config), + HiddenAction::Spawn => spawn_daemon(&config), + HiddenAction::RunDaemon => run_daemon(config), } - run_daemon(config) } impl Config { @@ -481,7 +493,7 @@ fn refresh_if_running_service(config: &Config) -> Result<()> { Ok(()) } -fn uninstall_service(config: &Config) -> Result<()> { +fn uninstall_service(config: &Config, purge_ca: bool) -> Result<()> { unload_service(config); let _ = fs::remove_file(config.launchd_path()); let _ = fs::remove_file(config.resolver_path()); @@ -489,8 +501,11 @@ fn uninstall_service(config: &Config) -> Result<()> { if config.requires_privileged_service() { let _ = fs::remove_file(SYSTEM_HELPER_PATH); } - // Leave the CA trusted: re-trusting on the next enable would re-open the - // Security auth dialog. The local dev CA persists like mkcert's. + untrust_ca(config)?; + if purge_ca { + let _ = fs::remove_dir_all(config.ca_dir()); + let _ = fs::remove_dir_all(config.cert_dir()); + } Ok(()) } @@ -1154,6 +1169,14 @@ fn generate_ca(config: &Config) -> Result<()> { .arg("2048"), ) .context("generate ingress CA key")?; + // Name-constrain the CA to the ingress domain so that trusting it cannot + // enable interception of any other host. IP-address names are excluded + // entirely; leaf certificates only ever carry . DNS names. + let name_constraints = format!( + "nameConstraints=critical,permitted;DNS:.{domain},permitted;DNS:{domain},\ + excluded;IP:0.0.0.0/0.0.0.0,excluded;IP:0:0:0:0:0:0:0:0/0:0:0:0:0:0:0:0", + domain = config.domain + ); run_command( Command::new("openssl") .arg("req") @@ -1167,6 +1190,12 @@ fn generate_ca(config: &Config) -> Result<()> { .arg("3650") .arg("-subj") .arg(format!("/CN={CA_COMMON_NAME}")) + .arg("-addext") + .arg("basicConstraints=critical,CA:TRUE,pathlen:0") + .arg("-addext") + .arg("keyUsage=critical,keyCertSign,cRLSign") + .arg("-addext") + .arg(&name_constraints) .arg("-out") .arg(&cert), ) @@ -1180,6 +1209,9 @@ fn trust_ca(config: &Config) -> Result<()> { if !cfg!(target_os = "macos") { return Ok(()); } + // Each enable regenerates the CA; drop stale trusted copies first so old + // roots do not accumulate in the System keychain. + remove_trusted_ca_certs(); run_command( Command::new("security") .arg("add-trusted-cert") @@ -1193,21 +1225,35 @@ fn trust_ca(config: &Config) -> Result<()> { .context("trust ingress CA") } -// Retained for an explicit teardown; normal disable leaves the CA trusted. -#[allow(dead_code)] +// Disable removes keychain trust; `lnx ingress uninstall` also deletes the +// on-disk CA and certificate state. fn untrust_ca(_config: &Config) -> Result<()> { if !cfg!(target_os = "macos") { return Ok(()); } - let _ = Command::new("security") - .arg("delete-certificate") - .arg("-c") - .arg(CA_COMMON_NAME) - .arg("/Library/Keychains/System.keychain") - .status(); + remove_trusted_ca_certs(); Ok(()) } +fn remove_trusted_ca_certs() { + // delete-certificate removes one match per invocation; loop until none + // remain so repeated enables cannot leave stale roots behind. + loop { + let status = Command::new("security") + .arg("delete-certificate") + .arg("-c") + .arg(CA_COMMON_NAME) + .arg("/Library/Keychains/System.keychain") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + match status { + Ok(status) if status.success() => continue, + _ => break, + } + } +} + fn run_command(command: &mut Command) -> Result<()> { let debug = format!("{command:?}"); let status = command.status().with_context(|| format!("run {debug}"))?; diff --git a/src/ingress/tests.rs b/src/ingress/tests.rs index c3f0eff..2c6fbd3 100644 --- a/src/ingress/tests.rs +++ b/src/ingress/tests.rs @@ -332,3 +332,43 @@ fn preserves_other_proxy_host_headers() { assert_eq!(rewrite_proxy_request_host(request.clone(), 6080), request); } + +#[test] +fn generated_ca_is_name_constrained_to_the_ingress_domain() { + let state_dir = tempfile::tempdir().expect("tempdir"); + let config = Config { + domain: "lnx".to_string(), + dns_addr: "127.0.0.1:5354".to_string(), + http_addr: "127.0.0.1:8080".to_string(), + https_addr: "127.0.0.1:8443".to_string(), + resolver_dir: state_dir.path().join("resolver"), + state_dir: state_dir.path().to_path_buf(), + }; + fs::create_dir_all(config.ca_dir()).expect("create ca dir"); + + generate_ca(&config).expect("generate ca"); + + let output = Command::new("openssl") + .arg("x509") + .arg("-in") + .arg(config.ca_cert_path()) + .arg("-text") + .arg("-noout") + .output() + .expect("read ca cert"); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).expect("utf8 cert text"); + + assert!(text.contains("X509v3 Name Constraints: critical"), "{text}"); + assert!(text.contains("Permitted:"), "{text}"); + assert!(text.contains("DNS:.lnx"), "{text}"); + assert!(text.contains("DNS:lnx"), "{text}"); + assert!(text.contains("Excluded:"), "{text}"); + assert!(text.contains("IP:0.0.0.0/0.0.0.0"), "{text}"); + assert!( + text.contains("X509v3 Basic Constraints: critical"), + "{text}" + ); + assert!(text.contains("CA:TRUE, pathlen:0"), "{text}"); + assert!(text.contains("Certificate Sign, CRL Sign"), "{text}"); +} diff --git a/third_party/libkrun/src/devices/src/virtio/fs/macos/passthrough.rs b/third_party/libkrun/src/devices/src/virtio/fs/macos/passthrough.rs index b7e4979..4c111a3 100644 --- a/third_party/libkrun/src/devices/src/virtio/fs/macos/passthrough.rs +++ b/third_party/libkrun/src/devices/src/virtio/fs/macos/passthrough.rs @@ -4568,16 +4568,12 @@ impl FileSystem for PassthroughFs { .cloned() .ok_or_else(ebadf)?; - // SEEK_DATA and SEEK_HOLE have slightly different semantics - // in Linux vs. macOS, which means we can't support them. - let mwhence = if whence == 3 { - // SEEK_DATA - return Ok(offset); - } else if whence == 4 { - // SEEK_HOLE - libc::SEEK_END - } else { - whence as i32 + // The guest passes Linux whence values, where SEEK_DATA=3 and + // SEEK_HOLE=4; macOS supports both but with the numbering swapped. + let mwhence = match whence { + 3 => libc::SEEK_DATA, + 4 => libc::SEEK_HOLE, + w => w as i32, }; let fd = data.file.write().unwrap().as_raw_fd(); diff --git a/third_party/libkrun/src/vmm/src/macos/vstate.rs b/third_party/libkrun/src/vmm/src/macos/vstate.rs index 0ae8ab7..76d89be 100644 --- a/third_party/libkrun/src/vmm/src/macos/vstate.rs +++ b/third_party/libkrun/src/vmm/src/macos/vstate.rs @@ -1217,6 +1217,8 @@ mod tests { #[cfg(target_arch = "x86_64")] use std::time::Duration; + use std::ffi::CString; + use super::*; use arch::aarch64::layout::DRAM_MEM_START_EFI; use devices::legacy::VcpuList; @@ -1347,8 +1349,30 @@ mod tests { assert!(vcpu.mmio_bus.is_some()); } + // CI macOS runners are VMs without Hypervisor.framework; every other test + // in this module avoids creating a real HVF VM, but this one cannot. + fn hvf_available() -> bool { + let name = CString::new("kern.hv_support").unwrap(); + let mut value: libc::c_int = 0; + let mut size = std::mem::size_of::(); + let ret = unsafe { + libc::sysctlbyname( + name.as_ptr(), + &mut value as *mut libc::c_int as *mut libc::c_void, + &mut size, + std::ptr::null_mut(), + 0, + ) + }; + ret == 0 && value == 1 + } + #[test] fn test_vm_memory_init() { + if !hvf_available() { + eprintln!("skipping test_vm_memory_init: host has no Hypervisor.framework support"); + return; + } let mut vm = Vm::new(false).expect("Cannot create new vm"); // Use a realistic guest physical address; hv_vm_map rejects GPA 0.