diff --git a/docs/BUILD.md b/docs/BUILD.md index 85d186c4..d760e0a7 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -4,7 +4,7 @@ - the toolchain, Linux kernel and modules, U-Boot, `fip.bin` and the boot images; - the SOPHGO media libraries, the board service, the server and the web UI; -- the six `nanokvm-*` APK packages, the Alpine root file system and the image. +- the seven `nanokvm-*` APK packages, the Alpine root file system and the image. Host requirements, the steps, the outputs and how they are checked are described in [platform/README.md](../platform/README.md). Buildroot (`firmware/buildroot/configs/nanokvm_platform_defconfig`) builds only the toolchain, host tools and initramfs utilities; the installed OS and optional software are Alpine APKs. diff --git a/firmware/alpine/README.md b/firmware/alpine/README.md index bce2d43d..8ed7edef 100644 --- a/firmware/alpine/README.md +++ b/firmware/alpine/README.md @@ -78,7 +78,9 @@ NanoKVM still supplies its SG2002 kernel, drivers, board firmware and applicatio The target repository list is: -1. the signed NanoKVM repository (`https://nkos.pesin.pro/repos/nanokvm`); +1. the signed NanoKVM repository, read through its v3 index + (`https://nkos.pesin.pro/repos/nanokvm/riscv64/Packages.adb`; see + [signing keys](#signing-keys)); 2. Alpine v3.24 `main`; 3. Alpine v3.24 `community`; 4. Alpine `edge/community`, tagged as `@edgecommunity`. @@ -107,6 +109,93 @@ Full recovery images remain necessary for partition layout, filesystem, FIP or bootloader changes. Moving to another Alpine stable branch is an explicit system upgrade rather than an incidental package update. +## Signing keys + +apk trusts every public key in `/etc/apk/keys` for every repository; it does +not tie a key to a repository. The NanoKVM release keys are in `keys/`: + +| Key | Type | Signs | +|---|---|---| +| `dgrace-6aaddbb6.rsa.pub` | RSA 4096 | the packages and `APKINDEX.tar.gz` (RSA256, RSA with SHA-256); `Packages.adb` during the transition | +| `nkos-release-ec-b8e89b66.pub` | ECDSA P-256 | `Packages.adb` | + +The package `nanokvm-keys` installs them in `/etc/apk/keys` and owns them, as +`alpine-keys` does for Alpine's keys; `nanokvm-base` depends on it. Because a +package owns the files, a release can add a key, and a key removed from `keys/` +is removed from devices when they update. A key copied by hand stays. + +The image build must trust the keys before it can install from the repository, +so the rootfs step copies `keys/` into the root first. When apk then installs +`nanokvm-keys`, it takes over these identical files: `/etc/apk` is not a +protected path, so there is neither a conflict nor an `.apk-new`. Devices +installed before `nanokvm-keys` have the RSA key as a file without an owner; +their update takes it over the same way. (Tested with apk-tools 3.0.8 in a +scratch root on the device.) + +### Two indexes + +The repository has two indexes of the same abuild packages in `riscv64/`: + +- `APKINDEX.tar.gz`, the v2 index, signed with the RSA key. Devices up to 2.0 + use the repository line `https://nkos.pesin.pro/repos/nanokvm`, read this + index and trust only the RSA key. +- `Packages.adb`, the apk-tools v3 index, signed with the ECDSA key and, during + the transition, the RSA key; apk accepts it if it trusts either key. Images + from 2.5 on use the line + `https://nkos.pesin.pro/repos/nanokvm/riscv64/Packages.adb`. When an older + device installs 2.5, the post-install script of `nanokvm-keys` changes its + NanoKVM line to that URL, after the keys are in place. Alpine's lines and any + other line stay as they are. + +apk checks a package from an index against the package hash in that index, so +the packages need no ECDSA signature. + +### Publishing a release + +1. Build with both release keys, each with its public key next to it: + + ```sh + platform/build.sh -k /secure/dgrace-6aaddbb6.rsa -e /secure/nkos-release-ec-b8e89b66.key + ``` + +2. Upload the contents of `OUTPUT/release/apk/recipes/riscv64/` (the `*.apk` + files, `APKINDEX.tar.gz` and `Packages.adb`) to + `https://nkos.pesin.pro/repos/nanokvm/riscv64/`, replacing the previous + release. Upload the packages first and the two indexes last, so that no + index names a package that is not there yet. +3. Check the published indexes from a scratch root, for example + `apk --keys-dir DIR verify Packages.adb` with only one of the keys in `DIR`. + +### Moving to the ECDSA key alone + +- While devices on 2.0 or earlier may still update, keep publishing + `APKINDEX.tar.gz` signed with the RSA key, and keep the RSA signature on + `Packages.adb`. Such a device can update from the v2 index; that update + installs `nanokvm-keys` and moves it to the v3 index. +- Once those devices have updated, a later release may sign `Packages.adb` with + the ECDSA key alone (remove the RSA `--sign-key` from the packages step of + `platform/build.sh`). A device that still reads only the v2 index then keeps + working as long as `APKINDEX.tar.gz` is published; after that it needs the + ECDSA key and the new repository line by hand. +- The packages themselves stay signed with the RSA key, which `apk verify` and + `apk add ./file.apk` check. Keep `dgrace-6aaddbb6.rsa.pub` in `keys/` while + packages are signed with it. + +### Replacing a key + +1. Create the new key on the signing machine, add its public key to `keys/`, + and publish a release signed with the old key. `nanokvm-keys` ships the new + key; devices that install the release trust both keys. `platform/build.sh` + refuses `-k` or `-e` keys whose public key is not in `keys/`, so a key + cannot be used before it is shipped. +2. Sign the following releases with the new key. During a transition, sign each + index with both keys, so that a device that skipped step 1 can still update: + `Packages.adb` with two `--sign-key` options, and `APKINDEX.tar.gz` with a + second signature in front, `abuild-sign -t RSA256 -k old.rsa APKINDEX.tar.gz`. + apk-tools 3.0.8 accepts an index with two signatures if it trusts either key. +3. In a later release, remove the old key from `keys/`; updating `nanokvm-keys` + removes it from devices. + ## Existing C906 installations Removing the tuned repository alone does not replace its higher `pkgrel` @@ -207,4 +296,5 @@ PAYLOAD_ROOT=work/alpine/payloads \ The repository consumed by APK is `work/alpine/repo/stock/recipes/riscv64`; its parent `work/alpine/repo/stock/recipes` is the repository URL because APK appends -the target architecture. +the target architecture. This helper writes only the v2 index; the packages +step of `platform/build.sh` also writes the signed v3 index `Packages.adb`. diff --git a/firmware/alpine/attended/README.md b/firmware/alpine/attended/README.md index 30222084..4d360d27 100644 --- a/firmware/alpine/attended/README.md +++ b/firmware/alpine/attended/README.md @@ -46,6 +46,12 @@ Repository order in the generated root is: 3. Alpine `main`; 4. Alpine `community`. +The NanoKVM and C906 repositories are read through their apk-tools v3 index: +a repository base URL becomes `URL/riscv64/Packages.adb`, and a URL ending in +`.adb` is used as it is. Pass the ECDSA key that signs the v3 index +(`nkos-release-ec-b8e89b66.pub`) as a `--repo-key` together with the RSA key +(see [signing keys](../README.md#signing-keys)). + The NanoKVM packages have unique names. Tuned replacements retain their Alpine package names and use the tested higher `pkgrel`; packages missing from the two local repositories resolve normally from Alpine. A changing Alpine branch URL @@ -53,7 +59,7 @@ does not provide byte-for-byte reproducibility by itself. Production should point `--alpine-main` and `--alpine-community` at immutable mirrored snapshots. The output records the exact effective repository file and every installed package version. The HTTP service also hashes the current remote Alpine -`APKINDEX.tar.gz` files, both local repository indexes, the public keys and all +`APKINDEX.tar.gz` files, both local `Packages.adb` indexes, the public keys and all image-building inputs into the build ID. A repeated request is cached only while those inputs are unchanged, so a new Alpine index produces a new personal image. @@ -104,7 +110,8 @@ signing key: --base-rootfs work/alpine/alpine-minirootfs-3.24.2-riscv64.tar.gz \ --boot-fit work/alpine/final-vm-good/boot-alpine.sd \ --nanokvm-repo work/alpine/repo-r4/stock/recipes \ - --repo-key work/alpine/signing/dgrace-6aaddbb6.rsa.pub \ + --repo-key firmware/alpine/keys/dgrace-6aaddbb6.rsa.pub \ + --repo-key firmware/alpine/keys/nkos-release-ec-b8e89b66.pub \ --qemu-static work/alpine/tuned-builder-full/rootfs/usr/bin/qemu-riscv64-static ``` @@ -122,10 +129,9 @@ sudo apt-get install python3 qemu-user-static binfmt-support device-tree-compile `qemu-user-static` and `binfmt-support` are required for APK maintainer scripts; the bundled Buildroot `mkimage` invokes the host `dtc` when it creates the recovery FIT. Run one build as root in a fresh output directory. The public key -filename must match the key name used by the APK signatures. The current -development NanoKVM and C906 repositories use the same signing key. If -production separates those keys, repeat the repo-key option once for each -public key. +filename of an RSA key must match the key name used by the APK signatures. The +NanoKVM repository needs both release keys; the C906 repository uses the same +RSA key. Repeat the repo-key option once for each public key. ```sh sudo ./scripts/build-alpine-personal-image.sh \ @@ -135,7 +141,8 @@ sudo ./scripts/build-alpine-personal-image.sh \ --boot-fit /srv/nanokvm/input/boot-alpine.sd \ --boot-sha256 \ --nanokvm-repo https://builder.example/nanokvm/stock/recipes \ - --repo-key /srv/nanokvm/keys/nanokvm-packages.rsa.pub \ + --repo-key /srv/nanokvm/keys/dgrace-6aaddbb6.rsa.pub \ + --repo-key /srv/nanokvm/keys/nkos-release-ec-b8e89b66.pub \ --packages-file request.packages \ --output /srv/nanokvm/output/request-001 ``` diff --git a/firmware/alpine/attended/builder-config.example.json b/firmware/alpine/attended/builder-config.example.json index 47163495..c1d5fd0d 100644 --- a/firmware/alpine/attended/builder-config.example.json +++ b/firmware/alpine/attended/builder-config.example.json @@ -6,7 +6,8 @@ "boot_sha256": "6421359320b60b6b03528f553290f266279f83cdacb03200af378d16254a5c12", "nanokvm_repo": "repos/nanokvm", "repo_keys": [ - "keys/dgrace-6aaddbb6.rsa.pub" + "keys/dgrace-6aaddbb6.rsa.pub", + "keys/nkos-release-ec-b8e89b66.pub" ], "qemu_static": "qemu/qemu-riscv64-static", "output_root": "output", diff --git a/firmware/alpine/keys/nkos-release-ec-b8e89b66.pub b/firmware/alpine/keys/nkos-release-ec-b8e89b66.pub new file mode 100644 index 00000000..a89d26f4 --- /dev/null +++ b/firmware/alpine/keys/nkos-release-ec-b8e89b66.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE+4mJDPL3dBzWA5n3rem3qAECqfmW +Htic+nbyTgyVr6FyBp+KCv/kAcuy2fSD4kbd2KtIdcJsU/A2SXKqYblppQ== +-----END PUBLIC KEY----- diff --git a/firmware/alpine/packages/README.md b/firmware/alpine/packages/README.md index 9f951ed6..9929b5ec 100644 --- a/firmware/alpine/packages/README.md +++ b/firmware/alpine/packages/README.md @@ -13,6 +13,7 @@ and invokes `abuild` in dependency order. Set `PAYLOAD_ROOT` to a directory containing these subdirectories: ``` +keys/ etc/apk/keys/*.pub (default: firmware/alpine/keys) base/ files installed below / kernel-sg2002/ FIT, DTB and kernel payload below /usr/lib/nanokvm/boot kmod-sg2002/ lib/modules// @@ -24,7 +25,10 @@ release/ etc/nanokvm-release and build profile metadata The helper also accepts `PAYLOAD_ROOT//...` (for example `PAYLOAD_ROOT/stock/app` and `PAYLOAD_ROOT/c906-scalar/app`) so stock and tuned repositories can be generated from the same checkout. Files containing device -identity, keys, `/data`, or user settings must not be put in these payloads. +identity, private keys, `/data`, or user settings must not be put in these +payloads; `nanokvm-keys` refuses anything but public keys in `/etc/apk/keys`. +`nanokvm-base` depends on `nanokvm-keys`, which owns the repository keys (see +[signing keys](../README.md#signing-keys)). ## Sophgo `devmem` dependency @@ -72,7 +76,8 @@ The helper copies each recipe to a temporary build directory and runs `abuild checksum` there before `abuild -r`. This keeps checksums out of the working tree while retaining normal APKBUILD checksum verification. The final repository check requires a valid `APKINDEX.tar.gz` and one APK per recipe; -package and index signing are performed by the configured abuild key. +package and index signing are performed by the configured abuild key, with +RSA256 signatures (RSA with SHA-256) instead of abuild's default RSA with SHA-1. The kernel APK stages all board FITs and their hashes below `/usr/lib/nanokvm/boot` and has an exact dependency on the matching module APK. diff --git a/firmware/alpine/packages/nanokvm-base/APKBUILD b/firmware/alpine/packages/nanokvm-base/APKBUILD index 040f42b5..155cde82 100644 --- a/firmware/alpine/packages/nanokvm-base/APKBUILD +++ b/firmware/alpine/packages/nanokvm-base/APKBUILD @@ -5,7 +5,7 @@ pkgdesc="NanoKVM Alpine integration and OpenRC services" url="https://github.com/sipeed/NanoKVM" arch="riscv64" license="GPL-3.0-only AND GPL-2.0-only" -depends="bash chrony-openrc avahi-openrc dbus-openrc busybox-openrc openssh-server openssh-server-common nftables>=1.1.6-r0 iproute2-tc util-linux openrc eudev kmod exfatprogs f2fs-tools" +depends="nanokvm-keys bash chrony-openrc avahi-openrc dbus-openrc busybox-openrc openssh-server openssh-server-common nftables>=1.1.6-r0 iproute2-tc util-linux openrc eudev kmod exfatprogs f2fs-tools" source="$pkgname-$pkgver.tar.gz nanokvm-banner.sh" options="!check !strip" diff --git a/firmware/alpine/packages/nanokvm-keys/APKBUILD b/firmware/alpine/packages/nanokvm-keys/APKBUILD new file mode 100644 index 00000000..38aa9ecb --- /dev/null +++ b/firmware/alpine/packages/nanokvm-keys/APKBUILD @@ -0,0 +1,36 @@ +# The public keys that NanoKVM OS packages and indexes are signed with, as +# alpine-keys holds Alpine's. Owning them lets an update add or remove a key. +pkgname=nanokvm-keys +pkgver=${PKGVER:-0.1.0} +pkgrel=0 +pkgdesc="Public keys for NanoKVM OS packages" +url="https://github.com/sipeed/NanoKVM" +arch="noarch" +license="GPL-3.0-only" +source="$pkgname-$pkgver.tar.gz" +options="!check !strip !tracedeps" +# Moves the NanoKVM repository line to the v3 index once the keys are in place. +install="$pkgname.post-install" + +build() { + : +} + +package() { + mkdir -p "$pkgdir" + cp -a "$srcdir/$pkgname-$pkgver/." "$pkgdir/" + # Public keys in /etc/apk/keys and nothing else, never a private key. + local f count=0 + for f in $(cd "$pkgdir" && find . ! -type d | LC_ALL=C sort); do + case $f in + ./etc/apk/keys/*.pub) ;; + *) die "unexpected file $f" ;; + esac + [ -f "$pkgdir/$f" ] && [ "$(head -n 1 "$pkgdir/$f")" = "-----BEGIN PUBLIC KEY-----" ] && + ! grep -q PRIVATE "$pkgdir/$f" || die "$f is not a public key" + count=$((count + 1)) + done + [ "$count" -gt 0 ] || die "no keys in the payload" +} + +sha256sums="" diff --git a/firmware/alpine/packages/nanokvm-keys/nanokvm-keys.post-install b/firmware/alpine/packages/nanokvm-keys/nanokvm-keys.post-install new file mode 100755 index 00000000..f8168537 --- /dev/null +++ b/firmware/alpine/packages/nanokvm-keys/nanokvm-keys.post-install @@ -0,0 +1,23 @@ +#!/bin/sh +# Devices from before nanokvm-keys read the v2 index (APKINDEX.tar.gz) of the +# NanoKVM repository, which only the RSA key signs. Now that this package has +# installed the release keys, including the ECDSA key, read the v3 index +# (Packages.adb) instead; it is signed with the ECDSA key. Only that one +# exact line changes: Alpine's repositories and any other line stay as they +# are, and a failure leaves the working v2 line in place. +repositories=/etc/apk/repositories +old=https://nkos.pesin.pro/repos/nanokvm +new=https://nkos.pesin.pro/repos/nanokvm/riscv64/Packages.adb + +[ -f "$repositories" ] || exit 0 +awk -v old="$old" '{ sub(/^[ \t]+/, ""); sub(/[ \t]+$/, ""); sub(/\/$/, "") } + $0 == old { found = 1 } END { exit !found }' "$repositories" || exit 0 +tmp=$(mktemp "$repositories.XXXXXX") || exit 0 +if awk -v old="$old" -v new="$new" '{ line = $0; sub(/^[ \t]+/, "", line); sub(/[ \t]+$/, "", line) + sub(/\/$/, "", line); print (line == old ? new : $0) }' "$repositories" > "$tmp" && + chmod 0644 "$tmp" && mv "$tmp" "$repositories"; then + echo "NanoKVM repository: now $new" >&2 +else + rm -f "$tmp" +fi +exit 0 diff --git a/firmware/alpine/packages/nanokvm-release/APKBUILD b/firmware/alpine/packages/nanokvm-release/APKBUILD index c565e8de..cbc5b921 100644 --- a/firmware/alpine/packages/nanokvm-release/APKBUILD +++ b/firmware/alpine/packages/nanokvm-release/APKBUILD @@ -5,7 +5,7 @@ pkgdesc="Tested NanoKVM Alpine package set" url="https://github.com/sipeed/NanoKVM" arch="riscv64" license="GPL-3.0-only" -depends="alpine-base busybox-extras tzdata busybox-openrc !openvpn3 !openssl4-libs avahi avahi-openrc bash chrony chrony-openrc curl dbus dbus-openrc dnsmasq iproute2-tc nftables openresolv util-linux wireguard-tools ca-certificates coreutils eudev exfatprogs f2fs-tools hostapd i2c-tools iproute2 iw kmod libgcc libstdc++ lz4 openssh openssl util-linux-misc wpa_supplicant xz zstd nanokvm-base>=$pkgver-r0 nanokvm-kernel-sg2002>=$pkgver-r0 nanokvm-kmod-sg2002>=$pkgver-r0 nanokvm-firmware-sg2002>=$pkgver-r0 nanokvm-app>=$pkgver-r0" +depends="alpine-base busybox-extras tzdata busybox-openrc !openvpn3 !openssl4-libs avahi avahi-openrc bash chrony chrony-openrc curl dbus dbus-openrc dnsmasq iproute2-tc nftables openresolv util-linux wireguard-tools ca-certificates coreutils eudev exfatprogs f2fs-tools hostapd i2c-tools iproute2 iw kmod libgcc libstdc++ lz4 openssh openssl util-linux-misc wpa_supplicant xz zstd nanokvm-keys>=$pkgver-r0 nanokvm-base>=$pkgver-r0 nanokvm-kernel-sg2002>=$pkgver-r0 nanokvm-kmod-sg2002>=$pkgver-r0 nanokvm-firmware-sg2002>=$pkgver-r0 nanokvm-app>=$pkgver-r0" source="$pkgname-$pkgver.tar.gz" options="!check !strip !tracedeps" diff --git a/firmware/alpine/tests/test-keys-repository.py b/firmware/alpine/tests/test-keys-repository.py new file mode 100755 index 00000000..752aa6b3 --- /dev/null +++ b/firmware/alpine/tests/test-keys-repository.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""nanokvm-keys post-install moves only the NanoKVM line to the v3 index.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "firmware/alpine/packages/nanokvm-keys/nanokvm-keys.post-install" +V3 = "https://nkos.pesin.pro/repos/nanokvm/riscv64/Packages.adb" +ALPINE = ("https://dl-cdn.alpinelinux.org/alpine/v3.24/main\n" + "https://dl-cdn.alpinelinux.org/alpine/v3.24/community\n" + "@edgecommunity https://dl-cdn.alpinelinux.org/alpine/edge/community\n") + + +class RepositorySwitchTests(unittest.TestCase): + def run_script(self, content): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "repositories" + if content is not None: + path.write_text(content) + # Redirect the file only in the test copy; no host writes. + source = SCRIPT.read_text().replace("/etc/apk/repositories", str(path)) + result = subprocess.run(["sh", "-s"], input=source, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + left = sorted(p.name for p in Path(directory).iterdir()) + return (path.read_text() if path.exists() else None), left + + def test_v2_line_moves_to_v3(self): + text, files = self.run_script("https://nkos.pesin.pro/repos/nanokvm\n" + ALPINE) + self.assertEqual(text, V3 + "\n" + ALPINE) + self.assertEqual(files, ["repositories"]) + + def test_trailing_slash_and_spaces(self): + text, _ = self.run_script(" https://nkos.pesin.pro/repos/nanokvm/ \n" + ALPINE) + self.assertEqual(text, V3 + "\n" + ALPINE) + + def test_other_lines_unchanged(self): + original = ("https://nkos.pesin.pro/repos/c906-qualified\n" + "@nk https://nkos.pesin.pro/repos/nanokvm\n" + "# https://nkos.pesin.pro/repos/nanokvm\n" + "https://nkos.pesin.pro/repos/nanokvm-test\n" + ALPINE) + text, _ = self.run_script(original) + self.assertEqual(text, original) + + def test_already_v3_unchanged(self): + text, _ = self.run_script(V3 + "\n" + ALPINE) + self.assertEqual(text, V3 + "\n" + ALPINE) + + def test_missing_file(self): + text, files = self.run_script(None) + self.assertIsNone(text) + self.assertEqual(files, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/firmware/alpine/tests/test-stock-profile.py b/firmware/alpine/tests/test-stock-profile.py index 74e7a115..7f06374f 100644 --- a/firmware/alpine/tests/test-stock-profile.py +++ b/firmware/alpine/tests/test-stock-profile.py @@ -31,10 +31,18 @@ def test_cli_defaults_stock_and_tuned_requires_opt_in(self): result = subprocess.run(args, capture_output=True, text=True, check=True) self.assertIn("profile=stock", result.stdout) self.assertNotIn("c906", result.stdout) + # NanoKVM repositories are read through their v3 index. + self.assertIn(" https://example.test/nanokvm/riscv64/Packages.adb\n", result.stdout) + self.assertNotIn(" https://example.test/nanokvm\n", result.stdout) result = subprocess.run(args + ["--profile", "c906-scalar"], capture_output=True, text=True) self.assertNotEqual(result.returncode, 0) result = subprocess.run(args + ["--profile", "c906-scalar", "--tuned-repo", "https://example.test/c906"], capture_output=True, text=True, check=True) self.assertIn("profile=c906-scalar", result.stdout) + self.assertIn(" https://example.test/c906/riscv64/Packages.adb\n", result.stdout) + # An explicit v3 index URL is kept as it is. + explicit = [a.replace("https://example.test/nanokvm", "https://example.test/x/Packages.adb") for a in args] + result = subprocess.run(explicit, capture_output=True, text=True, check=True) + self.assertIn(" https://example.test/x/Packages.adb\n", result.stdout) result = subprocess.run(args + ["--runtime-tuned-repo", "https://example.test/c906"], capture_output=True, text=True) self.assertNotEqual(result.returncode, 0) @@ -54,7 +62,8 @@ def test_package_builder_rejects_mislabeled_release(self): def config(self, tuned=False): for name in ("base", "boot", "qemu", "scripts/build-alpine-personal-image.sh", "scripts/build-alpine-update-bundle.sh", "scripts/build-alpine-recovery-fit.py", - "firmware/alpine/recovery/init", "repo/riscv64/APKINDEX.tar.gz", "key.pub"): + "firmware/alpine/recovery/init", "repo/riscv64/APKINDEX.tar.gz", + "repo/riscv64/Packages.adb", "key.pub"): p = self.root / name p.parent.mkdir(parents=True, exist_ok=True) p.write_text("PUBLIC KEY" if name == "key.pub" else "fixture") @@ -87,12 +96,13 @@ def test_tuned_still_available_explicitly(self): handler.server = types.SimpleNamespace(config=config, local_url="http://127.0.0.1:8080") self.assertIn("--tuned-repo", handler.build_command("c906-scalar", [], self.root / "output")) - def migration(self, mode, fail=False, custom=False, fix_fail=False, checksum_hold=False): + def migration(self, mode, fail=False, custom=False, fix_fail=False, checksum_hold=False, v3=False): + index = "/riscv64/Packages.adb" if v3 else "" for name, content in { "etc/alpine-release": "3.24.2\n", "etc/nanokvm-build-profile": "c906-scalar\n", "etc/nanokvm-release": 'VERSION="2.0-b12"\nBUILD_PROFILE="c906-scalar"\n', - "etc/apk/repositories": "https://nkos.pesin.pro/repos/c906-qualified\nhttps://nkos.pesin.pro/repos/nanokvm\nhttps://dl-cdn.alpinelinux.org/alpine/v3.24/main\n@edgecommunity https://dl-cdn.alpinelinux.org/alpine/edge/community\n" + ("https://custom.test/c906-scalar\n" if custom else ""), + "etc/apk/repositories": f"https://nkos.pesin.pro/repos/c906-qualified{index}\nhttps://nkos.pesin.pro/repos/nanokvm{index}\nhttps://dl-cdn.alpinelinux.org/alpine/v3.24/main\n@edgecommunity https://dl-cdn.alpinelinux.org/alpine/edge/community\n" + ("https://custom.test/c906-scalar\n" if custom else ""), "etc/apk/world": "busybox\nnanokvm-kernel-sg2002" + (">/dev/null 2>&1 & + if [ "$executable" = /tmp/kvm_system/kvm_system ]; then + # Let the OOM killer take helpers such as apk before the hardware + # daemon. NanoKVM-Server sets the same value for itself at start. + { echo -300 > "/proc/$!/oom_score_adj"; } 2>/dev/null || true + fi } prepare_runtime_directory() { diff --git a/platform/README.md b/platform/README.md index daf111c6..65052dd0 100644 --- a/platform/README.md +++ b/platform/README.md @@ -4,7 +4,7 @@ - the toolchain, the Linux kernel and all kernel modules, U-Boot, OpenSBI, `fip.bin`, the initramfs and the `boot.sd` images; - the SOPHGO media libraries, the board service `kvm_system`, the server, the update helpers and the web UI; - the board tools, the EDID profiles and the firmware files; -- the six `nanokvm-*` APK packages, the Alpine 3.24 root file system and the SD card image. +- the seven `nanokvm-*` APK packages, the Alpine 3.24 root file system and the SD card image. Every upstream input is pinned in `sources.lock` and checked when it is downloaded. The outputs of the build steps are checked against `expected.sha256`. The OpenSBI/FIP candidate passed host checks and ROM UART RAM boot on SG2002 with Linux 7.2.9, timer/PLIC, fixed PMU counters and live video. Persistent installation and candidate SD cold boot remain untested. @@ -22,7 +22,7 @@ Every upstream input is pinned in `sources.lock` and checked when it is download | `images/web/` | `nanokvm-app`, `/kvmapp/server/web` | | `images/tools/` (devmem, `nanokvm_update_edid`, `nkos-board-probe`, `usb-audio-capture`, EDID profiles) | `nanokvm-base`, `nanokvm-app`, `nanokvm-firmware-sg2002` | | `images/firmware/` (AIC8800 Wi-Fi, regulatory database, video codec) | `nanokvm-firmware-sg2002` | -| `release/apk/` | the signed APK repository of the six packages | +| `release/apk/` | the signed APK repository of the seven packages: `recipes/riscv64/` with the packages, `APKINDEX.tar.gz` and `Packages.adb`, and the public keys | | `release/alpine-rootfs.tar.gz`, `release/installed-packages.txt` | the root file system and its package versions | | `release/image/NanoKVM-OS-.img.zip`, `SHA256SUMS` | the SD card image | @@ -44,7 +44,11 @@ Go modules, npm packages and Alpine packages are downloaded during the build and ### Signing key -The APK packages are signed. Without `-k`, the first run creates a key in `OUTPUT/keys` and the image trusts it. Release builds pass the maintainer key: `-k path/to/dgrace-6aaddbb6.rsa`, with its `.rsa.pub` next to it. Every image also trusts the public key of NanoKVM OS releases in `firmware/alpine/keys` and uses the release repository `https://nkos.pesin.pro/repos/nanokvm`, so an image built from this repository updates to later releases. +The APK packages and indexes are signed, and the packages and rootfs steps need keys. Release builds pass both maintainer keys: `-k path/to/dgrace-6aaddbb6.rsa` (RSA, with its `.rsa.pub` next to it) and `-e path/to/nkos-release-ec-b8e89b66.key` (ECDSA P-256, with `nkos-release-ec-b8e89b66.pub` next to it). Both public keys must be in `firmware/alpine/keys`, or `build.sh` stops before the first step. Local test builds pass `-d` instead: the first run creates the test keys `OUTPUT/keys/nanokvm-test-*.rsa` and `nanokvm-test-ec-*.key`, and the image trusts them, so never publish those packages or images. apk does not tie a key to a repository: a device trusts every key in `/etc/apk/keys` for every repository it uses. + +The packages step signs the packages and the v2 index `APKINDEX.tar.gz` with the RSA key (RSA256: RSA with SHA-256), and writes the apk-tools v3 index `Packages.adb` next to it, signed with the ECDSA key and the RSA key; it checks that each key alone verifies `Packages.adb`. Devices up to 2.0 read the v2 index; images from 2.5 on read `https://nkos.pesin.pro/repos/nanokvm/riscv64/Packages.adb`. + +The package `nanokvm-keys` installs the public keys of `firmware/alpine/keys` (with `-d`, also the test keys) in `/etc/apk/keys` and owns them, so a later release can add or remove a key; on an older device it also moves the NanoKVM repository line to the v3 index. The rootfs step copies the keys into the root before it installs `nanokvm-release`, and apk takes over the identical files; the step fails if a key in `/etc/apk/keys` has no owner. Publishing a release, moving to the ECDSA key and replacing a key are described in [firmware/alpine/README.md](../firmware/alpine/README.md#signing-keys). The root file system uses official Alpine packages (`BUILD_PROFILE="stock"`). Releases up to v2.0-b7 replaced busybox, coreutils, openssl, lz4 and zstd with C906-tuned builds (`c906-scalar`); that overlay is not built here. @@ -56,7 +60,7 @@ The root file system uses official Alpine packages (`BUILD_PROFILE="stock"`). Re | `sources.lock` | Every upstream input: archives by SHA-256, Git trees by commit and tree id | | `build.sh` | All build steps | | `expected.sha256` | Hash of every output in `images/`; `build.sh verify` fails if an output is missing, differs or is not listed | -| `packages.list` | The contents of the six APK packages: each file, its mode and where it comes from | +| `packages.list` | The contents of the seven APK packages: each file, its mode and where it comes from | | `kernel/` | `config` and patches for kernel.org Linux 7.2.9 | | `modules//` | Patches for the SOPHGO media drivers (`osdrv`), AIC8800 and RTL8733BS Wi-Fi and cryptodev; `sg2002-aes/` is the CryptoDMA driver source | | `uboot/` | `defconfig` and patches for U-Boot 2026.07 | diff --git a/platform/build.sh b/platform/build.sh index 7589ca6e..51e6dde5 100755 --- a/platform/build.sh +++ b/platform/build.sh @@ -2,7 +2,7 @@ # NanoKVM OS build: everything in the SD card image, from the inputs pinned in # sources.lock. # -# platform/build.sh [-o OUTPUT] [-j JOBS] [-k KEY] [STEP...] +# platform/build.sh [-o OUTPUT] [-j JOBS] [-k KEY -e ECKEY | -d] [STEP...] # # Steps, in order (default: all of them): # fetch download and verify every input in sources.lock @@ -21,8 +21,8 @@ # tools devmem, nanokvm_update_edid, EDID profiles, board probe, USB audio # firmware Wi-Fi, regulatory and video codec firmware # verify compare the outputs with expected.sha256 -# payloads contents of the six nanokvm-* packages, from packages.list -# packages signed APK repository of the six packages +# payloads contents of the seven nanokvm-* packages, from packages.list +# packages signed APK repository of the seven packages # rootfs Alpine 3.24 root file system with nanokvm-release # image SD card image # Not in the default list: @@ -31,9 +31,14 @@ # to subordinate IDs (see packages) # # Checked outputs are in OUTPUT/images (default: build/platform/images), the -# APK repository and the SD card image in OUTPUT/release. -k names the APK -# signing key (an abuild .rsa private key with its .rsa.pub next to it); without -# it, the first run creates one in OUTPUT/keys. +# APK repository and the SD card image in OUTPUT/release. The packages step +# signs the packages and APKINDEX.tar.gz (RSA256) with -k KEY, an abuild .rsa +# private key with its .rsa.pub next to it, and the apk v3 index Packages.adb +# with -e ECKEY, an ECDSA P-256 private key NAME.key with NAME.pub next to it, +# and with KEY. Both public keys must be in firmware/alpine/keys: the image +# trusts those, and nanokvm-keys owns them. For local tests -d uses test keys +# in OUTPUT/keys instead, created on the first run; such an image also trusts +# them, so never publish it. set -euo pipefail export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C TZ=UTC umask 022 @@ -42,15 +47,50 @@ repo=$(dirname "$here") out=${NANOKVM_PLATFORM_OUT:-$repo/build/platform} jobs=$(nproc) key= -usage() { sed -n '2,35p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2; } -while getopts o:j:k:h opt; do - case $opt in o) out=$OPTARG ;; j) jobs=$OPTARG ;; k) key=$(realpath "$OPTARG") ;; *) usage ;; esac +eckey= +devkey= +usage() { sed -n '2,41p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2; } +while getopts o:j:k:e:dh opt; do + case $opt in + o) out=$OPTARG ;; j) jobs=$OPTARG ;; k) key=$(realpath "$OPTARG") ;; e) eckey=$(realpath "$OPTARG") ;; + d) devkey=1 ;; *) usage ;; + esac done +[ -z "$key$eckey" ] || [ -z "$devkey" ] || { echo "-k and -e exclude -d" >&2; exit 2; } +# The public half of the EC key: NAME.pub next to NAME.key. +ec_public() { echo "${1%.key}.pub"; } shift $((OPTIND - 1)) out=$(realpath -m "$out") steps=("$@") [ "${#steps[@]}" -gt 0 ] || steps=(fetch toolchain kernel modules uboot opensbi fip initramfs boot native system server web tools firmware verify payloads packages rootfs image) +# Fail before a long build, not at the signing steps. +case " ${steps[*]} " in + *" packages "* | *" rootfs "*) + [ -n "$devkey" ] || { [ -n "$key" ] && [ -n "$eckey" ]; } || { + echo "APK signing keys required: -k path/to/key.rsa -e path/to/key.key, or -d for local test keys" >&2 + exit 2 + } + # Devices trust the keys of nanokvm-keys; a release key must be one of them. + [ -z "$key" ] || cmp -s "$key.pub" "$repo/firmware/alpine/keys/$(basename "$key" .rsa).rsa.pub" || { + echo "$key.pub is not in firmware/alpine/keys under the same name; add it there," >&2 + echo "and publish it in nanokvm-keys before signing with it (firmware/alpine/README.md)" >&2 + exit 2 + } + if [ -n "$eckey" ]; then + openssl pkey -pubin -in "$(ec_public "$eckey")" -noout -text 2> /dev/null | + grep -qxE 'ASN1 OID: (prime256v1|secp384r1)' || { + echo "-e: $(ec_public "$eckey") is missing or not an ECDSA P-256 or P-384 public key" >&2; exit 2; } + for pub in "$repo"/firmware/alpine/keys/*.pub ""; do + [ -n "$pub" ] || { + echo "$(ec_public "$eckey") is not in firmware/alpine/keys; add it there," >&2 + echo "and publish it in nanokvm-keys before signing with it (firmware/alpine/README.md)" >&2 + exit 2 + } + ! cmp -s "$(ec_public "$eckey")" "$pub" || break + done + fi ;; +esac # Keep builds inside OUTPUT from finding an enclosing Git checkout. export GIT_CEILING_DIRECTORIES=$out @@ -588,7 +628,7 @@ firmware() { cp "$out/firmware/wireless-regdb-2026.09.03/LICENSE" "$f/licenses/wireless-regdb-LICENSE" } -# Assemble the six package payloads from packages.list. +# Assemble the seven package payloads from packages.list. payloads() { local p=$out/payloads gen pkg type path mode src dest file rm -rf "$p" @@ -679,7 +719,7 @@ in_userns() ( exec {mapped_fd}<>"$sync/mapped" unshare --user --mount --pid --fork --kill-child \ bash -c 'echo > "$1/ready"; read -r _ < "$1/mapped"; shift; exec "$@"' sh "$sync" \ - env NANOKVM_USERNS=1 bash "$here/build.sh" -o "$out" -j "$jobs" ${key:+-k "$key"} "$1" & + env NANOKVM_USERNS=1 bash "$here/build.sh" -o "$out" -j "$jobs" ${key:+-k "$key"} ${eckey:+-e "$eckey"} ${devkey:+-d} "$1" & pid=$! deadline=$((SECONDS + 30)) until read -r -t 0.1 -u "$ready_fd" _; do @@ -749,30 +789,52 @@ in_chroot() ( chroot "$root" "$@" ) -# The APK signing key: -k KEY, or one created on the first run. +# The APK signing keys: -k KEY and -e ECKEY, or with -d local test keys created +# on the first run. Never a key nobody chose: an image trusts it for every +# repository. signing_key() { local tmp - if [ -n "$key" ]; then + if [ -n "$key" ] && [ -n "$eckey" ]; then keyfile=$key + ecfile=$eckey + elif [ -z "$devkey" ]; then + echo "APK signing keys required: -k path/to/key.rsa -e path/to/key.key, or -d for local test keys" >&2 + exit 2 else - keyfile= - [ ! -d "$out/keys" ] || keyfile=$(find "$out/keys" -name '*.rsa' | head -n 1) + echo "Signing with local test keys: do not publish these packages or this image" >&2 + # Only keys this option created; never another key left in OUTPUT/keys. + keyfile= ecfile= + if [ -d "$out/keys" ]; then + keyfile=$(find "$out/keys" -name 'nanokvm-test-*.rsa' | LC_ALL=C sort | head -n 1) + ecfile=$(find "$out/keys" -name 'nanokvm-test-ec-*.key' | LC_ALL=C sort | head -n 1) + fi + mkdir -p "$out/keys" if [ -z "$keyfile" ]; then - mkdir -p "$out/keys" tmp=$out/keys/new.rsa openssl genrsa -out "$tmp" 4096 2> /dev/null openssl rsa -in "$tmp" -pubout -out "$tmp.pub" 2> /dev/null - keyfile=$out/keys/nanokvm-build-$(sha256sum < "$tmp.pub" | cut -c1-8).rsa + keyfile=$out/keys/nanokvm-test-$(sha256sum < "$tmp.pub" | cut -c1-8).rsa mv "$tmp" "$keyfile" mv "$tmp.pub" "$keyfile.pub" echo "Created APK signing key $keyfile" fi + if [ -z "$ecfile" ]; then + tmp=$out/keys/new-ec + openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$tmp.key" 2> /dev/null + openssl pkey -in "$tmp.key" -pubout -out "$tmp.pub" 2> /dev/null + ecfile=$out/keys/nanokvm-test-ec-$(openssl pkey -pubin -in "$tmp.pub" -outform DER | sha256sum | cut -c1-8).key + mv "$tmp.key" "$ecfile" + mv "$tmp.pub" "$(ec_public "$ecfile")" + echo "Created APK index signing key $ecfile" + fi fi [ -f "$keyfile.pub" ] || { echo "APK signing key: $keyfile.pub is missing" >&2; exit 1; } + ecpub=$(ec_public "$ecfile") + [ -f "$ecpub" ] || { echo "APK index signing key: $ecpub is missing" >&2; exit 1; } keyname=$(basename "$keyfile" .rsa) } -# Build the six packages with abuild in an Alpine riscv64 tree. +# Build the seven packages with abuild in an Alpine riscv64 tree. packages() ( local b=$out/apk-builder register_qemu @@ -783,7 +845,12 @@ packages() ( cp "$repo/scripts/build-alpine-packages.sh" "$b/build/src/scripts/" cp -r "$repo/firmware/alpine/packages" "$repo/firmware/alpine/release.env" "$b/build/src/firmware/alpine/" cp -a "$out/payloads" "$b/build/payloads" - trap 'rm -f "$b/root/.abuild/$keyname.rsa"' EXIT + # A test image trusts the test keys too; nanokvm-keys owns them there. + if [ -n "$devkey" ]; then + install -D -m 0644 "$keyfile.pub" "$b/build/payloads/keys/etc/apk/keys/$keyname.rsa.pub" + install -m 0644 "$ecpub" "$b/build/payloads/keys/etc/apk/keys/" + fi + trap 'rm -f "$b/root/.abuild/$keyname.rsa" "$b/root/.abuild/index-ec.key"' EXIT install -m 0600 "$keyfile" "$b/root/.abuild/$keyname.rsa" install -m 0644 "$keyfile.pub" "$b/root/.abuild/$keyname.rsa.pub" install -m 0644 "$keyfile.pub" "$b/etc/apk/keys/$keyname.rsa.pub" @@ -794,11 +861,28 @@ packages() ( SOURCE_DATE_EPOCH="$(git -C "$repo" log -1 --format=%ct)" PAYLOAD_ROOT=/build/payloads \ REPODEST=/build/repo SRCDEST=/build/distfiles ABUILD_FLAGS='-F -d' \ /bin/sh /build/src/scripts/build-alpine-packages.sh stock - rm -f "$b/root/.abuild/$keyname.rsa" + # The apk v3 index next to APKINDEX.tar.gz, signed with the EC key and, + # while 2.0 devices that trust only the RSA key may read it, the RSA key. + # mkndx checks the packages with the builder's /etc/apk/keys. + install -m 0600 "$ecfile" "$b/root/.abuild/index-ec.key" + in_chroot "$b" /bin/sh -c 'cd /build/repo/stock/recipes/riscv64 && + /sbin/apk --sign-key "$1" --sign-key /root/.abuild/index-ec.key mkndx \ + --description "$2" --output Packages.adb ./*.apk' \ + sh "/root/.abuild/$keyname.rsa" "NanoKVM OS $version" + rm -f "$b/root/.abuild/$keyname.rsa" "$b/root/.abuild/index-ec.key" + # Each key alone must verify the v3 index. + rm -rf "$b/build/verify" + mkdir -p "$b/build/verify/rsa" "$b/build/verify/ec" + install -m 0644 "$keyfile.pub" "$b/build/verify/rsa/$keyname.rsa.pub" + install -m 0644 "$ecpub" "$b/build/verify/ec/" + for pub in rsa ec; do + in_chroot "$b" /sbin/apk --keys-dir "/build/verify/$pub" verify /build/repo/stock/recipes/riscv64/Packages.adb + done rm -rf "${rel:?}/apk" mkdir -p "$rel/apk" cp -a "$b/build/repo/stock/." "$rel/apk/" cp "$keyfile.pub" "$rel/apk/$keyname.rsa.pub" + cp "$ecpub" "$rel/apk/" ) # Alpine 3.24 root file system: the minirootfs with nanokvm-release from the @@ -809,14 +893,19 @@ rootfs() { signing_key rm -rf "$out/rootfs" alpine_tree "$r" + # apk must trust the keys before it can install nanokvm-keys; it then takes + # over these identical files (/etc/apk is not a protected path). install -m 0644 "$keyfile.pub" "$r/etc/apk/keys/$keyname.rsa.pub" - for pub in "$repo"/firmware/alpine/keys/*.rsa.pub; do install -m 0644 "$pub" "$r/etc/apk/keys/"; done - printf '%s\n' /mnt/nanokvm-apk/recipes https://dl-cdn.alpinelinux.org/alpine/v3.24/main \ + [ -z "$devkey" ] || install -m 0644 "$ecpub" "$r/etc/apk/keys/" + for pub in "$repo"/firmware/alpine/keys/*.pub; do install -m 0644 "$pub" "$r/etc/apk/keys/"; done + # The v3 index, as on the device. + printf '%s\n' /mnt/nanokvm-apk/recipes/riscv64/Packages.adb https://dl-cdn.alpinelinux.org/alpine/v3.24/main \ https://dl-cdn.alpinelinux.org/alpine/v3.24/community > "$r/etc/apk/repositories" BIND=$rel/apk:/mnt/nanokvm-apk in_chroot "$r" /sbin/apk --no-cache add nanokvm-release rmdir "$r/mnt/nanokvm-apk" - # The repositories of the device: NanoKVM OS releases and Alpine. - printf '%s\n' https://nkos.pesin.pro/repos/nanokvm https://dl-cdn.alpinelinux.org/alpine/v3.24/main \ + # The repositories of the device: the v3 index of NanoKVM OS releases + # (nanokvm-keys switches older devices to it) and Alpine. + printf '%s\n' https://nkos.pesin.pro/repos/nanokvm/riscv64/Packages.adb https://dl-cdn.alpinelinux.org/alpine/v3.24/main \ https://dl-cdn.alpinelinux.org/alpine/v3.24/community \ '@edgecommunity https://dl-cdn.alpinelinux.org/alpine/edge/community' > "$r/etc/apk/repositories" rm -f "$r/etc/resolv.conf" @@ -829,6 +918,14 @@ rootfs() { done [ -d "$r/lib/modules/$(cat "$r/usr/lib/nanokvm/boot/kernel.release")" ] || { echo "rootfs: boot.sd and modules differ" >&2; exit 1; } + # Every trusted key belongs to a package (nanokvm-keys, alpine-keys), so + # that updates can add and remove keys; this also catches an .apk-new. + for pub in "$r"/etc/apk/keys/*; do + awk -v name="${pub##*/}" '/^F:/ { dir = substr($0, 3) } + /^R:/ && dir == "etc/apk/keys" && substr($0, 3) == name { found = 1 } + END { exit !found }' "$r/lib/apk/db/installed" || { + echo "rootfs: no package owns /etc/apk/keys/${pub##*/}" >&2; exit 1; } + done for path in dev proc sys mnt/nanokvm-apk; do ! mountpoint -q "$r/$path" || { echo "rootfs: $path is still mounted" >&2; exit 1; } done diff --git a/platform/expected.sha256 b/platform/expected.sha256 index bc92ece7..ebaf13ba 100644 --- a/platform/expected.sha256 +++ b/platform/expected.sha256 @@ -276,9 +276,9 @@ a13df92024a7de3320f1fb109dd3ced4f7b54b09f08b66b4ead807bcbd665d45 native/libvpss bb805115945d744eeb86218d1a6ce5c6a7d3cb72ee602a8c8e09d91fbc91df94 opensbi/build-manifest.json 6df03b97a913f0b3a14d2eb8f0e46f558c7a09cca97d50c4649f1938a308ac22 opensbi/fw_dynamic.bin dba944a8ed999dd134feced5797304166ddb20ce433f932f5c86bf57b849120a opensbi/sg2002.dtb -790802687d2c2f049d2786d0b3f1414fbda32458729d999ad1296d3766798c7b server/NanoKVM-Server -9d94f8377b639842a77af97e43a8f3e6ce2b9e87862619f33687632622e93e48 server/nkos-apply-updates -1fbd905fab9cd46d2f550c003dc4a1dfca96fb50286d2c46a2de12e401f7b9f4 server/nkos-update +e3fc55799abbf514b46190697efbf0aaf7a28976246f5fd32bd886bdc789f54d server/NanoKVM-Server +f575cf6b7620b61922889833399bc1f4b2553a8d436fb48a1e6916eb709c86bb server/nkos-apply-updates +83c7e22c6d6c2b61931a45066f9b931f91c61948fa8074dbaa2d3f40f062b4c6 server/nkos-update 2e2934ee5fcbd6fef9f422bdf057938e8ce03167272097d0f4a6888c54ce32fd system/kvm_system bbfc9843646d483c334664f651c208b9839626891d8f17604db2146962f43548 tools/busybox-LICENSE fc838e52cd115f72989338efcf194870d3b30825a1feb5e3944a371d80348bd6 tools/devmem @@ -303,187 +303,202 @@ a65a021f18ceb2672df28fd387860a7b5c6a6d5a36a2167ba690a782aa72a4af tools/usb-audi 01e1167d54a096d123cf6dfbbeb19587278845c6481d2d66d545669846079551 tools/usb-audio/opus-COPYING 1401e1769d37424806c1f453124a7220b7fe544c82cdc68d3137c64451baf61e tools/usb-audio/tinyalsa-LICENSE bff4144429aea728fc2ac1fa19df73b8f95f231d5842f5a841005222530cd883 u-boot.bin -d68e8c47241a006af4162c2a922c05f83426c58bfb2cbcd877dfc38a3ae77c5a web/assets/ActionButton-CR_gFDfD.js -b7c4e8baa911ce9fb0927d7cfe7dc655dfbd5964eaf8f7d705160c4653827f57 web/assets/Addon-DuUvONWG.js -18785a8ca39bef1bd0f566b0bd3948e8228546477b1f31cd91bd257c506f56bf web/assets/CheckOutlined-DEjMQ_Tq.js -d21648c7b9bf997879c7cf578d4c5ddbbc90cd796db2460d9d575609ef6cdae7 web/assets/CloseOutlined-BBToYvlG.js -e4b05ba6003bf5b0f63735836c9dd5a0f5f2a471980c6742e7386a7b5a8db217 web/assets/ContextIsolator-ZBpQ1Wjm.js -22d17fc51e6919ebc3acbf93cf7c64fadec7b4fd4c62c20e895592f4f4aaea04 web/assets/EllipsisOutlined-LP7szExP.js -a5108ae3de8986d09690c57bbf2d4fa57f42fa339c25e8a0fffb25c1501d5b25 web/assets/ExclamationCircleFilled-YC2vhaMl.js -c9031a98eec08e9103d40795986aaac8a765bda8d74770533f8752a4ebbcd3ba web/assets/InfoCircleFilled-ypjWKpZn.js -8046588e467ac6e9344b07f7f8d0831b14a23bb3719f35c355f1948203c31e4f web/assets/LockOutlined-Dj2eHHH_.js -395064ed37dc36a0277970a5df9bdb8c180e503fbd09c0309c25f8fe9633487a web/assets/LogoutOutlined-BJVuAV7-.js -c9995e65b09b276c9f0433f6ac4a41aa598d2236925a3ac68300a0deef2cbcaf web/assets/PlusOutlined-D-c4vQpR.js -a514142bdb13c6b1e86cf9efec444d710c47721297f45508d25058b71a77c331 web/assets/Provider-DTq9fAYg.js -822876c15e1e8b777297d6d5de199c39527b6a940a5744ada8379b22e3ce9416 web/assets/PurePanel-B8BZ4W7L.js -d356098942a3be87a9db15a774ece22d964e2eec5d57ac1281399774e103bfc1 web/assets/SearchOutlined-DxG5zgHi.js -687aa6851759622e7bf61ce188be41fc3c05dd3537c2f74fed5e1089e10b39c6 web/assets/about-mQI8hTpr.js -8ffdfb0275f3413700bfb19b5de2ace0ca17f8c1e6ce664d0de5017e7e8076aa web/assets/account-cwqaZg24.js -6ef17bf59e4058a0b685a8a5b893b3bf4a1d3c8e35180ceffef5a35d22363e6a web/assets/addons-rGEZOMWe.js -d8fd69323ceff02af484a4c9b37500f9acc85989236099c7c8c524199cb3ac76 web/assets/ai-control-CHOL12Nj.js -b42d153d3962570a5b4bfb968da1c0df01d7f1fe9e2e7e13bf48dbb920627cc9 web/assets/alert-BlyaGiG2.js -b0b67251dfa24311d3fc3b58aea374f8ddd531a50add77839cdca57120c6a7ad web/assets/appearance-CanGyuzE.js -b577789d572b78eaae3acae7bb16e311e70b162647165ebb670d26285eba05bb web/assets/arrow-up-right-C4IeVH8D.js -c13fcfcbe9a00b711c053d323d1fd031d709a57cc185ed30970fdf40c6f79849 web/assets/auth-CWc30gPh.js -81212707686fa6272d3da32a2e240444f5d95a8cc3ae360a4b7f2b913092d5af web/assets/auth-DpeSNyz5.js -32552a8a5cd686a73945dcf6bbf31bbf08ccce5c65bc5cb5c9c4f8613cbfdcb3 web/assets/branding-CmpczUCu.js -97546e847b48e9998966385854e3c59cd5b76aec8963e0a560ac86ca754c7836 web/assets/button-BHBw59DU.js -3559ba629d3737737c5da04985023ad6bf6e4ad68678ba3c8d729eacb0c76beb web/assets/ca-B3B--_SH.js -6c29ab5af814420221b7083d5c809b80d99d8fff0fbf0440647751066a60acd4 web/assets/card-DsGprIh7.js -08d1723e8f9fe37899235f2dc8ddc56e165e917e9799ce836fba1b57629b11de web/assets/check-6tiePKPC.js -8046a2cbaef5edb3fb093a9f63a92798469ca4dba764c3ac232526301d66b0cf web/assets/checkbox-B048vBZI.js -5f6b3aab0b65335a50c7b04468973372e7f482d689e0a9489c2eac0ce7ba2054 web/assets/chevron-down-BPLhz6Gt.js -6daac402b180bfd4e4cce95ba778644f7097d2e43c91a35319d9203c3513f785 web/assets/chevron-up-DYHyqMcV.js -db5ef127508cc3d154c785c44c63b2c275b5cc2572f27253062efb0dbeeb749c web/assets/chunk-OB3PAWPO-D2tLoo1B.js -2b742cc40bae9de0ed11c09edc2f1b1e72b9f3166da3e30ebbcfcae1bfd4bb6c web/assets/circle-alert-LdIT7WIS.js -d610d933ba7098c0fbf054d00447fd57d87dad0402fc6607eff96c339bf12616 web/assets/clapperboard-Borj99oZ.js -25e18d3745636607cbeb412f0d0fd8b730272dc032c4ba4a3120ee833b5166ef web/assets/colors-B0lCvNQ9.js -0ee3272ce62a4dab5b70f304cc9f49ec79b7a188159371d6d89be788fde1d274 web/assets/compact-item-LpdEDpLZ.js -6d601fc3de8c89d4f7d34617fd7177d5f3d4950a215cb6f2d0012039690e6e62 web/assets/config-provider-D4W3MNKl.js -97a12bf327e46bbbf241f0328963ea32708ee26f814e176b5e70ab3400058ef5 web/assets/constants-TaqAHL_7.js -1fff0ef040539b7ba728ffc94e4dd54c68051547575587aba42cb1561767da96 web/assets/cpu-Bagpt4pC.js -b9fd31522ee519fc5092a24e536e84ae421d0f155b99f16fe07569d55288689f web/assets/createLucideIcon-SPwtTyVj.js -17b2b5ddbb7b0b5f5eba8e69dc20703f1b8370a0d2d54a5ef38d00c3e11b41c1 web/assets/cz-BpVvBsIk.js -433f863fce9b8a8d611a7d1a77aa2c1312803d8b0fd7dbef0ae7382a4a8e7760 web/assets/da-CvBxF9oq.js -7d1307e7161183c75ff8db1f6ac4dadbfb81ff0d37337edd0acf704115d4bcc2 web/assets/dashboard-CaVbWYQ1.js -7a92c0f9863f1b3094f0f02b395d3c2dfe46608f7611a41076b687ee785a4421 web/assets/date-time-C-AvW_Bn.js -ce99242a7fddbbb3db57c7db5a199836828c0e9d7a6df790a73fff85f3fb9f65 web/assets/date-time-C-u5DRxf.js -94554068d4e9877ade548fbff98b146f6fed7bed2f0e3d463328237e6cce0f01 web/assets/de-DkWCDSG5.js -29700468bc285c34fa797411dc75ca57a63f5881b3ab418b8f9c766474c61cb2 web/assets/desktop-BCb6RBBA.css -a71c88880aeb272205463683f36c14533e41dc1014f449f2f64e86f969852e9b web/assets/desktop-DuHhMd3s.js -c1f5c92614ab9ef11495ddc839fc76f39deba76bca20774a4c9e29d84920f21b web/assets/device-vloLmtwo.js -b6dd25250e6c1e88c35b182130983797057f7889e9331d917001d1beda138968 web/assets/diagnostics-I8A8sfDW.js +d1263d98f58742bf615417b6c2d3f93b5acf117eae4acd7bcc9d67e3a448a191 web/assets/about-BEuQro0r.js +23006b45fc1567b8b213ac3be7172eb911b6e449db628883417c88ad4438462a web/assets/about-BEuQro0r.js.gz +33d02735f9b099250ff0279219caa7aa0e2c1066352061f407c59af0abd0fbe8 web/assets/account-D2OmnysP.js +2a8719f3a510cd1f1bbb7b488a2038d1852de0a94d54b14351a9a928089f4e05 web/assets/account-D2OmnysP.js.gz +883f24e047b593fbdd142fbba155d8fec28b048610915a6e1104e98430e95574 web/assets/addons-DGQ1tgCY.js +23e1abfda71aa4ee6e1aabd20af802ad81c679a75e5a973599e5c508f0459414 web/assets/addons-DGQ1tgCY.js.gz +fdb86e93874d658c17ebd0f57bc62ff953cff3a561e9dfc65092f013225f807e web/assets/admin-items-BOPUYtLL.css +9572369340064f06f90b07086c1d12c67d0091758ba724771aad27d17401353e web/assets/admin-items-BOPUYtLL.css.gz +3f64979dda736d51ca7419edee89efb1b5f592c2d2d4611bb315cca13949cb99 web/assets/admin-items-HulA-J_h.js +57e1c1c6c592483ed64624878ee9201999e407a8546a4557810a4000bc55591b web/assets/admin-items-HulA-J_h.js.gz +831fc08a7eb70ce6d78b10327eeb3010ef63c6917d534b674a4d5f3d339e1717 web/assets/antd-BWQXdMjQ.js +eee1dacffe1f8ae02a40be837e32a2c6baea6e43dc17087d2cff3269da33f91a web/assets/antd-BWQXdMjQ.js.gz +7b6a1d2d2f586ded1dcbb7c34f74839014925b3f6f82f8a31ae5b16493b03448 web/assets/antd-Bi033KD4.js +251317e02527fbef9372db0033ca83b85cb6aaa1443b40b40d27dffdd6c91c27 web/assets/antd-Bi033KD4.js.gz +1a051f7b5a18636df4960e814bb7a07b2b8a0035c4be1396f2f05c446c910b47 web/assets/antd-BmeV0-Hw.js +b69cda4ad0ddaf6d3377f04af2898f2e82dfd163f4c6e61541d324821be06d83 web/assets/antd-BmeV0-Hw.js.gz +918dcabca3a59d50e791bca8b1edefcaa4a07505aaba67816a769202ac23dbfe web/assets/antd-BmjTfbxQ.js +860e2050d1b80fb7e8aef7fd2b7e7828117edcc2180d401a08c35d965b26eb19 web/assets/antd-CHuSgqfN.js +88621b0c347ce7b9e3af86629a21ef9111d6222794f6d4612303a45555608689 web/assets/antd-CHuSgqfN.js.gz +703c68e69d98bb3a9219651dda5d24a16a6ff95474c77042b703d51bb73d7407 web/assets/antd-CR43n19K.js +f7eb91bf732d7c74836b0365009343aeca8216ca8be340c541fb67033878b983 web/assets/antd-CR43n19K.js.gz +4a04b86ec0f4d09cf283b9fd73f5d1fb36fb98610613b87ea94db90e34e911f3 web/assets/antd-CYCpsN_Y.js +fcc6784db51541b920bb3ee009cb29550f2291858b321b430a4dc5c49bdda76b web/assets/antd-CYCpsN_Y.js.gz +38b34784ff8a28e7bb7ceab27fa5c842c038bbe55b7dde14ae2786506e2d43a9 web/assets/antd-Cn1YwY0B.js +1e83bed4e29f4cc6912b9e7e089103ab9a62e78e094c21b8336e6f18a6ea69b9 web/assets/antd-Cn1YwY0B.js.gz +98b1343aa371e11ef8b986c2927f3cffba22fc461e73575d74fd176a4ea79038 web/assets/antd-CnNKKUtY.js +a9445512daa9f92da9c5e5864f789d581d0dffa32a282558701d64f3660ef260 web/assets/antd-CnNKKUtY.js.gz +42522870351995448809584590ccc3d3a21ee266f436b896d643610af3d4abb0 web/assets/antd-DyvCHtfM.js +9f0e0e6b6c378fbad3d4e67efa58c2125c63c6ce02060fe1e63a6791d7f14c76 web/assets/antd-DyvCHtfM.js.gz +85ce2f27a561a42d9be3b7d53da19cb0c4782e24d185e233594bf34837cbb379 web/assets/antd-MRsSrZx0.js +e1c8a5840d8b5dff6d0f85351a6022dd7405c6c8e7a5d773e6be6a3aba45f9ca web/assets/antd-MRsSrZx0.js.gz +e730596ee3e8097f03ad6ce36bbd4d8c22dc09eb9418e075025f048dbe22df72 web/assets/antd-cPaSfDgA.js +14fb3ec546d5bd409fe2899177710ddb01385b3bcc100f5ff4275843903e952c web/assets/antd-cPaSfDgA.js.gz +b5d2b5477ac0dbb71dba5cb6087b82b3982f34f55c315d1bf72eb45a666ea602 web/assets/antd-icons-DTMJ6jmt.js +91541bd1b7751b89d7fa620f33f9ab212fb85afeca5c7de7897de10bdc2aa1aa web/assets/antd-icons-DTMJ6jmt.js.gz +aad4658fb0a349aee84f44448de5e85b3c7b7f3fa7287948de166fd834168c86 web/assets/antd-o3eLwnFy.js +2050bfd36c491367506efa7ce2ff54d5e41c3c547ade49f147dcc976420e6dd4 web/assets/antd-o3eLwnFy.js.gz +bf67c76415e06561e0c334260071bfb23826700b3da815ca50d5fabdaa57f5c1 web/assets/app-G5uacEnK.js +9b9a38ac235db44ba5b3b6da0702d43b49e12128cbf70d024d81c8e1cc5adda1 web/assets/app-G5uacEnK.js.gz +6e039b3f0feb8d66e604487bd7e86c1b9c73e8a7e7bafc74bc930db9f8407d59 web/assets/appearance-SDcUDk8g.js +38413e87a09a8e3ddb2e450830ec3a344ac214becabcd046f95d22cee11e08a0 web/assets/appearance-SDcUDk8g.js.gz +d1219c6da4d91dcb9bfcc68df51e400d76f5f8947c7f02cd6e242573c5d2b417 web/assets/ca-DPfDEZJK.js +559243645ab5eabf49b722078b14d1df02693a4734cc40a7a2484a95fefcd296 web/assets/ca-DPfDEZJK.js.gz +d17a6827b2f1d3b808df87ee8ee0e44277eaeef65b3117862403f9206b6bf39c web/assets/capture-control-CpyHmWfG.js +35ccddc5ce4ebdb908e9239ef8a1c2b0cb9bb1fe2532c60912971e1c31049c65 web/assets/confirm-CqIu9qx1.js +5971129c3807526f73c185bba8e78028e206dbd648f57a5cb73362601bebb408 web/assets/cz-B_MOjO7F.js +3e62ab6a4389348ffd193d2b425025d9edc19ccea29815ff72c7c29720e64cce web/assets/cz-B_MOjO7F.js.gz +9c8f96f52942120092200e69f4bde89d0d22ade1b7a40808858005a0a9cf89c9 web/assets/da-D7LH2H4g.js +4f8a851eaf9a0cfecc5420f1074c59b704274d520b0704c4c1a8ac9c4ac5be5a web/assets/da-D7LH2H4g.js.gz +1bd69beb3dfc3a66d01e02931718028ce4a281b305eb0266f9fc50d06dce6c94 web/assets/dashboard-BUH3dQjT.js +f190f2f2b25dcc0422d0b2d9c26335fcd18adcbb71ea55c80610d638205d49ee web/assets/dashboard-BUH3dQjT.js.gz +e058b5e735c568151d071db5a62c270be92f93da60f870bd426ff93e5a20ef68 web/assets/date-time-CxJ7TwKM.js +92dcbc4ab15d0aca3c906a8f51e04b618758f6ca7c53dfd2a771912d5afa5335 web/assets/date-time-CxJ7TwKM.js.gz +5715d73e9fbaa9c66db18ca15d3f4e4427b0bb3bea3148c142aaf1694c3f97da web/assets/de-B69c2YZq.js +0ea281c620545c72ee6b3668aaeae214c499a9b4ef9cdf4da3616d9faa809535 web/assets/de-B69c2YZq.js.gz +9636319456a77389b23ded93cc7c647007dd505b940baa564afd434fec0840ad web/assets/desktop-ClrKTFYh.js +c710a17375e93150a56ad8cb407508de70b323af1b2350d374384ae04e1543a9 web/assets/desktop-ClrKTFYh.js.gz +8a67841fea5913b9ce0637ac8e0bc7fd1a5659151a0c17bf2baf52a583be5aee web/assets/desktop-DCYxGBuo.css +1fcdab455435589ef43209edebbc829e2bff9b3e43005256ac35c7b4eb720434 web/assets/diagnostics-njN6CYZW.js +bd17d32b2cd688223b5b7a0a57f846c9837f9a5790cdce4928d194c33badda85 web/assets/diagnostics-njN6CYZW.js.gz b872871b2454af4126da0e718780c1011c62cc526513389644bf277bccb89265 web/assets/direct.worker-OEgV2J-Z.js -5f7cab10ccf973d4e4e75050898280b7fe0e4e12ecb55fbc216a4b221cd65f11 web/assets/dist-CsgBC_Zx.js -6f8595647c62a214950934c0ea2dff1ccec84d745808ee6cab434fb0fb49cc3b web/assets/divider-B6NUFuAh.js -0bd9b6972cab21592973db038fd1866b36afb4e9f2fa00a3529bf4ddeb6da9b6 web/assets/download-BWu9-hhf.js -8d6ae8e4f6b088b34f68278711e9f9e8958e240e1212844181342d7db393e95f web/assets/ellipsis-CF2EqkFB.js -b48614546934da13b9a35b51bc9266c5b90b21fc4c7f38b016cf5caa5fc8a173 web/assets/encoder-Bn8l9lkx.js -f39bc7802e3cfa8ec0dc5ea90be4a78bb2ad626d9a530aac5e4a0db4491ad9df web/assets/encrypt-DOWhXPRc.js -4c24d697d0fdbbae30844a86143d2cff355766e04dabcd02fa225b4ca5f5ce19 web/assets/es-CIE0CyWf.js -05a389887ed515a3b61a28bf9ce5d7a0b874eed97f8c00e4e59cee855c63300b web/assets/es-DPLsNQhR.js -bff3b3fb1e8197346fd8dd4bb5e82f165f53da4ab9612bceadea2acb0bb3ddf8 web/assets/extension-install-result-CZSfBudN.js -90d54d26a58520409bfd82629cc47a23810a1a25911034fbe7c69b2bec19b009 web/assets/external-link-BgFpO1cq.js -c154f2ccfcd410a94a503194afb97722aa94f40eb7725af6b211e6449b224942 web/assets/eye-CkRadHwK.js -1626cbd2db8bf3f47f90611db676f66fe55ae5450f48b38b027ddc8d1f543604 web/assets/fallbackProp-Bfe6RA8u.js -63ed2c8d60b8a0de0da1c1bcd62b463cc39d5f36b1e753b128f38f9739153d1a web/assets/file-up-CYEMtLnj.js -3716df760b56e5bea6d31e11bb92e7295af29164a0a5ca63f09a681deb8a0edb web/assets/form-DWEywnuv.js -b6746b80a0fb56453bf988fb540db7e0b500ab321e99c343940cb5f537bab829 web/assets/fr-8qVqru7T.js -3652dc6652f988c82608d28724e4897d264d35c27a6377236ce8f64589e49fd6 web/assets/handshake-age-B22TqLLt.js -87870538892474f500a0cd0c36c5977a028479e5e446f31cb3c05ae0e6c40916 web/assets/head-xXL6NXt1.js -196854735f8b22a2f161a6daecee485ef60ba802186d9740e150249f9b0f8ed1 web/assets/http-C_D6tEiV.js -7cf54bab06a745da333970c1ca87711fb03967347ee2b318b7792a0a6691c9fb web/assets/hu-DlOzFSoH.js -a149c24b40c906becd3bc593c28d5e38b50c87095bd4bac8bf9e0389d62f7790 web/assets/icon-base-UsPW_thU.js -53063ff8ed42bec8c03b3fbe273beebadac299c82c5cda7c0e9fa6d0fa84349b web/assets/id-CZjhHYgA.js -10d3cc0a06de89fddbdb1df406bc934c738cedc49596e2754c7bee9c4a77dc5b web/assets/index-BaOLvLnH.js -385be3a0d008ca0bcc7474927be5cb1f24906dd872a53ce62480ba652e2a8851 web/assets/index-cRMNgLBd.css -96204d9eb3091810ea32dc4796a5b3b714e91a0cc0574bf9d066aa9c2bff7307 web/assets/index.esm-DvsKJRK-.js -0331365157c00bdf6682eae6333d825de24767e802b86b0ff6413aec10b6d657 web/assets/input-BS_sHr3S.js -cbbe12ef27386d9e7ec47e7d73c8003f2e81475df5afde6413a7272845e62eaf web/assets/input-number-BGITyM0f.js -9e5b7262e904f0f4968d04fac1d73283d0bdb1800834e5cfdb9d31944f332c11 web/assets/it-B-f4gVn4.js -00b2971382ea10562a756f556caed72e099eb4fe9985b7fe38509f141ed5d665 web/assets/ja-Dj7bYtPq.js -94350b64c2e18d0bf25598936867965858f584238093d97fcd3b8f421cd6849e web/assets/key-round-BGOsDlHk.js -5f1c4808515788641abd618579b2c9ba916fbf61c4f0bbd7d74197a48508c5de web/assets/keyboard-DmanAhpe.js -7f11054ee3b0c934bb3319561ca5022c34c001555c9112e5cd15afc182b3390d web/assets/ko-DWGEXqXe.js +2f005833f40e1863085950443e6decec0a48434119d5e9262614296d7c8d4387 web/assets/direct.worker-OEgV2J-Z.js.gz +c8833a8b87a3582d9dfd694ebd7cad288ac5a0afc4f1f7947734d57163f9c621 web/assets/dist-D4W6cL0M.js +d491e0a295ab2fe377af8bce30b010e16f6631590258b116fd431b2c936b9ae9 web/assets/dist-D4W6cL0M.js.gz +ab8866625236c45344f6874779d14bc6e5d56bb9382888f9c316297a6b8d8be8 web/assets/encoder-05AyXH2T.js +d8fa2d4cbfbdf52282a8bc62c9f154c8511137bb3a8740083255e3fdeffa0785 web/assets/encoder-05AyXH2T.js.gz +53644f475459701c14d3333652f5720870307aebb9098ef6f2d7ae03e9b7f7a9 web/assets/encrypt-B_x3HU1U.js +6b836ec7942768e43a5773cc8f860d3948e64c790242279c87e45f1dc718832f web/assets/encrypt-B_x3HU1U.js.gz +f933eb778581b5be58385e5d64f6a3ffb26bb8510fe4556488935a3ca8ed100b web/assets/es-PhtxZt9I.js +a46569b4aca7a47763377d053b9769da8bc095a3dd161048468c895e06bf4705 web/assets/es-PhtxZt9I.js.gz +2fcac828237cbdb49cd6e6fa74b4feef2ba3c3ab204fd3f03d3476b643e31dd4 web/assets/esm-CmIc4CG8.js +3878a7ac02ba51b4f7a530ce410a5ebe6d6bff8b02f0c0ffe6a76039a4654a7e web/assets/esm-CmIc4CG8.js.gz +15df7336424adece22d8f12d98133fd317bf739acea7af1baa85d8b4f01a5156 web/assets/extension-install-result-DoGWD26c.js +759ca488275bef04fbf07f4524c18403128e7215aee3d236a04f7d59bf1014ed web/assets/fr-DGNl_3UX.js +cb3b88df9214994fabdad2f5e0256c1ef9dd84c4f1979617ed22a465fcb954a2 web/assets/fr-DGNl_3UX.js.gz +84e4fbfdf752172645bffce68f19a327339effcc7d4503b130bb2892c30151e6 web/assets/h264-direct-D9SN5Lk4.js +b42f2be93085539c0f237b4c63cb471e25d90f1399181babcc898dd95c67d522 web/assets/h264-direct-D9SN5Lk4.js.gz +da511d3e068f84696b38902bc8882958451cfa3a1f66da233fd51ae67dd40605 web/assets/h264-webrtc-C37--2rH.js +637a98d2ad6cfd2fc34fe171de1ca697e0042c9093f235b1f2efde3d6bd21f26 web/assets/h264-webrtc-C37--2rH.js.gz +323d0daa051f817aa312e76af044211fc0bcf35aad76d33a956d2a846f22b675 web/assets/handshake-age-CmsgdSaR.js +201acb18187f38fb00d157cad4329eee8fd2845ef2dcf57bd89ac1aee7502573 web/assets/head-ARdx_cnl.js +4a68c07f1d9a5be69063fc1e6003d71f35b0ad8a4a22620f7df92d1dec475b32 web/assets/hu-DvHiihE4.js +04150839e3f2eb19ba1bab3fa0bf0e6260b78ea8c78f5f2c415d298cd2bd4e80 web/assets/hu-DvHiihE4.js.gz +7024f7ccb5de1fc6d267b971118e3fb7b26c78b34fa2df5395f7d678cc6b9e59 web/assets/icon-base-BRZztynU.js +3f27966e897f851fa70adfc978f4e29c310189858f6535da3aa3cea9ca669ce7 web/assets/icons-5M-8yseQ.js +aee90b39ab60de1be492275a0ac6dbc8610acf335bb068c7175039351e98f650 web/assets/icons-5M-8yseQ.js.gz +4ed975d0809fcab4d8f942cf1fbf5e640d7df352bc1ffdab9ef7c2eac61cfa8f web/assets/id-CGIUsxPB.js +7771d5f295b3ac0920cf9a465424815b2845a7b3adce93c756d54587f38e6558 web/assets/id-CGIUsxPB.js.gz +300b42bd7530a50af302732f045b06d41e57e7a06ced93566f02ccd1154c76c2 web/assets/index-BQWxznH8.js +44fdec1b433d09793c28c7eccedfc72c8f9ffb14c95a96bb8e01481bcde04aac web/assets/it-LAqoEykT.js +d35434b733ecf1cf9cd3b46f8688c138a9d8c28d85a5b943577629490bda961c web/assets/it-LAqoEykT.js.gz +cc9433d2523ddabf82b16e3d6bbcdd2877fe61cb83304dcb35ce7c974fb425ad web/assets/ja-BbucyEve.js +c716e0f72de8dd470261bb4a014f994152fb9652e854d2973dc05e5d3483bafc web/assets/ja-BbucyEve.js.gz +fade1bf92932d488aad75e9444e59f99c44232486366c852c50103fc24b287c3 web/assets/ko-D4tOeat8.js +2f551704cc3d1da437f1131a410f652ff04952d6af497371d6a94fdbcfd24e4f web/assets/ko-D4tOeat8.js.gz 5cf7fa92e744ad8c4a51c12c3d9ca2b78420fe25f3b642000aa2ce7b0813a8f9 web/assets/launch-5swH-n41.js -4cbe800c5ee3f1885f1cfb153da36b77867ef87573acabefc0a2d4becd7cc447 web/assets/loader-circle-DcgspPPj.js -e3f222fc8da3bff686dbb873683c0fc97ddef401a0d8c6091d408980ee24d97c web/assets/locale-loader-RZ-w0NVp.js -fdac81dce21fa66028306354c36b4f8eb4a49a5e4c6300ebabf46f0cd77ef73b web/assets/localstorage-DvDa4DKO.js -18e429d5c0039b72272d607788a638cdddc4a39b9fc22b36ca2e5cc311c381c8 web/assets/login-BKSuVyuP.js -2b6b08de542e8c779b5e396353c8761abe0f1d4cd04812cfc11f3989f10c112c web/assets/mcp-C_rLC4-z.js -aaa87cdc154ee717a8dce240d3eaf9d9a9a4f63420db764be55d71ceb4bed921 web/assets/memory-OSnXp477.js -337bd40df54ac1477df480d22cb9f6dca1da51b5b7dc54c31e0571e263e9dc46 web/assets/message-BRTuxOg5.js -e1e60b32af38d8a87ce2d993c85ec8a4e8ffb8e68e73519674dfd3d7da10389a web/assets/modal--ydCtA1D.js -415d5b5fc3e031618a06c5f3380422d5320a1508cf760926afde76dde1ffcc17 web/assets/nb-CdD5FKxs.js -7165f6157dc8d34e20c23c7bc660eb5d43b4584b95651b64b31b632337d8a1b6 web/assets/netbird-ZQ9qXxu7.js -8383373a2d33eef0155848d180fb7f47dfb13809ac08e9896a32ed4a032625d5 web/assets/network-Bk5jakZ8.js -abefa60cff0971da3ae2ca89bd1008f3dbca43c71b4502fcc2c16014ab3ffb24 web/assets/network-D4JturdD.js -8234b2a89761dfac4dde7fec5a381100f00c84396fd3a16d8a409565a2d7a6b7 web/assets/nl-C2GxzOHo.js -2e2b6b45d6e2530e7cd08184ccd1042911e11553091bb15a2d7d194b0efa97b9 web/assets/openvpn-BBcd3i_N.js -6fc757d0cb1d12b91f55b2570d62f6e87e8b65b81455969328844121a245563c web/assets/password-qiXpLuox.js -88a95081e878e27c563c7aab1bcbe48bc6dde0cced1c108f52e7a8f2155c1ebe web/assets/pencil-p4GtBdhO.js -8dd93c4ae6b275990779e76cc519d16aa646adc448d7040214148bedf872f8fb web/assets/picoclaw-BNtr6xwJ.js -b3b3aa45077b87c12000ceb63ac16c182ac18675b5d0d9bacd0031479332096a web/assets/picoclaw-DLrOwoAr.js -c5669e742999ef0662e109053011a6449f3e07f05526a74812cffbbd492d34e7 web/assets/pl-AIzP2GGO.js -14402d61a98980e288baa50defee74adb975f4b7a328581aa0ab09c0a26cf8f4 web/assets/play-CYjiwwwp.js -2ff5ce20c17f88acd71fc91c0babb8cfb807cae4a65f373bfddd23a4131d66d3 web/assets/plus-BvOErqoO.js -bdc73920524ed8c5fd6bd1e21185ad40b7eace00238c87f2b13bb97dbf6e23dc web/assets/popconfirm-KUhHuXzp.js -3d13d5390749723c26cef54d31468e6ba67b255ff90ccfb604d2bfa6fa618608 web/assets/popover-nJdAHZfW.js -43b3858bdacba6c1e8d1bfbef7fb2e15e89c5ad505b5e4f6714dd0939a7851bd web/assets/power-slOFCwp0.js -a1d4743b730a25c83cb506931dfe757723e473d69d982b19076d1f722eadbcfe web/assets/progress-C0sVxPQW.js -38e21b5ea462139930629f5c73de28fcccc2c8d8962a63e52abeb06d6624718c web/assets/pt_br-DgpPVE0C.js -4ac9820f545b685f40b218be247c29a224d3e923729a2be85c5842b9e025bad1 web/assets/react-error-boundary-CHlfuAmE.js -599031202ea083d298ae21a21d428b5f0fe739aff4379f5d6f03cbd53d1aeb57 web/assets/refresh-cw-CMym-F3j.js -110478f38ed06aa2f87f11ed6a3f78ce63f292c11da0df24215fb3c4bfc45291 web/assets/robot-BCmk18vz.js -52c5267368f9fc6b947118342d56cbb4de043d3438c2c613713bebfd16ed9b51 web/assets/rotate-cw-B0Ugt6AV.js -05a293a05e4dd7dd3085cc35f0ed386730500c867998abd83a2031622030e15a web/assets/row-D7lwedpe.js -bb48681b53146538f955b15afc4bdb2d7b148b02b6f4fbfd8494a563125d11d4 web/assets/ru-BJ842rr-.js -e0118de8d6355756c1ea0e48b8a67431963b57b29188ef35bbc49815b185a72c web/assets/rustdesk-DjVIW1Ix.js -36835da7917d3ddf935c4dfc445801454ebe3a2d45698bd8c54f1ae1b9e988e0 web/assets/rustdesk-bAuzyXtW.js -32acb2474988d880c8b2579f47020d19f81fdc3ff4167d667f602f8c0ee90650 web/assets/rustdesk-versions--UU_m92E.js -cfee890bced931000b901d6ce35cb6d85abc107b12557470a8113b920f44e39b web/assets/se-k8R-EV2N.js -b9bb2cd6cee7a39c3747271ccc9a8afb3aacd83c0fe7dd9719e139444eff488c web/assets/segmented-qp_LyeZz.js -2dd36e915a37495b2097ac6930e0f3507eb4424b08022c01d60978d85cf4ba19 web/assets/select-BbLxwjOP.js -47b2abefe4ceb17da4d19d953e85a8bbcb158a999b3700168851ceb93e36096d web/assets/send-CpRU4ptr.js -ba968941b630f3efc9b1b8f48819dda454fd597c718faa8125765be85e1f34d5 web/assets/service-DCl4G_S9.js -6c1eb2ee73e6a74f342393cff9143dee50780615065e16512b00beb5f5af0291 web/assets/settings-suQqP4z0.js -1900017ed76e6618218840a5457bafba3c962b2ee109bc0b57d6ece716967ff8 web/assets/skeleton-CcLLWdrY.js +e518f98bf6f5b42a83af92db0ecd535db8561dd643b450fa82e8cbb508ff0155 web/assets/list-DWmy1r0Q.js +ee581451a8acf6e75072531e4f641e2b0518ce4283daff24a778a4e3ef530d90 web/assets/list-DWmy1r0Q.js.gz +1c775eaf637dc4299618bb8e0c51844fda51f5ce0ecc7bf7c8211cdb3460b12d web/assets/login-DZWCyy28.js +e2dffc9afc183ac5536152ea85584f3ad769d204349e1a611797d117017d0d24 web/assets/login-DZWCyy28.js.gz +9954be069f857214d3205ada851c4dd70fad28cbeed518e046baa6eb6802ac35 web/assets/mcp-Moh9PXiW.js +18af34bd10e9b6b44da60b332d1c06c953345a8d7ce1f27259faf1fed4ca4ac3 web/assets/mcp-Moh9PXiW.js.gz +7b48b5b7fc84fbe64ddc23ab257def036944a69b666d6c0c8c74a718b2b9b243 web/assets/memory-CSpU-qjZ.js +9890f393c00959ebcdb68b119b9906c39ddf60353fac3cdaf5bce63cd9333818 web/assets/memory-CSpU-qjZ.js.gz +68b381e7ed3fa95987bb4c69077c7c189f6900e5585943d39a31097bee6f707c web/assets/menu-item-B-ld_Q6k.js +ca008ef96cae62857d018e7da515355e6c83142e539fa4fbf5985edd79582673 web/assets/menu-item-B-ld_Q6k.js.gz +a0021a2628eacbe525fa70d38a04643b527f258f863fccde3351db84f18d4c97 web/assets/mjpeg-Cn3vcwFO.js +39fb22647943066cca7b38b5007e4734cad78eb4fa583c4a94545135403dce39 web/assets/mjpeg-Cn3vcwFO.js.gz +88ce14faa156d95fd891aa359abae574b8dab5058e326588072ba5739f5c96bd web/assets/nb--x5k_pvr.js +09e62a3701e7c98e4400796ed95344cdea08147423edaa47e85880ae8a8910f9 web/assets/nb--x5k_pvr.js.gz +fef598abbdacff5773e4c975616a15b7f0d3127a6d824be158bc31dd78f58039 web/assets/netbird-amwHvSb4.js +e3a9ede7d16038da2019276d10fd626233531d28b8f10fef1d0f321274d9ef13 web/assets/netbird-amwHvSb4.js.gz +624376663398e10476dc2aad73f431c725a509af57db0e68ce7f3585ad768a38 web/assets/network-kCM4R5uq.js +48ed50197f6a83994df97b2a2994023aedef2180ba4f827d77bc1da2a8e26b0d web/assets/network-kCM4R5uq.js.gz +f4713bd325930cb0c05f378ad0cb382965507d73f7fee5117d2df30a639ab459 web/assets/nl-x5rxaC1j.js +2b5dab3f7b7c34b807c8beee9f695b594dd918503c7ebac1ad9f50bcc3af8d3a web/assets/nl-x5rxaC1j.js.gz +956e027f8b541f6c2bb3e4686bc89d229b5f43755c7d031d7f23a6120a4f1424 web/assets/openvpn-WMBNFnRV.js +6d36e35021a9c257705801e8c067afac8c1d1b5531b02fe72b22d14b8f954a27 web/assets/openvpn-WMBNFnRV.js.gz +94af471a9433a008c004a59688f10be2b043eaefc139b471f6cc1d5fb7677ea5 web/assets/password-5ZIH89z3.js +f0a6ec9caebcf2536346c010a214519b2723ad18dc7133a661194de5dec50d9f web/assets/password-5ZIH89z3.js.gz +c3afb09ed7ed7212ee73db1f25d1f0cb8738c62472292f460937de9b80cc7840 web/assets/picoclaw-CiQvbayb.js +66bc0c8a9dfed0171a8c7a9c34f88ddcf2ad282387c91746b23af04c5fa3cdea web/assets/picoclaw-CiQvbayb.js.gz +d0fb896e3c75225e6e8df252d16d1509c34dbbcd43597026abdd2027de85a33f web/assets/pl-VYQjyBTO.js +931d774ec7cd103e9d63f7185ee912ce3286bb1cde89c366b93b6c8f77cf9596 web/assets/pl-VYQjyBTO.js.gz +b1dd66ec6e9a9a0d9d798cecc7731fadae927484cb5858edb852bb9e1e764dae web/assets/profile-tone-XYMLkYnK.js +0cf1e58b990eab7644de5ef3dd6c2a7b57f6fc9fde1b6023193264b70f2466b3 web/assets/providers-cWXzEgLY.js +6fac59677834c1c14c9b69982349ec47b2a3eb6ecdc342a451492b06ce9326c8 web/assets/providers-cWXzEgLY.js.gz +0ee7f77f582cd978d629a39391f4a26c1a23b176fb332bd2872af4de8db5c26c web/assets/pt_br-IYOcfy7I.js +807b2d859470b3e68180c52f92bc805c42a969db857e083e67231101be0a67dd web/assets/pt_br-IYOcfy7I.js.gz +8fadd2874d74c0003a9f2186df296c3c80a274b19896bb85695233cdf7acdcc7 web/assets/robot-BrPy_jCI.js +c5f0356f5d0918a3137e4e811fd18950efe8ac8f6bf84d1d4da7811c477a24d8 web/assets/rolldown-runtime-Dd_uD5pT.js +047a43bdb9cde4312fd0f0752a4ccefbae8faf3836c2a5c88f2bcac5e77d4b45 web/assets/rolldown-runtime-Dd_uD5pT.js.gz +c48dbcb036b1781c6e80cc4a8d20f6c81b9e38219fb7f2ce2ba04d2cc30636cf web/assets/ru-CMxVLsQ0.js +ad36c214ddaf0807b6daad878d7439758f977a670ae6fd1dcf1fd1f4a9aba714 web/assets/ru-CMxVLsQ0.js.gz +3b5591fafd0bdddacb3c1701f2b57546f3e94eaaca4a921054e1e9ed6fbc388f web/assets/rustdesk-CMYBkBmh.js +b67530ea119ec2f6a04fb287fc51d0c7ec6d5616c67891a80d201d18a4c9c359 web/assets/rustdesk-CMYBkBmh.js.gz +d4a14ec4d33971a29971c3f147da104bb81295ec58474e7b8d3fc9db75a249b0 web/assets/rustdesk-DzEq-kue.js +1c7f8b074d17c637c7443077e75b7dc6a07734f88eadae2049133a4f697591f4 web/assets/rustdesk-DzEq-kue.js.gz +00814d2a95fef40dca188bd51947e35fc4c91a56e2696d9fd2ff064eed7d0371 web/assets/rustdesk-versions-i1E8y3rL.js +d01335254067b516a3ec8cfc01f3167a684c3de634f35decd28182018811de84 web/assets/screenshot-B8o_FIxM.js +04901bb8943adefef0100d231afd445bf0e6c75d2c0deda2d20bc085b06a4a16 web/assets/screenshot-B8o_FIxM.js.gz +86fe19b52d7f2b78baea1781b56faa1e0bbf112b47ccc3d97d7c903550d815d9 web/assets/se-AqKf_b9C.js +6af49fa1933bf195070fabdccab80a13b76e381391ce679bd681e25fcb183b14 web/assets/se-AqKf_b9C.js.gz +0702e7e1c585ac1ab2317c82453b05becf23dbeaaaf01e55f6e4e6c608846c50 web/assets/settings-BO4rgB47.js +f8686a851c15ffb3e4bdc75727bfc06cc9568c9e863ecc0db9859a9294866b66 web/assets/settings-BO4rgB47.js.gz +0cbcc1843804ccc5c329459d3be97bbc706f49a7cf074d9872adba03101d125b web/assets/show-request-error-BSk4rzwO.js 605fde48212c8267a99adfe222e034dc5a7900d5a6999126b564b30cffce3f92 web/assets/software-3BxnaZD1.css -f77437f7c08ad67d9bf97039293856d1a4456784f9ea723d7b9600999426b728 web/assets/software-im1MDuMZ.js -e32d3bf4de1436276442ac89012a860ae5fa6a91dfea11d9cd872c1ff5753f6a web/assets/space-Bg59IR9l.js -5d40c455410dfd9ac20082117e4351f65d93746dd6b4d77a7405809ef641c1f0 web/assets/spin-1d-Xh5Yc.js -3b289f33046f433ea0a3939a3e87a033066044eb8946893a6b0db21746b0aa31 web/assets/statusUtils-DsutvQ8g.js -7dd166a8e72b1bcd9022c66ea477d4a5c91c4b07a25ff199597c0642e787ec0c web/assets/style-BZH5Zqlk.js -77257559ce0016aafa9fc95e0b850f46758a8c2cbd9186b479da6577a784a694 web/assets/style-Clz2-_RM.js -5a392d9825ab2f622640fb6ce14df0015f8db3e487b37c6ef363dccbda90b894 web/assets/switch-DsWCGStW.js -bc217474d1adc99038191767955c2be5cd84e1d9429f6d53dc41cc039e8025a5 web/assets/system-jv803fPQ.js -8dbd16dbf54c1b66c874202bd55155d2cd7c36c480d9a9c2e6a78ae4b7bd42eb web/assets/tabs-DMQHP1Ai.js -8d24cbbd9f42d8238a2c41b3d1ba413f5f4b7e9cf4c3b342700743ed8f29af71 web/assets/tag-CiRW9Qp1.js -4ffa1da6999315574695d05f9e6ddfd3504b6d5ae3f9f52e462d031d527847b1 web/assets/tailscale-riWn2O3c.js +7167e0d63d0a57b925e2ceb0c1ec5848dd664432615d4c132d0712a3236a2a61 web/assets/software-dz7LXN9L.js +ffb76f2242804e2b2109ce1018679539f11dee038a988c9006a80a928f01bd03 web/assets/software-dz7LXN9L.js.gz +14089ed635232988f2471c46bbca9fe54699e0d8ce13b082078ac4559d2fb967 web/assets/system-BC6kiaZ-.js +a83c1e91352614b4263b846b9d186b802d039fa3ab2af4e5f81cb4532496a495 web/assets/system-BC6kiaZ-.js.gz +532e03f7f661f063e4d8ff597c461f022df1157ab893e1d390dfba6883afbd3c web/assets/tailscale-a0jsT43j.js +a8d955a920c9754dea8074b0efb1ab1e5a654e15ed3e0cf9ffcb592ab7849855 web/assets/tailscale-a0jsT43j.js.gz 57f6b4269be22a36b3bff82db73e8cfc0942862c40489a181512ca35d8794ce3 web/assets/terminal-CHOd7Rch.css -4a15dfac10b494e8ccd94eb152675736974bf3474e0259434fc314230aae8d19 web/assets/terminal-U0g0k9FF.js -7ac582df6fce9c093e44228267f8886343f0f098b637186146af20952fc15705 web/assets/th-84u-NtRY.js -b3ac28f1385bc8e943ea4d43c7ba5842eaf6dc4b03de4903ed6df92d588b6daa web/assets/theme-QTkD-6DQ.js -716bc3e4a64238edbc8d74e119896518574298c5c975e3b2df389e9e1ec05426 web/assets/tooltip-DAjc4nWk.js -b34a62d923b9a175160b32a4b2ab959f391d7bcde6f31814a1f815f0ccf285e6 web/assets/tr-DdGIenYz.js -86d2fa0d29d8eeb0f5a0bc7f778b6a872a9563ac79cb822fcbf08af914e598e1 web/assets/trash-C5z1nz_N.js -2d14805d139332a95d50dc94b79634a3c4f71a2bb6ab3878ef0eb6fed13001c6 web/assets/typography-BnYrQmbu.js -a513759b55b6e0e24956d79cdfd2f86a6dc572aa5f42b664d67fead6fc50cfa8 web/assets/uk-CON_bnpk.js -64110ba0a6e647c96ad477e51d363a2c111ab7832f91a54b358a37bc00e5321b web/assets/updates-CA21PxZn.js -51c9dacf23f05c1c1c4f2a9182ddf94059fac6684e634934923319a0513bfec8 web/assets/usb-C60ihbp7.js -c011cbb3fe44e0f3d5ff0f31d6715d63310553f9b5774f216c86a2d39aa03e08 web/assets/usb-SaaBdPkI.js -a320a6133939154f5caf81799011ffcb669c099a4c35e1151c7054fd84fe7d0c web/assets/useAtom-BRIf5Mnl.js -1cfd14a19f23cf9e0ef20de5afbed9958e411c98ac9fc5c270ce3f90a646955c web/assets/useAtomValue-D8gYnIuK.js -77b470fa5f335f9706a78e795d0631ea0a6f238fba380b14d080a96aed656123 web/assets/useBubbleLock-CkOFgrD9.js -6fe76fb3acef8888bd0e64e059769d49519247901b6f7ebeeec6a07375a510ff web/assets/useClosable-BhrDZeZr.js -e918a8b904435a62a8614c07a38fa544bfe813facfc612c530c37ae909afcd48 web/assets/useForceUpdate-DtwrSVnu.js -f0806cffb14c03ebca8b33f94a3d47b55850b0feb7aa3d51c8b1feb396a43495 web/assets/useSetAtom-Cfw0N87f.js -99c3ecbe5d6074008a43bbed96d08fa7d35fb589303a674b1e9a5c8c10e3edf1 web/assets/useVariants-CH-S3H8t.js -1e9f3f851fcd16f681d10ec1ced2f5067fa2e449f526f117628cffcace1b6158 web/assets/util-C9tK6HiV.js -a0e1e81308f389421ab63047938c55c16ca9cd8afb82de76b0b097bc2b2c5f1b web/assets/util-Ce--GUzu.js -8b5638816d12304342c243e7b8661f4cb84d9700248e44613f459d3cdc1fb887 web/assets/validateMessagesContext-CnSaOK7Z.js +0685bdf0128de8af21323b7c8eab24d9c88d72c7d63ac4cd5cd5eda70116b52a web/assets/terminal-CHOd7Rch.css.gz +b6680c8bf978e7d310c2d710f14fcf3c89be28fd04c0b4719725543de3cd56d9 web/assets/terminal-D-bhyZnL.js +281a29a8de6cf4cddc2ae2bc6e45db30ca8d8c3c4daaa118eef327d09021831d web/assets/terminal-D-bhyZnL.js.gz +dd42ad4c216794ddb6165295e1cbbf51428ec951319e779f7759cfe39e615831 web/assets/th-ByjtNJPD.js +b0f11bb1e6cc8e751c0074c2b4772227c5dd4caaf4cb46d1529b1bf0d8270633 web/assets/th-ByjtNJPD.js.gz +3007f0e8eb084e604d0288c868c650735716037108844f475f225b960a91621f web/assets/tr-BJDqx5_a.js +a94729b18f04b91aaaa4d09f97c624de324cdf25502251e460de998ca2bad082 web/assets/tr-BJDqx5_a.js.gz +fbddaead3245b1c5b2265db8fca0eaadf1090168ee425941a22eed3bec41e7b6 web/assets/uk-B5m321Ke.js +9c538937ccad56331a10fe505399c79675397b3240f708d5c7d4255fbaeeae4d web/assets/uk-B5m321Ke.js.gz +e1e4830743bd39622a9ef9afd34a3422657c008d65957416c4bf4e2dd4c2ae8a web/assets/updates-Bb-tB_G4.js +fa31adf760f7f884405c209131d659b500608a0cc6eeaa14a8e908354db694dd web/assets/updates-Bb-tB_G4.js.gz +cef6c33f9b07c7e839c74aed45af126b0d52e7dc0eeb37291626b01438d8ad84 web/assets/usb-BJKIrgj1.js +e8b63b04f62c1c02bb28e0167a994c37b8b6906583646c57171bc812c827009b web/assets/usb-lalwTD8x.js +3c36c8cbfa7762c30c1d0a241321e3f19e11301005fada54f6656435eda0eeae web/assets/usb-lalwTD8x.js.gz +ba476080d2164004132821aecd207074266d41e86ea25c8462390bacb555bcea web/assets/use-video-settings-B-oMyYGv.js +1614c3c921af28359c7ee5209052075832c849ee507fae68937c124189bf2a21 web/assets/use-video-settings-B-oMyYGv.js.gz +ea1d451360bfecf3f761095a4739cf481629176f5258b770caa94738cb79b6ba web/assets/useDeviceTime-BcsnilAW.js +37d5c99021e486e2ec359c2c7e12d92db0fff04ea5109fad2cc226f183aae782 web/assets/vendor-B3akPKaa.js +686403be1a48eedb5e59da4da5190aeb47057db5870e913ceed18e72726f43bc web/assets/vendor-B3akPKaa.js.gz +57c95d17497442a80d164c2555dba6bfb1ee2c09e4711cfdb166b02dbe2feb2b web/assets/vendor-W3G8rYcF.css +30c53ca4badc3d2b422eaa1fffa311d1b1fc373b5fa3ab7975a0846dca86868b web/assets/vendor-W3G8rYcF.css.gz 9aa4102b75d1cd3a8f48235f8ddf9a5759dbaf2866d665d49f4afb931eed7eb0 web/assets/version-BlrQyO3h.js -763b75d4dab631bb15460e9444dec8637d98e1f34f75abb93c006b362d26a334 web/assets/version-Dc_KeRYG.js -b1bc3cfa9e1f2affced4e87d88fa1c7382afdbc3fa519ab566615170746a2e52 web/assets/vi-CSS8LojI.js -f2c9e67a68ddde6d57220b62376a83dbca4729ce144ba76465b206a2f49d7890 web/assets/video-B9oXMna3.js -e32a0ebd87d347d5c9cc589b8e6754de9fdfaa01d82bc669562cac5bc9b1071e web/assets/video-CRfvxyVf.js -a3cf240cf908d9ad1284062c91504dafac5b51784f2b65c1f20759041e14b09a web/assets/virtual-keyboard-B4fyNG4Q.js +66dfb4248f66299b08a40e8adff866ff532dbf40bf952b254cd3b934c4576d06 web/assets/version-DfPf6Xr7.js +852d60fcbd9f34e0020f63a6dcc05f94b85bd72eedb9a4cf1de0eb99d6974469 web/assets/vi-C-4hd5jx.js +ec4714b4a1d943b289a020232aa916384bfd29898e32cc1c10557616dbd92977 web/assets/vi-C-4hd5jx.js.gz +fea5b3919bb8279271196d7d2f18e12249d8cf42d41a63c05c637b877ed80c78 web/assets/video-BhdBTGkD.js +a2b0c681c3909c8d3ff3d230a8552c5ab5052881d722785ce3fa20049793d00a web/assets/video-BhdBTGkD.js.gz +edcb67b0d31dbb0a8592d0815ed5bc0e38176cf453a74ff58c078798395f594d web/assets/viewport-DCol3Nw6.js +4793b9520adb204b4e4aec4b34c8db82f7cffa028f14d9aa6033512227c5e91f web/assets/viewport-DCol3Nw6.js.gz +780996d08d0f75dedc5e7ffc9ca9f524d9b27a58329fb94b8a1781f00d5e378d web/assets/virtual-keyboard-B-Blf87k.js +b5fbc12f327aac833ecbff77d4f2b3072aae90638248d5fb084e16dce8dfc38e web/assets/virtual-keyboard-B-Blf87k.js.gz 12e4472f9fa30bc3b681c3a5bd49759e363a7cddbf4c10f0ed8da640919f75ee web/assets/virtual-keyboard-DoHGYJj7.css -0bc8dbb930b63f2cef427b5d138928d5d69ec458fed92b04dc7cb4f73f9b5729 web/assets/visible-poll-CjyqbgSV.js -3286b900d395941a32e92efc3c2a0e9ddded0528c79530e3dab59ad17fbc3eed web/assets/vm-DPMRUNTq.js -da5316082cf771d2cf97eb2af85fe77e6fc791836bee0b3180ba7b99dd7dd24e web/assets/vpn-DBwIY_VW.js -85b6c1ffb36cf931dc6d8afab0e0f6ceea636430b7a1629f3a4f1b6402683759 web/assets/websocket-Bp-4BzVk.js -bcfae05bbbd9ff51e3c27b28ec5ddbc1b7484f0a0b7f646e97699b91c2da7e34 web/assets/wifi-B2Twvanx.js -764ec1035436fa928d8e079f0e9e05965ff1da10d668e5bc7b13407c583f0090 web/assets/wifi-DMhSvl_v.js -28ba1b3d0a1d1743bf23f1c3f1c579e7e7f32cb6cabbce6bfa581ba8db98dc2f web/assets/wireguard-CXz8lXws.js -aa4f0a2453eabc5f13ff9e4f83ddeb2a62fb52fcfb6a8c542d1ba914d05133ba web/assets/x-B6RkDZBA.js -7e9afaaf25d603628d678c916e9170fcc1858d3760872f702a17ced1de528890 web/assets/zh-Beex-suj.js -a84593ebc9c3d54b84459bf70cd96429c62ebdea5bdf5dc5d25b46cd23cc3b7d web/assets/zh_tw-DaCJA7oe.js -7c148d1e86bc0dcd5c6aee65253cd4bc250d74aeea1988c4ed099c1722ca0442 web/assets/zoom-qVLFOr-a.js -a26e486e22b4f4eb4684d6d9867ff226ba0ec451f97f81a3307e89c34e5a07ba web/index.html -da5f34398fcdec8c6b9098981cf41f398775cd997753ce95a994019190c11f8f web/mockServiceWorker.js +bcdeb9a19a9b8cdd1c7c4246c21a0f3e5090326a47f0fcbac8ae0c4d736e1614 web/assets/virtual-keyboard-DoHGYJj7.css.gz +332a8df8ca27b722d8394a35878a61310aff0a1cdca10b26e8b0c00891200c53 web/assets/visible-poll-eP1ShL2Q.js +31c173777db5b6e01b18193ce76192fd27756b9214c4979ada33acb9f687c573 web/assets/vpn-BChBE-h7.js +e2c108516f1de4216b13376ff8df85f4f5b55bb5e6ba01abd3e0d514ff742186 web/assets/vpn-BChBE-h7.js.gz +1c0dc6aa4851625e7755e1449cdf84012364fb1e3eda1bf91a5e2ea449e30375 web/assets/wifi-YT4SXzZh.js +287f50bbf1c40d823bd3b65d560d6845a575d7da963b1cb9decd55961a389889 web/assets/wifi-YT4SXzZh.js.gz +a54b863fb401158e62e7c8dbde90898d7a60014078e39275d9be1ee22242323d web/assets/zh-m-gSdN4c.js +fe6899bdf36a80fb725d774517e6c9eb851be499cf0d3d7015596fc932ceab46 web/assets/zh-m-gSdN4c.js.gz +2f70fe3c42e40351d4fa6b0a836584cadb483821320ceaddaa2d555de5fe2246 web/assets/zh_tw-BB1zIoPH.js +ee8f16cf32eb766ab5690a7264d5b3b12e0f0a58eb33193cf2026258d581ab8b web/assets/zh_tw-BB1zIoPH.js.gz +404cc9086144892539c3f2e228afaacadf8dd75215e337cbeb7f3df0e32d8e9e web/index.html 5d9c5529060f34690d7955b850e80b9d6acd507a67aff3c58731b3abb8f84476 web/nanokvm-os-connection-full.svg cb73ed110f43200464b7525a4471b9b9cb0a0c5fdb2b09acf24abab5d66638dc web/nanokvm-os-connection.svg -fc2c6318ac7a4f16c12b88b2c76d6e5c2bc8611ead9dcdc3d159a8afa9f89b17 web/nanokvm-os-screen-full.svg -c793eb262c47b3850f4db3671aa419c0528c01db17fdd8722d340c18fec4fd37 web/nanokvm-os-screen.svg -cb73ed110f43200464b7525a4471b9b9cb0a0c5fdb2b09acf24abab5d66638dc web/nanokvm-os.svg diff --git a/platform/packages.list b/platform/packages.list index ec5679f8..0710b7ab 100644 --- a/platform/packages.list +++ b/platform/packages.list @@ -1,8 +1,8 @@ -# Contents of the six nanokvm-* APK packages (firmware/alpine/packages). +# Contents of the seven nanokvm-* APK packages (firmware/alpine/packages). # # package type path mode source # -# package: base, app, release, firmware-sg2002, kernel-sg2002, kmod-sg2002 +# package: keys, base, app, release, firmware-sg2002, kernel-sg2002, kmod-sg2002 # type: file one file # tree every file below a directory, with this mode; directories 0755 # dir a directory with a mode other than 0755 @@ -12,7 +12,11 @@ # %name a file that the payloads step writes (build.sh payloads). # Parent directories are created with mode 0755. -# nanokvm-base: OpenRC services, compatibility scripts and board tools +# nanokvm-keys: the public keys that the packages are signed with. With -d the +# packages step adds the local test key. +keys tree /etc/apk/keys 0644 firmware/alpine/keys + +# nanokvm-base:OpenRC services, compatibility scripts and board tools base file /etc/apk/commit_hooks.d/50-nanokvm-apply 0755 firmware/alpine/compat/50-nanokvm-apply base file /etc/apk/protected_paths.d/nanokvm.list 0644 %protected-paths base file /etc/chrony.conf 0644 firmware/buildroot/board/overlay/etc/chrony.conf diff --git a/scripts/README.md b/scripts/README.md index 7bc7a5f9..83522b9c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -11,7 +11,7 @@ It calls these component builders: | build-server-existing-libs.py | `NanoKVM-Server`, `nkos-update` and `nkos-apply-updates` | | build-busybox-devmem.sh, build-usb-audio.py | `devmem` and `usb-audio-capture` | | build-qhd-edid.py, build-monitor-edids.py, build-portrait-edid.py | the EDID profiles | -| build-alpine-packages.sh | the six APK packages, inside the Alpine builder tree | +| build-alpine-packages.sh | the seven APK packages, inside the Alpine builder tree | | build-alpine-sd-image.py | the SD card image | Run them through `platform/build.sh`: it prepares their pinned sources and toolchain. diff --git a/scripts/build-alpine-builder-deploy.py b/scripts/build-alpine-builder-deploy.py index 6d08f982..d02326c0 100755 --- a/scripts/build-alpine-builder-deploy.py +++ b/scripts/build-alpine-builder-deploy.py @@ -96,7 +96,8 @@ def main() -> None: parser.add_argument("--boot-fit", type=Path, required=True) parser.add_argument("--nanokvm-repo", type=Path, required=True) parser.add_argument("--tuned-repo", type=Path, help="Optional experimental C906 repository") - parser.add_argument("--repo-key", type=Path, required=True) + # Repeatable: the RSA key and the ECDSA key that signs the v3 index. + parser.add_argument("--repo-key", type=Path, action="append", required=True) parser.add_argument("--qemu-static", type=Path, required=True) parser.add_argument("--f2fs-build", type=Path, default=Path("build/f2fs")) parser.add_argument("--sample-package", default="nano") @@ -111,7 +112,6 @@ def main() -> None: inputs = { "base_rootfs": require_file(resolve(repo, args.base_rootfs), "base rootfs"), "boot_fit": require_file(resolve(repo, args.boot_fit), "normal FIT"), - "repo_key": require_file(resolve(repo, args.repo_key), "repository public key"), "qemu": require_file(resolve(repo, args.qemu_static), "qemu-riscv64-static"), } nanokvm_repo = require_directory(resolve(repo, args.nanokvm_repo), "NanoKVM repository") @@ -122,9 +122,15 @@ def main() -> None: parser.error(f"output exists; pass --force to replace it: {output}") output.parent.mkdir(parents=True, exist_ok=True) - key_text = inputs["repo_key"].read_text(encoding="ascii", errors="strict") - if "PRIVATE KEY" in key_text or "PUBLIC KEY" not in key_text: - parser.error("--repo-key must be a PEM public key; private keys are forbidden") + repo_keys = [require_file(resolve(repo, key), "repository public key") for key in args.repo_key] + if len({key.name for key in repo_keys}) != len(repo_keys): + parser.error("--repo-key file names must differ") + for key in repo_keys: + key_text = key.read_text(encoding="ascii", errors="strict") + if "PRIVATE KEY" in key_text or "PUBLIC KEY" not in key_text: + parser.error("--repo-key must be a PEM public key; private keys are forbidden") + if not (nanokvm_repo / "riscv64/Packages.adb").is_file(): + parser.error(f"{nanokvm_repo}/riscv64/Packages.adb is missing; images read the v3 index") with tempfile.TemporaryDirectory(prefix="nanokvm-builder-deploy-") as temp_name: bundle = Path(temp_name) / "nanokvm-builder" @@ -144,7 +150,8 @@ def main() -> None: copy_file(inputs["base_rootfs"], bundle / "input" / inputs["base_rootfs"].name) copy_file(inputs["boot_fit"], bundle / "input/boot-alpine.sd") - copy_file(inputs["repo_key"], bundle / "keys" / inputs["repo_key"].name) + for key in repo_keys: + copy_file(key, bundle / "keys" / key.name) copy_file(inputs["qemu"], bundle / "qemu/qemu-riscv64-static") shutil.copytree(nanokvm_repo, bundle / "repos/nanokvm") if tuned_repo is not None: @@ -156,7 +163,6 @@ def main() -> None: base_hash = sha256(inputs["base_rootfs"]) boot_hash = sha256(inputs["boot_fit"]) - key_name = inputs["repo_key"].name config = { "project_root": ".", "base_rootfs": f"input/{inputs['base_rootfs'].name}", @@ -165,7 +171,7 @@ def main() -> None: "boot_sha256": boot_hash, "nanokvm_repo": "repos/nanokvm", "tuned_repo": "repos/c906-qualified" if tuned_repo is not None else None, - "repo_keys": [f"keys/{key_name}"], + "repo_keys": [f"keys/{key.name}" for key in repo_keys], "qemu_static": "qemu/qemu-riscv64-static", "output_root": "output", "alpine_version": "3.24", @@ -194,7 +200,7 @@ def main() -> None: " --boot-fit input/boot-alpine.sd \\\n" f" --boot-sha256 {boot_hash} \\\n" " --nanokvm-repo http://127.0.0.1:18080/nanokvm \\\n" - f" --repo-key keys/{key_name} \\\n" + + "".join(f" --repo-key keys/{key.name} \\\n" for key in repo_keys) + " --packages-file requests/sample.packages \\\n" " --qemu-static qemu/qemu-riscv64-static \\\n" " --output output/sample\n" diff --git a/scripts/build-alpine-packages.sh b/scripts/build-alpine-packages.sh index acf2fbce..38f60379 100755 --- a/scripts/build-alpine-packages.sh +++ b/scripts/build-alpine-packages.sh @@ -7,8 +7,10 @@ # # The payload tree is prepared by the NanoKVM image staging step. It may either # contain package directories directly or contain one directory per profile: -# PAYLOAD_ROOT/stock/{base,kernel-sg2002,kmod-sg2002,firmware-sg2002,app,release} -# PAYLOAD_ROOT/c906-scalar/{base,kernel-sg2002,kmod-sg2002,firmware-sg2002,app,release} +# PAYLOAD_ROOT/stock/{keys,base,kernel-sg2002,kmod-sg2002,firmware-sg2002,app,release} +# PAYLOAD_ROOT/c906-scalar/{keys,base,kernel-sg2002,kmod-sg2002,firmware-sg2002,app,release} +# Without keys/, nanokvm-keys holds firmware/alpine/keys; without release/, +# a release marker is generated. # # This script only creates source archives and calls abuild. It does not install # packages, change a device, or contact a NanoKVM. Build on Alpine with abuild @@ -96,6 +98,17 @@ EOF echo "$release_dir" } +make_keys_payload() { + if [ -d "$PROFILE_ROOT/keys" ]; then + echo "$PROFILE_ROOT/keys" + return + fi + mkdir -p "$WORK/keys/etc/apk/keys" + cp "$ROOT"/firmware/alpine/keys/*.pub "$WORK/keys/etc/apk/keys/" + chmod 0644 "$WORK"/keys/etc/apk/keys/* + echo "$WORK/keys" +} + archive_payload() { pkg=$1 payload=$2 @@ -108,6 +121,8 @@ archive_payload() { echo "prepared $SRCDEST/$pkg-$version.tar.gz" } +keys_payload=$(make_keys_payload) +archive_payload nanokvm-keys "$keys_payload" archive_payload nanokvm-base "$PROFILE_ROOT/base" archive_payload nanokvm-kernel-sg2002 "$PROFILE_ROOT/kernel-sg2002" archive_payload nanokvm-kmod-sg2002 "$PROFILE_ROOT/kmod-sg2002" @@ -119,6 +134,19 @@ archive_payload nanokvm-release "$release_payload" export CARCH=riscv64 export REPODEST SRCDEST PKGVER +# abuild signs each package and the index with abuild-sign and no type, which +# means RSA: RSA with SHA-1. abuild has no setting for the type, so a wrapper +# first in PATH asks for RSA256, RSA with SHA-256 (apk-tools 2.12 and later). +real_abuild_sign=$(command -v abuild-sign) || { + echo "build-alpine-packages: abuild-sign is required" >&2 + exit 1 +} +mkdir -p "$WORK/bin" +printf '#!/bin/sh\nexec %s -t RSA256 "$@"\n' "$real_abuild_sign" > "$WORK/bin/abuild-sign" +chmod 0755 "$WORK/bin/abuild-sign" +PATH=$WORK/bin:$PATH +export PATH + run_abuild() { if [ -n "$ABUILD_FLAGS" ]; then # Intentional word splitting: this variable is a short list of abuild flags. @@ -153,7 +181,7 @@ prepare_recipe() { fi } -for pkg in nanokvm-base nanokvm-kernel-sg2002 nanokvm-kmod-sg2002 nanokvm-firmware-sg2002 \ +for pkg in nanokvm-keys nanokvm-base nanokvm-kernel-sg2002 nanokvm-kmod-sg2002 nanokvm-firmware-sg2002 \ nanokvm-app nanokvm-release; do echo "building $pkg ($PROFILE)" prepare_recipe "$pkg" @@ -172,14 +200,18 @@ tar -tzf "$index" >/dev/null || { echo "build-alpine-packages: invalid APKINDEX.tar.gz: $index" >&2 exit 1 } +tar -tzf "$index" | grep -qE '(^|/)\.SIGN\.RSA256\.' || { + echo "build-alpine-packages: APKINDEX.tar.gz has no RSA256 signature: $index" >&2 + exit 1 +} apk_count=$(find "$REPODEST" -type f -name '*.apk' | wc -l) -[ "$apk_count" -ge 6 ] || { - echo "build-alpine-packages: expected six APKs, found $apk_count" >&2 +[ "$apk_count" -ge 7 ] || { + echo "build-alpine-packages: expected seven APKs, found $apk_count" >&2 exit 1 } find "$REPODEST" -type f -name '*.apk' -print | while IFS= read -r apk; do - if ! tar -tzf "$apk" | grep -qE '(^|/)\.SIGN\.RSA\.'; then - echo "build-alpine-packages: unsigned APK: $apk" >&2 + if ! tar -tzf "$apk" | grep -qE '(^|/)\.SIGN\.RSA256\.'; then + echo "build-alpine-packages: APK without an RSA256 signature: $apk" >&2 exit 1 fi done diff --git a/scripts/build-alpine-personal-image.sh b/scripts/build-alpine-personal-image.sh index 51f367c2..26842b6f 100755 --- a/scripts/build-alpine-personal-image.sh +++ b/scripts/build-alpine-personal-image.sh @@ -38,7 +38,9 @@ Execution: -h, --help Show this help Only unversioned APK names are accepted in a request. nanokvm-release is -always installed. Repository URLs must use http:// or https://. The output +always installed. Repository URLs must use http:// or https://. The NanoKVM +and C906 repositories are read through their apk v3 index: a base URL becomes +URL/riscv64/Packages.adb, and a URL ending in .adb is kept. The output directory must not already exist. EOF } @@ -172,6 +174,19 @@ for path_value in "$BASE_ROOTFS" "$BOOT_FIT" "$OUTPUT" "$QEMU_STATIC"; do done [ -s "$KEYS_RAW" ] || die "at least one --repo-key is required" +# The v3 index is signed with the ECDSA release key (and, during the +# transition, the RSA key); the v2 APKINDEX only with the RSA key. +v3_index() { + case "$1" in + ''|*.adb) printf '%s\n' "$1" ;; + *) printf '%s/riscv64/Packages.adb\n' "${1%/}" ;; + esac +} +NANOKVM_REPO=$(v3_index "$NANOKVM_REPO") +RUNTIME_NANOKVM_REPO=$(v3_index "$RUNTIME_NANOKVM_REPO") +TUNED_REPO=$(v3_index "$TUNED_REPO") +RUNTIME_TUNED_REPO=$(v3_index "$RUNTIME_TUNED_REPO") + ALPINE_MAIN=${ALPINE_MAIN:-https://dl-cdn.alpinelinux.org/alpine/v$ALPINE_VERSION/main} ALPINE_COMMUNITY=${ALPINE_COMMUNITY:-https://dl-cdn.alpinelinux.org/alpine/v$ALPINE_VERSION/community} valid_repo "$ALPINE_MAIN" || die "invalid Alpine main repository URL" diff --git a/scripts/build-alpine-tuned-packages.sh b/scripts/build-alpine-tuned-packages.sh index 2c6f783a..1d70a18b 100755 --- a/scripts/build-alpine-tuned-packages.sh +++ b/scripts/build-alpine-tuned-packages.sh @@ -31,14 +31,17 @@ C906_FLAGS=${C906_FLAGS:-'-march=rv64gc_xtheadba_xtheadbb_xtheadbs_xtheadcmo_xth if [ "${1:-}" = --help ]; then cat <<'EOF' -Usage: SIGN_KEY=/path/key.rsa [WORK_DIR=/writable/path] \ +Usage: SIGN_KEY=/path/key.rsa [EC_SIGN_KEY=/path/key.key] [WORK_DIR=/writable/path] \ scripts/build-alpine-tuned-packages.sh - SIGN_KEY=/path/key.rsa WORK_DIR=/existing/work \ + SIGN_KEY=/path/key.rsa [EC_SIGN_KEY=/path/key.key] WORK_DIR=/existing/work \ OUTPUT_DIR=/existing/repository \ scripts/build-alpine-tuned-packages.sh --finalize-only Run as an unprivileged Alpine abuild user in an Alpine v3.24 riscv64 rootfs. +SIGN_KEY signs the APKs and APKINDEX.tar.gz (RSA256). Packages.adb is signed +with SIGN_KEY and, if set, the ECDSA key EC_SIGN_KEY (NAME.key with NAME.pub +next to it), so that a device trusting either key accepts it. EOF exit 0 fi @@ -91,6 +94,11 @@ need sha256sum [ -n "$SIGN_KEY" ] || die "set SIGN_KEY to the private APK signing key (do not commit it)" [ -f "$SIGN_KEY" ] || die "signing key does not exist: $SIGN_KEY" +EC_SIGN_KEY=${EC_SIGN_KEY:-} +if [ -n "$EC_SIGN_KEY" ]; then + [ -f "$EC_SIGN_KEY" ] || die "EC signing key does not exist: $EC_SIGN_KEY" + [ -f "${EC_SIGN_KEY%.key}.pub" ] || die "EC public key does not exist: ${EC_SIGN_KEY%.key}.pub" +fi if [ "$FINALIZE_ONLY" -eq 1 ]; then [ -d "$OUTPUT_DIR" ] || die "finalize output does not exist: $OUTPUT_DIR" @@ -138,6 +146,16 @@ if [ ! -e "$KEYS_DIR/${key_name}.pub" ]; then fi fi +# abuild signs packages and its index with abuild-sign and no type, which +# means RSA: RSA with SHA-1. A wrapper first in PATH asks for RSA256, RSA with +# SHA-256. apk mkndx signs Packages.adb with SHA-512 by itself. +real_abuild_sign=$(command -v abuild-sign) || die "required command is missing: abuild-sign" +mkdir -p "$WORK_DIR/bin" +printf '#!/bin/sh\nexec %s -t RSA256 "$@"\n' "$real_abuild_sign" > "$WORK_DIR/bin/abuild-sign" +chmod 0755 "$WORK_DIR/bin/abuild-sign" +PATH=$WORK_DIR/bin:$PATH +export PATH + if [ "$FINALIZE_ONLY" -eq 0 ]; then rm -rf "$WORK_DIR/ports" mkdir -p "$WORK_DIR/ports" @@ -210,10 +228,22 @@ index_count=$(tar -xOzf "$OUTPUT_DIR/riscv64/APKINDEX.tar.gz" APKINDEX | die "APKINDEX contains $index_count packages, expected $apk_count" index="$OUTPUT_DIR/riscv64/Packages.adb" -apk --keys-dir "$KEYS_DIR" --sign-key "$SIGN_KEY" mkndx \ +# With EC_SIGN_KEY the v3 index carries two signatures, RSA and ECDSA. +apk --keys-dir "$KEYS_DIR" --sign-key "$SIGN_KEY" \ + ${EC_SIGN_KEY:+--sign-key "$EC_SIGN_KEY"} mkndx \ --output "$index" \ --description "NanoKVM Alpine v3.24 c906-scalar $APORTS_COMMIT" \ "$OUTPUT_DIR/riscv64"/*.apk +if [ -n "$EC_SIGN_KEY" ]; then + # Each key alone must verify it. + rm -rf "$WORK_DIR/verify-ec" + mkdir -p "$WORK_DIR/verify-ec" + cp "${EC_SIGN_KEY%.key}.pub" "$WORK_DIR/verify-ec/" + apk --keys-dir "$WORK_DIR/verify-ec" verify "$index" >/dev/null || + die "EC_SIGN_KEY does not verify $index" +fi +apk --keys-dir "$KEYS_DIR" verify "$index" >/dev/null || + die "SIGN_KEY does not verify $index" # Exact package identities are read from .PKGINFO, not inferred from filenames # (subpackage names can contain hyphens). This is the handoff consumed by the @@ -234,6 +264,10 @@ for file in "$OUTPUT_DIR/riscv64"/*.apk; do done # Verify both signatures before handing the repository to the image builder. +for file in "$OUTPUT_DIR/riscv64"/*.apk "$OUTPUT_DIR/riscv64/APKINDEX.tar.gz"; do + tar -tzf "$file" | grep -qE '(^|/)\.SIGN\.RSA256\.' || + die "no RSA256 signature in $file" +done for file in "$OUTPUT_DIR/riscv64"/*.apk; do apk --keys-dir "$KEYS_DIR" verify "$file" >/dev/null done diff --git a/scripts/migrate-alpine-stock.sh b/scripts/migrate-alpine-stock.sh index 95c98871..81af32ef 100755 --- a/scripts/migrate-alpine-stock.sh +++ b/scripts/migrate-alpine-stock.sh @@ -19,7 +19,8 @@ fi # NanoKVM board/application channel, Alpine mirrors and explicit edge tags. repos=$(mktemp /etc/apk/.stock-repositories.XXXXXX) trap 'rm -f "$repos"' EXIT HUP INT TERM -awk '$1 !~ /^https:\/\/nkos\.pesin\.pro\/repos\/c906-qualified\/?$/ { print }' /etc/apk/repositories > "$repos" +# The C906 line names the repository or its v3 index (riscv64/Packages.adb). +awk '$1 !~ /^https:\/\/nkos\.pesin\.pro\/repos\/c906-qualified(\/|\/riscv64\/Packages\.adb)?$/ { print }' /etc/apk/repositories > "$repos" # Custom experimental endpoints must be removed explicitly by their operator. # Never guess at unrelated repository ownership or silently discard it. if grep -E '^[[:space:]]*[^#].*c906' "$repos"; then diff --git a/scripts/qualify-alpine-c906-repo.py b/scripts/qualify-alpine-c906-repo.py index ac2bb4cb..429f98b9 100755 --- a/scripts/qualify-alpine-c906-repo.py +++ b/scripts/qualify-alpine-c906-repo.py @@ -114,11 +114,11 @@ def sign_legacy_index(unsigned: Path, signed: Path, key: Path, key_name: str, epoch: int, abuild_tar: Path, rootfs: Path | None, qemu: Path | None, work: Path) -> None: signature = work / "index.signature" - run(["openssl", "dgst", "-sha1", "-sign", str(key), "-out", + run(["openssl", "dgst", "-sha256", "-sign", str(key), "-out", str(signature), str(unsigned)]) os.utime(signature, (epoch, epoch)) - member_name = f".SIGN.RSA.{key_name}" + member_name = f".SIGN.RSA256.{key_name}" signature_member = work / member_name shutil.copyfile(signature, signature_member) tar_path = work / "signature.tar" @@ -148,6 +148,9 @@ def main() -> int: default=root / "firmware/alpine/evidence/2026-09-19-qualified-c906") parser.add_argument("--sign-key", type=Path, default=Path(os.environ["SIGN_KEY"]) if os.environ.get("SIGN_KEY") else None) + parser.add_argument("--ec-sign-key", type=Path, + default=Path(os.environ["EC_SIGN_KEY"]) if os.environ.get("EC_SIGN_KEY") else None, + help="ECDSA key NAME.key (NAME.pub next to it) that also signs Packages.adb") parser.add_argument("--rootfs", type=Path, default=Path(os.environ.get("ALPINE_TOOL_ROOTFS", root / "work/alpine/tuned-builder-full/rootfs"))) parser.add_argument("--qemu", type=Path, @@ -172,6 +175,10 @@ def main() -> int: parser.error(f"source repository does not exist: {source}") if not key.is_file(): parser.error(f"signing key does not exist: {key}") + ec_key = args.ec_sign_key.resolve() if args.ec_sign_key else None + ec_public = ec_key.with_suffix(".pub") if ec_key else None + if ec_key and not (ec_key.is_file() and ec_public.is_file()): + parser.error(f"EC signing key or its .pub does not exist: {ec_key}") for path in (output, evidence): if path.exists(): if not args.force: @@ -250,7 +257,9 @@ def main() -> int: "--description", "NanoKVM Alpine v3.24 c906-scalar qualified", *[str(package) for package in staged_packages], env=apk_env) packages_adb = staging_arch / "Packages.adb" - apk_run(apk, rootfs, qemu, "--keys-dir", str(keys), "--sign-key", str(key), + # With an EC key the v3 index has two signatures; either key verifies it. + ec_sign = ["--sign-key", str(ec_key)] if ec_key else [] + apk_run(apk, rootfs, qemu, "--keys-dir", str(keys), "--sign-key", str(key), *ec_sign, "mkndx", "--output", str(packages_adb), "--description", "NanoKVM Alpine v3.24 c906-scalar qualified", *[str(package) for package in staged_packages], env=apk_env) @@ -282,6 +291,11 @@ def main() -> int: # signatures before making the output visible. apk_run(apk, rootfs, qemu, "--keys-dir", str(keys), "verify", str(signed_index)) apk_run(apk, rootfs, qemu, "--keys-dir", str(keys), "verify", str(packages_adb)) + if ec_key: + ec_keys = temp / "keys-ec" + ec_keys.mkdir() + shutil.copyfile(ec_public, ec_keys / ec_public.name) + apk_run(apk, rootfs, qemu, "--keys-dir", str(ec_keys), "verify", str(packages_adb)) for package in staged_packages: apk_run(apk, rootfs, qemu, "--keys-dir", str(keys), "verify", str(package)) @@ -317,7 +331,8 @@ def main() -> int: "`xz` and `zlib` are excluded.\n\n" "The APK bytes are retained only under the ignored work output " "`work/alpine/repo-c906-qualified`. `APKINDEX.tar.gz` and `Packages.adb` " - "are regenerated and signed with the existing RSA key. Package and index " + "are regenerated and signed with the existing RSA key (Packages.adb also " + "with the ECDSA key, if one is given). Package and index " "signatures were verified before publication of the output directory. " "Each APK is stored under its declared `riscv64` or `noarch` path. " "The private key is not copied to either output or evidence.\n\n" diff --git a/scripts/serve-alpine-personal-builder.py b/scripts/serve-alpine-personal-builder.py index 61775f6f..7fd0e3b6 100755 --- a/scripts/serve-alpine-personal-builder.py +++ b/scripts/serve-alpine-personal-builder.py @@ -133,8 +133,9 @@ def __init__(self, source: Path) -> None: ("nanokvm_repo", self.nanokvm_repo), ("tuned_repo", self.tuned_repo), ): - if path is not None and not (path / "riscv64/APKINDEX.tar.gz").is_file(): - raise SystemExit(f"missing {label} APKINDEX: {path}") + # Images read the v3 index (build-alpine-personal-image.sh). + if path is not None and not (path / "riscv64/Packages.adb").is_file(): + raise SystemExit(f"missing {label} Packages.adb: {path}") if not self.repo_keys: raise SystemExit("repo_keys must contain at least one public key") for key in self.repo_keys: @@ -161,8 +162,8 @@ def build_id(self, profile: str, packages: list[str]) -> str: "packages": packages, "base_sha256": self.base_sha256, "boot_sha256": self.boot_sha256, - "nanokvm_index_sha256": sha256(self.nanokvm_repo / "riscv64/APKINDEX.tar.gz"), - "tuned_index_sha256": sha256(self.tuned_repo / "riscv64/APKINDEX.tar.gz") if profile == "c906-scalar" else None, + "nanokvm_index_sha256": sha256(self.nanokvm_repo / "riscv64/Packages.adb"), + "tuned_index_sha256": sha256(self.tuned_repo / "riscv64/Packages.adb") if profile == "c906-scalar" else None, "alpine_main_index_sha256": repository_index_sha256(self.alpine_main), "alpine_community_index_sha256": repository_index_sha256(self.alpine_community), "builder_sha256": sha256(self.builder), diff --git a/server/cmd/nkos-apply-updates/main.go b/server/cmd/nkos-apply-updates/main.go index 81670fc3..99ca7496 100644 --- a/server/cmd/nkos-apply-updates/main.go +++ b/server/cmd/nkos-apply-updates/main.go @@ -16,6 +16,8 @@ import ( "strings" "syscall" "time" + + "NanoKVM-Server/internal/oomscore" ) const stateDir = "/run/nanokvm-apk" @@ -576,6 +578,12 @@ func mainErr() error { fmt.Println("NanoKVM: applying changed components; nkos-apply-updates status shows the result") return nil case "apply": + // This worker descends from apk, which the NanoKVM apk runner makes + // the preferred OOM victim; the services it restarts must not inherit + // that or a lowered priority. It runs while apk may still hold the + // runner's lock and therefore never runs apk itself. + _ = oomscore.Set(0, oomscore.Normal) + _ = syscall.Setpriority(syscall.PRIO_PROCESS, 0, 0) var s snapshot b, e := os.ReadFile(path) if e != nil { diff --git a/server/common/frame_detect.go b/server/common/frame_detect.go new file mode 100644 index 00000000..3b0bc7df --- /dev/null +++ b/server/common/frame_detect.go @@ -0,0 +1,38 @@ +package common + +import ( + "os" + "strings" +) + +// FrameDetectFile keeps the MJPEG frame-detect choice across restarts. The +// native encoder starts with frame detection off, so the saved choice is +// applied when KvmVision is initialized. +var FrameDetectFile = "/etc/kvm/frame_detect" + +// FrameDetectInterval is the number of unchanged frames after which the +// encoder stops sending MJPEG frames while frame detection is enabled. +const FrameDetectInterval uint8 = 60 + +// FrameDetectEnabled reports the saved choice; it is off when unset. +func FrameDetectEnabled() bool { + data, err := os.ReadFile(FrameDetectFile) + return err == nil && strings.TrimSpace(string(data)) == "1" +} + +// SaveFrameDetect persists the choice. Callers apply it to the encoder. +func SaveFrameDetect(enabled bool) error { + value := "0\n" + if enabled { + value = "1\n" + } + return os.WriteFile(FrameDetectFile, []byte(value), 0o644) +} + +// FrameDetectFrames is the encoder setting for a frame-detect choice. +func FrameDetectFrames(enabled bool) uint8 { + if enabled { + return FrameDetectInterval + } + return 0 +} diff --git a/server/common/kvm_vision.go b/server/common/kvm_vision.go index 0fa89120..8370ea04 100644 --- a/server/common/kvm_vision.go +++ b/server/common/kvm_vision.go @@ -63,6 +63,7 @@ func GetKvmVision() *KvmVision { if C.set_mjpeg_chroma(C.uint8_t(boolToInt(GetScreen().MjpegChroma == 422))) != 0 { log.Error("failed to select saved MJPEG chroma") } + C.set_frame_detact(C.uint8_t(FrameDetectFrames(FrameDetectEnabled()))) log.Debugf("kvm vision initialized") }) diff --git a/server/internal/apkrun/apkrun.go b/server/internal/apkrun/apkrun.go new file mode 100644 index 00000000..d63c8d94 --- /dev/null +++ b/server/internal/apkrun/apkrun.go @@ -0,0 +1,298 @@ +// Package apkrun is the one way NanoKVM-Server and its helpers run apk. +// +// Measured on the SG2002 in the 4K video memory mode (about 104 MiB for +// Linux, zram swap of half that): an apk run that loads the repository indexes +// (query --from=repositories, search, version, info --verbose, update, add, +// del, upgrade) peaks at 33-36 MiB of anonymous memory with the default +// repositories, plus about 4 MiB of shared file pages, and needs 15-18 s of +// CPU. Two of them at once do not fit, and the OOM killer then chose one of +// them or the web server. Therefore every apk started through this package +// +// - holds an exclusive flock on LockPath while it runs, so apk runs from the +// server, the detached update helper and the server after a restart queue +// behind each other instead of overlapping; +// - is refused before it starts when even swapping every other process out +// to zram could not make room for it (see checkMemory); +// - gets oom_score_adj 1000, so the kernel kills it before anything else; +// - runs at nice 19 when it only reads the indexes, so streaming and HTTP +// requests keep the CPU. +// +// Package transactions keep the caller's priority: package scripts and the +// commit hook start and restart services, which would inherit nice 19. +// Queries of the installed database only (info -e) need about 7 MiB and well +// under a second; they skip the lock, so status pages stay responsive while a +// long upgrade runs. +// +// The lock is taken on a descriptor that is closed on exec. apk's commit hook +// (nkos-apply-updates) runs while the lock is held and must not take it. +package apkrun + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "time" + + "NanoKVM-Server/internal/oomscore" + + "golang.org/x/sys/unix" +) + +// LockPath is shared by every process that runs apk through this package. +var LockPath = "/run/nanokvm-apk.lock" + +var ( + memInfoPath = "/proc/meminfo" + lockPoll = 200 * time.Millisecond +) + +// requiredKiB is the smallest measured anonymous peak of an index-loading apk +// run with the default repositories (Alpine main and community and the +// NanoKVM repository): 36.8 MiB maximum RSS of which about 4 MiB are shared +// file pages. checkMemory compares it with an upper bound of the memory apk +// can get, so a refusal means the run would certainly have pushed the system +// into the OOM killer, not that it might. +const requiredKiB = 32 * 1024 + +// ErrBusy means the wait for another apk run ended before that run finished. +var ErrBusy = errors.New("another package operation is still running; try again when it has finished") + +// LowMemoryError means apk was not started because it could not fit in memory. +type LowMemoryError struct { + AvailableKiB, RequiredKiB int64 +} + +func (e *LowMemoryError) Error() string { + return fmt.Sprintf("not enough free memory to run APK safely: about %d MiB available, %d MiB needed; "+ + "close remote sessions or stop unused add-ons, or reboot, and try again", e.AvailableKiB/1024, e.RequiredKiB/1024) +} + +// notStartedError marks an error that kept apk from starting. +type notStartedError struct{ err error } + +func (e *notStartedError) Error() string { return e.err.Error() } +func (e *notStartedError) Unwrap() error { return e.err } + +// NotStarted reports whether err means apk never ran: the lock wait ended, +// memory was insufficient or the lock file was unusable. +func NotStarted(err error) bool { + var target *notStartedError + return errors.As(err, &target) +} + +type class int + +const ( + installedOnly class = iota // reads the installed database only + indexQuery // loads the repository indexes, runs no scripts + transaction // changes packages, runs package scripts and hooks +) + +// classify looks at the applet, the first argument that is not an option, and +// treats anything it does not know as an index query. +func classify(args []string) class { + applet := "" + options := map[string]bool{} + for _, arg := range args { + if strings.HasPrefix(arg, "-") { + options[arg] = true + } else if applet == "" { + applet = arg + } + } + switch applet { + case "info": + if options["-e"] || options["--installed"] { + return installedOnly + } + case "add", "del", "upgrade", "fix": + if !options["-s"] && !options["--simulate"] { + return transaction + } + } + return indexQuery +} + +// Cmd describes one apk run, like exec.Cmd. +type Cmd struct { + Path string + Args []string // without Path + + Stdout, Stderr io.Writer + + // LockWait bounds the wait for another apk run. Zero waits as long as the + // context allows. + LockWait time.Duration + // Timeout bounds the apk run itself, counted once it holds the lock. Zero + // leaves only the context. + Timeout time.Duration + + ctx context.Context +} + +// Command returns a Cmd that runs path (the apk executable) with args. The +// context bounds the whole call, including the wait for the lock. +func Command(ctx context.Context, path string, args ...string) *Cmd { + return &Cmd{Path: path, Args: args, ctx: ctx} +} + +// CombinedOutput runs apk and returns its standard output and error. When apk +// is not started, the output is the reason, so callers that report apk's +// output explain the failure without special cases. +func (c *Cmd) CombinedOutput() ([]byte, error) { + if c.Stdout != nil || c.Stderr != nil { + return nil, errors.New("apkrun: Stdout or Stderr already set") + } + var output bytes.Buffer + c.Stdout, c.Stderr = &output, &output + err := c.Run() + if NotStarted(err) { + return []byte(err.Error()), err + } + return output.Bytes(), err +} + +// Run runs apk. When apk is not started, the reason is also written to +// Stderr, which is usually an operation log. +func (c *Cmd) Run() error { + err := c.run() + if NotStarted(err) && c.Stderr != nil { + fmt.Fprintln(c.Stderr, err) + } + return err +} + +func (c *Cmd) run() error { + ctx := c.ctx + if ctx == nil { + ctx = context.Background() + } + kind := classify(c.Args) + if kind != installedOnly { + lock, err := acquire(ctx, c.LockWait) + if err != nil { + return ¬StartedError{err} + } + defer lock.Close() + if err = checkMemory(); err != nil { + return ¬StartedError{err} + } + } + if c.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, c.Timeout) + defer cancel() + } + cmd := exec.CommandContext(ctx, c.Path, c.Args...) + cmd.Stdout, cmd.Stderr = c.Stdout, c.Stderr + if err := cmd.Start(); err != nil { + return err + } + // Set on the running child instead of through a wrapper: apk needs far + // longer than this to load anything, and nothing else can fail here. + pid := cmd.Process.Pid + _ = oomscore.Set(pid, oomscore.Expendable) + if kind == indexQuery { + _ = unix.Setpriority(unix.PRIO_PROCESS, pid, 19) + } + return cmd.Wait() +} + +// acquire takes the exclusive lock, polling so that both the context and the +// wait limit can end the wait. +func acquire(ctx context.Context, wait time.Duration) (*os.File, error) { + fd, err := syscall.Open(LockPath, syscall.O_CREAT|syscall.O_RDWR|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0600) + if err != nil { + return nil, fmt.Errorf("cannot open APK lock: %w", err) + } + lock := os.NewFile(uintptr(fd), LockPath) + if info, err := lock.Stat(); err != nil || !info.Mode().IsRegular() { + lock.Close() + return nil, errors.New("unsafe APK lock file") + } + var expired <-chan time.Time + if wait > 0 { + timer := time.NewTimer(wait) + defer timer.Stop() + expired = timer.C + } + ticker := time.NewTicker(lockPoll) + defer ticker.Stop() + for { + err = unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + return lock, nil + } + if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EINTR) { + lock.Close() + return nil, fmt.Errorf("cannot lock APK: %w", err) + } + select { + case <-ctx.Done(): + lock.Close() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, ErrBusy + } + return nil, ctx.Err() + case <-expired: + lock.Close() + return nil, ErrBusy + case <-ticker.C: + } + } +} + +// checkMemory refuses an index-loading run that cannot fit. MemAvailable is +// what the kernel can hand out without swapping. On top of it, zram can take +// at most the anonymous pages of other processes (AnonPages, apk is not yet +// running) and stores them at about 2:1 (measured mem_used_total against +// orig_data_size), so swapping frees at most half of min(SwapFree, AnonPages). +// An unreadable meminfo blocks nothing. +func checkMemory() error { + info, err := readMemInfo(memInfoPath) + if err != nil { + return nil + } + available, ok := info["MemAvailable"] + if !ok { + return nil + } + budget := available + min(info["SwapFree"], info["AnonPages"])/2 + if budget < requiredKiB { + return &LowMemoryError{AvailableKiB: budget, RequiredKiB: requiredKiB} + } + return nil +} + +// readMemInfo returns the kB values of /proc/meminfo. +func readMemInfo(path string) (map[string]int64, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + values := map[string]int64{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + name, rest, ok := strings.Cut(scanner.Text(), ":") + if !ok { + continue + } + fields := strings.Fields(rest) + if len(fields) == 0 { + continue + } + if value, err := strconv.ParseInt(fields[0], 10, 64); err == nil { + values[name] = value + } + } + return values, scanner.Err() +} diff --git a/server/internal/apkrun/apkrun_test.go b/server/internal/apkrun/apkrun_test.go new file mode 100644 index 00000000..45c96797 --- /dev/null +++ b/server/internal/apkrun/apkrun_test.go @@ -0,0 +1,280 @@ +package apkrun + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// The helper process runs one apk query through the runner, so the lock is +// exercised between processes as well as between goroutines. +func TestMain(m *testing.M) { + if os.Getenv("APKRUN_HELPER") == "1" { + LockPath = os.Getenv("APKRUN_LOCK") + output, err := Command(context.Background(), os.Getenv("APKRUN_APK"), "search", "helper").CombinedOutput() + if err != nil { + fmt.Fprintln(os.Stderr, string(output), err) + os.Exit(1) + } + os.Exit(0) + } + os.Exit(m.Run()) +} + +type fixture struct { + dir, apk string +} + +// newFixture installs a fake apk that fails if another one is running and +// records each run, and points the runner at a private lock and meminfo. +func newFixture(t *testing.T, script string) fixture { + t.Helper() + dir := t.TempDir() + oldLock, oldMem, oldPoll := LockPath, memInfoPath, lockPoll + t.Cleanup(func() { LockPath, memInfoPath, lockPoll = oldLock, oldMem, oldPoll }) + LockPath = filepath.Join(dir, "apk.lock") + memInfoPath = filepath.Join(dir, "meminfo") + lockPoll = 10 * time.Millisecond + writeMemInfo(t, 60000, 40000, 14000) + apk := filepath.Join(dir, "apk") + if script == "" { + script = `if ! mkdir "$DIR/running" 2>/dev/null; then echo overlap >> "$DIR/overlaps"; fi +echo "$*" >> "$DIR/runs" +sleep 0.15 +rmdir "$DIR/running" 2>/dev/null +echo "ran $*"` + } + content := "#!/bin/sh\nDIR=" + dir + "\n" + script + "\n" + if err := os.WriteFile(apk, []byte(content), 0700); err != nil { + t.Fatal(err) + } + return fixture{dir: dir, apk: apk} +} + +func writeMemInfo(t *testing.T, available, swapFree, anon int64) { + t.Helper() + text := fmt.Sprintf("MemTotal: 106924 kB\nMemAvailable: %d kB\nAnonPages: %d kB\nSwapFree: %d kB\n", available, anon, swapFree) + if err := os.WriteFile(memInfoPath, []byte(text), 0600); err != nil { + t.Fatal(err) + } +} + +func (f fixture) lines(name string) []string { + data, _ := os.ReadFile(filepath.Join(f.dir, name)) + text := strings.TrimSpace(string(data)) + if text == "" { + return nil + } + return strings.Split(text, "\n") +} + +func TestClassify(t *testing.T) { + for _, tc := range []struct { + args []string + want class + }{ + {[]string{"info", "-e", "nanokvm-rustdesk"}, installedOnly}, + {[]string{"info", "-e", "-v", "nanokvm-rustdesk"}, installedOnly}, + {[]string{"info", "--verbose"}, indexQuery}, + {[]string{"query", "--no-network", "--from=repositories", "x"}, indexQuery}, + {[]string{"search", "--all", "*mc*"}, indexQuery}, + {[]string{"version", "-l", "<"}, indexQuery}, + {[]string{"update"}, indexQuery}, + {[]string{"del", "--simulate", "--", "mc"}, indexQuery}, + {[]string{"add", "--", "mc"}, transaction}, + {[]string{"del", "--", "mc"}, transaction}, + {[]string{"upgrade"}, transaction}, + {[]string{"fix"}, transaction}, + {[]string{"--no-network", "add", "x"}, transaction}, + {nil, indexQuery}, + } { + if got := classify(tc.args); got != tc.want { + t.Errorf("classify(%q) = %d, want %d", tc.args, got, tc.want) + } + } +} + +func TestRunsDoNotOverlapAcrossGoroutines(t *testing.T) { + f := newFixture(t, "") + var wait sync.WaitGroup + for i := 0; i < 5; i++ { + wait.Add(1) + go func(i int) { + defer wait.Done() + args := []string{"search", fmt.Sprint(i)} + if i%2 == 1 { + args = []string{"add", "--", fmt.Sprint(i)} + } + output, err := Command(context.Background(), f.apk, args...).CombinedOutput() + if err != nil || !strings.HasPrefix(string(output), "ran ") { + t.Errorf("run %d: %q %v", i, output, err) + } + }(i) + } + wait.Wait() + if overlaps := f.lines("overlaps"); len(overlaps) != 0 { + t.Fatalf("%d apk runs overlapped", len(overlaps)) + } + if runs := f.lines("runs"); len(runs) != 5 { + t.Fatalf("runs: %q", runs) + } +} + +func TestRunsDoNotOverlapAcrossProcesses(t *testing.T) { + f := newFixture(t, "") + helpers := make([]*exec.Cmd, 3) + for i := range helpers { + helpers[i] = exec.Command(os.Args[0], "-test.run=^$") + helpers[i].Env = append(os.Environ(), "APKRUN_HELPER=1", "APKRUN_LOCK="+LockPath, "APKRUN_APK="+f.apk) + helpers[i].Stderr = os.Stderr + if err := helpers[i].Start(); err != nil { + t.Fatal(err) + } + } + for i := 0; i < 3; i++ { + if _, err := Command(context.Background(), f.apk, "update").CombinedOutput(); err != nil { + t.Fatal(err) + } + } + for _, helper := range helpers { + if err := helper.Wait(); err != nil { + t.Fatal(err) + } + } + if overlaps := f.lines("overlaps"); len(overlaps) != 0 { + t.Fatalf("%d apk runs overlapped", len(overlaps)) + } + if runs := f.lines("runs"); len(runs) != 6 { + t.Fatalf("runs: %q", runs) + } +} + +func TestLockWaitEndsWithBusy(t *testing.T) { + f := newFixture(t, "") + held, err := acquire(context.Background(), 0) + if err != nil { + t.Fatal(err) + } + defer held.Close() + + cmd := Command(context.Background(), f.apk, "search", "x") + cmd.LockWait = 50 * time.Millisecond + output, err := cmd.CombinedOutput() + if !errors.Is(err, ErrBusy) || !NotStarted(err) || string(output) != ErrBusy.Error() { + t.Fatalf("output %q err %v", output, err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if _, err = Command(ctx, f.apk, "add", "x").CombinedOutput(); !errors.Is(err, ErrBusy) { + t.Fatalf("context deadline: %v", err) + } + // Installed-database queries do not wait for index loads or transactions. + if output, err = Command(context.Background(), f.apk, "info", "-e", "x").CombinedOutput(); err != nil { + t.Fatalf("installed query: %q %v", output, err) + } + if runs := f.lines("runs"); len(runs) != 1 || runs[0] != "info -e x" { + t.Fatalf("runs: %q", runs) + } +} + +func TestQueuedRunGetsItsOwnTimeout(t *testing.T) { + f := newFixture(t, "") + held, err := acquire(context.Background(), 0) + if err != nil { + t.Fatal(err) + } + time.AfterFunc(300*time.Millisecond, func() { held.Close() }) + cmd := Command(context.Background(), f.apk, "search", "x") + cmd.Timeout = 250 * time.Millisecond // shorter than the wait, longer than the run + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%q %v", output, err) + } +} + +func TestLowMemoryRefusesIndexLoads(t *testing.T) { + f := newFixture(t, "") + // 20 MiB available plus half of 14 MiB of swappable anonymous memory. + writeMemInfo(t, 20*1024, 40*1024, 14*1024) + var log strings.Builder + cmd := Command(context.Background(), f.apk, "upgrade") + cmd.Stdout, cmd.Stderr = &log, &log + err := cmd.Run() + var low *LowMemoryError + if !errors.As(err, &low) || !NotStarted(err) || low.AvailableKiB != 27*1024 || low.RequiredKiB != requiredKiB { + t.Fatalf("err %v", err) + } + if !strings.Contains(log.String(), "not enough free memory to run APK safely: about 27 MiB available, 32 MiB needed") { + t.Fatalf("log %q", log.String()) + } + if output, err := Command(context.Background(), f.apk, "info", "-e", "x").CombinedOutput(); err != nil { + t.Fatalf("installed query refused: %q %v", output, err) + } + // Swap only helps as far as other processes have pages to swap out. + writeMemInfo(t, 20*1024, 40*1024, 30*1024) + if output, err := Command(context.Background(), f.apk, "update").CombinedOutput(); err != nil { + t.Fatalf("enough memory refused: %q %v", output, err) + } + if err := os.Remove(memInfoPath); err != nil { + t.Fatal(err) + } + if output, err := Command(context.Background(), f.apk, "update").CombinedOutput(); err != nil { + t.Fatalf("unknown memory refused: %q %v", output, err) + } + if runs := f.lines("runs"); len(runs) != 3 { + t.Fatalf("runs: %q", runs) + } +} + +func TestMeasuredDeviceMemoryAllowsOneRun(t *testing.T) { + newFixture(t, "") + // /proc/meminfo on the device with the server idle (2026-10-07). + writeMemInfo(t, 50452, 47048, 13712) + if err := checkMemory(); err != nil { + t.Fatal(err) + } + // The same device while another index load was at its peak. + writeMemInfo(t, 9204, 44332, 13712+34164) + if err := checkMemory(); err == nil { + t.Fatal("a second index load was allowed during the first one's peak") + } +} + +func TestChildPriorityAndOOMScore(t *testing.T) { + f := newFixture(t, `sleep 0.2 +echo "$(cut -d' ' -f19 /proc/$$/stat) $(cat /proc/$$/oom_score_adj)"`) + own, err := os.ReadFile("/proc/self/stat") + if err != nil { + t.Skip(err) + } + fields := strings.Fields(string(own[strings.LastIndexByte(string(own), ')')+2:])) + ownNice := fields[16] + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"query", "x"}, "19 1000"}, + {[]string{"add", "x"}, ownNice + " 1000"}, + {[]string{"info", "-e", "x"}, ownNice + " 1000"}, + } { + output, err := Command(context.Background(), f.apk, tc.args...).CombinedOutput() + if err != nil || strings.TrimSpace(string(output)) != tc.want { + t.Errorf("%q: %q %v, want %q", tc.args, output, err, tc.want) + } + } +} + +func TestRunReportsApkFailure(t *testing.T) { + f := newFixture(t, `echo "ERROR: unable to select packages" >&2; exit 2`) + output, err := Command(context.Background(), f.apk, "add", "x").CombinedOutput() + var exit *exec.ExitError + if !errors.As(err, &exit) || NotStarted(err) || !strings.Contains(string(output), "unable to select") { + t.Fatalf("%q %v", output, err) + } +} diff --git a/server/internal/oomscore/oomscore.go b/server/internal/oomscore/oomscore.go new file mode 100644 index 00000000..514fb4bc --- /dev/null +++ b/server/internal/oomscore/oomscore.go @@ -0,0 +1,65 @@ +// Package oomscore sets how strongly the kernel OOM killer prefers a process. +// +// With the 4K video memory mode Linux has about 104 MiB and a zram swap of half +// that. The OOM killer picks the process with the highest badness: its +// resident and swapped pages plus oom_score_adj thousandths of RAM+swap. An +// adjustment of -300 is worth about 47 MiB there: the web server, which also +// owns video capture and normally stays below 40 MiB, then outlives other +// processes unless it grows far beyond its normal footprint, when the kernel +// may still kill it (-1000 would forbid that and leave a runaway server +// holding the device). Helpers such as apk get 1000 and go first. +// +// The value is inherited by children, so a protected process resets it for +// children that run arbitrary or long-lived programs. +package oomscore + +import ( + "os" + "strconv" + "strings" +) + +const ( + // Critical is used for NanoKVM-Server and kvm_system. + Critical = -300 + // Normal is the kernel default. + Normal = 0 + // Expendable makes a helper the first OOM victim. + Expendable = 1000 +) + +var procRoot = "/proc" + +// ResetPrefix resets the adjustment of a sh -c script before it runs. +const ResetPrefix = "echo 0 >/proc/self/oom_score_adj; " + +// Unprotected returns the command line that runs name with args at the +// normal adjustment. A protected process uses it for daemons it starts (for +// example through rc-service): the shell resets its own value and then +// becomes the program, so nothing the program starts inherits the protection. +func Unprotected(name string, args ...string) []string { + return append([]string{"/bin/sh", "-c", ResetPrefix + `exec "$0" "$@"`, name}, args...) +} + +// Set writes the adjustment for pid; pid 0 means the calling process. Raising +// it is always allowed; lowering it requires CAP_SYS_RESOURCE. +func Set(pid, value int) error { + target := "self" + if pid != 0 { + target = strconv.Itoa(pid) + } + return os.WriteFile(procRoot+"/"+target+"/oom_score_adj", []byte(strconv.Itoa(value)), 0) +} + +// Get reads the adjustment for pid; pid 0 means the calling process. +func Get(pid int) (int, error) { + target := "self" + if pid != 0 { + target = strconv.Itoa(pid) + } + data, err := os.ReadFile(procRoot + "/" + target + "/oom_score_adj") + if err != nil { + return 0, err + } + return strconv.Atoi(strings.TrimSpace(string(data))) +} diff --git a/server/internal/oomscore/oomscore_test.go b/server/internal/oomscore/oomscore_test.go new file mode 100644 index 00000000..e65380f1 --- /dev/null +++ b/server/internal/oomscore/oomscore_test.go @@ -0,0 +1,42 @@ +package oomscore + +import ( + "os/exec" + "testing" +) + +func TestSetAndGetChild(t *testing.T) { + cmd := exec.Command("sleep", "5") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }() + own, err := Get(0) + if err != nil { + t.Skip(err) + } + if got, err := Get(cmd.Process.Pid); err != nil || got != own { + t.Fatalf("child inherited %d %v, want %d", got, err, own) + } + // Raising needs no privilege. + if err = Set(cmd.Process.Pid, Expendable); err != nil { + t.Fatal(err) + } + if got, err := Get(cmd.Process.Pid); err != nil || got != Expendable { + t.Fatalf("got %d %v", got, err) + } +} + +func TestUnprotectedResetsBeforeExec(t *testing.T) { + // A child stands in for the protected server: it raises its own value, + // which needs no privilege, then starts the program through Unprotected. + argv := Unprotected("cat", "/proc/self/oom_score_adj") + script := "echo 500 >/proc/self/oom_score_adj && exec \"$@\"" + out, err := exec.Command("/bin/sh", append([]string{"-c", script, "sh"}, argv...)...).Output() + if err != nil { + t.Skip(err) + } + if got := string(out); got != "0\n" { + t.Fatalf("program ran with oom_score_adj %q, want 0", got) + } +} diff --git a/server/internal/toolcache/toolcache.go b/server/internal/toolcache/toolcache.go new file mode 100644 index 00000000..6d440afb --- /dev/null +++ b/server/internal/toolcache/toolcache.go @@ -0,0 +1,66 @@ +// Package toolcache keeps what a program printed, such as its version, until +// the program file is replaced, so that status polls do not start the same +// program every few seconds. +package toolcache + +import ( + "fmt" + "os" + "strings" + "sync" + "syscall" + "time" +) + +// Cache runs a command once per executable file. Package upgrades replace +// the file (a new inode), which starts the command again. Callers asking at +// the same time share one run. +type Cache struct { + mu sync.Mutex + run func(path string, args ...string) (string, error) + retry time.Duration + now func() time.Time + entries map[string]entry +} + +type entry struct { + stamp string + output string + err error + at time.Time +} + +// New returns a cache over run. A failed run is answered from the cache for +// retry only, so a timeout under load does not stick. +func New(run func(path string, args ...string) (string, error), retry time.Duration) *Cache { + return &Cache{run: run, retry: retry, now: time.Now, entries: map[string]entry{}} +} + +// Output returns the output and error of run(path, args...) from the last +// run on the same file. A file that cannot be examined is run every time. +func (c *Cache) Output(path string, args ...string) (string, error) { + key := strings.Join(append([]string{path}, args...), "\x00") + stamp := fileStamp(path) + c.mu.Lock() + defer c.mu.Unlock() + now := c.now() + if e, ok := c.entries[key]; ok && stamp != "" && e.stamp == stamp && (e.err == nil || now.Sub(e.at) < c.retry) { + return e.output, e.err + } + output, err := c.run(path, args...) + c.entries[key] = entry{stamp: stamp, output: output, err: err, at: now} + return output, err +} + +// fileStamp identifies the file at path, following symbolic links. +func fileStamp(path string) string { + info, err := os.Stat(path) + if err != nil { + return "" + } + var inode uint64 + if st, ok := info.Sys().(*syscall.Stat_t); ok { + inode = st.Ino + } + return fmt.Sprintf("%d:%d:%d", inode, info.Size(), info.ModTime().UnixNano()) +} diff --git a/server/internal/toolcache/toolcache_test.go b/server/internal/toolcache/toolcache_test.go new file mode 100644 index 00000000..067c8065 --- /dev/null +++ b/server/internal/toolcache/toolcache_test.go @@ -0,0 +1,69 @@ +package toolcache + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestOutputIsKeptUntilTheProgramIsReplaced(t *testing.T) { + dir := t.TempDir() + tool := filepath.Join(dir, "wg") + if err := os.WriteFile(tool, []byte("v1"), 0o755); err != nil { + t.Fatal(err) + } + runs := 0 + c := New(func(path string, args ...string) (string, error) { + runs++ + data, err := os.ReadFile(path) + return string(data) + " " + args[0], err + }, time.Minute) + for range 3 { + if out, err := c.Output(tool, "--version"); err != nil || out != "v1 --version" { + t.Fatalf("%q, %v", out, err) + } + } + if runs != 1 { + t.Fatalf("ran %d times for one program", runs) + } + if out, _ := c.Output(tool, "version"); out != "v1 version" || runs != 2 { + t.Fatalf("other arguments shared an entry: %q", out) + } + // An upgrade renames a new file into place. + next := filepath.Join(dir, ".wg.new") + _ = os.WriteFile(next, []byte("v2"), 0o755) + if err := os.Rename(next, tool); err != nil { + t.Fatal(err) + } + if out, _ := c.Output(tool, "--version"); out != "v2 --version" || runs != 3 { + t.Fatalf("replaced program not run again: %q", out) + } +} + +func TestFailuresAreRetriedAndMissingFilesNotCached(t *testing.T) { + tool := filepath.Join(t.TempDir(), "netbird") + _ = os.WriteFile(tool, nil, 0o755) + runs, failure := 0, errors.New("timed out") + now := time.Unix(1000, 0) + c := New(func(string, ...string) (string, error) { runs++; return "partial", failure }, time.Minute) + c.now = func() time.Time { return now } + if out, err := c.Output(tool, "version"); err == nil || out != "partial" { + t.Fatalf("%q, %v", out, err) + } + if _, err := c.Output(tool, "version"); err == nil || runs != 1 { + t.Fatal("failure not kept for the retry period") + } + now = now.Add(time.Minute) + failure = nil + if _, err := c.Output(tool, "version"); err != nil || runs != 2 { + t.Fatal("failure not retried") + } + missing := filepath.Join(t.TempDir(), "absent") + _, _ = c.Output(missing, "version") + _, _ = c.Output(missing, "version") + if runs != 4 { + t.Fatalf("missing program cached (%d runs)", runs) + } +} diff --git a/server/main.go b/server/main.go index ebf524f9..9f1d651b 100644 --- a/server/main.go +++ b/server/main.go @@ -11,6 +11,7 @@ import ( "NanoKVM-Server/common" "NanoKVM-Server/config" + "NanoKVM-Server/internal/oomscore" "NanoKVM-Server/logger" "NanoKVM-Server/logs" "NanoKVM-Server/middleware" @@ -42,6 +43,13 @@ func initialize(stopMemory context.CancelFunc) { } logger.Init() + utils.InitGoMemLimit() + // The server owns video capture; let the OOM killer take other processes + // first. Set here rather than by the init script so that every way of + // starting the server gets it. + if err := oomscore.Set(0, oomscore.Critical); err != nil { + log.Printf("failed to set OOM score adjustment: %v", err) + } vm.ApplySavedCPUFrequency() if err := network.InitializeIPv6(); err != nil { log.Printf("failed to initialize IPv6 policy: %v", err) diff --git a/server/osupdate/apk.go b/server/osupdate/apk.go index 99c38929..6d4fc286 100644 --- a/server/osupdate/apk.go +++ b/server/osupdate/apk.go @@ -12,6 +12,8 @@ import ( "strings" "syscall" "time" + + "NanoKVM-Server/internal/apkrun" ) var apkStateDir = "/run/nanokvm-apk-ui" @@ -110,7 +112,7 @@ func RunAPK(action string) (result error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() run := func(args ...string) error { - cmd := exec.CommandContext(ctx, apkExecutable, args...) + cmd := apkrun.Command(ctx, apkExecutable, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() @@ -119,7 +121,7 @@ func RunAPK(action string) (result error) { return fmt.Errorf("APK index refresh failed: %w", e) } if action == "check" { - b, e := exec.CommandContext(ctx, apkExecutable, "version", "--limit", "<").CombinedOutput() + b, e := apkrun.Command(ctx, apkExecutable, "version", "--limit", "<").CombinedOutput() if e != nil { return fmt.Errorf("APK upgrade check failed: %s", strings.TrimSpace(string(b))) } diff --git a/server/osupdate/main_test.go b/server/osupdate/main_test.go new file mode 100644 index 00000000..21df3b75 --- /dev/null +++ b/server/osupdate/main_test.go @@ -0,0 +1,21 @@ +package osupdate + +import ( + "os" + "path/filepath" + "testing" + + "NanoKVM-Server/internal/apkrun" +) + +// The fake apk runs take the apk lock; keep it out of the system's /run. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "osupdate-apk-lock-") + if err != nil { + panic(err) + } + apkrun.LockPath = filepath.Join(dir, "apk.lock") + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} diff --git a/server/osupdate/software.go b/server/osupdate/software.go index 16caf81c..8e6be816 100755 --- a/server/osupdate/software.go +++ b/server/osupdate/software.go @@ -13,6 +13,8 @@ import ( "strings" "syscall" "time" + + "NanoKVM-Server/internal/apkrun" ) const ( @@ -238,8 +240,15 @@ func GetSoftwareIndexStatus() SoftwareIndexStatus { return status } -func runAPKOutput(ctx context.Context, args ...string) (string, error) { - b, err := exec.CommandContext(ctx, apkExecutable, args...).CombinedOutput() +// softwareLockWait bounds how long a Software page query waits for another +// apk run. With the query's own timeout it stays below the browser's 60 s +// request limit. +const softwareLockWait = 12 * time.Second + +func runAPKOutput(timeout time.Duration, args ...string) (string, error) { + cmd := apkrun.Command(context.Background(), apkExecutable, args...) + cmd.LockWait, cmd.Timeout = softwareLockWait, timeout + b, err := cmd.CombinedOutput() if len(b) > softwareLogLimit { b = b[len(b)-softwareLogLimit:] } @@ -252,9 +261,7 @@ func ListInstalledSoftware() ([]SoftwarePackage, error) { } // On the NanoKVM, even the installed-package query can take around twenty // seconds after the APK database has grown. Keep it below the browser limit. - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - output, err := runAPKOutput(ctx, "info", "--verbose") + output, err := runAPKOutput(45*time.Second, "info", "--verbose") if err != nil { return nil, fmt.Errorf("cannot list installed packages: %s", strings.TrimSpace(output)) } @@ -272,9 +279,7 @@ func SearchSoftware(query string) ([]SoftwarePackage, error) { // Package catalogue scans run on the NanoKVM's low-power CPU and can take // longer than the lightweight installed-package query. Keep this below the // browser request timeout while allowing APK to finish a normal search. - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - output, err := runAPKOutput(ctx, "search", "--all", "--verbose", "*"+query+"*") + output, err := runAPKOutput(45*time.Second, "search", "--all", "--verbose", "*"+query+"*") if err != nil { return nil, fmt.Errorf("package search failed: %s", strings.TrimSpace(output)) } @@ -287,9 +292,7 @@ func ListSoftwareUpdates() ([]SoftwareUpdate, error) { if _, err := os.Stat("/etc/alpine-release"); err != nil { return nil, errors.New("native APK software management requires Alpine") } - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - output, err := runAPKOutput(ctx, "version", "-l", "<") + output, err := runAPKOutput(45*time.Second, "version", "-l", "<") if err != nil { return nil, fmt.Errorf("cannot check available package updates: %s", strings.TrimSpace(output)) } @@ -305,9 +308,7 @@ func PreviewSoftwareRemoval(name string) (string, error) { return "", err } defer lock.Close() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - output, err := runAPKOutput(ctx, "del", "--simulate", "--", name) + output, err := runAPKOutput(30*time.Second, "del", "--simulate", "--", name) if err != nil { return "", fmt.Errorf("cannot simulate package removal: %s", strings.TrimSpace(output)) } @@ -381,7 +382,7 @@ func RunSoftware(action, name string) (result error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() run := func(args ...string) error { - cmd := exec.CommandContext(ctx, apkExecutable, args...) + cmd := apkrun.Command(ctx, apkExecutable, args...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr return cmd.Run() } diff --git a/server/router/assets.go b/server/router/assets.go new file mode 100644 index 00000000..80d926b6 --- /dev/null +++ b/server/router/assets.go @@ -0,0 +1,65 @@ +package router + +import ( + "mime" + "net/http" + "os" + "path" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +// assets serves the content-hashed build files under /assets: they never +// change under the same name, so browsers may keep them for a year, and the +// precompressed .gz written at build time (web/scripts/precompress.mjs) is +// sent to clients that accept gzip. Everything else falls through to the +// static file server. +func assets(webPath string) gin.HandlerFunc { + return func(c *gin.Context) { + name := path.Clean("/" + c.Request.URL.Path) + if !strings.HasPrefix(name, "/assets/") { + c.Next() + return + } + // Only an asset that exists may be cached: a 404 during a package + // replacement must not stick in browsers once the file arrives. + plain := filepath.Join(webPath, filepath.FromSlash(name)) + if info, err := os.Stat(plain); err != nil || info.IsDir() { + c.Next() + return + } + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Header("Vary", "Accept-Encoding") + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead || + !acceptsGzip(c.GetHeader("Accept-Encoding")) { + c.Next() + return + } + file := plain + ".gz" + if info, err := os.Stat(file); err != nil || info.IsDir() { + c.Next() + return + } + // Set before serving, or the type would come from the .gz name. + if kind := mime.TypeByExtension(path.Ext(name)); kind != "" { + c.Header("Content-Type", kind) + } + c.Header("Content-Encoding", "gzip") + http.ServeFile(c.Writer, c.Request, file) + c.Abort() + } +} + +func acceptsGzip(header string) bool { + for _, part := range strings.Split(header, ",") { + coding, params, _ := strings.Cut(strings.TrimSpace(part), ";") + if !strings.EqualFold(strings.TrimSpace(coding), "gzip") { + continue + } + q := strings.ReplaceAll(strings.ToLower(params), " ", "") + return q != "q=0" && q != "q=0.0" && q != "q=0.00" && q != "q=0.000" + } + return false +} diff --git a/server/router/assets_test.go b/server/router/assets_test.go new file mode 100644 index 00000000..c257e49c --- /dev/null +++ b/server/router/assets_test.go @@ -0,0 +1,85 @@ +package router + +import ( + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/contrib/static" + "github.com/gin-gonic/gin" +) + +func TestAssetsServePrecompressedHashedFiles(t *testing.T) { + gin.SetMode(gin.TestMode) + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "assets"), 0755) + os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0644) + os.WriteFile(filepath.Join(dir, "assets", "app-abc.js"), []byte("plain"), 0644) + os.WriteFile(filepath.Join(dir, "assets", "small-abc.js"), []byte("small"), 0644) + gz, _ := os.Create(filepath.Join(dir, "assets", "app-abc.js.gz")) + w := gzip.NewWriter(gz) + w.Write([]byte("compressed")) + w.Close() + gz.Close() + + r := gin.New() + r.Use(assets(dir)) + r.Use(static.Serve("/", static.LocalFile(dir, true))) + get := func(path, encoding string) *httptest.ResponseRecorder { + req := httptest.NewRequest("GET", path, nil) + if encoding != "" { + req.Header.Set("Accept-Encoding", encoding) + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec + } + + rec := get("/assets/app-abc.js", "br, gzip;q=0.8") + if rec.Code != http.StatusOK || rec.Header().Get("Content-Encoding") != "gzip" { + t.Fatalf("gzip: %d %v", rec.Code, rec.Header()) + } + if kind := rec.Header().Get("Content-Type"); kind != "text/javascript; charset=utf-8" { + t.Fatalf("content type %q", kind) + } + body, _ := gzip.NewReader(rec.Body) + if data, _ := io.ReadAll(body); string(data) != "compressed" { + t.Fatalf("body %q", data) + } + if rec.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" { + t.Fatalf("cache %q", rec.Header().Get("Cache-Control")) + } + + for _, encoding := range []string{"", "identity", "gzip;q=0"} { + rec = get("/assets/app-abc.js", encoding) + if rec.Header().Get("Content-Encoding") != "" || rec.Body.String() != "plain" { + t.Fatalf("%q: %v %q", encoding, rec.Header(), rec.Body.String()) + } + } + // No .gz: the plain file, still cacheable. + rec = get("/assets/small-abc.js", "gzip") + if rec.Body.String() != "small" || rec.Header().Get("Cache-Control") == "" { + t.Fatalf("small: %v %q", rec.Header(), rec.Body.String()) + } + // A missing hashed asset (e.g. mid-update) is an uncached 404, even when + // a stray .gz exists. + os.WriteFile(filepath.Join(dir, "assets", "orphan-abc.js.gz"), []byte("x"), 0644) + for _, missing := range []string{"/assets/not-yet-installed-abc.js", "/assets/orphan-abc.js"} { + rec = get(missing, "gzip") + if rec.Code != http.StatusNotFound || rec.Header().Get("Cache-Control") != "" || + rec.Header().Get("Content-Encoding") != "" { + t.Fatalf("%s: %d %v", missing, rec.Code, rec.Header()) + } + } + // Outside /assets nothing changes, and paths cannot climb out of it. + if rec = get("/index.html", "gzip"); rec.Header().Get("Cache-Control") != "" { + t.Fatalf("index: %v", rec.Header()) + } + if rec = get("/assets/../index.html", "gzip"); rec.Header().Get("Content-Encoding") != "" { + t.Fatalf("traversal: %v", rec.Header()) + } +} diff --git a/server/router/router.go b/server/router/router.go index 02469936..3e18fee0 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -56,6 +56,7 @@ func web(r *gin.Engine) { c.Next() }) + r.Use(assets(webPath)) r.Use(static.Serve("/", static.LocalFile(webPath, true))) } diff --git a/server/router/stream.go b/server/router/stream.go index fafab00a..f84fc7c1 100644 --- a/server/router/stream.go +++ b/server/router/stream.go @@ -1,6 +1,7 @@ package router import ( + "NanoKVM-Server/authn" "NanoKVM-Server/middleware" "NanoKVM-Server/service/stream" "NanoKVM-Server/service/stream/audio" @@ -13,9 +14,13 @@ import ( func streamRouter(r *gin.Engine) { api := r.Group("/api").Use(middleware.CheckToken()) + admin := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(authn.RoleAdmin), + ) api.GET("/stream/mjpeg", mjpeg.Connect) // mjpeg stream - api.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect + admin.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect (device-wide) api.POST("/stream/mjpeg/detect/stop", mjpeg.StopFrameDetect) // temporary stop frame detect api.GET("/stream/state", stream.GetEncoderState) diff --git a/server/router/stream_frame_detect_test.go b/server/router/stream_frame_detect_test.go new file mode 100644 index 00000000..8f4fd552 --- /dev/null +++ b/server/router/stream_frame_detect_test.go @@ -0,0 +1,77 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "NanoKVM-Server/authn" + "NanoKVM-Server/common" + "NanoKVM-Server/middleware" + "github.com/gin-gonic/gin" +) + +func TestFrameDetectIsAdminOnlyAndPersisted(t *testing.T) { + gin.SetMode(gin.TestMode) + store := authn.NewStore(filepath.Join(t.TempDir(), "accounts.json")) + admin, err := store.SetPassword("admin", "owner-password") + if err != nil { + t.Fatalf("admin account: %v", err) + } + if err := store.Create("viewer", "valid-password", authn.RoleUser); err != nil { + t.Fatal(err) + } + viewer, err := store.Get("viewer") + if err != nil { + t.Fatal(err) + } + oldStore := authn.DefaultStore + authn.DefaultStore = store + t.Cleanup(func() { authn.DefaultStore = oldStore }) + oldFile := common.FrameDetectFile + common.FrameDetectFile = filepath.Join(t.TempDir(), "frame_detect") + t.Cleanup(func() { common.FrameDetectFile = oldFile }) + adminToken, err := middleware.GenerateJWT(admin.Username, admin.TokenVersion) + if err != nil { + t.Fatal(err) + } + viewerToken, err := middleware.GenerateJWT(viewer.Username, viewer.TokenVersion) + if err != nil { + t.Fatal(err) + } + r := gin.New() + streamRouter(r) + + post := func(token, body string) int { + req := httptest.NewRequest(http.MethodPost, "/api/stream/mjpeg/detect", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + writer := httptest.NewRecorder() + r.ServeHTTP(writer, req) + return writer.Code + } + + if common.FrameDetectEnabled() { + t.Fatal("frame detect must default to off when nothing is saved") + } + if status := post(viewerToken, `{"enabled":true}`); status != http.StatusForbidden { + t.Fatalf("viewer update = %d, want 403", status) + } + if common.FrameDetectEnabled() { + t.Fatal("a rejected viewer request changed the saved choice") + } + if status := post(adminToken, `{"enabled":true}`); status != http.StatusOK { + t.Fatalf("admin enable = %d, want 200", status) + } + if !common.FrameDetectEnabled() { + t.Fatal("enabling frame detect was not persisted") + } + if status := post(adminToken, `{"enabled":false}`); status != http.StatusOK { + t.Fatalf("admin disable = %d, want 200", status) + } + if common.FrameDetectEnabled() { + t.Fatal("disabling frame detect was not persisted") + } +} diff --git a/server/router/vm.go b/server/router/vm.go index 5c61c9a8..e7e88a6e 100644 --- a/server/router/vm.go +++ b/server/router/vm.go @@ -28,6 +28,12 @@ func vmRouter(r *gin.Engine) { api.GET("/vm/info", service.GetInfo) // get device information api.GET("/vm/hardware", service.GetHardware) // get hardware version + // HDMI, ATX LEDs, input, audio and (administrators) USB in one poll. + api.GET("/vm/live-status", func(c *gin.Context) { + principal, _ := middleware.CurrentPrincipal(c) + service.GetLiveStatus(c, principal.Role == authn.RoleAdmin) + }) + api.POST("/vm/gpio", requireInputOwner(), service.SetGpio) // update gpio api.GET("/vm/gpio", service.GetGpio) // get gpio admin.POST("/vm/screen", service.SetScreen) // update screen diff --git a/server/service/extensions/apkpkg/apkpkg.go b/server/service/extensions/apkpkg/apkpkg.go index 8108a885..701f1b27 100644 --- a/server/service/extensions/apkpkg/apkpkg.go +++ b/server/service/extensions/apkpkg/apkpkg.go @@ -5,12 +5,12 @@ import ( "errors" "fmt" "os" - "os/exec" "path/filepath" "regexp" "strings" "time" + "NanoKVM-Server/internal/apkrun" "NanoKVM-Server/osupdate" ) @@ -50,14 +50,14 @@ func InstallTagged(tag, repository string, names ...string) error { } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() - if output, runErr := exec.CommandContext(ctx, "/sbin/apk", "update").CombinedOutput(); runErr != nil { + if output, runErr := apkrun.Command(ctx, "/sbin/apk", "update").CombinedOutput(); runErr != nil { return fmt.Errorf("apk update failed: %s", tail(output)) } args := []string{"add", "--"} for _, name := range names { args = append(args, name+"@"+tag) } - if output, runErr := exec.CommandContext(ctx, "/sbin/apk", args...).CombinedOutput(); runErr != nil { + if output, runErr := apkrun.Command(ctx, "/sbin/apk", args...).CombinedOutput(); runErr != nil { return fmt.Errorf("apk add failed: %s", tail(output)) } return nil diff --git a/server/service/extensions/netbird/cli.go b/server/service/extensions/netbird/cli.go index 9cf77abf..3d4c2a3d 100644 --- a/server/service/extensions/netbird/cli.go +++ b/server/service/extensions/netbird/cli.go @@ -9,11 +9,15 @@ import ( "io" "os" "os/exec" + "path/filepath" "regexp" "strings" "sync" "syscall" "time" + + "NanoKVM-Server/internal/oomscore" + "NanoKVM-Server/internal/toolcache" ) const ( @@ -73,7 +77,7 @@ func (c *Cli) Start() error { if err != nil || running { return err } - return runProgram(commandTimeout, "rc-service", "netbird", "start") + return startDaemon("start") } func (c *Cli) Restart() error { @@ -94,9 +98,9 @@ func (c *Cli) Restart() error { return err } if !running { - return runProgram(commandTimeout, "rc-service", "netbird", "start") + return startDaemon("start") } - return runProgram(commandTimeout, "rc-service", "netbird", "restart") + return startDaemon("restart") } func (c *Cli) Stop() error { @@ -192,7 +196,7 @@ func (c *Cli) Status() (*NbStatus, error) { } func (c *Cli) ServiceRunning() (bool, error) { - if !isInstalled() { + if !isInstalled() || !openrcMayRun("netbird") { return false, nil } cmd := exec.Command("rc-service", "netbird", "status") @@ -217,8 +221,28 @@ func parseStatus(output string) (*NbStatus, error) { return &status, nil } +var openrcStartedDir = "/run/openrc/started" + +// openrcMayRun is false for a service without its started link: rc-service +// status exits non-zero for it, so it need not run (about 0.45 s on the +// device). rc-service still decides for a started service, which may have +// crashed, and whenever OpenRC state is not visible. +func openrcMayRun(service string) bool { + if _, err := os.Lstat(openrcStartedDir); err != nil { + return true + } + _, err := os.Lstat(filepath.Join(openrcStartedDir, service)) + return !errors.Is(err, os.ErrNotExist) +} + +// Stopped NetBird reports this version on every status poll; it only +// changes when the program is replaced. +var versions = toolcache.New(func(path string, args ...string) (string, error) { + return runProgramOutput(5*time.Second, path, args...) +}, time.Minute) + func installedVersion() string { - output, _ := runProgramOutput(5*time.Second, NetbirdPath, "version") + output, _ := versions.Output(NetbirdPath, "version") return versionRE.FindString(output) } @@ -319,3 +343,10 @@ func scanLoginURL(reader io.Reader, urls chan<- string, wg *sync.WaitGroup) { } } } + +// startDaemon starts the netbird service through OpenRC without the server +// OOM protection, which it would otherwise inherit. +func startDaemon(action string) error { + argv := oomscore.Unprotected("rc-service", "netbird", action) + return runProgram(commandTimeout, argv[0], argv[1:]...) +} diff --git a/server/service/extensions/netbird/cli_test.go b/server/service/extensions/netbird/cli_test.go index e51cb3e4..c1d60bd6 100644 --- a/server/service/extensions/netbird/cli_test.go +++ b/server/service/extensions/netbird/cli_test.go @@ -1,6 +1,8 @@ package netbird import ( + "os" + "path/filepath" "strings" "sync" "testing" @@ -25,6 +27,28 @@ func TestScanLoginURL(t *testing.T) { } } +func TestOpenRCMayRunOnlyWithStartedLink(t *testing.T) { + previous := openrcStartedDir + defer func() { openrcStartedDir = previous }() + openrcStartedDir = filepath.Join(t.TempDir(), "started") + if !openrcMayRun("netbird") { + t.Fatal("unknown OpenRC state must be left to rc-service") + } + if err := os.Mkdir(openrcStartedDir, 0o755); err != nil { + t.Fatal(err) + } + if openrcMayRun("netbird") { + t.Fatal("service without a started link reported as possibly running") + } + // OpenRC's started entries are symbolic links to the init script. + if err := os.Symlink("/etc/init.d/netbird-absent", filepath.Join(openrcStartedDir, "netbird")); err != nil { + t.Fatal(err) + } + if !openrcMayRun("netbird") { + t.Fatal("started service not left to rc-service") + } +} + func TestGetIPv4(t *testing.T) { if got := getIPv4("100.64.0.8/16"); got != "100.64.0.8" { t.Fatalf("unexpected IPv4 %q", got) diff --git a/server/service/extensions/openvpn/service.go b/server/service/extensions/openvpn/service.go index a17914f0..7f5fe759 100644 --- a/server/service/extensions/openvpn/service.go +++ b/server/service/extensions/openvpn/service.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "os" "os/exec" @@ -19,6 +20,7 @@ import ( "syscall" "time" + "NanoKVM-Server/internal/oomscore" "NanoKVM-Server/proto" "NanoKVM-Server/service/extensions/apkpkg" "github.com/gin-gonic/gin" @@ -46,6 +48,11 @@ type Status struct { type Service struct { mu sync.Mutex dir, run string + links map[string]linkKind // DCO kind by interface name +} +type linkKind struct { + index int + dco bool } func NewService() *Service { @@ -227,6 +234,9 @@ func (s *Service) start(p Profile) error { if e = cmd.Start(); e != nil { return fmt.Errorf("OpenVPN could not start") } + // openvpn runs in the foreground without scripts, so resetting the + // adjustment it inherited from the server right after start is enough. + _ = oomscore.Set(cmd.Process.Pid, oomscore.Normal) go func() { _ = cmd.Wait() }() if e = writePrivate(s.runtime(p.ID, ".pid"), []byte(strconv.Itoa(cmd.Process.Pid)+"\n")); e != nil { _ = cmd.Process.Kill() @@ -281,17 +291,20 @@ func readCounter(path string) uint64 { return n } -func interfaceAddress(name string) string { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - b, e := exec.CommandContext(ctx, "ip", "-o", "-4", "addr", "show", "dev", name).Output() +// interfaceAddress is the first IPv4 address, the local one on a +// point-to-point link, as ip -o -4 addr show listed it. +func interfaceAddress(iface *net.Interface) string { + addrs, e := iface.Addrs() if e != nil { return "" } - fields := strings.Fields(string(b)) - for i, field := range fields { - if field == "inet" && i+1 < len(fields) { - return strings.SplitN(fields[i+1], "/", 2)[0] + return firstIPv4(addrs) +} + +func firstIPv4(addrs []net.Addr) string { + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.To4() != nil { + return ipNet.IP.String() } } return "" @@ -328,19 +341,39 @@ func (s *Service) liveStatus(id string) (state, address string, received, sent u } else { state = "connecting" } - address = interfaceAddress(name) base := filepath.Join("/sys/class/net", name, "statistics") received = readCounter(filepath.Join(base, "rx_bytes")) sent = readCounter(filepath.Join(base, "tx_bytes")) - dco = dcoInterface(name) + if iface, e := net.InterfaceByName(name); e == nil { + address = interfaceAddress(iface) + dco = s.dcoInterface(iface) + } return } -func dcoInterface(name string) bool { +var linkDetail = func(name string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - detail, e := exec.CommandContext(ctx, "ip", "-j", "-d", "link", "show", "dev", name).Output() - return e == nil && dcoLinkJSON(detail) + return exec.CommandContext(ctx, "ip", "-j", "-d", "link", "show", "dev", name).Output() +} + +// The link kind is fixed for the life of an interface, so ip runs once per +// tunnel interface (a restarted tunnel has a new index), not on every +// status poll. Callers hold s.mu. +func (s *Service) dcoInterface(iface *net.Interface) bool { + if known, ok := s.links[iface.Name]; ok && known.index == iface.Index { + return known.dco + } + detail, e := linkDetail(iface.Name) + if e != nil { + return false + } + dco := dcoLinkJSON(detail) + if s.links == nil { + s.links = map[string]linkKind{} + } + s.links[iface.Name] = linkKind{index: iface.Index, dco: dco} + return dco } func dcoLinkJSON(data []byte) bool { diff --git a/server/service/extensions/openvpn/service_test.go b/server/service/extensions/openvpn/service_test.go index 9eb7d804..d5f709c5 100644 --- a/server/service/extensions/openvpn/service_test.go +++ b/server/service/extensions/openvpn/service_test.go @@ -1,6 +1,8 @@ package openvpn import ( + "errors" + "net" "os" "path/filepath" "testing" @@ -56,3 +58,49 @@ func TestDCOLinkJSONUsesKernelLinkKind(t *testing.T) { }) } } + +func TestDCOKindIsReadOncePerInterface(t *testing.T) { + previous := linkDetail + defer func() { linkDetail = previous }() + runs := 0 + kind, failure := "ovpn", error(nil) + linkDetail = func(name string) ([]byte, error) { + runs++ + return []byte(`[{"ifname":"` + name + `","linkinfo":{"info_kind":"` + kind + `"}}]`), failure + } + s := &Service{} + tunnel := &net.Interface{Name: "nv0123456789", Index: 7} + if !s.dcoInterface(tunnel) || !s.dcoInterface(tunnel) || runs != 1 { + t.Fatalf("DCO link read %d times", runs) + } + // A restarted tunnel is a new interface, whatever its kind now. + kind = "tun" + if s.dcoInterface(&net.Interface{Name: tunnel.Name, Index: 8}) || runs != 2 { + t.Fatalf("new interface index not read again (%d runs)", runs) + } + failure = errors.New("ip failed") + if s.dcoInterface(&net.Interface{Name: tunnel.Name, Index: 9}) || s.dcoInterface(&net.Interface{Name: tunnel.Name, Index: 9}) || runs != 4 { + t.Fatalf("a failed read was kept (%d runs)", runs) + } +} + +func TestFirstIPv4SkipsIPv6(t *testing.T) { + _, v6, _ := net.ParseCIDR("fd00::2/64") + addrs := []net.Addr{v6, &net.IPNet{IP: net.IPv4(10, 8, 0, 6), Mask: net.CIDRMask(32, 32)}, &net.IPNet{IP: net.IPv4(10, 9, 0, 2), Mask: net.CIDRMask(24, 32)}} + if got := firstIPv4(addrs); got != "10.8.0.6" { + t.Fatalf("firstIPv4() = %q", got) + } + if got := firstIPv4(addrs[:1]); got != "" { + t.Fatalf("IPv6-only interface reported %q", got) + } +} + +func TestInterfaceAddressReadsTheKernel(t *testing.T) { + loopback, err := net.InterfaceByName("lo") + if err != nil { + t.Skip("no loopback interface") + } + if got := interfaceAddress(loopback); got != "127.0.0.1" { + t.Fatalf("interfaceAddress(lo) = %q", got) + } +} diff --git a/server/service/extensions/tailscale/cli.go b/server/service/extensions/tailscale/cli.go index 323cd8e0..ce2b8126 100644 --- a/server/service/extensions/tailscale/cli.go +++ b/server/service/extensions/tailscale/cli.go @@ -9,6 +9,8 @@ import ( "os/exec" "regexp" "strings" + + "NanoKVM-Server/internal/oomscore" ) const TailscaledPath = "/usr/sbin/tailscaled" @@ -41,7 +43,7 @@ func (c *Cli) Start() error { if c.IsRunning() { return nil } - return exec.Command("rc-service", "tailscale", "start").Run() + return startDaemon("start") } func (c *Cli) Restart() error { @@ -55,9 +57,9 @@ func (c *Cli) Restart() error { return err } if !c.IsRunning() { - return exec.Command("rc-service", "tailscale", "start").Run() + return startDaemon("start") } - return exec.Command("rc-service", "tailscale", "restart").Run() + return startDaemon("restart") } func (c *Cli) Stop() error { @@ -79,7 +81,17 @@ func (c *Cli) Up() error { func (c *Cli) Down() error { return exec.Command(TailscalePath, "down").Run() } +// Status is polled every 5 s by the VPN page. It asks tailscaled directly +// and starts the client only when the local API gives no usable answer. func (c *Cli) Status() (*TsStatus, error) { + status, err := localStatus(localAPISocket) + if err == nil || errors.Is(err, errDaemonUnreachable) { + return status, err + } + return cliStatus() +} + +var cliStatus = func() (*TsStatus, error) { output, err := exec.Command(TailscalePath, "status", "--json").CombinedOutput() if err != nil { return nil, err @@ -138,3 +150,10 @@ func ensureNetbirdStopped() error { } return nil } + +// startDaemon starts tailscaled through OpenRC without the server's OOM +// protection, which it would otherwise inherit. +func startDaemon(action string) error { + argv := oomscore.Unprotected("rc-service", "tailscale", action) + return exec.Command(argv[0], argv[1:]...).Run() +} diff --git a/server/service/extensions/tailscale/localapi.go b/server/service/extensions/tailscale/localapi.go new file mode 100644 index 00000000..2b2fb575 --- /dev/null +++ b/server/service/extensions/tailscale/localapi.go @@ -0,0 +1,53 @@ +package tailscale + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "syscall" + "time" +) + +// The socket the tailscale client uses on Linux when /var/run exists. +var localAPISocket = "/var/run/tailscale/tailscaled.sock" + +// errDaemonUnreachable means nothing listens on the socket: tailscale status +// fails the same way, so there is no point in starting it. +var errDaemonUnreachable = errors.New("tailscaled is not reachable") + +const localAPITimeout = 10 * time.Second + +// localStatus reads the status that tailscale status --json prints from +// tailscaled's local API, without starting the client, a large Go program. +func localStatus(socket string) (*TsStatus, error) { + client := http.Client{Timeout: localAPITimeout, Transport: &http.Transport{ + DisableKeepAlives: true, + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "unix", socket) + if errors.Is(err, syscall.ENOENT) || errors.Is(err, syscall.ECONNREFUSED) { + return nil, fmt.Errorf("%w: %w", errDaemonUnreachable, err) + } + return conn, err + }, + }} + // The local API only accepts this host name; the client leaves out peers + // it does not need. + response, err := client.Get("http://local-tailscaled.sock/localapi/v0/status?peers=false") + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("tailscaled local API: %s", response.Status) + } + var status TsStatus + if err = json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&status); err != nil { + return nil, err + } + return &status, nil +} diff --git a/server/service/extensions/tailscale/localapi_test.go b/server/service/extensions/tailscale/localapi_test.go new file mode 100644 index 00000000..b34b5445 --- /dev/null +++ b/server/service/extensions/tailscale/localapi_test.go @@ -0,0 +1,81 @@ +package tailscale + +import ( + "errors" + "net" + "net/http" + "os" + "path/filepath" + "testing" +) + +func serveLocalAPI(t *testing.T, handler http.HandlerFunc) string { + t.Helper() + socket := filepath.Join(t.TempDir(), "tailscaled.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + server := &http.Server{Handler: handler} + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { _ = server.Close() }) + return socket +} + +func useLocalAPI(t *testing.T, socket string, cli func() (*TsStatus, error)) { + t.Helper() + previousSocket, previousCLI := localAPISocket, cliStatus + localAPISocket, cliStatus = socket, cli + t.Cleanup(func() { localAPISocket, cliStatus = previousSocket, previousCLI }) +} + +func TestStatusReadsTheLocalAPI(t *testing.T) { + socket := serveLocalAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.Host != "local-tailscaled.sock" || r.URL.Path != "/localapi/v0/status" || r.Method != http.MethodGet { + http.Error(w, "invalid localapi request", http.StatusForbidden) + return + } + _, _ = w.Write([]byte(`{"Version":"1.90.0","BackendState":"Running","Self":{"HostName":"nanokvm","TailscaleIPs":["100.64.0.9","fd7a::9"]},"CurrentTailnet":{"Name":"example.ts.net"}}`)) + }) + useLocalAPI(t, socket, func() (*TsStatus, error) { t.Fatal("client started"); return nil, nil }) + status, err := NewCli().Status() + if err != nil || status.BackendState != "Running" || status.Self.HostName != "nanokvm" || len(status.Self.TailscaleIPs) != 2 || status.CurrentTailnet.Name != "example.ts.net" { + t.Fatalf("%+v, %v", status, err) + } +} + +func TestStatusWithoutDaemonDoesNotStartTheClient(t *testing.T) { + socket := filepath.Join(t.TempDir(), "tailscaled.sock") + useLocalAPI(t, socket, func() (*TsStatus, error) { t.Fatal("client started"); return nil, nil }) + if _, err := NewCli().Status(); !errors.Is(err, errDaemonUnreachable) { + t.Fatalf("missing socket: %v", err) + } + // A socket left behind by a stopped daemon refuses connections. + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + listener.(*net.UnixListener).SetUnlinkOnClose(false) + _ = listener.Close() + if _, err := os.Stat(socket); err != nil { + t.Fatal(err) + } + if _, err := NewCli().Status(); !errors.Is(err, errDaemonUnreachable) { + t.Fatalf("stale socket: %v", err) + } +} + +func TestStatusFallsBackToTheClient(t *testing.T) { + for name, handler := range map[string]http.HandlerFunc{ + "refused": func(w http.ResponseWriter, r *http.Request) { http.Error(w, "denied", http.StatusForbidden) }, + "invalid": func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("not json")) }, + } { + t.Run(name, func(t *testing.T) { + fromClient := &TsStatus{BackendState: "NeedsLogin"} + useLocalAPI(t, serveLocalAPI(t, handler), func() (*TsStatus, error) { return fromClient, nil }) + if status, err := NewCli().Status(); err != nil || status != fromClient { + t.Fatalf("%+v, %v", status, err) + } + }) + } +} diff --git a/server/service/extensions/vpn/versions.go b/server/service/extensions/vpn/versions.go index 48a5db96..32c0a069 100644 --- a/server/service/extensions/vpn/versions.go +++ b/server/service/extensions/vpn/versions.go @@ -5,33 +5,36 @@ import ( "os/exec" "regexp" "strings" - "sync" "time" + "NanoKVM-Server/internal/toolcache" "NanoKVM-Server/proto" "github.com/gin-gonic/gin" ) var versionPattern = regexp.MustCompile(`[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:[-+][a-zA-Z0-9.-]+)*`) -var mu sync.Mutex -var cached map[string]string -var expires time.Time + +var tools = map[string]string{"tailscale": "tailscale", "wireguard": "wg", "openvpn": "openvpn", "netbird": "netbird"} + +// The VPN pages ask every 5 s; a version only changes with the program file. +var versions = toolcache.New(func(path string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + b, err := exec.CommandContext(ctx, path, args...).Output() + return string(b), err +}, time.Minute) func Versions(c *gin.Context) { - mu.Lock() - defer mu.Unlock() - if time.Now().After(expires) { - cached = make(map[string]string) - for name, tool := range map[string]string{"tailscale": "tailscale", "wireguard": "wg", "openvpn": "openvpn", "netbird": "netbird"} { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - b, err := exec.CommandContext(ctx, tool, "--version").Output() - cancel() - if err == nil { - cached[name] = versionPattern.FindString(strings.SplitN(string(b), "\n", 2)[0]) - } + result := make(map[string]string) + for name, tool := range tools { + path, err := exec.LookPath(tool) + if err != nil { + continue + } + if out, err := versions.Output(path, "--version"); err == nil { + result[name] = versionPattern.FindString(strings.SplitN(out, "\n", 2)[0]) } - expires = time.Now().Add(time.Minute) } var rsp proto.Response - rsp.OkRspWithData(c, cached) + rsp.OkRspWithData(c, result) } diff --git a/server/service/extensions/wireguard/dump_test.go b/server/service/extensions/wireguard/dump_test.go new file mode 100644 index 00000000..e6408484 --- /dev/null +++ b/server/service/extensions/wireguard/dump_test.go @@ -0,0 +1,17 @@ +package wireguard + +import "testing" + +func TestParseDumpTakesNewestHandshakeAndSumsTransfer(t *testing.T) { + dump := "cHJpdmF0ZQ==\tcHVibGlj\t51820\toff\n" + + "cGVlcjE=\t(none)\t203.0.113.7:13231\t0.0.0.0/0,::/0\t1791386410\t18308\t67500\t25\n" + + "cGVlcjI=\t(none)\t(none)\t10.7.0.2/32\t1791386500\t2\t3\toff\n" + + "cGVlcjM=\t(none)\t(none)\t(none)\t0\t0\t0\toff\n" + handshake, received, sent := parseDump(dump) + if handshake != 1791386500 || received != 18310 || sent != 67503 { + t.Fatalf("handshake %d, received %d, sent %d", handshake, received, sent) + } + if handshake, received, sent = parseDump("cHJpdmF0ZQ==\tcHVibGlj\t51820\toff\n"); handshake != 0 || received != 0 || sent != 0 { + t.Fatal("a tunnel without peers reported traffic") + } +} diff --git a/server/service/extensions/wireguard/service.go b/server/service/extensions/wireguard/service.go index af81bee2..ba6938e8 100644 --- a/server/service/extensions/wireguard/service.go +++ b/server/service/extensions/wireguard/service.go @@ -222,6 +222,27 @@ func (s *Service) restore() { s.mu.Unlock() } } + +// parseDump reads wg show dump: an interface line, then per peer +// public-key, preshared-key, endpoint, allowed-ips, latest-handshake, +// transfer-rx, transfer-tx and persistent-keepalive, tab separated. It +// returns the newest handshake and the summed transfer of all peers. +func parseDump(text string) (lastHandshake int64, received, sent uint64) { + for _, line := range strings.Split(text, "\n") { + f := strings.Split(line, "\t") + if len(f) != 8 { + continue + } + n, _ := strconv.ParseInt(f[4], 10, 64) + lastHandshake = max(lastHandshake, n) + rx, _ := strconv.ParseUint(f[5], 10, 64) + tx, _ := strconv.ParseUint(f[6], 10, 64) + received += rx + sent += tx + } + return +} + func (s *Service) GetStatus(c *gin.Context) { s.mu.Lock() defer s.mu.Unlock() @@ -251,20 +272,13 @@ func (s *Service) GetStatus(c *gin.Context) { if iface, err := net.InterfaceByName(p.ID); err == nil { st.MTU = iface.MTU } - b, err := command(2*time.Second, "wg", "show", p.ID, "latest-handshakes") + // One wg run for handshakes and transfer: status is polled every 5 s. + b, err := command(2*time.Second, "wg", "show", p.ID, "dump") if err != nil { st.State = "error" st.Error = "cannot read tunnel status" } else { - for _, line := range strings.Split(string(b), "\n") { - f := strings.Fields(line) - if len(f) == 2 { - n, _ := strconv.ParseInt(f[1], 10, 64) - if n > st.LastHandshake { - st.LastHandshake = n - } - } - } + st.LastHandshake, st.Received, st.Sent = parseDump(string(b)) if st.LastHandshake > 0 { st.State = "idle" if time.Now().Unix()-st.LastHandshake < 180 { @@ -272,16 +286,6 @@ func (s *Service) GetStatus(c *gin.Context) { } } } - b, _ = command(2*time.Second, "wg", "show", p.ID, "transfer") - for _, line := range strings.Split(string(b), "\n") { - f := strings.Fields(line) - if len(f) == 3 { - rx, _ := strconv.ParseUint(f[1], 10, 64) - tx, _ := strconv.ParseUint(f[2], 10, 64) - st.Received += rx - st.Sent += tx - } - } } else if st.Error != "" { st.State = "error" } diff --git a/server/service/hid/status.go b/server/service/hid/status.go index b419abd4..0451d875 100644 --- a/server/service/hid/status.go +++ b/server/service/hid/status.go @@ -58,16 +58,22 @@ func (s *Service) GetHidMode(c *gin.Context) { // GetInputStatus is read-only and available to every authenticated KVM viewer. func (s *Service) GetInputStatus(c *gin.Context) { + var rsp proto.Response + rsp.OkRspWithData(c, InputStatus()) +} + +// InputStatus is the GET /api/hid/input-status payload, also part of the +// combined live status. +func InputStatus() gin.H { h := GetHid() h.Lock() defer h.Unlock() keyboard, relative, absolute := disabledHIDFunctions("/boot") - var rsp proto.Response - rsp.OkRspWithData(c, gin.H{ + return gin.H{ "available": !keyboard || !relative || !absolute, "keyboard": !keyboard, "relative": !relative, "absolute": !absolute, "pointerProfile": map[bool]string{false: "default", true: "windows"}[common.WindowsPointerEnabled()], - }) + } } func (s *Service) GetKeyboardLedStatus(c *gin.Context) { diff --git a/server/service/network/gateway.go b/server/service/network/gateway.go index 8e380432..30bdd45d 100644 --- a/server/service/network/gateway.go +++ b/server/service/network/gateway.go @@ -91,34 +91,30 @@ func writeGatewayPreference(preferred string) error { return os.Rename(temporary, gatewayPreferenceFile) } +// The kernel's main IPv4 table, which ip -4 route show lists, without +// starting ip each time the network settings open. +var gatewayRouteTable = "/proc/net/route" + func defaultGatewayRoutes() []proto.GatewayRoute { - output, err := gatewayRouteCommand("-4", "route", "show", "default") + table, err := os.ReadFile(gatewayRouteTable) if err != nil { return []proto.GatewayRoute{} } - return parseDefaultGatewayRoutes(string(output)) + return parseDefaultGatewayRoutes(string(table)) } -func parseDefaultGatewayRoutes(output string) []proto.GatewayRoute { +// parseDefaultGatewayRoutes reads /proc/net/route: Iface, Destination, +// Gateway, Flags, RefCnt, Use, Metric, Mask, ... with addresses in hex. +func parseDefaultGatewayRoutes(table string) []proto.GatewayRoute { routes := []proto.GatewayRoute{} seen := map[string]bool{} - for _, line := range strings.Split(output, "\n") { + for _, line := range strings.Split(table, "\n") { fields := strings.Fields(line) - if len(fields) < 5 || fields[0] != "default" { + if len(fields) < 8 || fields[1] != "00000000" || fields[7] != "00000000" { continue } - var gateway, iface string - metric := 0 - for i := 0; i+1 < len(fields); i++ { - switch fields[i] { - case "via": - gateway = fields[i+1] - case "dev": - iface = fields[i+1] - case "metric": - metric, _ = strconv.Atoi(fields[i+1]) - } - } + iface, gateway := fields[0], parseRouteGateway(fields[2]) + metric, _ := strconv.Atoi(fields[6]) if gateway == "" || !isGatewayInterface(iface) { continue } diff --git a/server/service/network/gateway_test.go b/server/service/network/gateway_test.go index 82105eab..975ebcdb 100644 --- a/server/service/network/gateway_test.go +++ b/server/service/network/gateway_test.go @@ -1,11 +1,24 @@ package network -import "testing" +import ( + "os" + "path/filepath" + "testing" +) + +const routeTableHeader = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n" func TestParseDefaultGatewayRoutesOnlyReturnsPhysicalInterfaces(t *testing.T) { - routes := parseDefaultGatewayRoutes("default via 192.168.1.1 dev eth0 proto dhcp metric 100\ndefault via 10.0.0.1 dev wlan0 proto dhcp metric 200\ndefault dev tun0 scope link metric 50\ndefault via 192.168.1.1 dev eth0 proto dhcp metric 100\n") + routes := parseDefaultGatewayRoutes(routeTableHeader + + "eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n" + + "wlan0\t00000000\t0100000A\t0003\t0\t0\t200\t00000000\t0\t0\t0\n" + + "tun0\t00000000\t00000000\t0001\t0\t0\t50\t00000000\t0\t0\t0\n" + + "wg0\t00000000\t0100070A\t0003\t0\t0\t10\t00000000\t0\t0\t0\n" + + "*\t00000000\t00000000\t0201\t0\t0\t0\t00000000\t0\t0\t0\n" + + "eth0\t0001A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0\n" + + "eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n") if len(routes) != 2 { - t.Fatalf("got %d routes, want 2", len(routes)) + t.Fatalf("got %d routes, want 2: %+v", len(routes), routes) } if routes[0].Interface != "eth0" || routes[0].Gateway != "192.168.1.1" || routes[0].Metric != 100 { t.Fatalf("unexpected Ethernet route: %+v", routes[0]) @@ -14,3 +27,17 @@ func TestParseDefaultGatewayRoutesOnlyReturnsPhysicalInterfaces(t *testing.T) { t.Fatalf("unexpected Wi-Fi route: %+v", routes[1]) } } + +func TestDefaultGatewayRoutesReadsTheRouteTable(t *testing.T) { + previous := gatewayRouteTable + defer func() { gatewayRouteTable = previous }() + gatewayRouteTable = filepath.Join(t.TempDir(), "route") + if routes := defaultGatewayRoutes(); routes == nil || len(routes) != 0 { + t.Fatalf("unreadable table: %+v", routes) + } + _ = os.WriteFile(gatewayRouteTable, []byte(routeTableHeader+"wlan0\t00000000\t0104A8C0\t0003\t0\t0\t303\t00000000\t0\t0\t0\n"), 0600) + routes := defaultGatewayRoutes() + if len(routes) != 1 || routes[0].Interface != "wlan0" || routes[0].Gateway != "192.168.4.1" || routes[0].Metric != 303 { + t.Fatalf("%+v", routes) + } +} diff --git a/server/service/network/wifi_control.go b/server/service/network/wifi_control.go index 022a1feb..7a2533ba 100644 --- a/server/service/network/wifi_control.go +++ b/server/service/network/wifi_control.go @@ -22,11 +22,42 @@ import ( var wifiMu sync.Mutex var wifiBusy bool var wifiError string -var wifiControl = radioControl{etc: "/etc/kvm", net: "/sys/class/net/wlan0", run: runRadioCommand} +var wifiControl = radioControl{etc: "/etc/kvm", net: "/sys/class/net/wlan0", run: runRadioCommand, + request: wpaControl(wpaControlSocket), bands: &bandCache{ttl: 30 * time.Second}} type radioControl struct { etc, net string run func(string, ...string) (string, error) + // request sends a command to wpa_supplicant for wlan0 and returns the reply. + request func(string) (string, error) + // bands, when set, lets status polls reuse a recent channel list. + bands *bandCache +} + +// bandCache keeps the last successful channel list of a phy. iw reports +// hardware and regulatory limits, which change rarely, while the Wi-Fi page +// asks for status every few seconds. Concurrent pollers share one iw run. +type bandCache struct { + mu sync.Mutex + ttl time.Duration + phy string + at time.Time + freqs map[string][]string +} + +func (c *bandCache) get(phy string, now time.Time, read func() map[string][]string) map[string][]string { + c.mu.Lock() + defer c.mu.Unlock() + if c.freqs != nil && c.phy == phy && now.Sub(c.at) >= 0 && now.Sub(c.at) < c.ttl { + return c.freqs + } + freqs := read() + c.freqs = nil + // A failed or empty answer is not kept; the next poll asks again. + if len(freqs) > 0 { + c.phy, c.at, c.freqs = phy, now, freqs + } + return freqs } func runRadioCommand(name string, args ...string) (string, error) { @@ -85,18 +116,36 @@ func parseFrequencies(output string) map[string][]string { } return result } -func (w radioControl) frequencies() map[string][]string { +func (w radioControl) phy() string { phy, err := filepath.EvalSymlinks(filepath.Join(w.net, "phy80211")) if err != nil { + return "" + } + return filepath.Base(phy) +} + +func (w radioControl) phyFrequencies(phy string) map[string][]string { + if phy == "" { return map[string][]string{} } - out, err := w.run("iw", "phy", filepath.Base(phy), "info") + out, err := w.run("iw", "phy", phy, "info") if err != nil { return map[string][]string{} } return parseFrequencies(out) } +func (w radioControl) frequencies() map[string][]string { return w.phyFrequencies(w.phy()) } + +// Scan and connect read the channel list afresh; only status reuses it. +func (w radioControl) statusFrequencies() map[string][]string { + phy := w.phy() + if w.bands == nil || phy == "" { + return w.phyFrequencies(phy) + } + return w.bands.get(phy, time.Now(), func() map[string][]string { return w.phyFrequencies(phy) }) +} + const wifiBandPreferenceFile = "wifi.band_preference" // Older installations do not have a preference file. Keep their connection @@ -244,7 +293,7 @@ func (w radioControl) status() *proto.GetWifiRsp { return d } d.Model = w.model() - freqs := w.frequencies() + freqs := w.statusFrequencies() for _, b := range []string{"2.4", "5"} { if len(freqs[b]) > 0 { d.Bands = append(d.Bands, b) @@ -253,7 +302,7 @@ func (w radioControl) status() *proto.GetWifiRsp { if !d.Enabled || d.ApMode { return d } - out, err := w.run("wpa_cli", "-i", "wlan0", "status") + out, err := w.request("STATUS") if err != nil { return d } diff --git a/server/service/network/wifi_control_test.go b/server/service/network/wifi_control_test.go index 93539858..a8510ae5 100644 --- a/server/service/network/wifi_control_test.go +++ b/server/service/network/wifi_control_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "github.com/gin-gonic/gin" + gonet "net" "net/http/httptest" "os" "path/filepath" @@ -119,6 +120,11 @@ func TestWifiHardwareAndStatus(t *testing.T) { if name == "iw" { return "* 2412 MHz [1]\n* 5180 MHz [36]", nil } + return "", errors.New("unexpected command " + name) + }, request: func(command string) (string, error) { + if command != "STATUS" { + return "", errors.New("unexpected request " + command) + } return "wpa_state=COMPLETED\nssid=Actual\x20Network\nfreq=5180", nil }} if !w.enabled() || w.present() { @@ -143,12 +149,82 @@ func TestWifiHardwareAndStatus(t *testing.T) { } _ = os.Remove(filepath.Join(etc, "wifi.disabled")) w.run = func(string, ...string) (string, error) { return "", errors.New("unavailable") } + w.request = func(string) (string, error) { return "", errors.New("unavailable") } d = w.status() if d.Connected || len(d.Bands) != 0 { t.Fatal("invented state on command failure") } } +func TestWifiStatusReusesRecentBands(t *testing.T) { + root := t.TempDir() + net := filepath.Join(root, "wlan0") + _ = os.Mkdir(net, 0700) + for _, phy := range []string{"phy0", "phy1"} { + _ = os.Mkdir(filepath.Join(root, phy), 0700) + } + _ = os.Symlink(filepath.Join(root, "phy0"), filepath.Join(net, "phy80211")) + runs := 0 + output, failure := "* 2412 MHz [1]\n* 5180 MHz [36]", error(nil) + w := radioControl{etc: root, net: net, bands: &bandCache{ttl: time.Hour}, run: func(name string, args ...string) (string, error) { + runs++ + return output, failure + }, request: func(string) (string, error) { return "wpa_state=SCANNING", nil }} + bands := func() []string { return w.status().Bands } + if got := bands(); len(got) != 2 || runs != 1 { + t.Fatalf("bands %v after %d runs", got, runs) + } + output = "* 2412 MHz [1]" + if got := bands(); len(got) != 2 || runs != 1 { + t.Fatalf("status did not reuse the channel list: %v after %d runs", got, runs) + } + if got := w.frequencies(); len(got["5"]) != 0 || runs != 2 { + t.Fatalf("scan and connect must read channels afresh: %v", got) + } + // A replaced phy (driver reload) is read again. + _ = os.Remove(filepath.Join(net, "phy80211")) + _ = os.Symlink(filepath.Join(root, "phy1"), filepath.Join(net, "phy80211")) + if got := bands(); len(got) != 1 || runs != 3 { + t.Fatalf("new phy not read: %v after %d runs", got, runs) + } + // So is an expired entry, and a failure is not kept. + w.bands.at = w.bands.at.Add(-2 * time.Hour) + failure = errors.New("busy") + if got := bands(); len(got) != 0 || runs != 4 { + t.Fatalf("failure: %v after %d runs", got, runs) + } + failure = nil + if got := bands(); len(got) != 1 || runs != 5 { + t.Fatalf("failure was cached: %v after %d runs", got, runs) + } +} + +func TestWpaControlRequest(t *testing.T) { + socket := filepath.Join(t.TempDir(), "wlan0") + server, err := gonet.ListenUnixgram("unixgram", &gonet.UnixAddr{Net: "unixgram", Name: socket}) + if err != nil { + t.Fatal(err) + } + defer server.Close() + go func() { + buf := make([]byte, 64) + n, from, err := server.ReadFromUnix(buf) + if err != nil || from == nil { + return + } + // An event before the reply must be skipped. + _, _ = server.WriteToUnix([]byte("<3>CTRL-EVENT-SCAN-STARTED"), from) + _, _ = server.WriteToUnix([]byte("wpa_state=COMPLETED\nrequest="+string(buf[:n])+"\n"), from) + }() + reply, err := wpaControl(socket)("STATUS") + if err != nil || reply != "wpa_state=COMPLETED\nrequest=STATUS\n" { + t.Fatalf("reply %q, err %v", reply, err) + } + if _, err = wpaControl(filepath.Join(t.TempDir(), "absent"))("STATUS"); err == nil { + t.Fatal("missing wpa_supplicant socket reported a reply") + } +} + func TestWifiProfileStorage(t *testing.T) { w := radioControl{etc: t.TempDir()} p := wifiProfile{Ssid: "Private", Password: "valid123", Hidden: true, Security: "personal", Band: "5"} diff --git a/server/service/network/wpa_ctrl.go b/server/service/network/wpa_ctrl.go new file mode 100644 index 00000000..8a13231a --- /dev/null +++ b/server/service/network/wpa_ctrl.go @@ -0,0 +1,55 @@ +package network + +import ( + "fmt" + "net" + "os" + "strings" + "sync/atomic" + "time" +) + +// wpa_cli's default control directory; wpa_cli -i wlan0 talks to this socket. +const wpaControlSocket = "/var/run/wpa_supplicant/wlan0" + +// wpa_cli waits ten seconds for a reply and reads at most 4 KiB. +const ( + wpaControlTimeout = 10 * time.Second + wpaReplyLimit = 4096 +) + +var wpaClientSequence atomic.Uint64 + +// wpaControl returns a client for wpa_supplicant's control socket. A request +// gets the same reply wpa_cli prints for the command, without starting a +// process on every Wi-Fi status poll. +func wpaControl(socket string) func(string) (string, error) { + return func(command string) (string, error) { + // wpa_supplicant replies to the sender's address, so the client socket + // needs a name; the abstract namespace leaves no file behind. + local := &net.UnixAddr{Net: "unixgram", Name: fmt.Sprintf("@nanokvm-wpa-%d-%d", os.Getpid(), wpaClientSequence.Add(1))} + conn, err := net.DialUnix("unixgram", local, &net.UnixAddr{Net: "unixgram", Name: socket}) + if err != nil { + return "", err + } + defer conn.Close() + if err = conn.SetDeadline(time.Now().Add(wpaControlTimeout)); err != nil { + return "", err + } + if _, err = conn.Write([]byte(command)); err != nil { + return "", err + } + reply := make([]byte, wpaReplyLimit) + for { + n, err := conn.Read(reply) + if err != nil { + return "", err + } + // Like wpa_ctrl_request, skip event messages ("text"); they + // only reach monitors that attached, which this client never does. + if text := string(reply[:n]); !strings.HasPrefix(text, "<") { + return text, nil + } + } + } +} diff --git a/server/service/rustdesk/service.go b/server/service/rustdesk/service.go index a9a2a642..49b0c6a2 100644 --- a/server/service/rustdesk/service.go +++ b/server/service/rustdesk/service.go @@ -1,6 +1,7 @@ package rustdesk import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -10,12 +11,17 @@ import ( "net/url" "os" "os/exec" + "path/filepath" "regexp" "strings" "sync" "time" + "NanoKVM-Server/internal/apkrun" + "NanoKVM-Server/internal/oomscore" "NanoKVM-Server/service/vm" + + "golang.org/x/sys/unix" ) const Package = "nanokvm-rustdesk" @@ -61,11 +67,61 @@ type Service struct { passwordFile string upstreamFile string sourceFile string + startedFile string ensureUSB func(bool) error + + // Package facts only change with the apk database, world or indexes, so + // status polling reuses them until apkStamp changes or a package action runs. + apkStamp func() string + // apkMu keeps a repository refresh, including its apk state check, apart + // from package actions; apkrun serializes the apk runs themselves with + // everything else on the device. + apkMu sync.Mutex + factsMu sync.Mutex + generation int + installed installedFacts + repository repositoryFacts + refreshing chan struct{} + failedStamp string + retryAt time.Time + repositoryWait time.Duration +} + +type installedFacts struct { + stamp string + installed bool + version string +} +type repositoryFacts struct { + stamp string + version string // installed version the upgrade candidate was selected for + available bool + update string +} + +// Loading the repository indexes takes apk several seconds of CPU and about +// 20 MiB on the device, so these queries run in the background, one at a time, +// at idle priority and with a deadline that lets them finish under load. +const repositoryTimeout = 3 * time.Minute +const repositoryRetry = time.Minute + +type idlePriority struct{} + +// Commands run with this context yield the CPU to requests and streaming. +func withIdlePriority(ctx context.Context) context.Context { + return context.WithValue(ctx, idlePriority{}, true) +} + +// Files apk rewrites whenever installed packages, repositories or their cached +// indexes change. +var apkStatePaths = []string{ + "/etc/apk/arch", "/etc/apk/keys", "/etc/apk/repositories", "/etc/apk/repositories.d", + "/lib/apk/repositories.d", "/etc/apk/world", "/lib/apk/db/installed", "/etc/apk/cache", "/var/cache/apk", } func NewService(b *Bridge) *Service { - s := &Service{bridge: b, run: runCommand, passwordFile: RuntimeDir + "/temporary-password", upstreamFile: "/usr/share/nanokvm-rustdesk/upstream.json", sourceFile: "/usr/share/nanokvm-rustdesk/source.json", ensureUSB: vm.EnsureRemoteAccessUSB} + s := &Service{bridge: b, run: runCommand, passwordFile: RuntimeDir + "/temporary-password", upstreamFile: "/usr/share/nanokvm-rustdesk/upstream.json", sourceFile: "/usr/share/nanokvm-rustdesk/source.json", startedFile: "/run/openrc/started/" + Package, ensureUSB: vm.EnsureRemoteAccessUSB, + apkStamp: func() string { return apkStamp(apkStatePaths) }, repositoryWait: 250 * time.Millisecond} b.mu.Lock() b.prepareUSB = s.PrepareUSB b.mu.Unlock() @@ -74,8 +130,11 @@ func NewService(b *Bridge) *Service { func audioRequested(c Config) bool { return c.AudioEnabled == nil || *c.AudioEnabled } -// Called once by the enabled daemon before it accepts remote connections. -// This covers cold boot, package upgrade and terminal service restarts too. +// Called once by the enabled daemon before it accepts remote connections, +// including after cold boot, package upgrade and service restarts. It leaves +// the USB composition alone: USB functions are enabled when the user turns +// RustDesk or its sound on (Configure), and a later choice in USB settings, +// such as turning USB off, must survive restarts. func (s *Service) PrepareUSB() error { c, err := readConfig() if err != nil { @@ -84,14 +143,35 @@ func (s *Service) PrepareUSB() error { if !c.Enabled { return errors.New("RustDesk remote access is disabled") } - if _, err := os.Stat(Binary); err != nil { - return err - } - return s.ensureUSB(audioRequested(c)) + _, err = os.Stat(Binary) + return err } func runCommand(ctx context.Context, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) - data, err := cmd.CombinedOutput() + var data []byte + var err error + if name == "apk" { + // The shared runner queues apk behind runs of other services and + // processes within the context's deadline, and lowers the priority of + // repository queries itself. + data, err = apkrun.Command(ctx, name, args...).CombinedOutput() + } else { + argv := append([]string{name}, args...) + if name == "rc-service" && len(args) > 1 && (args[1] == "start" || args[1] == "restart") { + // The daemon must not inherit the server's OOM protection. + argv = oomscore.Unprotected(name, args...) + } + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + err = cmd.Start() + if err == nil { + if ctx.Value(idlePriority{}) != nil { + _ = unix.Setpriority(unix.PRIO_PROCESS, cmd.Process.Pid, 19) + } + err = cmd.Wait() + } + data = output.Bytes() + } if err != nil { return nil, fmt.Errorf("%s failed: %s", name, strings.TrimSpace(string(data[:min(len(data), 2048)]))) } @@ -103,9 +183,166 @@ func (s *Service) command(name string, args ...string) ([]byte, error) { return s.run(ctx, name, args...) } func (s *Service) statusCommand(name string, args ...string) ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + data, _, err := s.probe(context.Background(), 3*time.Second, name, args...) + return data, err +} + +// probe also reports whether the command finished before its deadline: an +// expired deadline says nothing about the package and is never cached. +func (s *Service) probe(parent context.Context, timeout time.Duration, name string, args ...string) ([]byte, bool, error) { + ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() - return s.run(ctx, name, args...) + data, err := s.run(ctx, name, args...) + return data, ctx.Err() == nil, err +} + +// apkStamp summarizes the size and modification time of apk's state files and +// of the files in its state directories. +func apkStamp(paths []string) string { + var b strings.Builder + for _, path := range paths { + info, err := os.Stat(path) + if err != nil { + fmt.Fprintf(&b, "%s:-;", path) + continue + } + fmt.Fprintf(&b, "%s:%d:%d;", path, info.Size(), info.ModTime().UnixNano()) + if !info.IsDir() { + continue + } + entries, _ := os.ReadDir(path) + for _, entry := range entries { + if info, err := entry.Info(); err == nil { + fmt.Fprintf(&b, "%s:%d:%d;", entry.Name(), info.Size(), info.ModTime().UnixNano()) + } + } + } + return b.String() +} + +// stamp identifies the apk state that cached facts were read from. Package +// actions also advance it in case file timestamps are too coarse to notice. +func (s *Service) stamp() string { + s.factsMu.Lock() + generation := s.generation + s.factsMu.Unlock() + return fmt.Sprintf("%d;%s", generation, s.apkStamp()) +} +func (s *Service) forgetPackageFacts() { + s.factsMu.Lock() + s.generation++ + s.factsMu.Unlock() +} + +func (s *Service) installedFacts(stamp string) installedFacts { + s.factsMu.Lock() + cached := s.installed + s.factsMu.Unlock() + if cached.stamp == stamp { + return cached + } + facts := installedFacts{stamp: stamp} + _, conclusive, err := s.probe(context.Background(), 3*time.Second, "apk", "info", "-e", Package) + facts.installed = err == nil + if facts.installed { + version, finished, versionErr := s.probe(context.Background(), 3*time.Second, "apk", "info", "-e", "-v", Package) + conclusive = conclusive && finished + if versionErr == nil { + value := strings.TrimSpace(string(version)) + if strings.HasPrefix(value, Package+"-") { + facts.version = strings.TrimPrefix(value, Package+"-") + } + } + } + if conclusive { + s.factsMu.Lock() + s.installed = facts + s.factsMu.Unlock() + } + return facts +} + +// repositoryFacts starts a refresh after apk changed and only the request that +// starts it waits briefly. Until it finishes, the previous answer stands in; +// its upgrade candidate only counts for the installed version it was selected +// for. A refresh still running for an older state is followed by another one +// on a later request. +func (s *Service) repositoryFacts(stamp, version string) repositoryFacts { + s.factsMu.Lock() + defer s.factsMu.Unlock() + if s.repository.stamp == stamp || s.refreshing != nil || s.failedStamp == stamp && time.Now().Before(s.retryAt) { + return s.repository + } + done := make(chan struct{}) + s.refreshing = done + go s.refreshRepository(stamp, version, done) + s.factsMu.Unlock() + timer := time.NewTimer(s.repositoryWait) + select { + case <-done: + case <-timer.C: + } + timer.Stop() + s.factsMu.Lock() + return s.repository +} +func (s *Service) refreshRepository(stamp, version string, done chan struct{}) { + defer close(done) + s.apkMu.Lock() + var facts repositoryFacts + var err error + // A package action may have changed apk while this waited; the next + // status request then asks again. + current := s.stamp() == stamp + if current { + facts, err = s.queryRepository(version) + } + s.apkMu.Unlock() + s.factsMu.Lock() + defer s.factsMu.Unlock() + s.refreshing = nil + if !current { + return + } + // A failed query reports nothing, as before, and is retried later. + if err != nil { + s.failedStamp, s.retryAt = stamp, time.Now().Add(repositoryRetry) + stamp = "" + } + facts.stamp = stamp + s.repository = facts +} +func (s *Service) queryRepository(version string) (repositoryFacts, error) { + facts := repositoryFacts{version: version} + var err, updateErr error + _, facts.available, err = s.repositoryVersion(false) + if version != "" { + facts.update, _, updateErr = s.repositoryVersion(true) + } + return facts, errors.Join(err, updateErr) +} + +// The default openrc-run status succeeds only in the started state, so a +// service without its started link is stopped and needs no rc-service run. +// rc-service still decides for a started service, which may have crashed. +func (s *Service) daemonRunning() bool { + if _, err := os.Lstat(filepath.Dir(s.startedFile)); err == nil { + if _, err = os.Lstat(s.startedFile); errors.Is(err, os.ErrNotExist) { + return false + } + } + _, err := s.statusCommand("rc-service", Package, "status") + return err == nil +} + +// packageCommand holds the index lock so that a status query never loads the +// indexes alongside an apk transaction. +func (s *Service) packageCommand(args ...string) error { + s.apkMu.Lock() + defer s.apkMu.Unlock() + defer s.forgetPackageFacts() + _, err := s.command("apk", args...) + return err } func defaultConfig() Config { return Config{Official: true, Codec: "auto", MaxClients: 1, PasswordMode: "temporary"} @@ -141,10 +378,12 @@ func (s *Service) Status() (Status, error) { } status := Status{Config: c, HasPassword: c.Password != ""} status.Config.Password = "" - _, err = s.statusCommand("apk", "info", "-e", Package) - status.Installed = err == nil + stamp := s.stamp() + installed := s.installedFacts(stamp) + status.Installed = installed.installed // Status polling reads the existing indexes; repository refresh belongs to package management. - _, status.Available = s.repositoryVersion(false) + repository := s.repositoryFacts(stamp, installed.version) + status.Available = repository.available if status.Installed { status.SourceURL = s.SourceURL() // This metadata belongs to the installed daemon, including when stopped. @@ -167,17 +406,11 @@ func (s *Service) Status() (Status, error) { status.USBAudioEnabled = status.SupportsAudio && s.bridge.audioEnabled() } } - if version, versionErr := s.statusCommand("apk", "info", "-e", "-v", Package); versionErr == nil { - value := strings.TrimSpace(string(version)) - if strings.HasPrefix(value, Package+"-") { - status.Version = strings.TrimPrefix(value, Package+"-") - } - } - if status.Version != "" { - status.UpdateVersion, _ = s.repositoryVersion(true) + status.Version = installed.version + if status.Version != "" && repository.version == status.Version { + status.UpdateVersion = repository.update } - _, err = s.statusCommand("rc-service", Package, "status") - status.Running = err == nil + status.Running = s.daemonRunning() } if data, readErr := os.ReadFile(ConfigDir + "/settings-output.json"); readErr == nil { var fields map[string]string @@ -220,7 +453,8 @@ func (s *Service) SourceURL() string { } // APK reports newer repository versions using its own upgrade selection. -func (s *Service) repositoryVersion(upgradable bool) (string, bool) { +// Only a failed query is an error; a missing or unreadable answer is not. +func (s *Service) repositoryVersion(upgradable bool) (string, bool, error) { args := []string{"query", "--no-network"} if !upgradable { args = append(args, "--from=repositories") @@ -230,23 +464,23 @@ func (s *Service) repositoryVersion(upgradable bool) (string, bool) { if upgradable { args = append(args, "--upgradable") } - data, err := s.statusCommand("apk", append(args, Package)...) + data, _, err := s.probe(withIdlePriority(context.Background()), repositoryTimeout, "apk", append(args, Package)...) if err != nil { - return "", false + return "", false, err } var packages []struct { Name string `json:"name"` Version string `json:"version"` } if json.Unmarshal(data, &packages) != nil { - return "", false + return "", false, nil } for _, p := range packages { if p.Name == Package && p.Version != "" { - return p.Version, true + return p.Version, true, nil } } - return "", false + return "", false, nil } func serverAddress(value string, port string) (string, error) { @@ -399,7 +633,10 @@ func (s *Service) Configure(candidate Config) error { if candidate.AudioEnabled != nil && !s.supportsAudioSettings() { return errors.New("update the RustDesk add-on before configuring sound transmission") } - if candidate.Enabled { + // Only turning RustDesk or its sound on changes USB; saving other settings + // keeps what the user chose in USB settings. + turningOn := candidate.Enabled && (!current.Enabled || audioRequested(candidate) && !audioRequested(current)) + if turningOn { if err = s.ensureUSB(audioRequested(candidate)); err != nil { return fmt.Errorf("prepare USB for RustDesk: %w", err) } @@ -453,13 +690,11 @@ func (s *Service) Action(action string) error { _, err = s.command("rc-service", Package, "restart") return err case "install": - _, err := s.command("apk", "add", Package) - return err + return s.packageCommand("add", Package) case "upgrade": - _, err := s.command("apk", "upgrade", Package) - return err + return s.packageCommand("upgrade", Package) case "remove": - if _, err := s.command("apk", "del", Package); err != nil { + if err := s.packageCommand("del", Package); err != nil { return err } s.bridge.Stop() diff --git a/server/service/rustdesk/service_test.go b/server/service/rustdesk/service_test.go index 0b6ac4ed..53ae20df 100644 --- a/server/service/rustdesk/service_test.go +++ b/server/service/rustdesk/service_test.go @@ -4,10 +4,14 @@ import ( "context" "encoding/json" "errors" + "fmt" + "net" "os" "path/filepath" "strings" + "sync" "testing" + "time" ) func temporaryConfig(t *testing.T) { @@ -153,6 +157,38 @@ func TestRemoteAccessPreparationFailurePreservesSettingsAndRunningService(t *tes } } +func TestRestartsAndUnrelatedSavesKeepUserUSBComposition(t *testing.T) { + temporaryConfig(t) + current := defaultConfig() + current.Enabled = true + current.Password = "stored-pass" + if err := writeConfig(current); err != nil { + t.Fatal(err) + } + media, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer media.Close() + b := NewBridge() + b.media = media // already started: Configure must not open runtime sockets + s := NewService(b) + s.run = func(context.Context, string, ...string) ([]byte, error) { return nil, nil } + calls := 0 + s.ensureUSB = func(bool) error { calls++; return nil } + + // The daemon asks on every start, e.g. after boot or a package upgrade. + _ = s.PrepareUSB() + candidate := current + candidate.MaxClients = 2 + if err := s.Configure(candidate); err != nil { + t.Fatal(err) + } + if calls != 0 { + t.Fatalf("USB composition changed %d times without turning RustDesk on", calls) + } +} + func TestPackageActionsUseFixedArgv(t *testing.T) { s := NewService(NewBridge()) s.run = func(_ context.Context, name string, args ...string) ([]byte, error) { @@ -449,6 +485,7 @@ func TestAudioCapabilityFollowsInstalledMetadataAndOptionalUSBState(t *testing.T s := NewService(b) s.upstreamFile = filepath.Join(t.TempDir(), "upstream.json") installed := true + s.apkStamp = func() string { return fmt.Sprint(installed) } s.run = func(_ context.Context, name string, args ...string) ([]byte, error) { if name == "apk" && strings.Join(args, " ") == "info -e "+Package && installed { return nil, nil @@ -475,3 +512,273 @@ func TestAudioCapabilityFollowsInstalledMetadataAndOptionalUSBState(t *testing.T } } } + +// fakeAPK answers status probes for an installed package and records them. +type fakeAPK struct { + mu sync.Mutex + stamp int + version string + update string + fail bool + gate chan struct{} // blocks repository queries while set + commands []string +} + +func (f *fakeAPK) install(s *Service) { + s.apkStamp = func() string { f.mu.Lock(); defer f.mu.Unlock(); return fmt.Sprint(f.stamp) } + s.run = func(ctx context.Context, name string, args ...string) ([]byte, error) { + command := name + " " + strings.Join(args, " ") + f.mu.Lock() + f.commands = append(f.commands, command) + version, update, fail, gate := f.version, f.update, f.fail, f.gate + f.mu.Unlock() + switch { + case command == "apk info -e -v "+Package: + return []byte(Package + "-" + version + "\n"), nil + case strings.HasPrefix(command, "apk query"): + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) < 30*time.Second { + return nil, errors.New("repository query cannot finish on the device") + } + if gate != nil { + <-gate + } + if fail { + return nil, errors.New("query failed") + } + if strings.Contains(command, "--upgradable") { + if update == "" { + return []byte("[]"), nil + } + return []byte(`[{"name":"nanokvm-rustdesk","version":"` + update + `"}]`), nil + } + return []byte(`[{"name":"nanokvm-rustdesk","version":"0.2.1-r0"}]`), nil + } + return nil, nil + } +} +func waitForRefresh(s *Service) { + for { + s.factsMu.Lock() + done := s.refreshing + s.factsMu.Unlock() + if done == nil { + return + } + <-done + } +} +func (f *fakeAPK) apkCommands() int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, command := range f.commands { + if strings.HasPrefix(command, "apk ") { + n++ + } + } + f.commands = nil + return n +} + +func TestStatusReusesPackageFactsUntilAPKChanges(t *testing.T) { + temporaryConfig(t) + s := NewService(NewBridge()) + f := &fakeAPK{version: "0.2.0-r0", update: "0.2.1-r0"} + f.install(s) + check := func(version, update string, apk int) { + t.Helper() + status, err := s.Status() + if err != nil || !status.Installed || !status.Available || !status.Running || status.Version != version || status.UpdateVersion != update { + t.Fatalf("status: %+v %v", status, err) + } + if got := f.apkCommands(); got != apk { + t.Fatalf("%d apk commands, want %d", got, apk) + } + } + check("0.2.0-r0", "0.2.1-r0", 4) + check("0.2.0-r0", "0.2.1-r0", 0) + f.mu.Lock() + f.stamp++ // for example apk update in Software settings + f.mu.Unlock() + check("0.2.0-r0", "0.2.1-r0", 4) + // Package actions are noticed even when apk's files look unchanged. + if err := s.Action("upgrade"); err != nil { + t.Fatal(err) + } + f.mu.Lock() + f.version, f.update = "0.2.1-r0", "" + f.mu.Unlock() + f.apkCommands() + check("0.2.1-r0", "", 4) + check("0.2.1-r0", "", 0) +} + +func TestSlowRepositoryQueryDoesNotDelayStatus(t *testing.T) { + temporaryConfig(t) + s := NewService(NewBridge()) + s.repositoryWait = 10 * time.Millisecond + f := &fakeAPK{version: "0.2.0-r0", update: "0.2.1-r0", gate: make(chan struct{})} + f.install(s) + started := time.Now() + status, err := s.Status() + if err != nil || !status.Installed || status.Version != "0.2.0-r0" || status.Available || status.UpdateVersion != "" || time.Since(started) > time.Second { + t.Fatalf("status while the indexes load: %+v %v", status, err) + } + // Later polls do not wait for the refresh another request started. + s.repositoryWait = time.Minute + started = time.Now() + if status, err = s.Status(); err != nil || status.Available || time.Since(started) > time.Second { + t.Fatalf("second status while the indexes load: %+v %v", status, err) + } + close(f.gate) + waitForRefresh(s) + if status, err = s.Status(); err != nil || !status.Available || status.UpdateVersion != "0.2.1-r0" { + t.Fatalf("status after the indexes loaded: %+v %v", status, err) + } + // After an upgrade the previous candidate no longer applies; until the + // indexes are read again only the availability is reused. + f.mu.Lock() + f.version, f.update, f.gate = "0.2.1-r0", "", make(chan struct{}) + f.stamp++ + f.mu.Unlock() + s.repositoryWait = 10 * time.Millisecond + if status, err = s.Status(); err != nil || status.Version != "0.2.1-r0" || !status.Available || status.UpdateVersion != "" { + t.Fatalf("status after upgrade: %+v %v", status, err) + } + close(f.gate) +} + +func TestFailedRepositoryQueryIsRetriedLater(t *testing.T) { + temporaryConfig(t) + s := NewService(NewBridge()) + f := &fakeAPK{version: "0.2.0-r0", update: "0.2.1-r0", fail: true} + f.install(s) + for range 2 { + if status, err := s.Status(); err != nil || status.Available || status.UpdateVersion != "" { + t.Fatalf("failed query: %+v %v", status, err) + } + } + if got := f.apkCommands(); got != 4 { + t.Fatalf("failed query repeated immediately: %d apk commands", got) + } + f.mu.Lock() + f.fail = false + f.mu.Unlock() + s.factsMu.Lock() + s.retryAt = time.Now().Add(-time.Second) + s.factsMu.Unlock() + if status, err := s.Status(); err != nil || !status.Available || status.UpdateVersion != "0.2.1-r0" { + t.Fatalf("retried query: %+v %v", status, err) + } +} + +func TestRepositoryQueriesNeverOverlapPackageTransactions(t *testing.T) { + temporaryConfig(t) + s := NewService(NewBridge()) + s.repositoryWait = 0 + s.apkStamp = func() string { return "unchanged" } + var active, overlaps, queries int + var mu sync.Mutex + s.run = func(_ context.Context, name string, args ...string) ([]byte, error) { + if name != "apk" || args[0] == "info" { + return nil, nil + } + mu.Lock() + active++ + overlaps += active - 1 + if args[0] == "query" { + queries++ + } + mu.Unlock() + time.Sleep(20 * time.Millisecond) + mu.Lock() + active-- + mu.Unlock() + return []byte("[]"), nil + } + if _, err := s.Status(); err != nil { + t.Fatal(err) + } + if err := s.Action("upgrade"); err != nil { + t.Fatal(err) + } + waitForRefresh(s) + s.repositoryWait = time.Minute + if _, err := s.Status(); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if overlaps != 0 || queries == 0 { + t.Fatalf("%d overlapping apk runs, %d queries", overlaps, queries) + } +} + +func TestStoppedServiceStatusNeedsNoRCService(t *testing.T) { + temporaryConfig(t) + s := NewService(NewBridge()) + f := &fakeAPK{version: "0.2.0-r0"} + f.install(s) + s.startedFile = filepath.Join(t.TempDir(), Package) + rcService := func() int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, command := range f.commands { + if strings.HasPrefix(command, "rc-service ") { + n++ + } + } + f.commands = nil + return n + } + if status, err := s.Status(); err != nil || status.Running || rcService() != 0 { + t.Fatalf("stopped service: %+v %v", status, err) + } + // A started service may have crashed; openrc decides. + if err := os.Symlink("/etc/init.d/"+Package, s.startedFile); err != nil { + t.Fatal(err) + } + if status, err := s.Status(); err != nil || !status.Running || rcService() != 1 { + t.Fatalf("started service: %+v %v", status, err) + } +} + +func TestRepositoryQueriesRunAtIdlePriority(t *testing.T) { + // The shell reports its own nice value after runCommand lowered it. + data, err := runCommand(withIdlePriority(context.Background()), "sh", "-c", `sleep 0.2; cut -d" " -f19 /proc/$$/stat`) + if err != nil || strings.TrimSpace(string(data)) != "19" { + t.Fatalf("nice %q %v", data, err) + } +} + +func TestAPKStampFollowsStateFiles(t *testing.T) { + dir := t.TempDir() + installed, cache := filepath.Join(dir, "installed"), filepath.Join(dir, "cache") + if err := os.WriteFile(installed, []byte("a"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(cache, 0755); err != nil { + t.Fatal(err) + } + paths := []string{installed, cache, filepath.Join(dir, "missing")} + stamp := apkStamp(paths) + if apkStamp(paths) != stamp { + t.Fatal("unchanged state changed the stamp") + } + index := filepath.Join(cache, "APKINDEX.0.tar.gz") + for _, change := range []func() error{ + func() error { return os.WriteFile(index, []byte("index"), 0644) }, + func() error { return os.Chtimes(index, time.Now(), time.Unix(1, 0)) }, + func() error { return os.WriteFile(installed, []byte("ab"), 0644) }, + } { + if err := change(); err != nil { + t.Fatal(err) + } + next := apkStamp(paths) + if next == stamp { + t.Fatal("apk state change kept the stamp") + } + stamp = next + } +} diff --git a/server/service/stream/audio/webrtc.go b/server/service/stream/audio/webrtc.go index a2af1ffc..ed5b0094 100644 --- a/server/service/stream/audio/webrtc.go +++ b/server/service/stream/audio/webrtc.go @@ -22,7 +22,13 @@ type signal struct { var sessions atomic.Int32 func Status(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"audio": enabled(), "bitrate": 192000, "complexity": 3}}) + c.JSON(http.StatusOK, gin.H{"code": 0, "data": StatusData()}) +} + +// StatusData is the GET /api/stream/audio/status payload, also part of the +// combined live status. +func StatusData() gin.H { + return gin.H{"audio": enabled(), "bitrate": 192000, "complexity": 3} } func iceServers() []webrtc.ICEServer { servers := []webrtc.ICEServer{} diff --git a/server/service/stream/mjpeg/frame-detect.go b/server/service/stream/mjpeg/frame-detect.go index ac2d8df0..1af2eb93 100644 --- a/server/service/stream/mjpeg/frame-detect.go +++ b/server/service/stream/mjpeg/frame-detect.go @@ -9,8 +9,7 @@ import ( log "github.com/sirupsen/logrus" ) -const FrameDetectInterval uint8 = 60 - +// UpdateFrameDetect saves the device-wide frame-detect choice and applies it. func UpdateFrameDetect(c *gin.Context) { var req proto.UpdateFrameDetectReq var rsp proto.Response @@ -20,12 +19,13 @@ func UpdateFrameDetect(c *gin.Context) { return } - var frame uint8 = 0 - if req.Enabled { - frame = FrameDetectInterval + if err := common.SaveFrameDetect(req.Enabled); err != nil { + log.Errorf("save frame detect failed: %s", err) + rsp.ErrRsp(c, -2, "save frame detect failed") + return } - common.GetKvmVision().SetFrameDetect(frame) + common.GetKvmVision().SetFrameDetect(common.FrameDetectFrames(req.Enabled)) rsp.OkRsp(c) log.Debugf("update frame detect: %t", req.Enabled) @@ -40,6 +40,12 @@ func StopFrameDetect(c *gin.Context) { return } + // Nothing to pause, and restoring would enable it against the saved choice. + if !common.FrameDetectEnabled() { + rsp.OkRsp(c) + return + } + duration := 10 * time.Second if req.Duration > 0 { duration = time.Duration(req.Duration) * time.Second @@ -49,7 +55,8 @@ func StopFrameDetect(c *gin.Context) { vision.SetFrameDetect(0) time.Sleep(duration) - vision.SetFrameDetect(FrameDetectInterval) + // Restore the saved choice, which an administrator may have changed meanwhile. + vision.SetFrameDetect(common.FrameDetectFrames(common.FrameDetectEnabled())) rsp.OkRsp(c) } diff --git a/server/service/vm/cpufreq.go b/server/service/vm/cpufreq.go index b73f964d..08a3221b 100644 --- a/server/service/vm/cpufreq.go +++ b/server/service/vm/cpufreq.go @@ -22,12 +22,29 @@ var cpuFreqRuntimePreference = "/run/nanokvm-cpufreq" var cpuFreqWrite = os.WriteFile var cpuThermalRoot = "/sys/class/thermal" +// With the boot flag the saved preference may hold an overclock, applied +// when the application starts after boot. The pending marker lives until +// the overclocked start has run for cpuFreqBootSettle; finding it at the +// next boot means that start froze, so that boot falls back to 1000 MHz, +// clears the flag and leaves the failed marker for the settings page. +var cpuFreqBootFlag = "/etc/kvm/cpufreq.boot" +var cpuFreqBootPending = "/etc/kvm/cpufreq.boot-pending" +var cpuFreqBootFailed = "/etc/kvm/cpufreq.boot-failed" +var cpuFreqBootSettle = 2 * time.Minute + type cpuFrequencyStatus struct { - Supported bool `json:"supported"` - Throttled bool `json:"throttled"` - Running int `json:"running"` - Target int `json:"target"` - Options []int `json:"options"` + Supported bool `json:"supported"` + Throttled bool `json:"throttled"` + Running int `json:"running"` + Target int `json:"target"` + Options []int `json:"options"` + ApplyAtBoot bool `json:"applyAtBoot"` + BootFallback bool `json:"bootFallback"` +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil } func readCPUFreq(name string) (string, error) { @@ -61,9 +78,9 @@ func cpuThermalLimited() bool { } func cpuFrequencyState() cpuFrequencyStatus { - state := cpuFrequencyStatus{Target: 1000, Options: []int{}} + state := cpuFrequencyStatus{Target: 1000, Options: []int{}, ApplyAtBoot: fileExists(cpuFreqBootFlag), BootFallback: fileExists(cpuFreqBootFailed)} if data, err := os.ReadFile(cpuFreqPreference); err == nil { - if n, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil && (n == 850 || n == 1000) { + if n, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil && (n == 850 || n == 1000 || state.ApplyAtBoot && validCPUFrequency(n)) { state.Target = n } } @@ -162,9 +179,21 @@ func setCPUFreqRuntime(mhz int) error { return nil } -func persistCPUFreq(mhz int) error { - // Overclock survives application restarts, but /run is cleared at boot. - if err := persistCPUFreqFile(cpuFreqPreference, min(mhz, 1000)); err != nil { +func persistCPUFreq(mhz int, applyAtBoot bool) error { + // Overclock survives application restarts, but /run is cleared at boot; + // only the boot flag lets the saved preference keep it. + boot := min(mhz, 1000) + if applyAtBoot { + boot = mhz + if err := persistCPUFreqFile(cpuFreqBootFlag, 1); err != nil { + return err + } + } else if err := os.Remove(cpuFreqBootFlag); err != nil && !os.IsNotExist(err) { + return err + } + // A deliberate choice replaces the report of an earlier failed start. + os.Remove(cpuFreqBootFailed) + if err := persistCPUFreqFile(cpuFreqPreference, boot); err != nil { return err } return persistCPUFreqFile(cpuFreqRuntimePreference, mhz) @@ -199,7 +228,8 @@ func persistCPUFreqFile(path string, mhz int) error { return directory.Sync() } -func applyCPUFreq(mhz int, persist bool) error { +// applyAtBoot is used only with persist. +func applyCPUFreq(mhz int, persist, applyAtBoot bool) error { cpuFreqMu.Lock() defer cpuFreqMu.Unlock() before := cpuFrequencyState() @@ -208,7 +238,7 @@ func applyCPUFreq(mhz int, persist bool) error { } err := setCPUFreqRuntime(mhz) if err == nil && persist { - err = persistCPUFreq(mhz) + err = persistCPUFreq(mhz, applyAtBoot) } restoreTarget := before.Running if before.Throttled { @@ -224,11 +254,45 @@ func applyCPUFreq(mhz int, persist bool) error { // ApplySavedCPUFrequency runs only after the matching kernel has exposed a // qualified driver. A missing driver leaves stock hardware configuration alone. +// The first start after boot has no runtime preference yet. func ApplySavedCPUFrequency() { state := cpuFrequencyState() - if state.Supported { - if err := applyCPUFreq(state.Target, false); err != nil { - log.Errorf("apply saved CPU frequency: %v", err) + if !state.Supported { + return + } + firstStart := !fileExists(cpuFreqRuntimePreference) + if firstStart && state.Target > 1000 { + if fileExists(cpuFreqBootPending) { + log.Errorf("previous start at %d MHz did not settle; starting at 1000 MHz and turning off overclock at boot", state.Target) + err := errors.Join( + os.Remove(cpuFreqBootFlag), + persistCPUFreqFile(cpuFreqPreference, 1000), + persistCPUFreqFile(cpuFreqBootFailed, state.Target), + os.Remove(cpuFreqBootPending), + ) + if err != nil { + log.Errorf("record CPU frequency boot fallback: %v", err) + } + state.Target = 1000 + } else if err := persistCPUFreqFile(cpuFreqBootPending, state.Target); err != nil { + // Without the marker a freeze could repeat on every boot. + log.Errorf("mark overclocked start, staying at 1000 MHz: %v", err) + state.Target = 1000 + } else { + settle, pending := cpuFreqBootSettle, cpuFreqBootPending + go func() { + time.Sleep(settle) + os.Remove(pending) + }() + } + } + if err := applyCPUFreq(state.Target, false, false); err != nil { + log.Errorf("apply saved CPU frequency: %v", err) + return + } + if firstStart { + if err := persistCPUFreqFile(cpuFreqRuntimePreference, state.Target); err != nil { + log.Errorf("record applied CPU frequency: %v", err) } } } @@ -244,13 +308,19 @@ func (s *Service) GetCPUFrequency(c *gin.Context) { func (s *Service) SetCPUFrequency(c *gin.Context) { var req struct { Target int `json:"target" validate:"oneof=850 1000 1050 1075 1100 1125 1150"` + // Omitted by older clients: keep the saved choice. + ApplyAtBoot *bool `json:"applyAtBoot"` } var rsp proto.Response if err := proto.ParseFormRequest(c, &req); err != nil { rsp.ErrRsp(c, -1, "invalid CPU frequency") return } - if err := applyCPUFreq(req.Target, true); err != nil { + applyAtBoot := fileExists(cpuFreqBootFlag) + if req.ApplyAtBoot != nil { + applyAtBoot = *req.ApplyAtBoot + } + if err := applyCPUFreq(req.Target, true, applyAtBoot); err != nil { rsp.ErrRsp(c, -2, err.Error()) return } diff --git a/server/service/vm/cpufreq_test.go b/server/service/vm/cpufreq_test.go index 24981e7e..3e6b8e6f 100644 --- a/server/service/vm/cpufreq_test.go +++ b/server/service/vm/cpufreq_test.go @@ -13,12 +13,19 @@ func cpuFixture(t *testing.T) { oldRuntime := cpuFreqRuntimePreference cpuFreqRuntimePreference = filepath.Join(t.TempDir(), "runtime-cpufreq") oldPolicy, oldPreference, oldWrite, oldThermal := cpuFreqPolicy, cpuFreqPreference, cpuFreqWrite, cpuThermalRoot + oldFlag, oldPending, oldFailed, oldSettle := cpuFreqBootFlag, cpuFreqBootPending, cpuFreqBootFailed, cpuFreqBootSettle cpuThermalRoot = t.TempDir() cpuFreqPolicy = t.TempDir() - cpuFreqPreference = filepath.Join(t.TempDir(), "cpufreq") + etc := t.TempDir() + cpuFreqPreference = filepath.Join(etc, "cpufreq") + cpuFreqBootFlag = filepath.Join(etc, "cpufreq.boot") + cpuFreqBootPending = filepath.Join(etc, "cpufreq.boot-pending") + cpuFreqBootFailed = filepath.Join(etc, "cpufreq.boot-failed") + cpuFreqBootSettle = time.Hour t.Cleanup(func() { cpuFreqRuntimePreference = oldRuntime cpuFreqPolicy, cpuFreqPreference, cpuFreqWrite, cpuThermalRoot = oldPolicy, oldPreference, oldWrite, oldThermal + cpuFreqBootFlag, cpuFreqBootPending, cpuFreqBootFailed, cpuFreqBootSettle = oldFlag, oldPending, oldFailed, oldSettle }) for name, value := range map[string]string{"scaling_driver": "sg2002-cpufreq", "cpuinfo_cur_freq": "850000"} { if err := os.WriteFile(filepath.Join(cpuFreqPolicy, name), []byte(value), 0600); err != nil { @@ -34,7 +41,7 @@ func cpuFixture(t *testing.T) { } func TestCPUFrequencyRequiresDriverAndActualReadback(t *testing.T) { cpuFixture(t) - if err := applyCPUFreq(1000, true); err != nil { + if err := applyCPUFreq(1000, true, false); err != nil { t.Fatal(err) } state := cpuFrequencyState() @@ -43,24 +50,24 @@ func TestCPUFrequencyRequiresDriverAndActualReadback(t *testing.T) { } // A successful sysfs write is insufficient when readback did not change. cpuFreqWrite = os.WriteFile - if err := applyCPUFreq(850, true); err == nil { + if err := applyCPUFreq(850, true, false); err == nil { t.Fatal("accepted false readback") } if state = cpuFrequencyState(); state.Target != 1000 { t.Fatal("saved unverified value") } os.Remove(filepath.Join(cpuFreqPolicy, "scaling_driver")) - if err := applyCPUFreq(850, true); err == nil { + if err := applyCPUFreq(850, true, false); err == nil { t.Fatal("accepted missing driver") } } func TestCPUFrequencyErrorsDoNotPersistTarget(t *testing.T) { cpuFixture(t) - if err := applyCPUFreq(1200, true); err == nil { + if err := applyCPUFreq(1200, true, false); err == nil { t.Fatal("accepted unsupported clock") } cpuFreqWrite = func(string, []byte, os.FileMode) error { return errors.New("sysfs denied") } - if err := applyCPUFreq(1000, true); err == nil { + if err := applyCPUFreq(1000, true, false); err == nil { t.Fatal("ignored sysfs error") } if _, err := os.Stat(cpuFreqPreference); !os.IsNotExist(err) { @@ -90,7 +97,7 @@ func TestCPUFrequencyWaitsForEffectiveLimit(t *testing.T) { return baseWrite(path, data, mode) } // Runtime 850 raises the allowed ceiling temporarily then lowers it again. - if err := applyCPUFreq(850, false); err != nil { + if err := applyCPUFreq(850, false, false); err != nil { t.Fatal(err) } } @@ -124,7 +131,7 @@ func TestCPUFrequencyPreservesTargetWhileThermallyClamped(t *testing.T) { return baseWrite(path, data, mode) } for _, target := range []int{1000, 850, 1000} { - if err := applyCPUFreq(target, true); err != nil { + if err := applyCPUFreq(target, true, false); err != nil { t.Fatal(err) } state := cpuFrequencyState() @@ -138,7 +145,7 @@ func TestCPUFrequencyPreservesTargetWhileThermallyClamped(t *testing.T) { t.Fatal(err) } cpuFreqWrite = baseWrite - if err := applyCPUFreq(1000, false); err != nil { + if err := applyCPUFreq(1000, false, false); err != nil { t.Fatal(err) } state := cpuFrequencyState() @@ -152,12 +159,12 @@ func TestCPUFrequencyOverclockIsBootScopedAndCapabilityChecked(t *testing.T) { if cpuFrequencyState().Target != 1000 { t.Fatal("default must be 1000 MHz") } - if err := applyCPUFreq(1100, true); err == nil { + if err := applyCPUFreq(1100, true, false); err == nil { t.Fatal("old kernel accepted overclock") } os.WriteFile(filepath.Join(cpuFreqPolicy, "scaling_available_frequencies"), []byte("600000 850000 1000000 1050000 1075000 1100000 1125000 1150000"), 0600) for _, target := range []int{1050, 1075, 1100, 1125, 1150} { - if err := applyCPUFreq(target, true); err != nil { + if err := applyCPUFreq(target, true, false); err != nil { t.Fatal(err) } if s := cpuFrequencyState(); s.Target != target || s.Running != target { @@ -175,6 +182,69 @@ func TestCPUFrequencyOverclockIsBootScopedAndCapabilityChecked(t *testing.T) { } } +func TestCPUFrequencyBootOverclockFallsBackAfterFrozenStart(t *testing.T) { + cpuFixture(t) + os.WriteFile(filepath.Join(cpuFreqPolicy, "scaling_available_frequencies"), []byte("600000 850000 1000000 1050000 1075000 1100000 1125000 1150000"), 0600) + if err := applyCPUFreq(1100, true, true); err != nil { + t.Fatal(err) + } + if s := cpuFrequencyState(); !s.ApplyAtBoot || s.Target != 1100 { + t.Fatalf("state=%+v", s) + } + reboot := func() { + t.Helper() + os.Remove(cpuFreqRuntimePreference) + os.WriteFile(filepath.Join(cpuFreqPolicy, "cpuinfo_cur_freq"), []byte("1000000"), 0600) + ApplySavedCPUFrequency() + } + + reboot() + if s := cpuFrequencyState(); s.Running != 1100 || !fileExists(cpuFreqBootPending) { + t.Fatalf("overclocked start: state=%+v pending=%v", s, fileExists(cpuFreqBootPending)) + } + // An application restart in the same boot is not a new start. + ApplySavedCPUFrequency() + if s := cpuFrequencyState(); s.Running != 1100 || !s.ApplyAtBoot { + t.Fatalf("restart: state=%+v", s) + } + + // The device froze before the start settled: the marker is still there. + reboot() + s := cpuFrequencyState() + if s.Running != 1000 || s.Target != 1000 || s.ApplyAtBoot || !s.BootFallback || fileExists(cpuFreqBootPending) { + t.Fatalf("fallback: state=%+v pending=%v", s, fileExists(cpuFreqBootPending)) + } + reboot() + if s := cpuFrequencyState(); s.Running != 1000 { + t.Fatalf("fallback must persist: state=%+v", s) + } + + if err := applyCPUFreq(1000, true, false); err != nil { + t.Fatal(err) + } + if cpuFrequencyState().BootFallback { + t.Fatal("saving a choice must clear the failed-start report") + } + + // A start that settles removes its marker. + cpuFreqBootSettle = time.Millisecond + if err := applyCPUFreq(1050, true, true); err != nil { + t.Fatal(err) + } + reboot() + deadline := time.Now().Add(2 * time.Second) + for fileExists(cpuFreqBootPending) { + if time.Now().After(deadline) { + t.Fatal("settled start kept its marker") + } + time.Sleep(time.Millisecond) + } + reboot() + if s := cpuFrequencyState(); s.Running != 1050 || !s.ApplyAtBoot { + t.Fatalf("settled overclock: state=%+v", s) + } +} + func TestCPUFrequencyExpandedCoolingTable(t *testing.T) { cpuFixture(t) zone := filepath.Join(cpuThermalRoot, "thermal_zone0") @@ -197,7 +267,7 @@ func TestCPUFrequencyExpandedCoolingTable(t *testing.T) { } return os.WriteFile(path, data, mode) } - if err := applyCPUFreq(1125, true); err != nil { + if err := applyCPUFreq(1125, true, false); err != nil { t.Fatal(err) } if s := cpuFrequencyState(); !s.Throttled || s.Target != 1125 || s.Running != 850 { diff --git a/server/service/vm/date_time.go b/server/service/vm/date_time.go index 97dbf379..3e910e33 100644 --- a/server/service/vm/date_time.go +++ b/server/service/vm/date_time.go @@ -6,6 +6,7 @@ import ( "os/exec" "time" + "NanoKVM-Server/internal/oomscore" "NanoKVM-Server/proto" "NanoKVM-Server/timeconfig" "github.com/gin-gonic/gin" @@ -18,7 +19,8 @@ var deviceTime = timeconfig.Store{Dir: "/etc", Restart: func() error { if deviceUsesChrony() { script = "/etc/init.d/S49chronyd" } - return exec.CommandContext(ctx, script, "restart").Run() + argv := oomscore.Unprotected(script, "restart") + return exec.CommandContext(ctx, argv[0], argv[1:]...).Run() }} func deviceUsesChrony() bool { _, err := os.Stat("/etc/chrony.conf"); return err == nil } diff --git a/server/service/vm/hdmi.go b/server/service/vm/hdmi.go index f6b605a9..c6893ad9 100644 --- a/server/service/vm/hdmi.go +++ b/server/service/vm/hdmi.go @@ -71,19 +71,21 @@ func (s *Service) DisableHdmi(c *gin.Context) { func (s *Service) GetHdmiState(c *gin.Context) { var rsp proto.Response + rsp.OkRspWithData(c, hdmiState()) + log.Debug("get hdmi state") +} + +func hdmiState() *proto.GetGetHdmiStateRsp { enabled := !isHdmiDisabled() hdmiMutex.Lock() viewerCount := hdmiDemand.ViewerCount() hdmiMutex.Unlock() - - rsp.OkRspWithData(c, &proto.GetGetHdmiStateRsp{ + return &proto.GetGetHdmiStateRsp{ Enabled: enabled, ViewerCount: viewerCount, Signal: enabled && getHdmiSignal(), IdleTimeout: getHdmiIdleTimeout(), - }) - - log.Debug("get hdmi state") + } } func EnableHdmiCapture() { diff --git a/server/service/vm/live-status.go b/server/service/vm/live-status.go new file mode 100644 index 00000000..9f7f7d2f --- /dev/null +++ b/server/service/vm/live-status.go @@ -0,0 +1,34 @@ +package vm + +import ( + "github.com/gin-gonic/gin" + + "NanoKVM-Server/proto" + "NanoKVM-Server/service/hid" + "NanoKVM-Server/service/stream/audio" +) + +// liveStatus combines the states the remote desktop page watches, so one +// request replaces five separate polls. Each field has the payload of its own +// endpoint; a part that cannot be read is null and the others still arrive. +type liveStatus struct { + HDMI *proto.GetGetHdmiStateRsp `json:"hdmi"` + GPIO *proto.GetGpioRsp `json:"gpio"` + Input gin.H `json:"input"` + Audio gin.H `json:"audio"` + USB *proto.GetVirtualDeviceRsp `json:"usb"` // administrators only +} + +// GetLiveStatus serves GET /api/vm/live-status. The USB composition is +// included only for administrators, like GET /api/vm/device/virtual. +func (s *Service) GetLiveStatus(c *gin.Context, admin bool) { + status := liveStatus{HDMI: hdmiState(), Input: hid.InputStatus(), Audio: audio.StatusData()} + if leds, err := monitoredGPIOInputs.Current(c.Request.Context()); err == nil { + status.GPIO = &proto.GetGpioRsp{PWR: leds.Power, HDD: leds.HDD} + } + if admin { + status.USB = virtualDeviceState() + } + var rsp proto.Response + rsp.OkRspWithData(c, status) +} diff --git a/server/service/vm/live-status_test.go b/server/service/vm/live-status_test.go new file mode 100644 index 00000000..a5cd588a --- /dev/null +++ b/server/service/vm/live-status_test.go @@ -0,0 +1,35 @@ +package vm + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestLiveStatusShowsUSBOnlyToAdministrators(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, admin := range []bool{false, true} { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/vm/live-status", nil) + (&Service{}).GetLiveStatus(c, admin) + + var body struct { + Code int `json:"code"` + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body.Code != 0 { + t.Fatalf("admin=%v: %v %s", admin, err, w.Body.String()) + } + for _, part := range []string{"hdmi", "input", "audio"} { + if string(body.Data[part]) == "null" || body.Data[part] == nil { + t.Errorf("admin=%v: %s missing", admin, part) + } + } + if hasUSB := string(body.Data["usb"]) != "null"; hasUSB != admin { + t.Errorf("admin=%v: usb=%s", admin, body.Data["usb"]) + } + } +} diff --git a/server/service/vm/mdns.go b/server/service/vm/mdns.go index deef1e60..7013c6c9 100644 --- a/server/service/vm/mdns.go +++ b/server/service/vm/mdns.go @@ -1,6 +1,7 @@ package vm import ( + "NanoKVM-Server/internal/oomscore" "NanoKVM-Server/proto" "fmt" "os" @@ -45,7 +46,7 @@ func (s *Service) EnableMdns(c *gin.Context) { fmt.Sprintf("%s start", AvahiDaemonScript), } - command := strings.Join(commands, " && ") + command := oomscore.ResetPrefix + strings.Join(commands, " && ") err := exec.Command("sh", "-c", command).Run() if err != nil { log.Errorf("failed to start avahi-daemon: %s", err) @@ -115,7 +116,8 @@ func setAlpineMdns(c *gin.Context, enabled bool) { err = os.WriteFile(marker, []byte("1\n"), 0600) } if err == nil { - err = exec.Command("rc-service", "avahi-daemon", action).Run() + argv := oomscore.Unprotected("rc-service", "avahi-daemon", action) + err = exec.Command(argv[0], argv[1:]...).Run() } if err != nil { rsp.ErrRsp(c, -1, "failed to change mDNS state") diff --git a/server/service/vm/screen.go b/server/service/vm/screen.go index b512b529..bd6a1a1e 100644 --- a/server/service/vm/screen.go +++ b/server/service/vm/screen.go @@ -63,6 +63,7 @@ func (s *Service) GetScreen(c *gin.Context) { "videoOutputWidth": common.ReadVideoValue("/run/nanokvm/video_width"), "videoOutputHeight": common.ReadVideoValue("/run/nanokvm/video_height"), "measuredFps": common.ReadVideoValue("/run/nanokvm/now_fps"), + "frameDetect": common.FrameDetectEnabled(), "effectiveFps": common.GetCaptureScreen().FPS}) } diff --git a/server/service/vm/ssh.go b/server/service/vm/ssh.go index 30b398b1..0b65983c 100644 --- a/server/service/vm/ssh.go +++ b/server/service/vm/ssh.go @@ -2,6 +2,7 @@ package vm import ( "NanoKVM-Server/authn" + "NanoKVM-Server/internal/oomscore" "NanoKVM-Server/proto" "NanoKVM-Server/utils" "errors" @@ -52,7 +53,8 @@ func (s *Service) EnableSSH(c *gin.Context) { return } - command := fmt.Sprintf("%s permanent_on", SSHScript) + // sshd must not inherit the server's OOM protection. + command := oomscore.ResetPrefix + fmt.Sprintf("%s permanent_on", SSHScript) err = exec.Command("sh", "-c", command).Run() if err != nil { log.Errorf("failed to run SSH script: %s", err) diff --git a/server/service/vm/terminal_session.go b/server/service/vm/terminal_session.go index 76c1a56a..b6a7ceb5 100644 --- a/server/service/vm/terminal_session.go +++ b/server/service/vm/terminal_session.go @@ -7,6 +7,8 @@ import ( "syscall" "time" + "NanoKVM-Server/internal/oomscore" + "github.com/creack/pty" "github.com/gorilla/websocket" ) @@ -32,6 +34,9 @@ func runTerminalSession(ws *websocket.Conn, cmd *exec.Cmd) error { if err != nil { return err } + // Commands typed here, apk included, must not share the server's OOM + // protection. + _ = oomscore.Set(cmd.Process.Pid, oomscore.Normal) defer func() { _ = ptmx.Close() _ = cmd.Process.Kill() diff --git a/server/service/vm/virtual-device.go b/server/service/vm/virtual-device.go index 3cc25407..3af16eac 100644 --- a/server/service/vm/virtual-device.go +++ b/server/service/vm/virtual-device.go @@ -54,11 +54,15 @@ type usbComposition struct { } func (s *Service) GetVirtualDevice(c *gin.Context) { + var rsp proto.Response + rsp.OkRspWithData(c, virtualDeviceState()) +} + +func virtualDeviceState() *proto.GetVirtualDeviceRsp { h := hid.GetHid() h.Lock() defer h.Unlock() - var rsp proto.Response - rsp.OkRspWithData(c, getUSBComposition().response()) + return getUSBComposition().response() } func (s usbComposition) response() *proto.GetVirtualDeviceRsp { diff --git a/server/utils/cert.go b/server/utils/cert.go index bd105bdd..8bd125c3 100644 --- a/server/utils/cert.go +++ b/server/utils/cert.go @@ -1,8 +1,9 @@ package utils import ( + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" - "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" @@ -59,9 +60,13 @@ func generateCertFiles(certFile, keyFile string) error { validFor = time.Hour * 24 * 365 * 10 ) - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + // ECDSA P-256: on the device an RSA-2048 signature takes about 80 ms of + // every full TLS handshake and generating the key about 6.5 s at first + // boot; P-256 takes about 3.6 ms and 10 ms. Existing certificates, RSA + // included, are kept so browsers that trust them see no new warning. + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { - log.Errorf("failed to generate RSA private key: %v", err) + log.Errorf("failed to generate ECDSA private key: %v", err) return err } publicKey := &privateKey.PublicKey @@ -80,7 +85,7 @@ func generateCertFiles(certFile, keyFile string) error { }, NotBefore: time.Now(), NotAfter: time.Now().Add(validFor), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + KeyUsage: x509.KeyUsageDigitalSignature, // ECDSA keys only sign ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: false, diff --git a/server/utils/cert_test.go b/server/utils/cert_test.go index 619c3c54..28552cb5 100644 --- a/server/utils/cert_test.go +++ b/server/utils/cert_test.go @@ -2,7 +2,9 @@ package utils import ( "bytes" + "crypto/ecdsa" "crypto/tls" + "crypto/x509" "os" "path/filepath" "testing" @@ -15,9 +17,16 @@ func TestFirstBootCertificateAndReuse(t *testing.T) { if err := ensureGeneratedCertificate(cert, key); err != nil { t.Fatal(err) } - if _, err := tls.LoadX509KeyPair(cert, key); err != nil { + pair, err := tls.LoadX509KeyPair(cert, key) + if err != nil { t.Fatal(err) } + if _, ok := pair.PrivateKey.(*ecdsa.PrivateKey); !ok { + t.Fatalf("generated key is %T, want ECDSA", pair.PrivateKey) + } + if usage := pair.Leaf.KeyUsage; usage != x509.KeyUsageDigitalSignature { + t.Fatalf("key usage %v", usage) + } before, _ := os.ReadFile(key) info, _ := os.Stat(key) if info.Mode().Perm() != 0600 { diff --git a/server/utils/memory.go b/server/utils/memory.go index 8d0b1593..84c2ee98 100644 --- a/server/utils/memory.go +++ b/server/utils/memory.go @@ -15,7 +15,16 @@ import ( const GoMemLimitFile = "/etc/kvm/GOMEMLIMIT" const minGoMemLimitMiB int64 = 50 -const defaultGoMemLimitBytes int64 = 1024 * 1024 * 1024 + +// Without a saved setting the server limits the memory the Go runtime manages +// to a quarter of MemTotal: 26 MiB in the 4K video memory mode, where Linux +// has about 104 MiB. The whole server process normally has 11 MiB of +// anonymous memory (Go and C together) and peaked at about 22 MiB, so the +// limit does not make the collector work in normal operation; it makes it +// collect harder instead of letting the heap grow into the memory that apk and +// the rest of the system need. A GOMEMLIMIT environment variable takes the +// place of this default. +const defaultGoMemLimitDivisor = 4 // Serialize persistence and application so concurrent API requests cannot leave // the process using a different limit from the one saved for the next start. @@ -23,9 +32,33 @@ type goMemoryLimitStore struct { mutex sync.Mutex path string apply func(int64) int64 + // fallback applies without a saved setting; zero until InitGoMemLimit. + fallback int64 + memInfo string } -var goMemLimit = goMemoryLimitStore{path: GoMemLimitFile, apply: debug.SetMemoryLimit} +var goMemLimit = goMemoryLimitStore{path: GoMemLimitFile, apply: debug.SetMemoryLimit, memInfo: "/proc/meminfo"} + +// defaultLimit is the GOMEMLIMIT environment value the runtime started with, +// otherwise a quarter of MemTotal, otherwise no limit. +func (s *goMemoryLimitStore) defaultLimit() int64 { + if current := s.apply(-1); current != math.MaxInt64 { + return current + } + data, err := os.ReadFile(s.memInfo) + if err != nil { + return math.MaxInt64 + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[0] == "MemTotal:" { + if kib, err := strconv.ParseInt(fields[1], 10, 64); err == nil && kib > 0 { + return kib * 1024 / defaultGoMemLimitDivisor + } + } + } + return math.MaxInt64 +} func normalizeGoMemLimit(limit int64) (int64, error) { if limit < 0 || limit > math.MaxInt64/(1024*1024) { @@ -46,19 +79,31 @@ func (s *goMemoryLimitStore) read() (int64, error) { return normalizeGoMemLimit(limit) } +// InitGoMemLimit applies the saved limit, or the default without one. It runs +// once at start, before anything else changes the runtime limit. func InitGoMemLimit() { - goMemLimit.mutex.Lock() - defer goMemLimit.mutex.Unlock() - limit, err := goMemLimit.read() + goMemLimit.init() +} + +func (s *goMemoryLimitStore) init() { + s.mutex.Lock() + defer s.mutex.Unlock() + s.fallback = s.defaultLimit() + limit, err := s.read() if os.IsNotExist(err) { + s.apply(s.fallback) + if s.fallback != math.MaxInt64 { + log.Infof("GOMEMLIMIT %d MiB (default)", s.fallback/(1024*1024)) + } return } if err != nil { log.Errorf("failed to read GOMEMLIMIT: %s", err) + s.apply(s.fallback) return } - goMemLimit.apply(limit * 1024 * 1024) - log.Debugf("set GOMEMLIMIT to %d MiB", limit) + s.apply(limit * 1024 * 1024) + log.Infof("GOMEMLIMIT %d MiB (saved)", limit) } func (s *goMemoryLimitStore) set(limit int64) error { @@ -109,7 +154,11 @@ func (s *goMemoryLimitStore) remove() error { if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) { return err } - s.apply(defaultGoMemLimitBytes) + if s.fallback == 0 { + s.apply(math.MaxInt64) + } else { + s.apply(s.fallback) + } return nil } diff --git a/server/utils/memory_test.go b/server/utils/memory_test.go index f9e9b62d..3636376f 100644 --- a/server/utils/memory_test.go +++ b/server/utils/memory_test.go @@ -32,11 +32,98 @@ func TestGoMemoryLimitPersistsEffectiveValue(t *testing.T) { if err := store.remove(); err != nil { t.Fatalf("idempotent disable: %v", err) } - if applied != defaultGoMemLimitBytes { + if applied != math.MaxInt64 { t.Fatalf("disabled limit=%d", applied) } } +// fakeRuntime stands in for debug.SetMemoryLimit, including its -1 query. +type fakeRuntime struct{ limit int64 } + +func (r *fakeRuntime) apply(n int64) int64 { + previous := r.limit + if n >= 0 { + r.limit = n + } + return previous +} + +func newDefaultStore(t *testing.T, meminfo string) (*goMemoryLimitStore, *fakeRuntime) { + t.Helper() + dir := t.TempDir() + runtime := &fakeRuntime{limit: math.MaxInt64} + store := &goMemoryLimitStore{path: filepath.Join(dir, "limit"), apply: runtime.apply, memInfo: filepath.Join(dir, "meminfo")} + if meminfo != "" { + if err := os.WriteFile(store.memInfo, []byte(meminfo), 0o644); err != nil { + t.Fatal(err) + } + } + return store, runtime +} + +const deviceMemInfo = "MemTotal: 106924 kB\nMemFree: 12704 kB\nMemAvailable: 49240 kB\n" + +func TestGoMemoryLimitDefaultsToQuarterOfMemTotal(t *testing.T) { + store, runtime := newDefaultStore(t, deviceMemInfo) + store.init() + if want := int64(106924 * 1024 / 4); runtime.limit != want { + t.Fatalf("default limit=%d want %d", runtime.limit, want) + } + // Turning a saved limit off returns to the default, not to no limit. + if err := store.set(80); err != nil || runtime.limit != 80*1024*1024 { + t.Fatalf("set: %d %v", runtime.limit, err) + } + if err := store.remove(); err != nil || runtime.limit != 106924*1024/4 { + t.Fatalf("remove: %d %v", runtime.limit, err) + } +} + +func TestGoMemoryLimitSavedSettingWinsAtStart(t *testing.T) { + store, runtime := newDefaultStore(t, deviceMemInfo) + if err := os.WriteFile(store.path, []byte("75\n"), 0o644); err != nil { + t.Fatal(err) + } + store.init() + if runtime.limit != 75*1024*1024 { + t.Fatalf("saved limit=%d", runtime.limit) + } + if err := store.remove(); err != nil || runtime.limit != 106924*1024/4 { + t.Fatalf("remove: %d %v", runtime.limit, err) + } + // A corrupt setting falls back to the default. + if err := os.WriteFile(store.path, []byte("bad"), 0o644); err != nil { + t.Fatal(err) + } + runtime.limit = math.MaxInt64 + store.init() + if runtime.limit != 106924*1024/4 { + t.Fatalf("corrupt setting limit=%d", runtime.limit) + } +} + +func TestGoMemoryLimitEnvironmentReplacesDefault(t *testing.T) { + store, runtime := newDefaultStore(t, deviceMemInfo) + runtime.limit = 40 * 1024 * 1024 // GOMEMLIMIT=40MiB in the environment + store.init() + if runtime.limit != 40*1024*1024 { + t.Fatalf("environment limit replaced: %d", runtime.limit) + } + if err := store.set(60); err != nil { + t.Fatal(err) + } + if err := store.remove(); err != nil || runtime.limit != 40*1024*1024 { + t.Fatalf("remove: %d %v", runtime.limit, err) + } +} + +func TestGoMemoryLimitWithoutMemInfoStaysUnlimited(t *testing.T) { + store, runtime := newDefaultStore(t, "") + store.init() + if runtime.limit != math.MaxInt64 { + t.Fatalf("limit=%d", runtime.limit) + } +} + func TestGoMemoryLimitFailureDoesNotChangeRuntime(t *testing.T) { dir := t.TempDir() applied := int64(75 * 1024 * 1024) diff --git a/web/eslint.config.js b/web/eslint.config.js index 6d311f89..8c3d7692 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import eslintConfigPrettier from 'eslint-config-prettier' export default tseslint.config( - { ignores: ['dist', 'public/mockServiceWorker.js'] }, + { ignores: ['dist', 'msw/mockServiceWorker.js'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], diff --git a/web/public/mockServiceWorker.js b/web/msw/mockServiceWorker.js similarity index 100% rename from web/public/mockServiceWorker.js rename to web/msw/mockServiceWorker.js diff --git a/web/package.json b/web/package.json index 7fc79b67..55e3922c 100644 --- a/web/package.json +++ b/web/package.json @@ -10,9 +10,10 @@ "scripts": { "dev": "vite", "mocked": "vite --mode mocked", - "build": "tsc && vite build", + "build": "tsc && vite build && node scripts/precompress.mjs", "test:usb": "node --experimental-strip-types --test tests/usb-composition.test.mjs", "test:direct": "node --import tsx --test tests/direct-playout.test.mjs tests/direct-worker.test.mjs", + "test:i18n": "node --import tsx --test tests/i18n-keys.test.mjs", "lint": "eslint . --report-unused-disable-directives --max-warnings 0", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"", "preview": "vite preview" @@ -43,7 +44,6 @@ "react-simple-keyboard": "3.8.278", "remark-gfm": "4.0.1", "vaul": "1.1.2", - "websocket": "1.0.35", "yocto-queue": "1.2.2" }, "devDependencies": { @@ -54,7 +54,6 @@ "@types/node": "24.19.1", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", - "@types/websocket": "1.0.10", "@typescript-eslint/eslint-plugin": "8.71.0", "@typescript-eslint/parser": "8.71.0", "@typescript/native": "npm:typescript@7.0.2", @@ -78,7 +77,7 @@ }, "msw": { "workerDirectory": [ - "public" + "msw" ] } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 044f7cea..e8435812 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -83,9 +83,6 @@ importers: vaul: specifier: 1.1.2 version: 1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - websocket: - specifier: 1.0.35 - version: 1.0.35 yocto-queue: specifier: 1.2.2 version: 1.2.2 @@ -111,9 +108,6 @@ importers: '@types/react-dom': specifier: 19.3.0 version: 19.3.0(@types/react@19.3.0) - '@types/websocket': - specifier: 1.0.10 - version: 1.0.10 '@typescript-eslint/eslint-plugin': specifier: 8.71.0 version: 8.71.0(@typescript-eslint/parser@8.71.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)) @@ -1314,9 +1308,6 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/websocket@1.0.10': - resolution: {integrity: sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==} - '@typescript-eslint/eslint-plugin@8.71.0': resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1597,10 +1588,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bufferutil@4.1.0: - resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} - engines: {node: '>=6.14.2'} - cacheable@2.5.0: resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} @@ -1673,21 +1660,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - d@1.0.2: - resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} - engines: {node: '>=0.12'} - dayjs@1.11.23: resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1751,17 +1726,6 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es5-ext@0.10.64: - resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} - engines: {node: '>=0.10'} - - es6-iterator@2.0.3: - resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} - - es6-symbol@3.1.4: - resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} - engines: {node: '>=0.12'} - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -1818,10 +1782,6 @@ packages: jiti: optional: true - esniff@2.0.1: - resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} - engines: {node: '>=0.10'} - espree@11.2.0: resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1845,12 +1805,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - event-emitter@0.3.5: - resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} - - ext@1.7.0: - resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} - extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2067,9 +2021,6 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-typedarray@1.0.0: - resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2446,9 +2397,6 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2480,13 +2428,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next-tick@1.1.0: - resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - - node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true - node-releases@2.0.57: resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} engines: {node: '>=18'} @@ -2887,12 +2828,6 @@ packages: resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} engines: {node: '>=20'} - type@2.7.3: - resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} - - typedarray-to-buffer@3.1.5: - resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} - typescript-eslint@8.71.0: resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2968,10 +2903,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - utf-8-validate@5.0.10: - resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} - engines: {node: '>=6.14.2'} - vaul@1.1.2: resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} peerDependencies: @@ -3027,10 +2958,6 @@ packages: yaml: optional: true - websocket@1.0.35: - resolution: {integrity: sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==} - engines: {node: '>=4.0.0'} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3048,11 +2975,6 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - yaeti@0.0.6: - resolution: {integrity: sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==} - engines: {node: '>=0.10.32'} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4138,10 +4060,6 @@ snapshots: '@types/unist@3.0.3': {} - '@types/websocket@1.0.10': - dependencies: - '@types/node': 24.19.1 - '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0))': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4433,10 +4351,6 @@ snapshots: node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.3) - bufferutil@4.1.0: - dependencies: - node-gyp-build: 4.8.4 - cacheable@2.5.0: dependencies: '@cacheable/memory': 2.2.0 @@ -4498,17 +4412,8 @@ snapshots: csstype@3.2.3: {} - d@1.0.2: - dependencies: - es5-ext: 0.10.64 - type: 2.7.3 - dayjs@1.11.23: {} - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -4561,24 +4466,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - es5-ext@0.10.64: - dependencies: - es6-iterator: 2.0.3 - es6-symbol: 3.1.4 - esniff: 2.0.1 - next-tick: 1.1.0 - - es6-iterator@2.0.3: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-symbol: 3.1.4 - - es6-symbol@3.1.4: - dependencies: - d: 1.0.2 - ext: 1.7.0 - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -4681,13 +4568,6 @@ snapshots: transitivePeerDependencies: - supports-color - esniff@2.0.1: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - event-emitter: 0.3.5 - type: 2.7.3 - espree@11.2.0: dependencies: acorn: 8.18.0 @@ -4708,15 +4588,6 @@ snapshots: esutils@2.0.3: {} - event-emitter@0.3.5: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - - ext@1.7.0: - dependencies: - type: 2.7.3 - extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -4919,8 +4790,6 @@ snapshots: is-plain-obj@4.1.0: {} - is-typedarray@1.0.0: {} - isexe@2.0.0: {} jiti@2.7.0: {} @@ -5445,8 +5314,6 @@ snapshots: dependencies: brace-expansion: 5.0.12 - ms@2.0.0: {} - ms@2.1.3: {} msw@3.0.1(@types/node@24.19.1)(@typescript/typescript6@6.0.2)(graphql@16.14.2)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): @@ -5477,10 +5344,6 @@ snapshots: natural-compare@1.4.0: {} - next-tick@1.1.0: {} - - node-gyp-build@4.8.4: {} - node-releases@2.0.57: {} object-assign@4.1.1: {} @@ -5838,12 +5701,6 @@ snapshots: dependencies: tagged-tag: 1.0.0 - type@2.7.3: {} - - typedarray-to-buffer@3.1.5: - dependencies: - is-typedarray: 1.0.0 - typescript-eslint@8.71.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)): dependencies: '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.11.0(jiti@2.7.0)) @@ -5946,10 +5803,6 @@ snapshots: dependencies: react: 19.3.0 - utf-8-validate@5.0.10: - dependencies: - node-gyp-build: 4.8.4 - vaul@1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -5984,17 +5837,6 @@ snapshots: tsx: 4.23.15 yaml: 2.9.1 - websocket@1.0.35: - dependencies: - bufferutil: 4.1.0 - debug: 2.6.9 - es5-ext: 0.10.64 - typedarray-to-buffer: 3.1.5 - utf-8-validate: 5.0.10 - yaeti: 0.0.6 - transitivePeerDependencies: - - supports-color - which@2.0.2: dependencies: isexe: 2.0.0 @@ -6009,8 +5851,6 @@ snapshots: y18n@5.0.8: {} - yaeti@0.0.6: {} - yallist@3.1.1: {} yaml@2.9.1: {} diff --git a/web/public/nanokvm-os-screen-full.svg b/web/public/nanokvm-os-screen-full.svg deleted file mode 100644 index 6c7bf731..00000000 --- a/web/public/nanokvm-os-screen-full.svg +++ /dev/null @@ -1,10 +0,0 @@ - -NanoKVM OS - - NanoKVM OS - - - - -NanoKVM OS - diff --git a/web/public/nanokvm-os-screen.svg b/web/public/nanokvm-os-screen.svg deleted file mode 100644 index 06ab1bdc..00000000 --- a/web/public/nanokvm-os-screen.svg +++ /dev/null @@ -1,5 +0,0 @@ - - NanoKVM OS - - - diff --git a/web/public/nanokvm-os.svg b/web/public/nanokvm-os.svg deleted file mode 100644 index 3459f133..00000000 --- a/web/public/nanokvm-os.svg +++ /dev/null @@ -1,5 +0,0 @@ - -NanoKVM OS - - - diff --git a/web/scripts/precompress.mjs b/web/scripts/precompress.mjs new file mode 100644 index 00000000..66b7a8c2 --- /dev/null +++ b/web/scripts/precompress.mjs @@ -0,0 +1,26 @@ +// Store a .gz next to each compressible build asset. The device serves them +// (server/router/assets.go) instead of compressing on its single slow core; +// the uncompressed file stays for clients without gzip. +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { constants, gzipSync } from 'node:zlib'; + +const root = new URL('../dist/assets/', import.meta.url).pathname; +const compressible = /\.(js|css|svg|json|wasm)$/; + +let before = 0; +let after = 0; +for (const name of readdirSync(root)) { + const path = join(root, name); + if (!compressible.test(name) || !statSync(path).isFile()) continue; + const data = readFileSync(path); + // zlib writes no file name and a zero timestamp: the output is reproducible. + const gz = gzipSync(data, { level: constants.Z_BEST_COMPRESSION }); + if (data.length < 1024 || gz.length > data.length * 0.9) continue; + writeFileSync(`${path}.gz`, gz); + before += data.length; + after += gz.length; +} +console.log( + `precompressed assets: ${Math.round(before / 1024)} KiB -> ${Math.round(after / 1024)} KiB` +); diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index d32290c7..c1010d51 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -34,10 +34,6 @@ export function changePassword(currentPassword: string, password: string) { return http.post('/api/auth/password', { currentPassword, password }); } -export function isPasswordUpdated() { - return http.get('/api/auth/password'); -} - export function getUsers() { return http.get('/api/auth/users'); } diff --git a/web/src/api/autostart.ts b/web/src/api/autostart.ts deleted file mode 100644 index 1fe971cb..00000000 --- a/web/src/api/autostart.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { http } from '@/lib/http.ts'; - -// Names are free text in the UI; encode them so "#", "?" or "/" cannot change -// the request path. -const autostartPath = (name: string) => '/api/vm/autostart/' + encodeURIComponent(name); - -export function getAutostart() { - return http.get('/api/vm/autostart'); -} - -export function uploadAutostart(name: string, content: string) { - return http.post(autostartPath(name), { content }); -} - -export function deleteAutostart(name: string) { - return http.request({ - url: autostartPath(name), - method: 'delete', - }); -} - -export function getAutostartContent(name: string) { - return http.get(autostartPath(name)); -} diff --git a/web/src/api/hid.ts b/web/src/api/hid.ts index 1a611ec9..4665dfc3 100644 --- a/web/src/api/hid.ts +++ b/web/src/api/hid.ts @@ -11,24 +11,11 @@ export function reset() { return http.post('/api/hid/reset'); } -// get hid mode -export function getHidMode() { - return http.get('/api/hid/mode'); -} - // get remote keyboard lock LED status export function getKeyboardLedStatus() { return http.get('/api/hid/leds'); } -// set hid mode -export function setHidMode(mode: string) { - const data = { - mode - }; - return http.post('/api/hid/mode', data); -} - // get shortcuts export function getShortcuts() { return http.get('/api/hid/shortcuts'); diff --git a/web/src/api/network.ts b/web/src/api/network.ts index 4f8a596b..9a2f9f0b 100644 --- a/web/src/api/network.ts +++ b/web/src/api/network.ts @@ -77,20 +77,6 @@ export function verifyApLogin(apPassword: string) { ); } -// connect wifi -export function connectWifi(ssid: string, password: string) { - const data = { - ssid, - password - }; - return http.post('/api/network/wifi/connect', data); -} - -// disconnect wifi -export function disconnectWifi() { - return http.post('/api/network/wifi/disconnect'); -} - export function getDNS() { return http.get('/api/network/dns'); } diff --git a/web/src/api/picoclaw-gateway.ts b/web/src/api/picoclaw-gateway.ts new file mode 100644 index 00000000..7a614a7d --- /dev/null +++ b/web/src/api/picoclaw-gateway.ts @@ -0,0 +1,48 @@ +// Gateway helpers live apart from the REST calls in ./picoclaw.ts so that the +// gateway client is only bundled with the PicoClaw sidebar, not the desktop page. +import { http } from '@/lib/http.ts'; +import { picoclawGateway } from '@/lib/picoclaw-gateway.ts'; + +const sessionIDHeader = 'X-PicoClaw-Session-ID'; + +export function connectGateway(sessionId?: string) { + return picoclawGateway.connect({ sessionId }); +} + +export function releaseRuntimeSession(sessionId?: string) { + const activeSessionId = sessionId || picoclawGateway.getSessionId(); + if (!activeSessionId) { + return Promise.resolve(null); + } + + return http.request({ + method: 'delete', + url: '/api/picoclaw/runtime/session', + headers: { + [sessionIDHeader]: activeSessionId + } + }); +} + +export function sendChatMessage( + content: string, + options?: { id?: string; maxSteps?: number; maxRuntimeMs?: number; trackState?: boolean } +) { + return picoclawGateway.sendChatMessage(content, options); +} + +export function sendStopMessage() { + return picoclawGateway.sendStopMessage(); +} + +export async function closeGateway() { + const activeSessionId = picoclawGateway.getSessionId(); + picoclawGateway.close(); + if (!activeSessionId) { + return; + } + + await releaseRuntimeSession(activeSessionId).catch(() => undefined); +} + +export { picoclawGateway }; diff --git a/web/src/api/picoclaw.ts b/web/src/api/picoclaw.ts index e6c6f944..981364cb 100644 --- a/web/src/api/picoclaw.ts +++ b/web/src/api/picoclaw.ts @@ -1,11 +1,4 @@ import { http } from '@/lib/http.ts'; -import { - picoclawGateway, - type GatewayRunState, - type GatewayTransportState -} from '@/lib/picoclaw-gateway.ts'; - -const sessionIDHeader = 'X-PicoClaw-Session-ID'; type ModelConfigRequest = { model: string; @@ -72,10 +65,6 @@ export function stopRuntime() { return http.post('/api/picoclaw/runtime/stop'); } -export function getAIControlStatus() { - return http.get('/api/ai/control/status'); -} - export function setAIControlMode(mode: 'off' | 'mcp' | 'picoclaw') { return http.request({ method: 'put', @@ -91,53 +80,3 @@ export function installRuntime() { export function uninstallRuntime() { return http.post('/api/picoclaw/runtime/uninstall'); } - -export function connectGateway(sessionId?: string) { - return picoclawGateway.connect({ sessionId }); -} - -export function releaseRuntimeSession(sessionId?: string) { - const activeSessionId = sessionId || picoclawGateway.getSessionId(); - if (!activeSessionId) { - return Promise.resolve(null); - } - - return http.request({ - method: 'delete', - url: '/api/picoclaw/runtime/session', - headers: { - [sessionIDHeader]: activeSessionId - } - }); -} - -export function sendChatMessage( - content: string, - options?: { id?: string; maxSteps?: number; maxRuntimeMs?: number; trackState?: boolean } -) { - return picoclawGateway.sendChatMessage(content, options); -} - -export function sendStopMessage() { - return picoclawGateway.sendStopMessage(); -} - -export async function closeGateway() { - const activeSessionId = picoclawGateway.getSessionId(); - picoclawGateway.close(); - if (!activeSessionId) { - return; - } - - await releaseRuntimeSession(activeSessionId).catch(() => undefined); -} - -export function onGatewayConnectionState(listener: (state: GatewayTransportState) => void) { - return picoclawGateway.on('transport_state', listener); -} - -export function onGatewayRunState(listener: (state: GatewayRunState) => void) { - return picoclawGateway.on('run_state', listener); -} - -export { picoclawGateway }; diff --git a/web/src/api/virtual-device.ts b/web/src/api/virtual-device.ts index a30824a2..9bb26f78 100644 --- a/web/src/api/virtual-device.ts +++ b/web/src/api/virtual-device.ts @@ -30,14 +30,3 @@ export async function setUsbComposition(composition: UsbComposition, revision: s if (response.code === 0) window.dispatchEvent(new Event(usbCompositionChangedEvent)); return response; } - -// mount/unmount virtual device -export async function updateVirtualDevice(device: string) { - const data = { - device - }; - - const response = await http.post('/api/vm/device/virtual', data); - if (response.code === 0) window.dispatchEvent(new Event(usbCompositionChangedEvent)); - return response; -} diff --git a/web/src/api/vm.ts b/web/src/api/vm.ts index 5eeef0d4..9e118a89 100644 --- a/web/src/api/vm.ts +++ b/web/src/api/vm.ts @@ -66,10 +66,6 @@ export function getInputRegion() { return http.get('/api/vm/input-region'); } -export function getInputResolution() { - return http.get('/api/vm/input-resolution'); -} - // save the device-level absolute mouse input region export function setInputRegion(region: InputRegion) { return http.post('/api/vm/input-region', { mode: 'manual', ...region }); @@ -93,10 +89,6 @@ export function setControlRegionMode(mode: ControlRegionMode) { return http.post('/api/vm/input-region', { mode }); } -export function setOriginalResolutions(mode: ControlRegionMode, resolutions: OriginalResolution[]) { - return http.post('/api/vm/input-region', { mode, resolutions }); -} - export function setOriginalResolutionConfig( resolutions: OriginalResolution[], selectedResolution: string, @@ -131,20 +123,6 @@ export function setManualRegions( }); } -// get memory limit -export function getMemoryLimit() { - return http.get('/api/vm/memory/limit'); -} - -// set memory limit -export function setMemoryLimit(enabled: boolean, limit: number) { - const data = { - enabled, - limit - }; - return http.post('/api/vm/memory/limit', data); -} - // get OLED configuration export function getOLED() { return http.get('/api/vm/oled'); @@ -203,16 +181,6 @@ export function disableSSH() { return http.post('/api/vm/ssh/disable'); } -// get swap file size -export function getSwap() { - return http.get('/api/vm/swap'); -} - -// set swap file size -export function setSwap(size: number) { - return http.post('/api/vm/swap', { size }); -} - // get mouse jiggler export function getMouseJiggler() { return http.get('/api/vm/mouse-jiggler'); @@ -323,8 +291,8 @@ export function setMemorySwap( export function getCPUFrequency() { return http.get('/api/vm/cpu-frequency'); } -export function setCPUFrequency(target: number) { - return http.post('/api/vm/cpu-frequency', { target }); +export function setCPUFrequency(target: number, applyAtBoot: boolean) { + return http.post('/api/vm/cpu-frequency', { target, applyAtBoot }); } export function setVideoMemory(mode: VideoMemoryMode) { diff --git a/web/src/assets/styles/index.css b/web/src/assets/styles/index.css index b5cf74be..b37775d7 100644 --- a/web/src/assets/styles/index.css +++ b/web/src/assets/styles/index.css @@ -2,6 +2,21 @@ @import 'tailwindcss/theme.css' layer(theme); @import 'tailwindcss/utilities.css' layer(utilities); +/* Semantic colours (lib/theme-tokens.ts holds the same values for antd; a test + * keeps them equal): text-fg, text-fg-muted, border-line, bg-surface, + * bg-surface-raised, text-success / warning / danger / info. */ +@theme { + --color-fg: #e5e5e5; + --color-fg-muted: #a3a3a3; + --color-line: rgb(64 64 64 / 0.7); + --color-surface: rgb(38 38 38 / 0.4); + --color-surface-raised: rgb(38 38 38 / 0.7); + --color-success: #22c55e; + --color-warning: #fbbf24; + --color-danger: #f87171; + --color-info: #38bdf8; +} + /* Keep the established no-preflight policy for Ant Design and remote video. */ body { font-family: var(--font-sans); @@ -29,25 +44,6 @@ body { max-width: calc(100vw - 76px); } -@media (max-width: 639px) { - .nanokvm-settings-mobile-content .flex.items-center.justify-between { - flex-wrap: wrap; - } - - .nanokvm-settings-mobile-content .flex.items-center.justify-between > :first-child { - min-width: 0; - } - - .nanokvm-settings-mobile-content .flex.items-center.justify-between > :last-child { - max-width: 100%; - margin-left: auto; - } - - .nanokvm-settings-mobile-content :is(.ant-select, .ant-input, .ant-input-number) { - max-width: 100%; - } -} - .nanokvm-mobile-menu-popover .ant-popover-content { max-height: calc(100dvh - 96px); overflow: hidden; diff --git a/web/src/components/app-theme.tsx b/web/src/components/app-theme.tsx index d6f673cf..4ac20f30 100644 --- a/web/src/components/app-theme.tsx +++ b/web/src/components/app-theme.tsx @@ -1,28 +1,44 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, ReactNode, useEffect, useMemo } from 'react'; import { ConfigProvider, theme } from 'antd'; import { useAtomValue } from 'jotai'; +import { themeTokens } from '@/lib/theme-tokens'; import { brandingAtom, brandingButtonColor, buttonTextColor } from '@/jotai/branding'; export const AppTheme = ({ children }: PropsWithChildren) => { const branding = useAtomValue(brandingAtom); const colorPrimary = brandingButtonColor(branding); - const themeConfig = { - algorithm: theme.darkAlgorithm, - token: { - colorPrimary, - colorSuccess: '#22c55e' - }, - components: { - Button: { - primaryColor: buttonTextColor(colorPrimary) + const themeConfig = useMemo( + () => ({ + algorithm: theme.darkAlgorithm, + token: { + colorPrimary, + colorSuccess: themeTokens.success, + colorWarning: themeTokens.warning, + colorError: themeTokens.danger, + colorInfo: themeTokens.info, + colorTextDescription: themeTokens.fgMuted }, - Collapse: { - headerPadding: 0, - contentPadding: 0 + components: { + Button: { + primaryColor: buttonTextColor(colorPrimary) + }, + Collapse: { + headerPadding: 0, + contentPadding: 0 + } } - } - }; + }), + [colorPrimary] + ); + + // Static message, Modal.confirm and notification calls render outside this + // provider; give them the same dark theme and colours. + useEffect(() => { + ConfigProvider.config({ + holderRender: (node: ReactNode) => {node} + }); + }, [themeConfig]); return {children}; }; diff --git a/web/src/components/auth.tsx b/web/src/components/auth.tsx index f2f438d8..b01b3a4d 100644 --- a/web/src/components/auth.tsx +++ b/web/src/components/auth.tsx @@ -4,7 +4,7 @@ import { Spin } from 'antd'; import { Navigate, useLocation } from 'react-router-dom'; import { Account, getAccount } from '@/api/auth.ts'; -import { AUTH_EXPIRED_EVENT } from '@/lib/auth-events.ts'; +import { AUTH_EXPIRED_EVENT, PASSWORD_CHANGE_REQUIRED_EVENT } from '@/lib/auth-events.ts'; export const ProtectedRoute = ({ children }: { children: ReactNode }) => { const location = useLocation(); @@ -41,11 +41,16 @@ export const ProtectedRoute = ({ children }: { children: ReactNode }) => { setIsAuthenticated(false); setIsLoading(false); }; + const handlePasswordChangeRequired = () => { + setAccount((current) => current && { ...current, mustChangePassword: true }); + }; window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired); + window.addEventListener(PASSWORD_CHANGE_REQUIRED_EVENT, handlePasswordChangeRequired); return () => { active = false; window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired); + window.removeEventListener(PASSWORD_CHANGE_REQUIRED_EVENT, handlePasswordChangeRequired); }; }, []); diff --git a/web/src/components/handshake-age.tsx b/web/src/components/handshake-age.tsx index be891d58..e3e1c1c0 100644 --- a/web/src/components/handshake-age.tsx +++ b/web/src/components/handshake-age.tsx @@ -1,9 +1,14 @@ import { useEffect, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { useTranslation } from 'react-i18next'; +import { formatDeviceTime } from '@/lib/date-time.ts'; +import { timePreferencesAtom } from '@/hooks/useDeviceTime.ts'; + // Anchor to the device clock, then advance locally without polling every second. export function HandshakeAge({ timestamp, serverNow }: { timestamp: number; serverNow?: number }) { const { i18n } = useTranslation(); + const preferences = useAtomValue(timePreferencesAtom); const [sample, setSample] = useState(() => ({ now: serverNow ?? Date.now(), at: performance.now() @@ -22,11 +27,14 @@ export function HandshakeAge({ timestamp, serverNow }: { timestamp: number; serv seconds < 60 ? 'second' : seconds < 3600 ? 'minute' : seconds < 86400 ? 'hour' : 'day'; const divisor = { second: 1, minute: 60, hour: 3600, day: 86400 }[unit]; return ( - <> + ); } diff --git a/web/src/components/icons/github.tsx b/web/src/components/icons/github.tsx new file mode 100644 index 00000000..1fddbe02 --- /dev/null +++ b/web/src/components/icons/github.tsx @@ -0,0 +1,12 @@ +import { IconBase, type IconProps } from './icon-base.tsx'; + +// GitHub mark from @ant-design/icons-svg 4.6.0 (MIT), GithubOutlined. +// lucide-react 1.x ships no brand icons. The viewBox leaves the same optical +// padding as a lucide glyph, so size={16} matches the neighbouring lucide icons. +export const GithubIcon = (props: IconProps) => { + return ( + + + + ); +}; diff --git a/web/src/components/ui/confirm.ts b/web/src/components/ui/confirm.ts new file mode 100644 index 00000000..a6bdb540 --- /dev/null +++ b/web/src/components/ui/confirm.ts @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; +import { Modal } from 'antd'; + +type ConfirmOptions = { + title: ReactNode; + content?: ReactNode; + okText?: string; + cancelText?: string; + // Red confirm button for actions that cut a connection or risk the device. + danger?: boolean; +}; + +// Ask before a change that interrupts the connection or risks the device. +// Resolves to true when confirmed. Static Modal follows the app theme +// (components/app-theme.tsx). +export const confirmAction = ({ title, content, okText, cancelText, danger }: ConfirmOptions) => + new Promise((resolve) => { + Modal.confirm({ + title, + content, + okText, + cancelText, + okButtonProps: danger ? { danger: true } : undefined, + onOk: () => resolve(true), + onCancel: () => resolve(false) + }); + }); diff --git a/web/src/components/ui/settings.tsx b/web/src/components/ui/settings.tsx new file mode 100644 index 00000000..fb58db52 --- /dev/null +++ b/web/src/components/ui/settings.tsx @@ -0,0 +1,171 @@ +import { ComponentProps, ReactNode } from 'react'; +import { Button, Divider, Tooltip } from 'antd'; +import clsx from 'clsx'; +import { CircleHelpIcon } from 'lucide-react'; + +// Building blocks for the settings pages. One page = SettingsPage; groups of +// related settings = SettingsSection; one setting = SettingRow (label and +// description on the left, the control on the right); bordered groups and +// lists = Panel. Colours come from the semantic tokens (lib/theme-tokens.ts). + +type SettingsPageProps = { + title: ReactNode; + description?: ReactNode; + // Page-level actions (for example a refresh button) next to the title. + actions?: ReactNode; + children: ReactNode; +}; + +export const SettingsPage = ({ title, description, actions, children }: SettingsPageProps) => ( +
+
+

{title}

+ {actions &&
{actions}
} +
+ {description &&

{description}

} + +
{children}
+
+); + +type SettingsSectionProps = { + title?: ReactNode; + description?: ReactNode; + // Section-level actions (for example a refresh button) next to the title. + actions?: ReactNode; + children: ReactNode; +}; + +// Sections of a page are separated by space-y-6 (the page root), the rows +// inside a section by space-y-4. +export const SettingsSection = ({ + title, + description, + actions, + children +}: SettingsSectionProps) => ( +
+ {(title || description || actions) && ( +
+
+ {title &&

{title}

} + {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ )} +
{children}
+
+); + +type SettingRowProps = { + label: ReactNode; + description?: ReactNode; + // Extra explanation behind a help icon, for details most people do not need. + help?: string; + // id of the control. The description gets the id -description; + // pass it to the control as aria-describedby. + htmlFor?: string; + // Put the control under the label (wide controls: long selects, inputs). + stacked?: boolean; + children: ReactNode; +}; + +export const SettingRow = ({ + label, + description, + help, + htmlFor, + stacked, + children +}: SettingRowProps) => { + const descriptionId = htmlFor ? `${htmlFor}-description` : undefined; + return ( +
+
+
+ {htmlFor ? : {label}} + {help && } +
+ {description && ( +
+ {description} +
+ )} +
+
{children}
+
+ ); +}; + +// A help icon for neutral extra information. The tooltip text is also the +// icon's accessible name, and the tooltip opens on keyboard focus too. +export const HelpTip = ({ title }: { title: string }) => ( + + + +); + +type PanelProps = { + className?: string; + // No padding, for lists whose rows bring their own (divide-y divide-line). + flush?: boolean; + children: ReactNode; +}; + +export const Panel = ({ className, flush, children }: PanelProps) => ( +
+ {children} +
+); + +type Tone = 'success' | 'warning' | 'danger' | 'info' | 'neutral'; + +const toneClass: Record = { + success: 'text-success', + warning: 'text-warning', + danger: 'text-danger', + info: 'text-info', + neutral: 'text-fg-muted' +}; + +// A state shown as a coloured dot plus text, never colour alone. +export const StatusBadge = ({ tone, children }: { tone: Tone; children: ReactNode }) => ( + + + {children} + +); + +type IconButtonProps = Omit< + ComponentProps, + 'type' | 'size' | 'icon' | 'children' +> & { + // Accessible name and tooltip. + label: string; + icon: ReactNode; +}; + +// The remaining props (including the ref and the event handlers a wrapping +// Popconfirm or Popover injects) go to the button. +export const IconButton = ({ label, icon, ...props }: IconButtonProps) => ( + + - } placeholder={t('auth.placeholderUsername')} /> + } placeholder={t('auth.placeholderUsername')} /> { rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]} > } + prefix={} type="password" placeholder={t('auth.placeholderPassword')} /> diff --git a/web/src/pages/auth/login/tips.tsx b/web/src/pages/auth/login/tips.tsx index 6a044913..79572620 100644 --- a/web/src/pages/auth/login/tips.tsx +++ b/web/src/pages/auth/login/tips.tsx @@ -18,12 +18,13 @@ export const Tips = () => { return ( <> - {t('auth.forgetPassword')} - + { const { t } = useTranslation(); const [msg, setMsg] = useState(''); const navigate = useNavigate(); const { account } = useAuth(); + const forced = account.mustChangePassword === true; useEffect(() => { if (msg) { @@ -41,11 +44,15 @@ export const Password = () => { setMsg( rsp.code === invalidCurrentPasswordCode ? t('auth.invalidCurrentPassword') - : t('auth.error') + : rsp.code === invalidNewPasswordCode && rsp.msg + ? rsp.msg + : t('auth.error') ); return; } + // The server ended every session of this user; sign in again. + void message.success(t('auth.passwordChanged')); notifyAuthExpired(); navigate('/auth/login', { replace: true }); }) @@ -55,15 +62,23 @@ export const Password = () => { } function cancel() { - // The device stays locked to this page until the factory password changes. - if (account.mustChangePassword) { - void api.logout().finally(() => { - notifyAuthExpired(); - navigate('/auth/login', { replace: true }); - }); + // The device stays locked to this page until the factory password changes, + // so cancelling a forced change logs out (the button says so). + if (forced) { + void api + .logout() + .catch(() => { + /* The session is dropped locally either way. */ + }) + .finally(() => { + notifyAuthExpired(); + navigate('/auth/login', { replace: true }); + }); return; } - window.location.replace('/'); + // Return to the previous page without reloading the application. + if ((window.history.state?.idx ?? 0) > 0) navigate(-1); + else navigate('/', { replace: true }); } return ( @@ -72,18 +87,19 @@ export const Password = () => {

{t('auth.changePassword')}

+ {forced && ( +

+ {t('auth.changePasswordDesc')} +

+ )} - + } + prefix={} type="password" autoComplete="current-password" placeholder={t('auth.placeholderCurrentPassword')} @@ -98,7 +114,7 @@ export const Password = () => { ]} > } + prefix={} type="password" autoComplete="new-password" placeholder={t('auth.placeholderPassword')} @@ -113,7 +129,7 @@ export const Password = () => { ]} > } + prefix={} type="password" autoComplete="new-password" placeholder={t('auth.placeholderPassword2')} @@ -127,7 +143,7 @@ export const Password = () => { {t('auth.ok')}
diff --git a/web/src/pages/desktop/index.tsx b/web/src/pages/desktop/index.tsx index 75cd0689..1301cbb0 100644 --- a/web/src/pages/desktop/index.tsx +++ b/web/src/pages/desktop/index.tsx @@ -7,10 +7,10 @@ import { useMediaQuery } from 'react-responsive'; import { getEncoderState } from '@/api/stream.ts'; import { getInputRegion, getScreen, setControlRegionMode } from '@/api/vm.ts'; import { ControlRegionConfig, InputRegion } from '@/types'; -import { refreshCapture } from '@/lib/capture-control.ts'; +import { adoptLiveCapture } from '@/lib/capture-control.ts'; import { getEncoderCodec, initializeEncoderCodec } from '@/lib/encoder.ts'; +import { refreshLiveStatus, subscribeLiveStatus } from '@/lib/live-status.ts'; import * as storage from '@/lib/localstorage.ts'; -import { pollWhileVisible } from '@/lib/visible-poll.ts'; import { client } from '@/lib/websocket.ts'; import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts'; import { picoclawChatOpenAtom } from '@/jotai/picoclaw.ts'; @@ -26,6 +26,7 @@ import { videoModeAtom } from '@/jotai/screen.ts'; import { usbInputAtom } from '@/jotai/usb-input.ts'; +import { useDeviceTime } from '@/hooks/useDeviceTime.ts'; import { Head } from '@/components/head.tsx'; import { CaptureStatusOverlay, useCaptureStatus } from './capture-status'; @@ -34,7 +35,7 @@ import { ControlNotice } from './control.tsx'; import { Keyboard } from './keyboard'; import { Menu } from './menu'; import { Mouse } from './mouse'; -import { H264ModeNotification, Notification } from './notification.tsx'; +import { H264ModeNotification } from './notification.tsx'; import { ActionOverlay } from './picoclaw/action-overlay.tsx'; import { Screen } from './screen'; import { AutoRegion } from './screen/auto-region.tsx'; @@ -46,6 +47,7 @@ import { } from './screen/geometry.ts'; import { InputRegionOverlay } from './screen/input-region-overlay.tsx'; import { ManualRegion } from './screen/manual-region.tsx'; +import { preloadPlayer } from './screen/players.ts'; function getVideoMode() { const directSupported = window.isSecureContext && !!window.VideoDecoder; @@ -94,6 +96,8 @@ export const Desktop = () => { if (!usbInput.keyboard) setKeyboardOpen(false); }, [usbInput.keyboard, setKeyboardOpen]); const { t } = useTranslation(); + // Load the device time format once for every displayed device time. + useDeviceTime(); const isBigScreen = useMediaQuery({ minWidth: 850 }); const [activeVideoMode] = useState(getVideoMode); const [encoderReady, setEncoderReady] = useState(false); @@ -114,14 +118,13 @@ export const Desktop = () => { const captureStatus = useCaptureStatus(activeVideoMode); const captureEnabled = useAtomValue(isHdmiEnabledAtom); useEffect(() => { - const refresh = () => { - void refreshCapture().catch(() => undefined); - }; - refresh(); - const stopPolling = pollWhileVisible(refresh, 3000); + const refresh = () => void refreshLiveStatus({ force: true }); + const unsubscribe = subscribeLiveStatus((status, startedAt) => + adoptLiveCapture(status?.hdmi ?? null, startedAt) + ); window.addEventListener('focus', refresh); return () => { - stopPolling(); + unsubscribe(); window.removeEventListener('focus', refresh); }; }, []); @@ -151,6 +154,9 @@ export const Desktop = () => { } await initializeEncoderCodec(activeVideoMode === 'h264' ? 'webrtc' : 'direct', codec); }; + // Fetch the player chunk alongside the encoder state, so that the player + // mounts together with the input handlers that attach to #screen. + const playerReady = preloadPlayer(activeVideoMode); void join() .catch((error: unknown) => { if (!active) return; @@ -160,6 +166,7 @@ export const Desktop = () => { : 'screen.encoderStateFailed' ); }) + .then(() => playerReady) .finally(() => { if (!active) return; setVideoMode(activeVideoMode); @@ -185,7 +192,6 @@ export const Desktop = () => { rsp.code === 0 ? { width: rsp.data.width, height: rsp.data.height } : { width: 0, height: 0 }; - storage.setResolution(res); setResolution(res); }) .catch(() => { @@ -324,7 +330,6 @@ export const Desktop = () => {
- {isBigScreen && } diff --git a/web/src/pages/desktop/keyboard-led-status/index.tsx b/web/src/pages/desktop/keyboard-led-status/index.tsx index b792c39d..4be4b139 100644 --- a/web/src/pages/desktop/keyboard-led-status/index.tsx +++ b/web/src/pages/desktop/keyboard-led-status/index.tsx @@ -4,12 +4,6 @@ import { useTranslation } from 'react-i18next'; import { useKeyboardLedStatus } from './use-keyboard-led-status'; -const LOCK_INDICATORS = { - numLock: { label: 'Num Lock', shortLabel: 'Num' }, - capsLock: { label: 'Caps Lock', shortLabel: 'Caps' }, - scrollLock: { label: 'Scroll Lock', shortLabel: 'Scr' } -} as const; - type LockIndicatorProps = { labelKey: 'numLock' | 'capsLock' | 'scrollLock'; active: boolean; @@ -18,10 +12,10 @@ type LockIndicatorProps = { }; function LockIndicator({ labelKey, active, known, disabled }: LockIndicatorProps) { - const { label, shortLabel } = LOCK_INDICATORS[labelKey]; - const { t } = useTranslation(); - const state = disabled ? t('vpn.keyboardDisabled') : known ? (active ? 'On' : 'Off') : 'Unknown'; - const indicatorLabel = `${label}: ${state}`; + const { t } = useTranslation('translation', { keyPrefix: 'settings.keyboardLedStatus' }); + const shortLabel = t(`${labelKey}Short`); + const state = t(disabled ? 'keyboardDisabled' : known ? (active ? 'on' : 'off') : 'unknown'); + const indicatorLabel = t('indicatorLabel', { label: t(labelKey), state }); return ( @@ -50,6 +44,7 @@ function LockIndicator({ labelKey, active, known, disabled }: LockIndicatorProps } export function KeyboardLedStatus() { + const { t } = useTranslation(); const status = useKeyboardLedStatus(); const disabled = status?.keyboardEnabled === false; const known = !disabled && (status?.known ?? false); @@ -57,7 +52,7 @@ export function KeyboardLedStatus() { return (
{ - api.getLeaderKey().then((rsp: any) => { - if (rsp.code === 0 && rsp.data?.key !== undefined) { - setLeaderKeyCode(rsp.data.key); - } - }); + api + .getLeaderKey() + .then((rsp: any) => { + if (rsp.code === 0 && rsp.data?.key !== undefined) { + setLeaderKeyCode(rsp.data.key); + } + }) + .catch(() => { + /* Keep the current leader key; the request is retried on the next page load. */ + }); }, [setLeaderKeyCode]); // Update leader key diff --git a/web/src/pages/desktop/menu/admin-items.ts b/web/src/pages/desktop/menu/admin-items.ts new file mode 100644 index 00000000..4b93e44a --- /dev/null +++ b/web/src/pages/desktop/menu/admin-items.ts @@ -0,0 +1,9 @@ +// Toolbar items rendered only for admins. The menu loads them lazily as one +// chunk, so non-admin sessions never download them. +export { Capture } from './capture'; +export { DownloadImage } from './download.tsx'; +export { Image } from './image'; +export { Picoclaw } from './picoclaw'; +export { Script } from './script'; +export { Terminal } from './terminal'; +export { UsbMenu } from './usb'; diff --git a/web/src/pages/desktop/menu/audio.tsx b/web/src/pages/desktop/menu/audio.tsx index 873ae245..1ebfab71 100644 --- a/web/src/pages/desktop/menu/audio.tsx +++ b/web/src/pages/desktop/menu/audio.tsx @@ -45,7 +45,7 @@ export const AudioMenu = ({ audio }: { audio: ReturnType })
{blocked && ( )}
diff --git a/web/src/pages/desktop/menu/download.tsx b/web/src/pages/desktop/menu/download.tsx index 43d367a9..b66aba79 100644 --- a/web/src/pages/desktop/menu/download.tsx +++ b/web/src/pages/desktop/menu/download.tsx @@ -8,6 +8,7 @@ import { DownloadIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { cancelDownloadImage, downloadImage, imageEnabled, statusImage } from '@/api/download.ts'; +import { notifyAuthExpired } from '@/lib/auth-events.ts'; import { readTransferProgress, transferBytes } from '@/lib/download-progress'; import { keyboardLockAtom } from '@/jotai/keyboard.ts'; import { MenuItem } from '@/components/menu-item.tsx'; @@ -309,10 +310,15 @@ export const DownloadImage = () => { body: formData }) .then(async (response) => { - const rsp = await response.json(); - if (!response.ok || rsp.code !== 0) { - const message = rsp.msg === 'sha256 mismatch' ? t('download.checksumFailed') : rsp.msg; - throw new Error(message || t('download.failed')); + // Error pages from the server or a proxy (e.g. 413) need not be JSON. + const rsp = await response.json().catch(() => null); + if (response.status === 401) { + // Same as the shared HTTP client: the session expired, sign in again. + notifyAuthExpired(); + } + if (!response.ok || rsp?.code !== 0) { + const message = rsp?.msg === 'sha256 mismatch' ? t('download.checksumFailed') : rsp?.msg; + throw new Error(message || `${t('download.failed')} (HTTP ${response.status})`); } finishImageTransfer(true); diff --git a/web/src/pages/desktop/menu/image/images.tsx b/web/src/pages/desktop/menu/image/images.tsx index 608517f6..9fed93fb 100644 --- a/web/src/pages/desktop/menu/image/images.tsx +++ b/web/src/pages/desktop/menu/image/images.tsx @@ -12,6 +12,7 @@ import { import { useTranslation } from 'react-i18next'; import * as api from '@/api/storage.ts'; +import { showRequestError } from '@/lib/show-request-error.ts'; import { client } from '@/lib/websocket.ts'; const imageUpdatedEvent = 'nanokvm:image-updated'; @@ -38,13 +39,18 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = // get mounted image const getMountedImage = useCallback(() => { - api.getMountedImage().then((rsp) => { - if (rsp.code !== 0) return; + api + .getMountedImage() + .then((rsp) => { + if (rsp.code !== 0) return; - const file = rsp.data?.file; - setMountedImage(file); - setIsMounted(!!file); - }); + const file = rsp.data?.file; + setMountedImage(file); + setIsMounted(!!file); + }) + .catch(() => { + /* The image list read below reports the failure. */ + }); }, [setIsMounted]); const imageReadInFlight = useRef(false); @@ -70,6 +76,7 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = setImages([]); } }) + .catch((err) => showRequestError(err)) .finally(() => { imageReadInFlight.current = false; setIsLoading(false); @@ -105,7 +112,6 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = .mountImage(filename, cdrom, force) .then((rsp) => { if (rsp.code !== 0) { - console.log(rsp.msg); if (rsp.code === -4 && isMounted && !force) { setForceEjectImage(image); } else { @@ -117,6 +123,7 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = setMountedImage(filename); setIsMounted(!!filename); }) + .catch(() => openNotification(isMounted)) .finally(() => { setMountingImage(''); client.connect(); @@ -149,7 +156,7 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = .deleteImage(selectedImage) .then((rsp) => { if (rsp.code !== 0) { - console.log(rsp.msg); + showRequestError(rsp); return; } @@ -157,6 +164,7 @@ export const Images = ({ isOpen, cdrom, setIsMounted, disabled }: ImagesProps) = setSelectedImage(''); }) + .catch((err) => showRequestError(err)) .finally(() => { setDeletingImage(''); }); diff --git a/web/src/pages/desktop/menu/image/index.tsx b/web/src/pages/desktop/menu/image/index.tsx index dafbdc97..62a3367f 100644 --- a/web/src/pages/desktop/menu/image/index.tsx +++ b/web/src/pages/desktop/menu/image/index.tsx @@ -38,7 +38,7 @@ export const Image = ({ tooltipPlacement = 'bottom' }: ImageProps) => { label: (
- Mass Storage + {t('image.massStorage')}
) }, @@ -47,7 +47,7 @@ export const Image = ({ tooltipPlacement = 'bottom' }: ImageProps) => { label: (
- CD/DVD + {t('image.cdrom')}
) } diff --git a/web/src/pages/desktop/menu/index.tsx b/web/src/pages/desktop/menu/index.tsx index 038372ca..ff744e7f 100644 --- a/web/src/pages/desktop/menu/index.tsx +++ b/web/src/pages/desktop/menu/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { useAuth } from '@/contexts/auth.ts'; import { Button, Divider } from 'antd'; import clsx from 'clsx'; @@ -28,25 +28,42 @@ import { MobileMenuItemProvider } from '@/components/menu-item.tsx'; import { Control } from '../control.tsx'; import { KeyboardLedStatus } from '../keyboard-led-status'; import { AudioMenu } from './audio'; -import { Capture } from './capture'; -import { DownloadImage } from './download.tsx'; import { Fullscreen } from './fullscreen'; -import { Image } from './image'; import { Keyboard } from './keyboard'; import { Logout } from './logout'; import { Mouse } from './mouse'; import { Collapse, Expand } from './operations'; -import { Picoclaw } from './picoclaw'; import { Power } from './power'; import { Recorder } from './recorder'; import { Screen } from './screen'; import { Screenshot } from './screenshot'; -import { Script } from './script'; import { Settings } from './settings'; -import { Terminal } from './terminal'; -import { UsbMenu } from './usb'; import { Wol } from './wol'; +// Admin-only items come from one lazily loaded chunk (see admin-items.ts). +const loadAdminItems = () => import('./admin-items.ts'); +const Capture = lazy(() => loadAdminItems().then((module) => ({ default: module.Capture }))); +const DownloadImage = lazy(() => + loadAdminItems().then((module) => ({ default: module.DownloadImage })) +); +const Image = lazy(() => loadAdminItems().then((module) => ({ default: module.Image }))); +const Picoclaw = lazy(() => loadAdminItems().then((module) => ({ default: module.Picoclaw }))); +const Script = lazy(() => loadAdminItems().then((module) => ({ default: module.Script }))); +const Terminal = lazy(() => loadAdminItems().then((module) => ({ default: module.Terminal }))); +const UsbMenu = lazy(() => loadAdminItems().then((module) => ({ default: module.UsbMenu }))); + +// Keeps a lazy toolbar item's 30x30 slot while its chunk loads, so the bar +// does not shift. Image renders nothing until the virtual disk state is known, +// so its placeholder is empty as well. +const ToolbarSlot = () =>