Skip to content

v2.5: simpler settings, less work per request, OOM protection, ECDSA repository - #64

Merged
dormancygrace merged 110 commits into
mainfrom
claude/v2.5-settings-perf-trust
Oct 7, 2026
Merged

dormancygrace merged 110 commits into
mainfrom
claude/v2.5-settings-perf-trust

Conversation

@dormancygrace

Copy link
Copy Markdown
Owner

Fixes and optimisation for 2.5: a simpler settings UI, far less work per request on the device, protection against running out of memory, and an ECDSA-signed package repository. No version bump.

Settings UI

  • Navigation: seven top-level entries (Dashboard, Video, USB, Network, VPN, System, Software, Appearance) instead of a long flat list.
    • VPN is one entry whose page lists the providers with their state, version and active profile; each provider opens as VPN / <provider> with a back arrow. Old network-*/vpn-* ids and dashboard links still work.
  • Shared components and theme tokens (components/ui/settings.tsx, lib/theme-tokens.ts) on every settings page: 58 → 13 distinct raw colour classes, 16 → 3 card variants.
  • Confirmations before changes that cut the connection or risk the device: HTTPS, Wi-Fi off, USB off, CPU overclock.
  • Consistency fixes:
    • heading and paragraph spacing (no Tailwind preflight);
    • reboot and sign-out as normal setting rows;
    • user names no longer truncated;
    • Ethernet and overclock warnings shown only when relevant;
    • no "no matching packages" before a search;
    • RustDesk loading spinner.
  • Dashboard:
    • CPU and RAM tiles link to their settings;
    • the VPN card shows only installed VPNs, including NetBird, which it used to miss.
  • Mobile: checked at 390 px in touch mode; nothing overflows and collection back navigation works.

Performance (measured on the device)

Before After
Idle API requests per 20 s 26 (five pollers) 7 (one GET /api/vm/live-status)
Cold load of the desktop page 131 requests, ~1.6 MB 43 requests, 530 KB
Repeat load everything again 4 KB; 42 of 43 assets from cache
RustDesk status ~7 s ~30 ms
Wi-Fi / gateway / VPN status polls up to 0.3–0.7 s each 10–30 ms
New TLS handshake (RSA-2048 → ECDSA P-256 certificates) 70–95 ms 18–30 ms
  • Cold and repeat loads: merged build chunks (201 → 102), and build-time .gz assets served with year-long immutable caching for /assets.
  • Status handlers:
    • Wi-Fi status: wpa_supplicant socket instead of wpa_cli/iw;
    • gateway: /proc/net/route;
    • WireGuard: one wg show dump;
    • OpenVPN: no ip;
    • VPN tool versions: cached until the program changes;
    • stopped daemons: answered from /run/openrc/started;
    • Tailscale: its local socket;
    • RustDesk: apk facts cached and repository queries run in the background.
  • Certificates: only newly generated certificates use ECDSA; existing ones are kept.

Reliability

  • One apk runner (internal/apkrun): a cross-process lock under /run, low priority for index loads, and a memory check that refuses a run that cannot fit (one index load peaks at ~40 MiB of the ~104 MiB Linux has in 4K mode).
  • OOM order: apk children are preferred victims (oom_score_adj 1000); NanoKVM-Server is protected (-300); daemons the server starts reset to 0.
  • GOMEMLIMIT: a saved value was never applied at start; it now is, and the default is a quarter of RAM.
  • RustDesk no longer silently turns USB back on at every daemon start; it does so only when the user enables RustDesk or its sound.
  • CPU frequency: optional "apply at startup", marked dangerous. If an overclocked start freezes within 2 minutes, the next start uses 1000 MHz and turns the option off. This is the one new option in this release, added on request.

APK trust and signing

  • platform/build.sh requires a chosen key: -k (RSA) and -e (ECDSA) for releases, or -d for a local test key. It no longer silently creates and trusts a throwaway key.

  • New nanokvm-keys package owns the release public keys (dgrace-6aaddbb6.rsa.pub, nkos-release-ec-b8e89b66.pub); nanokvm-base depends on it, so keys can be rotated by updates.

  • Signatures:

    • packages and the v2 APKINDEX.tar.gz are signed with RSA-SHA256 instead of SHA-1;
    • a v3 Packages.adb index is dual-signed (RSA + ECDSA);
    • the C906 overlay can dual-sign too.
  • Device migration: on install, nanokvm-keys moves the NanoKVM repository line to …/riscv64/Packages.adb. 2.0 devices receive 2.5 through the RSA-signed v2 index and then switch.

  • Verified on apk-tools 3.0.8 in scratch roots:

    • RSA256 is accepted;
    • an ECDSA-only v3 index installs abuild v2 packages and rejects a tampered package;
    • each key alone verifies the dual-signed index.
  • Publishing 2.5:

    1. platform/build.sh -k …/dgrace-6aaddbb6.rsa -e …/nkos-release-ec-b8e89b66.key.
    2. Upload OUTPUT/release/apk/recipes/riscv64/* to https://nkos.pesin.pro/repos/nanokvm/riscv64/, packages first and both indexes last.
    3. Keep publishing the v2 index for devices that skip 2.5.

    Details are in firmware/alpine/README.md#signing-keys.

Testing

  • CI-equivalent checks pass locally:
    • Go: build, vet and test with teststub (49 packages), gofmt clean;
    • web: pnpm build, lint, 168 tests;
    • shellcheck, Python syntax, all scripts/test-*.py, firmware/alpine/tests, tools tests.
  • platform/expected.sha256: only the web/ and server/ entries are updated. Two consecutive builds were identical; kernel, boot, native and firmware outputs are unchanged by this branch.
  • On the device: the server and web from this branch ran on the device. The live status, all settings pages, USB-off confirmation, CPU-at-boot toggle, compressed assets, status timings, apk runner (a package search) and OOM scores were checked there.

Not covered

  • Not tested live: OpenVPN, NetBird and Tailscale are not installed on the test device, so their status changes are covered by unit tests only.
  • Overclocked boot: the fallback is covered by tests; a real overclocked reboot was not run.
  • Init scripts: the kvm_system -300 setting in S95nanokvm takes effect once installed from packages.
  • RustDesk daemon after package upgrades: it still inherits apk's OOM score; the fix belongs in the separately released nanokvm-rustdesk add-on.
  • Formatting: 47 web files are not Prettier-formatted; all of them already were on main, and CI does not check Prettier.

🤖 Generated with Claude Code

dormancygrace and others added 30 commits October 7, 2026 03:21
The toolbar polled /api/vm/gpio every 200 ms for as long as the desktop
was open, although outside the popover it only renders the power-on glow
and the HDD LED. Poll every 300 ms while the popover is open and every
3 s otherwise; opening the popover refreshes immediately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Screen toolbar item wrote /kvmapp/kvm/type (via POST /api/vm/screen
type) on mount and on every local mode or codec change whenever the
viewer was an administrator. That file is device-wide, so merely opening
the page in a browser with a different local player choice silently
changed the setting for everyone.

Nothing depends on that implicit write: the Go server only writes the
file (service/vm/screen.go) and never reads it back; the Direct, WebRTC
and MJPEG streams pick their encoder from the stream that is opened and
from the shared encoder state (POST /api/stream/state). Its only reader
is kvm_system, which shows the type on the OLED and uses it to bucket
the quality value. The video settings dialog already sends `type` in the
batched POST /api/vm/video request when an administrator applies a
different transport or codec (settingsRequest in lib/video-model.ts),
which is the explicit path that should own this setting.

Remove the effect and the now unused getScreenType helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The RustDesk add-on kept its own en/ru label map and picked it with
startsWith("ru"), so every other language got English and the strings
were invisible to translators. Move them to settings.rustdesk.* in en.ts
and ru.ts, use i18next interpolation for the version, and drop the two
labels that were never rendered (installed, codec).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g blocks

- One set of semantic colours (fg, fg-muted, line, surface, success,
  warning, danger, info) in lib/theme-tokens.ts for antd and in @theme for
  Tailwind utilities; a test keeps them equal. fg-muted is neutral-400,
  the lowest contrast meant for information.
- Static message, Modal.confirm and notification calls render outside the
  ConfigProvider and used the default light theme; holderRender gives them
  the app theme.
- components/ui/settings.tsx: SettingsPage, SettingsSection, SettingRow,
  Panel, StatusBadge and IconButton for the settings pages, which each
  hand-build these today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three dashboard section links used ids that no settings tab has:
'device-general' (the tab is 'device') and 'date-time' (the tab is
'system-date-time') opened an empty page, and 'network' only toggled the
sidebar group instead of opening the network page ('network-general').

Also fall back to the dashboard when a tab id is unknown or the tab is
no longer available (for example an extension that was uninstalled), so
a stale id can never render a blank settings page again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
encrypt() uses a single passphrase-based AES call, but importing the
crypto-js root bundled every hash and cipher. crypto-js/aes pulls in only
AES and its MD5/EvpKDF/Base64/OpenSSL-format dependencies, so the output
format is unchanged; a test checks that the full library still decrypts
it and that both imports are byte-identical for a fixed salt.

encrypt chunk: 69.5 kB -> 17.7 kB (gzip 24.1 -> 6.0 kB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Frame detect is a device-wide encoder setting, but it had no source of
truth: the server only pushed it into the native encoder (which starts
with it off after every restart), any signed-in user could change it,
and each browser remembered its own copy in localStorage
(nano-kvm-frame-detect). The settings dialog could therefore show it as
on while the device had it off, and the MJPEG player paused frame detect
based on that stale local copy.

Server:
- Save the choice in /etc/kvm/frame_detect (like the other /etc/kvm
  settings) and apply it when KvmVision is initialized.
- Return it as `frameDetect` from GET /api/vm/screen.
- Move POST /api/stream/mjpeg/detect into the admin group; the temporary
  pause (/detect/stop) stays available to viewers, does nothing when
  frame detect is off and restores the saved choice afterwards instead
  of always re-enabling it.

Web:
- Read frameDetect from GET /api/vm/screen; applying the setting no
  longer writes localStorage, and the key's getter and setter are gone.
- The MJPEG player always asks for the short pause and lets the server
  decide whether frame detect is on.

Add a router test for the admin-only update and its persistence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The date and time settings promise that the 12/24-hour format is shared
across the interface, including VPN handshake times, but useDeviceTime()
was never called and timePreferencesAtom was read nowhere, so the format
only affected the preview in the settings page.

Wiring it is small, so do that rather than removing the promise: load
the preferences once at the desktop root (re-polled every minute, and
only replacing the atom value when it changes), show the handshake time
in the device time zone and format as the tooltip of the relative age,
and format the system log, boot and diagnostics timestamps with it
instead of the browser's locale defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In browsers the package only re-exports the native WebSocket through its
w3cwebsocket shim (its constructor returns the native instance), so
WsClient and the WebRTC signalling socket behave identically with
WebSocket directly: same onopen/onclose/onerror/onmessage handlers,
binaryType, reconnect and heartbeat logic. The shim and its es5-ext
dependency leave the bundle (websocket chunk 7.5 kB -> 5.1 kB), and
websocket/@types/websocket are removed with pnpm remove.

@types/websocket was also what pulled Node types into the program, so
the one Node-only test under src now references them explicitly. Tests
that injected the shim now inject a WebSocket stub instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ManualRegion recomputed the centered input region every 250 ms and
always stored a new object in inputRegionAtom, which the desktop root
reads, so the whole desktop re-rendered four times a second while a
manual region was selected. The region selector overlay did the same
with its media size and frame rectangle.

Keep the previous object when the geometry is unchanged (shallow
comparison in isSameGeometry) and add a test for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop menu imports api/picoclaw.ts for the runtime status poll,
which also re-exported the gateway helpers and so pulled the gateway
client (lib/picoclaw-gateway.ts) into the desktop page for every user.
Move the gateway helpers unchanged into api/picoclaw-gateway.ts, used
only by the lazily loaded sidebar; api/picoclaw.ts keeps the REST calls.
The gateway singleton has no constructor side effects, so creating it
when the sidebar first loads changes nothing at runtime.

Shared picoclaw chunk on the desktop page: 8.7 kB -> 2.4 kB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Route the remaining literal labels through i18next with en and ru keys:
the Extensions section and its load/empty states (previously chosen with
startsWith("ru")), Dashboard and Ethernet titles, dashboard ZRAM/SD swap
rows and the code-built fps, Mbit/s and MiB values, the preferred gateway
options, the Wi-Fi "not detected" note, the USB pointer profile default,
keyboard lock indicators (with their own keyboardDisabled key), the HDD
LED tooltip, image mount modes, the touchpad guide diagram label,
autostart table headers, the Wi-Fi setup password placeholder and the
NetBird/Tailscale English error fallbacks. Add the missing audio.resume
key. Virtual keyboard layouts are now listed by their native names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the 22 keys the Russian locale was missing (current password,
password length, roles and the whole user management section) and
translate values that were still copied from English: PicoClaw control,
connection and start texts, Route Allowed IPs, the SSH banner style,
the credits button and the USB gadget diagnostic label. Brand names,
protocol names and placeholders remain identical on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The screen statically imported the MJPEG, WebRTC (H.264) and Direct
players although a session uses exactly one (switching modes reloads
the page). Each player is now its own chunk (mjpeg 1.6 kB, h264-direct
3.9 kB, h264-webrtc 8.8 kB).

The desktop page fetches the chosen player alongside the encoder state
and only then marks the encoder ready, so the player still mounts in
the same commit as the mouse/region handlers that attach to its #screen
element, and no fallback is shown. If that preload failed, the screen
retries the import, renders nothing until it arrives and reports a
second failure to the error boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 22 community locales still carried six settings.tailscale keys from
the removed manual-install instructions. Remove them, and flag every
locale that translates well under 90% of the English keys (all of them
except en and ru, at about 42%) as partial. The language selector shows
such languages with a translated "partial" note, since half of their
interface falls back to English. Danish, Italian and Indonesian are
listed by their native names like the other languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Many actions only logged a failure with console.log or had no rejection
handler at all, so the user saw nothing (or an unhandled rejection in
the console) when a script upload, image mount or delete, Mouse
Jiggler, hostname, web title, mDNS, reboot, keyboard shortcut or
Tailscale restart/stop/uninstall request failed; the HDMI idle timeout
silently reverted, and a failed HDMI state read left the switch
spinning.

Add lib/request-error.ts, which recognizes the failures the shared HTTP
client already handles (aborted requests, 401 session expiry, the forced
password change redirect) and builds the text to show: a localized
lead plus the server's message as detail. showRequestError() shows it
with the antd message API, as the settings pages already do, and takes
a translation key so loaders called from mount effects need no `t`
dependency. Wake-on-LAN keeps reporting in its own status line, the
image mount keeps its existing notification, and the background leader
key read now just keeps the current key on failure.

The HTTP client now logs only unexpected errors rather than every
aborted or 401 request.

The ISO upload keeps fetch() for its progress handling but no longer
assumes a JSON body: a 413 or other non-JSON error page now shows
"Download failed (HTTP 413)" instead of a JSON parse error, and a 401
signals session expiry like the shared client.

Add error.requestFailed (en, ru) and tests for the error classification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/i18n-keys.test.mjs loads every locale and fails when a locale has
keys that en.ts does not define, when a language without the partial flag
misses an English key, when a translation interpolates a value English
does not pass, or when languages.ts and the locale files disagree. Plural
suffixes are compared per language. CI already runs tests/*.test.mjs;
pnpm test:i18n runs the check alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The password page gave no reason when it was forced on a factory
password, returned to the login page without saying the change worked,
and replaced the server's validation message (code -5, e.g. a password
that is too weak) with "Unexpected error". Cancel silently logged out
on a forced change, and otherwise reloaded the whole application.

The HTTP client handled the server's "password change required" answer
by setting the URL hash directly. The route guard's account still said
no change was required, so the page opened in its non-forced form and
Cancel led straight back to a page whose requests bounced to it again.

- Show auth.changePasswordDesc when the change is forced, and a success
  message after the change before returning to the login page.
- Show the server's message for code -5.
- The client now raises a password-change-required event; the route
  guard marks the account and redirects, so the page knows it is forced.
- A forced Cancel is labelled "Cancel and log out"; otherwise Cancel
  goes back in the router history (or to the desktop) without a reload.
- The tips card said the change also resets the root/SSH password for
  every administrator. The server does that only for the built-in
  (system) admin account (service/auth/password.go), and GET
  /api/auth/account does not say which account that is, so reword the
  tip to name the built-in admin account (en, ru).
- Drop initialValues={{ remember: true }} from the login and password
  forms, which have no such field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hotspot setup page showed the success text after any exception and
after unknown response codes. Only code 0 is a success now; an answer
from the device with another code is a failure. A request that fails
without any response is expected while the device leaves the hotspot to
join the network, so it shows a neutral "connecting, the hotspot may
disconnect" text (en, ru) instead of claiming success or failure.

The "Finished" button that replaced the submit button after success had
no action; remove it and keep the submit button, so a wrong network can
be corrected. The password placeholder was a hard-coded English string
and now uses auth.placeholderPassword. Drop the stray
initialValues={{ remember: true }} from both forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The terminal page reacted only to close code 4401 (session expired).
Any other close (the shell exited, the server restarted, the network
dropped, the WebSocket never connected) left a silent, dead terminal.

Write a visible "Connection closed. Press Enter to reconnect." line
(en, ru) into the terminal on any other close that the page did not
initiate itself; Enter reloads the page, which reconnects with the same
serial parameters because they are kept in sessionStorage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use sentence case for labels, buttons and titles, "OK" instead of "Ok",
the single ellipsis character, "Sign in"/"Sign out" for the account
actions (key names unchanged), lowercase "fps" in prose and labels and
"HID-only" and "leader key" as common nouns. Remove the unused
screen.videoDirectTips string, which pointed to the wrong settings page,
from all locales. In Russian, use the ellipsis character and Cyrillic
"ОК" throughout, describe frame rate in words and fix the leader-key
recorder prompt that called a shortcut a "ярлык".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop mounted a Notification that asked GET /api/auth/password
whether the password was still the factory one and then offered to
change it (dismissable for three days via the
nano-kvm-skip-modify-password key). It can never appear: that state is
the account's mustChangePassword flag, and ProtectedRoute redirects such
an account to /auth/password before the desktop mounts; while the flag
is set the server answers every other request with the
"password change required" code, which now also sends the route guard
to the password page.

Remove the component, its mount, the web isPasswordUpdated() helper,
the skip key's getter/setter and the expiry helpers only it used. The
server endpoint stays for API compatibility.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The toolbar Settings button is shown to everyone, but the modal body
(navigation, page loaders, VPN/extension icons, the Radix scroll area)
was part of the desktop chunk. Move the modal JSX, tab list and titles
unchanged into settings/dialog.tsx and load it lazily the first time the
modal opens (by click or by a settings request); it stays mounted
afterwards so the close animation is kept. All state, the addon
inventory polling and the open/close/lock logic stay in the button
component, and the chunk is prefetched when the button is hovered or
focused.

Desktop chunk: 284 kB -> 251 kB (dialog chunk: 23 kB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Capture, USB, Image, Download, Terminal, Script and PicoClaw are only
rendered for admins, yet their code (including the USB settings page,
download progress UI and script runner) was in the desktop chunk for
every user. They now come from one lazily loaded chunk (admin-items,
47 kB) that non-admin sessions never request. Order, keys and grouping
are unchanged; while the chunk loads each item keeps its 30x30 slot, and
Image (which renders nothing until the virtual-disk state is known)
keeps an empty one, so the toolbar does not shift.

Desktop chunk: 251 kB -> 202 kB; JS loaded by the desktop page for a
non-admin (WebRTC player): 1484 kB -> 1416 kB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Translating the NetBird and Tailscale status fallback inside getStatus
made the callback depend on t, so switching the language started a new
status request (caught by effect-lifecycle.test.mjs). Store the fallback
as its key and translate it when rendering instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	web/src/i18n/locales/en.ts
#	web/src/pages/desktop/menu/power/index.tsx
public/mockServiceWorker.js (12.8 kB) was copied into every production
build although only "vite --mode mocked" registers it. Move it to msw/
(also the msw.workerDirectory, so msw keeps it updated on install) and
add a small Vite plugin, enabled only in mocked mode, that serves it from
the dev server and emits it for "vite build --mode mocked". Checked:
"pnpm run mocked" serves /mockServiceWorker.js byte-identical to the
file, and a production build no longer contains it.

The vite.config.ts import order now follows the repository Prettier
configuration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nanokvm-os.svg, nanokvm-os-screen.svg and nanokvm-os-screen-full.svg are
not referenced by index.html, the web sources, the server or the
scripts; only the favicon nanokvm-os-connection*.svg files are used.
The next recorded platform outputs (platform/expected.sha256) will no
longer list these files under web/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the lazy-loading changes the largest chunks are the terminal page
(xterm, 337 kB), the shared React DOM/antd core chunk (297 kB) and the
PicoClaw sidebar (256 kB), all below Vite's default 500 kB limit, so the
raised 1024 kB limit is dropped and the build reports no warnings.

manualChunks was measured and not added. A react/react-dom/react-router
vendor chunk changes nothing (Rolldown already keeps them in app-free
chunks). Adding antd/rc-component to a vendor chunk grows the JS+CSS
loaded by the login page from 1071 kB to 1424 kB and by the desktop page
(non-admin, WebRTC) from 1484 kB to 1629 kB, while saving only 53 kB of
re-downloads after a small app-only change (331 kB -> 278 kB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dormancygrace and others added 22 commits October 7, 2026 18:23
OS and Software updates, Software page queries, VPN add-on installs and the
RustDesk service now queue behind each other, also across the detached update
helper. Software page queries wait up to 12 s for another run and then keep
their former time limits, so they stay below the browser timeout. RustDesk
keeps its own mutex, which also covers its apk state check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The VPN list asks for the tool versions every 5 s and each provider page
every 60 s. The answer was kept for a minute, after which every installed
tool ran with --version again (wg: 10 ms from a Go process on the device;
the Go-based tailscale and netbird clients take far longer, a 4 MiB Go
program already needs 130 ms, 730 ms with a cold page cache).

A version only changes when apk replaces the program, so the output is
now kept per executable until its inode, size or modification time
changes. A failed run is still retried after a minute, and concurrent
requests share one run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The VPN list polls the NetBird status every 5 s and the dashboard every
30 s. With NetBird installed but stopped, each poll ran rc-service
netbird status (0.45 s on the device for an existing service) and then
netbird version, a Go program.

As for RustDesk, a service without its /run/openrc/started link is
stopped and rc-service now runs only for a started service, which may
have crashed. The installed version is kept until the netbird program is
replaced. A running daemon is still asked with rc-service and netbird
status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The VPN list polls the Tailscale status every 5 s and the dashboard
every 30 s, and each poll started tailscale status --json. The client is
a large Go program; on the device a 4 MiB Go program already takes
130 ms to start, 730 ms with a cold page cache, and the client's memory
competes with video on a 104 MiB system.

The status now comes from tailscaled's local API on the socket the
client itself uses, GET /localapi/v0/status, which is the document the
client prints. With nothing listening on the socket the client would
fail the same way, so the daemon is reported as not running directly.
Any other failure, such as a refused request or an unreadable answer,
falls back to the client as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
InitGoMemLimit was never called, so a saved /etc/kvm/GOMEMLIMIT only took
effect when set from the web page and was lost at the next start. Apply it at
start, and without a saved value limit the Go-managed memory to a quarter of
MemTotal (26 MiB in the 4K video memory mode, above the measured 22 MiB peak
of the whole process anonymous memory). A GOMEMLIMIT environment value replaces
the default; turning the saved limit off returns to the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NanoKVM-Server, which also owns video capture, sets oom_score_adj -300 for
itself at start, so it works for the OpenRC service, the legacy init script
and manual starts alike; S95nanokvm sets the same for kvm_system. -300 is about
47 MiB in the 4K video memory mode: the server outlives other processes in its
normal footprint but can still be killed if it runs away. Web terminal shells
and the nkos-apply-updates worker, which restarts services after apk, reset
the value to 0 so that what they start is not protected or preferred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NanoKVM-Server lowers its own oom_score_adj to -300, and children
inherit it: sshd, avahi, chrony, OpenVPN and the Tailscale, NetBird and
RustDesk daemons started from settings would be protected like the
server. Those starts now reset the value to 0 before exec, through a
shell wrapper for rc-service and init scripts (so what they spawn starts
at 0 too) and right after start for openvpn, which runs no scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without -k the packages and rootfs steps created a key in OUTPUT/keys
and made the image trust it. apk does not tie a key to a repository, so
such an image trusted a throwaway key, whose private half stays on the
build machine, for every repository; a release built without -k would
ship that. Those steps now require -k with the release key, or -d for a
local test key, named nanokvm-test-*, with a warning not to publish.
The check runs before the first step, not after a long build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The packages and rootfs steps run build.sh again in a user namespace,
which passed -k but not -d, so a -d build stopped there with "APK signing
key required". -d also took any .rsa in OUTPUT/keys, such as a key left
by an earlier build or copied there; it now uses only the nanokvm-test-*
key that it creates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Devices trust the keys in /etc/apk/keys, but no package owned the
NanoKVM release key there: the rootfs step and manual setup copied it.
An update could therefore neither add a new key nor remove an old one.

nanokvm-keys installs firmware/alpine/keys in /etc/apk/keys, as
alpine-keys does for Alpine; its package() accepts only public keys
there. nanokvm-base depends on it, as alpine-base depends on
alpine-keys, and nanokvm-release lists it with the tested version.
build-alpine-packages.sh builds it first and falls back to
firmware/alpine/keys when the payload tree has no keys/.

The rootfs step still copies the keys before apk add nanokvm-release,
since apk must trust the repository first. /etc/apk is not a protected
path, so apk takes over the identical file without a conflict or an
.apk-new, also on devices that already have the unowned file; the step
now fails if any file in /etc/apk/keys has no owner. With -d the
packages step adds the test key to nanokvm-keys, so a test image owns
the key it trusts. -k must name a key whose public half is in
firmware/alpine/keys under the same name, else devices that update
would not trust the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abuild calls abuild-sign without -t, and abuild-sign's default type RSA
is RSA with SHA-1 (.SIGN.RSA.<key>). abuild has no setting for the type,
so build-alpine-packages.sh and build-alpine-tuned-packages.sh put a
small abuild-sign wrapper first in PATH that adds -t RSA256: packages
and APKINDEX.tar.gz get .SIGN.RSA256.<key>, RSA with SHA-256. Both
scripts now refuse a package or index without such a signature.
qualify-alpine-c906-repo.py signs its regenerated APKINDEX the same way.

apk-tools 3.0.8 on the device accepts RSA256 package and index
signatures from a trusted key, and rejects them from an untrusted key or
with a changed signature. apk mkndx already signs Packages.adb with
SHA-512 and needs no change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The maintainer's new release key for the apk-tools v3 index
(Packages.adb): an ECDSA P-256 public key, SHA-256 of its DER
b8e89b667c3ffeb5acb14e45779d74eaf0423205f27d7d904f36af81a50269bf.
nanokvm-keys ships it next to dgrace-6aaddbb6.rsa.pub, so devices trust
it once they install the next release, and the rootfs step installs it
for the bootstrap. nanokvm-keys and both key copies now take any
*.pub, not only *.rsa.pub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The packages step now also writes Packages.adb, the apk-tools v3 index,
next to APKINDEX.tar.gz with apk mkndx in the builder. It is signed with
the ECDSA release key given by the new option -e and with the RSA key of
-k, so that devices which trust either key accept it during the
transition; the step checks that each key alone verifies it. The v2
APKINDEX stays signed with the RSA key (RSA256), so 2.0 devices can
still read it, and the packages stay ordinary abuild packages.

Release builds need both -k and -e, checked before the first step like
-k: -e NAME.key needs NAME.pub next to it, an ECDSA P-256 or P-384 key
that is one of firmware/alpine/keys. With -d the build creates a test
key nanokvm-test-ec-*.key as well, puts it in nanokvm-keys and makes the
image trust it.

The rootfs step installs from the v3 index, and the image's NanoKVM
repository line is now
https://nkos.pesin.pro/repos/nanokvm/riscv64/Packages.adb. Alpine's
lines are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No package manages /etc/apk/repositories, so devices that update keep
the v2 line https://nkos.pesin.pro/repos/nanokvm. nanokvm-keys is new to
them, so its post-install runs once on the update that brings the
release keys, after its files, the ECDSA key among them, are in place.
It replaces that one exact line (trailing slash and spaces allowed) with
https://nkos.pesin.pro/repos/nanokvm/riscv64/Packages.adb and leaves
every other line, Alpine's and tagged ones included, as it is. A
failure keeps the old line, which still works. The new apk settings take
effect with the next apk run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build-alpine-tuned-packages.sh (EC_SIGN_KEY) and
qualify-alpine-c906-repo.py (--ec-sign-key or EC_SIGN_KEY) take the
ECDSA release key, NAME.key with NAME.pub next to it, and pass it to apk
mkndx as a second --sign-key. Packages.adb then carries an RSA and an
ECDSA signature, and both scripts check that each key alone verifies it.
Without the EC key they sign with the RSA key only, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build-alpine-personal-image.sh turns the NanoKVM and C906 repository
base URLs into their v3 index URL, BASE/riscv64/Packages.adb, for the
build and for the image's /etc/apk/repositories; a URL that already
ends in .adb is kept. Alpine's lines are unchanged.

The personal builder server requires riscv64/Packages.adb in its
repositories and keys a build by that index. build-alpine-builder-deploy
takes --repo-key more than once, for the RSA and the ECDSA key, and
requires the v3 index; the example configuration lists both keys.
migrate-alpine-stock.sh also removes a C906 line in the v3 form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
firmware/alpine/README.md gets a Signing keys section: which key signs
what, how nanokvm-keys owns the keys and takes over the copies of the
bootstrap, the v2 and v3 indexes and how devices move to the v3 index,
how to publish a release (build with -k and -e, upload riscv64/ with
both indexes last), how to move to the ECDSA key alone while 2.0
devices still update, and how to replace a key: ship it signed by the
old key, sign with the new key (both during the transition), then drop
the old key. platform/README.md describes -e and the v3 index, and the
attended and tuning READMEs the second key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web UI and NanoKVM-Server changed in this branch (chunking,
precompressed .gz assets, live status, faster status handlers, apk
runner, OOM and certificate changes). Only the web/ and server/ entries
are replaced: two consecutive builds produced identical outputs. Kernel,
boot, native and firmware outputs are unchanged by this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread web/src/lib/encrypt.ts Dismissed
@dormancygrace

Copy link
Copy Markdown
Owner Author

Reviewed head 01b745ecfadb239b77598e58b07890b495c0884d. The request/status optimizations and the APK key migration are useful, but I found two regressions to fix before merging:

  1. [P1] Restore executable modes on the scripts. The diff changes platform/build.sh, kvmapp/system/init.d/S95nanokvm, scripts/build-alpine-packages.sh, scripts/build-alpine-tuned-packages.sh, scripts/build-alpine-builder-deploy.py, scripts/build-alpine-personal-image.sh, scripts/migrate-alpine-stock.sh and scripts/qualify-alpine-c906-repo.py from 100755 to 100644. The documented ./platform/build.sh ... command now fails with EACCES/Permission denied; I reproduced that directly on this head. Restore executable bits in Git (including the init script). The Alpine payload manifest explicitly sets S95 to 0755, so this finding does not claim the packaged Alpine service necessarily loses its mode.

  2. [P2] Do not give missing assets a one-year immutable cache policy. server/router/assets.go:26 sets Cache-Control: public, max-age=31536000, immutable before checking whether the asset exists. A GET for /assets/not-yet-installed-abc.js through the same Gin/static middleware returns 404 with that cache header. A request during package replacement or an incomplete deployment can therefore cache the failure even after the file becomes available. Set the long-lived policy only for successfully served assets; ensure errors are not cached this way. Add coverage for a missing hashed asset, alongside the existing compressed/plain success tests.

CodeQL: the failing security check points at web/src/lib/encrypt.ts:9. The fixed passphrase and CryptoJS AES call already existed on main; the change narrows the import. Both encryption compatibility tests pass, including byte-for-byte equality with a fixed salt. This is not evidence of a newly introduced password-hashing regression. Triage the alert explicitly; do not silently dismiss it or claim that this client-side wrapper replaces HTTPS/password hashing.

Validation performed here: targeted Go suites for the APK runner, OOM scores, tool cache, VM, network, RustDesk and memory utilities pass. The router suite fails only on the added missing-asset regression reproducer. Eight focused web tests for encryption, shared polling and settings aliases pass. No deployment, reboot, or device changes were made.

Release integration note: the prepared v2.5-a1 release branch already contains the signing/index commits plus USB NCM Internet sharing and separate image/application versioning. Preserve those additions when integrating this PR, and regenerate final output checksums after the combined server/web build instead of replacing the release manifest wholesale.

dormancygrace and others added 2 commits October 7, 2026 20:08
Editing these files through the Windows file share dropped their
executable bit, so ./platform/build.sh failed with Permission denied.
They get back the modes they have on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The year-long immutable Cache-Control was set before checking the file,
so a 404 for a hashed asset (during a package replacement or an
incomplete deployment) carried it and could stick in browsers after the
file arrived. The headers are now set only when the asset exists; a
missing one, even with a stray .gz, is an ordinary uncached 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dormancygrace

Copy link
Copy Markdown
Owner Author

Thanks. Both regressions are fixed in c6c429e and 6ca7ada.

[P1] Executable modes. The bits were lost because these files were edited through the Windows share (\\wsl.localhost). c6c429e restores 100755 on all nine files: the eight you listed plus server/osupdate/software.go, which is 100755 on main as well. git diff --summary origin/main..HEAD now shows no mode changes, and ./platform/build.sh -h runs.

[P2] Cache policy on missing assets. In 6ca7ada, assets() sets Cache-Control: public, max-age=31536000, immutable and Vary only after the requested hashed file exists. A missing asset is an ordinary 404 without those headers, even when a stray .gz exists. The router test now covers /assets/not-yet-installed-abc.js and an orphan .gz next to the compressed and plain success cases. It fails on the previous head with the exact 404 + immutable header you reproduced, and passes now.

CodeQL alert #50 (web/src/lib/encrypt.ts:9): triage

  • What the code does. It is not password hashing. encrypt() AES-encrypts the password with crypto-js using the constant passphrase nanokvm-sipeed-2024, which ships in the JS bundle. The server reverses it (utils.DecodeDecrypt) before bcrypt-verifying or bcrypt-hashing the password (authn/store.go, bcrypt.DefaultCost). The OpenSSL-style EvpKDF/MD5 derivation the rule detects is applied to that public constant, not to the user's password.
  • What it is worth. The wrapper only avoids sending the password as literal plaintext. It is reversible by anyone who has the bundle, so it gives no confidentiality of its own. Confidentiality in transit depends on HTTPS; stored passwords are protected by bcrypt on the server. The wrapper replaces neither.
  • Why the alert is new here. The pattern is unchanged from main. This PR only narrows the import from crypto-js to crypto-js/aes to shrink the bundle, and CodeQL now resolves the call. The encryption output is byte-identical (tests/encrypt.test.mjs).
  • Decision. I am dismissing the alert as won't fix for this release, with a link to this comment. Changing the wire format, for example sending the plain password over TLS only, needs a coordinated server/client API change and handling for older clients. That is out of scope for 2.5 and should be a separate issue.

@dormancygrace

Copy link
Copy Markdown
Owner Author

Rechecked head 6ca7adaabdbe20b6d5250de96e8bccc068d0ef2c.

Both review findings are resolved:

  • All nine executable modes are restored; the documented ./platform/build.sh -h now executes and prints usage.
  • Missing hashed assets and orphan .gz files return 404 without the immutable cache header. The updated router suite passes, and my original independent missing-asset reproducer also passes against this head.

The CodeQL explanation correctly distinguishes the reversible client-side wrapper from HTTPS transport protection and server-side bcrypt. All current GitHub checks are green.

No remaining blockers from my review. No device deployment or reboot was performed. When integrating into the prepared release, retain USB NCM Internet sharing and separate image/application versions, and rebuild the combined server/web artifacts and checksums.

@dormancygrace
dormancygrace merged commit dc42a32 into main Oct 7, 2026
13 checks passed
@dormancygrace
dormancygrace deleted the claude/v2.5-settings-perf-trust branch October 7, 2026 17:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants