From bbb504acdc9a564551a4ede58c59771505251e6b Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:18:16 +0530 Subject: [PATCH 01/88] chore(packaging): add validated packaging wrappers and preflight checks Add tools/package to validate goreleaser output (one .deb/.rpm, checksums, artifact paths) and tools/package-check to preflight host capabilities (CGO, PAM headers, systemd-analyze) without assuming a distro package manager. --- package.json | 4 +++- tools/package | 42 +++++++++++++++++++++++++++++++++ tools/package-check | 57 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100755 tools/package create mode 100755 tools/package-check diff --git a/package.json b/package.json index c0d3a67..d5cb7f8 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "dev": "./tools/tako-host watch", "dev:ui": "nx run-many -t dev --projects=backend,dashboard --parallel=2", "build": "nx build backend", - "package": "goreleaser release --snapshot --clean", + "package": "./tools/package", + "package:check": "./tools/package-check", + "test:packaging": "./apps/backend/packaging/scripts/test.sh", "test": "nx run-many -t test,typecheck", "lint": "nx run-many -t lint", "format": "nx run-many -t format", diff --git a/tools/package b/tools/package new file mode 100755 index 0000000..58d4532 --- /dev/null +++ b/tools/package @@ -0,0 +1,42 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" + +./tools/package-check +goreleaser release --snapshot --clean + +deb_count=$(find dist -maxdepth 1 -type f -name '*.deb' | wc -l) +rpm_count=$(find dist -maxdepth 1 -type f -name '*.rpm' | wc -l) +[ "$deb_count" -eq 1 ] || { echo "package: expected one .deb, found $deb_count" >&2; exit 1; } +[ "$rpm_count" -eq 1 ] || { echo "package: expected one .rpm, found $rpm_count" >&2; exit 1; } +[ -s dist/checksums.txt ] || { echo "package: missing checksums.txt" >&2; exit 1; } +(cd dist && sha256sum --check checksums.txt) + +bun -e ' +const artifacts = await Bun.file("dist/artifacts.json").json(); +const packages = artifacts.filter((artifact) => artifact.type === "Linux Package"); +const expectedFormats = new Set(["deb", "rpm"]); +const requiredPaths = [ + "/usr/bin/tako", + "/usr/lib/systemd/system/tako-sessiond.service", + "/usr/lib/systemd/system/tako-sessiond.socket", + "/usr/lib/systemd/system/tako.service", + "/usr/lib/systemd/system/tako.socket", + "/usr/lib/sysusers.d/tako.conf", + "/usr/lib/tmpfiles.d/tako.conf", + "/usr/share/polkit-1/actions/org.velopulent.tako.policy", + "/etc/pam.d/tako", +]; +if (packages.length !== 2) throw new Error(`expected 2 package artifacts, found ${packages.length}`); +for (const artifact of packages) { + if (!expectedFormats.delete(artifact.extra.Format)) throw new Error(`unexpected duplicate format: ${artifact.extra.Format}`); + if (artifact.goos !== "linux" || artifact.goarch !== "amd64") throw new Error(`unexpected target: ${artifact.target}`); + const paths = new Set(artifact.extra.Files.map((file) => file.dst)); + for (const path of requiredPaths) if (!paths.has(path)) throw new Error(`${artifact.extra.Format} missing ${path}`); +} +if (expectedFormats.size) throw new Error(`missing formats: ${[...expectedFormats].join(", ")}`); +' + +echo "Packages written to dist/" diff --git a/tools/package-check b/tools/package-check new file mode 100755 index 0000000..595c8b4 --- /dev/null +++ b/tools/package-check @@ -0,0 +1,57 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" + +fail() { + echo "package check: $*" >&2 + exit 1 +} + +for command_name in bun go goreleaser cc sha256sum systemd-analyze; do + command -v "$command_name" >/dev/null 2>&1 || fail "missing command: $command_name" +done + +[ "$(uname -s)" = Linux ] || fail "packaging supports Linux hosts only" +case "$(uname -m)" in + x86_64|amd64) ;; + *) fail "packaging currently supports amd64 hosts only" ;; +esac +[ "$(go env CGO_ENABLED)" = 1 ] || fail "CGO must be enabled" + +probe_dir=$(mktemp -d) +trap 'rm -rf "$probe_dir"' EXIT HUP INT TERM +if ! printf '%s\n' '#include ' 'int main(void) { return PAM_SUCCESS; }' \ + | cc -x c - -lpam -o "$probe_dir/pam-probe" >/dev/null 2>&1; then + fail "PAM headers or libpam linker files missing; install host PAM development package" +fi + +goreleaser check + +for script in apps/backend/packaging/scripts/deb/*.sh \ + apps/backend/packaging/scripts/rpm/*.sh \ + apps/backend/packaging/smoke-test.sh \ + apps/backend/packaging/lifecycle-test.sh; do + sh -n "$script" +done + +verify_log="$probe_dir/systemd-analyze.log" +if ! systemd-analyze verify \ + apps/backend/packaging/systemd/tako-sessiond.socket \ + apps/backend/packaging/systemd/tako-sessiond.service \ + apps/backend/packaging/systemd/tako.socket \ + apps/backend/packaging/systemd/tako.service 2>"$verify_log"; then + # Restricted build sandboxes can block systemd's userdb credential sockets. + # Ignore only those environment errors; unit diagnostics remain fatal. + unexpected="$probe_dir/systemd-analyze-unexpected.log" + grep -Ev 'Failed to turn off SO_PASSRIGHTS on user lookup socket|Failed to enable SO_PASSCRED on handoff timestamp socket' \ + "$verify_log" >"$unexpected" || true + if [ -s "$unexpected" ]; then + cat "$verify_log" >&2 + fail "systemd unit validation failed" + fi +fi + +apps/backend/packaging/scripts/test.sh +echo "Packaging checks passed" From a91eb880c432426cdf2b73669a4293f9a8025dcb Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:18:18 +0530 Subject: [PATCH 02/88] fix(packaging): correct deb and rpm dependencies and recommendations Debian: add libnss-systemd and init-system-helpers to dependencies; demote packagekit and packagekit-tools to recommends. RPM: demote PackageKit to recommends. Tako can fall back to the native package manager so PackageKit must not be a hard dependency. --- .goreleaser.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1a1df41..d8504d6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -130,12 +130,14 @@ nfpms: deb: dependencies: - systemd + - libnss-systemd - libpam0g - dbus - polkitd + - init-system-helpers + recommends: - packagekit - packagekit-tools - - init-system-helpers scripts: postinstall: apps/backend/packaging/scripts/deb/postinst.sh preremove: apps/backend/packaging/scripts/deb/prerm.sh @@ -146,6 +148,7 @@ nfpms: - pam - dbus - polkit + recommends: - PackageKit scripts: postinstall: apps/backend/packaging/scripts/rpm/post.sh From a765924f248c34b29a834cf4ed2900e583f2ef91 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:18:21 +0530 Subject: [PATCH 03/88] fix(packaging): overhaul systemd lifecycle handling in maintainer scripts Debian postinst now respects DPKG_ROOT and TAKO_SYSTEMD_RUNTIME_DIR, uses deb-systemd-helper unmask/was-enabled/enable/update-state to preserve admin disabled/masked state and repair legacy helper masks, starts tako-sessiond.socket before tako.socket on fresh install and on reinstall via package-removed marker, and on upgrade stops services before sockets, resets failed state and restarts dependency socket first. prerm/postrm stop without masking and only purge clears helper state. RPM post/preun/postun add runtime-dir guards, preserve stopped/disabled/masked/failed socket state and reuse the same ordered restart logic. --- .../backend/packaging/scripts/deb/postinst.sh | 166 +++++++++++++----- apps/backend/packaging/scripts/deb/postrm.sh | 27 +-- apps/backend/packaging/scripts/deb/prerm.sh | 29 ++- apps/backend/packaging/scripts/rpm/post.sh | 109 ++++++++---- apps/backend/packaging/scripts/rpm/postun.sh | 5 +- apps/backend/packaging/scripts/rpm/preun.sh | 11 +- 6 files changed, 225 insertions(+), 122 deletions(-) diff --git a/apps/backend/packaging/scripts/deb/postinst.sh b/apps/backend/packaging/scripts/deb/postinst.sh index ce59e2b..d178b16 100755 --- a/apps/backend/packaging/scripts/deb/postinst.sh +++ b/apps/backend/packaging/scripts/deb/postinst.sh @@ -1,60 +1,134 @@ #!/bin/sh set -eu -systemd-sysusers /usr/lib/sysusers.d/tako.conf -systemd-tmpfiles --create /usr/lib/tmpfiles.d/tako.conf +case "${1:-}" in + configure|abort-upgrade|abort-deconfigure|abort-remove) ;; + *) exit 0 ;; +esac -if command -v deb-systemd-invoke >/dev/null 2>&1; then - deb-systemd-invoke daemon-reload >/dev/null 2>&1 || systemctl daemon-reload >/dev/null 2>&1 || true +systemd_runtime_dir="${TAKO_SYSTEMD_RUNTIME_DIR:-/run/systemd/system}" +default_package_state_dir="${DPKG_ROOT:-}/var/lib/tako" +package_state_dir="${TAKO_PACKAGE_STATE_DIR:-$default_package_state_dir}" +reinstall=false +if [ -f "$package_state_dir/package-removed" ]; then + reinstall=true +fi + +# Remember masks before repairing helper-owned masks. If an older Tako removal +# created one, this installation must behave like a fresh install. +session_socket_was_masked=false +gateway_socket_was_masked=false +if [ -z "${DPKG_ROOT:-}" ] && [ -d "$systemd_runtime_dir" ]; then + [ "$(systemctl is-enabled tako-sessiond.socket 2>/dev/null || true)" = masked ] && session_socket_was_masked=true + [ "$(systemctl is-enabled tako.socket 2>/dev/null || true)" = masked ] && gateway_socket_was_masked=true +fi + +if [ -n "${DPKG_ROOT:-}" ]; then + systemd-sysusers --root="$DPKG_ROOT" tako.conf + systemd-tmpfiles --root="$DPKG_ROOT" --create tako.conf else - systemctl daemon-reload >/dev/null 2>&1 || true -fi - -if [ "${1:-}" = configure ] || [ "${1:-}" = abort-upgrade ] || [ "${1:-}" = abort-deconfigure ] || [ "${1:-}" = abort-remove ]; then - # Always ensure the sockets are enabled (unless masked) and running. - # A remove+reinstall must re-enable (old packages disabled on remove), - # and an upgrade must restart — try-restart would leave a - # stopped-but-enabled socket dead. - for unit in tako.socket tako-sessiond.socket; do - if [ "$(systemctl is-enabled "$unit" 2>/dev/null || true)" != masked ]; then - deb-systemd-helper unmask "$unit" >/dev/null 2>&1 || true - deb-systemd-helper enable "$unit" >/dev/null 2>&1 || true - systemctl enable "$unit" >/dev/null 2>&1 || true - deb-systemd-helper update-state "$unit" >/dev/null 2>&1 || true - fi - done + systemd-sysusers tako.conf + systemd-tmpfiles --create tako.conf +fi - if [ -z "${2:-}" ]; then - _tako_action=start +# Mirror dh_installsystemd state handling. unmask removes masks made by +# deb-systemd-helper (including masks left by older Tako packages), but leaves +# administrator-created systemctl masks alone. +for unit in tako-sessiond.socket tako.socket; do + deb-systemd-helper unmask "$unit" >/dev/null || true + if deb-systemd-helper --quiet was-enabled "$unit"; then + deb-systemd-helper enable "$unit" >/dev/null || true else - _tako_action=restart + deb-systemd-helper update-state "$unit" >/dev/null || true fi - for unit in tako.socket tako-sessiond.socket; do - if [ "$(systemctl is-enabled "$unit" 2>/dev/null || true)" != masked ]; then - if command -v deb-systemd-invoke >/dev/null 2>&1; then - deb-systemd-invoke "$_tako_action" "$unit" >/dev/null 2>&1 || true - else - systemctl "$_tako_action" "$unit" >/dev/null 2>&1 || true - fi - fi - done - # Socket-activated services must never be started by the package; - # only pick up a new binary when already running. - for unit in tako-sessiond.service tako.service; do - if command -v deb-systemd-invoke >/dev/null 2>&1; then - deb-systemd-invoke try-restart "$unit" >/dev/null 2>&1 || true - else - systemctl try-restart "$unit" >/dev/null 2>&1 || true +done + +if [ -n "${DPKG_ROOT:-}" ] || [ ! -d "$systemd_runtime_dir" ]; then + exit 0 +fi + +run_systemctl() { + if ! systemctl "$@" >/dev/null 2>&1; then + echo "tako: warning: systemctl $* failed" >&2 + fi +} + +run_invoke() { + if ! deb-systemd-invoke "$@" >/dev/null 2>&1; then + echo "tako: warning: deb-systemd-invoke $* failed" >&2 + fi +} + +is_active() { + systemctl is-active --quiet "$1" +} + +is_failed() { + systemctl is-failed --quiet "$1" +} + +is_masked() { + [ "$(systemctl is-enabled "$1" 2>/dev/null || true)" = masked ] +} + +if [ "$session_socket_was_masked" = true ] && ! is_masked tako-sessiond.socket; then + reinstall=true +fi +if [ "$gateway_socket_was_masked" = true ] && ! is_masked tako.socket; then + reinstall=true +fi + +if [ -z "${2:-}" ] || [ "$reinstall" = true ]; then + run_systemctl daemon-reload + run_systemctl reset-failed tako-sessiond.socket tako.socket tako-sessiond.service tako.service + for unit in tako-sessiond.socket tako.socket; do + if ! is_masked "$unit"; then + run_invoke start "$unit" fi done + rm -f "$package_state_dir/package-removed" + exit 0 fi -# The gateway and sessiond are socket-activated and must never be -# boot-enabled; only the sockets carry an [Install] section. -for unit in tako.service tako-sessiond.service; do - if [ "$(systemctl is-enabled "$unit" 2>/dev/null || true)" != masked ]; then - deb-systemd-helper disable "$unit" >/dev/null 2>&1 || true - fi -done +# Preserve runtime state across upgrades. Stop services before touching their +# listening sockets, bring the private dependency socket back first, then +# restore only processes that were active before the upgrade. +session_socket_active=false +gateway_socket_active=false +session_service_active=false +gateway_service_active=false +session_socket_failed=false +gateway_socket_failed=false +is_active tako-sessiond.socket && session_socket_active=true +is_active tako.socket && gateway_socket_active=true +is_active tako-sessiond.service && session_service_active=true +is_active tako.service && gateway_service_active=true +is_failed tako-sessiond.socket && session_socket_failed=true +is_failed tako.socket && gateway_socket_failed=true + +if [ "$gateway_service_active" = true ]; then + run_invoke stop tako.service +fi +if [ "$session_service_active" = true ]; then + run_invoke stop tako-sessiond.service +fi + +run_systemctl daemon-reload +run_systemctl reset-failed tako-sessiond.socket tako.socket tako-sessiond.service tako.service + +if { [ "$session_socket_active" = true ] || [ "$session_socket_failed" = true ]; } && ! is_masked tako-sessiond.socket; then + run_invoke restart tako-sessiond.socket +fi +if { [ "$gateway_socket_active" = true ] || [ "$gateway_socket_failed" = true ]; } && ! is_masked tako.socket; then + run_invoke restart tako.socket +fi +if [ "$session_service_active" = true ]; then + run_invoke start tako-sessiond.service +fi +if [ "$gateway_service_active" = true ]; then + run_invoke start tako.service +fi + +rm -f "$package_state_dir/package-removed" exit 0 diff --git a/apps/backend/packaging/scripts/deb/postrm.sh b/apps/backend/packaging/scripts/deb/postrm.sh index 75cd10e..f213b4e 100755 --- a/apps/backend/packaging/scripts/deb/postrm.sh +++ b/apps/backend/packaging/scripts/deb/postrm.sh @@ -1,25 +1,16 @@ #!/bin/sh set -eu -if command -v deb-systemd-invoke >/dev/null 2>&1; then - deb-systemd-invoke daemon-reload >/dev/null 2>&1 || systemctl daemon-reload >/dev/null 2>&1 || true -else - systemctl daemon-reload >/dev/null 2>&1 || true +if [ "${1:-}" = purge ]; then + deb-systemd-helper purge tako-sessiond.socket tako.socket tako-sessiond.service tako.service >/dev/null || true + default_package_state_dir="${DPKG_ROOT:-}/var/lib/tako" + package_state_dir="${TAKO_PACKAGE_STATE_DIR:-$default_package_state_dir}" + rm -f "$package_state_dir/package-removed" fi -case "${1:-}" in - remove) - # Keep the prerm mask so a reinstall unmasks cleanly. - for unit in tako.socket tako-sessiond.socket; do - deb-systemd-helper mask "$unit" >/dev/null 2>&1 || true - done - ;; - purge|disappear) - for unit in tako.socket tako-sessiond.socket tako.service tako-sessiond.service; do - deb-systemd-helper purge "$unit" >/dev/null 2>&1 || true - deb-systemd-helper unmask "$unit" >/dev/null 2>&1 || true - done - ;; -esac +systemd_runtime_dir="${TAKO_SYSTEMD_RUNTIME_DIR:-/run/systemd/system}" +if [ -z "${DPKG_ROOT:-}" ] && [ -d "$systemd_runtime_dir" ]; then + systemctl daemon-reload >/dev/null 2>&1 || true +fi exit 0 diff --git a/apps/backend/packaging/scripts/deb/prerm.sh b/apps/backend/packaging/scripts/deb/prerm.sh index dbf6f2a..a8c0db6 100755 --- a/apps/backend/packaging/scripts/deb/prerm.sh +++ b/apps/backend/packaging/scripts/deb/prerm.sh @@ -1,21 +1,18 @@ #!/bin/sh set -eu -case "${1:-}" in - remove) - for unit in tako.service tako-sessiond.service tako.socket tako-sessiond.socket; do - deb-systemd-invoke stop "$unit" >/dev/null 2>&1 || true - done - # Mask (not disable) so a later reinstall restores the previous - # enabled state via postinst unmask+enable. Plain disable would - # destroy it and reinstalls would stay disabled. - for unit in tako.socket tako-sessiond.socket; do - deb-systemd-helper mask "$unit" >/dev/null 2>&1 || true - done - ;; - upgrade|deconfigure|failed-upgrade) - # restart-after-upgrade: leave units running; postinst restarts them. - ;; -esac +systemd_runtime_dir="${TAKO_SYSTEMD_RUNTIME_DIR:-/run/systemd/system}" +if [ "${1:-}" = remove ] && [ -z "${DPKG_ROOT:-}" ] && [ -d "$systemd_runtime_dir" ]; then + deb-systemd-invoke stop tako.service tako-sessiond.service tako.socket tako-sessiond.socket >/dev/null || true +fi +if [ "${1:-}" = remove ]; then + default_package_state_dir="${DPKG_ROOT:-}/var/lib/tako" + package_state_dir="${TAKO_PACKAGE_STATE_DIR:-$default_package_state_dir}" + mkdir -p "$package_state_dir" + : >"$package_state_dir/package-removed" +fi + +# Never mask or disable units here. Removal keeps deb-systemd-helper state so +# reinstall can restore package-managed enablement; purge clears that state. exit 0 diff --git a/apps/backend/packaging/scripts/rpm/post.sh b/apps/backend/packaging/scripts/rpm/post.sh index 0393f3d..897576e 100755 --- a/apps/backend/packaging/scripts/rpm/post.sh +++ b/apps/backend/packaging/scripts/rpm/post.sh @@ -1,46 +1,87 @@ #!/bin/sh set -eu -systemd-sysusers /usr/lib/sysusers.d/tako.conf -systemd-tmpfiles --create /usr/lib/tmpfiles.d/tako.conf -systemctl daemon-reload >/dev/null 2>&1 || true - -# Always ensure the sockets are enabled (unless masked). A reinstall must -# re-enable and an upgrade must not leave a stopped-but-enabled socket dead. -for unit in tako.socket tako-sessiond.socket; do - if [ "$(systemctl is-enabled "$unit" 2>/dev/null || true)" != masked ]; then - systemctl enable "$unit" >/dev/null 2>&1 || true +systemd-sysusers tako.conf +systemd-tmpfiles --create tako.conf + +systemd_runtime_dir="${TAKO_SYSTEMD_RUNTIME_DIR:-/run/systemd/system}" +if [ ! -d "$systemd_runtime_dir" ]; then + exit 0 +fi + +run_systemctl() { + if ! systemctl "$@" >/dev/null 2>&1; then + echo "tako: warning: systemctl $* failed" >&2 fi -done +} -if [ "${1:-0}" -eq 1 ]; then - for unit in tako.socket tako-sessiond.socket; do - if [ "$(systemctl is-enabled "$unit" 2>/dev/null || true)" != masked ]; then - systemctl start "$unit" >/dev/null 2>&1 || true - fi - done -else - # Upgrade: restart sockets so they pick up the new binary and start - # listening again even if stopped; only try-restart services so the - # socket-activated gateway is never started directly. - for unit in tako.socket tako-sessiond.socket; do - if [ "$(systemctl is-enabled "$unit" 2>/dev/null || true)" != masked ]; then - systemctl restart "$unit" >/dev/null 2>&1 || systemctl start "$unit" >/dev/null 2>&1 || true - fi - done - for unit in tako-sessiond.service tako.service; do - if systemctl is-active --quiet "$unit"; then - systemctl try-restart "$unit" >/dev/null 2>&1 || true +is_active() { + systemctl is-active --quiet "$1" +} + +is_failed() { + systemctl is-failed --quiet "$1" +} + +is_masked() { + [ "$(systemctl is-enabled "$1" 2>/dev/null || true)" = masked ] +} + +install_count="${1:-0}" +case "$install_count" in + ''|*[!0-9]*) install_count=0 ;; +esac + +if [ "$install_count" -eq 1 ]; then + run_systemctl daemon-reload + run_systemctl reset-failed tako-sessiond.socket tako.socket tako-sessiond.service tako.service + for unit in tako-sessiond.socket tako.socket; do + if ! is_masked "$unit"; then + run_systemctl enable "$unit" + run_systemctl start "$unit" fi done + exit 0 fi -# The gateway and sessiond are socket-activated and must never be -# boot-enabled; only the sockets carry an [Install] section. -for unit in tako.service tako-sessiond.service; do - if [ "$(systemctl is-enabled "$unit" 2>/dev/null || true)" != masked ]; then - systemctl disable "$unit" >/dev/null 2>&1 || true +# RPM passes a value greater than one on upgrade. Preserve enablement and +# runtime state; do not turn a deliberately stopped or disabled socket on. +if [ "$install_count" -gt 1 ]; then + session_socket_active=false + gateway_socket_active=false + session_service_active=false + gateway_service_active=false + session_socket_failed=false + gateway_socket_failed=false + is_active tako-sessiond.socket && session_socket_active=true + is_active tako.socket && gateway_socket_active=true + is_active tako-sessiond.service && session_service_active=true + is_active tako.service && gateway_service_active=true + is_failed tako-sessiond.socket && session_socket_failed=true + is_failed tako.socket && gateway_socket_failed=true + + if [ "$gateway_service_active" = true ]; then + run_systemctl stop tako.service + fi + if [ "$session_service_active" = true ]; then + run_systemctl stop tako-sessiond.service + fi + + run_systemctl daemon-reload + run_systemctl reset-failed tako-sessiond.socket tako.socket tako-sessiond.service tako.service + + if { [ "$session_socket_active" = true ] || [ "$session_socket_failed" = true ]; } && ! is_masked tako-sessiond.socket; then + run_systemctl restart tako-sessiond.socket + fi + if { [ "$gateway_socket_active" = true ] || [ "$gateway_socket_failed" = true ]; } && ! is_masked tako.socket; then + run_systemctl restart tako.socket + fi + if [ "$session_service_active" = true ]; then + run_systemctl start tako-sessiond.service fi -done + if [ "$gateway_service_active" = true ]; then + run_systemctl start tako.service + fi +fi exit 0 diff --git a/apps/backend/packaging/scripts/rpm/postun.sh b/apps/backend/packaging/scripts/rpm/postun.sh index 9e77083..8353ed6 100755 --- a/apps/backend/packaging/scripts/rpm/postun.sh +++ b/apps/backend/packaging/scripts/rpm/postun.sh @@ -1,6 +1,9 @@ #!/bin/sh set -eu -systemctl daemon-reload >/dev/null 2>&1 || true +systemd_runtime_dir="${TAKO_SYSTEMD_RUNTIME_DIR:-/run/systemd/system}" +if [ -d "$systemd_runtime_dir" ]; then + systemctl daemon-reload >/dev/null 2>&1 || true +fi exit 0 diff --git a/apps/backend/packaging/scripts/rpm/preun.sh b/apps/backend/packaging/scripts/rpm/preun.sh index 428a2f2..b4ed3d6 100755 --- a/apps/backend/packaging/scripts/rpm/preun.sh +++ b/apps/backend/packaging/scripts/rpm/preun.sh @@ -1,13 +1,10 @@ #!/bin/sh set -eu -if [ "${1:-0}" -eq 0 ]; then - for unit in tako.service tako-sessiond.service tako.socket tako-sessiond.socket; do - systemctl stop "$unit" >/dev/null 2>&1 || true - done - for unit in tako.service tako.socket tako-sessiond.socket; do - systemctl disable "$unit" >/dev/null 2>&1 || true - done +systemd_runtime_dir="${TAKO_SYSTEMD_RUNTIME_DIR:-/run/systemd/system}" +if [ "${1:-0}" -eq 0 ] && [ -d "$systemd_runtime_dir" ]; then + systemctl stop tako.service tako-sessiond.service tako.socket tako-sessiond.socket >/dev/null 2>&1 || true + systemctl disable tako-sessiond.socket tako.socket >/dev/null 2>&1 || true fi exit 0 From 24e2aa852ad8a807fe588a5152b2b989c9146b5b Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:18:22 +0530 Subject: [PATCH 04/88] test(packaging): add maintainer script unit and vm lifecycle harnesses Add scripts/test.sh mock harness for deb/rpm postinst/prerm/postrm covering fresh install, disabled was-enabled, active/failed upgrade, admin masked preservation and offline roots. Add lifecycle-test.sh disposable-VM harness (TAKO_DISPOSABLE_HOST=1) exercising fresh install, remove/reinstall, active upgrade with trigger-limit check, disabled/stopped and masked preservation, and purge. --- apps/backend/packaging/lifecycle-test.sh | 123 ++++++++++++ apps/backend/packaging/scripts/test.sh | 229 +++++++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100755 apps/backend/packaging/lifecycle-test.sh create mode 100755 apps/backend/packaging/scripts/test.sh diff --git a/apps/backend/packaging/lifecycle-test.sh b/apps/backend/packaging/lifecycle-test.sh new file mode 100755 index 0000000..91d490c --- /dev/null +++ b/apps/backend/packaging/lifecycle-test.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${TAKO_DISPOSABLE_HOST:-}" != 1 ]]; then + echo "Refusing to modify this host. Run only in a disposable VM with TAKO_DISPOSABLE_HOST=1." >&2 + exit 2 +fi +if [[ "$(id -u)" -ne 0 ]]; then + echo "Run as root inside the disposable VM." >&2 + exit 2 +fi +if [[ $# -ne 2 ]]; then + echo "usage: TAKO_DISPOSABLE_HOST=1 $0 OLD_PACKAGE NEW_PACKAGE" >&2 + exit 2 +fi + +old_package=$(realpath "$1") +new_package=$(realpath "$2") +units=(tako-sessiond.socket tako.socket tako-sessiond.service tako.service) + +case "$new_package" in + *.deb) + [[ "$old_package" == *.deb ]] || { echo "Both packages must be .deb files." >&2; exit 2; } + install_package() { dpkg --install "$1"; } + remove_package() { dpkg --remove tako; } + purge_package() { dpkg --purge tako >/dev/null 2>&1 || true; } + ;; + *.rpm) + [[ "$old_package" == *.rpm ]] || { echo "Both packages must be .rpm files." >&2; exit 2; } + install_package() { rpm --upgrade --verbose --hash "$1"; } + remove_package() { rpm --erase tako; } + purge_package() { rpm --erase tako >/dev/null 2>&1 || true; } + ;; + *) + echo "NEW_PACKAGE must end in .deb or .rpm." >&2 + exit 2 + ;; +esac + +reset_host() { + purge_package + systemctl unmask "${units[@]}" >/dev/null 2>&1 || true + systemctl reset-failed "${units[@]}" >/dev/null 2>&1 || true +} + +assert_socket_ready() { + local unit=$1 + systemctl is-enabled --quiet "$unit" + systemctl is-active --quiet "$unit" + ! systemctl is-failed --quiet "$unit" +} + +assert_default_install() { + assert_socket_ready tako-sessiond.socket + assert_socket_ready tako.socket + ! systemctl is-active --quiet tako-sessiond.service + ! systemctl is-active --quiet tako.service +} + +activate_services() { + local status + status=$(curl --silent --show-error --insecure --output /dev/null --write-out '%{http_code}' \ + https://127.0.0.1:9090/api/v1/auth/session) + [[ "$status" == 401 ]] + python3 -c 'import socket; client=socket.socket(socket.AF_UNIX); client.connect("/run/tako/session.sock"); client.close()' + systemctl is-active --quiet tako.service + systemctl is-active --quiet tako-sessiond.service +} + +assert_no_package_mask() { + local unit target + for unit in tako-sessiond.socket tako.socket; do + target=$(readlink -f "/etc/systemd/system/$unit" 2>/dev/null || true) + [[ "$target" != /dev/null ]] || { echo "$unit left masked after removal" >&2; exit 1; } + done +} + +trap reset_host EXIT + +# Fresh install and remove/reinstall must need no manual enable --now. +reset_host +install_package "$new_package" +assert_default_install +activate_services +remove_package +assert_no_package_mask +install_package "$new_package" +assert_default_install + +# Upgrade running services without leaving either socket failed. +reset_host +install_package "$old_package" +activate_services +journal_cursor=$(journalctl -u tako.socket -u tako-sessiond.socket -n 0 --show-cursor --no-pager \ + | sed -n 's/^-- cursor: //p') +install_package "$new_package" +assert_socket_ready tako-sessiond.socket +assert_socket_ready tako.socket +activate_services +if [[ -n "$journal_cursor" ]]; then + ! journalctl -u tako.socket -u tako-sessiond.socket --after-cursor "$journal_cursor" --no-pager \ + | grep -Fq 'Trigger limit hit' +fi + +# Upgrade must preserve administrator-stopped and disabled sockets. +reset_host +install_package "$old_package" +systemctl disable --now tako.socket tako-sessiond.socket +install_package "$new_package" +! systemctl is-active --quiet tako.socket +! systemctl is-active --quiet tako-sessiond.socket +[[ "$(systemctl is-enabled tako.socket 2>/dev/null || true)" == disabled ]] +[[ "$(systemctl is-enabled tako-sessiond.socket 2>/dev/null || true)" == disabled ]] + +# Upgrade must preserve a real administrator mask. +reset_host +install_package "$old_package" +systemctl mask --now tako.socket +install_package "$new_package" +[[ "$(systemctl is-enabled tako.socket 2>/dev/null || true)" == masked ]] + +reset_host +echo "Package lifecycle tests passed" diff --git a/apps/backend/packaging/scripts/test.sh b/apps/backend/packaging/scripts/test.sh new file mode 100755 index 0000000..c5a6a38 --- /dev/null +++ b/apps/backend/packaging/scripts/test.sh @@ -0,0 +1,229 @@ +#!/bin/sh +set -eu + +scripts_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT HUP INT TERM +mock_bin="$work/bin" +runtime_dir="$work/systemd" +state_dir="$work/state" +log="$work/calls.log" +mkdir -p "$mock_bin" "$runtime_dir" "$state_dir" + +cat >"$mock_bin/mock" <<'EOF' +#!/bin/sh +set -eu +name=$(basename "$0") +printf '%s %s\n' "$name" "$*" >>"$MOCK_LOG" + +has_unit() { + list=" $1 " + case "$list" in *" $2 "*) return 0 ;; *) return 1 ;; esac +} + +case "$name:$1" in + systemctl:is-active) + unit=${3:-} + if has_unit "${MOCK_ACTIVE_UNITS:-}" "$unit"; then + exit 0 + fi + exit 1 + ;; + systemctl:is-enabled) + unit=${2:-} + if has_unit "${MOCK_ADMIN_MASKED_UNITS:-}" "$unit" || [ -f "$MOCK_STATE_DIR/legacy-$unit" ]; then + echo masked + exit 1 + fi + if has_unit "${MOCK_DISABLED_UNITS:-}" "$unit"; then + echo disabled + exit 1 + fi + echo enabled + ;; + systemctl:is-failed) + unit=${3:-} + if has_unit "${MOCK_FAILED_UNITS:-}" "$unit"; then + exit 0 + fi + exit 1 + ;; + deb-systemd-helper:unmask) + rm -f "$MOCK_STATE_DIR/legacy-${2:-}" + ;; + deb-systemd-helper:--quiet) + unit=${3:-} + if has_unit "${MOCK_WAS_DISABLED_UNITS:-}" "$unit"; then + exit 1 + fi + exit 0 + ;; +esac +exit 0 +EOF +chmod +x "$mock_bin/mock" +for name in systemctl deb-systemd-helper deb-systemd-invoke systemd-sysusers systemd-tmpfiles; do + ln -s mock "$mock_bin/$name" +done + +run_script() { + : >"$log" + PATH="$mock_bin:$PATH" \ + MOCK_LOG="$log" \ + MOCK_STATE_DIR="$state_dir" \ + TAKO_SYSTEMD_RUNTIME_DIR="$runtime_dir" \ + MOCK_ACTIVE_UNITS="${MOCK_ACTIVE_UNITS:-}" \ + MOCK_ADMIN_MASKED_UNITS="${MOCK_ADMIN_MASKED_UNITS:-}" \ + MOCK_DISABLED_UNITS="${MOCK_DISABLED_UNITS:-}" \ + MOCK_WAS_DISABLED_UNITS="${MOCK_WAS_DISABLED_UNITS:-}" \ + MOCK_FAILED_UNITS="${MOCK_FAILED_UNITS:-}" \ + TAKO_PACKAGE_STATE_DIR="$state_dir" \ + DPKG_ROOT="${DPKG_ROOT:-}" \ + sh "$@" +} + +assert_has() { + grep -Fqx "$1" "$log" || { echo "missing call: $1" >&2; cat "$log" >&2; exit 1; } +} + +assert_lacks() { + if grep -Fq "$1" "$log"; then + echo "unexpected call containing: $1" >&2 + cat "$log" >&2 + exit 1 + fi +} + +assert_before() { + first=$(grep -nF "$1" "$log" | head -1 | cut -d: -f1) + second=$(grep -nF "$2" "$log" | head -1 | cut -d: -f1) + [ -n "$first" ] && [ -n "$second" ] && [ "$first" -lt "$second" ] || { + echo "wrong call order: $1 before $2" >&2 + cat "$log" >&2 + exit 1 + } +} + +deb_postinst="$scripts_dir/deb/postinst.sh" +deb_prerm="$scripts_dir/deb/prerm.sh" +deb_postrm="$scripts_dir/deb/postrm.sh" +rpm_post="$scripts_dir/rpm/post.sh" +rpm_preun="$scripts_dir/rpm/preun.sh" +rpm_postun="$scripts_dir/rpm/postun.sh" + +# Debian fresh install: repair old helper mask, enable through helper state, +# then start private socket before public socket. +touch "$state_dir/legacy-tako.socket" "$state_dir/legacy-tako-sessiond.socket" +run_script "$deb_postinst" configure +assert_before "deb-systemd-helper unmask tako-sessiond.socket" "deb-systemd-helper --quiet was-enabled tako-sessiond.socket" +assert_has "deb-systemd-helper enable tako-sessiond.socket" +assert_before "deb-systemd-invoke start tako-sessiond.socket" "deb-systemd-invoke start tako.socket" +assert_lacks " mask " + +touch "$state_dir/legacy-tako.socket" "$state_dir/legacy-tako-sessiond.socket" +run_script "$deb_postinst" configure 0.9.0 +assert_has "deb-systemd-invoke start tako-sessiond.socket" +assert_has "deb-systemd-invoke start tako.socket" + +# Disabled helper state is updated, not re-enabled. +MOCK_WAS_DISABLED_UNITS="tako.socket" run_script "$deb_postinst" configure +assert_has "deb-systemd-helper update-state tako.socket" +assert_lacks "deb-systemd-helper enable tako.socket" + +# Upgrade: stop live services first, clear failures, restart dependency socket +# first, and restore only services that had been active. +MOCK_ACTIVE_UNITS="tako-sessiond.socket tako.socket tako-sessiond.service tako.service" \ + run_script "$deb_postinst" configure 1.0.0 +assert_before "deb-systemd-invoke stop tako.service" "deb-systemd-invoke restart tako-sessiond.socket" +assert_before "systemctl reset-failed" "deb-systemd-invoke restart tako-sessiond.socket" +assert_before "deb-systemd-invoke restart tako-sessiond.socket" "deb-systemd-invoke restart tako.socket" +assert_has "deb-systemd-invoke start tako-sessiond.service" +assert_has "deb-systemd-invoke start tako.service" + +# Stopped upgrade stays stopped; administrator mask survives helper unmask. +run_script "$deb_postinst" configure 1.0.0 +assert_lacks "deb-systemd-invoke restart" +assert_lacks "deb-systemd-invoke start" +MOCK_ACTIVE_UNITS="tako-sessiond.socket tako.socket" MOCK_ADMIN_MASKED_UNITS="tako.socket" \ + run_script "$deb_postinst" configure 1.0.0 +assert_has "deb-systemd-invoke restart tako-sessiond.socket" +assert_lacks "deb-systemd-invoke restart tako.socket" + +# Failed sockets are recovered even though inactive; stopped healthy sockets +# remain stopped. Removal marker makes remove/reinstall start like fresh. +MOCK_FAILED_UNITS="tako.socket" run_script "$deb_postinst" configure 1.0.0 +assert_has "deb-systemd-invoke restart tako.socket" +run_script "$deb_prerm" remove +assert_has "deb-systemd-invoke stop tako.service tako-sessiond.service tako.socket tako-sessiond.socket" +[ -f "$state_dir/package-removed" ] || { echo "removal marker missing" >&2; exit 1; } +run_script "$deb_postinst" configure 1.0.0 +assert_has "deb-systemd-invoke start tako-sessiond.socket" +assert_has "deb-systemd-invoke start tako.socket" +[ ! -f "$state_dir/package-removed" ] || { echo "removal marker not cleared" >&2; exit 1; } + +# Offline roots create users/directories in target but never contact PID 1. +DPKG_ROOT="$work/root" run_script "$deb_postinst" configure +assert_has "systemd-sysusers --root=$work/root tako.conf" +assert_has "systemd-tmpfiles --root=$work/root --create tako.conf" +assert_lacks "systemctl " +assert_lacks "deb-systemd-invoke " + +# All documented Debian recovery entry points run; unrelated calls are no-ops. +for action in configure abort-upgrade abort-deconfigure abort-remove; do + run_script "$deb_postinst" "$action" 1.0.0 + assert_has "deb-systemd-helper unmask tako-sessiond.socket" +done +run_script "$deb_postinst" triggered +[ ! -s "$log" ] || { cat "$log" >&2; exit 1; } + +for action in upgrade deconfigure failed-upgrade; do + run_script "$deb_prerm" "$action" + assert_lacks "deb-systemd-invoke stop" +done +run_script "$deb_prerm" remove +assert_has "deb-systemd-invoke stop tako.service tako-sessiond.service tako.socket tako-sessiond.socket" +assert_lacks "mask" +run_script "$deb_postrm" remove +assert_lacks "deb-systemd-helper purge" +run_script "$deb_postrm" purge +assert_has "deb-systemd-helper purge tako-sessiond.socket tako.socket tako-sessiond.service tako.service" +[ ! -f "$state_dir/package-removed" ] || { echo "purge left removal marker" >&2; exit 1; } +assert_lacks "mask" + +# RPM fresh install and upgrade use same safe ordering and preserve stopped or +# masked runtime state. +run_script "$rpm_post" 1 +assert_before "systemctl start tako-sessiond.socket" "systemctl start tako.socket" +assert_has "systemctl enable tako-sessiond.socket" +assert_has "systemctl enable tako.socket" + +MOCK_ACTIVE_UNITS="tako-sessiond.socket tako.socket tako-sessiond.service tako.service" run_script "$rpm_post" 2 +assert_before "systemctl stop tako.service" "systemctl restart tako-sessiond.socket" +assert_before "systemctl reset-failed" "systemctl restart tako-sessiond.socket" +assert_before "systemctl restart tako-sessiond.socket" "systemctl restart tako.socket" +assert_lacks "systemctl enable" +assert_has "systemctl start tako-sessiond.service" +assert_has "systemctl start tako.service" + +run_script "$rpm_post" 3 +assert_lacks "systemctl restart" +assert_lacks "systemctl start" +assert_lacks "systemctl enable" +MOCK_ACTIVE_UNITS="tako-sessiond.socket tako.socket" MOCK_ADMIN_MASKED_UNITS="tako.socket" run_script "$rpm_post" 2 +assert_has "systemctl restart tako-sessiond.socket" +assert_lacks "systemctl restart tako.socket" +MOCK_FAILED_UNITS="tako.socket" run_script "$rpm_post" 2 +assert_has "systemctl restart tako.socket" + +run_script "$rpm_preun" 0 +assert_has "systemctl stop tako.service tako-sessiond.service tako.socket tako-sessiond.socket" +assert_has "systemctl disable tako-sessiond.socket tako.socket" +assert_lacks "mask" +for count in 1 2; do + run_script "$rpm_preun" "$count" + [ ! -s "$log" ] || { cat "$log" >&2; exit 1; } +done +run_script "$rpm_postun" 0 +assert_has "systemctl daemon-reload" + +echo "Maintainer script tests passed" From c8098fde19ee474ce7b3c64d0bbe1df4682437a2 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:18:24 +0530 Subject: [PATCH 05/88] fix(packaging): harden smoke test for static units and failed state Verify socket enablement plus static UnitFileState for gateway and session services, leave sockets running after stopping services, and fail if either socket enters failed state. --- apps/backend/packaging/smoke-test.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/backend/packaging/smoke-test.sh b/apps/backend/packaging/smoke-test.sh index 71ed1c7..c7285ee 100755 --- a/apps/backend/packaging/smoke-test.sh +++ b/apps/backend/packaging/smoke-test.sh @@ -11,9 +11,12 @@ session_service_unit="${TAKO_SESSION_SERVICE_UNIT:-tako-sessiond.service}" base_url="${TAKO_BASE_URL:-https://127.0.0.1:9090}" test -x "$binary" -systemctl is-enabled "$gateway_unit" >/dev/null systemctl is-enabled "$gateway_socket_unit" >/dev/null systemctl is-enabled "$session_socket_unit" >/dev/null +gateway_unit_state="$(systemctl show -p UnitFileState --value "$gateway_unit")" +session_unit_state="$(systemctl show -p UnitFileState --value "$session_service_unit")" +test "$gateway_unit_state" = static +test "$session_unit_state" = static systemctl is-active "$gateway_socket_unit" >/dev/null systemctl is-active "$session_socket_unit" >/dev/null if systemctl is-active --quiet "$gateway_unit"; then @@ -67,5 +70,9 @@ fi systemctl stop "$gateway_unit" "$session_service_unit" systemctl is-active "$gateway_socket_unit" >/dev/null systemctl is-active "$session_socket_unit" >/dev/null +if systemctl is-failed --quiet "$gateway_socket_unit" "$session_socket_unit"; then + echo "Tako socket entered failed state" >&2 + exit 1 +fi echo "Tako packaging smoke test passed" From 544c06c6ba309c3bd18e632c3d7e1787af555d7d Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:18:27 +0530 Subject: [PATCH 06/88] docs(packaging): document packaging requirements and lifecycle behavior Clarify C compiler/PAM linker requirements, package:check preflight, nFPM cross-format builds without dpkg-deb/rpmbuild, glibc compatibility caveat, demoted PackageKit recommends including libnss-systemd/init-system-helpers, socket activation ordering, disabled/masked and failed-state preservation via deb-systemd-helper/policy-rc.d, and updated smoke/lifecycle test seams. --- apps/backend/packaging/README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/backend/packaging/README.md b/apps/backend/packaging/README.md index 883c27f..c9c5ca8 100644 --- a/apps/backend/packaging/README.md +++ b/apps/backend/packaging/README.md @@ -29,9 +29,9 @@ Password changes and administrative resets use the host's standard `passwd` PAM ## GoReleaser packages -Local packaging requires installed Bun dependencies, Go 1.26+, GoReleaser 2.x, and Linux amd64 PAM development headers and compiler support. CGO is required for PAM, so arm64 cross-builds remain deferred until an AArch64 PAM toolchain is available. +Local packaging requires installed Bun dependencies, Go 1.26+, GoReleaser 2.x, a C compiler, and Linux amd64 PAM development headers and linker files. `bun run package:check` validates those host-neutral capabilities without assuming a distribution package manager. CGO is required for PAM, so arm64 cross-builds remain deferred until an AArch64 PAM toolchain is available. -Run `bun run package` from repository root to build local snapshot packages in `dist/`. GoReleaser first builds dashboard assets, then builds `tako` with CGO for Linux amd64 and emits one `.deb`, one `.rpm`, and `checksums.txt`. Snapshot packages do not publish releases. Tagged release builds take version from the Git tag. No signing, archive, or GitHub workflow is configured. +Run `bun run package` from repository root to validate packaging, build dashboard assets, build `tako` with CGO for Linux amd64, and emit exactly one `.deb`, one `.rpm`, and `checksums.txt` in `dist/`. GoReleaser's embedded nFPM packager creates both formats on Debian, Fedora, or Arch; building does not require `dpkg-deb`, `rpmbuild`, or `rpm`. Snapshot packages do not publish releases. Tagged release builds take version from the Git tag. No signing, archive, container, or GitHub workflow is configured. Because the executable is a native CGO build, its glibc and PAM compatibility follows the build host; cross-format output alone does not guarantee compatibility with older target libraries. Generated packages install full production runtime files: executable, four systemd units, sysusers and tmpfiles definitions, Polkit policy, the example TOML configuration under `/usr/share/doc/tako/`, read-only distro branding under `/usr/share/tako/branding/`, and a distribution-specific PAM stack at `/etc/pam.d/tako`. Debian includes Debian and Ubuntu art; RPM includes AlmaLinux, Fedora, RHEL, openSUSE, and Rocky Linux art. Arch Linux art is source-ready for host development or a future Arch package. Debian uses `pam/tako.debian`; RPM uses `pam/tako.redhat`. PAM files are package-managed as `config|noreplace`. Packages do not install a live `/etc/tako/config.toml`, sudoers example, smoke test, or operational README. @@ -46,9 +46,11 @@ from `/usr/share/tako/branding/` through the gateway's fixed `/branding/*.png` route. Production metadata revalidates with an ETag, while the selected release-versioned image URL is immutable-cacheable. -Package installation requires systemd, PAM, D-Bus, Polkit, and PackageKit. Debian packages additionally require `packagekit-tools` for `pkcon` and `init-system-helpers` for Debian systemd maintainer helpers. NetworkManager, UDisks2, Netplan, UFW/firewalld, SELinux, AppArmor, and other host-specific integrations remain optional; capability degradation is reported explicitly. Package scripts create Tako users and runtime directories, reload systemd, and manage only Tako units. They never start or enable external D-Bus, Polkit, or PackageKit services. Installs and upgrades enable `tako.socket` and `tako-sessiond.socket` unless masked and start (fresh install) or restart (upgrade) them, so a stopped-but-enabled socket never stays dead; service processes otherwise start only on socket traffic and are restarted only when already active. The gateway service is never boot-enabled unless masked. Removal stops and masks Tako sockets so a reinstall restores them; only purge disables and clears helper state. +Package installation requires systemd, PAM, D-Bus, and Polkit. Debian additionally requires `libnss-systemd` for the dynamic gateway identity and `init-system-helpers` for package-managed unit state. PackageKit and `pkcon` are recommendations rather than hard dependencies because Tako can fall back to the host's native package manager with reduced metadata. NetworkManager, UDisks2, Netplan, UFW/firewalld, SELinux, AppArmor, and other host-specific integrations remain optional; capability degradation is reported explicitly. -Removal stops and disables Tako units, reloads systemd, and leaves configuration, state, the `tako-session` group, and administrator drop-ins in place. +Fresh installs enable and start `tako-sessiond.socket` before the public `tako.socket`; services remain socket-activated. Upgrades preserve administrator-disabled, stopped, and explicitly masked units. When units were running, scripts stop services before restarting sockets, reload systemd, clear failed/start-limit state, restore the private socket before the public socket, then restore only services that were previously active. Debian uses `deb-systemd-helper` state and `deb-systemd-invoke`, including offline-root and `policy-rc.d` behavior. Upgrades from older Tako packages remove only masks previously created by `deb-systemd-helper`; administrator masks remain intact. + +Removal stops Tako units and never creates masks. RPM final removal disables the sockets. Debian removal keeps helper enablement state so reinstall restores package-managed defaults; purge clears that helper state. Configuration, application state, the `tako-session` group, and administrator drop-ins remain in place. Install `polkit/org.velopulent.tako.policy` when deploying manually outside generated packages. The optional `sudoers.d/tako.example` documents the exact `NOPASSWD` probe; copy and edit it only for a dedicated local operator group. Do not grant the gateway service account unrestricted sudo. @@ -56,4 +58,6 @@ For direct access, omit `allowed_origin`: Tako accepts same-origin requests base Host-integrated development (`tools/tako-host`, dashboard overlay) is documented in [`HACKING.md`](../../../HACKING.md). -The disposable-VM smoke seam is `packaging/smoke-test.sh`. It checks binary presence, socket activation, service identities, TLS reachability, and (when `TAKO_SMOKE_USER`/`TAKO_SMOKE_PASSWORD` are provided by the VM harness) a real PAM login. `TAKO_SMOKE_USER` must be a **non-root** UNIX account; root-only success hides the user-bridge spawn path. It never changes host state or stores credentials. +The installed-system smoke seam is `packaging/smoke-test.sh`. It checks binary presence, socket activation, static service enablement, service identities, TLS reachability, and (when `TAKO_SMOKE_USER`/`TAKO_SMOKE_PASSWORD` are provided) a real PAM login. `TAKO_SMOKE_USER` must be a **non-root** UNIX account; root-only success hides the user-bridge spawn path. It stops activated service processes at completion while leaving sockets running. + +Run `packaging/lifecycle-test.sh OLD_PACKAGE NEW_PACKAGE` only inside a disposable VM with `TAKO_DISPOSABLE_HOST=1`. It destructively exercises fresh install, active upgrade, disabled/stopped preservation, administrator masks, removal, reinstall, purge, and trigger-limit health for either two `.deb` files or two `.rpm` files. Dependencies must already be available in the VM. From ee029c4774816b59a499e4ae2b70ef5d138d1d71 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:55:18 +0530 Subject: [PATCH 07/88] build: introduce distro-aware Go wrapper Add tools/tako-go.sh to detect TAKO_DISTRO or /etc/os-release and inject the correct -tags value for all Nx Go targets. Update apps/backend/project.json to route go run/build/test/vet through the wrapper. Add distro/updates_tag_required.go guard that fails untagged production builds with a stable identifier. --- .../internal/distro/updates_tag_required.go | 7 +++++ apps/backend/project.json | 10 +++---- tools/tako-go.sh | 27 +++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 apps/backend/internal/distro/updates_tag_required.go create mode 100755 tools/tako-go.sh diff --git a/apps/backend/internal/distro/updates_tag_required.go b/apps/backend/internal/distro/updates_tag_required.go new file mode 100644 index 0000000..b0dd14a --- /dev/null +++ b/apps/backend/internal/distro/updates_tag_required.go @@ -0,0 +1,7 @@ +//go:build !debian && !ubuntu && !fedora && !rhel && !rocky && !almalinux && !archlinux && !opensuse + +package distro + +// This deliberately references an undefined symbol so an untagged production +// build fails with a useful, stable identifier. +var _ = distro_build_tag_required diff --git a/apps/backend/project.json b/apps/backend/project.json index 336d9c8..b2a22d9 100644 --- a/apps/backend/project.json +++ b/apps/backend/project.json @@ -10,7 +10,7 @@ "executor": "nx:run-commands", "continuous": true, "options": { - "command": "go run ./cmd/tako serve --dev", + "command": "../../tools/tako-go.sh run ./cmd/tako serve --dev", "cwd": "apps/backend", "env": { "GOCACHE": "/tmp/tako-go-cache", @@ -28,7 +28,7 @@ "options": { "commands": [ "mkdir -p ../../bin", - "go build -buildvcs=false -trimpath -ldflags='-s -w' -o ../../bin/tako ./cmd/tako" + "../../tools/tako-go.sh build -buildvcs=false -trimpath -ldflags='-s -w' -o ../../bin/tako ./cmd/tako" ], "cwd": "apps/backend", "env": { @@ -43,7 +43,7 @@ "cache": true, "inputs": ["default", "^production"], "options": { - "command": "go test ./...", + "command": "../../tools/tako-go.sh test ./...", "cwd": "apps/backend", "env": { "GOCACHE": "/tmp/tako-go-cache", @@ -56,7 +56,7 @@ "cache": true, "inputs": ["default", "^production"], "options": { - "command": "go test -race ./...", + "command": "../../tools/tako-go.sh test -race ./...", "cwd": "apps/backend", "env": { "GOCACHE": "/tmp/tako-go-cache", @@ -69,7 +69,7 @@ "cache": true, "inputs": ["default", "^production"], "options": { - "command": "go vet ./...", + "command": "../../tools/tako-go.sh vet ./...", "cwd": "apps/backend", "env": { "GOCACHE": "/tmp/tako-go-cache", diff --git a/tools/tako-go.sh b/tools/tako-go.sh new file mode 100755 index 0000000..035c09f --- /dev/null +++ b/tools/tako-go.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +distro=${TAKO_DISTRO:-} +if [ -z "$distro" ]; then + distro=$(sed -n 's/^ID=//p' /etc/os-release | head -n 1 | tr -d '"') +fi + +case "$distro" in + debian|ubuntu|fedora|rhel|rocky|almalinux|archlinux|opensuse) + tag=$distro + ;; + arch) + tag=archlinux + ;; + opensuse-leap|opensuse-tumbleweed|sles) + tag=opensuse + ;; + *) + echo "unsupported TAKO_DISTRO or /etc/os-release ID: $distro" >&2 + exit 2 + ;; +esac + +command=$1 +shift +exec go "$command" -tags "$tag" "$@" From 8f56070898feb55adde66df36c33f95e3bbdffc2 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:55:25 +0530 Subject: [PATCH 08/88] feat(platform)!: replace PackageKit with distro-native update providers Remove PackageKit D-Bus client (packagekit/*), the legacy autoupdates module and the PackageKit live watcher. Introduce the UpdateProvider interface and UpdateService (platform/updates.go) with bounded SSE progress/output replay, ANSI sanitization, plan fingerprinting and risky change detection. Add compile-time providers for apt, dnf, pacman and zypper behind distro build tags (distro/* + updateproviders/*) and rewrite platform tests to cover sanitization, exact-plan validation and stale-plan rejection. Update capabilities detection to probe the injected UpdateService and extend kpatch helper. BREAKING CHANGE: update backend no longer uses PackageKit D-Bus; builds require exactly one distro tag (debian, ubuntu, fedora, rhel, rocky, almalinux, archlinux, opensuse). --- apps/backend/internal/distro/updates_apt.go | 10 + apps/backend/internal/distro/updates_dnf.go | 10 + .../backend/internal/distro/updates_pacman.go | 10 + .../backend/internal/distro/updates_zypper.go | 10 + apps/backend/internal/packagekit/client.go | 113 - .../internal/packagekit/client_test.go | 68 - apps/backend/internal/packagekit/dbus.go | 831 ------- apps/backend/internal/packagekit/watcher.go | 165 -- .../internal/packagekit/watcher_test.go | 57 - apps/backend/internal/platform/autoupdates.go | 551 ----- .../internal/platform/autoupdates_test.go | 208 -- .../backend/internal/platform/capabilities.go | 45 +- .../internal/platform/capabilities_test.go | 23 +- apps/backend/internal/platform/kpatch.go | 10 + apps/backend/internal/platform/updates.go | 2156 +++++------------ .../platform/updates_integration_test.go | 2 +- .../backend/internal/platform/updates_live.go | 138 -- .../backend/internal/platform/updates_test.go | 244 +- .../internal/updateproviders/apt/apt.go | 195 ++ .../internal/updateproviders/apt/apt_test.go | 32 + .../internal/updateproviders/dnf/dnf.go | 206 ++ .../internal/updateproviders/dnf/dnf_test.go | 28 + .../internal/updateproviders/pacman/pacman.go | 110 + .../updateproviders/pacman/pacman_test.go | 14 + .../internal/updateproviders/zypper/zypper.go | 173 ++ .../updateproviders/zypper/zypper_test.go | 27 + 26 files changed, 1595 insertions(+), 3841 deletions(-) create mode 100644 apps/backend/internal/distro/updates_apt.go create mode 100644 apps/backend/internal/distro/updates_dnf.go create mode 100644 apps/backend/internal/distro/updates_pacman.go create mode 100644 apps/backend/internal/distro/updates_zypper.go delete mode 100644 apps/backend/internal/packagekit/client.go delete mode 100644 apps/backend/internal/packagekit/client_test.go delete mode 100644 apps/backend/internal/packagekit/dbus.go delete mode 100644 apps/backend/internal/packagekit/watcher.go delete mode 100644 apps/backend/internal/packagekit/watcher_test.go delete mode 100644 apps/backend/internal/platform/autoupdates.go delete mode 100644 apps/backend/internal/platform/autoupdates_test.go delete mode 100644 apps/backend/internal/platform/updates_live.go create mode 100644 apps/backend/internal/updateproviders/apt/apt.go create mode 100644 apps/backend/internal/updateproviders/apt/apt_test.go create mode 100644 apps/backend/internal/updateproviders/dnf/dnf.go create mode 100644 apps/backend/internal/updateproviders/dnf/dnf_test.go create mode 100644 apps/backend/internal/updateproviders/pacman/pacman.go create mode 100644 apps/backend/internal/updateproviders/pacman/pacman_test.go create mode 100644 apps/backend/internal/updateproviders/zypper/zypper.go create mode 100644 apps/backend/internal/updateproviders/zypper/zypper_test.go diff --git a/apps/backend/internal/distro/updates_apt.go b/apps/backend/internal/distro/updates_apt.go new file mode 100644 index 0000000..af8c95e --- /dev/null +++ b/apps/backend/internal/distro/updates_apt.go @@ -0,0 +1,10 @@ +//go:build debian || ubuntu + +package distro + +import ( + "github.com/velopulent/tako/internal/platform" + "github.com/velopulent/tako/internal/updateproviders/apt" +) + +func NewUpdateService() *platform.UpdateService { return platform.NewUpdateService(apt.New()) } diff --git a/apps/backend/internal/distro/updates_dnf.go b/apps/backend/internal/distro/updates_dnf.go new file mode 100644 index 0000000..b0f486d --- /dev/null +++ b/apps/backend/internal/distro/updates_dnf.go @@ -0,0 +1,10 @@ +//go:build fedora || rhel || rocky || almalinux + +package distro + +import ( + "github.com/velopulent/tako/internal/platform" + "github.com/velopulent/tako/internal/updateproviders/dnf" +) + +func NewUpdateService() *platform.UpdateService { return platform.NewUpdateService(dnf.New()) } diff --git a/apps/backend/internal/distro/updates_pacman.go b/apps/backend/internal/distro/updates_pacman.go new file mode 100644 index 0000000..7375462 --- /dev/null +++ b/apps/backend/internal/distro/updates_pacman.go @@ -0,0 +1,10 @@ +//go:build archlinux + +package distro + +import ( + "github.com/velopulent/tako/internal/platform" + "github.com/velopulent/tako/internal/updateproviders/pacman" +) + +func NewUpdateService() *platform.UpdateService { return platform.NewUpdateService(pacman.New()) } diff --git a/apps/backend/internal/distro/updates_zypper.go b/apps/backend/internal/distro/updates_zypper.go new file mode 100644 index 0000000..d2245df --- /dev/null +++ b/apps/backend/internal/distro/updates_zypper.go @@ -0,0 +1,10 @@ +//go:build opensuse + +package distro + +import ( + "github.com/velopulent/tako/internal/platform" + "github.com/velopulent/tako/internal/updateproviders/zypper" +) + +func NewUpdateService() *platform.UpdateService { return platform.NewUpdateService(zypper.New()) } diff --git a/apps/backend/internal/packagekit/client.go b/apps/backend/internal/packagekit/client.go deleted file mode 100644 index f434eed..0000000 --- a/apps/backend/internal/packagekit/client.go +++ /dev/null @@ -1,113 +0,0 @@ -package packagekit - -import ( - "regexp" - "sort" - "strings" -) - -const ( - EnumInfoLow = 3 - EnumInfoEnhancement = 4 - EnumInfoNormal = 5 - EnumInfoBugfix = 6 - EnumInfoImportant = 7 - EnumInfoSecurity = 8 -) - -// mapInfoToSeverity maps a PackageKit info enum to a Tako severity string: -// security, bugfix, or enhancement. -func mapInfoToSeverity(info uint32) string { - // HACK: security updates have 0x50008 with PK 1.2.8; mask lower 8 bits - info = info & 0xff - if info < EnumInfoLow || info > EnumInfoSecurity { - info = EnumInfoNormal - } - switch info { - case EnumInfoSecurity: - return "security" - case EnumInfoLow: - return "enhancement" - case EnumInfoEnhancement: - return "enhancement" - default: - // INFO_NORMAL, INFO_BUGFIX, INFO_IMPORTANT all -> bugfix - return "bugfix" - } -} - -var cvePattern = regexp.MustCompile(`CVE-\d{4}-\d+`) - -func parseCVEs(text string) []string { - if text == "" { - return nil - } - matches := cvePattern.FindAllString(text, -1) - if len(matches) == 0 { - return nil - } - seen := make(map[string]struct{}, len(matches)) - var out []string - for _, cve := range matches { - if _, ok := seen[cve]; ok { - continue - } - seen[cve] = struct{}{} - out = append(out, "https://www.cve.org/CVERecord?id="+cve) - } - return out -} - -func deduplicate(list []string) []string { - if len(list) == 0 { - return list - } - seen := make(map[string]struct{}, len(list)) - for _, v := range list { - seen[v] = struct{}{} - } - out := make([]string, 0, len(seen)) - for k := range seen { - out = append(out, k) - } - sort.Strings(out) - return out -} - -func removeHeading(text string) string { - if text == "" { - return text - } - trimmed := strings.TrimSpace(text) - // Strip a leading "== Title ==" heading line - if strings.HasPrefix(trimmed, "== ") { - // find first newline - if idx := strings.Index(trimmed, "\n"); idx != -1 { - firstLine := trimmed[:idx] - if strings.HasSuffix(strings.TrimSpace(firstLine), "==") { - return strings.TrimSpace(trimmed[idx+1:]) - } - } else { - // single line heading - if strings.HasSuffix(trimmed, "==") { - return "" - } - } - } - return text -} - -func splitIntoBatches(ids []string, batchSize int) [][]string { - if batchSize <= 0 { - batchSize = len(ids) - } - var batches [][]string - for i := 0; i < len(ids); i += batchSize { - end := i + batchSize - if end > len(ids) { - end = len(ids) - } - batches = append(batches, ids[i:end]) - } - return batches -} diff --git a/apps/backend/internal/packagekit/client_test.go b/apps/backend/internal/packagekit/client_test.go deleted file mode 100644 index 4ebb095..0000000 --- a/apps/backend/internal/packagekit/client_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package packagekit - -import ( - "reflect" - "testing" -) - -func TestMapInfoToSeverity(t *testing.T) { - cases := []struct { - info uint32 - want string - }{ - {EnumInfoSecurity, "security"}, - {EnumInfoBugfix, "bugfix"}, - {EnumInfoImportant, "bugfix"}, - {EnumInfoNormal, "bugfix"}, - {EnumInfoLow, "enhancement"}, - {EnumInfoEnhancement, "enhancement"}, - {99, "bugfix"}, // unknown -> normal -> bugfix - } - for _, c := range cases { - got := mapInfoToSeverity(c.info) - if got != c.want { - t.Fatalf("mapInfoToSeverity(%d)=%q want %q", c.info, got, c.want) - } - } -} - -func TestParseCVEs(t *testing.T) { - text := "Fixes CVE-2024-1234 and CVE-2024-5678 also CVE-2023-1" - got := parseCVEs(text) - want := []string{ - "https://www.cve.org/CVERecord?id=CVE-2024-1234", - "https://www.cve.org/CVERecord?id=CVE-2024-5678", - "https://www.cve.org/CVERecord?id=CVE-2023-1", - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("parseCVEs mismatch got %#v want %#v", got, want) - } -} - -func TestDeduplicate(t *testing.T) { - got := deduplicate([]string{"b", "a", "b", "c", "a"}) - want := []string{"a", "b", "c"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("deduplicate got %#v want %#v", got, want) - } -} - -func TestRemoveHeading(t *testing.T) { - if got := removeHeading("== version ==\nhello"); got != "hello" { - t.Fatalf("removeHeading failed got %q", got) - } - if got := removeHeading("hello\nworld"); got != "hello\nworld" { - t.Fatalf("removeHeading changed non-heading %q", got) - } -} - -func TestBatchSplitting(t *testing.T) { - ids := []string{"a", "b", "c", "d", "e"} - batches := splitIntoBatches(ids, 2) - if len(batches) != 3 { - t.Fatalf("expected 3 batches got %d", len(batches)) - } - if len(batches[0]) != 2 || batches[2][0] != "e" { - t.Fatalf("unexpected batches %#v", batches) - } -} diff --git a/apps/backend/internal/packagekit/dbus.go b/apps/backend/internal/packagekit/dbus.go deleted file mode 100644 index 0734044..0000000 --- a/apps/backend/internal/packagekit/dbus.go +++ /dev/null @@ -1,831 +0,0 @@ -package packagekit - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/godbus/dbus/v5" -) - -const ( - dbusDest = "org.freedesktop.PackageKit" - dbusPath = "/org/freedesktop/PackageKit" - dbusInterface = "org.freedesktop.PackageKit" - transactionIface = "org.freedesktop.PackageKit.Transaction" - transactionsPath = "/org/freedesktop/PackageKit/transactions" - RoleRefreshCache = 13 - RoleGetUpdates = 14 - RoleUpdatePackages = 22 -) - -// Transaction status enum values from lib/pk-enum.h (verified against the -// PackageKit source). -const ( - StatusWait = 1 - StatusWaitingForLock = 30 - StatusFinished = 18 - StatusCanceled = 19 -) - -var ( - ErrPackageKitNotAvailable = errors.New("PackageKit not available") - ErrTransactionFailed = errors.New("PackageKit transaction failed") -) - -// Update holds one PackageKit UpdateDetail result. -type Update struct { - ID string - Name string - Version string - Arch string - Summary string - Severity string // security, bugfix, enhancement - Description string - Markdown bool - BugURLs []string - CVEURLs []string - VendorURLs []string -} - -// Client wraps a D-Bus connection to PackageKit. -type Client struct { - conn *dbus.Conn -} - -// New creates a new PackageKit client with a system bus connection -func New() (*Client, error) { - conn, err := dbus.ConnectSystemBus() - if err != nil { - return nil, err - } - return &Client{conn: conn}, nil -} - -// NewWithConn for testing -func NewWithConn(conn *dbus.Conn) *Client { - return &Client{conn: conn} -} - -func (c *Client) Close() { - if c.conn != nil { - c.conn.Close() - } -} - -// Detect checks if PackageKit is available via VersionMajor property. -func (c *Client) Detect(ctx context.Context) bool { - if c.conn == nil { - conn, err := dbus.ConnectSystemBus() - if err != nil { - return false - } - defer conn.Close() - c = &Client{conn: conn} - } - obj := c.conn.Object(dbusDest, dbusPath) - var version uint32 - err := obj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, dbusInterface, "VersionMajor").Store(&version) - if err == nil { - return true - } - // also try ListNames fallback like old code - return packageKitAvailableFallback(ctx, c.conn) -} - -func packageKitAvailableFallback(ctx context.Context, conn *dbus.Conn) bool { - var names []string - if err := conn.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.ListNames", 0).Store(&names); err == nil { - for _, n := range names { - if n == "org.freedesktop.PackageKit" { - return true - } - } - } - var activatable []string - if err := conn.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.ListActivatableNames", 0).Store(&activatable); err == nil { - for _, n := range activatable { - if n == "org.freedesktop.PackageKit" { - return true - } - } - } - return false -} - -// GetTimeSinceAction returns seconds elapsed since the given role last ran. -func (c *Client) GetTimeSinceAction(ctx context.Context, role uint32) (int64, error) { - obj := c.conn.Object(dbusDest, dbusPath) - var seconds int64 - // PackageKit GetTimeSinceAction takes role enum - err := obj.CallWithContext(ctx, dbusInterface+".GetTimeSinceAction", 0, role).Store(&seconds) - if err != nil { - return 0, err - } - return seconds, nil -} - -// GetUpdates fetches updates with details. -func (c *Client) GetUpdates(ctx context.Context) ([]Update, error) { - updatesMap, err := c.getUpdatesRaw(ctx) - if err != nil { - return nil, err - } - if len(updatesMap) == 0 { - return nil, nil - } - ids := make([]string, 0, len(updatesMap)) - for id := range updatesMap { - ids = append(ids, id) - } - // Batch GetUpdateDetail (500 per batch, retry singles on failure). - if err := c.loadUpdateDetailsBatched(ctx, ids, updatesMap); err != nil { - // Continue with partial details. - fmt.Printf("warning: loadUpdateDetails failed: %v\n", err) - } - var out []Update - for _, u := range updatesMap { - out = append(out, *u) - } - return out, nil -} - -func (c *Client) getUpdatesRaw(ctx context.Context) (map[string]*Update, error) { - transactionPath, err := c.createTransaction(ctx) - if err != nil { - return nil, err - } - defer c.removeTransactionSignal(transactionPath) - - updates := make(map[string]*Update) - // Set up signal handling for Package - sigChan := make(chan *dbus.Signal, 32) - c.conn.Signal(sigChan) - defer c.conn.RemoveSignal(sigChan) - // Add match for this transaction - _ = c.conn.AddMatchSignal(dbus.WithMatchInterface(transactionIface), dbus.WithMatchPathNamespace(dbus.ObjectPath(transactionPath))) - defer c.conn.RemoveMatchSignal(dbus.WithMatchInterface(transactionIface), dbus.WithMatchPathNamespace(dbus.ObjectPath(transactionPath))) - - // Channel to collect result - errChan := make(chan error, 1) - finished := make(chan uint32, 1) - - go func() { - for sig := range sigChan { - if sig.Path != transactionPath { - continue - } - switch sig.Name { - case transactionIface + ".Package": - if len(sig.Body) < 3 { - continue - } - info, _ := sig.Body[0].(uint32) - packageID, _ := sig.Body[1].(string) - summary, _ := sig.Body[2].(string) - severity := mapInfoToSeverity(info) - fields := strings.Split(packageID, ";") - if len(fields) < 3 { - continue - } - name := fields[0] - version := fields[1] - arch := fields[2] - // HACK: dnf backend yields wrong severity with PK <1.2.4; - // handled by the range check in mapInfoToSeverity. - u := &Update{ - ID: packageID, - Name: name, - Version: version, - Arch: arch, - Summary: summary, - Severity: severity, - } - updates[packageID] = u - case transactionIface + ".ErrorCode": - // collect for later, but don't fail immediately - if len(sig.Body) >= 2 { - // code, details := sig.Body[0], sig.Body[1] - } - case transactionIface + ".Finished": - if len(sig.Body) >= 1 { - if exit, ok := sig.Body[0].(uint32); ok { - finished <- exit - return - } - } - finished <- 0 - return - } - } - }() - - // Call GetUpdates. The filter is a uint64 bitmask on the wire ("t"); - // passing uint32 makes the daemon reject the call, so keep this uint64. - obj := c.conn.Object(dbusDest, transactionPath) - call := obj.CallWithContext(ctx, transactionIface+".GetUpdates", 0, uint64(0)) - if call.Err != nil { - return nil, call.Err - } - - select { - case exit := <-finished: - if exit != 1 { // EXIT_SUCCESS =1 per packagekit.js Enum - return nil, fmt.Errorf("%w: exit %d", ErrTransactionFailed, exit) - } - return updates, nil - case <-ctx.Done(): - _ = c.cancelTransaction(transactionPath) - return nil, ctx.Err() - case <-time.After(15 * time.Second): - _ = c.cancelTransaction(transactionPath) - return nil, errors.New("GetUpdates timeout") - case err := <-errChan: - return nil, err - } -} - -func (c *Client) loadUpdateDetailsBatched(ctx context.Context, ids []string, updates map[string]*Update) error { - const initialBatch = 500 - batches := splitIntoBatches(ids, initialBatch) - for _, batch := range batches { - if err := c.loadUpdateDetailsBatch(ctx, batch, updates); err != nil { - // Retry with batch size 1. - if len(batch) > 1 { - for _, single := range batch { - _ = c.loadUpdateDetailsBatch(ctx, []string{single}, updates) - } - } else { - return err - } - } - } - return nil -} - -func (c *Client) loadUpdateDetailsBatch(ctx context.Context, batch []string, updates map[string]*Update) error { - transactionPath, err := c.createTransaction(ctx) - if err != nil { - return err - } - defer c.removeTransactionSignal(transactionPath) - - sigChan := make(chan *dbus.Signal, 32) - c.conn.Signal(sigChan) - defer c.conn.RemoveSignal(sigChan) - _ = c.conn.AddMatchSignal(dbus.WithMatchInterface(transactionIface), dbus.WithMatchPathNamespace(dbus.ObjectPath(transactionPath))) - defer c.conn.RemoveMatchSignal(dbus.WithMatchInterface(transactionIface), dbus.WithMatchPathNamespace(dbus.ObjectPath(transactionPath))) - - finished := make(chan uint32, 1) - go func() { - for sig := range sigChan { - if sig.Path != transactionPath { - continue - } - switch sig.Name { - case transactionIface + ".UpdateDetail": - if len(sig.Body) < 9 { - continue - } - packageID, _ := sig.Body[0].(string) - // updates, obsoletes skipped - vendorURLs, _ := sig.Body[3].([]string) - bugURLs, _ := sig.Body[4].([]string) - cveURLs, _ := sig.Body[5].([]string) - // restart ignored - updateText, _ := sig.Body[7].(string) - changelog, _ := sig.Body[8].(string) - u, ok := updates[packageID] - if !ok { - continue - } - u.VendorURLs = vendorURLs - u.Description = removeHeading(updateText) - if u.Description == "" { - u.Description = changelog - } else { - u.Markdown = updateText != "" - } - u.BugURLs = deduplicate(bugURLs) - // fallback CVE parse if empty - if len(cveURLs) == 0 { - cveURLs = parseCVEs(u.Description) - } else { - // Normalize and deduplicate the cve.org URLs. - cveURLs = deduplicate(cveURLs) - } - u.CVEURLs = deduplicate(cveURLs) - if len(u.CVEURLs) > 0 { - u.Severity = "security" - } - u.VendorURLs = deduplicate(vendorURLs) - case transactionIface + ".Finished": - if len(sig.Body) >= 1 { - if exit, ok := sig.Body[0].(uint32); ok { - finished <- exit - return - } - } - finished <- 0 - return - } - } - }() - - obj := c.conn.Object(dbusDest, transactionPath) - call := obj.CallWithContext(ctx, transactionIface+".GetUpdateDetail", 0, batch) - if call.Err != nil { - return call.Err - } - - select { - case exit := <-finished: - if exit != 1 { - return fmt.Errorf("%w: GetUpdateDetail exit %d", ErrTransactionFailed, exit) - } - return nil - case <-ctx.Done(): - _ = c.cancelTransaction(transactionPath) - return ctx.Err() - case <-time.After(15 * time.Second): - _ = c.cancelTransaction(transactionPath) - return errors.New("GetUpdateDetail timeout") - } -} - -func (c *Client) createTransaction(ctx context.Context) (dbus.ObjectPath, error) { - obj := c.conn.Object(dbusDest, dbusPath) - var path dbus.ObjectPath - err := obj.CallWithContext(ctx, dbusInterface+".CreateTransaction", 0).Store(&path) - if err != nil { - return "", err - } - return path, nil -} - -func (c *Client) cancelTransaction(path dbus.ObjectPath) error { - obj := c.conn.Object(dbusDest, path) - return obj.Call("org.freedesktop.PackageKit.Transaction.Cancel", 0).Err -} - -func (c *Client) removeTransactionSignal(path dbus.ObjectPath) { - // placeholder for cleanup, matches removed via defer -} - -// RefreshCache refreshes the PackageKit cache (force controls whether the -// cache is refreshed even when considered fresh). -func (c *Client) RefreshCache(ctx context.Context, force bool) error { - transactionPath, err := c.createTransaction(ctx) - if err != nil { - return err - } - defer c.removeTransactionSignal(transactionPath) - - sigChan := make(chan *dbus.Signal, 16) - c.conn.Signal(sigChan) - defer c.conn.RemoveSignal(sigChan) - _ = c.conn.AddMatchSignal(dbus.WithMatchInterface(transactionIface), dbus.WithMatchPathNamespace(dbus.ObjectPath(transactionPath))) - defer c.conn.RemoveMatchSignal(dbus.WithMatchInterface(transactionIface), dbus.WithMatchPathNamespace(dbus.ObjectPath(transactionPath))) - - finished := make(chan uint32, 1) - errorMsg := "" - go func() { - for sig := range sigChan { - if sig.Path != transactionPath { - continue - } - switch sig.Name { - case transactionIface + ".ErrorCode": - if len(sig.Body) >= 2 { - if details, ok := sig.Body[1].(string); ok { - errorMsg = details - } - } - case transactionIface + ".Finished": - if len(sig.Body) >= 1 { - if exit, ok := sig.Body[0].(uint32); ok { - finished <- exit - return - } - } - finished <- 0 - return - } - } - }() - - obj := c.conn.Object(dbusDest, transactionPath) - call := obj.CallWithContext(ctx, transactionIface+".RefreshCache", 0, force) - if call.Err != nil { - return call.Err - } - - select { - case exit := <-finished: - if exit != 1 { - if errorMsg != "" { - return fmt.Errorf("%w: %s", ErrTransactionFailed, errorMsg) - } - return fmt.Errorf("%w: exit %d", ErrTransactionFailed, exit) - } - return nil - case <-ctx.Done(): - _ = c.cancelTransaction(transactionPath) - return ctx.Err() - case <-time.After(5 * time.Minute): - _ = c.cancelTransaction(transactionPath) - return errors.New("RefreshCache timeout") - } -} - -// UpdatePackages runs an update transaction. Live progress is not streamed -// through this call: transactions are bus-visible objects, so the gateway -// observes Percentage/Status/LastPackage via UpdateSnapshot instead. -func (c *Client) UpdatePackages(ctx context.Context, packageIDs []string) error { - transactionPath, err := c.createTransaction(ctx) - if err != nil { - return err - } - defer c.removeTransactionSignal(transactionPath) - - sigChan := make(chan *dbus.Signal, 32) - c.conn.Signal(sigChan) - defer c.conn.RemoveSignal(sigChan) - _ = c.conn.AddMatchSignal(dbus.WithMatchInterface(transactionIface), dbus.WithMatchPathNamespace(dbus.ObjectPath(transactionPath))) - defer c.conn.RemoveMatchSignal(dbus.WithMatchInterface(transactionIface), dbus.WithMatchPathNamespace(dbus.ObjectPath(transactionPath))) - - finished := make(chan uint32, 1) - var errorDetail string - go func() { - for sig := range sigChan { - if sig.Path != transactionPath { - continue - } - switch sig.Name { - case transactionIface + ".ErrorCode": - if len(sig.Body) >= 2 { - if d, ok := sig.Body[1].(string); ok { - errorDetail = d - } - } - case transactionIface + ".Finished": - if len(sig.Body) >= 1 { - if exit, ok := sig.Body[0].(uint32); ok { - finished <- exit - return - } - } - finished <- 0 - return - } - } - }() - - obj := c.conn.Object(dbusDest, transactionPath) - // Transaction flags are uint64 on the wire ("t"), like GetUpdates above. - call := obj.CallWithContext(ctx, transactionIface+".UpdatePackages", 0, uint64(0), packageIDs) - if call.Err != nil { - return call.Err - } - - select { - case exit := <-finished: - if exit == 3 { // EXIT_CANCELLED - return context.Canceled - } - if exit != 1 { - if errorDetail != "" { - return fmt.Errorf("%w: %s", ErrTransactionFailed, errorDetail) - } - return fmt.Errorf("%w: exit %d", ErrTransactionFailed, exit) - } - return nil - case <-ctx.Done(): - _ = c.cancelTransaction(transactionPath) - return ctx.Err() - } -} - -// GetTransactionList returns the current PackageKit transaction paths. -func (c *Client) GetTransactionList(ctx context.Context) ([]dbus.ObjectPath, error) { - obj := c.conn.Object(dbusDest, dbusPath) - var paths []dbus.ObjectPath - err := obj.CallWithContext(ctx, dbusInterface+".GetTransactionList", 0).Store(&paths) - if err != nil { - return nil, err - } - return paths, nil -} - -// transactionUintProperty reads a uint32 property from a transaction object. -func (c *Client) transactionUintProperty(ctx context.Context, path dbus.ObjectPath, name string) (uint32, bool) { - obj := c.conn.Object(dbusDest, path) - var value uint32 - if err := obj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, transactionIface, name).Store(&value); err != nil { - return 0, false - } - return value, true -} - -func (c *Client) transactionStringProperty(ctx context.Context, path dbus.ObjectPath, name string) (string, bool) { - obj := c.conn.Object(dbusDest, path) - var value string - if err := obj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, transactionIface, name).Store(&value); err != nil { - return "", false - } - return value, true -} - -// TransactionBusy reports whether the given transaction is an in-flight cache -// refresh or package update. Only WAIT / WAITING_FOR_LOCK style -// live transactions are blocking; FINISHED (18) and CANCEL (19) transactions can -// linger in the list briefly without holding any lock. -func (c *Client) TransactionBusy(ctx context.Context, path dbus.ObjectPath) (bool, string) { - role, ok := c.transactionUintProperty(ctx, path, "Role") - if !ok { - // A vanished transaction cannot hold a lock; an unreadable one might. - if _, statOk := c.transactionUintProperty(ctx, path, "Status"); !statOk { - return false, "" - } - return true, "PackageKit transaction in progress" - } - if role != RoleRefreshCache && role != RoleUpdatePackages { - return false, "" - } - status, ok := c.transactionUintProperty(ctx, path, "Status") - if !ok { - return true, "PackageKit transaction in progress" - } - if status == StatusFinished || status == StatusCanceled { - return false, "" - } - if role == RoleRefreshCache { - return true, "Refreshing package metadata" - } - return true, "A package update is in progress" -} - -// LiveUpdateSnapshot describes the currently running package-update -// transaction. It is read directly from D-Bus properties so the gateway can -// observe updates started by sessiond, pkcon, or any other client. -type LiveUpdateSnapshot struct { - TransactionPath string - Percentage uint32 - AllowCancel bool - Status uint32 - StatusMessage string - LastPackage string - RemainingTime int64 -} - -func (c *Client) liveUpdateObjectPath(ctx context.Context) (dbus.ObjectPath, bool) { - paths, err := c.GetTransactionList(ctx) - if err != nil { - return "", false - } - var refreshPath dbus.ObjectPath - haveRefresh := false - for _, path := range paths { - role, ok := c.transactionUintProperty(ctx, path, "Role") - if !ok { - continue - } - if status, statOk := c.transactionUintProperty(ctx, path, "Status"); statOk && (status == StatusFinished || status == StatusCanceled) { - continue - } - switch role { - case RoleUpdatePackages: - return path, true - case RoleRefreshCache: - if !haveRefresh { - refreshPath, haveRefresh = path, true - } - } - } - return refreshPath, haveRefresh -} - -// UpdateSnapshot returns a snapshot of the running package-update transaction, -// or nil when no update is currently in flight. -func (c *Client) UpdateSnapshot(ctx context.Context) *LiveUpdateSnapshot { - path, ok := c.liveUpdateObjectPath(ctx) - if !ok { - return nil - } - snapshot := &LiveUpdateSnapshot{TransactionPath: string(path)} - if value, ok := c.transactionUintProperty(ctx, path, "Percentage"); ok { - snapshot.Percentage = value - } - if value, ok := c.transactionBoolProperty(ctx, path, "AllowCancel"); ok { - snapshot.AllowCancel = value - } - if value, ok := c.transactionUintProperty(ctx, path, "Status"); ok { - snapshot.Status = value - snapshot.StatusMessage = StatusMessage(value) - } - if value, ok := c.transactionStringProperty(ctx, path, "LastPackage"); ok { - snapshot.LastPackage = value - } - if value, ok := c.transactionIntProperty(ctx, path, "RemainingTime"); ok { - snapshot.RemainingTime = value - } - return snapshot -} - -// CancelActiveUpdate cancels the running package-update transaction, if any. -// It reports whether a transaction was found and cancelled. -func (c *Client) CancelActiveUpdate(ctx context.Context) (bool, error) { - path, ok := c.liveUpdateObjectPath(ctx) - if !ok { - return false, nil - } - if err := c.cancelTransaction(path); err != nil { - return true, err - } - return true, nil -} - -func (c *Client) transactionBoolProperty(ctx context.Context, path dbus.ObjectPath, name string) (bool, bool) { - obj := c.conn.Object(dbusDest, path) - var value bool - if err := obj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, transactionIface, name).Store(&value); err != nil { - return false, false - } - return value, true -} - -func (c *Client) transactionIntProperty(ctx context.Context, path dbus.ObjectPath, name string) (int64, bool) { - obj := c.conn.Object(dbusDest, path) - var value int64 - if err := obj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, transactionIface, name).Store(&value); err != nil { - return 0, false - } - return value, true -} - -// statusMessages covers the PackageKit statuses surfaced during an -// update; unknown values fall back to a generic label. -var statusMessages = map[uint32]string{ - StatusWait: "Waiting", - StatusWaitingForLock: "Waiting for another package operation", - 2: "Downloading", - 3: "Querying", - 5: "Removing", - 6: "Refreshing", - 7: "Downgrading", - 8: "Downloading", - 9: "Installing", - 10: "Updating", - 11: "Cleaning up", - 12: "Resolving dependencies", - 14: "Checking signatures", - 16: "Committing", -} - -// StatusMessage maps a PackageKit status enum to a human label. -func StatusMessage(status uint32) string { - switch status { - case StatusFinished: - return "Finished" - case StatusCanceled: - return "Canceled" - } - if message, ok := statusMessages[status]; ok { - return message - } - return "Updating" -} - -// HistoryEntry is one past update transaction: a timestamp plus the packages -// it touched (name -> version). -type HistoryEntry struct { - Time int64 `json:"time"` - Packages map[string]string `json:"packages"` -} - -const maxHistoryEntries = 20 - -// GetOldTransactions returns past update-package transactions, newest first: -// filter to ROLE_UPDATE_PACKAGES and parse the "action\tpackage-id" data -// lines. -func (c *Client) GetOldTransactions(ctx context.Context) ([]HistoryEntry, error) { - transactionPath, err := c.createTransaction(ctx) - if err != nil { - return nil, err - } - defer c.removeTransactionSignal(transactionPath) - - sigChan := make(chan *dbus.Signal, 64) - c.conn.Signal(sigChan) - defer c.conn.RemoveSignal(sigChan) - _ = c.conn.AddMatchSignal(dbus.WithMatchInterface(transactionIface), dbus.WithMatchPathNamespace(dbus.ObjectPath(transactionPath))) - defer c.conn.RemoveMatchSignal(dbus.WithMatchInterface(transactionIface), dbus.WithMatchPathNamespace(dbus.ObjectPath(transactionPath))) - - history := make([]HistoryEntry, 0, maxHistoryEntries) - done := make(chan struct{}) - finished := make(chan struct{}) - go func() { - defer close(done) - for sig := range sigChan { - if sig.Path != transactionPath { - continue - } - if sig.Name == transactionIface+".Finished" { - // PackageKit emits Finished once old-transaction enumeration - // completes, even when the list is empty. - close(finished) - return - } - if sig.Name != transactionIface+".Transaction" || len(sig.Body) < 5 { - continue - } - // Body: (objectPath, timeSpec, succeeded, role, duration, data); we - // keep the transaction list regardless of success. - timeSpec, ok := sig.Body[1].(string) - if !ok { - continue - } - role, ok := sig.Body[3].(uint32) - if !ok { - continue - } - data, ok := sig.Body[4].(string) - if !ok { - continue - } - parsed, ok := parseHistoryTimestamp(timeSpec) - if !ok || role != RoleUpdatePackages { - continue - } - entry := HistoryEntry{Time: parsed.UnixMilli(), Packages: map[string]string{}} - for _, line := range strings.Split(data, "\n") { - fields := strings.Split(strings.TrimSpace(line), "\t") - if len(fields) < 2 { - continue - } - idFields := strings.Split(fields[1], ";") - if len(idFields) < 2 || idFields[0] == "" { - continue - } - entry.Packages[idFields[0]] = idFields[1] - } - if len(entry.Packages) > 0 { - history = append(history, entry) - if len(history) >= maxHistoryEntries { - return - } - } - } - }() - - obj := c.conn.Object(dbusDest, transactionPath) - call := obj.CallWithContext(ctx, transactionIface+".GetOldTransactions", 0, uint32(0)) - if call.Err != nil { - return nil, call.Err - } - finalize := func() []HistoryEntry { - // Newest first (PK reports ascending). - for left, right := 0, len(history)-1; left < right; left, right = left+1, right-1 { - history[left], history[right] = history[right], history[left] - } - return history - } - select { - case <-finished: - return finalize(), nil - case <-done: - // Goroutine exited early: the history window was filled. - return finalize(), nil - case <-ctx.Done(): - _ = c.cancelTransaction(transactionPath) - return nil, ctx.Err() - case <-time.After(15 * time.Second): - _ = c.cancelTransaction(transactionPath) - return nil, errors.New("GetOldTransactions timeout") - } -} - -// parseHistoryTimestamp handles the timezone formats PackageKit emits, -// including the short offset form ("2026-01-29T12:57:49.112827-08") that -// neither Firefox nor Chromium parse but Go does after normalisation. -func parseHistoryTimestamp(timeSpec string) (time.Time, bool) { - trimmed := strings.TrimSpace(timeSpec) - if trimmed == "" { - return time.Time{}, false - } - if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil { - return parsed, true - } - // Normalise short offsets (-08) to RFC3339 (-08:00). - if len(trimmed) > 3 { - tail := trimmed[len(trimmed)-3:] - if (tail[0] == '-' || tail[0] == '+') && tail[1] >= '0' && tail[1] <= '9' && tail[2] >= '0' && tail[2] <= '9' { - if parsed, err := time.Parse(time.RFC3339Nano, trimmed+":00"); err == nil { - return parsed, true - } - } - } - return time.Time{}, false -} diff --git a/apps/backend/internal/packagekit/watcher.go b/apps/backend/internal/packagekit/watcher.go deleted file mode 100644 index 66a317a..0000000 --- a/apps/backend/internal/packagekit/watcher.go +++ /dev/null @@ -1,165 +0,0 @@ -package packagekit - -import ( - "sync" - "time" - - "github.com/godbus/dbus/v5" -) - -// maxActionLogEntries bounds the per-transaction action log so a huge update -// cannot grow memory without limit. -const maxActionLogEntries = 500 - -// ActionLogEntry is one Package signal observed during an update transaction: -// what happened to which package. -type ActionLogEntry struct { - Status uint32 `json:"status"` - StatusLabel string `json:"statusLabel"` - PackageID string `json:"packageId"` - Timestamp time.Time `json:"timestamp"` -} - -// TransactionWatcher records Package signals emitted by PackageKit -// transactions so the dashboard can render an update log even though the -// update itself runs inside sessiond or another client. It is safe for -// concurrent use and tolerates being started when PackageKit is absent -// (signals simply never arrive). -type TransactionWatcher struct { - conn *dbus.Conn - - mu sync.Mutex - logs map[dbus.ObjectPath][]ActionLogEntry - order []dbus.ObjectPath - started bool - stop chan struct{} - stopd chan struct{} -} - -// NewTransactionWatcher connects to the system bus and prepares the watcher. -func NewTransactionWatcher() (*TransactionWatcher, error) { - conn, err := dbus.ConnectSystemBus() - if err != nil { - return nil, err - } - return &TransactionWatcher{conn: conn, logs: make(map[dbus.ObjectPath][]ActionLogEntry)}, nil -} - -// Start subscribes to transaction signals. Calling Start more than once is a -// no-op; Close releases the bus connection and stops collection. -func (watcher *TransactionWatcher) Start() { - watcher.mu.Lock() - if watcher.started { - watcher.mu.Unlock() - return - } - watcher.started = true - watcher.stop = make(chan struct{}) - watcher.stopd = make(chan struct{}) - watcher.mu.Unlock() - - if err := watcher.conn.AddMatchSignal( - dbus.WithMatchInterface(transactionIface), - dbus.WithMatchPathNamespace(dbus.ObjectPath(transactionsPath)), - ); err != nil { - close(watcher.stopd) - return - } - sigChan := make(chan *dbus.Signal, 128) - watcher.conn.Signal(sigChan) - - go func() { - defer close(watcher.stopd) - defer watcher.conn.RemoveSignal(sigChan) - for { - select { - case <-watcher.stop: - return - case sig, ok := <-sigChan: - if !ok { - return - } - if sig.Name != transactionIface+".Package" || len(sig.Body) < 2 { - continue - } - status, okStatus := sig.Body[0].(uint32) - packageID, okID := sig.Body[1].(string) - if !okStatus || !okID { - continue - } - watcher.record(sig.Path, ActionLogEntry{ - Status: status, - StatusLabel: StatusMessage(status), - PackageID: packageID, - Timestamp: time.Now().UTC(), - }) - } - } - }() -} - -func (watcher *TransactionWatcher) record(path dbus.ObjectPath, entry ActionLogEntry) { - watcher.mu.Lock() - defer watcher.mu.Unlock() - entries := append(watcher.logs[path], entry) - if len(entries) > maxActionLogEntries { - entries = entries[len(entries)-maxActionLogEntries:] - } - if _, exists := watcher.logs[path]; !exists { - watcher.order = append(watcher.order, path) - // Keep at most the two most recent transactions' logs (current plus the - // one that just finished). - for len(watcher.order) > 2 { - delete(watcher.logs, watcher.order[0]) - watcher.order = watcher.order[1:] - } - } - watcher.logs[path] = entries -} - -// ActionLog returns the recorded Package signals for a transaction path. -func (watcher *TransactionWatcher) ActionLog(path string) []ActionLogEntry { - watcher.mu.Lock() - defer watcher.mu.Unlock() - entries := watcher.logs[dbus.ObjectPath(path)] - out := make([]ActionLogEntry, len(entries)) - copy(out, entries) - return out -} - -// LatestLog returns the most recent transaction's log, preferring the given -// live path when it has entries. -func (watcher *TransactionWatcher) LatestLog(livePath string) []ActionLogEntry { - if entries := watcher.ActionLog(livePath); len(entries) > 0 { - return entries - } - watcher.mu.Lock() - defer watcher.mu.Unlock() - if len(watcher.order) == 0 { - return []ActionLogEntry{} - } - entries := watcher.logs[watcher.order[len(watcher.order)-1]] - out := make([]ActionLogEntry, len(entries)) - copy(out, entries) - return out -} - -// Close unsubscribes and releases the connection. -func (watcher *TransactionWatcher) Close() { - watcher.mu.Lock() - started := watcher.started - if started && watcher.stop != nil { - select { - case <-watcher.stop: - default: - close(watcher.stop) - } - } - watcher.mu.Unlock() - if started { - <-watcher.stopd - } - if watcher.conn != nil { - _ = watcher.conn.Close() - } -} diff --git a/apps/backend/internal/packagekit/watcher_test.go b/apps/backend/internal/packagekit/watcher_test.go deleted file mode 100644 index dfceb34..0000000 --- a/apps/backend/internal/packagekit/watcher_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package packagekit - -import ( - "testing" - - "github.com/godbus/dbus/v5" -) - -func TestParseHistoryTimestampAcceptsShortOffsets(t *testing.T) { - cases := []string{ - "2026-01-29T12:57:49.112827-08", - "2026-01-29T19:27:49.112827-01:30", - "2026-01-29T20:57:49.112827Z", - } - for _, timeSpec := range cases { - if _, ok := parseHistoryTimestamp(timeSpec); !ok { - t.Fatalf("parseHistoryTimestamp(%q) failed", timeSpec) - } - } - if _, ok := parseHistoryTimestamp(""); ok { - t.Fatal("empty timestamp accepted") - } - if _, ok := parseHistoryTimestamp("not-a-time"); ok { - t.Fatal("garbage timestamp accepted") - } -} - -func TestStatusMessageCoversUpdateFlow(t *testing.T) { - if got := StatusMessage(StatusWaitingForLock); got != "Waiting for another package operation" { - t.Fatalf("waiting-for-lock message = %q", got) - } - if got := StatusMessage(8); got != "Downloading" { - t.Fatalf("download message = %q", got) - } - if got := StatusMessage(10); got != "Updating" { - t.Fatalf("update message = %q", got) - } - if got := StatusMessage(StatusFinished); got != "Finished" { - t.Fatalf("finished message = %q", got) - } -} - -func TestTransactionWatcherRecordBoundsAndLatestLog(t *testing.T) { - watcher := &TransactionWatcher{logs: make(map[dbus.ObjectPath][]ActionLogEntry)} - for i := 0; i < maxActionLogEntries+50; i++ { - watcher.record("/org/freedesktop/PackageKit/transactions/1", ActionLogEntry{Status: 10, PackageID: "pkg"}) - } - if got := len(watcher.ActionLog("/org/freedesktop/PackageKit/transactions/1")); got != maxActionLogEntries { - t.Fatalf("log length = %d, want %d", got, maxActionLogEntries) - } - if got := watcher.LatestLog(""); len(got) != maxActionLogEntries { - t.Fatalf("latest log = %d entries", len(got)) - } - if got := watcher.ActionLog("/org/freedesktop/PackageKit/transactions/missing"); len(got) != 0 { - t.Fatalf("missing path returned %d entries", len(got)) - } -} diff --git a/apps/backend/internal/platform/autoupdates.go b/apps/backend/internal/platform/autoupdates.go deleted file mode 100644 index c0854da..0000000 --- a/apps/backend/internal/platform/autoupdates.go +++ /dev/null @@ -1,551 +0,0 @@ -package platform - -import ( - "context" - "errors" - "fmt" - "os" - "regexp" - "strings" -) - -var ( - ErrInvalidAutoUpdatesOperation = errors.New("invalid automatic updates operation") - ErrAutoUpdatesUnavailable = errors.New("automatic updates backend unavailable") - ErrAutoUpdatesApply = errors.New("automatic updates configuration failed") -) - -// Automatic-update schedule vocabulary shared by every provider. -const ( - autoTypeAll = "all" - autoTypeSecurity = "security" -) - -var autoDays = map[string]bool{"": true, "mon": true, "tue": true, "wed": true, "thu": true, "fri": true, "sat": true, "sun": true} - -var autoTimePattern = regexp.MustCompile(`^([01]?[0-9]|2[0-3]):[0-5][0-9]$`) - -// AutoUpdatesConfig reports the host's automatic-update setup, shaped so a -// future unattended-upgrades (apt) or zypper provider slots in unchanged. -type AutoUpdatesConfig struct { - Available bool `json:"available"` - Supported bool `json:"supported"` - Installed bool `json:"installed"` - Enabled bool `json:"enabled"` - Type string `json:"type"` - Day string `json:"day"` - Time string `json:"time"` - Provider string `json:"provider,omitempty"` - PackageName string `json:"packageName,omitempty"` - Reason string `json:"reason,omitempty"` -} - -// AutoUpdatesOperation changes automatic-update configuration; nil fields are -// left untouched (sent as null on the wire). -type AutoUpdatesOperation struct { - Enabled *bool `json:"enabled,omitempty"` - Type *string `json:"type,omitempty"` - Day *string `json:"day,omitempty"` - Time *string `json:"time,omitempty"` -} - -func ValidateAutoUpdatesOperation(operation AutoUpdatesOperation) error { - if operation.Type != nil && *operation.Type != autoTypeAll && *operation.Type != autoTypeSecurity { - return ErrInvalidAutoUpdatesOperation - } - if operation.Day != nil && !autoDays[*operation.Day] { - return ErrInvalidAutoUpdatesOperation - } - if operation.Time != nil && *operation.Time != "" && !autoTimePattern.MatchString(*operation.Time) { - return ErrInvalidAutoUpdatesOperation - } - if operation.Time != nil && operation.Day == nil { - // A time only makes sense together with a day choice ("every day" is - // expressed as an explicit empty day), keeping operations unambiguous. - return ErrInvalidAutoUpdatesOperation - } - return nil -} - -// autoUpdatesProvider abstracts one distro family's automatic-update -// machinery. Providers are tried in order; the first whose Applies() holds -// owns inspection and mutation. -type autoUpdatesProvider interface { - Name() string - Applies(ctx context.Context, deps autoUpdatesDeps) bool - Inspect(ctx context.Context, deps autoUpdatesDeps) AutoUpdatesConfig - Apply(ctx context.Context, deps autoUpdatesDeps, operation AutoUpdatesOperation) error -} - -func autoUpdatesProviders() []autoUpdatesProvider { - return []autoUpdatesProvider{ - dnf5AutomaticProvider{}, - dnf4AutomaticProvider{}, - } -} - -// autoUpdatesDeps isolates the process/filesystem surface so tests can drive -// every provider without a real host. -type autoUpdatesDeps struct { - commandOutput func(ctx context.Context, name string, args ...string) updateCommandResult - commandExists func(string) bool - readFile func(string) ([]byte, error) - writeFile func(string, []byte, os.FileMode) error - removeFile func(string) error - mkdirAll func(string, os.FileMode) error -} - -func defaultAutoUpdatesDeps() autoUpdatesDeps { - return autoUpdatesDeps{ - commandOutput: func(ctx context.Context, name string, args ...string) updateCommandResult { - return runUpdateCommand(ctx, name, args...) - }, - commandExists: commandExists, - readFile: os.ReadFile, - writeFile: func(path string, data []byte, mode os.FileMode) error { - return os.WriteFile(path, data, mode) - }, - removeFile: os.Remove, - mkdirAll: os.MkdirAll, - } -} - -func (deps autoUpdatesDeps) commandSucceeds(ctx context.Context, name string, args ...string) bool { - result := deps.commandOutput(ctx, name, args...) - return result.Err == nil && result.ExitCode == 0 -} - -func (deps autoUpdatesDeps) readText(path string) string { - data, err := deps.readFile(path) - if err != nil { - return "" - } - return string(data) -} - -// confValue returns the last value for key in path, honouring nothing beyond -// simple ini-style lines (dnf config files are flat sections; the keys we -// manage are unique in practice). -func confValue(content, key string) string { - pattern := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `\s*=\s*(\S+)\s*$`) - matches := pattern.FindAllStringSubmatch(content, -1) - if len(matches) == 0 { - return "" - } - return matches[len(matches)-1][1] -} - -func hasConfKey(content, key string) bool { - pattern := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `\s*=`) - return pattern.MatchString(content) -} - -// setConfKey replaces or appends key=value under section, creating the file -// with the given section header when absent. It returns the new content. -func setConfKey(content, section, key, value string) string { - pattern := regexp.MustCompile(`(?m)^(\s*` + regexp.QuoteMeta(key) + `\s*=\s*).*$`) - if pattern.MatchString(content) { - return pattern.ReplaceAllString(content, "${1}"+value) - } - if strings.TrimSpace(content) == "" { - return "[" + section + "]\n" + key + " = " + value + "\n" - } - if !strings.HasSuffix(content, "\n") { - content += "\n" - } - return content + key + " = " + value + "\n" -} - -// parseAutoCalendar validates an OnCalendar spec: only specifications we -// would have written count as supported ("mon 06:00", "*-*-* 06:00", "06:00", -// or the daily OnUnitInactiveSec=1d default). -func parseAutoCalendar(spec string, inactiveDaily bool) (day, timeOfDay string, supported bool) { - spec = strings.TrimSpace(strings.ToLower(spec)) - if spec == "" { - if inactiveDaily { - return "", "", true - } - return "", "", false - } - daysOfWeek := []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"} - words := strings.Fields(spec) - day = "" - switch { - case containsString(daysOfWeek, words[0]): - day = words[0] - words = words[1:] - case words[0] == "*-*-*": - day = "" - words = words[1:] - default: - day = "" - } - if len(words) != 1 || !autoTimePattern.MatchString(words[0]) { - return "", "", false - } - timeOfDay = strings.TrimLeft(words[0], "0") - if strings.HasPrefix(timeOfDay, ":") { - timeOfDay = "0" + timeOfDay - } - return day, timeOfDay, true -} - -func containsString(list []string, want string) bool { - for _, item := range list { - if item == want { - return true - } - } - return false -} - -// timerSchedule extracts OnCalendar=/OnUnitInactiveSec= from `systemctl cat` -// output, preferring the last OnCalendar line. -func timerSchedule(unitOutput string) (calendar, inactiveSec string) { - for _, line := range strings.Split(unitOutput, "\n") { - line = strings.TrimSpace(line) - switch { - case strings.HasPrefix(line, "OnCalendar="): - calendar = strings.TrimSpace(strings.TrimPrefix(line, "OnCalendar=")) - case strings.HasPrefix(line, "OnUnitInactiveSec="): - inactiveSec = strings.TrimSpace(strings.TrimPrefix(line, "OnUnitInactiveSec=")) - } - } - return calendar, inactiveSec -} - -// ---- dnf5 ------------------------------------------------------------------ - -type dnf5AutomaticProvider struct{} - -func (dnf5AutomaticProvider) Name() string { return "dnf5-automatic" } - -const ( - dnf5ConfigPath = "/etc/dnf/dnf5-plugins/automatic.conf" - dnf5TimerUnit = "dnf5-automatic.timer" - dnf5TimerDropInDir = "/etc/systemd/system/dnf5-automatic.timer.d" - dnf5TimerDropIn = dnf5TimerDropInDir + "/time.conf" - dnf5PackageName = "dnf5-plugin-automatic" -) - -func (p dnf5AutomaticProvider) Applies(ctx context.Context, deps autoUpdatesDeps) bool { - if !deps.commandExists("dnf") { - return false - } - version, _ := runVersionProbe(ctx, deps) - return version.containsDNF5 -} - -func (p dnf5AutomaticProvider) Inspect(ctx context.Context, deps autoUpdatesDeps) AutoUpdatesConfig { - config := AutoUpdatesConfig{Available: true, Provider: p.Name(), PackageName: dnf5PackageName, Type: autoTypeAll} - if !deps.commandSucceeds(ctx, "rpm", "-q", dnf5PackageName) { - config.Reason = "Install " + dnf5PackageName + " to configure automatic updates." - return config - } - config.Installed = true - confContent := deps.readText(dnf5ConfigPath) - if confValue(confContent, "upgrade_type") == autoTypeSecurity { - config.Type = autoTypeSecurity - } - timerEnabled := deps.commandSucceeds(ctx, "systemctl", "--quiet", "is-enabled", dnf5TimerUnit) - if timerEnabled && confValue(confContent, "apply_updates") == "yes" { - config.Enabled = true - } - calendar, inactive := unitSchedule(ctx, deps, dnf5TimerUnit) - day, timeOfDay, supported := parseAutoCalendar(calendar, strings.HasPrefix(inactive, "1d")) - config.Supported = supported - config.Day, config.Time = day, timeOfDay - return config -} - -func (dnf5AutomaticProvider) Apply(ctx context.Context, deps autoUpdatesDeps, operation AutoUpdatesOperation) error { - settings := [][2]string{} - if operation.Type != nil { - value := "default" - if *operation.Type == autoTypeSecurity { - value = autoTypeSecurity - } - settings = append(settings, [2]string{"upgrade_type", value}) - } - scheduleChanged := operation.Day != nil || operation.Time != nil - var day, timeOfDay string - if scheduleChanged { - current := dnf5AutomaticProvider{}.Inspect(ctx, deps) - day, timeOfDay = current.Day, current.Time - if operation.Day != nil { - day = *operation.Day - } - if operation.Time != nil { - timeOfDay = *operation.Time - } - if day == "" && timeOfDay == "" { - // Restore packaged defaults by dropping our override. - if err := deps.removeFile(dnf5TimerDropIn); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("%w: %v", ErrAutoUpdatesApply, err) - } - } else { - if timeOfDay == "" { - timeOfDay = "6:00" - } - if err := writeTimerDropIn(deps, dnf5TimerDropInDir, dnf5TimerDropIn, day, timeOfDay); err != nil { - return err - } - } - } - if operation.Enabled != nil { - action := "disable" - if *operation.Enabled { - action = "enable" - } - if !deps.commandSucceeds(ctx, "systemctl", action, "--now", dnf5TimerUnit) { - return fmt.Errorf("%w: systemctl %s %s failed", ErrAutoUpdatesApply, action, dnf5TimerUnit) - } - if *operation.Enabled { - settings = append(settings, [2]string{"apply_updates", "yes"}, [2]string{"reboot", "when-needed"}) - } - } - if len(settings) > 0 { - content := deps.readText(dnf5ConfigPath) - for _, setting := range settings { - content = setConfKey(content, "commands", setting[0], setting[1]) - } - if err := deps.writeFile(dnf5ConfigPath, []byte(content), 0o644); err != nil { - return fmt.Errorf("%w: %v", ErrAutoUpdatesApply, err) - } - } - return daemonReload(ctx, deps) -} - -// ---- dnf4 ------------------------------------------------------------------ - -type dnf4AutomaticProvider struct{} - -func (dnf4AutomaticProvider) Name() string { return "dnf4-automatic" } - -const ( - dnf4ConfigPath = "/etc/dnf/automatic.conf" - dnf4InstallTimer = "dnf-automatic-install.timer" - dnf4LegacyTimer = "dnf-automatic.timer" - dnf4TimerDropInDir = "/etc/systemd/system/dnf-automatic-install.timer.d" - dnf4TimerDropIn = dnf4TimerDropInDir + "/time.conf" - dnf4RebootDropInD = "/etc/systemd/system/dnf-automatic-install.service.d" - dnf4RebootDropIn = dnf4RebootDropInD + "/autoreboot.conf" - dnf4PackageName = "dnf-automatic" -) - -func (dnf4AutomaticProvider) Applies(ctx context.Context, deps autoUpdatesDeps) bool { - if !deps.commandExists("dnf") { - return false - } - version, _ := runVersionProbe(ctx, deps) - return !version.containsDNF5 -} - -func (p dnf4AutomaticProvider) Inspect(ctx context.Context, deps autoUpdatesDeps) AutoUpdatesConfig { - config := AutoUpdatesConfig{Available: true, Provider: p.Name(), PackageName: dnf4PackageName, Type: autoTypeAll} - if !deps.commandSucceeds(ctx, "rpm", "-q", dnf4PackageName) { - config.Reason = "Install " + dnf4PackageName + " to configure automatic updates." - return config - } - config.Installed = true - confContent := deps.readText(dnf4ConfigPath) - if confValue(confContent, "upgrade_type") == autoTypeSecurity { - config.Type = autoTypeSecurity - } - timer := "" - if deps.commandSucceeds(ctx, "systemctl", "--quiet", "is-enabled", dnf4InstallTimer) { - config.Enabled = true - timer = dnf4InstallTimer - } else if deps.commandSucceeds(ctx, "systemctl", "--quiet", "is-enabled", dnf4LegacyTimer) && confValue(confContent, "apply_updates") == "yes" { - config.Enabled = true - timer = dnf4LegacyTimer - } - if timer != "" { - calendar, inactive := unitSchedule(ctx, deps, timer) - day, timeOfDay, supported := parseAutoCalendar(calendar, strings.HasPrefix(inactive, "1d")) - config.Supported = supported - config.Day, config.Time = day, timeOfDay - } else if config.Enabled { - config.Supported = true - } else { - // Disabled: the packaged default is a daily run; report it as the - // schedule that enabling would keep unless edited. - config.Supported = true - } - return config -} - -func (dnf4AutomaticProvider) Apply(ctx context.Context, deps autoUpdatesDeps, operation AutoUpdatesOperation) error { - if operation.Type != nil { - content := deps.readText(dnf4ConfigPath) - value := "default" - if *operation.Type == autoTypeSecurity { - value = autoTypeSecurity - } - content = setConfKey(content, "commands", "upgrade_type", value) - if err := deps.writeFile(dnf4ConfigPath, []byte(content), 0o644); err != nil { - return fmt.Errorf("%w: %v", ErrAutoUpdatesApply, err) - } - } - scheduleChanged := operation.Day != nil || operation.Time != nil - if scheduleChanged { - current := dnf4AutomaticProvider{}.Inspect(ctx, deps) - day, timeOfDay := current.Day, current.Time - if operation.Day != nil { - day = *operation.Day - } - if operation.Time != nil { - timeOfDay = *operation.Time - } - if day == "" && timeOfDay == "" { - if err := deps.removeFile(dnf4TimerDropIn); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("%w: %v", ErrAutoUpdatesApply, err) - } - } else { - // Pin a 6:00 start when first scheduling so enabling the - // timer does not fire immediately via the packaged OnBootSec=1h. - if timeOfDay == "" { - timeOfDay = "6:00" - } - if err := writeTimerDropIn(deps, dnf4TimerDropInDir, dnf4TimerDropIn, day, timeOfDay); err != nil { - return err - } - } - } - if operation.Enabled != nil { - if *operation.Enabled { - if !deps.commandSucceeds(ctx, "systemctl", "enable", "--now", dnf4InstallTimer) { - return fmt.Errorf("%w: systemctl enable %s failed", ErrAutoUpdatesApply, dnf4InstallTimer) - } - if err := dnf4ApplyRebootPolicy(ctx, deps); err != nil { - return err - } - } else { - if !deps.commandSucceeds(ctx, "systemctl", "disable", "--now", dnf4InstallTimer) { - return fmt.Errorf("%w: systemctl disable %s failed", ErrAutoUpdatesApply, dnf4InstallTimer) - } - // Legacy unit may not exist; ignore failure. - _ = deps.commandSucceeds(ctx, "systemctl", "disable", "--now", dnf4LegacyTimer) - if err := deps.removeFile(dnf4RebootDropIn); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("%w: %v", ErrAutoUpdatesApply, err) - } - } - } - return daemonReload(ctx, deps) -} - -// dnf4ApplyRebootPolicy handles the version split: dnf >= 4.15 -// understands `reboot = when-needed`; older setups get the ExecStartPost -// journal-grep hack drop-in. -func dnf4ApplyRebootPolicy(ctx context.Context, deps autoUpdatesDeps) error { - content := deps.readText(dnf4ConfigPath) - if hasConfKey(content, "reboot") { - if confValue(content, "reboot") == "never" { - content = setConfKey(content, "commands", "reboot", "when-needed") - if err := deps.writeFile(dnf4ConfigPath, []byte(content), 0o644); err != nil { - return fmt.Errorf("%w: %v", ErrAutoUpdatesApply, err) - } - } - if err := deps.removeFile(dnf4RebootDropIn); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("%w: %v", ErrAutoUpdatesApply, err) - } - return nil - } - hack := "[Service]\nExecStartPost=/bin/sh -ec \"if systemctl status --no-pager --lines=100 dnf-automatic-install.service| grep -q ===========$$; then shutdown -r +5 rebooting after applying package updates; fi\"\n" - if err := deps.mkdirAll(dnf4RebootDropInD, 0o755); err != nil { - return fmt.Errorf("%w: %v", ErrAutoUpdatesApply, err) - } - if err := deps.writeFile(dnf4RebootDropIn, []byte(hack), 0o644); err != nil { - return fmt.Errorf("%w: %v", ErrAutoUpdatesApply, err) - } - return daemonReload(ctx, deps) -} - -// ---- shared helpers -------------------------------------------------------- - -type dnfVersionProbe struct { - containsDNF5 bool - raw string -} - -func runVersionProbe(ctx context.Context, deps autoUpdatesDeps) (dnfVersionProbe, bool) { - result := deps.commandOutput(ctx, "dnf", "--version") - if result.Err != nil || result.ExitCode != 0 { - return dnfVersionProbe{}, false - } - raw := strings.TrimSpace(result.Output) - return dnfVersionProbe{containsDNF5: strings.Contains(raw, "dnf5"), raw: raw}, true -} - -func writeTimerDropIn(deps autoUpdatesDeps, dir, path, day, timeOfDay string) error { - if !autoTimePattern.MatchString(timeOfDay) { - return fmt.Errorf("%w: invalid schedule time %q", ErrInvalidAutoUpdatesOperation, timeOfDay) - } - if !autoDays[day] { - return fmt.Errorf("%w: invalid schedule day %q", ErrInvalidAutoUpdatesOperation, day) - } - content := "[Timer]\nOnBootSec=\nOnCalendar=" + day + " " + timeOfDay + "\n" - if err := deps.mkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("%w: %v", ErrAutoUpdatesApply, err) - } - if err := deps.writeFile(path, []byte(content), 0o644); err != nil { - return fmt.Errorf("%w: %v", ErrAutoUpdatesApply, err) - } - return nil -} - -func daemonReload(ctx context.Context, deps autoUpdatesDeps) error { - if !deps.commandSucceeds(ctx, "systemctl", "daemon-reload") { - return fmt.Errorf("%w: systemctl daemon-reload failed", ErrAutoUpdatesApply) - } - return nil -} - -// unitSchedule reads a timer unit plus its drop-ins through `systemctl cat` -// (bounded, side-effect free) and extracts OnCalendar=/OnUnitInactiveSec=, -// preferring the last OnCalendar line. -func unitSchedule(ctx context.Context, deps autoUpdatesDeps, unit string) (calendar, inactiveSec string) { - result := deps.commandOutput(ctx, "systemctl", "cat", unit) - if result.Err != nil || result.ExitCode != 0 { - return "", "" - } - return timerSchedule(result.Output) -} - -// ---- entry points ---------------------------------------------------------- - -// AutoUpdatesStatus inspects the host's automatic-update configuration. -func AutoUpdatesStatus(ctx context.Context) AutoUpdatesConfig { - deps := defaultAutoUpdatesDeps() - return AutoUpdatesStatusWithDeps(ctx, deps) -} - -func AutoUpdatesStatusWithDeps(ctx context.Context, deps autoUpdatesDeps) AutoUpdatesConfig { - for _, provider := range autoUpdatesProviders() { - if provider.Applies(ctx, deps) { - return provider.Inspect(ctx, deps) - } - } - return AutoUpdatesConfig{ - Available: false, - Reason: "No supported automatic-update backend detected (dnf-automatic or dnf5-plugin-automatic).", - } -} - -// ApplyAutoUpdates mutates automatic-update configuration through the first -// applicable provider. It runs inside sessiond. -func ApplyAutoUpdates(ctx context.Context, operation AutoUpdatesOperation) (AutoUpdatesConfig, error) { - return ApplyAutoUpdatesWithDeps(ctx, defaultAutoUpdatesDeps(), operation) -} - -func ApplyAutoUpdatesWithDeps(ctx context.Context, deps autoUpdatesDeps, operation AutoUpdatesOperation) (AutoUpdatesConfig, error) { - for _, provider := range autoUpdatesProviders() { - if provider.Applies(ctx, deps) { - if err := provider.Apply(ctx, deps, operation); err != nil { - return AutoUpdatesConfig{}, err - } - return provider.Inspect(ctx, deps), nil - } - } - return AutoUpdatesConfig{}, ErrAutoUpdatesUnavailable -} diff --git a/apps/backend/internal/platform/autoupdates_test.go b/apps/backend/internal/platform/autoupdates_test.go deleted file mode 100644 index 02501d5..0000000 --- a/apps/backend/internal/platform/autoupdates_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package platform - -import ( - "context" - "errors" - "os" - "strings" - "testing" -) - -func TestValidateAutoUpdatesOperationRejectsUnsafeValues(t *testing.T) { - enabled := true - badType := "sometimes" - badDay := "funday" - badTime := "25:99" - timeWithoutDay := "06:00" - if err := ValidateAutoUpdatesOperation(AutoUpdatesOperation{Enabled: &enabled, Type: &badType}); !errors.Is(err, ErrInvalidAutoUpdatesOperation) { - t.Fatalf("bad type accepted: %v", err) - } - if err := ValidateAutoUpdatesOperation(AutoUpdatesOperation{Enabled: &enabled, Day: &badDay}); !errors.Is(err, ErrInvalidAutoUpdatesOperation) { - t.Fatalf("bad day accepted: %v", err) - } - if err := ValidateAutoUpdatesOperation(AutoUpdatesOperation{Enabled: &enabled, Time: &badTime}); !errors.Is(err, ErrInvalidAutoUpdatesOperation) { - t.Fatalf("bad time accepted: %v", err) - } - if err := ValidateAutoUpdatesOperation(AutoUpdatesOperation{Time: &timeWithoutDay}); !errors.Is(err, ErrInvalidAutoUpdatesOperation) { - t.Fatalf("time without day accepted: %v", err) - } - day := "" - goodTime := "06:00" - if err := ValidateAutoUpdatesOperation(AutoUpdatesOperation{Enabled: &enabled, Day: &day, Time: &goodTime}); err != nil { - t.Fatalf("valid operation rejected: %v", err) - } -} - -func TestParseAutoCalendarAcceptedSpecs(t *testing.T) { - cases := []struct { - spec string - inactiveDaily bool - day string - timeOfDay string - supported bool - }{ - {"", true, "", "", true}, - {"", false, "", "", false}, - {"mon 06:00", false, "mon", "6:00", true}, - {"*-*-* 06:30", false, "", "6:30", true}, - {"06:05", false, "", "6:05", true}, - {"*-*-01 00:00", false, "", "", false}, - {"fri 6:*", false, "", "", false}, - } - for _, testCase := range cases { - day, timeOfDay, supported := parseAutoCalendar(testCase.spec, testCase.inactiveDaily) - if day != testCase.day || timeOfDay != testCase.timeOfDay || supported != testCase.supported { - t.Fatalf("parseAutoCalendar(%q, %v) = (%q, %q, %v)", testCase.spec, testCase.inactiveDaily, day, timeOfDay, supported) - } - } -} - -func TestConfValueAndSetConfKeyRoundTrip(t *testing.T) { - content := "[commands]\nupgrade_type = default\napply_updates = no\n" - if value := confValue(content, "upgrade_type"); value != "default" { - t.Fatalf("confValue = %q", value) - } - updated := setConfKey(content, "commands", "upgrade_type", "security") - if value := confValue(updated, "upgrade_type"); value != "security" || strings.Count(updated, "upgrade_type") != 1 { - t.Fatalf("setConfKey replace failed: %q", updated) - } - updated = setConfKey(updated, "commands", "reboot", "when-needed") - if value := confValue(updated, "reboot"); value != "when-needed" { - t.Fatalf("setConfKey append failed: %q", updated) - } - fresh := setConfKey("", "commands", "apply_updates", "yes") - if fresh != "[commands]\napply_updates = yes\n" { - t.Fatalf("setConfKey create failed: %q", fresh) - } - if hasConfKey(content, "reboot") { - t.Fatal("hasConfKey reported absent key") - } -} - -func TestTimerScheduleParsesDropInConcatenation(t *testing.T) { - output := "# /usr/lib/systemd/system/dnf-automatic.timer\n[Unit]\nDescription=dnf automatic timer\n\n[Timer]\nOnUnitInactiveSec=1d\n\n# drop-in\n[Timer]\nOnCalendar=fri 06:00\n" - calendar, inactive := timerSchedule(output) - if calendar != "fri 06:00" || inactive != "1d" { - t.Fatalf("timerSchedule = (%q, %q)", calendar, inactive) - } -} - -func fakeAutoDeps(rpmInstalled bool, dnfVersion string, configFiles map[string]string, enabledUnits map[string]bool) autoUpdatesDeps { - written := map[string][]byte{} - return autoUpdatesDeps{ - commandOutput: func(_ context.Context, name string, args ...string) updateCommandResult { - switch { - case name == "rpm" && args[0] == "-q": - if rpmInstalled { - return updateCommandResult{ExitCode: 0} - } - return updateCommandResult{ExitCode: 1} - case name == "dnf" && len(args) > 0 && args[0] == "--version": - return updateCommandResult{ExitCode: 0, Output: dnfVersion + "\n"} - case name == "systemctl" && args[0] == "--quiet": - unit := args[len(args)-1] - if enabledUnits[unit] { - return updateCommandResult{ExitCode: 0} - } - return updateCommandResult{ExitCode: 1} - case name == "systemctl" && args[0] == "enable": - for _, unit := range args { - enabledUnits[unit] = true - } - return updateCommandResult{ExitCode: 0} - case name == "systemctl" && args[0] == "disable": - for _, unit := range args { - delete(enabledUnits, unit) - } - return updateCommandResult{ExitCode: 0} - case name == "systemctl" && args[0] == "daemon-reload": - return updateCommandResult{ExitCode: 0} - case name == "systemctl" && args[0] == "cat": - if content, ok := written[args[len(args)-1]+".cat"]; ok { - return updateCommandResult{ExitCode: 0, Output: string(content)} - } - return updateCommandResult{ExitCode: 1} - } - return updateCommandResult{ExitCode: -1, Err: errors.New("unexpected command")} - }, - commandExists: func(name string) bool { return name == "dnf" || name == "rpm" }, - readFile: func(path string) ([]byte, error) { - if content, ok := configFiles[path]; ok { - return []byte(content), nil - } - if content, ok := written[path]; ok { - return content, nil - } - return nil, os.ErrNotExist - }, - writeFile: func(path string, data []byte, _ os.FileMode) error { - written[path] = data - return nil - }, - removeFile: func(path string) error { - delete(written, path) - return nil - }, - mkdirAll: func(string, os.FileMode) error { return nil }, - } -} - -func TestProviderSelectionFollowsDnfMajorVersion(t *testing.T) { - deps4 := fakeAutoDeps(false, "4.19.2", nil, nil) - deps5 := fakeAutoDeps(false, "dnf5 5.2.6", nil, nil) - for _, provider := range autoUpdatesProviders() { - if provider.Applies(context.Background(), deps5) != (provider.Name() == "dnf5-automatic") { - t.Fatalf("%s mis-selected for dnf5 host", provider.Name()) - } - if provider.Applies(context.Background(), deps4) != (provider.Name() == "dnf4-automatic") { - t.Fatalf("%s mis-selected for dnf4 host", provider.Name()) - } - } -} - -func TestAutoUpdatesStatusWithDepsReportsUninstalledPackage(t *testing.T) { - deps := fakeAutoDeps(false, "dnf5 5.2.6", map[string]string{}, map[string]bool{}) - config := AutoUpdatesStatusWithDeps(context.Background(), deps) - if !config.Available || config.Installed || config.Enabled { - t.Fatalf("unexpected status for missing package: %#v", config) - } - if config.PackageName != "dnf5-plugin-automatic" || config.Reason == "" { - t.Fatalf("missing package guidance: %#v", config) - } -} - -func TestApplyAutoUpdatesEnablesSecurityScheduleOnDnf5(t *testing.T) { - deps := fakeAutoDeps(true, "dnf5 5.2.6", map[string]string{}, map[string]bool{}) - enabled := true - security := "security" - everyday := "" - timeOfDay := "06:00" - operation := AutoUpdatesOperation{Enabled: &enabled, Type: &security, Day: &everyday, Time: &timeOfDay} - config, err := ApplyAutoUpdatesWithDeps(context.Background(), deps, operation) - if err != nil { - t.Fatal(err) - } - if !config.Enabled || config.Type != "security" || config.Provider != "dnf5-automatic" { - t.Fatalf("post-apply status wrong: %#v", config) - } - conf := deps.readText(dnf5ConfigPath) - if confValue(conf, "apply_updates") != "yes" || confValue(conf, "upgrade_type") != "security" || confValue(conf, "reboot") != "when-needed" { - t.Fatalf("automatic.conf settings wrong: %q", conf) - } -} - -func TestApplyAutoUpdatesRejectsWhenNoProvider(t *testing.T) { - deps := autoUpdatesDeps{ - commandOutput: func(context.Context, string, ...string) updateCommandResult { - return updateCommandResult{ExitCode: -1, Err: errors.New("no dnf")} - }, - commandExists: func(string) bool { return false }, - readFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }, - writeFile: func(string, []byte, os.FileMode) error { return nil }, - removeFile: func(string) error { return nil }, - mkdirAll: func(string, os.FileMode) error { return nil }, - } - if _, err := ApplyAutoUpdatesWithDeps(context.Background(), deps, AutoUpdatesOperation{}); !errors.Is(err, ErrAutoUpdatesUnavailable) { - t.Fatalf("expected unavailable, got %v", err) - } -} diff --git a/apps/backend/internal/platform/capabilities.go b/apps/backend/internal/platform/capabilities.go index c6e007e..ce646a8 100644 --- a/apps/backend/internal/platform/capabilities.go +++ b/apps/backend/internal/platform/capabilities.go @@ -70,7 +70,6 @@ var versionCommands = map[string]string{ "services": "systemctl", "logs": "journalctl", "storage": "udisksctl", - "updates": "pkcon", } func fileExists(path string) bool { @@ -169,7 +168,7 @@ func readBounded(reader io.Reader, limit int64) ([]byte, error) { return payload, nil } -func Detect(ctx context.Context) []Capability { +func Detect(ctx context.Context, services ...*UpdateService) []Capability { for { now := time.Now() capabilityCache.Lock() @@ -192,7 +191,7 @@ func Detect(ctx context.Context) []Capability { capabilityCache.Unlock() probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - result := detect(probeCtx, hostProbe{}) + result := detect(probeCtx, hostProbe{}, services...) cancel() capabilityCache.Lock() capabilityCache.value = append([]Capability(nil), result...) @@ -204,7 +203,7 @@ func Detect(ctx context.Context) []Capability { } } -func detect(ctx context.Context, probe runtimeProbe) []Capability { +func detect(ctx context.Context, probe runtimeProbe, services ...*UpdateService) []Capability { busNames, busErr := probe.BusNames(ctx) capabilities := []Capability{ localCapability("dashboard", "built-in", true, false), @@ -219,7 +218,7 @@ func detect(ctx context.Context, probe runtimeProbe) []Capability { dbusCapability("logs", "journald", "org.freedesktop.systemd1", busNames, busErr, false, false), dbusCapability("storage", "UDisks2", "org.freedesktop.UDisks2", busNames, busErr, false, false), ) - capabilities = append(capabilities, updateCapability(ctx, probe, busNames, busErr)) + capabilities = append(capabilities, updateCapability(ctx, services...)) capabilities = append(capabilities, networkCapability(ctx, probe, busNames, busErr)) capabilities = append(capabilities, firewallCapability(ctx, probe, busNames, busErr)) capabilities = append(capabilities, policyCapability(ctx, probe, "selinux", "getenforce", "sestatus", "/sys/fs/selinux", "Install SELinux user-space tools and enable SELinux.")) @@ -281,34 +280,16 @@ func dbusCapability(id, backend, busName string, names map[string]bool, busErr e return Capability{ID: id, State: StateReady, Backend: backend, Readable: true, Mutable: mutable, Rollback: rollback, ReadAuthority: "session", MutationAuthority: mutationAuthority, Contract: "dbus"} } -func updateCapability(ctx context.Context, probe runtimeProbe, names map[string]bool, busErr error) Capability { - // Updates are writable in both branches: inventory reads happen over - // PackageKit D-Bus (or bounded CLI fallback), while the apply job, - // automatic-update configuration, and kpatch settings execute through - // sessiond with administrative authority. Rollback is not offered by any - // supported backend. - if busErr == nil && busAvailable(names, "org.freedesktop.PackageKit") { - return Capability{ID: "updates", State: StateReady, Backend: "PackageKit", Readable: true, Mutable: true, ReadAuthority: "session", MutationAuthority: "administrative", Contract: "dbus"} - } - for _, candidate := range []struct { - command string - argument string - minimumMajor int - }{ - {command: "apt-get", argument: "--version", minimumMajor: 1}, - {command: "dnf", argument: "--version", minimumMajor: 4}, - } { - if version, ok := probe.CommandVersion(ctx, candidate.command, candidate.argument); ok && versionAtLeast(version, candidate.minimumMajor) { - return Capability{ - ID: "updates", State: StateReady, Backend: candidate.command, Version: version, - Readable: true, Mutable: true, ReadAuthority: "session", MutationAuthority: "administrative", - Contract: "bounded-command", - Reason: candidate.command + " command path in use; PackageKit is unavailable", - SetupGuidance: "Install and start PackageKit for richer advisory metadata, update history, and live transaction progress.", - } - } +func updateCapability(ctx context.Context, services ...*UpdateService) Capability { + if len(services) == 0 || services[0] == nil || services[0].provider == nil { + return unavailable("updates", "distro-provider", "No compile-time update provider was injected", "Build Tako with exactly one supported distro tag.") + } + service := services[0] + version, err := service.provider.Probe(ctx) + if err != nil { + return unavailable("updates", service.ProviderName(), boundedError(err), "Install the native package manager required by this distro build.") } - return unavailable("updates", "PackageKit", "No supported update backend detected", "Install and start PackageKit.") + return Capability{ID: "updates", State: StateReady, Backend: service.ProviderName(), Version: version, Readable: true, Mutable: true, ReadAuthority: "session", MutationAuthority: "administrative", Contract: "native-distro-provider"} } func networkCapability(ctx context.Context, probe runtimeProbe, names map[string]bool, busErr error) Capability { diff --git a/apps/backend/internal/platform/capabilities_test.go b/apps/backend/internal/platform/capabilities_test.go index a5102de..e6ea778 100644 --- a/apps/backend/internal/platform/capabilities_test.go +++ b/apps/backend/internal/platform/capabilities_test.go @@ -116,11 +116,11 @@ func TestDetectExplainsMissingAndDegradedDependencies(t *testing.T) { }) updates := findCapability(t, capabilities, "updates") - if updates.State != StateReady || !updates.Mutable || updates.Version != "apt 3.0" || updates.Contract != "bounded-command" { - t.Fatalf("apt command path should be ready and writable: %#v", updates) + if updates.State != StateUnavailable || updates.Backend != "distro-provider" { + t.Fatalf("missing injected provider should fail closed: %#v", updates) } - if updates.MissingDependency != "" || updates.SetupGuidance == "" { - t.Fatalf("PackageKit enrichment guidance missing: %#v", updates) + if updates.SetupGuidance == "" { + t.Fatalf("build-tag guidance missing: %#v", updates) } storage := findCapability(t, capabilities, "storage") if storage.State != StateDegraded || storage.Reason == "" || storage.SetupGuidance == "" { @@ -135,18 +135,17 @@ func TestDetectExplainsMissingAndDegradedDependencies(t *testing.T) { func TestDetectAdvertisesImplementedMutations(t *testing.T) { capabilities := detect(context.Background(), fakeProbe{ bus: map[string]bool{ - "org.freedesktop.systemd1": true, - "org.freedesktop.NetworkManager": true, - "available:org.freedesktop.PackageKit": true, + "org.freedesktop.systemd1": true, + "org.freedesktop.NetworkManager": true, }, files: map[string]bool{"/proc": true, "/proc/stat": true, "/etc/passwd": true}, - commands: map[string]string{"nmcli": "nmcli 1.50", "pkcon": "pkcon 1.2.8"}, - }) + commands: map[string]string{"nmcli": "nmcli 1.50"}, + }, NewUpdateService(&fakeUpdateProvider{})) - // PackageKit present: writable through the sessiond-brokered apply job. + // Injected provider is writable through the sessiond-brokered apply job. updates := findCapability(t, capabilities, "updates") - if !updates.Mutable || updates.MutationAuthority != "administrative" || updates.Contract != "dbus" || updates.Rollback { - t.Fatalf("PackageKit updates should be administratively writable without rollback: %#v", updates) + if !updates.Mutable || updates.MutationAuthority != "administrative" || updates.Contract != "native-distro-provider" || updates.Rollback { + t.Fatalf("native updates should be administratively writable without rollback: %#v", updates) } // Service actions and timers mutate systemd state. if services := findCapability(t, capabilities, "services"); !services.Mutable || services.Rollback { diff --git a/apps/backend/internal/platform/kpatch.go b/apps/backend/internal/platform/kpatch.go index ffcaa74..7e1373d 100644 --- a/apps/backend/internal/platform/kpatch.go +++ b/apps/backend/internal/platform/kpatch.go @@ -5,9 +5,19 @@ import ( "errors" "fmt" "os" + "regexp" "strings" ) +func confValue(content, key string) string { + pattern := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `\s*=\s*(\S+)\s*$`) + matches := pattern.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + return "" + } + return matches[len(matches)-1][1] +} + // KpatchStatus reports kernel live-patch state. type KpatchStatus struct { Supported bool `json:"supported"` diff --git a/apps/backend/internal/platform/updates.go b/apps/backend/internal/platform/updates.go index fffbc17..7ac0a48 100644 --- a/apps/backend/internal/platform/updates.go +++ b/apps/backend/internal/platform/updates.go @@ -8,24 +8,30 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "regexp" "sort" "strconv" "strings" + "sync" "syscall" "time" - - "github.com/godbus/dbus/v5" - "github.com/velopulent/tako/internal/packagekit" + "unicode/utf8" ) const ( MaxUpdatePackages = 500 + MaxUpdateHistory = 20 maxUpdateOutput = 4 << 20 + maxLiveOutput = 1 << 20 + maxLiveEvents = 500 + maxLiveLine = 8 << 10 ) +var ansiUpdatePattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`) + var ( ErrInvalidUpdateOperation = errors.New("invalid update operation") ErrUpdateConflict = errors.New("update inventory changed") @@ -33,6 +39,7 @@ var ( ErrUpdateUnavailable = errors.New("update backend unavailable") ErrUpdateVerification = errors.New("update verification failed") ErrUpdateApply = errors.New("update command failed") + ErrUpdateRiskNotAccepted = errors.New("risky update plan was not accepted") ) type UpdatePackage struct { @@ -53,7 +60,17 @@ type UpdatePackage struct { Markdown bool `json:"markdown,omitempty"` GroupKey string `json:"groupKey,omitempty"` Dependencies []string `json:"dependencies,omitempty"` - PackageID string `json:"packageId,omitempty"` +} + +type UpdateRecovery struct { + Authoritative bool `json:"authoritative"` + RebootRequired bool `json:"rebootRequired"` + RestartServices []string `json:"restartServices"` + RebootPackages []string `json:"rebootPackages,omitempty"` + ManualPackages []string `json:"manualPackages,omitempty"` + Hints []string `json:"hints"` + Source string `json:"source"` + Reason string `json:"reason,omitempty"` } type UpdateStatus struct { @@ -72,64 +89,192 @@ type UpdateStatus struct { TimeSinceRefresh *int64 `json:"timeSinceRefresh,omitempty"` } -// UpdateRecovery describes post-update work without pretending that every -// package backend can authoritatively determine restart requirements. -type UpdateRecovery struct { - Authoritative bool `json:"authoritative"` - RebootRequired bool `json:"rebootRequired"` - RestartServices []string `json:"restartServices"` - RebootPackages []string `json:"rebootPackages,omitempty"` - ManualPackages []string `json:"manualPackages,omitempty"` - Hints []string `json:"hints"` - Source string `json:"source"` - Reason string `json:"reason,omitempty"` +type UpdateChange struct { + Action string `json:"action"` + Name string `json:"name"` + Architecture string `json:"architecture,omitempty"` + CurrentVersion string `json:"currentVersion,omitempty"` + CandidateVersion string `json:"candidateVersion,omitempty"` + CurrentRepository string `json:"currentRepository,omitempty"` + TargetRepository string `json:"targetRepository,omitempty"` + CurrentVendor string `json:"currentVendor,omitempty"` + TargetVendor string `json:"targetVendor,omitempty"` } -type updateCommandResult struct { - Output string - ExitCode int - Err error +func (change UpdateChange) Risky() bool { + return change.Action == "remove" || change.Action == "downgrade" || change.Action == "replace" || + (change.CurrentRepository != "" && change.TargetRepository != "" && change.CurrentRepository != change.TargetRepository) || + (change.CurrentVendor != "" && change.TargetVendor != "" && change.CurrentVendor != change.TargetVendor) +} + +type UpdateOperation struct { + ExpectedFingerprint string `json:"expectedFingerprint"` + Confirmed bool `json:"confirmed"` + RiskAccepted bool `json:"riskAccepted,omitempty"` + JobID string `json:"-"` +} + +type UpdatePreview struct { + Current UpdateStatus `json:"current"` + Changes []UpdateChange `json:"changes"` + Warnings []string `json:"warnings"` + Fingerprint string `json:"fingerprint"` + Stale bool `json:"stale"` + Allowed bool `json:"allowed"` + RequiresConfirmation bool `json:"requiresConfirmation"` + RequiresRiskConfirmation bool `json:"requiresRiskConfirmation"` + Reason string `json:"reason,omitempty"` +} + +type UpdateResult struct { + Backend string `json:"backend"` + Changes []UpdateChange `json:"changes"` + Verified bool `json:"verified"` + Message string `json:"message"` + Fingerprint string `json:"fingerprint"` + Recovery UpdateRecovery `json:"recovery"` +} + +type UpdateHistoryEntry struct { + Time int64 `json:"time"` + Packages map[string]string `json:"packages"` +} + +type UpdateProgress struct { + Sequence uint64 `json:"sequence"` + JobID string `json:"jobId,omitempty"` + Active bool `json:"active"` + Phase string `json:"phase"` + Package string `json:"package,omitempty"` + Current int `json:"current"` + Total int `json:"total"` + Percent int `json:"percent"` + Message string `json:"message"` + Cancelable bool `json:"cancelable"` + Timestamp string `json:"timestamp"` +} + +type UpdateOutput struct { + Sequence uint64 `json:"sequence"` + JobID string `json:"jobId,omitempty"` + Stream string `json:"stream"` + Line string `json:"line"` + Timestamp string `json:"timestamp"` +} + +type UpdateStreamEvent struct { + Kind string `json:"kind"` + Progress UpdateProgress `json:"progress,omitempty"` + Output UpdateOutput `json:"output,omitempty"` +} + +type UpdateObservation struct { + Progress UpdateProgress `json:"progress"` + Output []UpdateOutput `json:"output"` + Events []UpdateStreamEvent `json:"events,omitempty"` +} + +type UpdateProvider interface { + Name() string + Probe(context.Context) (string, error) + Inventory(context.Context) ([]UpdatePackage, error) + Refresh(context.Context, bool, func(UpdateStreamEvent)) error + Plan(context.Context) ([]UpdateChange, error) + Apply(context.Context, func(UpdateStreamEvent)) error + History(context.Context, int) ([]UpdateHistoryEntry, error) + Recovery(context.Context) UpdateRecovery + LockStatus(context.Context) (bool, string) +} + +type updateWorkerEnvelope struct { + Event *UpdateStreamEvent `json:"event,omitempty"` + Result *UpdateResult `json:"result,omitempty"` + Error string `json:"error,omitempty"` } -type updateDependencies struct { - packageKitAvailable func(context.Context) bool - packageKitDBusFetch func(context.Context) ([]UpdatePackage, string, error) - packageKitLocked func(context.Context) bool - commandExists func(string) bool - commandVersion func(context.Context, string, ...string) (string, bool) - commandOutput func(context.Context, string, ...string) updateCommandResult - lockHeld func(string) bool +type updateWorkerJob struct { + ExpectedFingerprint string `json:"expectedFingerprint"` + Confirmed bool `json:"confirmed"` + RiskAccepted bool `json:"riskAccepted,omitempty"` + JobID string `json:"jobId"` +} + +type UpdateService struct { + provider UpdateProvider + operationMu sync.Mutex + mu sync.Mutex + sequence uint64 + progress UpdateProgress + events []UpdateStreamEvent + eventBytes int + subscribers map[chan UpdateStreamEvent]struct{} + activeJob string + workerExecutable string + eventHook func(UpdateStreamEvent) +} + +func NewUpdateService(provider UpdateProvider) *UpdateService { + return &UpdateService{provider: provider, progress: UpdateProgress{Phase: "idle", Percent: -1, Message: "No update is running."}, subscribers: make(map[chan UpdateStreamEvent]struct{})} +} + +func (service *UpdateService) UseWorker(executable string) { service.workerExecutable = executable } +func (service *UpdateService) SetEventHook(hook func(UpdateStreamEvent)) { service.eventHook = hook } +func (service *UpdateService) ProviderName() string { + if service == nil || service.provider == nil { + return "none" + } + return service.provider.Name() +} + +func ValidateUpdateOperation(operation UpdateOperation, applying bool) error { + if len(operation.ExpectedFingerprint) != sha256.Size*2 { + return ErrInvalidUpdateOperation + } + if _, err := hex.DecodeString(operation.ExpectedFingerprint); err != nil { + return ErrInvalidUpdateOperation + } + if applying && !operation.Confirmed { + return ErrInvalidUpdateOperation + } + if len(operation.JobID) > 128 || strings.ContainsAny(operation.JobID, "\x00\r\n/") { + return ErrInvalidUpdateOperation + } + return nil } -// Updates reports read-only installed-software updates. It deliberately does -// not invoke package installation or refresh package metadata. -func Updates(ctx context.Context) UpdateStatus { - // The inventory budget must cover a cold PackageKit daemon (activation + - // GetUpdates + batched GetUpdateDetail) and, when D-Bus is unavailable, - // the pkcon CLI fallback. Callers above allow 30s, so 25s keeps us inside - // their deadline while giving slow backends a real chance. +func (service *UpdateService) Status(ctx context.Context) UpdateStatus { + if service == nil || service.provider == nil { + return unavailableUpdateStatus("No distro update provider was configured") + } deadline, cancel := context.WithTimeout(ctx, 25*time.Second) defer cancel() - status := updatesWithDependencies(deadline, defaultUpdateDependencies()) - status.Fingerprint = UpdateFingerprint(status) - // Use a fresh timeout for recovery so inventory time does not starve the - // reboot check (the dnf fallback may probe up to three needs-restarting - // modes). - recoveryCtx, recoveryCancel := context.WithTimeout(ctx, 6*time.Second) - defer recoveryCancel() - status.Recovery = UpdateRecoveryForBackend(recoveryCtx, status.Backend) - // Populate TimeSinceRefresh via GetTimeSinceAction(REFRESH_CACHE) - if status.Backend == "PackageKit" { - if client, err := packagekit.New(); err == nil { - refreshCtx, refreshCancel := context.WithTimeout(ctx, 3*time.Second) - if secs, err := client.GetTimeSinceAction(refreshCtx, packagekit.RoleRefreshCache); err == nil { - status.TimeSinceRefresh = &secs - } - refreshCancel() - client.Close() - } + version, err := service.provider.Probe(deadline) + if err != nil { + return unavailableUpdateStatus(err.Error()) + } + packages, err := service.provider.Inventory(deadline) + if err != nil { + status := unavailableUpdateStatus(err.Error()) + status.Backend, status.Version = service.provider.Name(), version + return status } - status.LastChecked = time.Now().UTC().Format(time.RFC3339) + packages = SortUpdatePackages(packages) + locked, reason := service.provider.LockStatus(deadline) + status := UpdateStatus{Available: true, Backend: service.provider.Name(), Version: version, Contract: "native-distro-provider", Packages: packages, ExternalLock: locked, LockReason: reason, LastChecked: time.Now().UTC().Format(time.RFC3339), Recovery: service.provider.Recovery(deadline)} + status.Message = updateMessage(len(packages)) + status.Fingerprint = UpdateFingerprint(status) + normalizeUpdateStatus(&status) + return status +} + +func unavailableUpdateStatus(reason string) UpdateStatus { + status := UpdateStatus{Backend: "none", Contract: "unavailable", Packages: []UpdatePackage{}, Message: "No supported update backend is available", Reason: reason} + normalizeUpdateStatus(&status) + status.Fingerprint = UpdateFingerprint(status) + return status +} + +func normalizeUpdateStatus(status *UpdateStatus) { if status.Packages == nil { status.Packages = []UpdatePackage{} } @@ -139,753 +284,491 @@ func Updates(ctx context.Context) UpdateStatus { if status.Recovery.Hints == nil { status.Recovery.Hints = []string{} } - return status } -func UpdateRecoveryForBackend(ctx context.Context, backend string) UpdateRecovery { - recovery := UpdateRecovery{RestartServices: []string{}, Hints: []string{}, Source: "advisory"} - if backend == "none" || backend == "" { - recovery.Reason = "No update backend is available to assess recovery needs." - return recovery - } - // Try the python tracer first - if tracerResult := tryTracerRecovery(ctx); tracerResult != nil { - recovery.RebootRequired = len(tracerResult.Reboot) > 0 - recovery.RestartServices = tracerResult.Daemons - if recovery.RestartServices == nil { - recovery.RestartServices = []string{} - } - recovery.RebootPackages = tracerResult.Reboot - recovery.ManualPackages = tracerResult.Manual - recovery.Authoritative = true - recovery.Source = "tracer" - if recovery.RebootRequired { - recovery.Hints = append(recovery.Hints, "Reboot the host after the update job completes.") - } else if len(tracerResult.Daemons) > 0 { - recovery.Hints = append(recovery.Hints, "Restart services affected by updated libraries before relying on the new versions.") - recovery.Reason = fmt.Sprintf("%d services need restart", len(tracerResult.Daemons)) - } else if len(tracerResult.Manual) > 0 { - recovery.Hints = append(recovery.Hints, "Some software needs to be restarted manually.") - recovery.Reason = "Some software needs manual restart" - } else { - recovery.Hints = append(recovery.Hints, "Restart services affected by updated libraries before relying on the new versions.") - } - // Include manual as hints detail - if len(tracerResult.Manual) > 0 { - recovery.Hints = append(recovery.Hints, "Manual restart: "+strings.Join(tracerResult.Manual, ", ")) - } - return recovery - } - // Fallback to dnf needs-restarting for dnf, else file check - if _, err := os.Stat("/var/run/reboot-required"); err == nil { - recovery.RebootRequired = true - recovery.Authoritative = true - recovery.Source = "/var/run/reboot-required" - recovery.Hints = append(recovery.Hints, "Reboot the host after the update job completes.") - } - if backend == "dnf" { - if detail, available := dnfNeedsRestartingDetail(ctx); available { - // Fine-grained restart facts when the plugin - // supports them; fall back to a coarse boolean otherwise. - recovery.Source = "needs-restarting" - if len(detail.RebootPackages) > 0 { - recovery.RebootRequired = true - recovery.Authoritative = true - recovery.RebootPackages = append(recovery.RebootPackages, detail.RebootPackages...) - recovery.Hints = append(recovery.Hints, "Reboot the host after the update job completes.") - } - if len(detail.Services) > 0 { - recovery.RestartServices = append(recovery.RestartServices, detail.Services...) - recovery.Authoritative = true - } - if len(detail.Manual) > 0 { - recovery.ManualPackages = append(recovery.ManualPackages, detail.Manual...) - recovery.Authoritative = true - } - if recovery.Authoritative && !recovery.RebootRequired { - recovery.Hints = append(recovery.Hints, "Restart services affected by updated libraries before relying on the new versions.") - } - } else if result := runUpdateCommand(ctx, "needs-restarting", "-r"); result.Err == nil && result.ExitCode == 1 { - recovery.RebootRequired = true - recovery.Authoritative = true - recovery.Source = "needs-restarting" - recovery.Hints = append(recovery.Hints, "A reboot is required according to needs-restarting.") - } else if services := tryDnfNeedsRestartingServices(ctx); len(services) > 0 { - recovery.RestartServices = services - recovery.Authoritative = true - recovery.Source = "needs-restarting" - recovery.Hints = append(recovery.Hints, "Restart services affected by updated libraries before relying on the new versions.") - } +func (service *UpdateService) Refresh(ctx context.Context, force bool) error { + if service == nil || service.provider == nil { + return ErrUpdateUnavailable } - if !recovery.RebootRequired { - recovery.Hints = append(recovery.Hints, "Restart services affected by updated libraries before relying on the new versions.") + service.operationMu.Lock() + defer service.operationMu.Unlock() + service.beginJob("refresh-" + strconv.FormatInt(time.Now().UnixNano(), 36)) + service.emitProgress(UpdateProgress{Active: true, Phase: "refreshing", Percent: -1, Message: "Refreshing package metadata.", Cancelable: true}) + err := service.provider.Refresh(ctx, force, service.publish) + if err != nil { + service.emitProgress(UpdateProgress{Phase: "failed", Percent: -1, Message: boundedUpdateError(err, "")}) + return err } - return recovery -} - -type tracerResult struct { - Reboot []string `json:"reboot"` - Daemons []string `json:"daemons"` - Manual []string `json:"manual"` -} - -func tryTracerRecovery(ctx context.Context) *tracerResult { - // Run the python tracer - tracerScript := "from tracer.query import Query\nimport json\nq=Query()\napps=q.affected_applications().get()\n" - tracerScript += "def f(apps,t): return [a.name for a in apps if a.type==t]\n" - tracerScript += "print(json.dumps({\"reboot\":f(apps,\"static\"),\"daemons\":f(apps,\"daemon\"),\"manual\":f(apps,\"application\")}))\n" - result := runUpdateCommand(ctx, "python3", "-c", tracerScript) - if result.Err != nil || result.ExitCode != 0 || strings.TrimSpace(result.Output) == "" { - return nil - } - var res tracerResult - if err := json.Unmarshal([]byte(strings.TrimSpace(result.Output)), &res); err != nil { - return nil - } - // Deduplicate and shorten cockpit-wsinstance entries - res.Reboot = deduplicateAndShorten(res.Reboot) - res.Daemons = deduplicateAndShorten(res.Daemons) - res.Manual = deduplicateAndShorten(res.Manual) - return &res + service.emitProgress(UpdateProgress{Phase: "completed", Percent: 100, Message: "Package metadata refreshed."}) + return nil } -func tryDnfNeedsRestartingServices(ctx context.Context) []string { - result := runUpdateCommand(ctx, "dnf", "needs-restarting", "--services") - if result.Err != nil || result.ExitCode != 0 { - return nil - } - lines := strings.Split(strings.TrimSpace(result.Output), "\n") - var services []string - for _, l := range lines { - l = strings.TrimSpace(l) - if strings.HasSuffix(l, ".service") { - services = append(services, l) - } +func (service *UpdateService) Preview(ctx context.Context, operation UpdateOperation) (UpdatePreview, error) { + if err := ValidateUpdateOperation(operation, false); err != nil { + return UpdatePreview{}, err } - return services -} - -// dnfNeedsRestartingDetail probes needs-restarting's fine-grained flags, -// which newer plugin versions expose as machine-parseable (though not -// stable-API) output. It reports whether the fine-grained probing was -// available at all. -type dnfRestartDetail struct { - RebootPackages []string - Services []string - Manual []string -} - -func dnfNeedsRestartingDetail(ctx context.Context) (dnfRestartDetail, bool) { - var detail dnfRestartDetail - // --exclude-services was added much later than -r; probe for it first so - // ancient plugins fall back to the coarse boolean path. - exclusive := runUpdateCommand(ctx, "dnf", "needs-restarting", "--exclude-services") - if exclusive.Err != nil || exclusive.ExitCode != 0 { - return detail, false - } - // Format: "pid : argv", e.g. "1234 : mydaemon 3600" - for _, line := range strings.Split(exclusive.Output, "\n") { - line = strings.TrimSpace(line) - if pidArgumentRe.MatchString(line) { - detail.Manual = append(detail.Manual, line) - } + service.operationMu.Lock() + defer service.operationMu.Unlock() + status := service.Status(ctx) + preview := UpdatePreview{Current: status, Changes: []UpdateChange{}, Warnings: []string{}} + if operation.ExpectedFingerprint != "" && operation.ExpectedFingerprint != status.Fingerprint { + preview.Stale, preview.Reason = true, "Available update inventory changed; refresh before applying." + return preview, nil } - - if services := runUpdateCommand(ctx, "dnf", "needs-restarting", "--services"); services.Err == nil && services.ExitCode == 0 { - for _, line := range strings.Split(services.Output, "\n") { - line = strings.TrimSpace(line) - if strings.HasSuffix(line, ".service") { - detail.Services = append(detail.Services, line) - } - } + if !status.Available { + preview.Reason = status.Reason + return preview, nil } - - // --reboothint exits nonzero iff a reboot is required; package names are - // printed as " * kernel-core". - if hint := runUpdateCommand(ctx, "dnf", "needs-restarting", "--reboothint"); hint.Err == nil { - if hint.ExitCode == 1 { - for _, line := range strings.Split(hint.Output, "\n") { - if strings.HasPrefix(line, " * ") { - detail.RebootPackages = append(detail.RebootPackages, strings.TrimPrefix(line, " * ")) - } - } + if status.ExternalLock { + preview.Reason = status.LockReason + return preview, nil + } + changes, err := service.provider.Plan(ctx) + if err != nil { + return preview, err + } + preview.Changes = SortUpdateChanges(changes) + preview.Fingerprint = UpdatePlanFingerprint(service.provider.Name(), preview.Changes) + if len(preview.Changes) == 0 { + preview.Reason = "No updates are available." + return preview, nil + } + preview.Allowed, preview.RequiresConfirmation = true, true + for _, change := range preview.Changes { + if change.Risky() { + preview.RequiresRiskConfirmation = true + break } } - return detail, true + preview.Warnings = append(preview.Warnings, "Updates can restart services or require a host reboot.") + if preview.RequiresRiskConfirmation { + preview.Warnings = append(preview.Warnings, "Plan contains removals, replacements, downgrades, or repository/vendor changes.") + } + return preview, nil } -func deduplicateAndShorten(list []string) []string { - if len(list) == 0 { - return list - } - seen := make(map[string]struct{}, len(list)) - var out []string - for _, v := range list { - if strings.HasPrefix(v, "cockpit-wsinstance-https") { - v = "cockpit-wsinstance-https@." - } - if _, ok := seen[v]; !ok { - seen[v] = struct{}{} - out = append(out, v) - } +// PreviewUpdateStatus provides a deterministic preview for cached readers. +// Authoritative mutation still replans through UpdateService immediately +// before commit and requires the exact plan fingerprint. +func PreviewUpdateStatus(status UpdateStatus, operation UpdateOperation) (UpdatePreview, error) { + if err := ValidateUpdateOperation(operation, false); err != nil { + return UpdatePreview{}, err + } + changes := make([]UpdateChange, 0, len(status.Packages)) + for _, item := range status.Packages { + changes = append(changes, UpdateChange{Action: "upgrade", Name: item.Name, Architecture: item.Architecture, CurrentVersion: item.CurrentVersion, CandidateVersion: item.CandidateVersion}) + } + changes = SortUpdateChanges(changes) + preview := UpdatePreview{Current: status, Changes: changes, Warnings: []string{}, Fingerprint: UpdatePlanFingerprint(status.Backend, changes), Allowed: status.Available && !status.ExternalLock && len(changes) > 0, RequiresConfirmation: len(changes) > 0} + if operation.ExpectedFingerprint != "" && operation.ExpectedFingerprint != status.Fingerprint && operation.ExpectedFingerprint != preview.Fingerprint { + preview.Stale = true + preview.Allowed = false + preview.Reason = "Available update inventory changed; refresh before applying." + } + if status.ExternalLock { + preview.Reason = status.LockReason } - sort.Strings(out) - return out + return preview, nil } -func defaultUpdateDependencies() updateDependencies { - return updateDependencies{ - packageKitAvailable: packageKitAvailable, - packageKitDBusFetch: fetchPackageKitDBus, - packageKitLocked: isPackageKitLocked, - commandExists: commandExists, - commandVersion: func(ctx context.Context, name string, arguments ...string) (string, bool) { - return (hostProbe{}).CommandVersion(ctx, name, arguments...) - }, - commandOutput: runUpdateCommand, - lockHeld: updateLockHeld, +func (service *UpdateService) Apply(ctx context.Context, operation UpdateOperation) (UpdateResult, error) { + if err := ValidateUpdateOperation(operation, true); err != nil { + return UpdateResult{}, err } + if service == nil || service.provider == nil { + return UpdateResult{}, ErrUpdateUnavailable + } + service.operationMu.Lock() + defer service.operationMu.Unlock() + if service.workerExecutable != "" { + return service.applyWithWorker(ctx, operation) + } + return service.applyDirect(ctx, operation) } -func isPackageKitLocked(ctx context.Context) bool { - client, err := packagekit.New() - if err != nil { - return false +func (service *UpdateService) applyDirect(ctx context.Context, operation UpdateOperation) (UpdateResult, error) { + locked, _ := service.provider.LockStatus(ctx) + if locked { + return UpdateResult{}, ErrUpdateLocked } - defer client.Close() - paths, err := client.GetTransactionList(ctx) + changes, err := service.provider.Plan(ctx) if err != nil { - return false + return UpdateResult{}, err } - // Only role-matched live transactions (refresh/update) count as busy; - // finished or unrelated transactions linger in the list without holding a - // lock, avoiding the false positive where our own inventory query locked - // the page. - for _, path := range paths { - if busy, _ := client.TransactionBusy(ctx, path); busy { - return true + changes = SortUpdateChanges(changes) + if UpdatePlanFingerprint(service.provider.Name(), changes) != operation.ExpectedFingerprint { + return UpdateResult{}, ErrUpdateConflict + } + for _, change := range changes { + if change.Risky() && !operation.RiskAccepted { + return UpdateResult{}, ErrUpdateRiskNotAccepted } } - return false + jobID := operation.JobID + if jobID == "" { + jobID = strconv.FormatInt(time.Now().UnixNano(), 36) + } + service.beginJob(jobID) + applyCtx := context.WithoutCancel(ctx) + service.emitProgress(UpdateProgress{JobID: jobID, Active: true, Phase: "applying", Total: len(changes), Percent: -1, Message: "Applying system updates.", Cancelable: false}) + if err := service.provider.Apply(applyCtx, service.publish); err != nil { + service.emitProgress(UpdateProgress{JobID: jobID, Phase: "failed", Total: len(changes), Percent: -1, Message: boundedUpdateError(err, "")}) + return UpdateResult{}, fmt.Errorf("%w: %s", ErrUpdateApply, boundedUpdateError(err, "")) + } + service.emitProgress(UpdateProgress{JobID: jobID, Active: true, Phase: "verifying", Total: len(changes), Percent: -1, Message: "Verifying update result."}) + final := service.Status(applyCtx) + if !final.Available { + return UpdateResult{}, ErrUpdateVerification + } + service.emitProgress(UpdateProgress{JobID: jobID, Phase: "completed", Current: len(changes), Total: len(changes), Percent: 100, Message: "System updates completed."}) + return UpdateResult{Backend: service.provider.Name(), Changes: changes, Verified: true, Message: "Updates applied and verified.", Fingerprint: final.Fingerprint, Recovery: final.Recovery}, nil } -// packageKitLockReason returns a human reason for the first busy transaction, -// or an empty string when no relevant transaction is running. -func packageKitLockReason(ctx context.Context) string { - client, err := packagekit.New() +func (service *UpdateService) applyWithWorker(ctx context.Context, operation UpdateOperation) (UpdateResult, error) { + jobFile, err := writeUpdateWorkerJob(operation) + if err != nil { + return UpdateResult{}, err + } + defer os.Remove(jobFile) + jobID := operation.JobID + if jobID == "" { + jobID = strconv.FormatInt(time.Now().UnixNano(), 36) + } + service.beginJob(jobID) + service.emitProgress(UpdateProgress{JobID: jobID, Active: true, Phase: "starting", Percent: -1, Message: "Starting privileged update worker.", Cancelable: false}) + unit := "tako-update-" + safeUnitFragment(operation.JobID) + if strings.HasSuffix(unit, "-") { + unit += strconv.FormatInt(time.Now().UnixNano(), 36) + } + arguments := []string{"--quiet", "--pipe", "--wait", "--collect", "--service-type=exec", "--unit=" + unit, "--property=User=root", "--property=PrivateTmp=true", "--property=ProtectHome=true", "--property=ProtectKernelTunables=true", "--property=ProtectKernelModules=true", service.workerExecutable, "update-worker", "--job-file", jobFile} + command := exec.CommandContext(context.WithoutCancel(ctx), "systemd-run", arguments...) + stdout, err := command.StdoutPipe() if err != nil { - return "" + return UpdateResult{}, err } - defer client.Close() - paths, err := client.GetTransactionList(ctx) + stderr, err := command.StderrPipe() if err != nil { - return "" + return UpdateResult{}, err } - for _, path := range paths { - if busy, reason := client.TransactionBusy(ctx, path); busy && reason != "" { - return reason + if err := command.Start(); err != nil { + return UpdateResult{}, err + } + go streamUpdateOutput(stderr, "stderr", operation.JobID, service.publish) + var result UpdateResult + var workerError string + scanner := bufio.NewScanner(io.LimitReader(stdout, maxUpdateOutput)) + scanner.Buffer(make([]byte, 64<<10), 256<<10) + for scanner.Scan() { + var envelope updateWorkerEnvelope + if json.Unmarshal(scanner.Bytes(), &envelope) != nil { + continue + } + if envelope.Event != nil { + service.publish(*envelope.Event) + } + if envelope.Result != nil { + result = *envelope.Result + } + if envelope.Error != "" { + workerError = envelope.Error } } - return "" + waitErr := command.Wait() + if workerError != "" { + return UpdateResult{}, fmt.Errorf("%w: %s", ErrUpdateApply, workerError) + } + if waitErr != nil { + return UpdateResult{}, fmt.Errorf("%w: %s", ErrUpdateApply, boundedUpdateError(waitErr, "")) + } + if !result.Verified { + return UpdateResult{}, ErrUpdateVerification + } + return result, nil } -func fetchPackageKitDBus(ctx context.Context) ([]UpdatePackage, string, error) { - client, err := packagekit.New() - if err != nil { - return nil, "", err - } - defer client.Close() - if !client.Detect(ctx) { - return nil, "", ErrUpdateUnavailable - } - // Get backend version (VersionMajor is uint32) - version := "" - if conn, err := dbus.ConnectSystemBus(); err == nil { - obj := conn.Object("org.freedesktop.PackageKit", "/org/freedesktop/PackageKit") - var variant dbus.Variant - if err := obj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.PackageKit", "VersionMajor").Store(&variant); err == nil { - if v, ok := variant.Value().(uint32); ok { - version = fmt.Sprintf("%d", v) - } else if vs, ok := variant.Value().(string); ok { - version = vs - } - } - conn.Close() +func writeUpdateWorkerJob(operation UpdateOperation) (string, error) { + if os.Geteuid() != 0 { + return "", errors.New("update worker jobs require root") } - updates, err := client.GetUpdates(ctx) + file, err := os.CreateTemp("/run/tako", "update-job-*.json") if err != nil { - return nil, version, err - } - pkgs := make([]UpdatePackage, 0, len(updates)) - for _, u := range updates { - pkgs = append(pkgs, UpdatePackage{ - Name: u.Name, - Architecture: u.Arch, - CandidateVersion: u.Version, - Severity: u.Severity, - Summary: u.Summary, - Description: u.Description, - Markdown: u.Markdown, - CVEUrls: u.CVEURLs, - BugUrls: u.BugURLs, - VendorUrls: u.VendorURLs, - AdvisoryID: "", - GroupKey: "", - PackageID: u.ID, - }) - if len(u.CVEURLs) > 0 && pkgs[len(pkgs)-1].AdvisoryID == "" { - pkgs[len(pkgs)-1].AdvisoryID = u.CVEURLs[0] - } + return "", err + } + path := file.Name() + if err := file.Chmod(0o600); err != nil { + file.Close() + os.Remove(path) + return "", err + } + encoder := json.NewEncoder(file) + document := updateWorkerJob{ExpectedFingerprint: operation.ExpectedFingerprint, Confirmed: operation.Confirmed, RiskAccepted: operation.RiskAccepted, JobID: operation.JobID} + if err := encoder.Encode(document); err != nil { + file.Close() + os.Remove(path) + return "", err } - pkgs = sortUpdates(pkgs) - if pkgs == nil { - pkgs = []UpdatePackage{} + if err := file.Close(); err != nil { + os.Remove(path) + return "", err } - return pkgs, version, nil + return path, nil } -func RefreshUpdatesCache(ctx context.Context, force bool) error { - client, err := packagekit.New() +func ReadUpdateWorkerJob(path string) (UpdateOperation, error) { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { + return UpdateOperation{}, ErrInvalidUpdateOperation + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != 0 { + return UpdateOperation{}, ErrInvalidUpdateOperation + } + file, err := os.Open(path) if err != nil { - return ErrUpdateUnavailable + return UpdateOperation{}, err } - defer client.Close() - if !client.Detect(ctx) { - return ErrUpdateUnavailable + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, 16<<10)) + decoder.DisallowUnknownFields() + var document updateWorkerJob + if err := decoder.Decode(&document); err != nil || decoder.Decode(&struct{}{}) != io.EOF { + return UpdateOperation{}, ErrInvalidUpdateOperation } - return client.RefreshCache(ctx, force) + operation := UpdateOperation{ExpectedFingerprint: document.ExpectedFingerprint, Confirmed: document.Confirmed, RiskAccepted: document.RiskAccepted, JobID: document.JobID} + if ValidateUpdateOperation(operation, true) != nil { + return UpdateOperation{}, ErrInvalidUpdateOperation + } + return operation, nil } -func tryEnrichWithDnfInfo(ctx context.Context, deps updateDependencies, pkgs []UpdatePackage) []UpdatePackage { - if deps.commandExists == nil || deps.commandOutput == nil || !deps.commandExists("dnf") { - return pkgs +func safeUnitFragment(value string) string { + var result strings.Builder + for _, character := range value { + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || character == '-' || character == '_' { + result.WriteRune(character) + } } - result := deps.commandOutput(ctx, "dnf", "updateinfo", "info") - if result.Err != nil || result.ExitCode != 0 || strings.TrimSpace(result.Output) == "" { - return pkgs + if result.Len() > 48 { + return result.String()[:48] } - return applyDnfUpdateInfo(pkgs, result.Output) + return result.String() } -func applyDnfUpdateInfo(packages []UpdatePackage, infoOutput string) []UpdatePackage { - type advisory struct { - id string - typ string - severity string - description string - bugUrls []string - vendorUrls []string - pkgNames map[string]bool - } - advisories := []*advisory{} - var current *advisory - var currentField string - var descBuilder strings.Builder - var inPackages bool +func (service *UpdateService) RunWorker(ctx context.Context, operation UpdateOperation, output io.Writer) error { + encoder := json.NewEncoder(output) + service.SetEventHook(func(event UpdateStreamEvent) { _ = encoder.Encode(updateWorkerEnvelope{Event: &event}) }) + result, err := service.Apply(ctx, operation) + if err != nil { + _ = encoder.Encode(updateWorkerEnvelope{Error: err.Error()}) + return err + } + return encoder.Encode(updateWorkerEnvelope{Result: &result}) +} - scanner := bufio.NewScanner(strings.NewReader(infoOutput)) - for scanner.Scan() { - line := scanner.Text() - if strings.TrimSpace(line) == "" { - if inPackages { - inPackages = false - } - continue - } - if strings.HasPrefix(strings.TrimSpace(line), "Name") && strings.Contains(line, ":") { - if current != nil { - if descBuilder.Len() > 0 { - current.description = strings.TrimSpace(descBuilder.String()) - descBuilder.Reset() - } - advisories = append(advisories, current) - } - current = &advisory{pkgNames: make(map[string]bool)} - currentField = "" - inPackages = false - } - if current == nil { - continue - } - trimmedLine := line - colonIdx := strings.Index(line, ":") - if colonIdx == -1 { - continue - } - rawField := strings.TrimSpace(line[:colonIdx]) - value := strings.TrimSpace(line[colonIdx+1:]) - fieldIsEmpty := rawField == "" - var field string - if fieldIsEmpty { - field = currentField - } else { - field = rawField - currentField = field - if field == "Packages" { - inPackages = true - } else if inPackages && field != "" && field != "Packages" { - inPackages = false - } - } - if inPackages && (field == "Packages" || fieldIsEmpty) { - if value != "" && value != ":" { - token := strings.Fields(value)[0] - if token != "" && token != ":" { - name, _, _ := parsePkconPackageToken(token) - if name == "" { - name = token - } - current.pkgNames[name] = true - base := strings.Split(name, ".")[0] - current.pkgNames[base] = true - } - } - continue +func (service *UpdateService) History(ctx context.Context) ([]UpdateHistoryEntry, error) { + if service == nil || service.provider == nil { + return nil, ErrUpdateUnavailable + } + return service.provider.History(ctx, MaxUpdateHistory) +} + +func (service *UpdateService) Snapshot() UpdateObservation { + service.mu.Lock() + defer service.mu.Unlock() + output := make([]UpdateOutput, 0) + for _, event := range service.events { + if event.Kind == "output" { + output = append(output, event.Output) } - switch field { - case "Name": - if current.id == "" { - current.id = value - } - case "Type": - if current.typ == "" && !inPackages && descBuilder.Len() == 0 { - current.typ = strings.ToLower(value) - } - if strings.EqualFold(value, "bugzilla") { - // will capture Url next - } - case "Severity": - if current.severity == "" { - current.severity = strings.ToLower(value) - } - case "Description": - if descBuilder.Len() == 0 { - descBuilder.WriteString(value) - } else { - descBuilder.WriteString("\n" + value) - } - case "": - if currentField == "Description" { - if descBuilder.Len() > 0 { - descBuilder.WriteString("\n" + value) - } else { - descBuilder.WriteString(value) - } - } - case "Url": - if strings.Contains(value, "bugzilla") { - current.bugUrls = append(current.bugUrls, value) - } else if strings.Contains(value, "cve") || strings.Contains(value, "access.redhat") { - current.vendorUrls = append(current.vendorUrls, value) - } else if value != "" { - current.vendorUrls = append(current.vendorUrls, value) - } + } + events := append([]UpdateStreamEvent(nil), service.events...) + return UpdateObservation{Progress: service.progress, Output: output, Events: events} +} + +func (service *UpdateService) Subscribe(after uint64) (<-chan UpdateStreamEvent, func()) { + channel := make(chan UpdateStreamEvent, maxLiveEvents) + service.mu.Lock() + for _, event := range service.events { + sequence := event.Progress.Sequence + if event.Kind == "output" { + sequence = event.Output.Sequence } - if field == "Description" && !fieldIsEmpty { - // already handled + if sequence > after { + channel <- event } - _ = trimmedLine } - if current != nil { - if descBuilder.Len() > 0 { - current.description = strings.TrimSpace(descBuilder.String()) + service.subscribers[channel] = struct{}{} + service.mu.Unlock() + return channel, func() { + service.mu.Lock() + if _, ok := service.subscribers[channel]; ok { + delete(service.subscribers, channel) + close(channel) } - advisories = append(advisories, current) + service.mu.Unlock() } +} - pkgMap := make(map[string]*advisory) - for _, adv := range advisories { - for name := range adv.pkgNames { - pkgMap[name] = adv - } - } +func (service *UpdateService) beginJob(jobID string) { + service.mu.Lock() + service.events = nil + service.eventBytes = 0 + service.activeJob = jobID + service.mu.Unlock() +} +func (service *UpdateService) emitProgress(progress UpdateProgress) { + service.publish(UpdateStreamEvent{Kind: "progress", Progress: progress}) +} - result := make([]UpdatePackage, len(packages)) - copy(result, packages) - for idx := range result { - pkg := &result[idx] - adv, ok := pkgMap[pkg.Name] - if !ok { - continue - } - if adv.id != "" { - pkg.AdvisoryID = adv.id - } - if adv.typ != "" { - switch adv.typ { - case "security": - pkg.Severity = "security" - case "bugfix": - pkg.Severity = "bugfix" - case "enhancement": - pkg.Severity = "enhancement" - } +func (service *UpdateService) publish(event UpdateStreamEvent) { + if service == nil { + return + } + service.mu.Lock() + service.sequence++ + now := time.Now().UTC().Format(time.RFC3339Nano) + if event.Kind == "output" { + event.Output.Sequence, event.Output.Timestamp = service.sequence, now + event.Output.Line = sanitizeUpdateLine(event.Output.Line) + if event.Output.JobID == "" { + event.Output.JobID = service.activeJob } - if adv.description != "" { - pkg.Description = adv.description - pkg.Details = adv.description - if pkg.Summary == "" || pkg.Summary == "(updates)" { - firstLine := strings.Split(strings.TrimSpace(adv.description), "\n")[0] - if len(firstLine) > 120 { - firstLine = firstLine[:120] - } - pkg.Summary = firstLine - } - if cves := cvePattern.FindAllString(adv.description, -1); len(cves) > 0 { - seen := make(map[string]struct{}) - for _, cve := range cves { - if _, exists := seen[cve]; exists { - continue - } - seen[cve] = struct{}{} - url := "https://cve.mitre.org/cgi-bin/cvename.cgi?name=" + cve - already := false - for _, existing := range pkg.CVEUrls { - if existing == url { - already = true - break - } - } - if !already { - pkg.CVEUrls = append(pkg.CVEUrls, url) - } - if pkg.AdvisoryID == "" { - pkg.AdvisoryID = cve - } - } - } + service.eventBytes += len(event.Output.Line) + } else { + event.Kind = "progress" + event.Progress.Sequence, event.Progress.Timestamp = service.sequence, now + if event.Progress.JobID == "" { + event.Progress.JobID = service.activeJob } - if len(adv.bugUrls) > 0 { - pkg.BugUrls = append(pkg.BugUrls, adv.bugUrls...) + service.progress = event.Progress + if !event.Progress.Active && (event.Progress.Phase == "completed" || event.Progress.Phase == "failed" || event.Progress.Phase == "canceled") { + service.activeJob = "" } - if len(adv.vendorUrls) > 0 { - pkg.VendorUrls = append(pkg.VendorUrls, adv.vendorUrls...) + } + service.events = append(service.events, event) + for len(service.events) > maxLiveEvents || service.eventBytes > maxLiveOutput { + removed := service.events[0] + service.events = service.events[1:] + if removed.Kind == "output" { + service.eventBytes -= len(removed.Output.Line) } - if pkg.GroupKey == "" && adv.id != "" { - pkg.GroupKey = adv.id + "@" + pkg.CandidateVersion + } + for subscriber := range service.subscribers { + select { + case subscriber <- event: + default: } } - - return result + hook := service.eventHook + service.mu.Unlock() + if hook != nil { + hook(event) + } } -func updatesWithDependencies(ctx context.Context, dependencies updateDependencies) UpdateStatus { - if dependencies.packageKitAvailable != nil && dependencies.packageKitAvailable(ctx) { - status := UpdateStatus{Available: true, Backend: "PackageKit", Contract: "dbus-read-only", Packages: []UpdatePackage{}} - if dependencies.commandVersion != nil { - status.Version, _ = dependencies.commandVersion(ctx, "pkcon", "--version") - } - // Prefer D-Bus transaction over CLI parsing - if dependencies.packageKitDBusFetch != nil { - if pkgs, version, err := dependencies.packageKitDBusFetch(ctx); err == nil { - if version != "" { - status.Version = version - } - status.Packages = pkgs - // D-Bus already includes CVE/bug/vendor enrichment - status.Packages = tryEnrichWithDnfInfo(ctx, dependencies, status.Packages) - status.Packages = sortUpdates(status.Packages) - if dependencies.packageKitLocked != nil && dependencies.packageKitLocked(ctx) { - status.ExternalLock = true - status.LockReason = packageKitLockReason(ctx) - if status.LockReason == "" { - status.LockReason = "PackageKit transaction in progress" - } - } - status.Message = updateMessage(len(status.Packages)) - if len(status.Packages) == MaxUpdatePackages { - status.Message += " Package list truncated at 500." - } - return status - } +func sanitizeUpdateLine(line string) string { + if !utf8.ValidString(line) { + line = strings.ToValidUTF8(line, "�") + } + line = ansiUpdatePattern.ReplaceAllString(line, "") + line = strings.Map(func(r rune) rune { + if r == '\t' || r >= 0x20 { + return r } - if dependencies.commandExists != nil && dependencies.commandOutput != nil && dependencies.commandExists("pkcon") { - result := dependencies.commandOutput(ctx, "pkcon", "--noninteractive", "get-updates") - if result.Err == nil && result.ExitCode == 0 { - status.Packages = parsePackageKitUpdates(result.Output) - status.Packages = tryEnrichWithDnfInfo(ctx, dependencies, status.Packages) - status.Packages = sortUpdates(status.Packages) - if dependencies.packageKitLocked != nil && dependencies.packageKitLocked(ctx) { - status.ExternalLock = true - status.LockReason = packageKitLockReason(ctx) - if status.LockReason == "" { - status.LockReason = "PackageKit transaction in progress" - } - } - status.Message = updateMessage(len(status.Packages)) - if len(status.Packages) == MaxUpdatePackages { - status.Message += " Package list truncated at 500." - } - return status - } + return -1 + }, line) + if len(line) > maxLiveLine { + line = line[:maxLiveLine] + for !utf8.ValidString(line) { + line = line[:len(line)-1] } - status.Message = "PackageKit is available; update inventory could not be read" - status.Reason = "The PackageKit read-only query did not complete" - return status } - - if dependencies.commandVersion != nil { - if version, ok := dependencies.commandVersion(ctx, "apt-get", "--version"); ok && versionAtLeast(version, 1) { - status := commandUpdateStatus("apt-get", version, dependencies, aptLockPaths) - if dependencies.commandExists != nil && dependencies.commandExists("apt") { - result := dependencies.commandOutput(ctx, "apt", "list", "--upgradable") - if result.Err == nil && result.ExitCode == 0 { - status.Packages = parseAPTUpdates(result.Output) - status.Message = updateMessage(len(status.Packages)) - if len(status.Packages) == MaxUpdatePackages { - status.Message += " Package list truncated at 500." - } - return status - } - } - result := dependencies.commandOutput(ctx, "apt-get", "--just-print", "--simulate", "upgrade") - if result.Err == nil && result.ExitCode == 0 { - status.Packages = parseAPTGetUpdates(result.Output) - status.Message = updateMessage(len(status.Packages)) - if len(status.Packages) == MaxUpdatePackages { - status.Message += " Package list truncated at 500." - } - return status - } - status.Message = "APT is available; update inventory could not be read" - status.Reason = "The bounded APT read-only query did not complete" - return status - } - if version, ok := dependencies.commandVersion(ctx, "dnf", "--version"); ok && versionAtLeast(version, 4) { - status := commandUpdateStatus("dnf", version, dependencies, dnfLockPaths) - result := dependencies.commandOutput(ctx, "dnf", "--assumeno", "check-update") - if (result.Err == nil && (result.ExitCode == 0 || result.ExitCode == 100)) || result.ExitCode == 100 { - status.Packages = parseDNFUpdates(result.Output) - status.Packages = tryEnrichWithDnfInfo(ctx, dependencies, status.Packages) - status.Packages = sortUpdates(status.Packages) - status.Message = updateMessage(len(status.Packages)) - if len(status.Packages) == MaxUpdatePackages { - status.Message += " Package list truncated at 500." - } - return status - } - status.Message = "DNF is available; update inventory could not be read" - status.Reason = "The bounded DNF read-only query did not complete" - return status - } - } - return UpdateStatus{Available: false, Backend: "none", Contract: "unavailable", Packages: []UpdatePackage{}, Message: "No supported read-only update backend detected", Reason: "PackageKit, APT, and DNF are unavailable"} + return line } -func commandUpdateStatus(backend, version string, dependencies updateDependencies, paths func() []string) UpdateStatus { - status := UpdateStatus{Available: true, Backend: backend, Version: version, Contract: "bounded-command-read-only", Packages: []UpdatePackage{}} - for _, path := range paths() { - if dependencies.lockHeld != nil && dependencies.lockHeld(path) { - status.ExternalLock = true - status.LockReason = "A package-manager lock file exists: " + path - break - } - } - return status +type UpdateCommandResult struct { + Output string + ExitCode int + Err error } -func updateLockHeld(path string) bool { - file, err := os.Open(path) - if err != nil { - return false - } - defer file.Close() - fd := int(file.Fd()) - // Try flock first; PackageKit lock detection covers D-Bus, the CLI - // fallback needs a real file lock - if err := syscall.Flock(fd, syscall.LOCK_SH|syscall.LOCK_NB); err != nil { - return true +func RunUpdateCommand(ctx context.Context, name string, arguments []string, environment []string, emit func(UpdateStreamEvent)) UpdateCommandResult { + if err := ctx.Err(); err != nil { + return UpdateCommandResult{ExitCode: -1, Err: err} } - _ = syscall.Flock(fd, syscall.LOCK_UN) - // Dpkg uses fcntl OFD locks, not flock, so also try fcntl (apt/dnf locks) - // Use non-blocking F_SETLK to test if lock held - flock := &syscall.Flock_t{ - Type: syscall.F_WRLCK, - Whence: 0, - Start: 0, - Len: 0, - Pid: 0, - } - if err := syscall.FcntlFlock(uintptr(fd), syscall.F_SETLK, flock); err != nil { - return true + command := exec.CommandContext(ctx, name, arguments...) + command.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + command.Env = append(command.Env, environment...) + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + stdout, err := command.StdoutPipe() + if err != nil { + return UpdateCommandResult{ExitCode: -1, Err: err} } - // Unlock fcntl - flock.Type = syscall.F_UNLCK - _ = syscall.FcntlFlock(uintptr(fd), syscall.F_SETLK, flock) - return false -} - -func aptLockPaths() []string { - return []string{"/var/lib/dpkg/lock-frontend", "/var/lib/dpkg/lock", "/var/lib/apt/lists/lock", "/var/cache/apt/archives/lock"} -} - -func dnfLockPaths() []string { - return []string{"/var/cache/dnf/metadata_lock.pid", "/var/cache/dnf/lock.pid", "/var/run/dnf.pid"} -} - -func packageKitAvailable(ctx context.Context) bool { - conn, err := dbus.ConnectSystemBus() + stderr, err := command.StderrPipe() if err != nil { - return false + return UpdateCommandResult{ExitCode: -1, Err: err} } - defer conn.Close() - var active []string - if err := conn.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.ListNames", 0).Store(&active); err == nil { - for _, name := range active { - if name == "org.freedesktop.PackageKit" { - return true + if err := command.Start(); err != nil { + return UpdateCommandResult{ExitCode: -1, Err: err} + } + var output strings.Builder + var mu sync.Mutex + copyStream := func(reader io.Reader, stream string) { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 32<<10), maxUpdateOutput) + for scanner.Scan() { + line := sanitizeUpdateLine(scanner.Text()) + mu.Lock() + if output.Len() < maxUpdateOutput { + remaining := maxUpdateOutput - output.Len() + if len(line)+1 > remaining { + line = line[:max(0, remaining-1)] + for !utf8.ValidString(line) { + line = line[:len(line)-1] + } + } + output.WriteString(line) + output.WriteByte('\n') + } + mu.Unlock() + if emit != nil { + emit(UpdateStreamEvent{Kind: "output", Output: UpdateOutput{Stream: stream, Line: line}}) } } } - var activatable []string - if err := conn.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.ListActivatableNames", 0).Store(&activatable); err != nil { - return false - } - for _, name := range activatable { - if name == "org.freedesktop.PackageKit" { - return true + var wait sync.WaitGroup + wait.Add(2) + go func() { defer wait.Done(); copyStream(stdout, "stdout") }() + go func() { defer wait.Done(); copyStream(stderr, "stderr") }() + err = command.Wait() + wait.Wait() + exitCode := 0 + if err != nil { + exitCode = -1 + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() } } - return false + return UpdateCommandResult{Output: output.String(), ExitCode: exitCode, Err: err} } -func commandExists(name string) bool { - _, err := exec.LookPath(name) - return err == nil +func streamUpdateOutput(reader io.Reader, stream, jobID string, emit func(UpdateStreamEvent)) { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 32<<10), maxLiveLine*2) + for scanner.Scan() { + emit(UpdateStreamEvent{Kind: "output", Output: UpdateOutput{JobID: jobID, Stream: stream, Line: scanner.Text()}}) + } } +func CommandExists(name string) bool { _, err := exec.LookPath(name); return err == nil } +func commandExists(name string) bool { return CommandExists(name) } + +// Kept private because kpatch shares the same bounded command runner without +// becoming coupled to a distro update adapter. +type updateCommandResult = UpdateCommandResult func runUpdateCommand(ctx context.Context, name string, arguments ...string) updateCommandResult { - // Inventory fallbacks (pkcon/apt/dnf) routinely take 10s+ against a cold - // daemon; the caller's context still bounds us where it is tighter. - commandCtx, cancel := context.WithTimeout(ctx, 20*time.Second) - defer cancel() - command := exec.CommandContext(commandCtx, name, arguments...) - stdout, err := command.StdoutPipe() - if err != nil { - return updateCommandResult{Err: err, ExitCode: -1} - } - if err := command.Start(); err != nil { - return updateCommandResult{Err: err, ExitCode: -1} - } - output, readErr := readBounded(stdout, maxUpdateOutput) - waitErr := command.Wait() - if commandCtx.Err() != nil { - return updateCommandResult{Err: commandCtx.Err(), ExitCode: -1} - } - result := updateCommandResult{Output: string(output), ExitCode: 0} - if readErr != nil { - result.Err = readErr - } - if waitErr != nil { - result.Err = waitErr - var exitErr *exec.ExitError - if errors.As(waitErr, &exitErr) { - result.ExitCode = exitErr.ExitCode() - } else { - result.ExitCode = -1 - } - } - return result + return RunUpdateCommand(ctx, name, arguments, nil, nil) } var versionAtLeastPattern = regexp.MustCompile(`(?:^|\s)([0-9]+)(?:\.[0-9]+)?`) @@ -899,831 +782,84 @@ func versionAtLeast(version string, minimumMajor int) bool { return err == nil && major >= minimumMajor } -func updateMessage(count int) string { - if count == 0 { - return "No installed-software updates are currently available." - } - return fmt.Sprintf("%d installed-software update%s available.", count, pluralSuffix(count)) -} - -func pluralSuffix(count int) string { - if count == 1 { - return "" - } - return "s" -} - -type UpdateOperation struct { - Scope string `json:"scope"` - Packages []string `json:"packages,omitempty"` - ExpectedFingerprint string `json:"expectedFingerprint,omitempty"` - Confirmation string `json:"confirmation,omitempty"` - Preview bool `json:"preview,omitempty"` -} - -type UpdatePreview struct { - Operation UpdateOperation `json:"operation"` - Current UpdateStatus `json:"current"` - Selected []UpdatePackage `json:"selected"` - Changes []string `json:"changes"` - Warnings []string `json:"warnings"` - Fingerprint string `json:"fingerprint"` - Stale bool `json:"stale"` - Allowed bool `json:"allowed"` - RequiresConfirmation bool `json:"requiresConfirmation"` - Reason string `json:"reason,omitempty"` -} - -type UpdateResult struct { - Backend string `json:"backend"` - Scope string `json:"scope"` - Packages []string `json:"packages"` - Updated []UpdatePackage `json:"updated"` - Verified bool `json:"verified"` - Message string `json:"message"` - Fingerprint string `json:"fingerprint"` - Recovery UpdateRecovery `json:"recovery"` -} - -func ValidateUpdateOperation(operation UpdateOperation) error { - if operation.Scope != "all" && operation.Scope != "selected" { - return ErrInvalidUpdateOperation - } - if len(operation.Packages) > MaxUpdatePackages { - return ErrInvalidUpdateOperation - } - seen := make(map[string]struct{}, len(operation.Packages)) - for _, name := range operation.Packages { - if !validPackageName(name) || len(name) > 256 { - return ErrInvalidUpdateOperation - } - if _, exists := seen[name]; exists { - return ErrInvalidUpdateOperation - } - seen[name] = struct{}{} - } - if operation.Scope == "all" && len(operation.Packages) != 0 { - return ErrInvalidUpdateOperation - } - if operation.Scope == "selected" && len(operation.Packages) == 0 { - return ErrInvalidUpdateOperation - } - if operation.ExpectedFingerprint != "" { - if len(operation.ExpectedFingerprint) != sha256.Size*2 { - return ErrInvalidUpdateOperation - } - if _, err := hex.DecodeString(operation.ExpectedFingerprint); err != nil { - return ErrInvalidUpdateOperation - } - } - if len(operation.Confirmation) > 128 || strings.ContainsAny(operation.Confirmation, "\x00\r\n") { - return ErrInvalidUpdateOperation +func UpdateLockHeld(path string) bool { + file, err := os.Open(path) + if err != nil { + return false } - // Confirmation is optional; when provided it must be the dialog-confirmed value. - // Legacy typed "APPLY UPDATES" still accepted for backward compatibility. - if !operation.Preview && operation.Confirmation != "" && operation.Confirmation != "APPLY UPDATES" && operation.Confirmation != "CONFIRM" { - return ErrInvalidUpdateOperation + defer file.Close() + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_SH|syscall.LOCK_NB); err != nil { + return true } - return nil + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + return false } func UpdateFingerprint(status UpdateStatus) string { - packages := append([]UpdatePackage(nil), status.Packages...) - sort.Slice(packages, func(left, right int) bool { - if packages[left].Name != packages[right].Name { - return packages[left].Name < packages[right].Name - } - if packages[left].Architecture != packages[right].Architecture { - return packages[left].Architecture < packages[right].Architecture - } - return packages[left].CandidateVersion < packages[right].CandidateVersion - }) - // Use stable fields only, not volatile enrichment (Size, Severity, Summary). - type stablePackage struct { - Name string `json:"name"` - Architecture string `json:"architecture,omitempty"` - CandidateVersion string `json:"candidateVersion"` - } - stables := make([]stablePackage, len(packages)) - for i, p := range packages { - stables[i] = stablePackage{Name: p.Name, Architecture: p.Architecture, CandidateVersion: p.CandidateVersion} - } + packages := SortUpdatePackages(append([]UpdatePackage(nil), status.Packages...)) payload, _ := json.Marshal(struct { - Backend string `json:"backend"` - Packages []stablePackage `json:"packages"` - ExternalLock bool `json:"externalLock"` - }{status.Backend, stables, status.ExternalLock}) + Backend string + Packages []UpdatePackage + ExternalLock bool + }{status.Backend, packages, status.ExternalLock}) hash := sha256.Sum256(payload) return hex.EncodeToString(hash[:]) } - -func PreviewUpdates(ctx context.Context, operation UpdateOperation, statusFn func(context.Context) UpdateStatus) (UpdatePreview, error) { - operation.Preview = true - if err := ValidateUpdateOperation(operation); err != nil { - return UpdatePreview{}, err - } - if statusFn == nil { - return UpdatePreview{}, ErrUpdateUnavailable - } - current := statusFn(ctx) - if current.Packages == nil { - current.Packages = []UpdatePackage{} - } - if current.Recovery.RestartServices == nil { - current.Recovery.RestartServices = []string{} - } - if current.Recovery.Hints == nil { - current.Recovery.Hints = []string{} - } - preview := UpdatePreview{Operation: operation, Current: current, Selected: make([]UpdatePackage, 0), Changes: []string{}, Warnings: []string{}, Fingerprint: UpdateFingerprint(current)} - if operation.ExpectedFingerprint != "" && operation.ExpectedFingerprint != preview.Fingerprint { - preview.Stale = true - preview.Reason = "The available update inventory changed; refresh before applying." - return preview, nil - } - if !current.Available { - preview.Reason = current.Reason - if preview.Reason == "" { - preview.Reason = "No supported update backend is available." - } - return preview, nil - } - if current.ExternalLock { - preview.Reason = current.LockReason - if preview.Reason == "" { - preview.Reason = "Another package operation currently holds a lock." - } - preview.Warnings = append(preview.Warnings, preview.Reason) - return preview, nil - } - if operation.Scope == "all" { - preview.Selected = append(preview.Selected, current.Packages...) - } else { - for _, requested := range operation.Packages { - found := false - for _, available := range current.Packages { - if available.Name == requested { - preview.Selected = append(preview.Selected, available) - found = true - } - } - if !found { - preview.Stale = true - preview.Reason = "One or more selected packages are no longer available." - return preview, nil - } - } - } - if len(preview.Selected) == 0 { - preview.Reason = "No updates are available for the selected scope." - return preview, nil - } - preview.Allowed = true - preview.RequiresConfirmation = true - preview.Changes = append(preview.Changes, fmt.Sprintf("update %d package%s", len(preview.Selected), pluralSuffix(len(preview.Selected)))) - for _, item := range preview.Selected { - if item.Name == "tako" { - preview.Warnings = append(preview.Warnings, "The web console will restart during this update; the update continues in the background and you can reconnect afterwards.") - break - } - } - preview.Warnings = append(preview.Warnings, "Updates can restart services or require a host reboot.", "An interrupted package operation will not be retried automatically.") - return preview, nil +func UpdatePlanFingerprint(backend string, changes []UpdateChange) string { + payload, _ := json.Marshal(struct { + Backend string + Changes []UpdateChange + }{backend, SortUpdateChanges(append([]UpdateChange(nil), changes...))}) + hash := sha256.Sum256(payload) + return hex.EncodeToString(hash[:]) } - -func ApplyUpdates(ctx context.Context, operation UpdateOperation) (UpdateResult, error) { - if err := ValidateUpdateOperation(operation); err != nil { - return UpdateResult{}, err - } - if operation.Preview { - return UpdateResult{}, ErrInvalidUpdateOperation - } - current := Updates(ctx) - if !current.Available { - return UpdateResult{}, ErrUpdateUnavailable - } - fingerprint := UpdateFingerprint(current) - if operation.ExpectedFingerprint == "" || operation.ExpectedFingerprint != fingerprint { - return UpdateResult{}, ErrUpdateConflict +func SortUpdatePackages(packages []UpdatePackage) []UpdatePackage { + if packages == nil { + return []UpdatePackage{} } - if current.ExternalLock { - return UpdateResult{}, ErrUpdateLocked - } - selected := make([]UpdatePackage, 0, len(current.Packages)) - if operation.Scope == "all" { - selected = append(selected, current.Packages...) - } else { - for _, requested := range operation.Packages { - found := false - for _, available := range current.Packages { - if available.Name == requested { - selected = append(selected, available) - found = true - } - } - if !found { - return UpdateResult{}, ErrUpdateConflict - } - } - } - if len(selected) == 0 { - return UpdateResult{Backend: current.Backend, Scope: operation.Scope, Packages: []string{}, Updated: []UpdatePackage{}, Verified: true, Message: "No updates were available.", Fingerprint: fingerprint, Recovery: UpdateRecoveryForBackend(ctx, current.Backend)}, nil - } - // Prefer D-Bus UpdatePackages for the PackageKit backend - if current.Backend == "PackageKit" { - if err := applyViaPackageKitDBus(ctx, selected); err == nil { - // D-Bus success, proceed to verification - } else if errors.Is(err, ErrUpdateUnavailable) { - // Fallback to CLI if D-Bus not available - arguments, err := updateApplyArguments(current.Backend, operation.Scope, operation.Packages) - if err != nil { - return UpdateResult{}, err - } - result := runLongUpdateCommand(ctx, arguments[0], arguments[1:]...) - if result.Err != nil || result.ExitCode != 0 { - return UpdateResult{}, fmt.Errorf("%w: %s", ErrUpdateApply, boundedUpdateError(result.Err, result.Output)) - } - } else { - return UpdateResult{}, fmt.Errorf("%w: %s", ErrUpdateApply, boundedUpdateError(err, "")) + sort.Slice(packages, func(i, j int) bool { + if packages[i].Name != packages[j].Name { + return packages[i].Name < packages[j].Name } - } else { - arguments, err := updateApplyArguments(current.Backend, operation.Scope, operation.Packages) - if err != nil { - return UpdateResult{}, err - } - result := runLongUpdateCommand(ctx, arguments[0], arguments[1:]...) - if result.Err != nil || result.ExitCode != 0 { - return UpdateResult{}, fmt.Errorf("%w: %s", ErrUpdateApply, boundedUpdateError(result.Err, result.Output)) - } - } - final := Updates(ctx) - if !final.Available { - return UpdateResult{}, ErrUpdateVerification - } - remaining := make(map[string]struct{}, len(final.Packages)) - for _, item := range final.Packages { - key := item.Name + ":" + item.Architecture - remaining[key] = struct{}{} - remaining[item.Name] = struct{}{} // also keep name-only for backward compat with CLI fallback - } - for _, item := range selected { - key := item.Name + ":" + item.Architecture - if _, exists := remaining[key]; exists { - return UpdateResult{}, ErrUpdateVerification - } - if item.Architecture == "" { - if _, exists := remaining[item.Name]; exists { - return UpdateResult{}, ErrUpdateVerification - } - } - } - packages := make([]string, 0, len(selected)) - updated := make([]UpdatePackage, 0, len(selected)) - for _, item := range selected { - packages = append(packages, item.Name) - updated = append(updated, UpdatePackage{ - Name: item.Name, - Architecture: item.Architecture, - CurrentVersion: item.CurrentVersion, - CandidateVersion: item.CandidateVersion, - Severity: item.Severity, - Size: item.Size, - }) - } - return UpdateResult{Backend: current.Backend, Scope: operation.Scope, Packages: packages, Updated: updated, Verified: true, Message: "Updates applied and verified.", Fingerprint: UpdateFingerprint(final), Recovery: UpdateRecoveryForBackend(ctx, current.Backend)}, nil -} - -func applyViaPackageKitDBus(ctx context.Context, selected []UpdatePackage) error { - // Full package IDs look like "name;version;arch;repo" - var ids []string - for _, p := range selected { - if p.PackageID == "" { - return ErrUpdateUnavailable - } - ids = append(ids, p.PackageID) - } - if len(ids) == 0 { - return ErrUpdateUnavailable - } - client, err := packagekit.New() - if err != nil { - return err - } - defer client.Close() - if !client.Detect(ctx) { - return ErrUpdateUnavailable - } - // The transaction itself is observed live by the gateway via D-Bus - // (UpdateSnapshot), so no progress callback is threaded through here. - return client.UpdatePackages(ctx, ids) + return packages[i].Architecture < packages[j].Architecture + }) + return packages } - -func updateApplyArguments(backend, scope string, packages []string) ([]string, error) { - if backend == "PackageKit" { - arguments := []string{"pkcon", "--noninteractive", "update"} - if scope == "selected" { - arguments = append(arguments, packages...) - } - return arguments, nil - } - if backend == "apt-get" { - arguments := []string{"apt-get", "-y", "--no-remove", "--only-upgrade"} - if scope == "all" { - return append(arguments, "upgrade"), nil - } - return append(arguments, append([]string{"install", "--"}, packages...)...), nil +func SortUpdateChanges(changes []UpdateChange) []UpdateChange { + if changes == nil { + return []UpdateChange{} } - if backend == "dnf" { - arguments := []string{"dnf", "-y", "upgrade"} - if scope == "selected" { - arguments = append(arguments, "--") - arguments = append(arguments, packages...) + sort.Slice(changes, func(i, j int) bool { + if changes[i].Name != changes[j].Name { + return changes[i].Name < changes[j].Name } - return arguments, nil - } - return nil, ErrUpdateUnavailable -} - -func validPackageName(value string) bool { - if !validPackageField(value) { - return false - } - for _, character := range value { - if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && !strings.ContainsRune("+_.:@-", character) { - return false + if changes[i].Action != changes[j].Action { + return changes[i].Action < changes[j].Action } - } - return true + return changes[i].Architecture < changes[j].Architecture + }) + return changes } - -func runLongUpdateCommand(ctx context.Context, name string, arguments ...string) updateCommandResult { - if err := ctx.Err(); err != nil { - return updateCommandResult{Err: err, ExitCode: -1} - } - command := exec.Command(name, arguments...) - command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - stdout, err := command.StdoutPipe() - if err != nil { - return updateCommandResult{Err: err, ExitCode: -1} - } - if err := command.Start(); err != nil { - return updateCommandResult{Err: err, ExitCode: -1} - } - processDone := make(chan struct{}) - go func(pid int) { - select { - case <-ctx.Done(): - if pid > 0 { - _ = syscall.Kill(-pid, syscall.SIGTERM) - timer := time.NewTimer(time.Second) - select { - case <-timer.C: - _ = syscall.Kill(-pid, syscall.SIGKILL) - case <-processDone: - _ = timer.Stop() - } - } - case <-processDone: - } - }(command.Process.Pid) - output, readErr := readBounded(stdout, 8<<20) - if readErr != nil && ctx.Err() == nil && command.Process != nil { - _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL) - } - waitErr := command.Wait() - close(processDone) - if ctx.Err() != nil { - return updateCommandResult{Err: ctx.Err(), ExitCode: -1, Output: string(output)} +func updateMessage(count int) string { + if count == 0 { + return "No installed-software updates are currently available." } - result := updateCommandResult{Output: string(output), ExitCode: 0, Err: readErr} - if waitErr != nil { - result.Err = waitErr - var exitErr *exec.ExitError - if errors.As(waitErr, &exitErr) { - result.ExitCode = exitErr.ExitCode() - } else { - result.ExitCode = -1 - } + suffix := "s" + if count == 1 { + suffix = "" } - return result + return fmt.Sprintf("%d installed-software update%s available.", count, suffix) } - func boundedUpdateError(err error, output string) string { message := strings.TrimSpace(output) if message == "" && err != nil { message = err.Error() } - if len(message) > 512 { - message = message[:512] + if len(message) > 1024 { + message = message[:1024] } if message == "" { return "package manager rejected the update request" } return message } - -func parseAPTUpdates(output string) []UpdatePackage { - updates := make([]UpdatePackage, 0) - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() && len(updates) < MaxUpdatePackages { - fields := strings.Fields(scanner.Text()) - if len(fields) < 3 || !strings.Contains(fields[0], "/") || strings.EqualFold(fields[0], "Listing...") { - continue - } - name := strings.SplitN(fields[0], "/", 2)[0] - if !validPackageField(name) || !validPackageField(fields[1]) { - continue - } - item := UpdatePackage{Name: name, CandidateVersion: fields[1], Architecture: fields[2]} - line := scanner.Text() - if start := strings.Index(line, "[upgradable from:"); start >= 0 { - value := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line[start+len("[upgradable from:"):], "["), "]")) - item.CurrentVersion = strings.TrimSpace(value) - } - updates = append(updates, item) - } - return sortUpdates(updates) -} - -func parseAPTGetUpdates(output string) []UpdatePackage { - updates := make([]UpdatePackage, 0) - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() && len(updates) < MaxUpdatePackages { - fields := strings.Fields(scanner.Text()) - if len(fields) < 4 || fields[0] != "Inst" || !validPackageField(fields[1]) { - continue - } - item := UpdatePackage{Name: fields[1]} - if len(fields) > 2 { - item.CurrentVersion = strings.Trim(fields[2], "[]") - } - if len(fields) > 3 { - item.CandidateVersion = strings.Trim(fields[3], "()") - } - updates = append(updates, item) - } - return sortUpdates(updates) -} - -func parseDNFUpdates(output string) []UpdatePackage { - updates := make([]UpdatePackage, 0) - started := false - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() && len(updates) < MaxUpdatePackages { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - fields := strings.Fields(line) - if len(fields) >= 4 && strings.EqualFold(fields[0], "Package") && strings.EqualFold(fields[1], "Arch") { - started = true - continue - } - if !started || len(fields) < 4 || !validPackageField(fields[0]) || !validPackageField(fields[2]) { - continue - } - updates = append(updates, UpdatePackage{Name: fields[0], Architecture: fields[1], CandidateVersion: fields[2], Summary: strings.Join(fields[3:], " ")}) - } - return sortUpdates(updates) -} - -func mapPkconSeverity(tokens string) string { - lower := strings.ToLower(strings.TrimSpace(tokens)) - switch { - case strings.Contains(lower, "security"): - return "security" - case strings.Contains(lower, "bug"): - return "bugfix" - case strings.Contains(lower, "normal"): - // pkcon/Debian report ordinary upgrades as "Normal"; they are - // neither security nor enhancement advisories. - return "bugfix" - case strings.Contains(lower, "enhancement"): - return "enhancement" - case strings.Contains(lower, "available"): - return "enhancement" - default: - return "" - } -} - -func parsePkconPackageToken(token string) (name, version, arch string) { - token = strings.TrimSpace(token) - if token == "" { - return "", "", "" - } - dotIdx := strings.LastIndex(token, ".") - if dotIdx != -1 { - candidateArch := token[dotIdx+1:] - if validArchitecture(candidateArch) { - arch = candidateArch - token = token[:dotIdx] - } - } - splitIdx := -1 - for i := 0; i < len(token)-1; i++ { - if token[i] == '-' && token[i+1] >= '0' && token[i+1] <= '9' { - splitIdx = i - break - } - } - if splitIdx != -1 { - name = token[:splitIdx] - version = token[splitIdx+1:] - } else if idx := strings.LastIndex(token, "-"); idx != -1 { - name = token[:idx] - version = token[idx+1:] - } else { - name = token - } - return name, version, arch -} - -func parsePackageKitUpdates(output string) []UpdatePackage { - updates := make([]UpdatePackage, 0) - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() && len(updates) < MaxUpdatePackages { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - lowerLine := strings.ToLower(line) - if strings.HasPrefix(lowerLine, "transaction:") || strings.HasPrefix(lowerLine, "status:") || strings.HasPrefix(lowerLine, "results:") || strings.HasPrefix(lowerLine, "loading") { - continue - } - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - if strings.EqualFold(fields[0], "package") { - continue - } - if strings.Contains(line, ";") && strings.EqualFold(fields[0], "available") { - if len(fields) < 3 { - continue - } - if strings.Contains(fields[1], ";") { - parts := strings.Split(fields[1], ";") - if len(parts) < 3 || !validPackageField(parts[0]) || !validPackageField(parts[1]) { - continue - } - item := UpdatePackage{Name: parts[0], CandidateVersion: parts[1], Architecture: parts[2]} - if len(parts) > 3 { - item.Summary = strings.Join(append(parts[3:], fields[2:]...), " ") - } - sev := mapPkconSeverity(fields[0]) - if sev != "" { - item.Severity = sev - } - updates = append(updates, item) - continue - } - } - if strings.Contains(fields[0], ".") { - if dotIdx := strings.LastIndex(fields[0], "."); dotIdx != -1 && dotIdx+1 < len(fields[0]) && validArchitecture(fields[0][dotIdx+1:]) { - if len(fields) >= 2 && !strings.HasPrefix(fields[1], "(") && strings.ContainsAny(fields[1], "0123456789") { - namePart := fields[0][:dotIdx] - archPart := fields[0][dotIdx+1:] - if validPackageField(namePart) && validArchitecture(archPart) && validPackageField(fields[1]) { - item := UpdatePackage{Name: namePart, Architecture: archPart, CandidateVersion: fields[1], Summary: strings.Join(fields[2:], " ")} - if strings.Contains(strings.ToLower(item.Summary), "security") { - item.Severity = "security" - } - updates = append(updates, item) - continue - } - } - } - } - packageIdx := -1 - for i, f := range fields { - if strings.HasPrefix(f, "(") { - continue - } - if strings.Contains(f, ".") && strings.Contains(f, "-") { - dotIdx := strings.LastIndex(f, ".") - if dotIdx != -1 && dotIdx+1 < len(f) { - candidateArch := f[dotIdx+1:] - if validArchitecture(candidateArch) { - packageIdx = i - break - } - } - } - } - if packageIdx == -1 { - if len(fields) >= 4 && strings.EqualFold(fields[0], "available") && validPackageField(fields[1]) && validPackageField(fields[2]) { - updates = append(updates, UpdatePackage{Name: fields[1], CandidateVersion: fields[2], Architecture: fields[3], Summary: strings.Join(fields[4:], " "), Severity: mapPkconSeverity(fields[0])}) - } - continue - } - severityRaw := strings.Join(fields[:packageIdx], " ") - severity := mapPkconSeverity(severityRaw) - packageToken := fields[packageIdx] - repo := "" - if packageIdx+1 < len(fields) { - tail := strings.Join(fields[packageIdx+1:], " ") - repo = strings.TrimSpace(strings.Trim(tail, "()")) - } - name, version, arch := parsePkconPackageToken(packageToken) - if !validPackageField(name) || !validPackageField(version) { - continue - } - item := UpdatePackage{ - Name: name, - Architecture: arch, - CandidateVersion: version, - Severity: severity, - Summary: repo, - Details: "", - } - if item.Severity == "" { - item.Severity = "enhancement" - } - updates = append(updates, item) - } - return sortUpdates(updates) -} - -var ( - cvePattern = regexp.MustCompile(`CVE-\d{4}-\d{4,7}`) - bugIDPattern = regexp.MustCompile(`(?i)(?:bug|bz|rhbz)[^0-9]*([0-9]{5,8})`) - // HACK: dnf prints manual-restart lines as "pid : argv"; RHEL-84657 can - // also emit malformed entries, so require the pid prefix. - pidArgumentRe = regexp.MustCompile(`^\d+ : `) -) - -func enrichUpdatePackages(updates []UpdatePackage) []UpdatePackage { - for index := range updates { - pkg := &updates[index] - sourceText := strings.Join([]string{pkg.Summary, pkg.Details, pkg.Description}, " ") - lower := strings.ToLower(sourceText) - - // Sub-classify security severity from vendor errata anchors - // (#Critical/#Important/#Moderate/#Low). - pkg.SecSeverity = secSeverityFromURLs(pkg.VendorUrls) - - if pkg.Severity == "" { - if strings.Contains(lower, "security") || cvePattern.MatchString(sourceText) { - pkg.Severity = "security" - } else if strings.Contains(lower, "bug") && strings.Contains(lower, "fix") || strings.Contains(lower, "bugfix") { - pkg.Severity = "bugfix" - } else if strings.Contains(lower, "enhancement") { - pkg.Severity = "enhancement" - } - } else { - normalized := strings.ToLower(strings.TrimSpace(pkg.Severity)) - switch normalized { - case "critical", "important", "security", "sec", "cve": - pkg.Severity = "security" - case "bug", "bugfix", "bug-fix", "important-bug": - pkg.Severity = "bugfix" - case "enhancement", "feature", "recommended": - pkg.Severity = "enhancement" - default: - pkg.Severity = normalized - } - } - - if pkg.Description == "" { - if pkg.Details != "" { - pkg.Description = pkg.Details - } else if pkg.Summary != "" { - pkg.Description = pkg.Summary - } - } - - foundCVEs := cvePattern.FindAllString(sourceText, -1) - if len(foundCVEs) > 0 { - seen := make(map[string]struct{}, len(foundCVEs)) - for _, cve := range foundCVEs { - if _, exists := seen[cve]; exists { - continue - } - seen[cve] = struct{}{} - url := "https://cve.mitre.org/cgi-bin/cvename.cgi?name=" + cve - already := false - for _, existing := range pkg.CVEUrls { - if existing == url { - already = true - break - } - } - if !already { - pkg.CVEUrls = append(pkg.CVEUrls, url) - } - if pkg.AdvisoryID == "" { - pkg.AdvisoryID = cve - } - } - } - - if strings.Contains(lower, "rhsa") || strings.Contains(lower, "errata") { - if pkg.AdvisoryID == "" { - re := regexp.MustCompile(`(?i)(RHSA-\d{4}:\d+)`) - if match := re.FindString(sourceText); match != "" { - pkg.AdvisoryID = strings.ToUpper(match) - pkg.VendorUrls = append(pkg.VendorUrls, "https://access.redhat.com/errata/"+pkg.AdvisoryID) - } - } - } - - matches := bugIDPattern.FindAllStringSubmatch(sourceText, 5) - for _, match := range matches { - if len(match) < 2 { - continue - } - id := match[1] - url := "https://bugzilla.redhat.com/show_bug.cgi?id=" + id - already := false - for _, existing := range pkg.BugUrls { - if existing == url { - already = true - break - } - } - if !already { - pkg.BugUrls = append(pkg.BugUrls, url) - } - } - - if pkg.AdvisoryID != "" { - pkg.GroupKey = pkg.AdvisoryID + "@" + pkg.CandidateVersion - } else if pkg.Summary != "" { - summaryKey := pkg.Summary - if len(summaryKey) > 64 { - summaryKey = summaryKey[:64] - } - pkg.GroupKey = pkg.CandidateVersion + "@" + summaryKey - } else { - pkg.GroupKey = pkg.CandidateVersion - } - } - - groups := make(map[string][]int) - for idx, pkg := range updates { - if pkg.GroupKey == "" { - continue - } - groups[pkg.GroupKey] = append(groups[pkg.GroupKey], idx) - } - for _, indices := range groups { - if len(indices) < 2 { - continue - } - names := make([]string, 0, len(indices)) - for _, idx := range indices { - names = append(names, updates[idx].Name) - } - for _, idx := range indices { - peers := make([]string, 0, len(names)-1) - for _, name := range names { - if name != updates[idx].Name { - peers = append(peers, name) - } - } - updates[idx].Dependencies = peers - } - } - return updates -} - -func sortUpdates(updates []UpdatePackage) []UpdatePackage { - sort.Slice(updates, func(left, right int) bool { - if updates[left].Name == updates[right].Name { - return updates[left].Architecture < updates[right].Architecture - } - return updates[left].Name < updates[right].Name - }) - return enrichUpdatePackages(updates) -} - -func validPackageField(value string) bool { - return value != "" && len(value) <= 256 && !strings.ContainsAny(value, "\x00\r\n") -} - -// secSeverityFromURLs extracts the errata severity anchor (#Critical and -// friends) from vendor URLs. -func secSeverityFromURLs(urls []string) string { - for _, url := range urls { - hash := strings.LastIndex(url, "#") - if hash == -1 { - continue - } - switch strings.ToLower(url[hash+1:]) { - case "critical": - return "critical" - case "important": - return "important" - case "moderate": - return "moderate" - case "low": - return "low" - } - } - return "" -} - -func validArchitecture(value string) bool { - return validPackageField(value) && len(value) <= 32 && !strings.ContainsAny(value, "/[]:") -} diff --git a/apps/backend/internal/platform/updates_integration_test.go b/apps/backend/internal/platform/updates_integration_test.go index f50a7cd..fdfaf58 100644 --- a/apps/backend/internal/platform/updates_integration_test.go +++ b/apps/backend/internal/platform/updates_integration_test.go @@ -8,7 +8,7 @@ import ( ) // TestUpdatesAgainstHost exercises the real bounded read-only adapter in the -// disposable Linux VM. A host without PackageKit, APT, or DNF is a supported +// disposable Linux VM. A host without its tagged native package manager is a supported // degraded result, but the response must always remain bounded and explicit. func TestUpdatesAgainstHost(t *testing.T) { status := Updates(context.Background()) diff --git a/apps/backend/internal/platform/updates_live.go b/apps/backend/internal/platform/updates_live.go deleted file mode 100644 index 73e734d..0000000 --- a/apps/backend/internal/platform/updates_live.go +++ /dev/null @@ -1,138 +0,0 @@ -package platform - -import ( - "context" - "strings" - "time" - - "github.com/velopulent/tako/internal/packagekit" -) - -// UpdateLive is a point-in-time observation of the running package-update -// transaction. The gateway reads it straight from PackageKit so it can render -// progress for updates started by sessiond, pkcon, or an external dnf client. -type UpdateLive struct { - Active bool `json:"active"` - Source string `json:"source,omitempty"` - Percentage int `json:"percentage"` - AllowCancel bool `json:"allowCancel"` - Status string `json:"status,omitempty"` - CurrentPackage string `json:"currentPackage,omitempty"` - RemainingSeconds int64 `json:"remainingSeconds,omitempty"` - TransactionPath string `json:"transactionPath,omitempty"` -} - -// unknownProgress matches PackageKit's Percentage=101 sentinel. -const unknownProgress = -1 - -// UpdateLiveStatus snapshots the running update transaction, or reports an -// inactive snapshot when nothing is in flight. -func UpdateLiveStatus(ctx context.Context) UpdateLive { - client, err := packagekit.New() - if err != nil { - return InactiveUpdateLive() - } - defer client.Close() - if !client.Detect(ctx) { - return InactiveUpdateLive() - } - return UpdateLiveFromSnapshot(client.UpdateSnapshot(ctx)) -} - -// InactiveUpdateLive reports no running update. -func InactiveUpdateLive() UpdateLive { - return UpdateLive{Active: false, Percentage: unknownProgress} -} - -// UpdateLiveFromSnapshot converts a raw PackageKit snapshot into the API shape; -// a nil snapshot becomes an inactive report. -func UpdateLiveFromSnapshot(snapshot *packagekit.LiveUpdateSnapshot) UpdateLive { - if snapshot == nil { - return InactiveUpdateLive() - } - live := UpdateLive{ - Active: true, - Source: "packagekit", - Status: snapshot.StatusMessage, - CurrentPackage: FormatPackageID(snapshot.LastPackage), - AllowCancel: snapshot.AllowCancel, - TransactionPath: snapshot.TransactionPath, - } - if snapshot.Percentage <= 100 { - live.Percentage = int(snapshot.Percentage) - } else { - live.Percentage = unknownProgress - } - if snapshot.RemainingTime > 0 { - live.RemainingSeconds = snapshot.RemainingTime - } - return live -} - -// FormatPackageID renders a PackageKit package id ("name;version;arch;repo") -// as "name version (arch)". -func FormatPackageID(packageID string) string { - fields := strings.SplitN(packageID, ";", 4) - if len(fields) < 3 || fields[0] == "" { - return "" - } - name := fields[0] + " " + fields[1] - if fields[2] != "" { - name += " (" + fields[2] + ")" - } - return name -} - -// UpdateHistoryEntry is one past update transaction: wall-clock time plus the -// packages it touched (name -> version). -type UpdateHistoryEntry struct { - Time int64 `json:"time"` - Packages map[string]string `json:"packages"` -} - -// MaxUpdateHistory bounds the returned history window. -const MaxUpdateHistory = 20 - -// UpdateHistory returns recent package-update transactions, newest first. -func UpdateHistory(ctx context.Context) ([]UpdateHistoryEntry, error) { - client, err := packagekit.New() - if err != nil { - return nil, ErrUpdateUnavailable - } - defer client.Close() - if !client.Detect(ctx) { - return nil, ErrUpdateUnavailable - } - historyCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - defer cancel() - entries, err := client.GetOldTransactions(historyCtx) - if err != nil { - return nil, err - } - result := make([]UpdateHistoryEntry, 0, len(entries)) - for _, entry := range entries { - packages := entry.Packages - if packages == nil { - packages = map[string]string{} - } - result = append(result, UpdateHistoryEntry{Time: entry.Time, Packages: packages}) - } - return result, nil -} - -// CancelRunningUpdate cancels the currently active package-update transaction -// regardless of who started it. It returns false when no transaction was -// running. -func CancelRunningUpdate(ctx context.Context) (bool, error) { - client, err := packagekit.New() - if err != nil { - return false, ErrUpdateUnavailable - } - defer client.Close() - if !client.Detect(ctx) { - return false, ErrUpdateUnavailable - } - cancelCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - return client.CancelActiveUpdate(cancelCtx) -} diff --git a/apps/backend/internal/platform/updates_test.go b/apps/backend/internal/platform/updates_test.go index 4089ccd..2d87864 100644 --- a/apps/backend/internal/platform/updates_test.go +++ b/apps/backend/internal/platform/updates_test.go @@ -5,179 +5,113 @@ import ( "errors" "strings" "testing" - "time" ) -func TestUpdateParsersStayBoundedAndExposeVersions(t *testing.T) { - apt := parseAPTUpdates("" + - "WARNING: apt does not have a stable CLI interface. Use with caution in scripts.\n" + - "Listing...\n" + - "openssl/bookworm-security 3.0.14-1~deb12u2 amd64 [upgradable from: 3.0.11-1~deb12u1]\n") - if len(apt) != 1 || apt[0].Name != "openssl" || apt[0].CurrentVersion == "" || apt[0].CandidateVersion == "" || apt[0].Architecture != "amd64" { - t.Fatalf("unexpected apt inventory: %#v", apt) - } - dnf := parseDNFUpdates("Last metadata expiration check: 0:01:00 ago on Tue.\nPackage Arch Version Repository\nopenssl x86_64 3.0.14-1 updates\n") - if len(dnf) != 1 || dnf[0].Name != "openssl" || dnf[0].CandidateVersion != "3.0.14-1" { - t.Fatalf("unexpected dnf inventory: %#v", dnf) - } - packageKit := parsePackageKitUpdates("Available packages\nopenssl.x86_64 3.0.14-1 Security update\n") - if len(packageKit) != 1 || packageKit[0].Name != "openssl" || packageKit[0].Architecture != "x86_64" || packageKit[0].Summary == "" { - t.Fatalf("unexpected PackageKit inventory: %#v", packageKit) - } - packageKitID := parsePackageKitUpdates("Available openssl;3.0.14-1;x86_64;updates Security update\n") - if len(packageKitID) != 1 || packageKitID[0].Name != "openssl" || packageKitID[0].CandidateVersion != "3.0.14-1" || packageKitID[0].Architecture != "x86_64" { - t.Fatalf("PackageKit package id was not decoded: %#v", packageKitID) - } - oversized := strings.Repeat("pkg/updates 1.0 amd64\n", MaxUpdatePackages+10) - if got := len(parseAPTUpdates(oversized)); got != MaxUpdatePackages { - t.Fatalf("parser exceeded package bound: %d", got) - } +type fakeUpdateProvider struct { + packages []UpdatePackage + changes []UpdateChange + applied bool + applyStarted chan struct{} + applyRelease chan struct{} + applyCtxErr error } -func TestPackageKitParserDecodesDebianNormalStatus(t *testing.T) { - // Exact shape reported from Debian trixie: status word, nevra.arch token - // with tilde revisions, repo in parentheses, free-text summary. - debian := parsePackageKitUpdates("Normal docker-buildx-plugin-0.37.0-1~debian.13~trixie.amd64 (docker-trixie-stable) Docker Buildx cli plugin.\n") - if len(debian) != 1 { - t.Fatalf("unexpected Debian PackageKit inventory: %#v", debian) - } - item := debian[0] - if item.Name != "docker-buildx-plugin" || item.Architecture != "amd64" || item.CandidateVersion != "0.37.0-1~debian.13~trixie" { - t.Fatalf("Debian package token was not decoded: %#v", item) - } - if item.Severity == "security" || item.Severity == "" { - t.Fatalf("Debian normal severity misclassified: %#v", item) +func TestUpdateOutputSanitizedAndReplayBounded(t *testing.T) { + service := NewUpdateService(&fakeUpdateProvider{}) + for index := 0; index < maxLiveEvents+20; index++ { + service.publish(UpdateStreamEvent{Kind: "output", Output: UpdateOutput{Stream: "stdout", Line: "\x1b[31mok\x00" + strings.Repeat("x", maxLiveLine-1) + "界界"}}) + } + observation := service.Snapshot() + if len(observation.Output) > maxLiveEvents { + t.Fatalf("replay exceeded event bound: %d", len(observation.Output)) + } + for _, output := range observation.Output { + if strings.Contains(output.Line, "\x1b") || strings.Contains(output.Line, "\x00") || len(output.Line) > maxLiveLine || strings.ToValidUTF8(output.Line, "") != output.Line { + t.Fatalf("unsafe output retained: %q", output.Line[:min(len(output.Line), 32)]) + } } } -func TestUpdatesPreferPackageKitAndRemainReadOnly(t *testing.T) { - var calls []string - status := updatesWithDependencies(context.Background(), updateDependencies{ - packageKitAvailable: func(context.Context) bool { return true }, - commandExists: func(name string) bool { return name == "pkcon" }, - commandVersion: func(_ context.Context, name string, _ ...string) (string, bool) { - if name == "pkcon" { - return "pkcon 1.2", true - } - return "", false - }, - commandOutput: func(_ context.Context, name string, args ...string) updateCommandResult { - calls = append(calls, name+" "+strings.Join(args, " ")) - return updateCommandResult{Output: "Available packages\nvim.x86_64 9.0 editor\n", ExitCode: 0} - }, - }) - if !status.Available || status.Backend != "PackageKit" || status.Contract != "dbus-read-only" || len(status.Packages) != 1 || status.Packages[0].Name != "vim" { - t.Fatalf("unexpected PackageKit status: %#v", status) - } - if len(calls) != 1 || calls[0] != "pkcon --noninteractive get-updates" { - t.Fatalf("unexpected PackageKit command: %#v", calls) - } +func (provider *fakeUpdateProvider) Name() string { return "fake" } +func (provider *fakeUpdateProvider) Probe(context.Context) (string, error) { return "1", nil } +func (provider *fakeUpdateProvider) Inventory(context.Context) ([]UpdatePackage, error) { + return append([]UpdatePackage(nil), provider.packages...), nil } - -func TestUpdatesUseVersionGatedAPTAndReportHeldLock(t *testing.T) { - var calls []string - status := updatesWithDependencies(context.Background(), updateDependencies{ - packageKitAvailable: func(context.Context) bool { return false }, - commandExists: func(name string) bool { return name == "apt" }, - commandVersion: func(_ context.Context, name string, _ ...string) (string, bool) { - if name == "apt-get" { - return "apt 3.0", true - } - return "", false - }, - commandOutput: func(_ context.Context, name string, args ...string) updateCommandResult { - calls = append(calls, name+" "+strings.Join(args, " ")) - return updateCommandResult{Output: "Listing...\nvim/bookworm 9.0 amd64 [upgradable from: 8.2]\n", ExitCode: 0} - }, - lockHeld: func(path string) bool { return path == "/var/lib/dpkg/lock" }, - }) - if !status.Available || status.Backend != "apt-get" || !status.ExternalLock || status.LockReason == "" || len(status.Packages) != 1 { - t.Fatalf("unexpected APT status: %#v", status) - } - if len(calls) != 1 || calls[0] != "apt list --upgradable" { - t.Fatalf("APT did not use read-only inventory command: %#v", calls) - } +func (*fakeUpdateProvider) Refresh(context.Context, bool, func(UpdateStreamEvent)) error { return nil } +func (provider *fakeUpdateProvider) Plan(context.Context) ([]UpdateChange, error) { + return append([]UpdateChange(nil), provider.changes...), nil } - -func TestUpdatesAcceptDNFCheckUpdateExitCodeAndFailClosed(t *testing.T) { - status := updatesWithDependencies(context.Background(), updateDependencies{ - packageKitAvailable: func(context.Context) bool { return false }, - commandExists: func(string) bool { return false }, - commandVersion: func(_ context.Context, name string, _ ...string) (string, bool) { - if name == "apt-get" { - return "apt 0.9", true - } - if name == "dnf" { - return "dnf 4.18", true - } - return "", false - }, - commandOutput: func(_ context.Context, _ string, _ ...string) updateCommandResult { - return updateCommandResult{Output: "Package Arch Version Repository\nvim x86_64 9.0 updates\n", ExitCode: 100, Err: errors.New("updates available")} - }, - }) - if status.Backend != "dnf" || len(status.Packages) != 1 || status.Packages[0].CandidateVersion != "9.0" { - t.Fatalf("DNF exit 100 was not treated as inventory: %#v", status) - } - unavailable := updatesWithDependencies(context.Background(), updateDependencies{ - packageKitAvailable: func(context.Context) bool { return false }, - commandVersion: func(context.Context, string, ...string) (string, bool) { return "", false }, - }) - if unavailable.Available || unavailable.Contract != "unavailable" || unavailable.Backend != "none" { - t.Fatalf("unsupported backend did not fail closed: %#v", unavailable) - } +func (provider *fakeUpdateProvider) Apply(ctx context.Context, _ func(UpdateStreamEvent)) error { + if provider.applyStarted != nil { + close(provider.applyStarted) + <-provider.applyRelease + provider.applyCtxErr = ctx.Err() + } + provider.applied = true + provider.packages = nil + return nil } -func TestUpdateOperationsRejectUnsafeInputsAndDetectStaleInventory(t *testing.T) { - if err := ValidateUpdateOperation(UpdateOperation{Scope: "all", Packages: []string{"unexpected"}, Preview: true}); !errors.Is(err, ErrInvalidUpdateOperation) { - t.Fatalf("all-scope packages accepted: %v", err) - } - if err := ValidateUpdateOperation(UpdateOperation{Scope: "selected", Packages: []string{"bad/name"}, Preview: true}); !errors.Is(err, ErrInvalidUpdateOperation) { - t.Fatalf("unsafe package name accepted: %v", err) - } - status := UpdateStatus{Available: true, Backend: "apt-get", Version: "apt 3.0", Contract: "bounded-command-read-only", Packages: []UpdatePackage{{Name: "vim", CandidateVersion: "9.0"}}} - preview, err := PreviewUpdates(context.Background(), UpdateOperation{Scope: "selected", Packages: []string{"vim"}, ExpectedFingerprint: strings.Repeat("a", 64)}, func(context.Context) UpdateStatus { return status }) - if err != nil { - t.Fatal(err) - } - if !preview.Stale || preview.Allowed || preview.Reason == "" { - t.Fatalf("stale preview was allowed: %#v", preview) - } - if len(UpdateFingerprint(status)) != 64 { - t.Fatalf("unexpected update fingerprint: %q", UpdateFingerprint(status)) +func TestUpdateServiceCommitContinuesAfterCallerDisconnect(t *testing.T) { + provider := &fakeUpdateProvider{ + packages: []UpdatePackage{{Name: "one", CurrentVersion: "1", CandidateVersion: "2"}}, + changes: []UpdateChange{{Action: "upgrade", Name: "one", CurrentVersion: "1", CandidateVersion: "2"}}, + applyStarted: make(chan struct{}), + applyRelease: make(chan struct{}), + } + service := NewUpdateService(provider) + fingerprint := UpdatePlanFingerprint("fake", provider.changes) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + _, err := service.Apply(ctx, UpdateOperation{ExpectedFingerprint: fingerprint, Confirmed: true}) + result <- err + }() + <-provider.applyStarted + cancel() + close(provider.applyRelease) + if err := <-result; err != nil { + t.Fatalf("committed update stopped after disconnect: %v", err) + } + if provider.applyCtxErr != nil { + t.Fatalf("provider inherited caller cancellation: %v", provider.applyCtxErr) } } +func (*fakeUpdateProvider) History(context.Context, int) ([]UpdateHistoryEntry, error) { + return []UpdateHistoryEntry{}, nil +} +func (*fakeUpdateProvider) Recovery(context.Context) UpdateRecovery { + return UpdateRecovery{RestartServices: []string{}, Hints: []string{}} +} +func (*fakeUpdateProvider) LockStatus(context.Context) (bool, string) { return false, "" } -func TestUpdateApplyArgumentsAreBackendSpecificAndBounded(t *testing.T) { - cases := []struct { - backend string - scope string - want []string - }{ - {"PackageKit", "selected", []string{"pkcon", "--noninteractive", "update", "vim"}}, - {"apt-get", "all", []string{"apt-get", "-y", "--no-remove", "--only-upgrade", "upgrade"}}, - {"dnf", "selected", []string{"dnf", "-y", "upgrade", "--", "vim"}}, +func TestUpdateServiceRequiresExactPlanAndRiskConfirmation(t *testing.T) { + provider := &fakeUpdateProvider{ + packages: []UpdatePackage{{Name: "old", CurrentVersion: "2", CandidateVersion: "1"}}, + changes: []UpdateChange{{Action: "downgrade", Name: "old", CurrentVersion: "2", CandidateVersion: "1"}}, } - for _, item := range cases { - got, err := updateApplyArguments(item.backend, item.scope, []string{"vim"}) - if err != nil || strings.Join(got, " ") != strings.Join(item.want, " ") { - t.Fatalf("%s/%s args=%v err=%v, want %v", item.backend, item.scope, got, err, item.want) - } + service := NewUpdateService(provider) + status := service.Status(context.Background()) + preview, err := service.Preview(context.Background(), UpdateOperation{ExpectedFingerprint: status.Fingerprint}) + if err != nil || !preview.RequiresRiskConfirmation || preview.Fingerprint == "" { + t.Fatalf("unexpected preview: %#v, %v", preview, err) } -} - -func TestLongUpdateCommandStopsItsProcessGroupOnCancellation(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - started := time.Now() - result := runLongUpdateCommand(ctx, "sh", "-c", "sleep 10") - if !errors.Is(result.Err, context.DeadlineExceeded) || time.Since(started) > 2*time.Second { - t.Fatalf("canceled update command was not bounded: result=%#v elapsed=%s", result, time.Since(started)) + _, err = service.Apply(context.Background(), UpdateOperation{ExpectedFingerprint: preview.Fingerprint, Confirmed: true}) + if !errors.Is(err, ErrUpdateRiskNotAccepted) || provider.applied { + t.Fatalf("risky plan applied without confirmation: %v", err) + } + result, err := service.Apply(context.Background(), UpdateOperation{ExpectedFingerprint: preview.Fingerprint, Confirmed: true, RiskAccepted: true}) + if err != nil || !result.Verified || !provider.applied { + t.Fatalf("apply failed: %#v, %v", result, err) } } -func TestVersionGate(t *testing.T) { - if !versionAtLeast("apt 3.0", 1) || versionAtLeast("dnf 3.0", 4) || versionAtLeast("unknown", 1) { - t.Fatal("version gate misclassified backend versions") +func TestUpdateServiceRejectsStalePlan(t *testing.T) { + provider := &fakeUpdateProvider{changes: []UpdateChange{{Action: "upgrade", Name: "one", CandidateVersion: "2"}}} + service := NewUpdateService(provider) + stale := UpdatePlanFingerprint("fake", []UpdateChange{{Action: "upgrade", Name: "other"}}) + _, err := service.Apply(context.Background(), UpdateOperation{ExpectedFingerprint: stale, Confirmed: true}) + if !errors.Is(err, ErrUpdateConflict) { + t.Fatalf("expected stale-plan conflict, got %v", err) } } diff --git a/apps/backend/internal/updateproviders/apt/apt.go b/apps/backend/internal/updateproviders/apt/apt.go new file mode 100644 index 0000000..d80c892 --- /dev/null +++ b/apps/backend/internal/updateproviders/apt/apt.go @@ -0,0 +1,195 @@ +package apt + +import ( + "bufio" + "context" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/velopulent/tako/internal/platform" +) + +type Provider struct{} + +func New() Provider { return Provider{} } +func (Provider) Name() string { return "apt" } + +func (Provider) Probe(ctx context.Context) (string, error) { + if !platform.CommandExists("apt-get") { + return "", platform.ErrUpdateUnavailable + } + result := platform.RunUpdateCommand(ctx, "apt-get", []string{"--version"}, nil, nil) + if result.ExitCode != 0 { + return "", platform.ErrUpdateUnavailable + } + line, _, _ := strings.Cut(strings.TrimSpace(result.Output), "\n") + return line, nil +} + +func (Provider) Inventory(ctx context.Context) ([]platform.UpdatePackage, error) { + if !platform.CommandExists("apt") { + return nil, platform.ErrUpdateUnavailable + } + result := platform.RunUpdateCommand(ctx, "apt", []string{"list", "--upgradable"}, nil, nil) + if result.ExitCode != 0 { + return nil, result.Err + } + return parseInventory(result.Output), nil +} + +func (Provider) Refresh(ctx context.Context, force bool, emit func(platform.UpdateStreamEvent)) error { + args := []string{"update"} + if force { + args = append([]string{"-o", "Acquire::http::No-Cache=true"}, args...) + } + result := platform.RunUpdateCommand(ctx, "apt-get", args, []string{"DEBIAN_FRONTEND=noninteractive"}, emit) + if result.ExitCode != 0 { + return result.Err + } + return nil +} + +func (Provider) Plan(ctx context.Context) ([]platform.UpdateChange, error) { + result := platform.RunUpdateCommand(ctx, "apt-get", []string{"--simulate", "dist-upgrade"}, []string{"DEBIAN_FRONTEND=noninteractive"}, nil) + if result.ExitCode != 0 { + return nil, result.Err + } + return parsePlan(result.Output), nil +} + +func (Provider) Apply(ctx context.Context, emit func(platform.UpdateStreamEvent)) error { + wrapped := func(event platform.UpdateStreamEvent) { + if emit == nil { + return + } + if progress, ok := parseStatus(event); ok { + emit(platform.UpdateStreamEvent{Kind: "progress", Progress: progress}) + return + } + emit(event) + } + result := platform.RunUpdateCommand(ctx, "apt-get", []string{"-y", "-o", "APT::Status-Fd=1", "dist-upgrade"}, []string{"DEBIAN_FRONTEND=noninteractive"}, wrapped) + if result.ExitCode != 0 { + return result.Err + } + return nil +} + +func parseStatus(event platform.UpdateStreamEvent) (platform.UpdateProgress, bool) { + if event.Kind != "output" || event.Output.Stream != "stdout" { + return platform.UpdateProgress{}, false + } + fields := strings.SplitN(event.Output.Line, ":", 4) + if len(fields) != 4 || (fields[0] != "dlstatus" && fields[0] != "pmstatus") { + return platform.UpdateProgress{}, false + } + percent, err := strconv.ParseFloat(fields[2], 64) + if err != nil { + return platform.UpdateProgress{}, false + } + phase := "applying" + if fields[0] == "dlstatus" { + phase = "downloading" + } + return platform.UpdateProgress{Active: true, Phase: phase, Package: fields[1], Percent: int(percent), Message: fields[3], Cancelable: false}, true +} + +func (Provider) LockStatus(_ context.Context) (bool, string) { + for _, path := range []string{"/var/lib/dpkg/lock-frontend", "/var/lib/dpkg/lock", "/var/lib/apt/lists/lock", "/var/cache/apt/archives/lock"} { + if platform.UpdateLockHeld(path) { + return true, "APT lock is held: " + path + } + } + return false, "" +} + +func (Provider) Recovery(_ context.Context) platform.UpdateRecovery { + recovery := platform.UpdateRecovery{RestartServices: []string{}, Hints: []string{"Restart services affected by updated libraries."}, Source: "advisory"} + if _, err := os.Stat("/var/run/reboot-required"); err == nil { + recovery.Authoritative = true + recovery.RebootRequired = true + recovery.Source = "/var/run/reboot-required" + recovery.Hints = []string{"Reboot the host after updates complete."} + } + return recovery +} + +func (Provider) History(_ context.Context, limit int) ([]platform.UpdateHistoryEntry, error) { + data, err := os.ReadFile("/var/log/apt/history.log") + if err != nil { + return nil, err + } + entries := parseHistory(string(data)) + if len(entries) > limit { + entries = entries[len(entries)-limit:] + } + for left, right := 0, len(entries)-1; left < right; left, right = left+1, right-1 { + entries[left], entries[right] = entries[right], entries[left] + } + return entries, nil +} + +var aptLine = regexp.MustCompile(`^([^/\s]+)/\S+\s+(\S+)\s+(\S+)\s+\[upgradable from:\s*([^\]]+)\]`) + +func parseInventory(output string) []platform.UpdatePackage { + items := []platform.UpdatePackage{} + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() && len(items) < platform.MaxUpdatePackages { + match := aptLine.FindStringSubmatch(strings.TrimSpace(scanner.Text())) + if len(match) == 5 { + items = append(items, platform.UpdatePackage{Name: match[1], CandidateVersion: match[2], Architecture: match[3], CurrentVersion: match[4]}) + } + } + return platform.SortUpdatePackages(items) +} + +var aptPlanLine = regexp.MustCompile(`^(Inst|Remv)\s+(\S+)(?:\s+\[([^\]]+)\])?(?:\s+\(([^\s\)]+))?`) + +func parsePlan(output string) []platform.UpdateChange { + changes := []platform.UpdateChange{} + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + match := aptPlanLine.FindStringSubmatch(strings.TrimSpace(scanner.Text())) + if len(match) == 0 { + continue + } + action := "upgrade" + if match[1] == "Remv" { + action = "remove" + } else if match[3] == "" { + action = "install" + } + changes = append(changes, platform.UpdateChange{Action: action, Name: match[2], CurrentVersion: match[3], CandidateVersion: match[4]}) + } + return platform.SortUpdateChanges(changes) +} + +func parseHistory(content string) []platform.UpdateHistoryEntry { + entries := []platform.UpdateHistoryEntry{} + var current *platform.UpdateHistoryEntry + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "Start-Date: ") { + value := strings.TrimSpace(strings.TrimPrefix(line, "Start-Date: ")) + parsed, err := time.Parse("2006-01-02 15:04:05", value) + if err == nil { + current = &platform.UpdateHistoryEntry{Time: parsed.UnixMilli(), Packages: map[string]string{}} + } + } else if current != nil && strings.HasPrefix(line, "Upgrade: ") { + for _, item := range strings.Split(strings.TrimPrefix(line, "Upgrade: "), "),") { + fields := strings.Split(strings.TrimSpace(strings.TrimSuffix(item, ")")), " ") + if len(fields) >= 3 { + current.Packages[strings.Split(fields[0], ":")[0]] = strings.Trim(fields[2], ",") + } + } + } else if line == "End-Date: " || strings.HasPrefix(line, "End-Date:") { + if current != nil && len(current.Packages) > 0 { + entries = append(entries, *current) + } + current = nil + } + } + return entries +} diff --git a/apps/backend/internal/updateproviders/apt/apt_test.go b/apps/backend/internal/updateproviders/apt/apt_test.go new file mode 100644 index 0000000..4682b01 --- /dev/null +++ b/apps/backend/internal/updateproviders/apt/apt_test.go @@ -0,0 +1,32 @@ +package apt + +import ( + "testing" + + "github.com/velopulent/tako/internal/platform" +) + +func TestParseInventoryAndPlan(t *testing.T) { + items := parseInventory("openssl/bookworm 3.0.14 amd64 [upgradable from: 3.0.11]\n") + if len(items) != 1 || items[0].Name != "openssl" || items[0].CurrentVersion != "3.0.11" { + t.Fatalf("unexpected inventory: %#v", items) + } + changes := parsePlan("Inst openssl [3.0.11] (3.0.14 Debian:stable [amd64])\nRemv obsolete [1.0]\n") + if len(changes) != 2 || changes[0].Name != "obsolete" || changes[0].Action != "remove" || changes[1].Action != "upgrade" { + t.Fatalf("unexpected plan: %#v", changes) + } +} + +func TestParseDocumentedStatusFD(t *testing.T) { + progress, ok := parseStatus(platform.UpdateStreamEvent{Kind: "output", Output: platform.UpdateOutput{Stream: "stdout", Line: "dlstatus:openssl:42.5:Downloading"}}) + if !ok || progress.Phase != "downloading" || progress.Percent != 42 || progress.Cancelable { + t.Fatalf("unexpected progress: %#v", progress) + } +} + +func TestParseHistory(t *testing.T) { + items := parseHistory("Start-Date: 2026-01-02 03:04:05\nUpgrade: openssl:amd64 (1, 2)\nEnd-Date: 2026-01-02 03:04:06\n") + if len(items) != 1 || items[0].Packages["openssl"] != "2" { + t.Fatalf("unexpected history: %#v", items) + } +} diff --git a/apps/backend/internal/updateproviders/dnf/dnf.go b/apps/backend/internal/updateproviders/dnf/dnf.go new file mode 100644 index 0000000..6f8ffc7 --- /dev/null +++ b/apps/backend/internal/updateproviders/dnf/dnf.go @@ -0,0 +1,206 @@ +package dnf + +import ( + "bufio" + "context" + "encoding/json" + "github.com/velopulent/tako/internal/platform" + "os" + "regexp" + "strings" + "time" +) + +type Provider struct{} + +func New() Provider { return Provider{} } +func (Provider) Name() string { return "dnf" } +func (Provider) Probe(ctx context.Context) (string, error) { + if !platform.CommandExists("dnf") { + return "", platform.ErrUpdateUnavailable + } + r := platform.RunUpdateCommand(ctx, "dnf", []string{"--version"}, nil, nil) + if r.ExitCode != 0 { + return "", platform.ErrUpdateUnavailable + } + line, _, _ := strings.Cut(strings.TrimSpace(r.Output), "\n") + return line, nil +} +func (p Provider) Inventory(ctx context.Context) ([]platform.UpdatePackage, error) { + version, _ := p.Probe(ctx) + if strings.Contains(strings.ToLower(version), "dnf5") { + r := platform.RunUpdateCommand(ctx, "dnf", []string{"--cacheonly", "check-upgrade", "--json"}, nil, nil) + if r.ExitCode == 0 || r.ExitCode == 100 { + if items := parseJSON(r.Output); len(items) > 0 || r.ExitCode == 0 { + return items, nil + } + } + } + r := platform.RunUpdateCommand(ctx, "dnf", []string{"--cacheonly", "--assumeno", "check-update"}, nil, nil) + if r.ExitCode != 0 && r.ExitCode != 100 { + return nil, r.Err + } + return parseTable(r.Output), nil +} +func (Provider) Refresh(ctx context.Context, force bool, emit func(platform.UpdateStreamEvent)) error { + args := []string{"makecache"} + if force { + args = append(args, "--refresh") + } + r := platform.RunUpdateCommand(ctx, "dnf", args, nil, emit) + if r.ExitCode != 0 { + return r.Err + } + return nil +} +func (p Provider) Plan(ctx context.Context) ([]platform.UpdateChange, error) { + r := platform.RunUpdateCommand(ctx, "dnf", []string{"--cacheonly", "--assumeno", "upgrade"}, nil, nil) + changes := parsePlan(r.Output) + if len(changes) > 0 || r.ExitCode == 0 { + return changes, nil + } + items, err := p.Inventory(ctx) + if err != nil { + return nil, err + } + changes = make([]platform.UpdateChange, 0, len(items)) + for _, item := range items { + changes = append(changes, platform.UpdateChange{Action: "upgrade", Name: item.Name, Architecture: item.Architecture, CurrentVersion: item.CurrentVersion, CandidateVersion: item.CandidateVersion}) + } + return platform.SortUpdateChanges(changes), nil +} +func (Provider) Apply(ctx context.Context, emit func(platform.UpdateStreamEvent)) error { + r := platform.RunUpdateCommand(ctx, "dnf", []string{"-y", "upgrade"}, nil, emit) + if r.ExitCode != 0 { + return r.Err + } + return nil +} +func (Provider) LockStatus(_ context.Context) (bool, string) { + for _, path := range []string{"/var/cache/dnf/metadata_lock.pid", "/var/cache/dnf/lock.pid", "/var/run/dnf.pid"} { + if platform.UpdateLockHeld(path) { + return true, "DNF lock is held: " + path + } + } + return false, "" +} +func (Provider) Recovery(ctx context.Context) platform.UpdateRecovery { + recovery := platform.UpdateRecovery{RestartServices: []string{}, Hints: []string{"Restart services affected by updated libraries."}, Source: "advisory"} + r := platform.RunUpdateCommand(ctx, "dnf", []string{"needs-restarting", "-r"}, nil, nil) + if r.ExitCode == 1 { + recovery.Authoritative = true + recovery.RebootRequired = true + recovery.Source = "needs-restarting" + recovery.Hints = []string{"Reboot the host after updates complete."} + } + return recovery +} +func (Provider) History(_ context.Context, limit int) ([]platform.UpdateHistoryEntry, error) { + data, err := os.ReadFile("/var/log/dnf.rpm.log") + if err != nil { + return nil, err + } + entries := parseHistory(string(data)) + if len(entries) > limit { + entries = entries[len(entries)-limit:] + } + for l, r := 0, len(entries)-1; l < r; l, r = l+1, r-1 { + entries[l], entries[r] = entries[r], entries[l] + } + return entries, nil +} + +func parseJSON(output string) []platform.UpdatePackage { + var value any + if json.Unmarshal([]byte(output), &value) != nil { + return nil + } + items := []platform.UpdatePackage{} + var walk func(any) + walk = func(node any) { + switch typed := node.(type) { + case []any: + for _, child := range typed { + walk(child) + } + case map[string]any: + name, _ := typed["name"].(string) + version, _ := typed["version"].(string) + if version == "" { + version, _ = typed["evr"].(string) + } + arch, _ := typed["arch"].(string) + if name != "" && version != "" { + items = append(items, platform.UpdatePackage{Name: name, CandidateVersion: version, Architecture: arch}) + return + } + for _, child := range typed { + walk(child) + } + } + } + walk(value) + return platform.SortUpdatePackages(items) +} + +var dnfRow = regexp.MustCompile(`^([A-Za-z0-9+_.:@-]+)\.([A-Za-z0-9_+-]+)\s+(\S+)\s+(\S+)`) + +func parseTable(output string) []platform.UpdatePackage { + items := []platform.UpdatePackage{} + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() && len(items) < platform.MaxUpdatePackages { + match := dnfRow.FindStringSubmatch(strings.TrimSpace(scanner.Text())) + if len(match) == 5 && match[1] != "Package" && match[1] != "Last" { + items = append(items, platform.UpdatePackage{Name: match[1], Architecture: match[2], CandidateVersion: match[3], Summary: match[4]}) + } + } + return platform.SortUpdatePackages(items) +} + +func parsePlan(output string) []platform.UpdateChange { + changes := []platform.UpdateChange{} + action := "" + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() && len(changes) < platform.MaxUpdatePackages { + line := strings.TrimSpace(scanner.Text()) + switch { + case strings.HasPrefix(line, "Installing"): + action = "install" + case strings.HasPrefix(line, "Upgrading"): + action = "upgrade" + case strings.HasPrefix(line, "Removing"): + action = "remove" + case strings.HasPrefix(line, "Downgrading"): + action = "downgrade" + case strings.HasPrefix(line, "Replacing"): + action = "replace" + case line == "" || strings.HasPrefix(line, "Transaction Summary"): + action = "" + default: + fields := strings.Fields(line) + if action != "" && len(fields) >= 4 && fields[0] != "Package" { + changes = append(changes, platform.UpdateChange{Action: action, Name: fields[0], Architecture: fields[1], CandidateVersion: fields[2], TargetRepository: fields[3]}) + } + } + } + return platform.SortUpdateChanges(changes) +} + +var historyLine = regexp.MustCompile(`^(\S+)\s+(?:Upgraded|Upgrade):\s+(.+)$`) + +func parseHistory(content string) []platform.UpdateHistoryEntry { + entries := []platform.UpdateHistoryEntry{} + for _, line := range strings.Split(content, "\n") { + match := historyLine.FindStringSubmatch(strings.TrimSpace(line)) + if len(match) != 3 { + continue + } + parsed, err := time.Parse("2006-01-02T15:04:05-0700", match[1]) + if err != nil { + continue + } + name := strings.Split(match[2], "-")[0] + entries = append(entries, platform.UpdateHistoryEntry{Time: parsed.UnixMilli(), Packages: map[string]string{name: match[2]}}) + } + return entries +} diff --git a/apps/backend/internal/updateproviders/dnf/dnf_test.go b/apps/backend/internal/updateproviders/dnf/dnf_test.go new file mode 100644 index 0000000..25fa4f5 --- /dev/null +++ b/apps/backend/internal/updateproviders/dnf/dnf_test.go @@ -0,0 +1,28 @@ +package dnf + +import "testing" + +func TestParseJSONAndTable(t *testing.T) { + jsonItems := parseJSON(`{"packages":[{"name":"openssl","version":"3.2","arch":"x86_64"}]}`) + if len(jsonItems) != 1 || jsonItems[0].Name != "openssl" { + t.Fatalf("unexpected JSON inventory: %#v", jsonItems) + } + table := parseTable("openssl.x86_64 3.2 updates\n") + if len(table) != 1 || table[0].CandidateVersion != "3.2" { + t.Fatalf("unexpected table inventory: %#v", table) + } +} + +func TestParsePlanIncludesRiskyActions(t *testing.T) { + changes := parsePlan("Upgrading:\n openssl x86_64 3.2 updates 1 M\n\nRemoving:\n obsolete x86_64 1.0 installed 1 M\n") + if len(changes) != 2 || changes[0].Action != "remove" || changes[1].Action != "upgrade" { + t.Fatalf("unexpected plan: %#v", changes) + } +} + +func TestParseHistory(t *testing.T) { + items := parseHistory("2026-01-02T03:04:05+0000 Upgraded: openssl-3.2-1.x86_64\n") + if len(items) != 1 { + t.Fatalf("unexpected history: %#v", items) + } +} diff --git a/apps/backend/internal/updateproviders/pacman/pacman.go b/apps/backend/internal/updateproviders/pacman/pacman.go new file mode 100644 index 0000000..fdd30b4 --- /dev/null +++ b/apps/backend/internal/updateproviders/pacman/pacman.go @@ -0,0 +1,110 @@ +package pacman + +import ( + "bufio" + "context" + "github.com/velopulent/tako/internal/platform" + "os" + "regexp" + "strings" + "time" +) + +type Provider struct{} + +func New() Provider { return Provider{} } +func (Provider) Name() string { return "pacman" } +func (Provider) Probe(ctx context.Context) (string, error) { + if !platform.CommandExists("pacman") || !platform.CommandExists("checkupdates") { + return "", platform.ErrUpdateUnavailable + } + r := platform.RunUpdateCommand(ctx, "pacman", []string{"--version"}, nil, nil) + if r.ExitCode != 0 { + return "", platform.ErrUpdateUnavailable + } + return strings.TrimSpace(r.Output), nil +} +func (Provider) Inventory(ctx context.Context) ([]platform.UpdatePackage, error) { + r := platform.RunUpdateCommand(ctx, "checkupdates", []string{"--nosync", "--nocolor"}, []string{"CHECKUPDATES_DB=/run/tako/checkupdates"}, nil) + if r.ExitCode != 0 && r.ExitCode != 2 { + return nil, r.Err + } + return parseInventory(r.Output), nil +} +func (Provider) Refresh(ctx context.Context, _ bool, emit func(platform.UpdateStreamEvent)) error { + r := platform.RunUpdateCommand(ctx, "checkupdates", []string{"--nocolor"}, []string{"CHECKUPDATES_DB=/run/tako/checkupdates"}, emit) + if r.ExitCode != 0 && r.ExitCode != 2 { + return r.Err + } + return nil +} +func (p Provider) Plan(ctx context.Context) ([]platform.UpdateChange, error) { + items, err := p.Inventory(ctx) + if err != nil { + return nil, err + } + changes := make([]platform.UpdateChange, 0, len(items)) + for _, item := range items { + changes = append(changes, platform.UpdateChange{Action: "upgrade", Name: item.Name, CurrentVersion: item.CurrentVersion, CandidateVersion: item.CandidateVersion}) + } + return changes, nil +} +func (Provider) Apply(ctx context.Context, emit func(platform.UpdateStreamEvent)) error { + r := platform.RunUpdateCommand(ctx, "pacman", []string{"--noconfirm", "-Syu"}, nil, emit) + if r.ExitCode != 0 { + return r.Err + } + return nil +} +func (Provider) LockStatus(_ context.Context) (bool, string) { + if _, err := os.Stat("/var/lib/pacman/db.lck"); err == nil { + return true, "Pacman database lock is held" + } + return false, "" +} +func (Provider) Recovery(_ context.Context) platform.UpdateRecovery { + return platform.UpdateRecovery{RestartServices: []string{}, Hints: []string{"Reboot after kernel or core system library updates."}, Source: "advisory", Reason: "Pacman does not expose an authoritative reboot-required state."} +} +func (Provider) History(_ context.Context, limit int) ([]platform.UpdateHistoryEntry, error) { + data, err := os.ReadFile("/var/log/pacman.log") + if err != nil { + return nil, err + } + entries := parseHistory(string(data)) + if len(entries) > limit { + entries = entries[len(entries)-limit:] + } + for l, r := 0, len(entries)-1; l < r; l, r = l+1, r-1 { + entries[l], entries[r] = entries[r], entries[l] + } + return entries, nil +} +func parseInventory(output string) []platform.UpdatePackage { + items := []platform.UpdatePackage{} + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) >= 4 && fields[2] == "->" { + items = append(items, platform.UpdatePackage{Name: fields[0], CurrentVersion: fields[1], CandidateVersion: fields[3]}) + } + } + return platform.SortUpdatePackages(items) +} + +var historyLine = regexp.MustCompile(`^\[([^\]]+)\] \[ALPM\] upgraded (\S+) \((\S+) -> (\S+)\)`) + +func parseHistory(content string) []platform.UpdateHistoryEntry { + items := []platform.UpdateHistoryEntry{} + for _, line := range strings.Split(content, "\n") { + match := historyLine.FindStringSubmatch(line) + if len(match) != 5 { + continue + } + parsed, err := time.Parse("2006-01-02T15:04:05-0700", match[1]) + if err != nil { + continue + } + items = append(items, platform.UpdateHistoryEntry{Time: parsed.UnixMilli(), Packages: map[string]string{match[2]: match[4]}}) + } + return items +} diff --git a/apps/backend/internal/updateproviders/pacman/pacman_test.go b/apps/backend/internal/updateproviders/pacman/pacman_test.go new file mode 100644 index 0000000..1b237b1 --- /dev/null +++ b/apps/backend/internal/updateproviders/pacman/pacman_test.go @@ -0,0 +1,14 @@ +package pacman + +import "testing" + +func TestParseInventoryAndHistory(t *testing.T) { + items := parseInventory("linux 6.1 -> 6.2\n") + if len(items) != 1 || items[0].CurrentVersion != "6.1" || items[0].CandidateVersion != "6.2" { + t.Fatalf("unexpected inventory: %#v", items) + } + history := parseHistory("[2026-01-02T03:04:05+0000] [ALPM] upgraded linux (6.1 -> 6.2)\n") + if len(history) != 1 || history[0].Packages["linux"] != "6.2" { + t.Fatalf("unexpected history: %#v", history) + } +} diff --git a/apps/backend/internal/updateproviders/zypper/zypper.go b/apps/backend/internal/updateproviders/zypper/zypper.go new file mode 100644 index 0000000..3b37985 --- /dev/null +++ b/apps/backend/internal/updateproviders/zypper/zypper.go @@ -0,0 +1,173 @@ +package zypper + +import ( + "bufio" + "context" + "encoding/xml" + "github.com/velopulent/tako/internal/platform" + "os" + "strings" + "time" +) + +type Provider struct{ tumbleweed bool } + +func New() Provider { + data, _ := os.ReadFile("/etc/os-release") + text := strings.ToLower(string(data)) + return Provider{tumbleweed: strings.Contains(text, "tumbleweed")} +} +func (Provider) Name() string { return "zypper" } +func (Provider) Probe(ctx context.Context) (string, error) { + if !platform.CommandExists("zypper") { + return "", platform.ErrUpdateUnavailable + } + r := platform.RunUpdateCommand(ctx, "zypper", []string{"--version"}, nil, nil) + if r.ExitCode != 0 { + return "", platform.ErrUpdateUnavailable + } + return strings.TrimSpace(r.Output), nil +} +func (Provider) Inventory(ctx context.Context) ([]platform.UpdatePackage, error) { + r := platform.RunUpdateCommand(ctx, "zypper", []string{"--no-refresh", "--non-interactive", "--xmlout", "list-updates"}, nil, nil) + if r.ExitCode != 0 && r.ExitCode != 100 { + return nil, r.Err + } + return parseXML(r.Output), nil +} +func (Provider) Refresh(ctx context.Context, force bool, emit func(platform.UpdateStreamEvent)) error { + args := []string{"--non-interactive", "--xmlout", "refresh"} + if force { + args = append(args, "--force") + } + r := platform.RunUpdateCommand(ctx, "zypper", args, nil, emit) + if r.ExitCode != 0 { + return r.Err + } + return nil +} +func (p Provider) Plan(ctx context.Context) ([]platform.UpdateChange, error) { + r := platform.RunUpdateCommand(ctx, "zypper", []string{"--no-refresh", "--non-interactive", "--xmlout", "--dry-run", operation(p)}, nil, nil) + if r.ExitCode == 0 || r.ExitCode == 100 || r.ExitCode == 102 || r.ExitCode == 103 { + if changes := parsePlanXML(r.Output); len(changes) > 0 || r.ExitCode == 0 { + return changes, nil + } + } + items, err := p.Inventory(ctx) + if err != nil { + return nil, err + } + changes := make([]platform.UpdateChange, 0, len(items)) + for _, item := range items { + changes = append(changes, platform.UpdateChange{Action: "upgrade", Name: item.Name, Architecture: item.Architecture, CurrentVersion: item.CurrentVersion, CandidateVersion: item.CandidateVersion}) + } + return changes, nil +} +func (p Provider) Apply(ctx context.Context, emit func(platform.UpdateStreamEvent)) error { + r := platform.RunUpdateCommand(ctx, "zypper", []string{"--no-refresh", "--non-interactive", "--xmlout", operation(p)}, nil, emit) + if r.ExitCode != 0 { + return r.Err + } + return nil +} + +func operation(provider Provider) string { + if provider.tumbleweed { + return "dup" + } + return "update" +} +func (Provider) LockStatus(_ context.Context) (bool, string) { + for _, path := range []string{"/var/run/zypp.pid", "/var/run/zypp-rpm.pid"} { + if _, err := os.Stat(path); err == nil { + return true, "Zypper lock is held: " + path + } + } + return false, "" +} +func (Provider) Recovery(_ context.Context) platform.UpdateRecovery { + recovery := platform.UpdateRecovery{RestartServices: []string{}, Hints: []string{"Restart services affected by updated libraries."}, Source: "advisory"} + if _, err := os.Stat("/etc/zypp/needreboot"); err == nil { + recovery.Authoritative = true + recovery.RebootRequired = true + recovery.Source = "/etc/zypp/needreboot" + recovery.Hints = []string{"Reboot the host after updates complete."} + } + return recovery +} +func (Provider) History(_ context.Context, limit int) ([]platform.UpdateHistoryEntry, error) { + data, err := os.ReadFile("/var/log/zypp/history") + if err != nil { + return nil, err + } + entries := parseHistory(string(data)) + if len(entries) > limit { + entries = entries[len(entries)-limit:] + } + for l, r := 0, len(entries)-1; l < r; l, r = l+1, r-1 { + entries[l], entries[r] = entries[r], entries[l] + } + return entries, nil +} + +type stream struct { + Updates []struct { + Name string `xml:"name,attr"` + Edition string `xml:"edition,attr"` + EditionOld string `xml:"edition-old,attr"` + Arch string `xml:"arch,attr"` + } `xml:"update-list>update"` +} + +func parseXML(output string) []platform.UpdatePackage { + var value stream + if xml.Unmarshal([]byte(output), &value) != nil { + return nil + } + items := make([]platform.UpdatePackage, 0, len(value.Updates)) + for _, item := range value.Updates { + items = append(items, platform.UpdatePackage{Name: item.Name, Architecture: item.Arch, CurrentVersion: item.EditionOld, CandidateVersion: item.Edition}) + } + return platform.SortUpdatePackages(items) +} + +func parsePlanXML(output string) []platform.UpdateChange { + decoder := xml.NewDecoder(strings.NewReader(output)) + changes := []platform.UpdateChange{} + for len(changes) < platform.MaxUpdatePackages { + token, err := decoder.Token() + if err != nil { + break + } + start, ok := token.(xml.StartElement) + if !ok || start.Name.Local != "solvable" { + continue + } + attributes := map[string]string{} + for _, attribute := range start.Attr { + attributes[attribute.Name.Local] = attribute.Value + } + action := map[string]string{"to-be-installed": "install", "to-be-upgraded": "upgrade", "to-be-uninstalled": "remove", "to-be-downgraded": "downgrade", "to-be-reinstalled": "replace"}[attributes["status"]] + if action == "" || attributes["name"] == "" { + continue + } + changes = append(changes, platform.UpdateChange{Action: action, Name: attributes["name"], Architecture: attributes["arch"], CurrentVersion: attributes["edition-old"], CandidateVersion: attributes["edition"], TargetRepository: attributes["repository"], TargetVendor: attributes["vendor"]}) + } + return platform.SortUpdateChanges(changes) +} +func parseHistory(content string) []platform.UpdateHistoryEntry { + items := []platform.UpdateHistoryEntry{} + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + fields := strings.Split(scanner.Text(), "|") + if len(fields) < 5 || strings.TrimSpace(fields[1]) != "update" { + continue + } + parsed, err := time.Parse("2006-01-02 15:04:05", strings.TrimSpace(fields[0])) + if err != nil { + continue + } + items = append(items, platform.UpdateHistoryEntry{Time: parsed.UnixMilli(), Packages: map[string]string{strings.TrimSpace(fields[2]): strings.TrimSpace(fields[4])}}) + } + return items +} diff --git a/apps/backend/internal/updateproviders/zypper/zypper_test.go b/apps/backend/internal/updateproviders/zypper/zypper_test.go new file mode 100644 index 0000000..57e0046 --- /dev/null +++ b/apps/backend/internal/updateproviders/zypper/zypper_test.go @@ -0,0 +1,27 @@ +package zypper + +import "testing" + +func TestParseXMLAndHistory(t *testing.T) { + items := parseXML(``) + if len(items) != 1 || items[0].Name != "openssl" || items[0].CurrentVersion != "3.1" { + t.Fatalf("unexpected inventory: %#v", items) + } + history := parseHistory("2026-01-02 03:04:05|update|openssl|3.1|3.2|x86_64|repo\n") + if len(history) != 1 || history[0].Packages["openssl"] != "3.2" { + t.Fatalf("unexpected history: %#v", history) + } +} + +func TestParsePlanXMLIncludesRiskyActions(t *testing.T) { + changes := parsePlanXML(``) + if len(changes) != 2 || changes[0].Action != "upgrade" || changes[1].Action != "remove" { + t.Fatalf("unexpected plan: %#v", changes) + } +} + +func TestOperationDependsOnlyOnReleaseKind(t *testing.T) { + if operation(Provider{}) != "update" || operation(Provider{tumbleweed: true}) != "dup" { + t.Fatal("unexpected zypper operation") + } +} From f055d235b85f28d7a9bb73d1c79164851b6f6daf Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:55:45 +0530 Subject: [PATCH 09/88] feat!: wire UpdateService through sessiond, bridge and gateway Inject distro.NewUpdateService() in cmd/tako (serve/sessiond/bridge + update-worker --job-file) and thread the service through hostRuntime, sessiond handlers and the bridge. Replace PackageKit-owned hostRuntime client/watcher with UpdateService.Snapshot/Refresh/Preview/Apply. Update auth host reads to alias UpdateObservation to platform.UpdateObservation, add PreviewUpdates and drop Cancel/AutoUpdates flows. Align app gateway (host_broker, app routes, update_jobs SSE live handler, jobs) to ValidateUpdateOperation with confirmed/riskAccepted and remove /updates/cancel and /updates/automatic endpoints. Remove updates_automatic and sessiond updates tests. BREAKING CHANGE: gateway and sessiond no longer expose auto-updates or cancel endpoints; live updates are now an SSE stream. --- apps/backend/cmd/tako/main.go | 28 +- apps/backend/internal/app/app.go | 17 -- apps/backend/internal/app/app_test.go | 2 +- apps/backend/internal/app/host_broker.go | 36 +-- apps/backend/internal/app/jobs.go | 11 +- apps/backend/internal/app/update_jobs.go | 208 +++++++------- .../internal/app/updates_automatic_test.go | 271 ------------------ .../internal/app/updates_contract_test.go | 35 ++- apps/backend/internal/app/updates_test.go | 8 +- apps/backend/internal/auth/auth.go | 38 +-- apps/backend/internal/auth/host_reads.go | 52 ++-- apps/backend/internal/bridge/bridge.go | 8 +- apps/backend/internal/bridge/host_reads.go | 22 +- .../backend/internal/sessiond/bridge_reads.go | 6 - apps/backend/internal/sessiond/host_reads.go | 82 ++---- .../internal/sessiond/host_reads_test.go | 15 +- .../backend/internal/sessiond/host_runtime.go | 84 +----- apps/backend/internal/sessiond/journal.go | 2 +- apps/backend/internal/sessiond/sessiond.go | 75 ++--- .../internal/sessiond/updates_backend.go | 32 +-- .../backend/internal/sessiond/updates_test.go | 200 ------------- 21 files changed, 272 insertions(+), 960 deletions(-) delete mode 100644 apps/backend/internal/app/updates_automatic_test.go delete mode 100644 apps/backend/internal/sessiond/updates_test.go diff --git a/apps/backend/cmd/tako/main.go b/apps/backend/cmd/tako/main.go index 18e22b9..2ee4cf5 100644 --- a/apps/backend/cmd/tako/main.go +++ b/apps/backend/cmd/tako/main.go @@ -14,7 +14,9 @@ import ( "github.com/velopulent/tako/internal/app" "github.com/velopulent/tako/internal/bridge" "github.com/velopulent/tako/internal/config" + "github.com/velopulent/tako/internal/distro" "github.com/velopulent/tako/internal/logging" + "github.com/velopulent/tako/internal/platform" "github.com/velopulent/tako/internal/sessiond" "go.uber.org/zap" ) @@ -38,13 +40,22 @@ func run() int { logger.Info("process starting") err = nil + updates := distro.NewUpdateService() switch os.Args[1] { case "serve": err = serve(os.Args[2:]) case "sessiond": - err = sessiond.Run(os.Args[2:]) + executable, executableErr := os.Executable() + if executableErr != nil { + err = executableErr + break + } + updates.UseWorker(executable) + err = sessiond.Run(os.Args[2:], updates) case "bridge": - err = bridge.Run(os.Stdin, os.Stdout, os.Stderr) + err = bridge.Run(os.Stdin, os.Stdout, os.Stderr, updates) + case "update-worker": + err = runUpdateWorker(os.Args[2:], updates) case "help", "--help", "-h": usage() return 0 @@ -120,3 +131,16 @@ func usage() { tako sessiond [--socket path] [--config path] privileged PAM/session boundary tako bridge per-user framed RPC bridge`) } + +func runUpdateWorker(args []string, updates *platform.UpdateService) error { + flags := flag.NewFlagSet("update-worker", flag.ContinueOnError) + jobFile := flags.String("job-file", "", "root-owned update job file") + if err := flags.Parse(args); err != nil { + return err + } + operation, err := platform.ReadUpdateWorkerJob(*jobFile) + if err != nil { + return err + } + return updates.RunWorker(context.Background(), operation, os.Stdout) +} diff --git a/apps/backend/internal/app/app.go b/apps/backend/internal/app/app.go index fe1ca91..5d667a9 100644 --- a/apps/backend/internal/app/app.go +++ b/apps/backend/internal/app/app.go @@ -72,8 +72,6 @@ type Server struct { readUpdatesFn func(context.Context) (platform.UpdateStatus, error) previewUpdatesFn func(context.Context, platform.UpdateOperation) (platform.UpdatePreview, error) applyUpdatesFn func(context.Context, auth.UpdateRequest) (platform.UpdateResult, error) - autoUpdatesFn func(context.Context, auth.AutoUpdatesRequest) (platform.AutoUpdatesConfig, error) - autoUpdatesStatusFn func(context.Context) platform.AutoUpdatesConfig kpatchSettingsFn func(context.Context, auth.KpatchRequest) (platform.KpatchSettingsStatus, error) updateTokensMu sync.Mutex updateTokens map[string]string @@ -90,7 +88,6 @@ type Server struct { readUpdateHistoryFn func(context.Context) ([]platform.UpdateHistoryEntry, error) readUpdateLiveFn func(context.Context) (auth.UpdateObservation, error) refreshUpdatesFn func(context.Context, bool) (platform.UpdateStatus, error) - cancelUpdateFn func(context.Context) (bool, error) applyTimer func(context.Context, auth.TimerRequest) (platform.TimerState, error) applyOverride func(context.Context, auth.OverrideRequest) (platform.OverrideState, error) previewAccountFn func(context.Context, auth.LocalAccountRequest) (platform.LocalAccountPreview, error) @@ -180,13 +177,6 @@ func New(cfg config.Config) (*Server, error) { applyUpdatesFn: func(ctx context.Context, request auth.UpdateRequest) (platform.UpdateResult, error) { return server.hostBroker().ApplyUpdates(ctx, request) }, - autoUpdatesFn: func(ctx context.Context, request auth.AutoUpdatesRequest) (platform.AutoUpdatesConfig, error) { - return server.hostBroker().ApplyAutoUpdates(ctx, request) - }, - autoUpdatesStatusFn: func(ctx context.Context) platform.AutoUpdatesConfig { - status, _ := server.hostBroker().ReadAutoUpdatesStatus(ctx, credentialsFromContext(ctx)) - return status - }, kpatchSettingsFn: func(ctx context.Context, request auth.KpatchRequest) (platform.KpatchSettingsStatus, error) { return server.hostBroker().ApplyKpatch(ctx, request) }, @@ -264,10 +254,6 @@ func New(cfg config.Config) (*Server, error) { current, _ := ctx.Value(sessionKey{}).(session.Session) return server.hostBroker().RefreshUpdates(ctx, current.Identity.AdminToken, force) } - server.cancelUpdateFn = func(ctx context.Context) (bool, error) { - current, _ := ctx.Value(sessionKey{}).(session.Session) - return server.hostBroker().CancelUpdate(ctx, current.Identity.AdminToken) - } server.detectCapabilities = func(ctx context.Context) []platform.Capability { capabilities, _ := server.hostBroker().ReadCapabilities(ctx, credentialsFromContext(ctx)) return capabilities @@ -489,9 +475,6 @@ func (server *Server) routes() http.Handler { router.Get("/updates/live", server.updateLiveStatus) router.Get("/updates/kpatch", server.kpatchStatus) router.With(server.requireCSRF).Put("/updates/kpatch", server.applyKpatchSettings) - router.Get("/updates/automatic", server.automaticUpdatesStatus) - router.With(server.requireCSRF).Post("/updates/cancel", server.cancelRunningUpdate) - router.With(server.requireCSRF).Put("/updates/automatic", server.applyAutomaticUpdates) router.Get("/services", server.services) router.Get("/services/{scope}/{unit}", server.serviceDetail) router.Get("/services/{scope}/{unit}/configuration", server.serviceConfiguration) diff --git a/apps/backend/internal/app/app_test.go b/apps/backend/internal/app/app_test.go index 19c6ba7..0786024 100644 --- a/apps/backend/internal/app/app_test.go +++ b/apps/backend/internal/app/app_test.go @@ -247,7 +247,7 @@ func TestCapabilitiesExposeRuntimeContractsAndGuidance(t *testing.T) { return []platform.Capability{ {ID: "services", State: platform.StateReady, Backend: "systemd", Version: "systemd 257", Readable: true, Mutable: true, ReadAuthority: "session", MutationAuthority: "administrative", Contract: "dbus"}, {ID: "network", State: platform.StateConflicted, Backend: "NetworkManager+networkd", Readable: true, ReadAuthority: "session", MutationAuthority: "none", Contract: "conflicted-read-only", Reason: "Multiple managers are active", SetupGuidance: "Choose one network manager."}, - {ID: "updates", State: platform.StateReady, Backend: "apt-get", Version: "apt 3.0", Readable: true, Mutable: true, ReadAuthority: "session", MutationAuthority: "administrative", Contract: "bounded-command", Reason: "apt-get command path in use; PackageKit is unavailable", SetupGuidance: "Install and start PackageKit for richer advisory metadata, update history, and live transaction progress."}, + {ID: "updates", State: platform.StateReady, Backend: "apt", Version: "apt 3.0", Readable: true, Mutable: true, ReadAuthority: "session", MutationAuthority: "administrative", Contract: "native-distro-provider"}, } } cookie, _ := loginForTest(t, server.routes()) diff --git a/apps/backend/internal/app/host_broker.go b/apps/backend/internal/app/host_broker.go index 26fa921..cccf989 100644 --- a/apps/backend/internal/app/host_broker.go +++ b/apps/backend/internal/app/host_broker.go @@ -10,7 +10,6 @@ import ( "github.com/velopulent/tako/internal/auth" "github.com/velopulent/tako/internal/host" "github.com/velopulent/tako/internal/metrics" - "github.com/velopulent/tako/internal/packagekit" "github.com/velopulent/tako/internal/platform" "github.com/velopulent/tako/internal/session" ) @@ -50,9 +49,6 @@ type HostBroker interface { RefreshUpdates(context.Context, string, bool) (platform.UpdateStatus, error) ReadUpdateHistory(context.Context, auth.HostReadCredentials) ([]platform.UpdateHistoryEntry, error) ReadUpdateObservation(context.Context, auth.HostReadCredentials) (auth.UpdateObservation, error) - CancelUpdate(context.Context, string) (bool, error) - ReadAutoUpdatesStatus(context.Context, auth.HostReadCredentials) (platform.AutoUpdatesConfig, error) - ApplyAutoUpdates(context.Context, auth.AutoUpdatesRequest) (platform.AutoUpdatesConfig, error) ReadKpatch(context.Context, auth.HostReadCredentials) (platform.KpatchStatus, platform.KpatchSettingsStatus, error) ApplyKpatch(context.Context, auth.KpatchRequest) (platform.KpatchSettingsStatus, error) ReadCapabilities(context.Context, auth.HostReadCredentials) ([]platform.Capability, error) @@ -229,11 +225,7 @@ func (broker socketHostBroker) ReadUpdateStatus(ctx context.Context, credentials } func (broker socketHostBroker) PreviewUpdates(ctx context.Context, credentials auth.HostReadCredentials, operation platform.UpdateOperation) (platform.UpdatePreview, error) { - status, err := broker.ReadUpdateStatus(ctx, credentials) - if err != nil { - return platform.UpdatePreview{}, err - } - return platform.PreviewUpdates(ctx, operation, func(context.Context) platform.UpdateStatus { return status }) + return auth.PreviewUpdates(ctx, broker.path, credentials, operation) } func (broker socketHostBroker) ApplyUpdates(ctx context.Context, request auth.UpdateRequest) (platform.UpdateResult, error) { @@ -252,18 +244,6 @@ func (broker socketHostBroker) ReadUpdateObservation(ctx context.Context, creden return auth.ReadUpdateObservation(ctx, broker.path, credentials) } -func (broker socketHostBroker) CancelUpdate(ctx context.Context, adminToken string) (bool, error) { - return auth.CancelUpdate(ctx, broker.path, adminToken) -} - -func (broker socketHostBroker) ReadAutoUpdatesStatus(ctx context.Context, credentials auth.HostReadCredentials) (platform.AutoUpdatesConfig, error) { - return auth.ReadAutoUpdatesStatus(ctx, broker.path, credentials) -} - -func (broker socketHostBroker) ApplyAutoUpdates(ctx context.Context, request auth.AutoUpdatesRequest) (platform.AutoUpdatesConfig, error) { - return auth.ApplyAutoUpdatesConfig(ctx, broker.path, request) -} - func (broker socketHostBroker) ReadKpatch(ctx context.Context, credentials auth.HostReadCredentials) (platform.KpatchStatus, platform.KpatchSettingsStatus, error) { return auth.ReadKpatch(ctx, broker.path, credentials) } @@ -468,7 +448,7 @@ func (broker fakeHostBroker) PreviewUpdates(ctx context.Context, credentials aut if err != nil { return platform.UpdatePreview{}, err } - return platform.PreviewUpdates(ctx, operation, func(context.Context) platform.UpdateStatus { return status }) + return platform.PreviewUpdateStatus(status, operation) } func (fakeHostBroker) ApplyUpdates(context.Context, auth.UpdateRequest) (platform.UpdateResult, error) { @@ -484,17 +464,7 @@ func (fakeHostBroker) ReadUpdateHistory(context.Context, auth.HostReadCredential } func (fakeHostBroker) ReadUpdateObservation(context.Context, auth.HostReadCredentials) (auth.UpdateObservation, error) { - return auth.UpdateObservation{Live: platform.InactiveUpdateLive(), Log: []packagekit.ActionLogEntry{}}, nil -} - -func (fakeHostBroker) CancelUpdate(context.Context, string) (bool, error) { return false, nil } - -func (fakeHostBroker) ReadAutoUpdatesStatus(context.Context, auth.HostReadCredentials) (platform.AutoUpdatesConfig, error) { - return platform.AutoUpdatesConfig{}, nil -} - -func (fakeHostBroker) ApplyAutoUpdates(context.Context, auth.AutoUpdatesRequest) (platform.AutoUpdatesConfig, error) { - return platform.AutoUpdatesConfig{}, nil + return auth.UpdateObservation{Progress: platform.UpdateProgress{Phase: "idle", Percent: -1, Message: "No update is running."}, Output: []platform.UpdateOutput{}}, nil } func (fakeHostBroker) ReadKpatch(context.Context, auth.HostReadCredentials) (platform.KpatchStatus, platform.KpatchSettingsStatus, error) { diff --git a/apps/backend/internal/app/jobs.go b/apps/backend/internal/app/jobs.go index ae27889..18f2d62 100644 --- a/apps/backend/internal/app/jobs.go +++ b/apps/backend/internal/app/jobs.go @@ -494,11 +494,9 @@ func (server *Server) jobDetail(writer http.ResponseWriter, request *http.Reques return } payload := map[string]any{"job": job} - // Software-update jobs carry a live observation of the PackageKit - // transaction so the UI can render real progress even though sessiond - // performs the update. + // Software-update jobs include current normalized manager progress. if job.Kind == softwareUpdateJob { - observation := auth.UpdateObservation{Live: platform.InactiveUpdateLive()} + observation := auth.UpdateObservation{Progress: platform.UpdateProgress{Phase: "idle", Percent: -1, Message: "No update is running."}, Output: []platform.UpdateOutput{}} if server.readUpdateLiveFn != nil { if current, readErr := server.readUpdateLiveFn(request.Context()); readErr == nil { observation = current @@ -519,6 +517,11 @@ func (server *Server) cancelJob(writer http.ResponseWriter, request *http.Reques problem(writer, http.StatusServiceUnavailable, "jobs-unavailable", "Diagnostic jobs are unavailable") return } + current, lookupErr := server.jobs.GetJob(id) + if lookupErr == nil && current.Kind == softwareUpdateJob && current.State == JobRunning && current.Progress >= 20 { + problem(writer, http.StatusConflict, "update-not-cancelable", "Package commit has started and cannot be canceled safely") + return + } job, err := server.jobs.Cancel(request.Context(), id) server.deleteInventoryCredentials(id) if errors.Is(err, ErrJobNotFound) { diff --git a/apps/backend/internal/app/update_jobs.go b/apps/backend/internal/app/update_jobs.go index 3c4bea4..46af4fd 100644 --- a/apps/backend/internal/app/update_jobs.go +++ b/apps/backend/internal/app/update_jobs.go @@ -8,10 +8,11 @@ import ( "fmt" "io" "net/http" + "sort" + "strconv" "time" "github.com/velopulent/tako/internal/auth" - "github.com/velopulent/tako/internal/packagekit" "github.com/velopulent/tako/internal/platform" "github.com/velopulent/tako/internal/session" "go.uber.org/zap" @@ -31,11 +32,8 @@ func (server *Server) previewUpdates(writer http.ResponseWriter, request *http.R writeUpdateProblem(writer, err) return } - if preview.Selected == nil { - preview.Selected = []platform.UpdatePackage{} - } if preview.Changes == nil { - preview.Changes = []string{} + preview.Changes = []platform.UpdateChange{} } if preview.Warnings == nil { preview.Warnings = []string{} @@ -106,12 +104,7 @@ func decodeUpdateOperation(writer http.ResponseWriter, request *http.Request, pr problem(writer, http.StatusBadRequest, "invalid-update-operation", "Update operation is invalid") return platform.UpdateOperation{}, false } - if !preview && operation.Preview { - problem(writer, http.StatusBadRequest, "invalid-update-operation", "Update operation is invalid") - return platform.UpdateOperation{}, false - } - operation.Preview = preview - if err := platform.ValidateUpdateOperation(operation); err != nil { + if err := platform.ValidateUpdateOperation(operation, !preview); err != nil { problem(writer, http.StatusBadRequest, "invalid-update-operation", "Update operation is invalid") return platform.UpdateOperation{}, false } @@ -128,12 +121,8 @@ func writeUpdateProblem(writer http.ResponseWriter, err error) { problem(writer, http.StatusConflict, "update-locked", "Another package operation currently holds the package-manager lock") case errors.Is(err, platform.ErrUpdateUnavailable): problem(writer, http.StatusServiceUnavailable, "updates-unavailable", "No supported update backend is available") - case errors.Is(err, platform.ErrInvalidAutoUpdatesOperation): - problem(writer, http.StatusBadRequest, "invalid-auto-updates-operation", "Automatic updates operation is invalid") - case errors.Is(err, platform.ErrAutoUpdatesUnavailable): - problem(writer, http.StatusServiceUnavailable, "auto-updates-unavailable", "No supported automatic-update backend is available") - case errors.Is(err, platform.ErrAutoUpdatesApply): - problem(writer, http.StatusBadGateway, "auto-updates-apply-failed", "The automatic-update configuration could not be applied") + case errors.Is(err, platform.ErrUpdateRiskNotAccepted): + problem(writer, http.StatusConflict, "update-risk-not-accepted", "Risky update changes require explicit confirmation") default: problem(writer, http.StatusBadGateway, "update-preview-failed", "Update preview could not be completed") } @@ -143,15 +132,16 @@ func (server *Server) runSoftwareUpdateJob(ctx context.Context, job Job, update var operation platform.UpdateOperation decoder := json.NewDecoder(bytes.NewReader(job.Parameters)) decoder.DisallowUnknownFields() - if err := decoder.Decode(&operation); err != nil || decoder.Decode(&struct{}{}) != io.EOF || platform.ValidateUpdateOperation(operation) != nil || operation.Preview { + if err := decoder.Decode(&operation); err != nil || decoder.Decode(&struct{}{}) != io.EOF || platform.ValidateUpdateOperation(operation, true) != nil { return nil, platform.ErrInvalidUpdateOperation } adminToken := server.takeUpdateToken(job.ID) if adminToken == "" { return nil, auth.ErrServiceUnavailable } + operation.JobID = job.ID startedAt := time.Now().UTC() - target := "system/updates/" + operation.Scope + target := "system/updates" defer func() { server.deleteUpdateToken(job.ID) }() @@ -171,7 +161,7 @@ func (server *Server) runSoftwareUpdateJob(ctx context.Context, job Job, update } return nil, err } - if err := update(20, "Applying selected software updates"); err != nil { + if err := update(20, "Applying full system update"); err != nil { if errors.Is(err, ErrJobTerminal) { return nil, context.Canceled } @@ -298,18 +288,103 @@ func (server *Server) updateLiveStatus(writer http.ResponseWriter, request *http problem(writer, http.StatusServiceUnavailable, "updates-unavailable", "Live update status is unavailable") return } - observation, err := server.readUpdateLiveFn(request.Context()) - if err != nil { - problem(writer, http.StatusServiceUnavailable, "updates-unavailable", "Live update status is unavailable") + flusher, ok := writer.(http.Flusher) + if !ok { + problem(writer, http.StatusInternalServerError, "stream-unavailable", "Streaming is unavailable") return } - if observation.Log == nil { - observation.Log = []packagekit.ActionLogEntry{} + cursor, _ := strconv.ParseUint(request.Header.Get("Last-Event-ID"), 10, 64) + writer.Header().Set("Content-Type", "text/event-stream") + writer.Header().Set("Cache-Control", "no-cache") + writer.Header().Set("X-Accel-Buffering", "no") + writeSnapshot := func(progress platform.UpdateProgress) bool { + payload, err := json.Marshal(progress) + if err != nil { + return false + } + if _, err := fmt.Fprintf(writer, "event: progress\ndata: %s\n\n", payload); err != nil { + return false + } + flusher.Flush() + return true + } + + emit := func(kind string, sequence uint64, value any) bool { + if sequence <= cursor { + return true + } + payload, err := json.Marshal(value) + if err != nil { + return false + } + if _, err := fmt.Fprintf(writer, "id: %d\nevent: %s\ndata: %s\n\n", sequence, kind, payload); err != nil { + return false + } + cursor = sequence + flusher.Flush() + return true + } + initial := true + poll := func() bool { + observation, err := server.readUpdateLiveFn(request.Context()) + if err != nil { + return true + } + if initial { + initial = false + if !writeSnapshot(observation.Progress) { + return false + } + } + events := append([]platform.UpdateStreamEvent(nil), observation.Events...) + if len(events) == 0 { + events = append(events, platform.UpdateStreamEvent{Kind: "progress", Progress: observation.Progress}) + for _, output := range observation.Output { + events = append(events, platform.UpdateStreamEvent{Kind: "output", Output: output}) + } + } + sort.Slice(events, func(left, right int) bool { + sequence := func(event platform.UpdateStreamEvent) uint64 { + if event.Kind == "output" { + return event.Output.Sequence + } + return event.Progress.Sequence + } + return sequence(events[left]) < sequence(events[right]) + }) + for _, event := range events { + if event.Kind == "output" { + if !emit("output", event.Output.Sequence, event.Output) { + return false + } + } else if !emit("progress", event.Progress.Sequence, event.Progress) { + return false + } + } + return true + } + if !poll() { + return + } + pollTicker := time.NewTicker(time.Second) + heartbeat := time.NewTicker(15 * time.Second) + defer pollTicker.Stop() + defer heartbeat.Stop() + for { + select { + case <-request.Context().Done(): + return + case <-pollTicker.C: + if !poll() { + return + } + case <-heartbeat.C: + if _, err := fmt.Fprint(writer, ": heartbeat\n\n"); err != nil { + return + } + flusher.Flush() + } } - writeJSON(writer, http.StatusOK, map[string]any{ - "live": observation.Live, - "log": observation.Log, - }) } func (server *Server) updateHistory(writer http.ResponseWriter, request *http.Request) { @@ -335,37 +410,6 @@ func (server *Server) updateHistory(writer http.ResponseWriter, request *http.Re writeJSON(writer, http.StatusOK, map[string]any{"items": items, "available": true}) } -func (server *Server) cancelRunningUpdate(writer http.ResponseWriter, request *http.Request) { - current := request.Context().Value(sessionKey{}).(session.Session) - if !hasAdministrativeAccess(current) { - problem(writer, http.StatusForbidden, "administrative-access-required", "Gain Administrative access first") - return - } - cancelCtx, cancel := context.WithTimeout(request.Context(), 8*time.Second) - defer cancel() - if server.cancelUpdateFn == nil { - problem(writer, http.StatusServiceUnavailable, "updates-unavailable", "Update cancellation is unavailable") - return - } - found, err := server.cancelUpdateFn(cancelCtx) - if err != nil { - writeUpdateProblem(writer, err) - return - } - server.recordOperation(request.Context(), current.Identity.Username, "system/updates/cancel", time.Now().UTC(), "succeeded", "", true) - writeJSON(writer, http.StatusOK, map[string]any{"canceled": found}) -} - -func (server *Server) automaticUpdatesStatus(writer http.ResponseWriter, request *http.Request) { - if server.autoUpdatesStatusFn == nil { - problem(writer, http.StatusServiceUnavailable, "updates-unavailable", "The update configuration service is unavailable") - return - } - statusCtx, cancel := context.WithTimeout(request.Context(), 10*time.Second) - defer cancel() - writeJSON(writer, http.StatusOK, server.autoUpdatesStatusFn(statusCtx)) -} - func (server *Server) kpatchStatus(writer http.ResponseWriter, request *http.Request) { kpatchCtx, cancel := context.WithTimeout(request.Context(), 15*time.Second) defer cancel() @@ -434,50 +478,6 @@ func (server *Server) applyKpatchSettings(writer http.ResponseWriter, request *h writeJSON(writer, http.StatusOK, settings) } -func (server *Server) applyAutomaticUpdates(writer http.ResponseWriter, request *http.Request) { - current := request.Context().Value(sessionKey{}).(session.Session) - if !hasAdministrativeAccess(current) { - problem(writer, http.StatusForbidden, "administrative-access-required", "Gain Administrative access first") - return - } - request.Body = http.MaxBytesReader(writer, request.Body, 4<<10) - decoder := json.NewDecoder(request.Body) - decoder.DisallowUnknownFields() - var operation platform.AutoUpdatesOperation - if err := decoder.Decode(&operation); err != nil || decoder.Decode(&struct{}{}) != io.EOF || platform.ValidateAutoUpdatesOperation(operation) != nil { - problem(writer, http.StatusBadRequest, "invalid-auto-updates-operation", "Automatic updates operation is invalid") - return - } - if server.autoUpdatesFn == nil { - problem(writer, http.StatusServiceUnavailable, "updates-unavailable", "The update configuration service is unavailable") - return - } - startedAt := time.Now().UTC() - configCtx, cancel := context.WithTimeout(request.Context(), time.Minute) - defer cancel() - config, err := server.autoUpdatesFn(configCtx, auth.AutoUpdatesRequest{AdminToken: current.Identity.AdminToken, Operation: operation}) - if err != nil { - server.recordOperation(request.Context(), current.Identity.Username, "system/updates/auto", startedAt, "failed", autoUpdatesFailure(err), true) - writeUpdateProblem(writer, err) - return - } - server.recordOperation(request.Context(), current.Identity.Username, "system/updates/auto", startedAt, "succeeded", "", true) - writeJSON(writer, http.StatusOK, config) -} - -func autoUpdatesFailure(err error) string { - switch { - case errors.Is(err, platform.ErrInvalidAutoUpdatesOperation): - return "automatic updates operation invalid" - case errors.Is(err, platform.ErrAutoUpdatesUnavailable): - return "automatic updates backend unavailable" - case errors.Is(err, platform.ErrAutoUpdatesApply): - return "automatic updates apply failed" - default: - return "automatic updates failed" - } -} - func plural(count int) string { if count == 1 { return "" diff --git a/apps/backend/internal/app/updates_automatic_test.go b/apps/backend/internal/app/updates_automatic_test.go deleted file mode 100644 index 3fb2801..0000000 --- a/apps/backend/internal/app/updates_automatic_test.go +++ /dev/null @@ -1,271 +0,0 @@ -package app - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/velopulent/tako/internal/auth" - "github.com/velopulent/tako/internal/platform" - "github.com/velopulent/tako/internal/session" -) - -func adminSessionFor(t *testing.T, server *Server, username string) (*http.Cookie, string) { - t.Helper() - created, err := server.sessions.Create(auth.Identity{Username: username, BridgeToken: "bridge", AdminToken: "secret-admin"}) - if err != nil { - t.Fatal(err) - } - if !server.sessions.SetAdministrative(created.ID, "secret-admin", time.Now().Add(time.Hour)) { - t.Fatal("could not grant administrative access") - } - return &http.Cookie{Name: session.CookieName, Value: created.ID}, created.CSRF -} - -func TestUpdateLiveAndHistoryEndpointsReturnBoundedReads(t *testing.T) { - server, err := New(testConfig(t)) - if err != nil { - t.Fatal(err) - } - defer server.cancel() - cookie, _ := adminSessionFor(t, server, "operator") - - liveRequest := httptest.NewRequest(http.MethodGet, "/api/v1/updates/live", nil) - liveRequest.AddCookie(cookie) - liveRecorder := httptest.NewRecorder() - server.routes().ServeHTTP(liveRecorder, liveRequest) - if liveRecorder.Code != http.StatusOK { - t.Fatalf("live returned %d: %s", liveRecorder.Code, liveRecorder.Body.String()) - } - var liveResponse struct { - Live platform.UpdateLive `json:"live"` - Log []map[string]any `json:"log"` - } - if err := json.Unmarshal(liveRecorder.Body.Bytes(), &liveResponse); err != nil { - t.Fatal(err) - } - if liveResponse.Live.Active || liveResponse.Live.Percentage != -1 || liveResponse.Log == nil { - t.Fatalf("unexpected inactive observation: %#v", liveResponse) - } - - historyRequest := httptest.NewRequest(http.MethodGet, "/api/v1/updates/history", nil) - historyRequest.AddCookie(cookie) - historyRecorder := httptest.NewRecorder() - server.routes().ServeHTTP(historyRecorder, historyRequest) - if historyRecorder.Code != http.StatusOK { - t.Fatalf("history returned %d: %s", historyRecorder.Code, historyRecorder.Body.String()) - } -} - -func TestAutomaticUpdatesStatusUsesSeam(t *testing.T) { - server, err := New(testConfig(t)) - if err != nil { - t.Fatal(err) - } - defer server.cancel() - server.autoUpdatesStatusFn = func(context.Context) platform.AutoUpdatesConfig { - return platform.AutoUpdatesConfig{Available: true, Supported: true, Installed: true, Enabled: true, Type: "security", Day: "mon", Time: "6:00", Provider: "dnf4-automatic"} - } - created, err := server.sessions.Create(auth.Identity{Username: "operator", BridgeToken: "bridge"}) - if err != nil { - t.Fatal(err) - } - request := httptest.NewRequest(http.MethodGet, "/api/v1/updates/automatic", nil) - request.AddCookie(&http.Cookie{Name: session.CookieName, Value: created.ID}) - recorder := httptest.NewRecorder() - server.routes().ServeHTTP(recorder, request) - if recorder.Code != http.StatusOK { - t.Fatalf("automatic status returned %d: %s", recorder.Code, recorder.Body.String()) - } - var config platform.AutoUpdatesConfig - if err := json.Unmarshal(recorder.Body.Bytes(), &config); err != nil { - t.Fatal(err) - } - if !config.Enabled || config.Day != "mon" || config.Provider != "dnf4-automatic" { - t.Fatalf("unexpected automatic config: %#v", config) - } -} - -func TestApplyAutomaticUpdatesRequiresAdministrativeAccess(t *testing.T) { - server, err := New(testConfig(t)) - if err != nil { - t.Fatal(err) - } - defer server.cancel() - created, err := server.sessions.Create(auth.Identity{Username: "operator", BridgeToken: "bridge"}) - if err != nil { - t.Fatal(err) - } - request := httptest.NewRequest(http.MethodPut, "/api/v1/updates/automatic", bytes.NewBufferString(`{"enabled":true}`)) - request.AddCookie(&http.Cookie{Name: session.CookieName, Value: created.ID}) - request.Header.Set("X-CSRF-Token", created.CSRF) - recorder := httptest.NewRecorder() - server.routes().ServeHTTP(recorder, request) - if recorder.Code != http.StatusForbidden { - t.Fatalf("non-admin apply returned %d: %s", recorder.Code, recorder.Body.String()) - } -} - -func TestApplyAutomaticUpdatesRoutesOperationWithAdminToken(t *testing.T) { - server, err := New(testConfig(t)) - if err != nil { - t.Fatal(err) - } - defer server.cancel() - calls := make(chan auth.AutoUpdatesRequest, 1) - server.autoUpdatesFn = func(_ context.Context, request auth.AutoUpdatesRequest) (platform.AutoUpdatesConfig, error) { - calls <- request - return platform.AutoUpdatesConfig{Available: true, Supported: true, Installed: true, Enabled: true, Type: "all", Provider: "dnf5-automatic"}, nil - } - cookie, csrf := adminSessionFor(t, server, "operator") - request := httptest.NewRequest(http.MethodPut, "/api/v1/updates/automatic", bytes.NewBufferString(`{"enabled":true,"type":"all","day":"","time":"06:00"}`)) - request.AddCookie(cookie) - request.Header.Set("X-CSRF-Token", csrf) - recorder := httptest.NewRecorder() - server.routes().ServeHTTP(recorder, request) - if recorder.Code != http.StatusOK { - t.Fatalf("apply returned %d: %s", recorder.Code, recorder.Body.String()) - } - select { - case received := <-calls: - if received.AdminToken != "secret-admin" { - t.Fatalf("admin token missing from sessiond call: %#v", received) - } - if received.Operation.Enabled == nil || !*received.Operation.Enabled { - t.Fatalf("operation not forwarded: %#v", received.Operation) - } - default: - t.Fatal("autoUpdatesFn was not invoked") - } - badBody := `{"enabled":true,"day":"funday"}` - rejected := httptest.NewRequest(http.MethodPut, "/api/v1/updates/automatic", strings.NewReader(badBody)) - rejected.AddCookie(cookie) - rejected.Header.Set("X-CSRF-Token", csrf) - badRecorder := httptest.NewRecorder() - server.routes().ServeHTTP(badRecorder, rejected) - if badRecorder.Code != http.StatusBadRequest { - t.Fatalf("invalid operation returned %d: %s", badRecorder.Code, badRecorder.Body.String()) - } -} - -func TestCancelRunningUpdateRequiresAdministrativeAccess(t *testing.T) { - server, err := New(testConfig(t)) - if err != nil { - t.Fatal(err) - } - defer server.cancel() - created, err := server.sessions.Create(auth.Identity{Username: "operator", BridgeToken: "bridge"}) - if err != nil { - t.Fatal(err) - } - request := httptest.NewRequest(http.MethodPost, "/api/v1/updates/cancel", nil) - request.AddCookie(&http.Cookie{Name: session.CookieName, Value: created.ID}) - request.Header.Set("X-CSRF-Token", created.CSRF) - recorder := httptest.NewRecorder() - server.routes().ServeHTTP(recorder, request) - if recorder.Code != http.StatusForbidden { - t.Fatalf("non-admin cancel returned %d: %s", recorder.Code, recorder.Body.String()) - } - - cookie, csrf := adminSessionFor(t, server, "operator") - adminRequest := httptest.NewRequest(http.MethodPost, "/api/v1/updates/cancel", nil) - adminRequest.AddCookie(cookie) - adminRequest.Header.Set("X-CSRF-Token", csrf) - adminRecorder := httptest.NewRecorder() - server.routes().ServeHTTP(adminRecorder, adminRequest) - if adminRecorder.Code != http.StatusOK { - t.Fatalf("admin cancel returned %d: %s", adminRecorder.Code, adminRecorder.Body.String()) - } - var result struct { - Canceled bool `json:"canceled"` - } - if err := json.Unmarshal(adminRecorder.Body.Bytes(), &result); err != nil { - t.Fatal(err) - } - // No update transaction is running in the test environment. - if result.Canceled { - t.Fatal("cancel reported success without a running transaction") - } -} - -func TestApplyKpatchSettingsRequiresAdministrativeAccessAndRoutesOperation(t *testing.T) { - server, err := New(testConfig(t)) - if err != nil { - t.Fatal(err) - } - defer server.cancel() - created, err := server.sessions.Create(auth.Identity{Username: "operator", BridgeToken: "bridge", AdminToken: "secret-admin"}) - if err != nil { - t.Fatal(err) - } - // Non-admin is rejected before any privileged call. - request := httptest.NewRequest(http.MethodPut, "/api/v1/updates/kpatch", bytes.NewBufferString(`{"apply":true}`)) - request.AddCookie(&http.Cookie{Name: session.CookieName, Value: created.ID}) - request.Header.Set("X-CSRF-Token", created.CSRF) - recorder := httptest.NewRecorder() - server.routes().ServeHTTP(recorder, request) - if recorder.Code != http.StatusForbidden { - t.Fatalf("non-admin kpatch apply returned %d: %s", recorder.Code, recorder.Body.String()) - } - - if !server.sessions.SetAdministrative(created.ID, "secret-admin", time.Now().Add(time.Hour)) { - t.Fatal("could not grant administrative access") - } - calls := make(chan auth.KpatchRequest, 1) - server.kpatchSettingsFn = func(_ context.Context, request auth.KpatchRequest) (platform.KpatchSettingsStatus, error) { - calls <- request - return platform.KpatchSettingsStatus{Supported: true, Missing: []string{}, Unavailable: []string{}, Auto: true}, nil - } - adminRequest := httptest.NewRequest(http.MethodPut, "/api/v1/updates/kpatch", bytes.NewBufferString(`{"apply":true,"currentOnly":false}`)) - adminRequest.AddCookie(&http.Cookie{Name: session.CookieName, Value: created.ID}) - adminRequest.Header.Set("X-CSRF-Token", created.CSRF) - adminRecorder := httptest.NewRecorder() - server.routes().ServeHTTP(adminRecorder, adminRequest) - if adminRecorder.Code != http.StatusOK { - t.Fatalf("admin kpatch apply returned %d: %s", adminRecorder.Code, adminRecorder.Body.String()) - } - select { - case received := <-calls: - if received.AdminToken != "secret-admin" || received.Operation.Apply == nil || !*received.Operation.Apply { - t.Fatalf("operation not forwarded: %#v", received) - } - default: - t.Fatal("kpatchSettingsFn was not invoked") - } - - badRequest := httptest.NewRequest(http.MethodPut, "/api/v1/updates/kpatch", strings.NewReader(`{"currentOnly":true}`)) - badRequest.AddCookie(&http.Cookie{Name: session.CookieName, Value: created.ID}) - badRequest.Header.Set("X-CSRF-Token", created.CSRF) - badRecorder := httptest.NewRecorder() - server.routes().ServeHTTP(badRecorder, badRequest) - if badRecorder.Code != http.StatusBadRequest { - t.Fatalf("invalid kpatch operation returned %d: %s", badRecorder.Code, badRecorder.Body.String()) - } -} - -func TestSyncUpdateNotificationsOpenAndResolve(t *testing.T) { - server, err := New(testConfig(t)) - if err != nil { - t.Fatal(err) - } - defer server.cancel() - status := platform.UpdateStatus{Available: true, Backend: "dnf", Packages: []platform.UpdatePackage{ - {Name: "openssl", Severity: "security"}, - {Name: "vim", Severity: "bugfix"}, - }} - server.syncUpdateNotifications(status) - items := server.notifications.list("") - if len(items) != 1 || items[0].ID != "software-update-security" || items[0].Severity != "warning" { - t.Fatalf("expected security notification: %#v", items) - } - status.Packages = nil - server.syncUpdateNotifications(status) - if remaining := server.notifications.list("open"); len(remaining) != 0 { - t.Fatalf("notifications not resolved: %#v", remaining) - } -} diff --git a/apps/backend/internal/app/updates_contract_test.go b/apps/backend/internal/app/updates_contract_test.go index 1d7d614..bd88190 100644 --- a/apps/backend/internal/app/updates_contract_test.go +++ b/apps/backend/internal/app/updates_contract_test.go @@ -24,7 +24,7 @@ func TestUpdateHandlersEmitEmptyArraysNotNull(t *testing.T) { return platform.UpdateStatus{Available: true, Backend: "test", Contract: "test"}, nil } server.previewUpdatesFn = func(_ context.Context, operation platform.UpdateOperation) (platform.UpdatePreview, error) { - return platform.UpdatePreview{Operation: operation}, nil + return platform.UpdatePreview{}, nil } server.readUpdateHistoryFn = func(context.Context) ([]platform.UpdateHistoryEntry, error) { return nil, nil @@ -59,7 +59,7 @@ func TestUpdateHandlersEmitEmptyArraysNotNull(t *testing.T) { t.Fatalf("updates contains null packages: %s", statusBody) } - previewRequest := httptest.NewRequest(http.MethodPost, "/api/v1/updates/preview", strings.NewReader(`{"scope":"all"}`)) + previewRequest := httptest.NewRequest(http.MethodPost, "/api/v1/updates/preview", strings.NewReader(`{"expectedFingerprint":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","confirmed":false}`)) previewRequest.AddCookie(cookie) previewRecorder := httptest.NewRecorder() server.routes().ServeHTTP(previewRecorder, previewRequest) @@ -67,7 +67,7 @@ func TestUpdateHandlersEmitEmptyArraysNotNull(t *testing.T) { t.Fatalf("preview returned %d: %s", previewRecorder.Code, previewRecorder.Body.String()) } previewBody := previewRecorder.Body.String() - for _, key := range []string{`"selected":[]`, `"changes":[]`, `"warnings":[]`} { + for _, key := range []string{`"changes":[]`, `"warnings":[]`} { if !strings.Contains(previewBody, key) { t.Fatalf("preview missing %s: %s", key, previewBody) } @@ -78,8 +78,31 @@ func TestUpdateHandlersEmitEmptyArraysNotNull(t *testing.T) { t.Fatalf("history missing empty items: %s", historyBody) } - liveBody := get("/api/v1/updates/live") - if !strings.Contains(liveBody, `"log":[]`) { - t.Fatalf("live missing empty log: %s", liveBody) + liveContext, cancelLive := context.WithCancel(context.Background()) + cancelLive() + liveRequest := httptest.NewRequest(http.MethodGet, "/api/v1/updates/live", nil).WithContext(liveContext) + liveRequest.AddCookie(cookie) + liveRecorder := httptest.NewRecorder() + server.routes().ServeHTTP(liveRecorder, liveRequest) + if liveRecorder.Header().Get("Content-Type") != "text/event-stream" || !strings.Contains(liveRecorder.Body.String(), "event: progress") { + t.Fatalf("live endpoint is not SSE: %s", liveRecorder.Body.String()) + } + + server.readUpdateLiveFn = func(context.Context) (auth.UpdateObservation, error) { + return auth.UpdateObservation{ + Progress: platform.UpdateProgress{Sequence: 2, Phase: "applying", Percent: -1}, + Events: []platform.UpdateStreamEvent{ + {Kind: "output", Output: platform.UpdateOutput{Sequence: 1, Stream: "stdout", Line: "old"}}, + {Kind: "output", Output: platform.UpdateOutput{Sequence: 2, Stream: "stdout", Line: "new"}}, + }, + }, nil + } + replayRequest := httptest.NewRequest(http.MethodGet, "/api/v1/updates/live", nil).WithContext(liveContext) + replayRequest.Header.Set("Last-Event-ID", "1") + replayRequest.AddCookie(cookie) + replayRecorder := httptest.NewRecorder() + server.routes().ServeHTTP(replayRecorder, replayRequest) + if strings.Contains(replayRecorder.Body.String(), "data: {\"sequence\":1") || !strings.Contains(replayRecorder.Body.String(), "id: 2") { + t.Fatalf("SSE replay did not honor Last-Event-ID: %s", replayRecorder.Body.String()) } } diff --git a/apps/backend/internal/app/updates_test.go b/apps/backend/internal/app/updates_test.go index 065dac2..d83bed1 100644 --- a/apps/backend/internal/app/updates_test.go +++ b/apps/backend/internal/app/updates_test.go @@ -76,12 +76,12 @@ func TestUpdatePreviewAndJobKeepAdminTokenOutOfDurableParameters(t *testing.T) { } server.readUpdatesFn = func(context.Context) (platform.UpdateStatus, error) { return status, nil } server.previewUpdatesFn = func(_ context.Context, operation platform.UpdateOperation) (platform.UpdatePreview, error) { - return platform.UpdatePreview{Operation: operation, Current: status, Selected: status.Packages, Changes: []string{"update 1 package"}, Warnings: []string{}, Fingerprint: fingerprint, Allowed: true, RequiresConfirmation: true}, nil + return platform.UpdatePreview{Current: status, Changes: []platform.UpdateChange{{Action: "upgrade", Name: "openssl", CandidateVersion: "3.0.14"}}, Warnings: []string{}, Fingerprint: fingerprint, Allowed: true, RequiresConfirmation: true}, nil } called := make(chan auth.UpdateRequest, 1) server.applyUpdatesFn = func(_ context.Context, request auth.UpdateRequest) (platform.UpdateResult, error) { called <- request - return platform.UpdateResult{Backend: "apt-get", Scope: request.Operation.Scope, Packages: []string{"openssl"}, Updated: status.Packages, Verified: true, Message: "Updates applied and verified.", Fingerprint: fingerprint}, nil + return platform.UpdateResult{Backend: "apt-get", Changes: []platform.UpdateChange{{Action: "upgrade", Name: "openssl", CandidateVersion: "3.0.14"}}, Verified: true, Message: "Updates applied and verified.", Fingerprint: fingerprint}, nil } created, err := server.sessions.Create(auth.Identity{Username: "operator", BridgeToken: "bridge", AdminToken: "secret-admin"}) if err != nil { @@ -90,14 +90,14 @@ func TestUpdatePreviewAndJobKeepAdminTokenOutOfDurableParameters(t *testing.T) { if !server.sessions.SetAdministrative(created.ID, "secret-admin", time.Now().Add(time.Hour)) { t.Fatal("could not grant administrative access") } - previewRequest := httptest.NewRequest(http.MethodPost, "/api/v1/updates/preview", bytes.NewBufferString(`{"scope":"all","expectedFingerprint":"`+fingerprint+`"}`)) + previewRequest := httptest.NewRequest(http.MethodPost, "/api/v1/updates/preview", bytes.NewBufferString(`{"expectedFingerprint":"`+fingerprint+`"}`)) previewRequest.AddCookie(&http.Cookie{Name: session.CookieName, Value: created.ID}) previewRecorder := httptest.NewRecorder() server.routes().ServeHTTP(previewRecorder, previewRequest) if previewRecorder.Code != http.StatusOK { t.Fatalf("preview returned %d: %s", previewRecorder.Code, previewRecorder.Body.String()) } - applyRequest := httptest.NewRequest(http.MethodPost, "/api/v1/updates", bytes.NewBufferString(`{"scope":"all","expectedFingerprint":"`+fingerprint+`","confirmation":"APPLY UPDATES"}`)) + applyRequest := httptest.NewRequest(http.MethodPost, "/api/v1/updates", bytes.NewBufferString(`{"expectedFingerprint":"`+fingerprint+`","confirmed":true}`)) applyRequest.AddCookie(&http.Cookie{Name: session.CookieName, Value: created.ID}) applyRequest.Header.Set("X-CSRF-Token", created.CSRF) applyRecorder := httptest.NewRecorder() diff --git a/apps/backend/internal/auth/auth.go b/apps/backend/internal/auth/auth.go index 3994473..fda9279 100644 --- a/apps/backend/internal/auth/auth.go +++ b/apps/backend/internal/auth/auth.go @@ -135,7 +135,6 @@ type Request struct { PasswordChange *PasswordChangeOperation `json:"passwordChange,omitempty"` SSHKeys *platform.SSHKeyOperation `json:"sshKeys,omitempty"` Updates *platform.UpdateOperation `json:"updates,omitempty"` - AutoUpdates *platform.AutoUpdatesOperation `json:"autoUpdates,omitempty"` Kpatch *platform.KpatchOperation `json:"kpatch,omitempty"` File *platform.FileOperation `json:"file,omitempty"` Journal *platform.JournalQuery `json:"journal,omitempty"` @@ -154,11 +153,10 @@ type Request struct { StorageRead *StorageReadOperation `json:"storageRead,omitempty"` NetworkRead *NetworkReadOperation `json:"networkRead,omitempty"` UpdateRead *UpdateReadOperation `json:"updateRead,omitempty"` + UpdatePreview *platform.UpdateOperation `json:"updatePreview,omitempty"` UpdateRefresh *UpdateRefreshOperation `json:"updateRefresh,omitempty"` UpdateHistoryRead *UpdateHistoryReadOperation `json:"updateHistoryRead,omitempty"` UpdateLiveRead *UpdateLiveReadOperation `json:"updateLiveRead,omitempty"` - UpdateCancel *UpdateCancelOperation `json:"updateCancel,omitempty"` - AutoUpdatesRead *AutoUpdatesReadOperation `json:"autoUpdatesRead,omitempty"` KpatchRead *KpatchReadOperation `json:"kpatchRead,omitempty"` CapabilitiesRead *CapabilitiesReadOperation `json:"capabilitiesRead,omitempty"` LoginHistoryRead *LoginHistoryReadOperation `json:"loginHistoryRead,omitempty"` @@ -280,11 +278,6 @@ type UpdateRequest struct { Operation platform.UpdateOperation } -type AutoUpdatesRequest struct { - AdminToken string - Operation platform.AutoUpdatesOperation -} - type KpatchRequest struct { AdminToken string Operation platform.KpatchOperation @@ -613,32 +606,6 @@ func ApplyUpdates(ctx context.Context, path string, request UpdateRequest) (plat return *response.UpdateResult, nil } -// ApplyAutoUpdatesConfig mutates automatic-update configuration through -// sessiond; the gateway never edits package-manager configuration itself. -func ApplyAutoUpdatesConfig(ctx context.Context, path string, request AutoUpdatesRequest) (platform.AutoUpdatesConfig, error) { - operation := request.Operation - response, err := socketRequest(ctx, path, Request{Operation: "updates-auto", AdminToken: request.AdminToken, AutoUpdates: &operation}) - if err != nil { - return platform.AutoUpdatesConfig{}, err - } - if response.Error != "" { - switch response.Error { - case "invalid-auto-updates-operation": - return platform.AutoUpdatesConfig{}, platform.ErrInvalidAutoUpdatesOperation - case "auto-updates-unavailable": - return platform.AutoUpdatesConfig{}, platform.ErrAutoUpdatesUnavailable - case "auto-updates-apply-failed": - return platform.AutoUpdatesConfig{}, platform.ErrAutoUpdatesApply - default: - return platform.AutoUpdatesConfig{}, errors.New(response.Error) - } - } - if response.AutoUpdatesConfig == nil { - return platform.AutoUpdatesConfig{}, ErrServiceUnavailable - } - return *response.AutoUpdatesConfig, nil -} - // ApplyKpatchSettings turns kernel live patching on or off through sessiond. func ApplyKpatchSettings(ctx context.Context, path string, request KpatchRequest) (platform.KpatchSettingsStatus, error) { operation := request.Operation @@ -1107,7 +1074,7 @@ type Response struct { SSHKeyState *platform.SSHKeyState `json:"sshKeyState,omitempty"` SSHKeyPreview *platform.SSHKeyPreview `json:"sshKeyPreview,omitempty"` UpdateResult *platform.UpdateResult `json:"updateResult,omitempty"` - AutoUpdatesConfig *platform.AutoUpdatesConfig `json:"autoUpdatesConfig,omitempty"` + UpdatePreview *platform.UpdatePreview `json:"updatePreview,omitempty"` KpatchSettings *platform.KpatchSettingsStatus `json:"kpatchSettings,omitempty"` FileResult *platform.FileResult `json:"fileResult,omitempty"` JournalPage *platform.JournalPage `json:"journalPage,omitempty"` @@ -1132,7 +1099,6 @@ type Response struct { UpdateStatus *platform.UpdateStatus `json:"updateStatus,omitempty"` UpdateHistory []platform.UpdateHistoryEntry `json:"updateHistory,omitempty"` UpdateObservation *UpdateObservation `json:"updateObservation,omitempty"` - UpdateCanceled *bool `json:"updateCanceled,omitempty"` KpatchStatus *platform.KpatchStatus `json:"kpatchStatus,omitempty"` Capabilities []platform.Capability `json:"capabilities,omitempty"` LoginHistoryPage *platform.LoginHistoryPage `json:"loginHistoryPage,omitempty"` diff --git a/apps/backend/internal/auth/host_reads.go b/apps/backend/internal/auth/host_reads.go index cdbd7f6..ce2d2b4 100644 --- a/apps/backend/internal/auth/host_reads.go +++ b/apps/backend/internal/auth/host_reads.go @@ -10,7 +10,6 @@ import ( "github.com/velopulent/tako/internal/host" "github.com/velopulent/tako/internal/metrics" - "github.com/velopulent/tako/internal/packagekit" "github.com/velopulent/tako/internal/platform" ) @@ -36,10 +35,9 @@ type IdentityReadOperation struct{} type StorageReadOperation struct{} type NetworkReadOperation struct{} type UpdateReadOperation struct{} +type UpdatePreviewOperation struct{ Operation platform.UpdateOperation } type UpdateHistoryReadOperation struct{} type UpdateLiveReadOperation struct{} -type UpdateCancelOperation struct{} -type AutoUpdatesReadOperation struct{} type KpatchReadOperation struct{} type CapabilitiesReadOperation struct{} @@ -65,12 +63,7 @@ type MetricsFollowOperation struct { Interval time.Duration `json:"interval,omitempty"` } -// UpdateObservation is the bounded live update payload shared by the gateway -// and sessiond. PackageKit clients and signal watchers live only in sessiond. -type UpdateObservation struct { - Live platform.UpdateLive `json:"live"` - Log []packagekit.ActionLogEntry `json:"log"` -} +type UpdateObservation = platform.UpdateObservation func readHostResponse(ctx context.Context, path string, credentials HostReadCredentials, operation string, payload any, timeout time.Duration, limit int64) (Response, error) { request := Request{Operation: operation, Token: credentials.Token, AdminToken: credentials.AdminToken} @@ -97,16 +90,14 @@ func readHostResponse(ctx context.Context, path string, credentials HostReadCred request.NetworkRead = value case *UpdateReadOperation: request.UpdateRead = value + case *UpdatePreviewOperation: + request.UpdatePreview = &value.Operation case *UpdateRefreshOperation: request.UpdateRefresh = value case *UpdateHistoryReadOperation: request.UpdateHistoryRead = value case *UpdateLiveReadOperation: request.UpdateLiveRead = value - case *UpdateCancelOperation: - request.UpdateCancel = value - case *AutoUpdatesReadOperation: - request.AutoUpdatesRead = value case *KpatchReadOperation: request.KpatchRead = value case *CapabilitiesReadOperation: @@ -341,6 +332,17 @@ func ReadUpdateStatus(ctx context.Context, path string, credentials HostReadCred return *response.UpdateStatus, nil } +func PreviewUpdates(ctx context.Context, path string, credentials HostReadCredentials, operation platform.UpdateOperation) (platform.UpdatePreview, error) { + response, err := readHostResponse(ctx, path, credentials, "updates.preview", &UpdatePreviewOperation{Operation: operation}, 2*time.Minute, 8<<20) + if err != nil { + return platform.UpdatePreview{}, err + } + if response.UpdatePreview == nil { + return platform.UpdatePreview{}, ErrServiceUnavailable + } + return *response.UpdatePreview, nil +} + func RefreshUpdates(ctx context.Context, path string, adminToken string, force bool) (platform.UpdateStatus, error) { operation := UpdateRefreshOperation{Force: force} response, err := readHostResponse(ctx, path, HostReadCredentials{AdminToken: adminToken}, "updates.refresh", &operation, 5*time.Minute, 8<<20) @@ -365,7 +367,7 @@ func ReadUpdateHistory(ctx context.Context, path string, credentials HostReadCre } func ReadUpdateObservation(ctx context.Context, path string, credentials HostReadCredentials) (UpdateObservation, error) { - response, err := readHostResponse(ctx, path, credentials, "updates.live", &UpdateLiveReadOperation{}, 10*time.Second, 512<<10) + response, err := readHostResponse(ctx, path, credentials, "updates.live", &UpdateLiveReadOperation{}, 10*time.Second, 2<<20) if err != nil { return UpdateObservation{}, err } @@ -375,28 +377,6 @@ func ReadUpdateObservation(ctx context.Context, path string, credentials HostRea return *response.UpdateObservation, nil } -func CancelUpdate(ctx context.Context, path string, adminToken string) (bool, error) { - response, err := readHostResponse(ctx, path, HostReadCredentials{AdminToken: adminToken}, "updates.cancel", &UpdateCancelOperation{}, 15*time.Second, 128<<10) - if err != nil { - return false, err - } - if response.UpdateCanceled == nil { - return false, ErrServiceUnavailable - } - return *response.UpdateCanceled, nil -} - -func ReadAutoUpdatesStatus(ctx context.Context, path string, credentials HostReadCredentials) (platform.AutoUpdatesConfig, error) { - response, err := readHostResponse(ctx, path, credentials, "updates.automatic.read", &AutoUpdatesReadOperation{}, 20*time.Second, 512<<10) - if err != nil { - return platform.AutoUpdatesConfig{}, err - } - if response.AutoUpdatesConfig == nil { - return platform.AutoUpdatesConfig{}, ErrServiceUnavailable - } - return *response.AutoUpdatesConfig, nil -} - func ReadKpatch(ctx context.Context, path string, credentials HostReadCredentials) (platform.KpatchStatus, platform.KpatchSettingsStatus, error) { response, err := readHostResponse(ctx, path, credentials, "updates.kpatch.read", &KpatchReadOperation{}, 30*time.Second, 512<<10) if err != nil { diff --git a/apps/backend/internal/bridge/bridge.go b/apps/backend/internal/bridge/bridge.go index 225951e..9a2451e 100644 --- a/apps/backend/internal/bridge/bridge.go +++ b/apps/backend/internal/bridge/bridge.go @@ -29,7 +29,11 @@ type frame struct { } // Run serves framed bridge RPC until input closes or an I/O error occurs. -func Run(input io.Reader, output io.Writer, errorOutput io.Writer) error { +func Run(input io.Reader, output io.Writer, errorOutput io.Writer, services ...*platform.UpdateService) error { + var updates *platform.UpdateService + if len(services) > 0 { + updates = services[0] + } logger, err := logging.NewWithOutput("bridge", errorOutput) if err != nil { return err @@ -49,7 +53,7 @@ func Run(input io.Reader, output io.Writer, errorOutput io.Writer) error { return err } response := frame{ID: message.ID, Error: "unsupported-method"} - if payload, handled, err := handleHostRead(message.Method, message.Payload); handled { + if payload, handled, err := handleHostRead(message.Method, message.Payload, updates); handled { if err == nil { response.Error = "" response.Payload, _ = json.Marshal(payload) diff --git a/apps/backend/internal/bridge/host_reads.go b/apps/backend/internal/bridge/host_reads.go index 2273076..0b930cf 100644 --- a/apps/backend/internal/bridge/host_reads.go +++ b/apps/backend/internal/bridge/host_reads.go @@ -13,7 +13,6 @@ import ( "github.com/velopulent/tako/internal/auth" "github.com/velopulent/tako/internal/host" - "github.com/velopulent/tako/internal/packagekit" "github.com/velopulent/tako/internal/platform" ) @@ -60,12 +59,16 @@ func runUserSystemctl(ctx context.Context, arguments ...string) error { return nil } -func handleHostRead(method string, payload json.RawMessage) (any, bool, error) { +func handleHostRead(method string, payload json.RawMessage, services ...*platform.UpdateService) (any, bool, error) { if !isHostReadMethod(method) { return nil, false, nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + var updates *platform.UpdateService + if len(services) > 0 { + updates = services[0] + } switch method { case "services.read", "services.list": @@ -148,18 +151,19 @@ func handleHostRead(method string, payload json.RawMessage) (any, bool, error) { item, err := platform.NetworkSnapshotRead(ctx) return item, true, err case "updates.read", "updates.status": - return platform.Updates(ctx), true, nil + return updates.Status(ctx), true, nil case "updates.history": - items, err := platform.UpdateHistory(ctx) + items, err := updates.History(ctx) return items, true, err case "updates.live": - return auth.UpdateObservation{Live: platform.UpdateLiveStatus(ctx), Log: []packagekit.ActionLogEntry{}}, true, nil - case "updates.automatic.read": - return platform.AutoUpdatesStatus(ctx), true, nil + if updates == nil { + return auth.UpdateObservation{Progress: platform.UpdateProgress{Phase: "idle", Percent: -1, Message: "No update is running."}, Output: []platform.UpdateOutput{}}, true, nil + } + return updates.Snapshot(), true, nil case "updates.kpatch.read": return map[string]any{"status": platform.InspectKpatchStatus(ctx), "settings": platform.InspectKpatchSettings(ctx)}, true, nil case "capabilities.read": - return platform.Detect(ctx), true, nil + return platform.Detect(ctx, updates), true, nil case "login-history.read": var operation auth.LoginHistoryReadOperation if err := decodeHostPayload(payload, &operation); err != nil { @@ -174,7 +178,7 @@ func handleHostRead(method string, payload json.RawMessage) (any, bool, error) { func isHostReadMethod(method string) bool { switch method { - case "services.read", "services.list", "services.detail", "services.configuration", "services.action", "host.info", "host.configuration.read", "host.power.read", "processes.signal-preview", "processes.signal", "identities.read", "storage.read", "network.read", "updates.read", "updates.status", "updates.history", "updates.live", "updates.automatic.read", "updates.kpatch.read", "capabilities.read", "login-history.read": + case "services.read", "services.list", "services.detail", "services.configuration", "services.action", "host.info", "host.configuration.read", "host.power.read", "processes.signal-preview", "processes.signal", "identities.read", "storage.read", "network.read", "updates.read", "updates.status", "updates.history", "updates.live", "updates.kpatch.read", "capabilities.read", "login-history.read": return true default: return false diff --git a/apps/backend/internal/sessiond/bridge_reads.go b/apps/backend/internal/sessiond/bridge_reads.go index ea1b1f1..07fd5fb 100644 --- a/apps/backend/internal/sessiond/bridge_reads.go +++ b/apps/backend/internal/sessiond/bridge_reads.go @@ -99,12 +99,6 @@ func (process *userBridgeProcess) readUpdateObservation(ctx context.Context) (au return result, err } -func (process *userBridgeProcess) readAutoUpdatesStatus(ctx context.Context) (platform.AutoUpdatesConfig, error) { - var result platform.AutoUpdatesConfig - err := process.decode(ctx, "updates-automatic", "updates.automatic.read", struct{}{}, &result) - return result, err -} - func (process *userBridgeProcess) readKpatch(ctx context.Context) (platform.KpatchStatus, platform.KpatchSettingsStatus, error) { var result struct { Status platform.KpatchStatus `json:"status"` diff --git a/apps/backend/internal/sessiond/host_reads.go b/apps/backend/internal/sessiond/host_reads.go index 5bb2f4e..0a1c500 100644 --- a/apps/backend/internal/sessiond/host_reads.go +++ b/apps/backend/internal/sessiond/host_reads.go @@ -33,7 +33,6 @@ type userHostReader interface { readUpdateStatus(context.Context) (platform.UpdateStatus, error) readUpdateHistory(context.Context) ([]platform.UpdateHistoryEntry, error) readUpdateObservation(context.Context) (auth.UpdateObservation, error) - readAutoUpdatesStatus(context.Context) (platform.AutoUpdatesConfig, error) readKpatch(context.Context) (platform.KpatchStatus, platform.KpatchSettingsStatus, error) readCapabilities(context.Context) ([]platform.Capability, error) readLoginHistory(context.Context, auth.LoginHistoryReadOperation) (platform.LoginHistoryPage, error) @@ -110,7 +109,7 @@ func handleHostOperation(conn net.Conn, encoder *json.Encoder, request auth.Requ func isHostOperation(operation string) bool { switch operation { - case "services.read", "services.list", "services.detail", "services.configuration", "services.action", "host.info", "host.configuration.read", "certificate.read", "host.power.read", "processes.list", "processes.detail", "processes.signal-preview", "processes.signal", "identities.read", "storage.read", "network.read", "updates.read", "updates.status", "updates.refresh", "updates.history", "updates.live", "updates.cancel", "updates.automatic.read", "updates.kpatch.read", "capabilities.read", "login-history.read", "local-group.preview", "local-group.apply", "metrics.history", "metrics.follow": + case "services.read", "services.list", "services.detail", "services.configuration", "services.action", "host.info", "host.configuration.read", "certificate.read", "host.power.read", "processes.list", "processes.detail", "processes.signal-preview", "processes.signal", "identities.read", "storage.read", "network.read", "updates.read", "updates.status", "updates.preview", "updates.refresh", "updates.history", "updates.live", "updates.kpatch.read", "capabilities.read", "login-history.read", "local-group.preview", "local-group.apply", "metrics.history", "metrics.follow": return true default: return false @@ -172,6 +171,10 @@ func validHostRequest(request auth.Request) bool { if request.UpdateRead != nil { payloads++ } + case "updates.preview": + if request.UpdatePreview != nil { + payloads++ + } case "updates.refresh": if request.UpdateRefresh != nil { payloads++ @@ -184,14 +187,6 @@ func validHostRequest(request auth.Request) bool { if request.UpdateLiveRead != nil { payloads++ } - case "updates.cancel": - if request.UpdateCancel != nil { - payloads++ - } - case "updates.automatic.read": - if request.AutoUpdatesRead != nil { - payloads++ - } case "updates.kpatch.read": if request.KpatchRead != nil { payloads++ @@ -226,8 +221,8 @@ func validHostRequest(request auth.Request) bool { request.ServiceAction != nil, request.CertificateRead != nil, request.PowerRead != nil, request.ProcessRead != nil, request.IdentityRead != nil, request.StorageRead != nil, request.NetworkRead != nil, - request.UpdateRead != nil, request.UpdateRefresh != nil, request.UpdateHistoryRead != nil, - request.UpdateLiveRead != nil, request.UpdateCancel != nil, request.AutoUpdatesRead != nil, + request.UpdateRead != nil, request.UpdatePreview != nil, request.UpdateRefresh != nil, request.UpdateHistoryRead != nil, + request.UpdateLiveRead != nil, request.KpatchRead != nil, request.CapabilitiesRead != nil, request.LoginHistoryRead != nil, request.SignalPreview != nil, request.SignalApply != nil, request.LocalGroup != nil, request.MetricsHistory != nil, request.MetricsFollow != nil, @@ -239,7 +234,7 @@ func validHostRequest(request auth.Request) bool { if totalTypedPayloads != 1 || totalTypedPayloads != payloads { return false } - return request.Username == "" && request.Password == "" && request.ConversationID == "" && len(request.Responses) == 0 && request.Columns == 0 && request.Rows == 0 && request.Action == "" && request.Unit == "" && request.Scope == "" && request.Hostname == "" && request.Timezone == "" && !request.NTPEnabled && request.ExpectedFingerprint == "" && request.PowerAction == "" && request.PowerConfirmation == "" && request.Timer == nil && request.Override == nil && request.Signal == nil && request.Account == nil && request.GroupMembership == nil && request.AdminRole == nil && request.PasswordChange == nil && request.SSHKeys == nil && request.Updates == nil && request.AutoUpdates == nil && request.Kpatch == nil && request.File == nil && request.Journal == nil && request.Network == nil && request.Firewall == nil && request.Security == nil && request.SupportReport == nil + return request.Username == "" && request.Password == "" && request.ConversationID == "" && len(request.Responses) == 0 && request.Columns == 0 && request.Rows == 0 && request.Action == "" && request.Unit == "" && request.Scope == "" && request.Hostname == "" && request.Timezone == "" && !request.NTPEnabled && request.ExpectedFingerprint == "" && request.PowerAction == "" && request.PowerConfirmation == "" && request.Timer == nil && request.Override == nil && request.Signal == nil && request.Account == nil && request.GroupMembership == nil && request.AdminRole == nil && request.PasswordChange == nil && request.SSHKeys == nil && request.Updates == nil && request.Kpatch == nil && request.File == nil && request.Journal == nil && request.Network == nil && request.Firewall == nil && request.Security == nil && request.SupportReport == nil } func dispatchHostOperation(ctx context.Context, request auth.Request, grants *grantStore, runtime *hostRuntime) (hostDispatchResult, error) { @@ -416,49 +411,30 @@ func dispatchHostOperation(ctx context.Context, request auth.Request, grants *gr } result.response.NetworkSnapshot = &item case "updates.read", "updates.status": - if administrative { - status := platform.Updates(ctx) - result.response.UpdateStatus = &status - } else { - status, readErr := reader.readUpdateStatus(ctx) - result.response.UpdateStatus, err = &status, readErr - } + result.lane = "root-sessiond" + status := runtime.updates.Status(ctx) + result.response.UpdateStatus = &status + case "updates.preview": + result.lane = "root-sessiond" + item, previewErr := runtime.updates.Preview(ctx, *request.UpdatePreview) + result.response.UpdatePreview, err = &item, previewErr case "updates.refresh": if !administrative { return result, errors.New("permission-denied") } operation := *request.UpdateRefresh - err = platform.RefreshUpdatesCache(ctx, operation.Force) + err = runtime.updates.Refresh(ctx, operation.Force) if err == nil { - status := platform.Updates(ctx) + status := runtime.updates.Status(ctx) result.response.UpdateStatus = &status } case "updates.history": - if administrative { - result.response.UpdateHistory, err = platform.UpdateHistory(ctx) - } else { - result.response.UpdateHistory, err = reader.readUpdateHistory(ctx) - } + result.lane = "root-sessiond" + result.response.UpdateHistory, err = runtime.updates.History(ctx) case "updates.live": - // PackageKit observation and the transaction watcher are sessiond-owned - // even for an ordinary authenticated reader. This avoids one D-Bus - // client per HTTP request and gives all clients the same live view. + result.lane = "root-sessiond" + // Update observation is sessiond-owned so every client sees one stream. result.response.UpdateObservation = observationPointer(runtime.updateObservation(ctx)) - case "updates.cancel": - if !administrative { - return result, errors.New("permission-denied") - } - found, cancelErr := cancelUpdate(ctx, runtime) - err = cancelErr - result.response.UpdateCanceled = &found - case "updates.automatic.read": - if administrative { - status := platform.AutoUpdatesStatus(ctx) - result.response.AutoUpdatesConfig = &status - } else { - status, readErr := reader.readAutoUpdatesStatus(ctx) - result.response.AutoUpdatesConfig, err = &status, readErr - } case "updates.kpatch.read": if administrative { status := platform.InspectKpatchStatus(ctx) @@ -472,11 +448,8 @@ func dispatchHostOperation(ctx context.Context, request auth.Request, grants *gr err = readErr } case "capabilities.read": - if administrative { - result.response.Capabilities = platform.Detect(ctx) - } else { - result.response.Capabilities, err = reader.readCapabilities(ctx) - } + result.lane = "root-sessiond" + result.response.Capabilities = platform.Detect(ctx, runtime.updates) case "login-history.read": operation := *request.LoginHistoryRead if !administrative && operation.Query.Username != identity.Username { @@ -590,15 +563,6 @@ func runUserServiceAction(backend userServiceActioner, operation platform.Servic return "" } -func cancelUpdate(ctx context.Context, runtime *hostRuntime) (bool, error) { - if client := runtime.updateClientFor(ctx); client != nil { - cancelContext, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - return client.CancelActiveUpdate(cancelContext) - } - return false, platform.ErrUpdateUnavailable -} - func handleMetricsFollow(conn net.Conn, encoder *json.Encoder, request auth.Request, grants *grantStore, runtime *hostRuntime, logger *zap.Logger) { lane := hostOperationRequestedLane(request) _, _, _, err := hostGrant(request, grants) diff --git a/apps/backend/internal/sessiond/host_reads_test.go b/apps/backend/internal/sessiond/host_reads_test.go index ce74144..bc86ea5 100644 --- a/apps/backend/internal/sessiond/host_reads_test.go +++ b/apps/backend/internal/sessiond/host_reads_test.go @@ -109,11 +109,6 @@ func (bridge *recordingUserBridge) readUpdateObservation(context.Context) (auth. return auth.UpdateObservation{}, nil } -func (bridge *recordingUserBridge) readAutoUpdatesStatus(context.Context) (platform.AutoUpdatesConfig, error) { - bridge.record("updates.automatic.read") - return platform.AutoUpdatesConfig{}, nil -} - func (bridge *recordingUserBridge) readKpatch(context.Context) (platform.KpatchStatus, platform.KpatchSettingsStatus, error) { bridge.record("updates.kpatch.read") return platform.KpatchStatus{}, platform.KpatchSettingsStatus{}, nil @@ -161,7 +156,7 @@ func TestHostOperationUsesAuthenticatedUserBridge(t *testing.T) { } } -func TestUpdateStatusOperationReachesUserBridge(t *testing.T) { +func TestUpdateStatusOperationUsesSharedSessiondProvider(t *testing.T) { bridge := &recordingUserBridge{} store, token := newReadTestStore(bridge) runtime := newHostRuntime() @@ -179,12 +174,12 @@ func TestUpdateStatusOperationReachesUserBridge(t *testing.T) { if err != nil { t.Fatalf("%s: %v", operation, err) } - if result.lane != "user-bridge" || result.response.UpdateStatus == nil { - t.Fatalf("%s: unexpected user-lane result: %+v", operation, result) + if result.lane != "root-sessiond" || result.response.UpdateStatus == nil { + t.Fatalf("%s: unexpected sessiond result: %+v", operation, result) } } - if !bridge.saw("updates.read") { - t.Fatal("update status bypassed authenticated user bridge") + if bridge.saw("updates.read") { + t.Fatal("update status should not start a per-user package-manager read") } } diff --git a/apps/backend/internal/sessiond/host_runtime.go b/apps/backend/internal/sessiond/host_runtime.go index 89eaea8..f16f1b0 100644 --- a/apps/backend/internal/sessiond/host_runtime.go +++ b/apps/backend/internal/sessiond/host_runtime.go @@ -2,12 +2,9 @@ package sessiond import ( "context" - "sync" "time" - "github.com/velopulent/tako/internal/auth" "github.com/velopulent/tako/internal/metrics" - "github.com/velopulent/tako/internal/packagekit" "github.com/velopulent/tako/internal/platform" ) @@ -16,16 +13,11 @@ const ( hostMetricInterval = time.Minute ) -// hostRuntime owns host-wide readers that must outlive an individual HTTP -// request. It lives in sessiond, never in the network gateway. type hostRuntime struct { sampler *metrics.Sampler processTracker *platform.ProcessTracker certificatePath string - - updateMu sync.Mutex - updateClient *packagekit.Client - updateWatcher *packagekit.TransactionWatcher + updates *platform.UpdateService } func newHostRuntime(settings ...time.Duration) *hostRuntime { @@ -49,76 +41,12 @@ func (runtime *hostRuntime) run(ctx context.Context) { if runtime == nil || runtime.sampler == nil { return } - // Configure already installed the deployment's default interval. Passing a - // non-zero value here would silently replace it with the legacy one-minute - // interval. runtime.sampler.Run(ctx, 0) } - -func (runtime *hostRuntime) close() { - if runtime == nil { - return - } - runtime.updateMu.Lock() - client, watcher := runtime.updateClient, runtime.updateWatcher - runtime.updateClient, runtime.updateWatcher = nil, nil - runtime.updateMu.Unlock() - if watcher != nil { - watcher.Close() - } - if client != nil { - client.Close() - } -} - -func (runtime *hostRuntime) updateClientFor(ctx context.Context) *packagekit.Client { - runtime.updateMu.Lock() - defer runtime.updateMu.Unlock() - if runtime.updateClient != nil { - return runtime.updateClient - } - client, err := packagekit.New() - if err != nil || !client.Detect(ctx) { - if client != nil { - client.Close() - } - return nil - } - runtime.updateClient = client - return client -} - -func (runtime *hostRuntime) updateWatcherFor() *packagekit.TransactionWatcher { - runtime.updateMu.Lock() - defer runtime.updateMu.Unlock() - if runtime.updateWatcher != nil { - return runtime.updateWatcher - } - watcher, err := packagekit.NewTransactionWatcher() - if err != nil { - return nil - } - watcher.Start() - runtime.updateWatcher = watcher - return watcher -} - -func (runtime *hostRuntime) updateObservation(ctx context.Context) auth.UpdateObservation { - result := auth.UpdateObservation{Live: platform.InactiveUpdateLive(), Log: []packagekit.ActionLogEntry{}} - observeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) - defer cancel() - client := runtime.updateClientFor(observeCtx) - var snapshot *packagekit.LiveUpdateSnapshot - if client != nil { - snapshot = client.UpdateSnapshot(observeCtx) - result.Live = platform.UpdateLiveFromSnapshot(snapshot) - } - if watcher := runtime.updateWatcherFor(); watcher != nil { - path := "" - if snapshot != nil { - path = snapshot.TransactionPath - } - result.Log = watcher.LatestLog(path) +func (runtime *hostRuntime) close() {} +func (runtime *hostRuntime) updateObservation(_ context.Context) platform.UpdateObservation { + if runtime == nil || runtime.updates == nil { + return platform.UpdateObservation{Progress: platform.UpdateProgress{Phase: "idle", Percent: -1, Message: "No update is running."}, Output: []platform.UpdateOutput{}} } - return result + return runtime.updates.Snapshot() } diff --git a/apps/backend/internal/sessiond/journal.go b/apps/backend/internal/sessiond/journal.go index 2c9ba36..e25b7e3 100644 --- a/apps/backend/internal/sessiond/journal.go +++ b/apps/backend/internal/sessiond/journal.go @@ -15,7 +15,7 @@ import ( ) func handleJournal(conn net.Conn, encoder *json.Encoder, request auth.Request, grants *grantStore, follow bool) { - if request.Journal == nil || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.File != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.Journal == nil || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.File != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-journal-query"}) return } diff --git a/apps/backend/internal/sessiond/sessiond.go b/apps/backend/internal/sessiond/sessiond.go index f2def6b..ebbd511 100644 --- a/apps/backend/internal/sessiond/sessiond.go +++ b/apps/backend/internal/sessiond/sessiond.go @@ -72,7 +72,7 @@ var errAdministrativeUnavailable = errors.New("administrative policy unavailable // Run starts the privileged local session service. The caller selects this // process mode explicitly; it never shares a process with the web gateway. -func Run(args []string) error { +func Run(args []string, updates *platform.UpdateService) error { flags := flag.NewFlagSet("sessiond", flag.ContinueOnError) socket := flags.String("socket", "/run/tako/session.sock", "Unix socket path") configPath := flags.String("config", "/etc/tako/config.toml", "gateway configuration file") @@ -136,6 +136,7 @@ func Run(args []string) error { grants := &grantStore{values: make(map[string]bridgeGrant), activity: activity} hostRuntime := newHostRuntime(runtimeConfig.MonitoringInterval, runtimeConfig.HistoryRetention) hostRuntime.certificatePath = runtimeConfig.Certificate + hostRuntime.updates = updates go hostRuntime.run(ctx) defer hostRuntime.close() policy := newAdministrativePolicy() @@ -196,7 +197,7 @@ func handle(conn net.Conn, service auth.PAMAuthenticator, conversations *convers // hostRuntime itself is still the only owner of shared host collectors in the // production session service. func handleWithRuntime(conn net.Conn, service auth.PAMAuthenticator, conversations *conversationStore, grants *grantStore, policy administrativePolicy, logger *zap.Logger, runtime *hostRuntime) { - handleWithAllBackendsAndUpdatesRuntime(conn, service, conversations, grants, policy, logger, systemHostConfigBackend{}, systemPowerBackend{}, systemTimerBackend{}, systemOverrideBackend{}, systemPasswordBackend{service: service}, systemSSHKeysBackend{}, systemLocalAccountBackend{}, systemGroupMembershipBackend{}, systemAdministrativeRoleBackend{}, systemUpdateBackend{}, systemAutoUpdatesBackend{}, systemKpatchBackend{}, runtime, systemServiceBackend{}) + handleWithAllBackendsAndUpdatesRuntime(conn, service, conversations, grants, policy, logger, systemHostConfigBackend{}, systemPowerBackend{}, systemTimerBackend{}, systemOverrideBackend{}, systemPasswordBackend{service: service}, systemSSHKeysBackend{}, systemLocalAccountBackend{}, systemGroupMembershipBackend{}, systemAdministrativeRoleBackend{}, systemUpdateBackend{service: runtime.updates}, systemKpatchBackend{}, runtime, systemServiceBackend{}) } func handleWithBackends(conn net.Conn, service auth.PAMAuthenticator, conversations *conversationStore, grants *grantStore, policy administrativePolicy, logger *zap.Logger, backend hostConfigBackend, power powerBackend, serviceBackends ...serviceBackend) { @@ -224,14 +225,14 @@ func handleWithSSHKeysBackend(conn net.Conn, service auth.PAMAuthenticator, conv } func handleWithAllBackends(conn net.Conn, service auth.PAMAuthenticator, conversations *conversationStore, grants *grantStore, policy administrativePolicy, logger *zap.Logger, backend hostConfigBackend, power powerBackend, timer timerBackend, override overrideBackend, password passwordBackend, sshKeys sshKeysBackend, account localAccountBackend, groups groupMembershipBackend, roles administrativeRoleBackend, serviceBackends ...serviceBackend) { - handleWithAllBackendsAndUpdates(conn, service, conversations, grants, policy, logger, backend, power, timer, override, password, sshKeys, account, groups, roles, systemUpdateBackend{}, systemAutoUpdatesBackend{}, systemKpatchBackend{}, serviceBackends...) + handleWithAllBackendsAndUpdates(conn, service, conversations, grants, policy, logger, backend, power, timer, override, password, sshKeys, account, groups, roles, systemUpdateBackend{}, systemKpatchBackend{}, serviceBackends...) } -func handleWithAllBackendsAndUpdates(conn net.Conn, service auth.PAMAuthenticator, conversations *conversationStore, grants *grantStore, policy administrativePolicy, logger *zap.Logger, backend hostConfigBackend, power powerBackend, timer timerBackend, override overrideBackend, password passwordBackend, sshKeys sshKeysBackend, account localAccountBackend, groups groupMembershipBackend, roles administrativeRoleBackend, updates updateBackend, autoUpdates autoUpdatesBackend, kpatch kpatchBackend, serviceBackends ...serviceBackend) { - handleWithAllBackendsAndUpdatesRuntime(conn, service, conversations, grants, policy, logger, backend, power, timer, override, password, sshKeys, account, groups, roles, updates, autoUpdates, kpatch, nil, serviceBackends...) +func handleWithAllBackendsAndUpdates(conn net.Conn, service auth.PAMAuthenticator, conversations *conversationStore, grants *grantStore, policy administrativePolicy, logger *zap.Logger, backend hostConfigBackend, power powerBackend, timer timerBackend, override overrideBackend, password passwordBackend, sshKeys sshKeysBackend, account localAccountBackend, groups groupMembershipBackend, roles administrativeRoleBackend, updates updateBackend, kpatch kpatchBackend, serviceBackends ...serviceBackend) { + handleWithAllBackendsAndUpdatesRuntime(conn, service, conversations, grants, policy, logger, backend, power, timer, override, password, sshKeys, account, groups, roles, updates, kpatch, nil, serviceBackends...) } -func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthenticator, conversations *conversationStore, grants *grantStore, policy administrativePolicy, logger *zap.Logger, backend hostConfigBackend, power powerBackend, timer timerBackend, override overrideBackend, password passwordBackend, sshKeys sshKeysBackend, account localAccountBackend, groups groupMembershipBackend, roles administrativeRoleBackend, updates updateBackend, autoUpdates autoUpdatesBackend, kpatch kpatchBackend, runtime *hostRuntime, serviceBackends ...serviceBackend) { +func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthenticator, conversations *conversationStore, grants *grantStore, policy administrativePolicy, logger *zap.Logger, backend hostConfigBackend, power powerBackend, timer timerBackend, override overrideBackend, password passwordBackend, sshKeys sshKeysBackend, account localAccountBackend, groups groupMembershipBackend, roles administrativeRoleBackend, updates updateBackend, kpatch kpatchBackend, runtime *hostRuntime, serviceBackends ...serviceBackend) { defer conn.Close() if backend == nil { backend = systemHostConfigBackend{} @@ -263,9 +264,6 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe if updates == nil { updates = systemUpdateBackend{} } - if autoUpdates == nil { - autoUpdates = systemAutoUpdatesBackend{} - } if kpatch == nil { kpatch = systemKpatchBackend{} } @@ -329,10 +327,6 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe _ = encoder.Encode(auth.Response{Error: "invalid-request"}) return } - if request.Operation != "updates-auto" && request.AutoUpdates != nil { - _ = encoder.Encode(auth.Response{Error: "invalid-request"}) - return - } if request.Operation != "kpatch" && request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-request"}) return @@ -726,7 +720,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe return } if request.Operation == "local-account" { - if request.Account == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.Account == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-local-account-request"}) return } @@ -761,7 +755,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe return } if request.Operation == "group-membership" { - if request.GroupMembership == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.AdminRole != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.GroupMembership == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.AdminRole != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-group-request"}) return } @@ -796,7 +790,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe return } if request.Operation == "admin-role" { - if request.AdminRole == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.AdminRole == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-admin-role-request"}) return } @@ -831,7 +825,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe return } if request.Operation == "password-change" { - if request.PasswordChange == nil || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.PasswordChange == nil || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-password-operation"}) return } @@ -885,7 +879,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe return } if request.Operation == "ssh-keys" { - if request.SSHKeys == nil || (request.Token == "" && request.AdminToken == "") || (request.Token != "" && request.AdminToken != "") || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.SSHKeys == nil || (request.Token == "" && request.AdminToken == "") || (request.Token != "" && request.AdminToken != "") || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-ssh-key-operation"}) return } @@ -936,7 +930,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe return } if request.Operation == "updates" { - if request.Updates == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.Updates == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-update-operation"}) return } @@ -947,7 +941,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe } operation := *request.Updates request.Updates = nil - if err := platform.ValidateUpdateOperation(operation); err != nil || operation.Preview { + if err := platform.ValidateUpdateOperation(operation, true); err != nil { _ = encoder.Encode(auth.Response{Error: "invalid-update-operation"}) return } @@ -964,37 +958,8 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe _ = encoder.Encode(auth.Response{UpdateResult: &result}) return } - if request.Operation == "updates-auto" { - if request.AutoUpdates == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.Kpatch != nil { - _ = encoder.Encode(auth.Response{Error: "invalid-auto-updates-operation"}) - return - } - identity, ok := grants.adminIdentity(request.AdminToken) - if !ok { - _ = encoder.Encode(auth.Response{Error: "invalid-admin-token"}) - return - } - operation := *request.AutoUpdates - request.AutoUpdates = nil - if err := platform.ValidateAutoUpdatesOperation(operation); err != nil { - _ = encoder.Encode(auth.Response{Error: "invalid-auto-updates-operation"}) - return - } - autoCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - config, applyErr := autoUpdates.Apply(autoCtx, operation, identity) - cancel() - if applyErr != nil { - _ = conn.SetWriteDeadline(time.Now().Add(15 * time.Second)) - _ = encoder.Encode(auth.Response{Error: autoUpdatesErrorCode(applyErr)}) - return - } - logger.Info("automatic updates reconfigured", zap.String("username", identity.Username), zap.Bool("enabled", config.Enabled), zap.String("provider", config.Provider)) - _ = conn.SetWriteDeadline(time.Now().Add(15 * time.Second)) - _ = encoder.Encode(auth.Response{AutoUpdatesConfig: &config}) - return - } if request.Operation == "kpatch" { - if request.Kpatch == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.AutoUpdates != nil { + if request.Kpatch == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil { _ = encoder.Encode(auth.Response{Error: "invalid-kpatch-operation"}) return } @@ -1028,7 +993,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe return } if request.Operation == "file" { - if request.File == nil || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.File == nil || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-file-operation"}) return } @@ -1084,7 +1049,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe return } if request.Operation == "network" { - if request.Network == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.File != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.Network == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.File != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-network-operation"}) return } @@ -1117,7 +1082,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe return } if request.Operation == "firewall" { - if request.Firewall == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.File != nil || request.Network != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.Firewall == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.File != nil || request.Network != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-firewall-operation"}) return } @@ -1148,7 +1113,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe return } if request.Operation == "security" { - if request.Security == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.File != nil || request.Network != nil || request.Firewall != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.Security == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.File != nil || request.Network != nil || request.Firewall != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-security-operation"}) return } @@ -1179,7 +1144,7 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe return } if request.Operation == "support-report" { - if request.SupportReport == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.File != nil || request.Network != nil || request.Firewall != nil || request.Security != nil || request.AutoUpdates != nil || request.Kpatch != nil { + if request.SupportReport == nil || request.AdminToken == "" || request.Token != "" || request.Username != "" || request.Password != "" || request.ConversationID != "" || len(request.Responses) != 0 || request.Columns != 0 || request.Rows != 0 || request.Action != "" || request.Unit != "" || request.Scope != "" || request.Hostname != "" || request.Timezone != "" || request.NTPEnabled || request.ExpectedFingerprint != "" || request.PowerAction != "" || request.PowerConfirmation != "" || request.AdminTTL != 0 || request.Timer != nil || request.Override != nil || request.Signal != nil || request.Account != nil || request.GroupMembership != nil || request.AdminRole != nil || request.PasswordChange != nil || request.SSHKeys != nil || request.Updates != nil || request.File != nil || request.Network != nil || request.Firewall != nil || request.Security != nil || request.Kpatch != nil { _ = encoder.Encode(auth.Response{Error: "invalid-support-report"}) return } diff --git a/apps/backend/internal/sessiond/updates_backend.go b/apps/backend/internal/sessiond/updates_backend.go index 0a9d74d..81441f0 100644 --- a/apps/backend/internal/sessiond/updates_backend.go +++ b/apps/backend/internal/sessiond/updates_backend.go @@ -12,22 +12,13 @@ type updateBackend interface { Apply(context.Context, platform.UpdateOperation, auth.Identity) (platform.UpdateResult, error) } -type systemUpdateBackend struct{} +type systemUpdateBackend struct{ service *platform.UpdateService } -func (systemUpdateBackend) Apply(ctx context.Context, operation platform.UpdateOperation, _ auth.Identity) (platform.UpdateResult, error) { - return platform.ApplyUpdates(ctx, operation) -} - -// autoUpdatesBackend isolates automatic-update configuration so tests can -// record operations instead of touching the host. -type autoUpdatesBackend interface { - Apply(context.Context, platform.AutoUpdatesOperation, auth.Identity) (platform.AutoUpdatesConfig, error) -} - -type systemAutoUpdatesBackend struct{} - -func (systemAutoUpdatesBackend) Apply(ctx context.Context, operation platform.AutoUpdatesOperation, _ auth.Identity) (platform.AutoUpdatesConfig, error) { - return platform.ApplyAutoUpdates(ctx, operation) +func (backend systemUpdateBackend) Apply(ctx context.Context, operation platform.UpdateOperation, _ auth.Identity) (platform.UpdateResult, error) { + if backend.service == nil { + return platform.UpdateResult{}, platform.ErrUpdateUnavailable + } + return backend.service.Apply(ctx, operation) } // kpatchBackend isolates kernel live-patch configuration for testing. @@ -59,14 +50,3 @@ func updateErrorCode(err error) string { return "update-unavailable" } } - -func autoUpdatesErrorCode(err error) string { - switch { - case errors.Is(err, platform.ErrInvalidAutoUpdatesOperation): - return "invalid-auto-updates-operation" - case errors.Is(err, platform.ErrAutoUpdatesUnavailable): - return "auto-updates-unavailable" - default: - return "auto-updates-apply-failed" - } -} diff --git a/apps/backend/internal/sessiond/updates_test.go b/apps/backend/internal/sessiond/updates_test.go deleted file mode 100644 index 67daa7d..0000000 --- a/apps/backend/internal/sessiond/updates_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package sessiond - -import ( - "context" - "encoding/json" - "net" - "strings" - "testing" - - "github.com/velopulent/tako/internal/auth" - "github.com/velopulent/tako/internal/platform" - "go.uber.org/zap" -) - -type recordingUpdateBackend struct { - operation platform.UpdateOperation - identity auth.Identity - err error -} - -func (backend *recordingUpdateBackend) Apply(_ context.Context, operation platform.UpdateOperation, identity auth.Identity) (platform.UpdateResult, error) { - backend.operation = operation - backend.identity = identity - if backend.err != nil { - return platform.UpdateResult{}, backend.err - } - return platform.UpdateResult{Backend: "apt-get", Scope: operation.Scope, Packages: []string{"vim"}, Verified: true, Message: "Updates applied and verified.", Fingerprint: strings.Repeat("b", 64)}, nil -} - -func TestUpdateSocketOperationRequiresAdminAndUsesTypedBackend(t *testing.T) { - store := &grantStore{values: make(map[string]bridgeGrant)} - bridgeToken, err := store.add(auth.Identity{Username: "operator", UID: 1000, GID: 1000}) - if err != nil { - t.Fatal(err) - } - adminToken, _, err := store.authorize(context.Background(), bridgeToken, "secret", 300, func(context.Context, auth.Identity, string) error { return nil }) - if err != nil { - t.Fatal(err) - } - backend := &recordingUpdateBackend{} - serverConn, clientConn := net.Pipe() - defer clientConn.Close() - go handleWithAllBackendsAndUpdates(serverConn, auth.PAMAuthenticator{}, nil, store, nil, zap.NewNop(), systemHostConfigBackend{}, systemPowerBackend{}, systemTimerBackend{}, systemOverrideBackend{}, systemPasswordBackend{}, systemSSHKeysBackend{}, systemLocalAccountBackend{}, systemGroupMembershipBackend{}, systemAdministrativeRoleBackend{}, backend, nil, nil) - operation := platform.UpdateOperation{Scope: "selected", Packages: []string{"vim"}, ExpectedFingerprint: strings.Repeat("a", 64), Confirmation: "APPLY UPDATES"} - if err := json.NewEncoder(clientConn).Encode(auth.Request{Operation: "updates", AdminToken: adminToken, Updates: &operation}); err != nil { - t.Fatal(err) - } - var response auth.Response - if err := json.NewDecoder(clientConn).Decode(&response); err != nil { - t.Fatal(err) - } - if response.Error != "" || response.UpdateResult == nil || backend.operation.Scope != "selected" || backend.identity.Username != "operator" { - t.Fatalf("update operation failed: response=%#v backend=%#v", response, backend) - } - - serverConn, clientConn = net.Pipe() - defer clientConn.Close() - go handleWithAllBackendsAndUpdates(serverConn, auth.PAMAuthenticator{}, nil, store, nil, zap.NewNop(), systemHostConfigBackend{}, systemPowerBackend{}, systemTimerBackend{}, systemOverrideBackend{}, systemPasswordBackend{}, systemSSHKeysBackend{}, systemLocalAccountBackend{}, systemGroupMembershipBackend{}, systemAdministrativeRoleBackend{}, backend, nil, nil) - if err := json.NewEncoder(clientConn).Encode(auth.Request{Operation: "updates", AdminToken: adminToken, Updates: &operation, Token: bridgeToken}); err != nil { - t.Fatal(err) - } - if err := json.NewDecoder(clientConn).Decode(&response); err != nil { - t.Fatal(err) - } - if response.Error != "invalid-update-operation" { - t.Fatalf("mixed update credentials returned %q", response.Error) - } -} - -type recordingAutoUpdatesBackend struct { - operation platform.AutoUpdatesOperation - identity auth.Identity - config platform.AutoUpdatesConfig - err error -} - -func (backend *recordingAutoUpdatesBackend) Apply(_ context.Context, operation platform.AutoUpdatesOperation, identity auth.Identity) (platform.AutoUpdatesConfig, error) { - backend.operation = operation - backend.identity = identity - if backend.err != nil { - return platform.AutoUpdatesConfig{}, backend.err - } - return backend.config, nil -} - -func TestAutoUpdatesSocketOperationRequiresAdminAndUsesTypedBackend(t *testing.T) { - store := &grantStore{values: make(map[string]bridgeGrant)} - bridgeToken, err := store.add(auth.Identity{Username: "operator", UID: 1000, GID: 1000}) - if err != nil { - t.Fatal(err) - } - adminToken, _, err := store.authorize(context.Background(), bridgeToken, "secret", 300, func(context.Context, auth.Identity, string) error { return nil }) - if err != nil { - t.Fatal(err) - } - backend := &recordingAutoUpdatesBackend{config: platform.AutoUpdatesConfig{Available: true, Supported: true, Installed: true, Enabled: true, Type: "security", Provider: "dnf5-automatic"}} - run := func(request auth.Request) auth.Response { - serverConn, clientConn := net.Pipe() - defer clientConn.Close() - go handleWithAllBackendsAndUpdates(serverConn, auth.PAMAuthenticator{}, nil, store, nil, zap.NewNop(), systemHostConfigBackend{}, systemPowerBackend{}, systemTimerBackend{}, systemOverrideBackend{}, systemPasswordBackend{}, systemSSHKeysBackend{}, systemLocalAccountBackend{}, systemGroupMembershipBackend{}, systemAdministrativeRoleBackend{}, nil, backend, nil) - if err := json.NewEncoder(clientConn).Encode(request); err != nil { - t.Fatal(err) - } - var response auth.Response - if err := json.NewDecoder(clientConn).Decode(&response); err != nil { - t.Fatal(err) - } - return response - } - - enabled := true - security := "security" - day := "" - timeOfDay := "06:00" - response := run(auth.Request{ - Operation: "updates-auto", - AdminToken: adminToken, - AutoUpdates: &platform.AutoUpdatesOperation{Enabled: &enabled, Type: &security, Day: &day, Time: &timeOfDay}, - }) - if response.Error != "" || response.AutoUpdatesConfig == nil || !response.AutoUpdatesConfig.Enabled || backend.operation.Enabled == nil || !*backend.operation.Enabled || backend.identity.Username != "operator" { - t.Fatalf("auto-updates operation failed: response=%#v backend=%#v", response, backend) - } - - // A bridge token must not authorize configuration changes. - backend.operation = platform.AutoUpdatesOperation{} - badDay := "funday" - response = run(auth.Request{ - Operation: "updates-auto", - AdminToken: adminToken, - AutoUpdates: &platform.AutoUpdatesOperation{Enabled: &enabled, Day: &badDay}, - }) - if response.Error != "invalid-auto-updates-operation" { - t.Fatalf("invalid schedule accepted: %q", response.Error) - } - if backend.operation.Day != nil || backend.operation.Enabled != nil { - t.Fatalf("rejected operation reached the backend: %#v", backend.operation) - } -} - -type recordingKpatchBackend struct { - operation platform.KpatchOperation - err error -} - -func (backend *recordingKpatchBackend) Apply(_ context.Context, operation platform.KpatchOperation, _ auth.Identity) (platform.KpatchSettingsStatus, error) { - backend.operation = operation - if backend.err != nil { - return platform.KpatchSettingsStatus{}, backend.err - } - return platform.KpatchSettingsStatus{Supported: true, Missing: []string{}, Unavailable: []string{}, Auto: true}, nil -} - -func TestKpatchSocketOperationRejectsInvalidAndForwardsTypedOperation(t *testing.T) { - store := &grantStore{values: make(map[string]bridgeGrant)} - bridgeToken, err := store.add(auth.Identity{Username: "operator", UID: 1000, GID: 1000}) - if err != nil { - t.Fatal(err) - } - adminToken, _, err := store.authorize(context.Background(), bridgeToken, "secret", 300, func(context.Context, auth.Identity, string) error { return nil }) - if err != nil { - t.Fatal(err) - } - backend := &recordingKpatchBackend{} - run := func(request auth.Request) auth.Response { - serverConn, clientConn := net.Pipe() - defer clientConn.Close() - go handleWithAllBackendsAndUpdates(serverConn, auth.PAMAuthenticator{}, nil, store, nil, zap.NewNop(), systemHostConfigBackend{}, systemPowerBackend{}, systemTimerBackend{}, systemOverrideBackend{}, systemPasswordBackend{}, systemSSHKeysBackend{}, systemLocalAccountBackend{}, systemGroupMembershipBackend{}, systemAdministrativeRoleBackend{}, nil, nil, backend) - if err := json.NewEncoder(clientConn).Encode(request); err != nil { - t.Fatal(err) - } - var response auth.Response - if err := json.NewDecoder(clientConn).Decode(&response); err != nil { - t.Fatal(err) - } - return response - } - - response := run(auth.Request{Operation: "kpatch", AdminToken: adminToken, Kpatch: &platform.KpatchOperation{}}) - if response.Error != "invalid-kpatch-operation" { - t.Fatalf("nil apply accepted: %q", response.Error) - } - - apply := true - currentOnly := true - response = run(auth.Request{Operation: "kpatch", AdminToken: adminToken, Kpatch: &platform.KpatchOperation{Apply: &apply, CurrentOnly: ¤tOnly}, Updates: &platform.UpdateOperation{Scope: "all"}}) - if response.Error == "" { - t.Fatal("mixed payload accepted") - } - if backend.operation.Apply != nil { - t.Fatalf("rejected operation reached the backend: %#v", backend.operation) - } - - response = run(auth.Request{Operation: "kpatch", AdminToken: adminToken, Kpatch: &platform.KpatchOperation{Apply: &apply, CurrentOnly: ¤tOnly}}) - if response.Error != "" || response.KpatchSettings == nil || !response.KpatchSettings.Auto { - t.Fatalf("valid kpatch operation failed: %#v", response) - } - if backend.operation.Apply == nil || !*backend.operation.Apply || !*backend.operation.CurrentOnly { - t.Fatalf("operation not forwarded: %#v", backend.operation) - } -} From 696567e2776a6ef4a8606dda548a75ac4b17d5f7 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:55:49 +0530 Subject: [PATCH 10/88] feat(api)!: revise update contracts for full-system preview and SSE live stream UpdateOperation now requires expectedFingerprint + confirmed with optional riskAccepted instead of scope/packages/confirmation/preview. Preview returns structured UpdateChange[] with requiresRiskConfirmation and stale checks. UpdateResult reports backend + changes. Replace UpdateLive/ActionLog with UpdateProgress/Output/StreamEvent and change GET /updates/live to text/event-stream. Remove POST /updates/cancel and /updates/automatic endpoints and their schemas. BREAKING CHANGE: clients must rework update preview/apply and live subscription to the new full-system and SSE contract. --- apps/backend/api/openapi.yaml | 175 ++++++++++++---------------------- 1 file changed, 62 insertions(+), 113 deletions(-) diff --git a/apps/backend/api/openapi.yaml b/apps/backend/api/openapi.yaml index 2432743..5aa8c32 100644 --- a/apps/backend/api/openapi.yaml +++ b/apps/backend/api/openapi.yaml @@ -529,7 +529,7 @@ paths: /updates/preview: post: operationId: previewUpdates - summary: Preview a selected or all-software update operation + summary: Preview a full-system update requestBody: required: true content: @@ -547,7 +547,7 @@ paths: /updates/refresh: post: operationId: refreshUpdates - summary: Refresh package manager cache (PackageKit RefreshCache) + summary: Refresh native package-manager metadata requestBody: required: false content: @@ -568,13 +568,13 @@ paths: /updates/live: get: operationId: getUpdateLiveStatus - summary: Observe the running package-update transaction and its action log + summary: Stream normalized update progress and bounded output responses: '200': - description: Live transaction snapshot (possibly inactive) with recent package actions + description: Authenticated server-sent event stream with progress and output events content: - application/json: - schema: { $ref: '#/components/schemas/UpdateObservation' } + text/event-stream: + schema: { type: string } /updates/history: get: operationId: getUpdateHistory @@ -593,22 +593,6 @@ paths: maxItems: 20 items: { $ref: '#/components/schemas/UpdateHistoryEntry' } available: { type: boolean } - /updates/cancel: - post: - operationId: cancelRunningUpdate - summary: Cancel the currently running package-update transaction - responses: - '200': - description: Whether a running transaction was found and cancelled - content: - application/json: - schema: - type: object - required: [canceled] - properties: - canceled: { type: boolean } - '403': { $ref: '#/components/responses/Problem' } - '503': { $ref: '#/components/responses/Problem' } /updates/kpatch: get: operationId: getKpatchStatus @@ -642,34 +626,6 @@ paths: '403': { $ref: '#/components/responses/Problem' } '502': { $ref: '#/components/responses/Problem' } '503': { $ref: '#/components/responses/Problem' } - /updates/automatic: - get: - operationId: getAutomaticUpdates - summary: Inspect automatic-update configuration for this host - responses: - '200': - description: Provider-specific automatic-update configuration - content: - application/json: - schema: { $ref: '#/components/schemas/AutoUpdatesConfig' } - put: - operationId: applyAutomaticUpdates - summary: Change automatic-update configuration through sessiond - requestBody: - required: true - content: - application/json: - schema: { $ref: '#/components/schemas/AutoUpdatesOperation' } - responses: - '200': - description: Configuration after applying the change - content: - application/json: - schema: { $ref: '#/components/schemas/AutoUpdatesConfig' } - '400': { $ref: '#/components/responses/Problem' } - '403': { $ref: '#/components/responses/Problem' } - '502': { $ref: '#/components/responses/Problem' } - '503': { $ref: '#/components/responses/Problem' } /services: get: operationId: listServices @@ -906,7 +862,7 @@ paths: operationId: getDiagnosticJob responses: '200': - description: In-memory diagnostic job state; software-update jobs also carry a live PackageKit observation + description: In-memory diagnostic job state; software-update jobs also carry normalized live progress content: application/json: schema: @@ -1721,45 +1677,46 @@ components: description: { type: string, maxLength: 4096 } groupKey: { type: string, maxLength: 256 } dependencies: { type: array, maxItems: 64, items: { type: string, maxLength: 256 } } - packageId: { type: string, maxLength: 512 } UpdateOperation: type: object additionalProperties: false - required: [scope] + required: [expectedFingerprint, confirmed] properties: - scope: { type: string, enum: [all, selected] } - packages: - type: array - maxItems: 500 - items: { type: string, maxLength: 256, pattern: '^[A-Za-z0-9+_.:@-]+$' } expectedFingerprint: { type: string, pattern: '^[a-fA-F0-9]{64}$' } - confirmation: { type: string, maxLength: 128, writeOnly: true } - preview: { type: boolean, readOnly: true } + confirmed: { type: boolean } + riskAccepted: { type: boolean } + UpdateChange: + type: object + required: [action, name] + properties: + action: { type: string, enum: [install, upgrade, remove, downgrade, replace] } + name: { type: string, maxLength: 256 } + architecture: { type: string, maxLength: 32 } + currentVersion: { type: string, maxLength: 256 } + candidateVersion: { type: string, maxLength: 256 } + currentRepository: { type: string, maxLength: 256 } + targetRepository: { type: string, maxLength: 256 } + currentVendor: { type: string, maxLength: 256 } + targetVendor: { type: string, maxLength: 256 } UpdatePreview: type: object - required: [operation, current, selected, changes, warnings, fingerprint, stale, allowed, requiresConfirmation] + required: [current, changes, warnings, fingerprint, stale, allowed, requiresConfirmation, requiresRiskConfirmation] properties: - operation: { $ref: '#/components/schemas/UpdateOperation' } current: { $ref: '#/components/schemas/UpdateStatus' } - selected: - type: array - maxItems: 500 - items: { $ref: '#/components/schemas/UpdatePackage' } - changes: { type: array, maxItems: 16, items: { type: string, maxLength: 256 } } + changes: { type: array, maxItems: 500, items: { $ref: '#/components/schemas/UpdateChange' } } warnings: { type: array, maxItems: 16, items: { type: string, maxLength: 512 } } fingerprint: { type: string, pattern: '^[a-fA-F0-9]{64}$' } stale: { type: boolean } allowed: { type: boolean } requiresConfirmation: { type: boolean } + requiresRiskConfirmation: { type: boolean } reason: { type: string, maxLength: 512 } UpdateResult: type: object - required: [backend, scope, packages, updated, verified, message, fingerprint, recovery] + required: [backend, changes, verified, message, fingerprint, recovery] properties: backend: { type: string, maxLength: 64 } - scope: { type: string, enum: [all, selected] } - packages: { type: array, maxItems: 500, items: { type: string, maxLength: 256 } } - updated: { type: array, maxItems: 500, items: { $ref: '#/components/schemas/UpdatePackage' } } + changes: { type: array, maxItems: 500, items: { $ref: '#/components/schemas/UpdateChange' } } verified: { type: boolean } message: { type: string, maxLength: 512 } fingerprint: { type: string, pattern: '^[a-fA-F0-9]{64}$' } @@ -1773,58 +1730,50 @@ components: type: object maxProperties: 500 additionalProperties: { type: string, maxLength: 256 } - UpdateActionLogEntry: + UpdateProgress: type: object - required: [statusLabel, packageId] + required: [sequence, active, phase, current, total, percent, message, cancelable, timestamp] properties: - status: { type: integer } - statusLabel: { type: string, maxLength: 64 } - packageId: { type: string, maxLength: 512 } + sequence: { type: integer, minimum: 0 } + jobId: { type: string, maxLength: 128 } + active: { type: boolean } + phase: { type: string, maxLength: 64 } + package: { type: string, maxLength: 512 } + current: { type: integer, minimum: 0 } + total: { type: integer, minimum: 0 } + percent: { type: integer, minimum: -1, maximum: 100 } + message: { type: string, maxLength: 1024 } + cancelable: { type: boolean } timestamp: { type: string, format: date-time } - UpdateLive: + UpdateOutput: type: object - required: [active, percentage, allowCancel] + required: [sequence, stream, line, timestamp] properties: - active: { type: boolean } - source: { type: string, maxLength: 32 } - percentage: { type: integer, minimum: -1, maximum: 100 } - allowCancel: { type: boolean } - status: { type: string, maxLength: 64 } - currentPackage: { type: string, maxLength: 512 } - remainingSeconds: { type: integer, minimum: 0 } - transactionPath: { type: string, maxLength: 256 } + sequence: { type: integer, minimum: 0 } + jobId: { type: string, maxLength: 128 } + stream: { type: string, enum: [stdout, stderr] } + line: { type: string, maxLength: 8192 } + timestamp: { type: string, format: date-time } + UpdateStreamEvent: + type: object + required: [kind] + properties: + kind: { type: string, enum: [progress, output] } + progress: { $ref: '#/components/schemas/UpdateProgress' } + output: { $ref: '#/components/schemas/UpdateOutput' } UpdateObservation: type: object - required: [live, log] + required: [progress, output] properties: - live: { $ref: '#/components/schemas/UpdateLive' } - log: + progress: { $ref: '#/components/schemas/UpdateProgress' } + output: type: array maxItems: 500 - items: { $ref: '#/components/schemas/UpdateActionLogEntry' } - AutoUpdatesConfig: - type: object - required: [available, supported, installed, enabled, type, day, time] - properties: - available: { type: boolean } - supported: { type: boolean } - installed: { type: boolean } - enabled: { type: boolean } - type: { type: string, enum: [all, security] } - day: { type: string, enum: ["", mon, tue, wed, thu, fri, sat, sun] } - time: { type: string, maxLength: 5, pattern: '^$|^([01]?[0-9]|2[0-3]):[0-5][0-9]$' } - provider: { type: string, maxLength: 64 } - packageName: { type: string, maxLength: 128 } - reason: { type: string, maxLength: 512 } - AutoUpdatesOperation: - type: object - additionalProperties: false - description: Null/omitted fields are left unchanged; a schedule change requires both day and time. - properties: - enabled: { type: boolean } - type: { type: string, enum: [all, security] } - day: { type: string, enum: ["", mon, tue, wed, thu, fri, sat, sun] } - time: { type: string, maxLength: 5, pattern: '^$|^([01]?[0-9]|2[0-3]):[0-5][0-9]$' } + items: { $ref: '#/components/schemas/UpdateOutput' } + events: + type: array + maxItems: 500 + items: { $ref: '#/components/schemas/UpdateStreamEvent' } KpatchStatus: type: object required: [supported, loaded, installed] From 7efb3d9057ea89ca486692c65c37c14d6fd3706d Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:55:54 +0530 Subject: [PATCH 11/88] refactor(dashboard)!: adopt full-system updates and SSE progress stream Remove auto-updates card and operation-controls (selected-scope workflow). Rewrite UpdateInventory to use EventSource on /api/v1/updates/live with UpdateProgress/Output types, single full-system preview with riskAccepted flow, and bounded output replay. Update live panel, package table and progress components to the new UpdateObservation shape. Regenerate embedded dashboard assets. BREAKING CHANGE: dashboard update flow drops scope/packages selection and polling live endpoint in favor of SSE progress/output events. --- .../internal/dashboard/dist/index.html | 4 +- .../src/components/auto-updates-card.test.tsx | 95 -- .../src/components/auto-updates-card.tsx | 251 ----- .../src/components/update-history-card.tsx | 6 +- .../src/components/update-inventory.test.tsx | 196 ++-- .../src/components/update-inventory.tsx | 871 ++++-------------- .../src/components/update-job-progress.tsx | 12 +- .../src/components/update-live-panel.tsx | 95 +- .../components/update-operation-controls.tsx | 116 --- .../src/components/update-package-table.tsx | 66 +- apps/dashboard/src/lib/api.ts | 83 +- 11 files changed, 329 insertions(+), 1466 deletions(-) delete mode 100644 apps/dashboard/src/components/auto-updates-card.test.tsx delete mode 100644 apps/dashboard/src/components/auto-updates-card.tsx delete mode 100644 apps/dashboard/src/components/update-operation-controls.tsx diff --git a/apps/backend/internal/dashboard/dist/index.html b/apps/backend/internal/dashboard/dist/index.html index 03a7bbf..3cb59bf 100644 --- a/apps/backend/internal/dashboard/dist/index.html +++ b/apps/backend/internal/dashboard/dist/index.html @@ -6,7 +6,7 @@ Tako - + @@ -19,7 +19,7 @@ - +
diff --git a/apps/dashboard/src/components/auto-updates-card.test.tsx b/apps/dashboard/src/components/auto-updates-card.test.tsx deleted file mode 100644 index f6fedd1..0000000 --- a/apps/dashboard/src/components/auto-updates-card.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { afterEach, describe, expect, it, vi } from "vitest" - -import { AutoUpdatesCard } from "@/components/auto-updates-card" - -function jsonResponse(value: unknown, status = 200) { - return new Response(JSON.stringify(value), { - status, - headers: { "Content-Type": "application/json" }, - }) -} - -const dnf5Config = { - available: true, - supported: true, - installed: true, - enabled: false, - type: "all", - day: "", - time: "", - provider: "dnf5-automatic", - packageName: "dnf5-plugin-automatic", -} - -function renderCard() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }) - return render( - - - - ) -} - -afterEach(() => vi.unstubAllGlobals()) - -describe("AutoUpdatesCard", () => { - it("shows state and applies a security schedule through PUT", async () => { - const fetchMock = vi.fn((...call: [RequestInfo | URL, RequestInit?]) => { - const url = String(call[0]) - if (url.endsWith("/updates/automatic")) { - return Promise.resolve(jsonResponse(dnf5Config)) - } - return Promise.resolve(jsonResponse({})) - }) - vi.stubGlobal("fetch", fetchMock) - const user = userEvent.setup() - renderCard() - - expect(await screen.findByText("Automatic updates")).toBeTruthy() - expect(screen.getByText("Disabled")).toBeTruthy() - - await user.click(screen.getByRole("button", { name: "Edit" })) - await user.click( - await screen.findByRole("button", { name: "Security only" }) - ) - await user.click(screen.getByRole("button", { name: "Save changes" })) - - await vi.waitFor(() => { - const call = fetchMock.mock.calls.find( - ([input, init]) => - String(input).endsWith("/updates/automatic") && init?.method === "PUT" - ) - expect(call).toBeTruthy() - const body = JSON.parse(String(call?.[1]?.body)) - expect(body.enabled).toBe(true) - expect(body.type).toBe("security") - }) - }) - - it("reports unavailable providers honestly", async () => { - vi.stubGlobal( - "fetch", - vi.fn(() => - Promise.resolve( - jsonResponse({ - available: false, - supported: false, - installed: false, - enabled: false, - type: "all", - day: "", - time: "", - }) - ) - ) - ) - renderCard() - expect(await screen.findByText("Not available")).toBeTruthy() - expect(screen.queryByRole("button", { name: "Edit" })).toBeNull() - }) -}) diff --git a/apps/dashboard/src/components/auto-updates-card.tsx b/apps/dashboard/src/components/auto-updates-card.tsx deleted file mode 100644 index 694b095..0000000 --- a/apps/dashboard/src/components/auto-updates-card.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { CalendarClock } from "lucide-react" -import * as React from "react" - -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" -import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" -import { - type AutoUpdatesConfig, - type AutoUpdatesOperation, - api, -} from "@/lib/api" - -const dayLabels: Record = { - "": "Every day", - mon: "Mondays", - tue: "Tuesdays", - wed: "Wednesdays", - thu: "Thursdays", - fri: "Fridays", - sat: "Saturdays", - sun: "Sundays", -} - -type Mode = "off" | "security" | "all" - -function describe(config: AutoUpdatesConfig) { - if (!config.installed) return "Not set up" - if (!config.enabled) return "Disabled" - const when = `${dayLabels[config.day]?.toLowerCase() ?? "every day"} at ${config.time || "6:00"}` - return config.type === "security" - ? `Security updates will be applied ${when}` - : `Updates will be applied ${when}` -} - -export function AutoUpdatesCard({ - csrfToken, - administrative, - onConfigChanged, -}: { - csrfToken: string - administrative: boolean - onConfigChanged?: (enabled: boolean) => void -}) { - const queryClient = useQueryClient() - const [dialogOpen, setDialogOpen] = React.useState(false) - const [mode, setMode] = React.useState("off") - const [day, setDay] = React.useState("") - const [time, setTime] = React.useState("06:00") - - const query = useQuery({ - queryKey: ["updates-automatic"], - queryFn: () => api("/updates/automatic"), - }) - - const save = useMutation({ - mutationFn: (operation: AutoUpdatesOperation) => - api("/updates/automatic", { - method: "PUT", - headers: { "X-CSRF-Token": csrfToken }, - body: JSON.stringify(operation), - }), - onSuccess: (config) => { - queryClient.setQueryData(["updates-automatic"], config) - onConfigChanged?.(config.enabled) - setDialogOpen(false) - }, - }) - - if (query.isPending) return null - // No provider for this distro family yet (apt/zypper/alpm adapters land - // later): keep the card honest instead of pretending to configure. - if (!query.data?.available) { - return ( -
-
-

Automatic updates

-

Not available

-
-
- ) - } - - const config = query.data - - const openDialog = () => { - setMode(config.enabled ? config.type : "off") - setDay(config.day) - setTime(config.time ? config.time.padStart(5, "0") : "06:00") - setDialogOpen(true) - } - - const submit = () => { - const operation: AutoUpdatesOperation = - mode === "off" - ? { enabled: false } - : { enabled: true, type: mode, day, time } - save.mutate(operation) - } - - return ( - <> -
-
-

- - Automatic updates -

-

- {describe(config)} -

-
- {!administrative || save.isPending ? ( - - ) : ( - - )} -
- - - - - Automatic updates - - Applies through the system package manager ({config.provider}). - Changes take effect on the host immediately. - - - - - Update policy - - setMode((values[values.length - 1] as Mode) ?? "off") - } - > - No updates - - Security only - - All updates - - - {mode !== "off" && ( - <> - - When -
- - setTime(event.target.value)} - aria-label="Update time of day" - /> -
-
- - - This host will reboot after updates are installed. - - - The package manager is configured to reboot when required. - - - - )} -
- - - - - {save.error && ( -

- {save.error.message || "The configuration could not be applied."} -

- )} - {!administrative && ( -

- Gain Administrative access to change automatic updates. -

- )} -
-
- - ) -} diff --git a/apps/dashboard/src/components/update-history-card.tsx b/apps/dashboard/src/components/update-history-card.tsx index 5c377bb..6917edb 100644 --- a/apps/dashboard/src/components/update-history-card.tsx +++ b/apps/dashboard/src/components/update-history-card.tsx @@ -21,8 +21,7 @@ import { api, type UpdateHistoryEntry } from "@/lib/api" const maxShownEntries = 3 const mergeWindowMs = 10 * 60 * 1000 -// Some PackageKit transactions come in pairs with identical package lists; -// merge them for presentation. +// Native manager histories can contain adjacent duplicate transactions. function mergeHistory(entries: UpdateHistoryEntry[]): UpdateHistoryEntry[] { const merged: UpdateHistoryEntry[] = [] for (const entry of entries.slice(0, 20)) { @@ -71,8 +70,7 @@ export function UpdateHistoryCard({ enabled }: { enabled: boolean }) { staleTime: 5 * 60 * 1000, }) - // Hidden entirely while automatic updates are enabled or unavailable: - // auto-updates keep their own records. + // Parent hides history when its provider is unavailable. if (!enabled || !query.data?.available) return null const history = mergeHistory(query.data.items ?? []) diff --git a/apps/dashboard/src/components/update-inventory.test.tsx b/apps/dashboard/src/components/update-inventory.test.tsx index 26eed5e..e280bf7 100644 --- a/apps/dashboard/src/components/update-inventory.test.tsx +++ b/apps/dashboard/src/components/update-inventory.test.tsx @@ -5,6 +5,11 @@ import { afterEach, describe, expect, it, vi } from "vitest" import { UpdateInventory } from "@/components/update-inventory" +class EventSourceStub { + addEventListener() {} + close() {} +} + function jsonResponse(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status, @@ -23,59 +28,39 @@ function renderInventory() { ) } +function status(fingerprint: string, externalLock = false) { + return { + available: true, + backend: "apt", + contract: "native-distro-provider", + packages: [ + { + name: "openssl", + architecture: "amd64", + currentVersion: "3.0.11", + candidateVersion: "3.0.14", + }, + ], + fingerprint, + externalLock, + lockReason: externalLock ? "APT lock is held" : "", + message: "1 installed-software update available.", + recovery: { restartServices: [], hints: [], source: "advisory" }, + } +} + afterEach(() => vi.unstubAllGlobals()) describe("UpdateInventory", () => { - it("shows bounded package versions and external lock state", async () => { - const updateStatus = { - available: true, - backend: "apt-get", - version: "apt 3.0", - contract: "bounded-command-read-only", - packages: [ - { - name: "openssl", - architecture: "amd64", - currentVersion: "3.0.11", - candidateVersion: "3.0.14", - severity: "security", - size: 1024, - summary: "TLS update", - }, - ], - fingerprint: "b".repeat(64), - externalLock: true, - lockReason: "A package-manager lock is held", - message: "1 installed-software update available.", - } + it("shows a non-cancelable external package-manager lock", async () => { + vi.stubGlobal("EventSource", EventSourceStub) vi.stubGlobal( "fetch", vi.fn((input: RequestInfo | URL) => { const url = String(input) - if (url.endsWith("/updates/live")) { - return Promise.resolve( - jsonResponse({ - live: { active: false, percentage: -1, allowCancel: false }, - log: [], - }) - ) - } if (url.endsWith("/updates/history")) { return Promise.resolve(jsonResponse({ items: [], available: false })) } - if (url.endsWith("/updates/automatic")) { - return Promise.resolve( - jsonResponse({ - available: false, - supported: false, - installed: false, - enabled: false, - type: "all", - day: "", - time: "", - }) - ) - } if (url.endsWith("/updates/kpatch")) { return Promise.resolve( jsonResponse({ @@ -92,72 +77,66 @@ describe("UpdateInventory", () => { }) ) } - return Promise.resolve(jsonResponse(updateStatus)) + return Promise.resolve(jsonResponse(status("b".repeat(64), true))) }) ) - renderInventory() - expect(await screen.findByText("openssl (amd64)")).toBeTruthy() - expect(screen.getByText("3.0.14")).toBeTruthy() - expect(screen.getByText("security")).toBeTruthy() - expect(screen.getByText("Another package tool holds a lock")).toBeTruthy() - }) - it("renders an explicit empty state and backend failure", async () => { - let failed = true - vi.stubGlobal( - "fetch", - vi.fn(() => - Promise.resolve( - failed - ? new Response("", { status: 503 }) - : jsonResponse({ - available: true, - backend: "PackageKit", - contract: "dbus-read-only", - packages: [], - externalLock: false, - message: - "No installed-software updates are currently available.", - }) - ) - ) - ) - renderInventory() - expect(await screen.findByText("Update inventory unavailable")).toBeTruthy() - failed = false renderInventory() + expect(await screen.findByText("Package manager busy")).toBeTruthy() + expect(screen.getByText("APT lock is held")).toBeTruthy() expect( - await screen.findByText("No installed-software updates") - ).toBeTruthy() + screen + .getByRole("button", { name: "Preview full update" }) + .hasAttribute("disabled") + ).toBe(true) }) - it("previews selected packages and starts a reconnectable job", async () => { - const fingerprint = "a".repeat(64) + it("submits only the confirmed full-system plan fingerprint", async () => { + const inventoryFingerprint = "a".repeat(64) + const planFingerprint = "c".repeat(64) + vi.stubGlobal("EventSource", EventSourceStub) const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) if (url.endsWith("/auth/session")) { return Promise.resolve(jsonResponse({ csrfToken: "csrf-token" })) } - if (url.endsWith("/updates/preview")) { + if (url.endsWith("/updates/history")) { + return Promise.resolve(jsonResponse({ items: [], available: false })) + } + if (url.endsWith("/updates/kpatch")) { return Promise.resolve( jsonResponse({ - operation: { scope: "selected", packages: ["openssl"] }, - current: { - available: true, - backend: "apt-get", - contract: "bounded-command-read-only", - packages: [], - fingerprint, - externalLock: false, - message: "", + status: { supported: false, loaded: [], installed: [] }, + settings: { + supported: false, + missing: [], + unavailable: [], + auto: false, + serviceEnabled: false, + patchInstalled: false, + patchUnavailable: false, }, - selected: [{ name: "openssl", candidateVersion: "3.0.14" }], - changes: ["update 1 package"], + }) + ) + } + if (url.endsWith("/updates/preview")) { + return Promise.resolve( + jsonResponse({ + current: status(inventoryFingerprint), + changes: [ + { + action: "upgrade", + name: "openssl", + currentVersion: "3.0.11", + candidateVersion: "3.0.14", + }, + ], warnings: [], - fingerprint, + fingerprint: planFingerprint, stale: false, allowed: true, requiresConfirmation: true, + requiresRiskConfirmation: false, }) ) } @@ -176,32 +155,18 @@ describe("UpdateInventory", () => { ) ) } - return Promise.resolve( - jsonResponse({ - available: true, - backend: "apt-get", - version: "apt 3.0", - contract: "bounded-command-read-only", - fingerprint, - packages: [{ name: "openssl", candidateVersion: "3.0.14" }], - externalLock: false, - message: "1 installed-software update available.", - }) - ) + return Promise.resolve(jsonResponse(status(inventoryFingerprint))) }) vi.stubGlobal("fetch", fetchMock) + const user = userEvent.setup() renderInventory() await user.click( - await screen.findByRole("button", { name: "Selected packages" }) - ) - await user.click(screen.getByRole("checkbox", { name: "Select openssl" })) - await user.click(screen.getByRole("button", { name: /Install selected/ })) - expect(await screen.findByText("Confirm updates")).toBeTruthy() - expect(await screen.findByText("update 1 package")).toBeTruthy() - await user.click( - screen.getByRole("button", { name: "Confirm and install" }) + await screen.findByRole("button", { name: "Preview full update" }) ) + expect(await screen.findByText("Confirm full-system update")).toBeTruthy() + await user.click(screen.getByRole("button", { name: "Apply full update" })) + await vi.waitFor(() => { const call = fetchMock.mock.calls.find( ([input, init]) => @@ -209,10 +174,13 @@ describe("UpdateInventory", () => { ) expect(call).toBeTruthy() const body = JSON.parse(String(call?.[1]?.body)) - expect(body.scope).toBe("selected") - expect(body.packages).toEqual(["openssl"]) - expect(["APPLY UPDATES", "CONFIRM"]).toContain(body.confirmation) - expect(body.expectedFingerprint).toBe(fingerprint) + expect(body).toEqual({ + expectedFingerprint: planFingerprint, + confirmed: true, + riskAccepted: false, + }) + expect(body).not.toHaveProperty("scope") + expect(body).not.toHaveProperty("packages") }) }) }) diff --git a/apps/dashboard/src/components/update-inventory.tsx b/apps/dashboard/src/components/update-inventory.tsx index 6174cec..2862604 100644 --- a/apps/dashboard/src/components/update-inventory.tsx +++ b/apps/dashboard/src/components/update-inventory.tsx @@ -1,16 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { - Bug, - Check, - RefreshCw, - RotateCcw, - Settings, - Shield, - Sparkles, - TriangleAlert, -} from "lucide-react" +import { Check, RefreshCw, TriangleAlert } from "lucide-react" import * as React from "react" -import { AutoUpdatesCard } from "@/components/auto-updates-card" + import { KpatchSettingsCard } from "@/components/kpatch-settings-card" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" @@ -30,39 +21,36 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyTitle, -} from "@/components/ui/empty" import { Skeleton } from "@/components/ui/skeleton" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" import { UpdateHistoryCard } from "@/components/update-history-card" import { UpdateJobProgress } from "@/components/update-job-progress" import { activeUpdateJobStates } from "@/components/update-job-state" -import { - ForeignUpdateAlert, - UpdateLivePanel, -} from "@/components/update-live-panel" +import { UpdateLivePanel } from "@/components/update-live-panel" import { UpdatePackageTable } from "@/components/update-package-table" import { - type AutoUpdatesConfig, api, type DiagnosticJob, type SessionResponse, - type UpdateLive, type UpdateObservation, type UpdateOperation, + type UpdateOutput, type UpdatePreview, + type UpdateProgress, type UpdateStatus, } from "@/lib/api" -const updateJobStorageKey = "tako-update-job:v1" +const updateJobStorageKey = "tako-update-job:v2" +const idleProgress: UpdateProgress = { + sequence: 0, + active: false, + phase: "idle", + current: 0, + total: 0, + percent: -1, + message: "No update is running.", + cancelable: false, + timestamp: "", +} function readSavedJob() { try { @@ -72,44 +60,21 @@ function readSavedJob() { } } -function formatLastChecked(iso?: string) { - if (!iso) return "" - const diff = Date.now() - new Date(iso).getTime() - const seconds = Math.round(diff / 1000) - if (seconds < 45) return "Last checked: just now" - const minutes = Math.round(seconds / 60) - if (minutes < 60) - return `Last checked: ${minutes} minute${minutes === 1 ? "" : "s"} ago` - const hours = Math.round(minutes / 60) - if (hours < 24) - return `Last checked: ${hours} hour${hours === 1 ? "" : "s"} ago` - const days = Math.round(hours / 24) - return `Last checked: ${days} day${days === 1 ? "" : "s"} ago` -} - -function getHighestSeverity(packages: UpdateStatus["packages"]) { - if (packages.some((p) => p.severity === "security")) return "security" - if (packages.some((p) => p.severity === "bugfix")) return "bugfix" - return "enhancement" -} - -function CountBadge({ severity }: { severity: string }) { - if (severity === "security") - return - if (severity === "bugfix") return - return +function formatLastChecked(value?: string) { + if (!value) return "" + return new Date(value).toLocaleString() } export function UpdateInventory() { const queryClient = useQueryClient() - const [scope, setScope] = React.useState("all") - const [selected, setSelected] = React.useState([]) const [jobID, setJobID] = React.useState(readSavedJob) + const [preview, setPreview] = React.useState(null) const [dialogOpen, setDialogOpen] = React.useState(false) - const [pendingOperation, setPendingOperation] = - React.useState(null) - const [pendingPreview, setPendingPreview] = - React.useState(null) + const [riskAccepted, setRiskAccepted] = React.useState(false) + const [observation, setObservation] = React.useState({ + progress: idleProgress, + output: [], + }) const query = useQuery({ queryKey: ["updates"], @@ -121,109 +86,76 @@ export function UpdateInventory() { }) const job = useQuery({ queryKey: ["update-job", jobID], - queryFn: () => - api<{ job: DiagnosticJob; update?: UpdateObservation }>(`/jobs/${jobID}`), + queryFn: () => api<{ job: DiagnosticJob }>(`/jobs/${jobID}`), enabled: jobID !== "", refetchInterval: (current) => { - const state = current?.state?.data?.job?.state + const state = current.state.data?.job?.state return state && activeUpdateJobStates.has(state) ? 2000 : false }, }) - const administrative = session.data?.administrative ?? false - const ownJobRunning = (() => { - const state = job.data?.job?.state - return state !== undefined && activeUpdateJobStates.has(state) - })() - const cancelForeignRequest = useMutation({ - mutationFn: () => - api<{ canceled: boolean }>("/updates/cancel", { - method: "POST", - headers: { "X-CSRF-Token": session.data?.csrfToken ?? "" }, - }), - onSuccess: () => foreignLive.refetch(), - }) + React.useEffect(() => { + const source = new EventSource("/api/v1/updates/live") + const onProgress = (event: MessageEvent) => { + const progress = JSON.parse(event.data) as UpdateProgress + setObservation((current) => ({ + progress, + output: + progress.jobId && progress.jobId !== current.progress.jobId + ? [] + : current.output, + })) + } + const onOutput = (event: MessageEvent) => { + const output = JSON.parse(event.data) as UpdateOutput + setObservation((current) => ({ + ...current, + output: [...current.output, output].slice(-500), + })) + } + source.addEventListener("progress", onProgress as EventListener) + source.addEventListener("output", onOutput as EventListener) + return () => source.close() + }, []) - const refreshRequest = useMutation({ + const refresh = useMutation({ mutationFn: () => api("/updates/refresh", { method: "POST", body: JSON.stringify({ force: true }), }), - onSuccess: (data) => { - queryClient.setQueryData(["updates"], data) - queryClient.invalidateQueries({ queryKey: ["updates"] }) - }, - }) - - // Watch package-manager transactions while they run: foreign updates when a - // lock is held, and our own metadata refresh for a progress bar. - const foreignLive = useQuery({ - queryKey: ["updates-live"], - queryFn: () => - api<{ live: UpdateLive; log: UpdateObservation["log"] }>("/updates/live"), - enabled: - ((query.data?.externalLock ?? false) && !ownJobRunning) || - refreshRequest.isPending, - refetchInterval: 1500, - }) - const automatic = useQuery({ - queryKey: ["updates-automatic"], - queryFn: () => api("/updates/automatic"), - staleTime: 30 * 1000, + onSuccess: (status) => queryClient.setQueryData(["updates"], status), }) - - // Auto-refresh: if cache never refreshed or >=1 day old, trigger refresh - // biome-ignore lint/correctness/useExhaustiveDependencies: effect intentionally triggers only off the refresh age; depending on the mutation object would re-fire on every mutation state change - React.useEffect(() => { - const secs = query.data?.timeSinceRefresh - if (secs !== undefined && (secs < 0 || secs >= 24 * 3600)) { - if (!refreshRequest.isPending) refreshRequest.mutate() - } - }, [query.data?.timeSinceRefresh]) - - // Also refresh on visibility change - React.useEffect(() => { - const handler = () => { - if (!document.hidden) { - queryClient.invalidateQueries({ queryKey: ["updates"] }) - query.refetch() - } - } - document.addEventListener("visibilitychange", handler) - return () => document.removeEventListener("visibilitychange", handler) - }, [queryClient, query]) - const previewRequest = useMutation({ mutationFn: (operation: UpdateOperation) => api("/updates/preview", { method: "POST", body: JSON.stringify(operation), }), + onSuccess: (value) => { + setPreview(value) + setRiskAccepted(false) + setDialogOpen(true) + }, }) - - const applyRequest = useMutation({ + const apply = useMutation({ mutationFn: (operation: UpdateOperation) => api<{ job: DiagnosticJob }>("/updates", { method: "POST", headers: { "X-CSRF-Token": session.data?.csrfToken ?? "" }, body: JSON.stringify(operation), }), - onSuccess: (value) => { - setJobID(value.job.id) + onSuccess: ({ job: value }) => { + setJobID(value.id) setDialogOpen(false) - setPendingPreview(null) - setPendingOperation(null) - queryClient.invalidateQueries({ queryKey: ["updates"] }) try { - sessionStorage.setItem(updateJobStorageKey, value.job.id) + sessionStorage.setItem(updateJobStorageKey, value.id) } catch { - // ignore + // Storage may be disabled. } }, }) - - const cancelRequest = useMutation({ + const cancel = useMutation({ mutationFn: () => api<{ job: DiagnosticJob }>(`/jobs/${jobID}/cancel`, { method: "POST", @@ -231,631 +163,186 @@ export function UpdateInventory() { }), }) - const currentJobState = job.data?.job?.state - React.useEffect(() => { - if (!currentJobState || activeUpdateJobStates.has(currentJobState)) return - try { - sessionStorage.removeItem(updateJobStorageKey) - } catch { - // ignore - } - }, [currentJobState]) - - const handleRefresh = () => { - // Prefer D-Bus RefreshCache, fallback to invalidate - if (refreshRequest.isPending) return - refreshRequest.mutate(undefined, { - onError: () => { - queryClient.invalidateQueries({ queryKey: ["updates"] }) - query.refetch() - }, - }) - } - - const handleToggle = React.useCallback( - (name: string) => { - if (!query.data) return - const pkg = query.data.packages.find((p) => p.name === name) - const groupKey = pkg?.groupKey - let groupNames: string[] = [] - if (groupKey) { - groupNames = query.data.packages - .filter((p) => p.groupKey === groupKey) - .map((p) => p.name) - } else if (pkg?.dependencies?.length) { - groupNames = [name, ...pkg.dependencies] - } else { - groupNames = [name] - } - setPendingPreview(null) - setSelected((current) => { - const hasAll = groupNames.every((n) => current.includes(n)) - if (hasAll) { - return current.filter((n) => !groupNames.includes(n)) - } - const next = new Set(current) - groupNames.forEach((n) => { - next.add(n) - }) - return Array.from(next) - }) - }, - [query.data] - ) - - const initiateInstall = (mode: "all" | "security" | "selected") => { - if (!query.data) return - let operation: UpdateOperation - if (mode === "all") { - operation = { scope: "all", expectedFingerprint: query.data.fingerprint } - } else if (mode === "security") { - const securityNames = query.data.packages - .filter((p) => p.severity === "security") - .map((p) => p.name) - operation = { - scope: "selected", - packages: securityNames, - expectedFingerprint: query.data.fingerprint, - } - } else { - // expand selected with dependencies already handled via toggle, but ensure deps included - const expanded = new Set(selected) - for (const name of selected) { - const pkg = query.data.packages.find((p) => p.name === name) - pkg?.dependencies?.forEach((dep) => { - expanded.add(dep) - }) - if (pkg?.groupKey) { - query.data.packages - .filter((p) => p.groupKey === pkg.groupKey) - .forEach((p) => { - expanded.add(p.name) - }) - } - } - const packages = Array.from(expanded) - operation = { - scope: "selected", - packages, - expectedFingerprint: query.data.fingerprint, - } - } - setPendingOperation(operation) - previewRequest.mutate(operation, { - onSuccess: (preview) => { - setPendingPreview(preview) - setDialogOpen(true) - }, - }) - } - - const handleConfirm = () => { - if (!pendingOperation) return - const operation: UpdateOperation = { - ...pendingOperation, - confirmation: "CONFIRM", - } - applyRequest.mutate(operation) - } - if (query.isPending) return - if (query.isError) { + if (query.isError || !query.data) { return ( Update inventory unavailable - {query.error.message} + {query.error?.message} ) } - if (!query.data) return null + const status = query.data const activeJob = job.data?.job - const actionError = - previewRequest.error || applyRequest.error || cancelRequest.error - - const total = status.packages.length - const securityCount = status.packages.filter( - (p) => p.severity === "security" - ).length - const bugfixCount = status.packages.filter( - (p) => p.severity === "bugfix" - ).length - const highestSeverity = getHighestSeverity(status.packages) - const hasMixed = securityCount > 0 && securityCount < total - const isLocked = status.externalLock - const canInstall = status.available && !isLocked - - const selectedWithDeps = (() => { - if (!status.packages.length) return [] - const expanded = new Set(selected) - for (const name of selected) { - const pkg = status.packages.find((p) => p.name === name) - pkg?.dependencies?.forEach((dep) => { - expanded.add(dep) - }) - if (pkg?.groupKey) { - status.packages - .filter((p) => p.groupKey === pkg.groupKey) - .forEach((p) => { - expanded.add(p.name) - }) - } - } - return Array.from(expanded) - })() + const error = + refresh.error || previewRequest.error || apply.error || cancel.error + const canUpdate = + status.available && !status.externalLock && status.packages.length > 0 return (
- {/* Status + Settings grid */} + {status.externalLock && ( + + + Package manager busy + + {status.lockReason || + "Another package-manager operation is active."} + + + )} + {error && ( + + Update action failed + {error.message} + + )}
- Status + Status - - - - - } + - - {total === 0 && status.available ? ( -
- -
-

System is up to date

- {status.lastChecked && ( -

- {formatLastChecked(status.lastChecked)} -

- )} -
-
- ) : total > 0 ? ( -
- - - -
-

- {securityCount === total && total > 0 - ? `${securityCount} security fix${securityCount === 1 ? "" : "es"} available` - : hasMixed - ? `${total} updates available, including ${securityCount} security fix${securityCount === 1 ? "" : "es"}` - : `${total} update${total === 1 ? "" : "s"} available`} -

- {status.lastChecked && ( -

- {formatLastChecked(status.lastChecked)} -

- )} - {bugfixCount > 0 && ( -

- {bugfixCount} bug fix{bugfixCount === 1 ? "" : "es"} ·{" "} - {total - securityCount - bugfixCount} enhancement - {total - securityCount - bugfixCount === 1 ? "" : "s"} -

- )} -
-
- ) : null} - - {status.recovery?.rebootRequired && ( - - - Reboot required - - {status.recovery?.hints?.join(" ") || - status.recovery?.reason || - "Reboot the host after the update job completes."} - {status.recovery?.rebootPackages?.length - ? ` Packages: ${status.recovery?.rebootPackages?.join(", ")}` - : ""} - - - )} - {!status.recovery?.rebootRequired && - (status.recovery?.restartServices?.length ?? 0) > 0 && ( - - - Restart services - -

{status.recovery?.restartServices.join(", ")}

-
- {status.recovery?.restartServices - .slice(0, 6) - .map((unit) => ( - - ))} -
-
-
- )} - {(status.recovery?.manualPackages?.length ?? 0) > 0 && ( - - - - Manual restart required - - - {status.recovery?.manualPackages?.join(", ")} - - - )} - {status.recovery && !status.recovery?.authoritative && ( -

- Advisory only; verify service state after applying updates. -

- )} - {isLocked && ( - - - - Another package tool holds a lock - - - {status.lockReason || - "Updates are paused until the other operation finishes."} - - - )} - {!status.available && status.reason && ( - - - Updates are unavailable - - - {status.reason} - - - )} - {status.version && ( -

- Backend {status.version} · contract {status.contract} + + {status.packages.length === 0 && status.available ? ( +

+ + System is up to date

+ ) : ( +

{status.message}

)} +

+ {status.backend} · {status.contract} + {status.lastChecked + ? ` · ${formatLastChecked(status.lastChecked)}` + : ""} +

- - Settings + Settings - - +
- {actionError && ( - - Update action failed - {actionError.message} - - )} - - {refreshRequest.isPending && foreignLive.data?.live?.active && ( - - )} - - {isLocked && foreignLive.data?.live?.active && ( - - administrative ? cancelForeignRequest.mutate() : undefined - } - /> - )} - + cancel.mutate() : undefined} + /> {activeJob && ( cancelRequest.mutate()} + observation={observation} + canceling={cancel.isPending} + onCancel={() => cancel.mutate()} /> )} - + Available updates - {total > 0 && ( - - {total} package{total === 1 ? "" : "s"} with advisories grouped - - )} - {total > 0 && ( - - {scope === "all" && hasMixed && ( - - )} - {scope === "all" ? ( - - ) : ( - - )} - - )} + + Full-system update using manager-default dependency resolution. + + + + - - {total === 0 ? ( - - - No installed-software updates - - The selected backend reported no available updates. - - - - ) : ( - <> -
- Update scope - {/* biome-ignore lint/a11y/useSemanticElements: labeled scope-switcher button pair; a fieldset restyle is out of scope */} -
- - -
- - Preview the current inventory before starting a serialized - update job. - -
- - - - {scope === "selected" && selectedWithDeps.length === 0 && ( -

- Select at least one advisory to install selected updates. - Dependent packages in the same advisory are auto-selected and - cannot be toggled individually. -

- )} -

- {status.backend} · {status.contract}{" "} - {status.lastChecked - ? `· ${formatLastChecked(status.lastChecked)}` - : ""} -

- - )} + +
- + - {pendingPreview?.allowed - ? "Confirm updates" + {preview?.allowed + ? "Confirm full-system update" : "Update cannot start"} - {pendingPreview?.allowed - ? "Review the preview and confirm to start the update job." - : "The preview indicates the operation cannot proceed."} + {preview?.reason || + `${preview?.changes.length ?? 0} planned changes`} -
- {previewRequest.isPending ? ( -

Loading preview…

- ) : pendingPreview ? ( - <> -

- {pendingPreview.reason || - pendingPreview.changes.join(", ") || - `Update ${pendingPreview.selected.length} package${pendingPreview.selected.length === 1 ? "" : "s"}`} -

- {pendingPreview.warnings.length > 0 && ( -

- {pendingPreview.warnings.join(" ")} -

- )} - {pendingPreview.stale && ( -

- Inventory changed; refresh before applying. -

- )} - {pendingPreview.selected.length > 0 && ( -
- {pendingPreview.selected.map((p) => ( -
- {p.name} - - {p.candidateVersion} - -
- ))} -
- )} - - ) : null} -
+
+ {preview?.changes.map((change) => ( +

+ {change.action}: {change.name} {change.currentVersion || ""}{" "} + {change.candidateVersion ? `→ ${change.candidateVersion}` : ""} +

+ ))} +
+ {preview?.requiresRiskConfirmation && ( + + )} - - {!session.data?.csrfToken && ( -

- Sign-in required to apply updates. -

- )}
- +
) } - -function RestartServiceButton({ - unit, - csrfToken, - disabled, -}: { - unit: string - csrfToken: string - disabled?: boolean -}) { - const [confirmOpen, setConfirmOpen] = React.useState(false) - const restart = useMutation({ - mutationFn: () => - api(`/services/system/${encodeURIComponent(unit)}/actions`, { - method: "POST", - headers: { "X-CSRF-Token": csrfToken }, - body: JSON.stringify({ action: "restart" }), - }), - onSettled: () => setConfirmOpen(false), - }) - - return ( - - - - - Restart {unit}? - - The service will briefly stop accepting work while it loads the - updated libraries. - - - - - - - {restart.error && ( -

- {restart.error.message || "The service action failed."} -

- )} -
-
- ) -} diff --git a/apps/dashboard/src/components/update-job-progress.tsx b/apps/dashboard/src/components/update-job-progress.tsx index 545a14d..09e5fb8 100644 --- a/apps/dashboard/src/components/update-job-progress.tsx +++ b/apps/dashboard/src/components/update-job-progress.tsx @@ -16,10 +16,10 @@ export function UpdateJobProgress({ canceling: boolean onCancel: () => void }) { - const live = observation?.live + const progress = observation?.progress const percent = - live?.active && live.percentage >= 0 && live.percentage <= 100 - ? live.percentage + progress?.active && progress.percent >= 0 && progress.percent <= 100 + ? progress.percent : job.progress return ( @@ -34,16 +34,16 @@ export function UpdateJobProgress({ /> {activeUpdateJobStates.has(job.state) ? ( <> - {live?.active ? ( + {progress?.active ? ( ) : (

{job.message}

)} - {(!live?.active || !live.allowCancel) && ( + {!progress?.active && ( - - - - Preview the current inventory before starting a serialized update - job. - - -
- - {preview?.allowed && preview.requiresConfirmation && ( -
- onConfirmationChange(event.target.value)} - /> - -
- )} -
- - {preview && ( - - - {preview.allowed ? "Update preview" : "Update cannot start"} - - - {preview.reason || preview.changes.join(", ")} - {preview.warnings.length > 0 && ( - {preview.warnings.join(" ")} - )} - - - )} - - ) -} diff --git a/apps/dashboard/src/components/update-package-table.tsx b/apps/dashboard/src/components/update-package-table.tsx index d3a7c4a..327cbf9 100644 --- a/apps/dashboard/src/components/update-package-table.tsx +++ b/apps/dashboard/src/components/update-package-table.tsx @@ -3,7 +3,6 @@ import * as React from "react" import { AdvisoryMarkdown } from "@/components/advisory-markdown" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" import { Tooltip, TooltipContent, @@ -83,17 +82,10 @@ function buildGroups(packages: UpdatePackage[]): AdvisoryGroup[] { export function UpdatePackageTable({ packages, - selected, - onToggle, - selectable = true, }: { packages: UpdatePackage[] - selected: string[] - onToggle: (name: string) => void - selectable?: boolean }) { const groups = React.useMemo(() => buildGroups(packages), [packages]) - const selectedSet = React.useMemo(() => new Set(selected), [selected]) const [expanded, setExpanded] = React.useState>(() => new Set()) const toggleExpanded = (key: string) => @@ -104,41 +96,6 @@ export function UpdatePackageTable({ return next }) - const allSelected = - groups.length > 0 && - groups.every((g) => g.packages.every((p) => selectedSet.has(p.name))) - const headerChecked = allSelected - - const handleGroupToggle = (group: AdvisoryGroup) => { - const every = group.packages.every((p) => selectedSet.has(p.name)) - // toggle whole group atomically: if every selected -> deselect all, else select all (including dependencies) - if (every) { - for (const pkg of group.packages) { - if (selectedSet.has(pkg.name)) onToggle(pkg.name) - } - } else { - for (const pkg of group.packages) { - if (!selectedSet.has(pkg.name)) onToggle(pkg.name) - } - } - } - - const handleHeaderToggle = () => { - if (allSelected) { - for (const g of groups) { - for (const p of g.packages) { - if (selectedSet.has(p.name)) onToggle(p.name) - } - } - } else { - for (const g of groups) { - for (const p of g.packages) { - if (!selectedSet.has(p.name)) onToggle(p.name) - } - } - } - } - return (
@@ -148,15 +105,6 @@ export function UpdatePackageTable({ Expand - {selectable && ( - - - - )} Name Version Severity @@ -183,9 +131,6 @@ export function UpdatePackageTable({ : group.severity === "bugfix" ? bugCount || group.packages.length : 0 - const isGroupSelected = group.packages.every((p) => - selectedSet.has(p.name) - ) const isSecurity = group.severity === "security" // special package detection like kpatch handled via badge maybe not needed @@ -213,15 +158,6 @@ export function UpdatePackageTable({ )} - {selectable && ( - - handleGroupToggle(group)} - /> - - )}
{displayNames.map((name, index) => { @@ -302,7 +238,7 @@ export function UpdatePackageTable({ {isExpanded && ( - +
diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index b6a0bfd..03b7264 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -734,29 +734,35 @@ export type UpdateRecovery = { reason?: string } export type UpdateOperation = { - scope: "all" | "selected" - packages?: string[] - expectedFingerprint?: string - confirmation?: string - preview?: boolean + expectedFingerprint: string + confirmed: boolean + riskAccepted?: boolean +} +export type UpdateChange = { + action: "install" | "upgrade" | "remove" | "downgrade" | "replace" | string + name: string + architecture?: string + currentVersion?: string + candidateVersion?: string + currentRepository?: string + targetRepository?: string + currentVendor?: string + targetVendor?: string } export type UpdatePreview = { - operation: UpdateOperation current: UpdateStatus - selected: UpdatePackage[] - changes: string[] + changes: UpdateChange[] warnings: string[] fingerprint: string stale: boolean allowed: boolean requiresConfirmation: boolean + requiresRiskConfirmation: boolean reason?: string } export type UpdateResult = { backend: string - scope: "all" | "selected" - packages: string[] - updated: UpdatePackage[] + changes: UpdateChange[] verified: boolean message: string fingerprint: string @@ -780,49 +786,34 @@ export type UpdatePackage = { markdown?: boolean groupKey?: string dependencies?: string[] - packageId?: string } export type UpdateHistoryEntry = { time: number packages: Record } -export type UpdateActionLogEntry = { - status: number - statusLabel: string - packageId: string - timestamp?: string -} -export type UpdateLive = { +export type UpdateProgress = { + sequence: number + jobId?: string active: boolean - source?: string - percentage: number - allowCancel: boolean - status?: string - currentPackage?: string - remainingSeconds?: number - transactionPath?: string -} -export type UpdateObservation = { - live: UpdateLive - log: UpdateActionLogEntry[] + phase: string + package?: string + current: number + total: number + percent: number + message: string + cancelable: boolean + timestamp: string } -export type AutoUpdatesConfig = { - available: boolean - supported: boolean - installed: boolean - enabled: boolean - type: "all" | "security" - day: "" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun" - time: string - provider?: string - packageName?: string - reason?: string +export type UpdateOutput = { + sequence: number + jobId?: string + stream: "stdout" | "stderr" | string + line: string + timestamp: string } -export type AutoUpdatesOperation = { - enabled?: boolean - type?: "all" | "security" - day?: "" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun" - time?: string +export type UpdateObservation = { + progress: UpdateProgress + output: UpdateOutput[] } export type KpatchStatus = { supported: boolean From 9d1108d262cfd059c50b690e1cc342748eff6b03 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:55:59 +0530 Subject: [PATCH 12/88] chore(packaging): emit eight distro-specific packages with per-distro branding and PAM Expand goreleaser from a single CGO build to eight tagged builds (debian, ubuntu, fedora, rhel, rocky, almalinux, archlinux, opensuse) and matching nFPM packages (2 deb, 5 rpm, 1 archlinux) each bundling exactly one /usr/share/tako/branding/.png and the correct PAM stack (tako.debian vs tako.redhat vs tako). Harden tools/package to validate all eight artifact IDs, formats, branding and PAM sources. Rename rockylinux.png to rocky.png and update branding asset mapping. --- .goreleaser.yaml | 313 +++++++++++------- apps/backend/internal/branding/branding.go | 18 +- .../internal/branding/branding_test.go | 4 +- .../branding/{rockylinux.png => rocky.png} | Bin tools/package | 56 +++- 5 files changed, 250 insertions(+), 141 deletions(-) rename apps/backend/packaging/branding/{rockylinux.png => rocky.png} (100%) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d8504d6..eb3e877 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -7,27 +7,42 @@ before: - bun nx build dashboard builds: - - id: tako + - &distro_build + id: debian dir: apps/backend main: ./cmd/tako binary: tako - targets: - - linux_amd64_v1 - env: - - CGO_ENABLED=1 - flags: - - -trimpath - - -buildvcs=false - ldflags: - - -s -w -X github.com/velopulent/tako/internal/version.Value={{ .Version }} + goos: [linux] + goarch: [amd64] + env: [CGO_ENABLED=1] + flags: [-trimpath, -buildvcs=false] + tags: [debian] + ldflags: ["-s -w -X github.com/velopulent/tako/internal/version.Value={{ .Version }}"] + - <<: *distro_build + id: ubuntu + tags: [ubuntu] + - <<: *distro_build + id: fedora + tags: [fedora] + - <<: *distro_build + id: rhel + tags: [rhel] + - <<: *distro_build + id: rocky + tags: [rocky] + - <<: *distro_build + id: almalinux + tags: [almalinux] + - <<: *distro_build + id: archlinux + tags: [archlinux] + - <<: *distro_build + id: opensuse + tags: [opensuse] archives: - - formats: - - tar.gz - format_overrides: - - goos: linux - formats: - - none + - formats: [none] + signs: [] sboms: [] @@ -36,14 +51,154 @@ checksum: algorithm: sha256 nfpms: - - id: tako - ids: - - tako + - &debian_package + id: debian-package + ids: [debian] + package_name: tako + formats: [deb] + file_name_template: "tako_{{ .Version }}_debian_{{ .Arch }}" + bindir: /usr/bin + vendor: Velopulent + homepage: https://tako.velopulent.com + maintainer: Tako maintainers + description: Linux administration dashboard + license: AGPL-3.0-only + section: admin + priority: optional + dependencies: [systemd, libnss-systemd, libpam0g, dbus, polkitd, init-system-helpers, apt] + scripts: + postinstall: apps/backend/packaging/scripts/deb/postinst.sh + preremove: apps/backend/packaging/scripts/deb/prerm.sh + postremove: apps/backend/packaging/scripts/deb/postrm.sh + contents: + - { src: apps/backend/packaging/systemd/tako.service, dst: /usr/lib/systemd/system/tako.service } + - { src: apps/backend/packaging/systemd/tako.socket, dst: /usr/lib/systemd/system/tako.socket } + - { src: apps/backend/packaging/systemd/tako-sessiond.service, dst: /usr/lib/systemd/system/tako-sessiond.service } + - { src: apps/backend/packaging/systemd/tako-sessiond.socket, dst: /usr/lib/systemd/system/tako-sessiond.socket } + - { src: apps/backend/packaging/sysusers.d/tako.conf, dst: /usr/lib/sysusers.d/tako.conf } + - { src: apps/backend/packaging/tmpfiles.d/tako.conf, dst: /usr/lib/tmpfiles.d/tako.conf } + - { src: apps/backend/packaging/polkit/org.velopulent.tako.policy, dst: /usr/share/polkit-1/actions/org.velopulent.tako.policy } + - { src: apps/backend/config.example.toml, dst: /usr/share/doc/tako/config.example.toml } + - { src: apps/backend/packaging/pam/tako.debian, dst: /etc/pam.d/tako, type: "config|noreplace" } + - { src: apps/backend/packaging/branding/debian.png, dst: /usr/share/tako/branding/debian.png } + + - <<: *debian_package + id: ubuntu-package + ids: [ubuntu] + file_name_template: "tako_{{ .Version }}_ubuntu_{{ .Arch }}" + contents: + - { src: apps/backend/packaging/systemd/tako.service, dst: /usr/lib/systemd/system/tako.service } + - { src: apps/backend/packaging/systemd/tako.socket, dst: /usr/lib/systemd/system/tako.socket } + - { src: apps/backend/packaging/systemd/tako-sessiond.service, dst: /usr/lib/systemd/system/tako-sessiond.service } + - { src: apps/backend/packaging/systemd/tako-sessiond.socket, dst: /usr/lib/systemd/system/tako-sessiond.socket } + - { src: apps/backend/packaging/sysusers.d/tako.conf, dst: /usr/lib/sysusers.d/tako.conf } + - { src: apps/backend/packaging/tmpfiles.d/tako.conf, dst: /usr/lib/tmpfiles.d/tako.conf } + - { src: apps/backend/packaging/polkit/org.velopulent.tako.policy, dst: /usr/share/polkit-1/actions/org.velopulent.tako.policy } + - { src: apps/backend/config.example.toml, dst: /usr/share/doc/tako/config.example.toml } + - { src: apps/backend/packaging/pam/tako.debian, dst: /etc/pam.d/tako, type: "config|noreplace" } + - { src: apps/backend/packaging/branding/ubuntu.png, dst: /usr/share/tako/branding/ubuntu.png } + + - &fedora_package + id: fedora-package + ids: [fedora] + package_name: tako + formats: [rpm] + file_name_template: "tako_{{ .Version }}_fedora_{{ .Arch }}" + bindir: /usr/bin + vendor: Velopulent + homepage: https://tako.velopulent.com + maintainer: Tako maintainers + description: Linux administration dashboard + license: AGPL-3.0-only + section: admin + priority: optional + dependencies: [systemd, pam, dbus, polkit, dnf] + scripts: &rpm_scripts + postinstall: apps/backend/packaging/scripts/rpm/post.sh + preremove: apps/backend/packaging/scripts/rpm/preun.sh + postremove: apps/backend/packaging/scripts/rpm/postun.sh + contents: + - { src: apps/backend/packaging/systemd/tako.service, dst: /usr/lib/systemd/system/tako.service } + - { src: apps/backend/packaging/systemd/tako.socket, dst: /usr/lib/systemd/system/tako.socket } + - { src: apps/backend/packaging/systemd/tako-sessiond.service, dst: /usr/lib/systemd/system/tako-sessiond.service } + - { src: apps/backend/packaging/systemd/tako-sessiond.socket, dst: /usr/lib/systemd/system/tako-sessiond.socket } + - { src: apps/backend/packaging/sysusers.d/tako.conf, dst: /usr/lib/sysusers.d/tako.conf } + - { src: apps/backend/packaging/tmpfiles.d/tako.conf, dst: /usr/lib/tmpfiles.d/tako.conf } + - { src: apps/backend/packaging/polkit/org.velopulent.tako.policy, dst: /usr/share/polkit-1/actions/org.velopulent.tako.policy } + - { src: apps/backend/config.example.toml, dst: /usr/share/doc/tako/config.example.toml } + - { src: apps/backend/packaging/pam/tako.redhat, dst: /etc/pam.d/tako, type: "config|noreplace" } + - { src: apps/backend/packaging/branding/fedora.png, dst: /usr/share/tako/branding/fedora.png } + + - <<: *fedora_package + id: rhel-package + ids: [rhel] + file_name_template: "tako_{{ .Version }}_rhel_{{ .Arch }}" + contents: + - { src: apps/backend/packaging/systemd/tako.service, dst: /usr/lib/systemd/system/tako.service } + - { src: apps/backend/packaging/systemd/tako.socket, dst: /usr/lib/systemd/system/tako.socket } + - { src: apps/backend/packaging/systemd/tako-sessiond.service, dst: /usr/lib/systemd/system/tako-sessiond.service } + - { src: apps/backend/packaging/systemd/tako-sessiond.socket, dst: /usr/lib/systemd/system/tako-sessiond.socket } + - { src: apps/backend/packaging/sysusers.d/tako.conf, dst: /usr/lib/sysusers.d/tako.conf } + - { src: apps/backend/packaging/tmpfiles.d/tako.conf, dst: /usr/lib/tmpfiles.d/tako.conf } + - { src: apps/backend/packaging/polkit/org.velopulent.tako.policy, dst: /usr/share/polkit-1/actions/org.velopulent.tako.policy } + - { src: apps/backend/config.example.toml, dst: /usr/share/doc/tako/config.example.toml } + - { src: apps/backend/packaging/pam/tako.redhat, dst: /etc/pam.d/tako, type: "config|noreplace" } + - { src: apps/backend/packaging/branding/rhel.png, dst: /usr/share/tako/branding/rhel.png } + + - <<: *fedora_package + id: rocky-package + ids: [rocky] + file_name_template: "tako_{{ .Version }}_rocky_{{ .Arch }}" + contents: + - { src: apps/backend/packaging/systemd/tako.service, dst: /usr/lib/systemd/system/tako.service } + - { src: apps/backend/packaging/systemd/tako.socket, dst: /usr/lib/systemd/system/tako.socket } + - { src: apps/backend/packaging/systemd/tako-sessiond.service, dst: /usr/lib/systemd/system/tako-sessiond.service } + - { src: apps/backend/packaging/systemd/tako-sessiond.socket, dst: /usr/lib/systemd/system/tako-sessiond.socket } + - { src: apps/backend/packaging/sysusers.d/tako.conf, dst: /usr/lib/sysusers.d/tako.conf } + - { src: apps/backend/packaging/tmpfiles.d/tako.conf, dst: /usr/lib/tmpfiles.d/tako.conf } + - { src: apps/backend/packaging/polkit/org.velopulent.tako.policy, dst: /usr/share/polkit-1/actions/org.velopulent.tako.policy } + - { src: apps/backend/config.example.toml, dst: /usr/share/doc/tako/config.example.toml } + - { src: apps/backend/packaging/pam/tako.redhat, dst: /etc/pam.d/tako, type: "config|noreplace" } + - { src: apps/backend/packaging/branding/rocky.png, dst: /usr/share/tako/branding/rocky.png } + + - <<: *fedora_package + id: almalinux-package + ids: [almalinux] + file_name_template: "tako_{{ .Version }}_almalinux_{{ .Arch }}" + contents: + - { src: apps/backend/packaging/systemd/tako.service, dst: /usr/lib/systemd/system/tako.service } + - { src: apps/backend/packaging/systemd/tako.socket, dst: /usr/lib/systemd/system/tako.socket } + - { src: apps/backend/packaging/systemd/tako-sessiond.service, dst: /usr/lib/systemd/system/tako-sessiond.service } + - { src: apps/backend/packaging/systemd/tako-sessiond.socket, dst: /usr/lib/systemd/system/tako-sessiond.socket } + - { src: apps/backend/packaging/sysusers.d/tako.conf, dst: /usr/lib/sysusers.d/tako.conf } + - { src: apps/backend/packaging/tmpfiles.d/tako.conf, dst: /usr/lib/tmpfiles.d/tako.conf } + - { src: apps/backend/packaging/polkit/org.velopulent.tako.policy, dst: /usr/share/polkit-1/actions/org.velopulent.tako.policy } + - { src: apps/backend/config.example.toml, dst: /usr/share/doc/tako/config.example.toml } + - { src: apps/backend/packaging/pam/tako.redhat, dst: /etc/pam.d/tako, type: "config|noreplace" } + - { src: apps/backend/packaging/branding/almalinux.png, dst: /usr/share/tako/branding/almalinux.png } + + - <<: *fedora_package + id: opensuse-package + ids: [opensuse] + file_name_template: "tako_{{ .Version }}_opensuse_{{ .Arch }}" + dependencies: [systemd, pam, dbus-1, polkit, zypper] + contents: + - { src: apps/backend/packaging/systemd/tako.service, dst: /usr/lib/systemd/system/tako.service } + - { src: apps/backend/packaging/systemd/tako.socket, dst: /usr/lib/systemd/system/tako.socket } + - { src: apps/backend/packaging/systemd/tako-sessiond.service, dst: /usr/lib/systemd/system/tako-sessiond.service } + - { src: apps/backend/packaging/systemd/tako-sessiond.socket, dst: /usr/lib/systemd/system/tako-sessiond.socket } + - { src: apps/backend/packaging/sysusers.d/tako.conf, dst: /usr/lib/sysusers.d/tako.conf } + - { src: apps/backend/packaging/tmpfiles.d/tako.conf, dst: /usr/lib/tmpfiles.d/tako.conf } + - { src: apps/backend/packaging/polkit/org.velopulent.tako.policy, dst: /usr/share/polkit-1/actions/org.velopulent.tako.policy } + - { src: apps/backend/config.example.toml, dst: /usr/share/doc/tako/config.example.toml } + - { src: apps/backend/packaging/pam/tako.redhat, dst: /etc/pam.d/tako, type: "config|noreplace" } + - { src: apps/backend/packaging/branding/opensuse.png, dst: /usr/share/tako/branding/opensuse.png } + + - id: arch-package + ids: [archlinux] package_name: tako - formats: - - deb - - rpm - file_name_template: "{{ .ConventionalFileName }}" + formats: [archlinux] + file_name_template: "tako_{{ .Version }}_archlinux_{{ .Arch }}" bindir: /usr/bin vendor: Velopulent homepage: https://tako.velopulent.com @@ -52,105 +207,15 @@ nfpms: license: AGPL-3.0-only section: admin priority: optional + dependencies: [systemd, pam, dbus, polkit, pacman, pacman-contrib] contents: - - src: apps/backend/packaging/systemd/tako.service - dst: /usr/lib/systemd/system/tako.service - - src: apps/backend/packaging/systemd/tako.socket - dst: /usr/lib/systemd/system/tako.socket - - src: apps/backend/packaging/systemd/tako-sessiond.service - dst: /usr/lib/systemd/system/tako-sessiond.service - - src: apps/backend/packaging/systemd/tako-sessiond.socket - dst: /usr/lib/systemd/system/tako-sessiond.socket - - src: apps/backend/packaging/sysusers.d/tako.conf - dst: /usr/lib/sysusers.d/tako.conf - - src: apps/backend/packaging/tmpfiles.d/tako.conf - dst: /usr/lib/tmpfiles.d/tako.conf - - src: apps/backend/packaging/polkit/org.velopulent.tako.policy - dst: /usr/share/polkit-1/actions/org.velopulent.tako.policy - - src: apps/backend/config.example.toml - dst: /usr/share/doc/tako/config.example.toml - - src: apps/backend/packaging/pam/tako.debian - dst: /etc/pam.d/tako - type: config|noreplace - packager: deb - - src: apps/backend/packaging/pam/tako.redhat - dst: /etc/pam.d/tako - type: config|noreplace - packager: rpm - - src: apps/backend/packaging/branding/debian.png - dst: /usr/share/tako/branding/debian.png - packager: deb - file_info: - mode: 0644 - owner: root - group: root - - src: apps/backend/packaging/branding/ubuntu.png - dst: /usr/share/tako/branding/ubuntu.png - packager: deb - file_info: - mode: 0644 - owner: root - group: root - - src: apps/backend/packaging/branding/almalinux.png - dst: /usr/share/tako/branding/almalinux.png - packager: rpm - file_info: - mode: 0644 - owner: root - group: root - - src: apps/backend/packaging/branding/fedora.png - dst: /usr/share/tako/branding/fedora.png - packager: rpm - file_info: - mode: 0644 - owner: root - group: root - - src: apps/backend/packaging/branding/opensuse.png - dst: /usr/share/tako/branding/opensuse.png - packager: rpm - file_info: - mode: 0644 - owner: root - group: root - - src: apps/backend/packaging/branding/rockylinux.png - dst: /usr/share/tako/branding/rockylinux.png - packager: rpm - file_info: - mode: 0644 - owner: root - group: root - - src: apps/backend/packaging/branding/rhel.png - dst: /usr/share/tako/branding/rhel.png - packager: rpm - file_info: - mode: 0644 - owner: root - group: root - overrides: - deb: - dependencies: - - systemd - - libnss-systemd - - libpam0g - - dbus - - polkitd - - init-system-helpers - recommends: - - packagekit - - packagekit-tools - scripts: - postinstall: apps/backend/packaging/scripts/deb/postinst.sh - preremove: apps/backend/packaging/scripts/deb/prerm.sh - postremove: apps/backend/packaging/scripts/deb/postrm.sh - rpm: - dependencies: - - systemd - - pam - - dbus - - polkit - recommends: - - PackageKit - scripts: - postinstall: apps/backend/packaging/scripts/rpm/post.sh - preremove: apps/backend/packaging/scripts/rpm/preun.sh - postremove: apps/backend/packaging/scripts/rpm/postun.sh + - { src: apps/backend/packaging/systemd/tako.service, dst: /usr/lib/systemd/system/tako.service } + - { src: apps/backend/packaging/systemd/tako.socket, dst: /usr/lib/systemd/system/tako.socket } + - { src: apps/backend/packaging/systemd/tako-sessiond.service, dst: /usr/lib/systemd/system/tako-sessiond.service } + - { src: apps/backend/packaging/systemd/tako-sessiond.socket, dst: /usr/lib/systemd/system/tako-sessiond.socket } + - { src: apps/backend/packaging/sysusers.d/tako.conf, dst: /usr/lib/sysusers.d/tako.conf } + - { src: apps/backend/packaging/tmpfiles.d/tako.conf, dst: /usr/lib/tmpfiles.d/tako.conf } + - { src: apps/backend/packaging/polkit/org.velopulent.tako.policy, dst: /usr/share/polkit-1/actions/org.velopulent.tako.policy } + - { src: apps/backend/config.example.toml, dst: /usr/share/doc/tako/config.example.toml } + - { src: apps/backend/packaging/pam/tako, dst: /etc/pam.d/tako, type: "config|noreplace" } + - { src: apps/backend/packaging/branding/archlinux.png, dst: /usr/share/tako/branding/archlinux.png } diff --git a/apps/backend/internal/branding/branding.go b/apps/backend/internal/branding/branding.go index c00c9be..2577b32 100644 --- a/apps/backend/internal/branding/branding.go +++ b/apps/backend/internal/branding/branding.go @@ -121,7 +121,7 @@ func AssetForID(id string) (distribution, asset string) { case "ubuntu": return "ubuntu", "ubuntu.png" case "rocky", "rockylinux": - return "rockylinux", "rockylinux.png" + return "rockylinux", "rocky.png" } if strings.HasPrefix(normalizeValue(id), "opensuse") { return "opensuse", "opensuse.png" @@ -141,12 +141,12 @@ func normalizeValue(value string) string { } var allowedAssets = map[string]struct{}{ - "almalinux.png": {}, - "archlinux.png": {}, - "debian.png": {}, - "fedora.png": {}, - "rhel.png": {}, - "opensuse.png": {}, - "rockylinux.png": {}, - "ubuntu.png": {}, + "almalinux.png": {}, + "archlinux.png": {}, + "debian.png": {}, + "fedora.png": {}, + "rhel.png": {}, + "opensuse.png": {}, + "rocky.png": {}, + "ubuntu.png": {}, } diff --git a/apps/backend/internal/branding/branding_test.go b/apps/backend/internal/branding/branding_test.go index c37c817..7f9bbf2 100644 --- a/apps/backend/internal/branding/branding_test.go +++ b/apps/backend/internal/branding/branding_test.go @@ -38,8 +38,8 @@ func TestAssetForIDUsesExplicitMappings(t *testing.T) { {id: "fedora", distribution: "fedora", asset: "fedora.png"}, {id: "rhel", distribution: "rhel", asset: "rhel.png"}, {id: "ubuntu", distribution: "ubuntu", asset: "ubuntu.png"}, - {id: "rocky", distribution: "rockylinux", asset: "rockylinux.png"}, - {id: "rockylinux", distribution: "rockylinux", asset: "rockylinux.png"}, + {id: "rocky", distribution: "rockylinux", asset: "rocky.png"}, + {id: "rockylinux", distribution: "rockylinux", asset: "rocky.png"}, {id: "opensuse-leap", distribution: "opensuse", asset: "opensuse.png"}, {id: "opensuse-tumbleweed", distribution: "opensuse", asset: "opensuse.png"}, {id: "redhat", distribution: "", asset: ""}, diff --git a/apps/backend/packaging/branding/rockylinux.png b/apps/backend/packaging/branding/rocky.png similarity index 100% rename from apps/backend/packaging/branding/rockylinux.png rename to apps/backend/packaging/branding/rocky.png diff --git a/tools/package b/tools/package index 58d4532..d5a73d0 100755 --- a/tools/package +++ b/tools/package @@ -9,15 +9,42 @@ goreleaser release --snapshot --clean deb_count=$(find dist -maxdepth 1 -type f -name '*.deb' | wc -l) rpm_count=$(find dist -maxdepth 1 -type f -name '*.rpm' | wc -l) -[ "$deb_count" -eq 1 ] || { echo "package: expected one .deb, found $deb_count" >&2; exit 1; } -[ "$rpm_count" -eq 1 ] || { echo "package: expected one .rpm, found $rpm_count" >&2; exit 1; } +arch_count=$(find dist -maxdepth 1 -type f -name '*.pkg.tar.zst' | wc -l) +[ "$deb_count" -eq 2 ] || { echo "package: expected two .deb files, found $deb_count" >&2; exit 1; } +[ "$rpm_count" -eq 5 ] || { echo "package: expected five .rpm files, found $rpm_count" >&2; exit 1; } +[ "$arch_count" -eq 1 ] || { echo "package: expected one .pkg.tar.zst file, found $arch_count" >&2; exit 1; } + +expect_one() { + pattern=$1 + count=$(find dist -maxdepth 1 -type f -name "$pattern" | wc -l) + [ "$count" -eq 1 ] || { echo "package: expected one artifact matching $pattern, found $count" >&2; exit 1; } +} + +expect_one 'tako_*_debian_amd64.deb' +expect_one 'tako_*_ubuntu_amd64.deb' +expect_one 'tako_*_fedora_amd64.rpm' +expect_one 'tako_*_rhel_amd64.rpm' +expect_one 'tako_*_rocky_amd64.rpm' +expect_one 'tako_*_almalinux_amd64.rpm' +expect_one 'tako_*_opensuse_amd64.rpm' +expect_one 'tako_*_archlinux_amd64.pkg.tar.zst' [ -s dist/checksums.txt ] || { echo "package: missing checksums.txt" >&2; exit 1; } (cd dist && sha256sum --check checksums.txt) bun -e ' const artifacts = await Bun.file("dist/artifacts.json").json(); const packages = artifacts.filter((artifact) => artifact.type === "Linux Package"); -const expectedFormats = new Set(["deb", "rpm"]); +const binaries = artifacts.filter((artifact) => artifact.type === "Binary"); +const expected = new Map(Object.entries({ + debian: { format: "deb", packageID: "debian-package", pam: "tako.debian" }, + ubuntu: { format: "deb", packageID: "ubuntu-package", pam: "tako.debian" }, + fedora: { format: "rpm", packageID: "fedora-package", pam: "tako.redhat" }, + rhel: { format: "rpm", packageID: "rhel-package", pam: "tako.redhat" }, + rocky: { format: "rpm", packageID: "rocky-package", pam: "tako.redhat" }, + almalinux: { format: "rpm", packageID: "almalinux-package", pam: "tako.redhat" }, + opensuse: { format: "rpm", packageID: "opensuse-package", pam: "tako.redhat" }, + archlinux: { format: "archlinux", packageID: "arch-package", pam: "tako" }, +})); const requiredPaths = [ "/usr/bin/tako", "/usr/lib/systemd/system/tako-sessiond.service", @@ -29,14 +56,31 @@ const requiredPaths = [ "/usr/share/polkit-1/actions/org.velopulent.tako.policy", "/etc/pam.d/tako", ]; -if (packages.length !== 2) throw new Error(`expected 2 package artifacts, found ${packages.length}`); +if (packages.length !== expected.size) throw new Error(`expected ${expected.size} package artifacts, found ${packages.length}`); +if (binaries.length !== expected.size) throw new Error(`expected ${expected.size} binary artifacts, found ${binaries.length}`); +for (const binary of binaries) { + const distro = binary.extra.ID; + if (!expected.has(distro)) throw new Error(`unexpected binary build ID: ${distro}`); + if (binary.goos !== "linux" || binary.goarch !== "amd64") throw new Error(`unexpected binary target: ${binary.target}`); +} for (const artifact of packages) { - if (!expectedFormats.delete(artifact.extra.Format)) throw new Error(`unexpected duplicate format: ${artifact.extra.Format}`); + const distro = [...expected.keys()].find((name) => artifact.name.includes(`_${name}_amd64.`)); + if (!distro) throw new Error(`cannot identify distro for ${artifact.name}`); + const wanted = expected.get(distro); + if (artifact.extra.Format !== wanted.format) throw new Error(`${distro} has format ${artifact.extra.Format}, want ${wanted.format}`); + if (artifact.extra.ID !== wanted.packageID) throw new Error(`${distro} has package ID ${artifact.extra.ID}, want ${wanted.packageID}`); if (artifact.goos !== "linux" || artifact.goarch !== "amd64") throw new Error(`unexpected target: ${artifact.target}`); const paths = new Set(artifact.extra.Files.map((file) => file.dst)); for (const path of requiredPaths) if (!paths.has(path)) throw new Error(`${artifact.extra.Format} missing ${path}`); + const branding = artifact.extra.Files.filter((file) => file.dst.startsWith("/usr/share/tako/branding/")); + if (branding.length !== 1 || branding[0].dst !== `/usr/share/tako/branding/${distro}.png`) { + throw new Error(`${distro} package must contain only its matching branding asset`); + } + const pam = artifact.extra.Files.find((file) => file.dst === "/etc/pam.d/tako"); + if (!pam || !pam.src.endsWith(`/pam/${wanted.pam}`)) throw new Error(`${distro} package has wrong PAM policy`); + expected.delete(distro); } -if (expectedFormats.size) throw new Error(`missing formats: ${[...expectedFormats].join(", ")}`); +if (expected.size) throw new Error(`missing distros: ${[...expected.keys()].join(", ")}`); ' echo "Packages written to dist/" From eed525cc3fbba433deacf98daa5832bc50d72aae Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:56:04 +0530 Subject: [PATCH 13/88] docs: describe distro-native update and multi-distro packaging Document distro-native update readiness in README and expand packaging/README to cover eight distro-tagged CGO builds, per-distro native package-manager dependencies, single-asset branding, PAM mapping and TAKO_DISTRO override. Clarify that GoReleaser emits two debs, five rpms and one pkg.tar.zst without requiring native builder tools. --- README.md | 2 +- apps/backend/packaging/README.md | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7f9e2af..06fedad 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Logs use human-readable console output in the service journal (`journalctl -u ta The gateway includes HTTPS certificate bootstrapping, systemd socket activation, PAM authentication, in-memory sessions, CSRF/origin protections, capability discovery, an embedded responsive SPA, and bounded live metrics over SSE. In production it is a pure HTTP/session broker: host authority is typed through sessiond and never falls back to the DynamicUser's own runtime bus or filesystem. -Dashboard, metrics, journal logs, systemd services, processes, users, mounted storage, network interfaces, PackageKit readiness, hardware, boot history, and restart status expose real host snapshots. Metrics retain a configurable 24-hour in-memory window and adapt collection to active browser intervals in one sessiond-owned collector. Production PAM sessions receive a short-lived opaque bridge grant and can open a binary WebSocket PTY running under the authenticated UNIX UID/GID. The UI-only `serve --dev` lane uses a fake broker and deliberately disables terminals. +Dashboard, metrics, journal logs, systemd services, processes, users, mounted storage, network interfaces, distro-native update readiness, hardware, boot history, and restart status expose real host snapshots. Metrics retain a configurable 24-hour in-memory window and adapt collection to active browser intervals in one sessiond-owned collector. Production PAM sessions receive a short-lived opaque bridge grant and can open a binary WebSocket PTY running under the authenticated UNIX UID/GID. The UI-only `serve --dev` lane uses a fake broker and deliberately disables terminals. The Services page inventories service, target, socket, timer, and path units, exposes unit relationships and journal entries, and supports allowlisted lifecycle actions. Ordinary system metadata and all user-scope operations run in the authenticated user bridge; administrative system changes run in root sessiond. If `pam_systemd`, `XDG_RUNTIME_DIR`, the user bus, or the user manager is unavailable, the affected user capability degrades with a stable error. Current elevation uses password-backed PAM. diff --git a/apps/backend/packaging/README.md b/apps/backend/packaging/README.md index c9c5ca8..8d08c46 100644 --- a/apps/backend/packaging/README.md +++ b/apps/backend/packaging/README.md @@ -31,9 +31,9 @@ Password changes and administrative resets use the host's standard `passwd` PAM Local packaging requires installed Bun dependencies, Go 1.26+, GoReleaser 2.x, a C compiler, and Linux amd64 PAM development headers and linker files. `bun run package:check` validates those host-neutral capabilities without assuming a distribution package manager. CGO is required for PAM, so arm64 cross-builds remain deferred until an AArch64 PAM toolchain is available. -Run `bun run package` from repository root to validate packaging, build dashboard assets, build `tako` with CGO for Linux amd64, and emit exactly one `.deb`, one `.rpm`, and `checksums.txt` in `dist/`. GoReleaser's embedded nFPM packager creates both formats on Debian, Fedora, or Arch; building does not require `dpkg-deb`, `rpmbuild`, or `rpm`. Snapshot packages do not publish releases. Tagged release builds take version from the Git tag. No signing, archive, container, or GitHub workflow is configured. Because the executable is a native CGO build, its glibc and PAM compatibility follows the build host; cross-format output alone does not guarantee compatibility with older target libraries. +Run `bun run package` from repository root to validate packaging, build dashboard assets, build all eight distro-tagged `tako` binaries with CGO for Linux amd64, and emit two `.deb`, five `.rpm`, one `.pkg.tar.zst`, and `checksums.txt` in `dist/`. GoReleaser's embedded nFPM packager creates every format without requiring native package-builder tools. Snapshot packages do not publish releases. Tagged release builds take version from the Git tag. No signing, archive, container, or GitHub workflow is configured. Because each executable is a native CGO build, its glibc and PAM compatibility follows the build host; cross-format output alone does not guarantee compatibility with older target libraries. -Generated packages install full production runtime files: executable, four systemd units, sysusers and tmpfiles definitions, Polkit policy, the example TOML configuration under `/usr/share/doc/tako/`, read-only distro branding under `/usr/share/tako/branding/`, and a distribution-specific PAM stack at `/etc/pam.d/tako`. Debian includes Debian and Ubuntu art; RPM includes AlmaLinux, Fedora, RHEL, openSUSE, and Rocky Linux art. Arch Linux art is source-ready for host development or a future Arch package. Debian uses `pam/tako.debian`; RPM uses `pam/tako.redhat`. PAM files are package-managed as `config|noreplace`. Packages do not install a live `/etc/tako/config.toml`, sudoers example, smoke test, or operational README. +Generated packages install full production runtime files: executable, four systemd units, sysusers and tmpfiles definitions, Polkit policy, the example TOML configuration under `/usr/share/doc/tako/`, exactly one matching distro branding asset under `/usr/share/tako/branding/`, and a distribution-specific PAM stack at `/etc/pam.d/tako`. Debian and Ubuntu use `pam/tako.debian`; RPM distributions use `pam/tako.redhat`; Arch Linux uses the generic `pam/tako` policy. PAM files are package-managed as `config|noreplace`. Packages do not install a live `/etc/tako/config.toml`, sudoers example, smoke test, or operational README. ## Login branding @@ -46,7 +46,23 @@ from `/usr/share/tako/branding/` through the gateway's fixed `/branding/*.png` route. Production metadata revalidates with an ETag, while the selected release-versioned image URL is immutable-cacheable. -Package installation requires systemd, PAM, D-Bus, and Polkit. Debian additionally requires `libnss-systemd` for the dynamic gateway identity and `init-system-helpers` for package-managed unit state. PackageKit and `pkcon` are recommendations rather than hard dependencies because Tako can fall back to the host's native package manager with reduced metadata. NetworkManager, UDisks2, Netplan, UFW/firewalld, SELinux, AppArmor, and other host-specific integrations remain optional; capability degradation is reported explicitly. +Package installation requires systemd, PAM, D-Bus, Polkit, and the native package manager selected when building. Debian and Ubuntu use APT; Fedora, RHEL, Rocky, and AlmaLinux use DNF; Arch Linux uses Pacman plus `pacman-contrib`; openSUSE uses Zypper. NetworkManager, UDisks2, Netplan, UFW/firewalld, SELinux, AppArmor, and other host-specific integrations remain optional; capability degradation is reported explicitly. + +Build one distro at a time: + +```bash +cd apps/backend +go build -tags debian ./cmd/tako +go build -tags ubuntu ./cmd/tako +go build -tags fedora ./cmd/tako +go build -tags rhel ./cmd/tako +go build -tags rocky ./cmd/tako +go build -tags almalinux ./cmd/tako +go build -tags archlinux ./cmd/tako +go build -tags opensuse ./cmd/tako +``` + +Nx commands detect `/etc/os-release`; set `TAKO_DISTRO` to override detection. Raw builds without exactly one distro-family tag fail compilation. Fresh installs enable and start `tako-sessiond.socket` before the public `tako.socket`; services remain socket-activated. Upgrades preserve administrator-disabled, stopped, and explicitly masked units. When units were running, scripts stop services before restarting sockets, reload systemd, clear failed/start-limit state, restore the private socket before the public socket, then restore only services that were previously active. Debian uses `deb-systemd-helper` state and `deb-systemd-invoke`, including offline-root and `policy-rc.d` behavior. Upgrades from older Tako packages remove only masks previously created by `deb-systemd-helper`; administrator masks remain intact. From 44dac96321688478d51678d7ee96c13e92e6aee4 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:56:54 +0530 Subject: [PATCH 14/88] feat(platform)!: root file operations with openat2, paginated list, staged uploads Pin all file operations beneath an openat2 file root with NO_SYMLINKS, blocking proc/sys/dev escapes even when privileged. Strengthen stat fingerprints with dev/ino/uid/gid/ctime. Add paginated directory listing (parent/nextOffset/hasMore), resumable chunked uploads with per-chunk SHA-256 and cancel-upload, and trash/restore backed by sidecar metadata. Tighten archive extraction and write validation. --- .../internal/platform/command_output.go | 29 + apps/backend/internal/platform/files.go | 509 +++++++++++++----- apps/backend/internal/platform/files_root.go | 174 ++++++ apps/backend/internal/platform/files_test.go | 109 ++++ .../backend/internal/platform/files_upload.go | 171 ++++++ apps/backend/internal/platform/files_walk.go | 62 +++ 6 files changed, 909 insertions(+), 145 deletions(-) create mode 100644 apps/backend/internal/platform/command_output.go create mode 100644 apps/backend/internal/platform/files_root.go create mode 100644 apps/backend/internal/platform/files_upload.go create mode 100644 apps/backend/internal/platform/files_walk.go diff --git a/apps/backend/internal/platform/command_output.go b/apps/backend/internal/platform/command_output.go new file mode 100644 index 0000000..9ca3947 --- /dev/null +++ b/apps/backend/internal/platform/command_output.go @@ -0,0 +1,29 @@ +package platform + +import "strings" + +func boundedLines(value string, max int) []string { + result := []string{} + for _, line := range strings.Split(value, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if len(line) > 512 { + line = line[:512] + } + result = append(result, line) + if len(result) >= max { + break + } + } + return result +} + +func firstLine(value string) string { + lines := boundedLines(value, 1) + if len(lines) == 0 { + return "" + } + return lines[0] +} diff --git a/apps/backend/internal/platform/files.go b/apps/backend/internal/platform/files.go index 665147b..3706eb7 100644 --- a/apps/backend/internal/platform/files.go +++ b/apps/backend/internal/platform/files.go @@ -5,12 +5,14 @@ import ( "bufio" "compress/gzip" "context" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" + "io/fs" "mime" "os" "os/user" @@ -63,6 +65,9 @@ type FileEntry struct { } type FileDirectory struct { + Parent string `json:"parent"` + NextOffset int64 `json:"nextOffset,omitempty"` + HasMore bool `json:"hasMore"` Path string `json:"path"` Entries []FileEntry `json:"entries"` ShowHidden bool `json:"showHidden"` @@ -89,6 +94,7 @@ type FileResult struct { Warnings []string `json:"warnings,omitempty"` Message string `json:"message,omitempty"` Fingerprint string `json:"fingerprint,omitempty"` + UploadID string `json:"uploadId,omitempty"` } // FileOperation is the narrow wire contract shared by the gateway, sessiond, @@ -97,6 +103,8 @@ type FileResult struct { // system locations and operation-specific limits. type FileOperation struct { Action string `json:"action"` + Scope string `json:"scope,omitempty"` + UploadID string `json:"uploadId,omitempty"` Path string `json:"path,omitempty"` Destination string `json:"destination,omitempty"` Kind string `json:"kind,omitempty"` @@ -121,7 +129,7 @@ type FileOperation struct { } func ValidateFileOperation(operation FileOperation) error { - validActions := map[string]bool{"list": true, "stat": true, "read": true, "read-window": true, "write": true, "write-text": true, "write-chunk": true, "create": true, "rename": true, "move": true, "copy": true, "trash": true, "delete": true, "search": true, "archive": true, "extract": true, "metadata": true} + validActions := map[string]bool{"list": true, "stat": true, "read": true, "read-window": true, "write": true, "write-text": true, "write-chunk": true, "create": true, "rename": true, "move": true, "copy": true, "trash": true, "delete": true, "search": true, "archive": true, "extract": true, "metadata": true, "cancel-upload": true, "restore": true} if !validActions[operation.Action] || len(operation.Path) > MaxFilePath || len(operation.Destination) > MaxFilePath || len(operation.ArchivePath) > MaxFilePath || strings.ContainsAny(operation.Path+operation.Destination+operation.ArchivePath, "\x00\r\n") { return ErrInvalidFileOperation } @@ -132,6 +140,18 @@ func ValidateFileOperation(operation FileOperation) error { if operation.Action == "write-text" { contentLimit = MaxTextFile } + if operation.Scope != "" && operation.Scope != "home" && operation.Scope != "system" { + return ErrInvalidFileOperation + } + if operation.UploadID != "" && (len(operation.UploadID) != 32 || !isHex(operation.UploadID)) { + return ErrInvalidFileOperation + } + if operation.Action == "write-chunk" && (operation.TotalSize <= 0 || operation.Offset > operation.TotalSize || int64(len(operation.Content)) > operation.TotalSize-operation.Offset || len(operation.Content) == 0 || operation.ContentSHA256 == "") { + return ErrInvalidFileOperation + } + if operation.Action == "list" && operation.Limit > MaxFileEntries { + return ErrInvalidFileOperation + } if operation.Offset < 0 || operation.Limit < 0 || operation.Limit > MaxFileChunk || len(operation.Content) > contentLimit { return ErrInvalidFileOperation } @@ -163,7 +183,7 @@ func ValidateFileOperation(operation FileOperation) error { return ErrInvalidFileOperation } } - if (operation.Action == "write" || operation.Action == "write-text" || operation.Action == "write-chunk") && operation.Limit == 0 && len(operation.Content) == 0 { + if (operation.Action == "write" || operation.Action == "write-text") && operation.Offset != 0 { return ErrInvalidFileOperation } if operation.Action == "rename" || operation.Action == "move" || operation.Action == "copy" { @@ -207,16 +227,44 @@ func applyFileOperation(ctx context.Context, operation FileOperation, root strin if err := ctx.Err(); err != nil { return FileResult{}, err } - resolve := func(value string) (string, error) { return resolveFilePath(root, value, privileged) } - path, err := resolve(operation.Path) - if operation.Action == "archive" && operation.Path == "" { - path = root + tree, err := openFileRoot(root) + if err != nil { + return FileResult{}, err + } + defer tree.Close() + f := fileTree{root: tree} + resolve := func(value string) (string, error) { + if value == "" { + value = "." + } + if filepath.IsAbs(value) { + if !privileged { + return "", ErrFilePermission + } + value = strings.TrimPrefix(filepath.Clean(value), "/") + if value == "" { + value = "." + } + } + value = filepath.Clean(value) + if !filepath.IsLocal(value) { + return "", ErrFilePermission + } + if privileged && (value == "proc" || strings.HasPrefix(value, "proc/") || value == "sys" || strings.HasPrefix(value, "sys/") || value == "dev" || strings.HasPrefix(value, "dev/")) { + return "", ErrFilePermission + } + return value, nil } + path, err := resolve(operation.Path) if err != nil { return FileResult{}, err } + if path == "." && operation.Action != "list" && operation.Action != "stat" && operation.Action != "search" && operation.Action != "archive" { + return FileResult{}, ErrFilePermission + } + if operation.ExpectedFingerprint != "" && (operation.Action == "read" || operation.Action == "read-window" || operation.Action == "stat") { - info, statErr := os.Stat(path) + info, statErr := f.root.Stat(path) if errors.Is(statErr, os.ErrNotExist) { return FileResult{}, ErrFileNotFound } @@ -229,51 +277,55 @@ func applyFileOperation(ctx context.Context, operation FileOperation, root strin } switch operation.Action { case "list": - return listDirectory(path, operation.Path, operation.ShowHidden) + return f.listDirectory(path, operation) case "stat": - entry, err := fileEntry(path, operation.Path) + entry, err := f.entry(path, operation.Path) return FileResult{Entry: &entry}, err case "read": - return readFile(path, operation.Offset, operation.Limit) + return f.readFile(path, operation.Offset, operation.Limit, operation.ExpectedFingerprint) case "read-window": - return ReadTextWindow(path, operation.LineOffset, operation.LineLimit) + return f.readTextWindow(path, operation.LineOffset, operation.LineLimit) case "write": - return writeFile(path, operation) + return f.writeFile(path, operation) case "write-text": if len(operation.Content) > MaxTextFile { return FileResult{}, ErrFileTooLarge } - return writeFile(path, operation) + return f.writeFile(path, operation) case "write-chunk": - return writeChunk(path, operation) + return f.writeChunk(path, operation) + case "cancel-upload": + return f.cancelUpload(path, operation) case "create": - return createFile(path, operation) + return f.createFile(path, operation) case "rename", "move", "copy": destination, resolveErr := resolve(operation.Destination) if resolveErr != nil { return FileResult{}, resolveErr } - return transferFile(operation.Action, path, destination, operation) + return f.transferFile(operation.Action, path, destination, operation) case "trash": - return trashFile(path, root, operation) + return f.trashFile(path, ".", operation) + case "restore": + return f.restoreFile(path, operation) case "delete": - return deleteFile(path, operation) + return f.deleteFile(path, operation) case "search": - return searchFiles(ctx, path, operation) + return f.searchFiles(ctx, path, operation) case "archive": archive, resolveErr := resolve(operation.ArchivePath) if resolveErr != nil { return FileResult{}, resolveErr } - return createArchive(ctx, path, archive, operation) + return f.createArchive(ctx, path, archive, operation) case "extract": archive, resolveErr := resolve(operation.ArchivePath) if resolveErr != nil { return FileResult{}, resolveErr } - return extractArchive(ctx, archive, path, operation) + return f.extractArchive(ctx, archive, path, operation) case "metadata": - return updateMetadata(path, operation, privileged) + return f.updateMetadata(path, operation, privileged) default: return FileResult{}, ErrInvalidFileOperation } @@ -315,43 +367,71 @@ func resolveFilePath(root, value string, privileged bool) (string, error) { return path, nil } -func listDirectory(path, displayPath string, showHidden bool) (FileResult, error) { - entries, err := os.ReadDir(path) - if errors.Is(err, os.ErrNotExist) { - return FileResult{}, ErrFileNotFound - } - if errors.Is(err, os.ErrPermission) { - return FileResult{}, ErrFilePermission +func (f fileTree) listDirectory(path string, operation FileOperation) (FileResult, error) { + directory, err := f.root.Open(path) + if err != nil { + return FileResult{}, err } + defer directory.Close() + info, err := directory.Stat() if err != nil { return FileResult{}, err } - if len(entries) > MaxFileEntries { - entries = entries[:MaxFileEntries] + if operation.ExpectedFingerprint != "" && statFingerprint(info) != operation.ExpectedFingerprint { + return FileResult{}, ErrFileConflict } - result := make([]FileEntry, 0, len(entries)) - for _, item := range entries { - if !showHidden && strings.HasPrefix(item.Name(), ".") { - continue - } - entry, entryErr := fileEntry(filepath.Join(path, item.Name()), filepath.Join(displayPath, item.Name())) - if entryErr != nil { - if errors.Is(entryErr, os.ErrPermission) { - result = append(result, FileEntry{Name: item.Name(), Path: filepath.Join(displayPath, item.Name()), Hidden: strings.HasPrefix(item.Name(), "."), PermissionDenied: true, Reason: "metadata is not readable"}) - continue + limit := int(operation.Limit) + if limit == 0 { + limit = 200 + } + result := FileDirectory{Path: path, Parent: filepath.Dir(path), Entries: []FileEntry{}, ShowHidden: operation.ShowHidden, Fingerprint: statFingerprint(info)} + var offset int64 + for offset < operation.Offset { + n := min(int64(256), operation.Offset-offset) + entries, err := directory.ReadDir(int(n)) + offset += int64(len(entries)) + if err != nil { + if errors.Is(err, io.EOF) { + return FileResult{Directory: &result}, nil } + return FileResult{}, err + } + } + for scanned := 0; scanned < 100000; scanned++ { + entries, readErr := directory.ReadDir(1) + if errors.Is(readErr, io.EOF) { + break + } + if readErr != nil { + return FileResult{}, readErr + } + item := entries[0] + offset++ + if scanned == 99999 { + result.HasMore = true + result.NextOffset = offset + } + if !operation.ShowHidden && strings.HasPrefix(item.Name(), ".") { continue } - result = append(result, entry) + if len(result.Entries) == limit { + result.HasMore = true + result.NextOffset = offset - 1 + break + } + entry, entryErr := f.entry(filepath.Join(path, item.Name()), filepath.Join(path, item.Name())) + if entryErr == nil { + result.Entries = append(result.Entries, entry) + } } - sort.Slice(result, func(left, right int) bool { - if result[left].Kind != result[right].Kind { - return result[left].Kind == "directory" + sort.Slice(result.Entries, func(i, j int) bool { + a, b := result.Entries[i], result.Entries[j] + if (a.Kind == "directory") != (b.Kind == "directory") { + return a.Kind == "directory" } - return strings.ToLower(result[left].Name) < strings.ToLower(result[right].Name) + return strings.ToLower(a.Name) < strings.ToLower(b.Name) }) - stat, _ := os.Stat(path) - return FileResult{Directory: &FileDirectory{Path: displayPath, Entries: result, ShowHidden: showHidden, Fingerprint: statFingerprint(stat)}}, nil + return FileResult{Directory: &result}, nil } func fileEntry(path, displayPath string) (FileEntry, error) { @@ -380,34 +460,73 @@ func fileEntry(path, displayPath string) (FileEntry, error) { return entry, nil } +func (f fileTree) entry(path, displayPath string) (FileEntry, error) { + info, err := f.root.Lstat(path) + if err != nil { + return FileEntry{}, err + } + kind := "file" + if info.IsDir() { + kind = "directory" + } else if info.Mode()&os.ModeSymlink != 0 { + kind = "symlink" + } + entry := FileEntry{Name: filepath.Base(path), Path: displayPath, Kind: kind, Size: info.Size(), Mode: uint32(info.Mode().Perm()), ModifiedAt: info.ModTime().UTC(), Fingerprint: statFingerprint(info), Hidden: strings.HasPrefix(filepath.Base(path), "."), Readable: info.Mode().Perm()&0o444 != 0, Writable: info.Mode().Perm()&0o222 != 0} + if kind == "file" { + entry.Mime = mime.TypeByExtension(filepath.Ext(path)) + if entry.Mime == "" { + entry.Mime = "application/octet-stream" + } + } + if kind == "symlink" { + if target, readErr := f.root.Readlink(path); readErr == nil && len(target) <= MaxFilePath { + entry.SymlinkTarget = target + } + } + return entry, nil +} + func statFingerprint(info os.FileInfo) string { if info == nil { return "" } value := fmt.Sprintf("%s:%d:%d:%o", info.Mode().String(), info.Size(), info.ModTime().UnixNano(), info.Mode().Perm()) + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + value += fmt.Sprintf(":%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Uid, stat.Gid, stat.Ctim.Sec, stat.Ctim.Nsec) + } hash := sha256.Sum256([]byte(value)) return hex.EncodeToString(hash[:]) } -func readFile(path string, offset, limit int64) (FileResult, error) { - info, err := os.Stat(path) +func (f fileTree) readFile(path string, offset, limit int64, expected string) (FileResult, error) { + info, err := f.root.Stat(path) if errors.Is(err, os.ErrNotExist) { return FileResult{}, ErrFileNotFound } if err != nil { return FileResult{}, err } - if info.IsDir() || info.Size() > MaxFileChunk*1024 { + if !info.Mode().IsRegular() { return FileResult{}, ErrFileTooLarge } if limit == 0 { limit = MaxFileChunk } - file, err := os.Open(path) + file, err := f.root.Open(path) if err != nil { return FileResult{}, err } defer file.Close() + info, err = file.Stat() + if err != nil { + return FileResult{}, err + } + if !info.Mode().IsRegular() { + return FileResult{}, ErrFilePermission + } + if expected != "" && statFingerprint(info) != expected { + return FileResult{}, ErrFileConflict + } if _, err := file.Seek(offset, io.SeekStart); err != nil { return FileResult{}, err } @@ -418,29 +537,29 @@ func readFile(path string, offset, limit int64) (FileResult, error) { return FileResult{Content: content, Offset: offset, Total: info.Size(), EOF: offset+int64(len(content)) >= info.Size(), Mime: mime.TypeByExtension(filepath.Ext(path)), Fingerprint: statFingerprint(info)}, nil } -func readText(path string, offset, limit int64) (FileResult, error) { - info, err := os.Stat(path) +func (f fileTree) readText(path string, offset, limit int64) (FileResult, error) { + info, err := f.root.Stat(path) if err != nil { return FileResult{}, err } if info.Size() > MaxTextFile { return FileResult{}, ErrFileTooLarge } - result, err := readFile(path, offset, limit) + result, err := f.readFile(path, offset, limit, "") if err == nil { result.Mime = "text/plain" } return result, err } -func writeFile(path string, operation FileOperation) (FileResult, error) { +func (f fileTree) writeFile(path string, operation FileOperation) (FileResult, error) { if operation.ContentSHA256 != "" { digest := sha256.Sum256(operation.Content) if !strings.EqualFold(hex.EncodeToString(digest[:]), operation.ContentSHA256) { return FileResult{}, ErrFileConflict } } - info, err := os.Stat(path) + info, err := f.root.Stat(path) if err != nil && !errors.Is(err, os.ErrNotExist) { return FileResult{}, err } @@ -448,7 +567,7 @@ func writeFile(path string, operation FileOperation) (FileResult, error) { return FileResult{}, ErrFileConflict } if operation.Offset > 0 { - file, openErr := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0o600) + file, openErr := f.root.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0o600) if openErr != nil { return FileResult{}, openErr } @@ -461,12 +580,12 @@ func writeFile(path string, operation FileOperation) (FileResult, error) { } _ = file.Sync() } else { - temporary, createErr := os.CreateTemp(filepath.Dir(path), ".tako-write-*") + temporary, createErr := f.temporary(filepath.Dir(path)) if createErr != nil { return FileResult{}, createErr } - temporaryName := temporary.Name() - defer os.Remove(temporaryName) + temporaryName := filepath.Join(filepath.Dir(path), filepath.Base(temporary.Name())) + defer f.root.Remove(temporaryName) if _, createErr = temporary.Write(operation.Content); createErr == nil { createErr = temporary.Sync() } @@ -474,66 +593,42 @@ func writeFile(path string, operation FileOperation) (FileResult, error) { createErr = closeErr } if createErr == nil { + if current, e := f.root.Stat(path); operation.ExpectedFingerprint != "" && (e != nil || statFingerprint(current) != operation.ExpectedFingerprint) { + return FileResult{}, ErrFileConflict + } if info != nil { - _ = os.Chmod(temporaryName, info.Mode().Perm()) + if modeErr := f.preserveMetadata(temporaryName, info); modeErr != nil { + return FileResult{}, modeErr + } } - createErr = os.Rename(temporaryName, path) + createErr = f.root.Rename(temporaryName, path) } if createErr != nil { return FileResult{}, createErr } } - entry, err := fileEntry(path, path) + entry, err := f.entry(path, path) return FileResult{Entry: &entry, Fingerprint: entry.Fingerprint}, err } -func writeChunk(path string, operation FileOperation) (FileResult, error) { - if operation.ContentSHA256 != "" { - digest := sha256.Sum256(operation.Content) - if !strings.EqualFold(hex.EncodeToString(digest[:]), operation.ContentSHA256) { - return FileResult{}, ErrFileConflict - } - } - if info, statErr := os.Stat(path); statErr == nil && operation.ExpectedFingerprint != "" && statFingerprint(info) != operation.ExpectedFingerprint { - return FileResult{}, ErrFileConflict - } - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) - if err != nil { - return FileResult{}, err - } - defer file.Close() - if _, err := file.Seek(operation.Offset, io.SeekStart); err != nil { - return FileResult{}, err - } - if _, err := file.Write(operation.Content); err != nil { - return FileResult{}, err - } - if err := file.Sync(); err != nil { - return FileResult{}, err - } - entry, err := fileEntry(path, path) - next := operation.Offset + int64(len(operation.Content)) - return FileResult{Entry: &entry, Offset: next, Total: entry.Size, EOF: operation.TotalSize > 0 && next >= operation.TotalSize, Fingerprint: entry.Fingerprint}, err -} - -func createFile(path string, operation FileOperation) (FileResult, error) { +func (f fileTree) createFile(path string, operation FileOperation) (FileResult, error) { if operation.Kind == "directory" { - if err := os.Mkdir(path, 0o755); err != nil { + if err := f.root.Mkdir(path, 0o755); err != nil { return FileResult{}, err } } else { - file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + file, err := f.root.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return FileResult{}, err } _ = file.Close() } - entry, err := fileEntry(path, path) + entry, err := f.entry(path, path) return FileResult{Entry: &entry}, err } -func transferFile(action, source, destination string, operation FileOperation) (FileResult, error) { - info, err := os.Lstat(source) +func (f fileTree) transferFile(action, source, destination string, operation FileOperation) (FileResult, error) { + info, err := f.root.Lstat(source) if err != nil { return FileResult{}, err } @@ -547,11 +642,11 @@ func transferFile(action, source, destination string, operation FileOperation) ( if info.IsDir() { return FileResult{}, errors.New("directory copy requires archive or recursive operation") } - input, openErr := os.Open(source) + input, openErr := f.root.Open(source) if openErr != nil { return FileResult{}, openErr } - output, createErr := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm()) + output, createErr := f.root.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm()) if createErr != nil { _ = input.Close() return FileResult{}, createErr @@ -560,46 +655,115 @@ func transferFile(action, source, destination string, operation FileOperation) ( _ = input.Close() _ = output.Close() if copyErr == nil || written > MaxArchiveBytes { - _ = os.Remove(destination) + _ = f.root.Remove(destination) return FileResult{}, ErrFileTooLarge } if copyErr != nil && !errors.Is(copyErr, io.EOF) { - _ = os.Remove(destination) + _ = f.root.Remove(destination) return FileResult{}, copyErr } - } else if err := os.Rename(source, destination); err != nil { + } else if err := f.root.rename(source, destination, true); err != nil { if errors.Is(err, syscall.EXDEV) && action == "move" { - if _, copyErr := transferFile("copy", source, destination, operation); copyErr != nil { + if _, copyErr := f.transferFile("copy", source, destination, operation); copyErr != nil { return FileResult{}, copyErr } - if removeErr := os.Remove(source); removeErr != nil { + if removeErr := f.root.Remove(source); removeErr != nil { return FileResult{}, removeErr } } else { return FileResult{}, err } } - entry, err := fileEntry(destination, destination) + entry, err := f.entry(destination, destination) return FileResult{Entry: &entry}, err } -func trashFile(path, root string, operation FileOperation) (FileResult, error) { +func (f fileTree) trashFile(path, root string, operation FileOperation) (FileResult, error) { if operation.Permanent { - return deleteFile(path, operation) + return f.deleteFile(path, operation) } - trashRoot := filepath.Join(root, ".local", "share", "Trash", "files") - if err := os.MkdirAll(trashRoot, 0o700); err != nil { + info, err := f.root.Lstat(path) + if err != nil { return FileResult{}, err } - destination := filepath.Join(trashRoot, filepath.Base(path)+"-"+fmt.Sprintf("%d", time.Now().UnixNano())) - if err := os.Rename(path, destination); err != nil { - return FileResult{Warnings: []string{"Trash is unavailable across filesystems; the item was not removed."}}, err + if operation.ExpectedFingerprint != "" && statFingerprint(info) != operation.ExpectedFingerprint { + return FileResult{}, ErrFileConflict + } + trashRoot := filepath.Join(root, ".local", "share", "Trash") + if strings.HasPrefix(path, trashRoot+"/") { + return FileResult{}, ErrInvalidFileOperation } - return FileResult{Message: "Item moved to the user trash."}, nil + if err := f.root.MkdirAll(filepath.Join(trashRoot, "files"), 0700); err != nil { + return FileResult{}, err + } + if err := f.root.MkdirAll(filepath.Join(trashRoot, "info"), 0700); err != nil { + return FileResult{}, err + } + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return FileResult{}, err + } + name := filepath.Base(path) + "-" + hex.EncodeToString(random[:]) + destination := filepath.Join(trashRoot, "files", name) + recordPath := filepath.Join(trashRoot, "info", name+".json") + record, err := f.root.OpenFile(recordPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return FileResult{}, err + } + err = json.NewEncoder(record).Encode(struct { + Path string `json:"path"` + }{path}) + if err == nil { + err = record.Sync() + } + record.Close() + if err != nil { + f.root.Remove(recordPath) + return FileResult{}, err + } + if err := f.root.rename(path, destination, true); err != nil { + f.root.Remove(recordPath) + return FileResult{}, err + } + entry, err := f.entry(destination, destination) + return FileResult{Entry: &entry, Message: "Item moved to trash."}, err } -func deleteFile(path string, operation FileOperation) (FileResult, error) { - info, err := os.Lstat(path) +func (f fileTree) restoreFile(path string, operation FileOperation) (FileResult, error) { + const directory = ".local/share/Trash/files/" + if !strings.HasPrefix(path, directory) || strings.Contains(strings.TrimPrefix(path, directory), "/") { + return FileResult{}, ErrInvalidFileOperation + } + info, err := f.root.Lstat(path) + if err != nil { + return FileResult{}, err + } + if operation.ExpectedFingerprint != "" && statFingerprint(info) != operation.ExpectedFingerprint { + return FileResult{}, ErrFileConflict + } + recordPath := filepath.Join(".local/share/Trash/info", filepath.Base(path)+".json") + record, err := f.root.Open(recordPath) + if err != nil { + return FileResult{}, err + } + var original struct { + Path string `json:"path"` + } + err = json.NewDecoder(io.LimitReader(record, MaxFilePath+128)).Decode(&original) + record.Close() + if err != nil || !filepath.IsLocal(original.Path) || original.Path == "." { + return FileResult{}, ErrInvalidFileOperation + } + if err := f.root.rename(path, original.Path, true); err != nil { + return FileResult{}, err + } + f.root.Remove(recordPath) + entry, err := f.entry(original.Path, original.Path) + return FileResult{Entry: &entry, Message: "Item restored."}, err +} + +func (f fileTree) deleteFile(path string, operation FileOperation) (FileResult, error) { + info, err := f.root.Lstat(path) if err != nil { return FileResult{}, err } @@ -610,32 +774,39 @@ func deleteFile(path string, operation FileOperation) (FileResult, error) { if !operation.Recursive { return FileResult{}, errors.New("directory deletion requires recursive confirmation") } - err = os.RemoveAll(path) + err = f.root.RemoveAll(path) } else { - err = os.Remove(path) + err = f.root.Remove(path) } return FileResult{Message: "Item deleted."}, err } -func searchFiles(ctx context.Context, path string, operation FileOperation) (FileResult, error) { +func (f fileTree) searchFiles(ctx context.Context, path string, operation FileOperation) (FileResult, error) { maxEntries := operation.MaxEntries if maxEntries == 0 { maxEntries = 1000 } result := FileSearchResult{Root: path, Query: operation.Query, Entries: []FileEntry{}} count := 0 - err := filepath.WalkDir(path, func(current string, item os.DirEntry, walkErr error) error { + visited := 0 + needle := strings.ToLower(operation.Query) + err := f.walk(ctx, path, func(current string, item os.DirEntry, walkErr error) error { if err := ctx.Err(); err != nil { return err } + visited++ + if visited > MaxSearchEntries || strings.Count(current, "/")-strings.Count(path, "/") > MaxArchiveDepth { + result.Limited = true + return fs.SkipAll + } if walkErr != nil { if errors.Is(walkErr, os.ErrPermission) { return filepath.SkipDir } return nil } - if current != path && strings.Contains(strings.ToLower(item.Name()), strings.ToLower(operation.Query)) { - entry, entryErr := fileEntry(current, current) + if current != path && strings.Contains(strings.ToLower(item.Name()), needle) { + entry, entryErr := f.entry(current, current) if entryErr == nil { result.Entries = append(result.Entries, entry) count++ @@ -647,19 +818,23 @@ func searchFiles(ctx context.Context, path string, operation FileOperation) (Fil } return nil }) + if errors.Is(err, ErrArchiveLimit) { + result.Limited = true + err = nil + } if err != nil && err.Error() != "search limit" { return FileResult{}, err } return FileResult{Search: &result}, nil } -func createArchive(ctx context.Context, source, destination string, operation FileOperation) (FileResult, error) { +func (f fileTree) createArchive(ctx context.Context, source, destination string, operation FileOperation) (FileResult, error) { sourceAbs, sourceErr := filepath.Abs(source) destinationAbs, destinationErr := filepath.Abs(destination) if sourceErr != nil || destinationErr != nil || pathWithin(sourceAbs, destinationAbs) { return FileResult{}, ErrInvalidFileOperation } - output, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + output, err := f.root.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return FileResult{}, err } @@ -667,14 +842,18 @@ func createArchive(ctx context.Context, source, destination string, operation Fi removePartial := true defer func() { if removePartial { - _ = os.Remove(destination) + _ = f.root.Remove(destination) } }() gzipWriter := gzip.NewWriter(output) tarWriter := tar.NewWriter(gzipWriter) entries := 0 bytesWritten := int64(0) - err = filepath.Walk(source, func(path string, info os.FileInfo, walkErr error) error { + err = f.walk(ctx, source, func(path string, item fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, walkErr := item.Info() if err := ctx.Err(); err != nil { return err } @@ -703,7 +882,7 @@ func createArchive(ctx context.Context, source, destination string, operation Fi if info.Size() > MaxArchiveEntryBytes || bytesWritten+info.Size() > MaxArchiveBytes { return ErrArchiveLimit } - input, openErr := os.Open(path) + input, openErr := f.root.Open(path) if openErr != nil { return openErr } @@ -726,8 +905,13 @@ func createArchive(ctx context.Context, source, destination string, operation Fi return FileResult{Message: fmt.Sprintf("Archive created with %d entries.", entries)}, nil } -func extractArchive(ctx context.Context, archivePath, destination string, operation FileOperation) (FileResult, error) { - input, err := os.Open(archivePath) +func (f fileTree) extractArchive(ctx context.Context, archivePath, destination string, operation FileOperation) (FileResult, error) { + destinationRoot, err := f.root.OpenRoot(destination) + if err != nil { + return FileResult{}, err + } + defer destinationRoot.Close() + input, err := f.root.Open(archivePath) if err != nil { return FileResult{}, err } @@ -758,23 +942,23 @@ func extractArchive(ctx context.Context, archivePath, destination string, operat if !safeArchiveName(header.Name) || strings.Count(filepath.ToSlash(header.Name), "/") > MaxArchiveDepth { return FileResult{}, ErrUnsafeArchive } - target := filepath.Join(destination, filepath.FromSlash(header.Name)) - if !filePathWithin(destination, target) { + target := filepath.FromSlash(header.Name) + if !filepath.IsLocal(target) { return FileResult{}, ErrUnsafeArchive } if header.FileInfo().IsDir() { - if err := os.MkdirAll(target, 0o755); err != nil { + if err := destinationRoot.MkdirAll(target, 0o755); err != nil { return FileResult{}, err } continue } - if header.Typeflag == tar.TypeSymlink || header.Typeflag == tar.TypeLink { + if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA { return FileResult{}, ErrUnsafeArchive } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + if err := destinationRoot.MkdirAll(filepath.Dir(target), 0o755); err != nil { return FileResult{}, err } - output, createErr := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY|os.O_EXCL, 0o600) + output, createErr := destinationRoot.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY|os.O_EXCL, 0o600) if createErr != nil { return FileResult{}, createErr } @@ -790,7 +974,7 @@ func extractArchive(ctx context.Context, archivePath, destination string, operat func safeArchiveName(name string) bool { name = filepath.ToSlash(name) - return name != "" && !strings.HasPrefix(name, "/") && name != "." && !strings.HasPrefix(name, "../") && !strings.Contains(name, "/../") && !strings.ContainsRune(name, '\x00') + return name != "" && !strings.HasPrefix(name, "/") && name != "." && name != ".." && !strings.HasPrefix(name, "../") && !strings.Contains(name, "/../") && !strings.ContainsRune(name, '\x00') } func filePathWithin(root, path string) bool { @@ -798,11 +982,11 @@ func filePathWithin(root, path string) bool { return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) } -func updateMetadata(path string, operation FileOperation, privileged bool) (FileResult, error) { +func (f fileTree) updateMetadata(path string, operation FileOperation, privileged bool) (FileResult, error) { if !privileged { return FileResult{}, ErrFilePermission } - info, err := os.Lstat(path) + info, err := f.root.Lstat(path) if err != nil { return FileResult{}, err } @@ -810,7 +994,7 @@ func updateMetadata(path string, operation FileOperation, privileged bool) (File return FileResult{}, ErrFileConflict } if operation.Mode != nil { - if err := os.Chmod(path, os.FileMode(*operation.Mode)&os.ModePerm); err != nil { + if err := f.chmod(path, os.FileMode(*operation.Mode)&os.ModePerm); err != nil { return FileResult{}, err } } @@ -838,21 +1022,21 @@ func updateMetadata(path string, operation FileOperation, privileged bool) (File } gid = parsed } - if err := os.Chown(path, uid, gid); err != nil { + if err := f.chown(path, uid, gid); err != nil { return FileResult{}, err } } - entry, err := fileEntry(path, path) + entry, err := f.entry(path, path) return FileResult{Entry: &entry, Message: "File metadata updated."}, err } // ReadTextWindow exposes bounded line windows without loading a large file in // the browser or gateway. Offset is a zero-based line number for text clients. -func ReadTextWindow(path string, lineOffset, lineLimit int) (FileResult, error) { +func (f fileTree) readTextWindow(path string, lineOffset, lineLimit int) (FileResult, error) { if lineOffset < 0 || lineLimit < 1 || lineLimit > 1000 { return FileResult{}, ErrInvalidFileOperation } - file, err := os.Open(path) + file, err := f.root.Open(path) if err != nil { return FileResult{}, err } @@ -875,6 +1059,41 @@ func ReadTextWindow(path string, lineOffset, lineLimit int) (FileResult, error) return FileResult{}, err } payload, _ := json.Marshal(lines) - info, _ := os.Stat(path) + info, _ := f.root.Stat(path) return FileResult{Content: payload, Offset: int64(lineOffset), Total: int64(line), EOF: line < lineOffset+lineLimit, Mime: "application/json", Fingerprint: statFingerprint(info)}, nil } + +// fileTree anchors every operation to an open directory rather than a checked pathname. +type fileTree struct{ root *fileRoot } + +func (f fileTree) temporary(directory string) (*os.File, error) { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + return nil, err + } + return f.root.OpenFile(filepath.Join(directory, ".tako-write-"+hex.EncodeToString(value[:])), os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) +} +func (f fileTree) chmod(path string, mode os.FileMode) error { + file, err := f.root.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return err + } + defer file.Close() + return file.Chmod(mode) +} +func (f fileTree) chown(path string, uid, gid int) error { + file, err := f.root.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return err + } + defer file.Close() + return file.Chown(uid, gid) +} +func (f fileTree) preserveMetadata(path string, info os.FileInfo) error { + if stat, ok := info.Sys().(*syscall.Stat_t); ok && os.Geteuid() == 0 { + if err := f.chown(path, int(stat.Uid), int(stat.Gid)); err != nil { + return err + } + } + return f.chmod(path, info.Mode().Perm()) +} diff --git a/apps/backend/internal/platform/files_root.go b/apps/backend/internal/platform/files_root.go new file mode 100644 index 0000000..72ee50c --- /dev/null +++ b/apps/backend/internal/platform/files_root.go @@ -0,0 +1,174 @@ +package platform + +import ( + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + + "golang.org/x/sys/unix" +) + +// Keep parent descriptors open through mutations. No path component may redirect +// an administrative operation through a symlink or a procfs magic link. +type fileRoot struct{ directory *os.File } + +func openFileRoot(path string) (*fileRoot, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil || !info.IsDir() { + file.Close() + if err == nil { + err = ErrFilePermission + } + return nil, err + } + return &fileRoot{file}, nil +} +func (r *fileRoot) Close() error { return r.directory.Close() } +func (r *fileRoot) Open(name string) (*os.File, error) { + return r.OpenFile(name, os.O_RDONLY|unix.O_NONBLOCK, 0) +} +func (r *fileRoot) OpenFile(name string, flags int, mode os.FileMode) (*os.File, error) { + if !filepath.IsLocal(name) { + return nil, ErrFilePermission + } + creationMode := uint64(0) + if flags&os.O_CREATE != 0 { + creationMode = uint64(mode.Perm()) + } + fd, err := unix.Openat2(int(r.directory.Fd()), name, &unix.OpenHow{Flags: uint64(flags | unix.O_CLOEXEC), Mode: creationMode, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_MAGICLINKS}) + if err != nil { + return nil, &os.PathError{Op: "open", Path: name, Err: err} + } + return os.NewFile(uintptr(fd), name), nil +} +func (r *fileRoot) Stat(name string) (os.FileInfo, error) { + f, e := r.OpenFile(name, unix.O_PATH, 0) + if e != nil { + return nil, e + } + defer f.Close() + return f.Stat() +} +func (r *fileRoot) Lstat(name string) (os.FileInfo, error) { + f, e := r.OpenFile(name, unix.O_PATH|unix.O_NOFOLLOW, 0) + if e != nil { + return nil, e + } + defer f.Close() + return f.Stat() +} +func (r *fileRoot) OpenRoot(name string) (*fileRoot, error) { + f, e := r.OpenFile(name, unix.O_RDONLY|unix.O_DIRECTORY, 0) + if e != nil { + return nil, e + } + return &fileRoot{f}, nil +} +func (r *fileRoot) parent(name string) (*fileRoot, string, error) { + if name == "." || !filepath.IsLocal(name) { + return nil, "", ErrFilePermission + } + parent, err := r.OpenRoot(filepath.Dir(name)) + return parent, filepath.Base(name), err +} +func (r *fileRoot) Mkdir(name string, mode os.FileMode) error { + p, b, e := r.parent(name) + if e != nil { + return e + } + defer p.Close() + return unix.Mkdirat(int(p.directory.Fd()), b, uint32(mode.Perm())) +} +func (r *fileRoot) MkdirAll(name string, mode os.FileMode) error { + if !filepath.IsLocal(name) { + return ErrFilePermission + } + current := "." + for _, part := range strings.Split(filepath.Clean(name), string(filepath.Separator)) { + if part == "." { + continue + } + current = filepath.Join(current, part) + e := r.Mkdir(current, mode) + if e != nil && !os.IsExist(e) { + return e + } + directory, e := r.OpenRoot(current) + if e != nil { + return e + } + directory.Close() + } + return nil +} +func (r *fileRoot) Remove(name string) error { + p, b, e := r.parent(name) + if e != nil { + return e + } + defer p.Close() + e = unix.Unlinkat(int(p.directory.Fd()), b, 0) + if e == unix.EISDIR { + e = unix.Unlinkat(int(p.directory.Fd()), b, unix.AT_REMOVEDIR) + } + return e +} +func (r *fileRoot) RemoveAll(name string) error { + p, b, e := r.parent(name) + if e != nil { + return e + } + defer p.Close() + // os.Root.RemoveAll never follows symlinks in the recursively removed tree. + root, e := os.OpenRoot("/proc/self/fd/" + strconv.Itoa(int(p.directory.Fd()))) + if e != nil { + return e + } + defer root.Close() + return root.RemoveAll(b) +} +func (r *fileRoot) rename(source, destination string, noReplace bool) error { + a, an, e := r.parent(source) + if e != nil { + return e + } + defer a.Close() + b, bn, e := r.parent(destination) + if e != nil { + return e + } + defer b.Close() + flags := uint(0) + if noReplace { + flags = unix.RENAME_NOREPLACE + } + return unix.Renameat2(int(a.directory.Fd()), an, int(b.directory.Fd()), bn, flags) +} +func (r *fileRoot) Rename(a, b string) error { return r.rename(a, b, false) } +func (r *fileRoot) Readlink(name string) (string, error) { + p, b, e := r.parent(name) + if e != nil { + return "", e + } + defer p.Close() + buffer := make([]byte, MaxFilePath+1) + n, e := unix.Readlinkat(int(p.directory.Fd()), b, buffer) + if e != nil { + return "", e + } + if n > MaxFilePath { + return "", ErrFileTooLarge + } + return string(buffer[:n]), nil +} + +type rootedFiles struct{ root *fileRoot } + +func (f rootedFiles) Open(name string) (fs.File, error) { return f.root.Open(name) } +func (r *fileRoot) FS() fs.FS { return rootedFiles{r} } diff --git a/apps/backend/internal/platform/files_test.go b/apps/backend/internal/platform/files_test.go index 2e53a4f..6b1ed90 100644 --- a/apps/backend/internal/platform/files_test.go +++ b/apps/backend/internal/platform/files_test.go @@ -1,7 +1,11 @@ package platform import ( + "archive/tar" + "compress/gzip" "context" + "crypto/sha256" + "encoding/hex" "errors" "os" "path/filepath" @@ -73,3 +77,108 @@ func TestArchiveRejectsUnsafeEntriesAndBounds(t *testing.T) { t.Fatal("safe archive path rejected") } } + +func TestRootedExtractionRejectsExistingSymlinkAncestor(t *testing.T) { + root, outside := t.TempDir(), t.TempDir() + if err := os.Mkdir(filepath.Join(root, "destination"), 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "destination", "link")); err != nil { + t.Fatal(err) + } + archive, err := os.Create(filepath.Join(root, "input.tar.gz")) + if err != nil { + t.Fatal(err) + } + compressed := gzip.NewWriter(archive) + writer := tar.NewWriter(compressed) + if err := writer.WriteHeader(&tar.Header{Name: "link/payload", Mode: 0600, Size: 1}); err != nil { + t.Fatal(err) + } + writer.Write([]byte("x")) + writer.Close() + compressed.Close() + archive.Close() + _, err = applyFileOperation(context.Background(), FileOperation{Action: "extract", Path: "destination", ArchivePath: "input.tar.gz", Confirmation: "CONFIRM FILE OPERATION"}, root, false) + if err == nil { + t.Fatal("symlink ancestor accepted") + } + if _, err := os.Stat(filepath.Join(outside, "payload")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("outside destination changed: %v", err) + } +} + +func TestRootedFileOperationsRejectSymlinkParents(t *testing.T) { + root, outside := t.TempDir(), t.TempDir() + os.Symlink(outside, filepath.Join(root, "link")) + for _, operation := range []FileOperation{{Action: "create", Path: "link/file"}, {Action: "write-text", Path: "link/file", Content: []byte("x")}, {Action: "metadata", Path: "link/file", Confirmation: "CONFIRM FILE OPERATION"}} { + if _, err := applyFileOperation(context.Background(), operation, root, true); err == nil { + t.Fatalf("%s followed symlink", operation.Action) + } + } +} + +func TestUploadStagesAndValidatesRetries(t *testing.T) { + root := t.TempDir() + chunk := func(offset int64, id string, content string) (FileResult, error) { + sum := sha256.Sum256([]byte(content)) + return applyFileOperation(context.Background(), FileOperation{Action: "write-chunk", Path: "upload", Offset: offset, TotalSize: 6, UploadID: id, Content: []byte(content), ContentSHA256: hex.EncodeToString(sum[:])}, root, false) + } + first, err := chunk(0, "", "abc") + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "upload")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("partial upload published") + } + if _, err := chunk(0, first.UploadID, "bad"); !errors.Is(err, ErrFileConflict) { + t.Fatalf("changed retry accepted: %v", err) + } + retry, err := chunk(0, first.UploadID, "abc") + if err != nil || retry.Offset != 3 { + t.Fatalf("retry: %+v %v", retry, err) + } + last, err := chunk(3, first.UploadID, "def") + if err != nil || !last.EOF { + t.Fatalf("completion: %+v %v", last, err) + } + content, err := os.ReadFile(filepath.Join(root, "upload")) + if err != nil || string(content) != "abcdef" { + t.Fatalf("content %q %v", content, err) + } +} + +func TestFilePagesAndEmptyTextSave(t *testing.T) { + root := t.TempDir() + for _, name := range []string{".hidden", "first", "second", "third"} { + os.WriteFile(filepath.Join(root, name), []byte("text"), 0600) + } + seen := map[string]bool{} + offset := int64(0) + for { + page, err := applyFileOperation(context.Background(), FileOperation{Action: "list", Path: ".", Limit: 1, Offset: offset}, root, false) + if err != nil { + t.Fatal(err) + } + for _, entry := range page.Directory.Entries { + if seen[entry.Name] { + t.Fatal("duplicate entry") + } + seen[entry.Name] = true + } + if !page.Directory.HasMore { + break + } + offset = page.Directory.NextOffset + } + if len(seen) != 3 || seen[".hidden"] { + t.Fatalf("listing: %v", seen) + } + if _, err := applyFileOperation(context.Background(), FileOperation{Action: "write-text", Path: "first", Content: []byte{}}, root, false); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(filepath.Join(root, "first")) + if len(data) != 0 { + t.Fatal("empty save retained content") + } +} diff --git a/apps/backend/internal/platform/files_upload.go b/apps/backend/internal/platform/files_upload.go new file mode 100644 index 0000000..d45fe25 --- /dev/null +++ b/apps/backend/internal/platform/files_upload.go @@ -0,0 +1,171 @@ +package platform + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +type uploadRecord struct { + Path string `json:"path"` + Total int64 `json:"total"` + Fingerprint string `json:"fingerprint"` +} + +func uploadNames(path, id string) (string, string) { + data := filepath.Join(filepath.Dir(path), ".tako-upload-"+id) + return data, data + ".json" +} +func (f fileTree) writeChunk(path string, operation FileOperation) (FileResult, error) { + digest := sha256.Sum256(operation.Content) + if !strings.EqualFold(hex.EncodeToString(digest[:]), operation.ContentSHA256) { + return FileResult{}, ErrFileConflict + } + id := operation.UploadID + if id == "" { + if operation.Offset != 0 { + return FileResult{}, ErrFileConflict + } + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + return FileResult{}, err + } + id = hex.EncodeToString(value[:]) + } + data, metadata := uploadNames(path, id) + flags := os.O_RDWR + if operation.UploadID == "" { + flags |= os.O_CREATE | os.O_EXCL + } + file, err := f.root.OpenFile(data, flags, 0600) + if err != nil { + return FileResult{}, err + } + defer file.Close() + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + return FileResult{}, ErrFileConflict + } + defer unix.Flock(int(file.Fd()), unix.LOCK_UN) + info, err := file.Stat() + if err != nil { + return FileResult{}, err + } + if !info.Mode().IsRegular() { + return FileResult{}, ErrFilePermission + } + if stat, ok := info.Sys().(*syscall.Stat_t); !ok || stat.Uid != uint32(os.Geteuid()) || stat.Nlink != 1 { + return FileResult{}, ErrFilePermission + } + record := uploadRecord{Path: path, Total: operation.TotalSize, Fingerprint: operation.ExpectedFingerprint} + if operation.UploadID == "" { + if current, e := f.root.Stat(path); e == nil { + if record.Fingerprint == "" || statFingerprint(current) != record.Fingerprint { + f.root.Remove(data) + return FileResult{}, ErrFileConflict + } + } else if !errors.Is(e, os.ErrNotExist) { + f.root.Remove(data) + return FileResult{}, e + } + meta, e := f.root.OpenFile(metadata, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if e != nil { + f.root.Remove(data) + return FileResult{}, e + } + e = json.NewEncoder(meta).Encode(record) + if e == nil { + e = meta.Sync() + } + meta.Close() + if e != nil { + f.root.Remove(data) + f.root.Remove(metadata) + return FileResult{}, e + } + } else { + meta, e := f.root.Open(metadata) + if e != nil { + return FileResult{}, e + } + e = json.NewDecoder(io.LimitReader(meta, 8192)).Decode(&record) + meta.Close() + if e != nil || record.Path != path || record.Total != operation.TotalSize { + return FileResult{}, ErrFileConflict + } + } + if info.Size() != operation.Offset { + // A retry may acknowledge bytes already committed, but never overwrite them. + if operation.Offset+int64(len(operation.Content)) > info.Size() { + return FileResult{}, ErrFileConflict + } + previous := make([]byte, len(operation.Content)) + if _, err := file.ReadAt(previous, operation.Offset); err != nil || !bytes.Equal(previous, operation.Content) { + return FileResult{}, ErrFileConflict + } + return FileResult{UploadID: id, Offset: info.Size(), Total: record.Total}, nil + } + if _, err := file.WriteAt(operation.Content, operation.Offset); err != nil { + return FileResult{}, err + } + if err := file.Sync(); err != nil { + return FileResult{}, err + } + next := operation.Offset + int64(len(operation.Content)) + result := FileResult{UploadID: id, Offset: next, Total: record.Total} + if next == record.Total { + if record.Fingerprint != "" { + current, e := f.root.Stat(path) + if e != nil || statFingerprint(current) != record.Fingerprint { + return FileResult{}, ErrFileConflict + } + if e := f.preserveMetadata(data, current); e != nil { + return FileResult{}, e + } + } + if err := f.root.rename(data, path, record.Fingerprint == ""); err != nil { + return FileResult{}, err + } + f.root.Remove(metadata) + entry, err := f.entry(path, path) + if err != nil { + return FileResult{}, err + } + result.Entry = &entry + result.Fingerprint = entry.Fingerprint + result.EOF = true + } + return result, nil +} +func (f fileTree) cancelUpload(path string, operation FileOperation) (FileResult, error) { + if operation.UploadID == "" { + return FileResult{}, ErrInvalidFileOperation + } + data, metadata := uploadNames(path, operation.UploadID) + file, err := f.root.OpenFile(data, os.O_RDWR, 0) + if errors.Is(err, os.ErrNotExist) { + return FileResult{Message: "Upload already removed."}, nil + } + if err != nil { + return FileResult{}, err + } + defer file.Close() + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + return FileResult{}, ErrFileConflict + } + defer unix.Flock(int(file.Fd()), unix.LOCK_UN) + if err := f.root.Remove(data); err != nil { + return FileResult{}, err + } + f.root.Remove(metadata) + return FileResult{Message: "Upload cancelled."}, nil +} diff --git a/apps/backend/internal/platform/files_walk.go b/apps/backend/internal/platform/files_walk.go new file mode 100644 index 0000000..01afe1a --- /dev/null +++ b/apps/backend/internal/platform/files_walk.go @@ -0,0 +1,62 @@ +package platform + +import ( + "context" + "errors" + "io" + "io/fs" + "path/filepath" +) + +// Read directory entries in bounded batches, including trees with no matches. +func (f fileTree) walk(ctx context.Context, root string, visit fs.WalkDirFunc) error { + remaining := MaxSearchEntries + var walk func(string, int) error + walk = func(path string, depth int) error { + if err := ctx.Err(); err != nil { + return err + } + if remaining == 0 || depth > MaxArchiveDepth { + return ErrArchiveLimit + } + remaining-- + info, err := f.root.Lstat(path) + if err != nil { + return visit(path, nil, err) + } + entry := fs.FileInfoToDirEntry(info) + if err := visit(path, entry, nil); err != nil { + if errors.Is(err, fs.SkipDir) { + return nil + } + return err + } + if !info.IsDir() { + return nil + } + directory, err := f.root.Open(path) + if err != nil { + return visit(path, entry, err) + } + defer directory.Close() + for { + entries, err := directory.ReadDir(128) + for _, entry := range entries { + if err := walk(filepath.Join(path, entry.Name()), depth+1); err != nil { + return err + } + } + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + } + } + err := walk(root, 0) + if errors.Is(err, fs.SkipAll) { + return nil + } + return err +} From 6f60c3e0c17d5cd6ecbaddce3f1173145ccd81c2 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:56:54 +0530 Subject: [PATCH 15/88] feat(platform): split firewall runtime and persistent inventory Introduce a FirewallStrategy seam and move read/apply into an adapter. Track runtime versus persistent rules with synchronized state, order-insensitive fingerprints, stricter per-action validation, and apply that verifies mutations without rolling back pre-existing rules. --- apps/backend/internal/platform/firewall.go | 344 +++++++++----- .../internal/platform/firewall_adapter.go | 440 ++++++++++++++++++ .../platform/firewall_adapter_test.go | 257 ++++++++++ 3 files changed, 912 insertions(+), 129 deletions(-) create mode 100644 apps/backend/internal/platform/firewall_adapter.go create mode 100644 apps/backend/internal/platform/firewall_adapter_test.go diff --git a/apps/backend/internal/platform/firewall.go b/apps/backend/internal/platform/firewall.go index f6afbd1..033a103 100644 --- a/apps/backend/internal/platform/firewall.go +++ b/apps/backend/internal/platform/firewall.go @@ -2,8 +2,11 @@ package platform import ( "context" + "encoding/json" "errors" "net" + "regexp" + "sort" "strconv" "strings" ) @@ -16,17 +19,25 @@ var ( ErrFirewallAccessRisk = errors.New("firewall operation could lock out management access") ) +// FirewallSnapshot contains the active firewall state and the two firewalld +// rule stores. Rules is retained as the runtime rule list for compatibility +// with the first beta contract. UFW has one effective store, so its runtime +// and persistent lists are the same view. type FirewallSnapshot struct { - Backend string `json:"backend"` - Active bool `json:"active"` - Version string `json:"version,omitempty"` - DefaultZone string `json:"defaultZone,omitempty"` - Zones []string `json:"zones"` - Rules []string `json:"rules"` - Conflicted bool `json:"conflicted"` - ReadOnly bool `json:"readOnly"` - Reason string `json:"reason,omitempty"` - Fingerprint string `json:"fingerprint"` + Backend string `json:"backend"` + Active bool `json:"active"` + Version string `json:"version,omitempty"` + DefaultZone string `json:"defaultZone,omitempty"` + PersistentDefaultZone string `json:"persistentDefaultZone,omitempty"` + Zones []string `json:"zones"` + Rules []string `json:"rules"` + RuntimeRules []string `json:"runtimeRules,omitempty"` + PersistentRules []string `json:"persistentRules,omitempty"` + Synchronized bool `json:"synchronized"` + Conflicted bool `json:"conflicted"` + ReadOnly bool `json:"readOnly"` + Reason string `json:"reason,omitempty"` + Fingerprint string `json:"fingerprint"` } type FirewallOperation struct { @@ -49,46 +60,41 @@ type FirewallState struct { Warning string `json:"warning,omitempty"` } +var firewallNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:@+-]{0,127}$`) + +// ReadFirewallStatus reads whichever supported manager is active. The +// command executor is kept behind FirewallStrategy so tests can use command +// fixtures and production always uses the bounded networkCommand runner. func ReadFirewallStatus(ctx context.Context) (FirewallSnapshot, error) { - snapshot := FirewallSnapshot{Zones: []string{}, Rules: []string{}} - firewalld, firewalldErr := firewallCommand(ctx, "firewall-cmd", "--state") - ufw, ufwErr := firewallCommand(ctx, "ufw", "status", "verbose") - if firewalldErr == nil && strings.Contains(strings.ToLower(firewalld), "running") { - snapshot.Backend = "firewalld" - snapshot.Active = true - version, _ := firewallCommand(ctx, "firewall-cmd", "--version") - snapshot.Version = strings.TrimSpace(version) - zones, _ := firewallCommand(ctx, "firewall-cmd", "--get-active-zones") - snapshot.Rules = boundedLines(zones, 256) - defaultZone, _ := firewallCommand(ctx, "firewall-cmd", "--get-default-zone") - snapshot.DefaultZone = strings.TrimSpace(defaultZone) - } else if ufwErr == nil && strings.HasPrefix(strings.TrimSpace(ufw), "Status: active") { - snapshot.Backend = "UFW" - snapshot.Active = true - version, _ := firewallCommand(ctx, "ufw", "--version") - snapshot.Version = firstLine(version) - snapshot.Rules = boundedLines(ufw, 256) - } else if firewalldErr == nil || ufwErr == nil { - if firewalldErr == nil && ufwErr == nil { - snapshot.Conflicted = true - snapshot.Reason = "firewalld and UFW were both detected; active state could not be established." - } else { - snapshot.ReadOnly = true - snapshot.Reason = "A firewall command responded, but no active firewall state was reported." - } - } else { - return snapshot, ErrFirewallUnavailable - } - snapshot.Fingerprint = fingerprintBytes([]byte(snapshot.Backend + "|" + strconv.FormatBool(snapshot.Active) + "|" + strings.Join(snapshot.Rules, "\n"))) - return snapshot, nil + return NewFirewallStrategy(nil).Read(ctx) } func ValidateFirewallOperation(operation FirewallOperation) error { backends := map[string]bool{"auto": true, "firewalld": true, "UFW": true} - actions := map[string]bool{"preview": true, "enable": true, "disable": true, "default-zone": true, "add-service": true, "remove-service": true, "add-port": true, "remove-port": true, "add-source": true, "remove-source": true, "reload": true} - if !backends[operation.Backend] || !actions[operation.Action] || len(operation.Zone) > 128 || len(operation.Service) > 128 || len(operation.Port) > 32 || len(operation.Source) > 128 || len(operation.DefaultZone) > 128 || len(operation.ExpectedFingerprint) > 128 || strings.ContainsAny(operation.Zone+operation.Service+operation.Port+operation.Source+operation.DefaultZone, "\x00\r\n") { + actions := map[string]bool{ + "preview": true, + "enable": true, + "disable": true, + "default-zone": true, + "add-service": true, + "remove-service": true, + "add-port": true, + "remove-port": true, + "add-source": true, + "remove-source": true, + "reload": true, + } + if !backends[operation.Backend] || !actions[operation.Action] { return ErrInvalidFirewallOperation } + if len(operation.Zone) > 128 || len(operation.Service) > 128 || len(operation.Port) > 32 || len(operation.Source) > 128 || len(operation.DefaultZone) > 128 || len(operation.ExpectedFingerprint) > 128 || len(operation.Confirmation) > 128 || strings.ContainsAny(operation.Zone+operation.Service+operation.Port+operation.Source+operation.DefaultZone+operation.ExpectedFingerprint+operation.Confirmation, "\x00\r\n") { + return ErrInvalidFirewallOperation + } + for _, value := range []string{operation.Zone, operation.Service, operation.DefaultZone} { + if value != "" && !firewallNamePattern.MatchString(value) { + return ErrInvalidFirewallOperation + } + } if operation.Port != "" { parts := strings.Split(operation.Port, "/") if len(parts) != 2 { @@ -99,23 +105,58 @@ func ValidateFirewallOperation(operation FirewallOperation) error { return ErrInvalidFirewallOperation } } - if operation.Source != "" && net.ParseIP(operation.Source) == nil { - if _, _, err := net.ParseCIDR(operation.Source); err != nil { + if operation.Source != "" { + if ip := net.ParseIP(operation.Source); ip == nil { + if _, _, err := net.ParseCIDR(operation.Source); err != nil { + return ErrInvalidFirewallOperation + } + } + } + + switch operation.Action { + case "default-zone": + if operation.DefaultZone == "" || operation.Zone != "" || operation.Service != "" || operation.Port != "" || operation.Source != "" { + return ErrInvalidFirewallOperation + } + case "add-service", "remove-service": + if operation.Service == "" || operation.Port != "" || operation.Source != "" || operation.DefaultZone != "" { + return ErrInvalidFirewallOperation + } + case "add-port", "remove-port": + if operation.Port == "" || operation.Service != "" || operation.Source != "" || operation.DefaultZone != "" { + return ErrInvalidFirewallOperation + } + case "add-source", "remove-source": + if operation.Source == "" || operation.Service != "" || operation.Port != "" || operation.DefaultZone != "" { + return ErrInvalidFirewallOperation + } + case "enable", "disable", "reload": + if operation.Zone != "" || operation.Service != "" || operation.Port != "" || operation.Source != "" || operation.DefaultZone != "" { return ErrInvalidFirewallOperation } } + if operation.Backend == "UFW" && operation.Action == "default-zone" { + return ErrInvalidFirewallOperation + } if operation.ExpectedFingerprint == "" && operation.Action != "preview" { return ErrInvalidFirewallOperation } if operation.Action != "preview" && operation.Confirmation != "CONFIRM FIREWALL CHANGE" && operation.Confirmation != "CONFIRM FIREWALL ACCESS" { return ErrInvalidFirewallOperation } - if (operation.Action == "disable" || operation.Action == "remove-service" || operation.Action == "remove-port") && operation.Confirmation != "CONFIRM FIREWALL ACCESS" { + if firewallAccessRisk(operation) && operation.Confirmation != "CONFIRM FIREWALL ACCESS" { return ErrFirewallAccessRisk } return nil } +func firewallAccessRisk(operation FirewallOperation) bool { + if operation.Action == "disable" { + return true + } + return operation.Action == "remove-service" || operation.Action == "remove-port" || operation.Action == "remove-source" +} + func PreviewFirewallOperation(ctx context.Context, operation FirewallOperation) (FirewallState, error) { operation.Action = "preview" if err := ValidateFirewallOperation(operation); err != nil { @@ -125,113 +166,158 @@ func PreviewFirewallOperation(ctx context.Context, operation FirewallOperation) if err != nil { return FirewallState{}, err } - if operation.Backend == "auto" { - operation.Backend = snapshot.Backend - } - return FirewallState{Snapshot: snapshot, Action: "preview", Warning: "Runtime and persistent firewall state remain separate; persistence requires an explicit commit."}, nil + return FirewallState{Snapshot: snapshot, Action: "preview", Warning: firewallPreviewWarning(snapshot)}, nil } func ApplyFirewallOperation(ctx context.Context, operation FirewallOperation) (FirewallState, error) { - if err := ValidateFirewallOperation(operation); err != nil { - return FirewallState{}, err - } - snapshot, err := ReadFirewallStatus(ctx) - if err != nil { - return FirewallState{}, err + return NewFirewallStrategy(nil).Apply(ctx, operation) +} + +func firewallPreviewWarning(snapshot FirewallSnapshot) string { + if snapshot.Backend == "firewalld" && !snapshot.Synchronized { + return "Runtime and persistent firewalld state differ; choose persistence explicitly before applying a change." } - if operation.Backend == "auto" { - operation.Backend = snapshot.Backend + return "Firewall changes are checked against the displayed fingerprint and management access requires explicit confirmation." +} + +type firewallFingerprintInput struct { + Backend string + Active bool + DefaultZone string + PersistentDefaultZone string + Zones []string + RuntimeRules []string + PersistentRules []string + Synchronized bool + Conflicted bool + ReadOnly bool +} + +func firewallFingerprint(snapshot FirewallSnapshot) string { + zones := sortedUnique(snapshot.Zones) + runtimeRules := append([]string(nil), snapshot.RuntimeRules...) + persistentRules := append([]string(nil), snapshot.PersistentRules...) + if len(runtimeRules) == 0 { + runtimeRules = append([]string(nil), snapshot.Rules...) } - if snapshot.Conflicted || snapshot.Backend != operation.Backend { - return FirewallState{}, ErrFirewallOwnership + if snapshot.Backend == "firewalld" { + sort.Strings(runtimeRules) + sort.Strings(persistentRules) } - if operation.ExpectedFingerprint != snapshot.Fingerprint { - return FirewallState{}, ErrFirewallConflict + payload, _ := json.Marshal(firewallFingerprintInput{ + Backend: snapshot.Backend, Active: snapshot.Active, DefaultZone: snapshot.DefaultZone, + PersistentDefaultZone: snapshot.PersistentDefaultZone, Zones: zones, + RuntimeRules: runtimeRules, PersistentRules: persistentRules, + Synchronized: snapshot.Synchronized, Conflicted: snapshot.Conflicted, ReadOnly: snapshot.ReadOnly, + }) + return fingerprintBytes(payload) +} + +func sortedUnique(values []string) []string { + result := append([]string(nil), values...) + sort.Strings(result) + if len(result) == 0 { + return []string{} } - arguments := []string{} - if operation.Backend == "firewalld" { - zone := operation.Zone - if zone == "" { - zone = snapshot.DefaultZone - } - if operation.Action == "default-zone" { - arguments = []string{"--set-default-zone=" + operation.DefaultZone} - } else if operation.Action == "reload" { - arguments = []string{"--reload"} - } else if operation.Action == "enable" { - arguments = []string{"--set-log-denied=all"} - } else if operation.Action == "add-service" || operation.Action == "remove-service" { - arguments = []string{"--zone=" + zone, "--" + strings.TrimSuffix(operation.Action, "-service") + "-service=" + operation.Service} - } else if operation.Action == "add-port" || operation.Action == "remove-port" { - arguments = []string{"--zone=" + zone, "--" + strings.TrimSuffix(operation.Action, "-port") + "-port=" + operation.Port} - } else if operation.Action == "add-source" || operation.Action == "remove-source" { - arguments = []string{"--zone=" + zone, "--" + strings.TrimSuffix(operation.Action, "-source") + "-source=" + operation.Source} - } else { - return FirewallState{}, ErrInvalidFirewallOperation - } - if operation.Persist && operation.Action != "reload" { - arguments = append(arguments, "--permanent") - } - if _, err := firewallCommand(ctx, "firewall-cmd", arguments...); err != nil { - return FirewallState{}, err - } - } else { - if operation.Action == "reload" { - if _, err := firewallCommand(ctx, "ufw", "reload"); err != nil { - return FirewallState{}, err - } - } else if operation.Action == "enable" || operation.Action == "disable" { - if _, err := firewallCommand(ctx, "ufw", operation.Action); err != nil { - return FirewallState{}, err - } - } else if operation.Action == "add-port" || operation.Action == "remove-port" { - action := "allow" - if operation.Action == "remove-port" { - action = "delete allow" - } - if _, err := firewallCommand(ctx, "ufw", strings.Fields(action+" "+operation.Port)...); err != nil { - return FirewallState{}, err - } - } else { - return FirewallState{}, ErrFirewallUnavailable + out := result[:1] + for _, value := range result[1:] { + if value != out[len(out)-1] { + out = append(out, value) } } - updated, err := ReadFirewallStatus(ctx) - if err != nil { - return FirewallState{}, err - } - return FirewallState{Snapshot: updated, Action: operation.Action, Applied: true, Warning: "Management access protection was evaluated before applying this change; verify reconnection from a fresh session."}, nil + return out } -func firewallCommand(ctx context.Context, name string, arguments ...string) (string, error) { - payload, err := networkCommand(ctx, name, arguments...) - return string(payload), err +func parseFirewalldZones(active, all string) []string { + result := []string{} + for _, line := range strings.Split(active, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(line, " ") || strings.Contains(trimmed, ":") || !firewallNamePattern.MatchString(trimmed) { + continue + } + result = append(result, trimmed) + } + for _, value := range strings.Fields(all) { + if firewallNamePattern.MatchString(value) { + result = append(result, value) + } + } + return sortedUnique(result) } -func boundedLines(value string, max int) []string { +func parseFirewalldRules(zone, output string) []string { result := []string{} - for _, line := range strings.Split(value, "\n") { + for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) - if line == "" { + if line == "" || strings.HasPrefix(line, zone+" ") || line == zone { continue } - if len(line) > 512 { - line = line[:512] + if strings.Contains(line, ":") || strings.HasPrefix(line, "rule ") { + fields := strings.Fields(line) + if len(fields) > 0 { + result = append(result, zone+" "+strings.Join(fields, " ")) + } } - result = append(result, line) - if len(result) >= max { - break + } + return sortedUnique(result) +} + +func parseUFWRules(output string) []string { + result := []string{} + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) + if trimmed == "" || strings.HasPrefix(trimmed, "Status:") || strings.HasPrefix(trimmed, "Logging:") || strings.HasPrefix(trimmed, "Default:") || strings.HasPrefix(trimmed, "New profiles:") || (strings.HasPrefix(lower, "to ") && strings.Contains(lower, "action") && strings.Contains(lower, "from")) || strings.HasPrefix(trimmed, "--") { + continue } + result = append(result, strings.Join(strings.Fields(trimmed), " ")) } return result } -func firstLine(value string) string { - lines := boundedLines(value, 1) - if len(lines) == 0 { - return "" +// firewallRuntimeMutationChanges reports whether a firewalld runtime command +// would alter a value that was present in the observed snapshot. It prevents +// a failed persistent write from undoing an operator's pre-existing rule. +func firewallRuntimeMutationChanges(snapshot FirewallSnapshot, operation FirewallOperation) bool { + switch operation.Action { + case "default-zone": + return snapshot.DefaultZone != operation.DefaultZone + case "add-service", "remove-service", "add-port", "remove-port", "add-source", "remove-source": + zone := operation.Zone + if zone == "" { + zone = snapshot.DefaultZone + } + present := firewallRuntimeRulePresent(snapshot.RuntimeRules, zone, operation) + return strings.HasPrefix(operation.Action, "add-") != present + default: + return false + } +} + +func firewallRuntimeRulePresent(rules []string, zone string, operation FirewallOperation) bool { + key, value := "", "" + switch { + case strings.HasSuffix(operation.Action, "service"): + key, value = "services", operation.Service + case strings.HasSuffix(operation.Action, "port"): + key, value = "ports", operation.Port + case strings.HasSuffix(operation.Action, "source"): + key, value = "sources", operation.Source + default: + return false + } + prefix := zone + " " + key + ":" + for _, rule := range rules { + if !strings.HasPrefix(rule, prefix) { + continue + } + for _, existing := range strings.Fields(strings.TrimSpace(strings.TrimPrefix(rule, prefix))) { + if existing == value { + return true + } + } } - return lines[0] + return false } func firewallErrorCode(err error) string { diff --git a/apps/backend/internal/platform/firewall_adapter.go b/apps/backend/internal/platform/firewall_adapter.go new file mode 100644 index 0000000..13c4d50 --- /dev/null +++ b/apps/backend/internal/platform/firewall_adapter.go @@ -0,0 +1,440 @@ +package platform + +import ( + "context" + "fmt" + "net" + "strconv" + "strings" +) + +// FirewallCommandRunner is the small command seam used by the firewall +// adapter. Production uses networkCommand, while tests provide deterministic +// command fixtures and therefore never mutate the host firewall. +type FirewallCommandRunner interface { + Run(context.Context, string, ...string) (string, error) +} + +// FirewallStrategy is the module-owned read/apply boundary. Keeping it +// separate from sessiond makes command planning and parser behavior testable. +type FirewallStrategy interface { + Read(context.Context) (FirewallSnapshot, error) + Apply(context.Context, FirewallOperation) (FirewallState, error) +} + +type firewallAdapter struct { + runner FirewallCommandRunner +} + +type systemFirewallCommandRunner struct{} + +func (systemFirewallCommandRunner) Run(ctx context.Context, name string, arguments ...string) (string, error) { + payload, err := networkCommand(ctx, name, arguments...) + return string(payload), err +} + +// NewFirewallStrategy returns a firewall adapter backed by runner. A nil +// runner selects the bounded production command executor. +func NewFirewallStrategy(runner FirewallCommandRunner) FirewallStrategy { + if runner == nil { + runner = systemFirewallCommandRunner{} + } + return &firewallAdapter{runner: runner} +} + +func (adapter *firewallAdapter) Read(ctx context.Context) (FirewallSnapshot, error) { + snapshot := FirewallSnapshot{Zones: []string{}, Rules: []string{}, RuntimeRules: []string{}, PersistentRules: []string{}} + firewalldState, firewalldErr := adapter.runner.Run(ctx, "firewall-cmd", "--state") + ufwOutput, ufwErr := adapter.runner.Run(ctx, "ufw", "status", "verbose") + firewalldReportedRunning := strings.EqualFold(strings.TrimSpace(firstLine(firewalldState)), "running") + firewalldActive := firewalldErr == nil && firewalldReportedRunning + ufwActive := ufwErr == nil && strings.HasPrefix(strings.ToLower(strings.TrimSpace(ufwOutput)), "status: active") + if firewalldReportedRunning && firewalldErr != nil { + if !ufwActive { + return snapshot, ErrFirewallUnavailable + } + snapshot.Backend = "firewalld+UFW" + snapshot.Active = true + snapshot.Conflicted = true + snapshot.ReadOnly = true + snapshot.Reason = "firewalld state could not be verified while UFW reported active; mutations are disabled." + snapshot.Rules = prefixRules("UFW", parseUFWRules(ufwOutput)) + snapshot.RuntimeRules = append([]string(nil), snapshot.Rules...) + snapshot.PersistentRules = append([]string(nil), snapshot.Rules...) + snapshot.Fingerprint = firewallFingerprint(snapshot) + return snapshot, nil + } + + switch { + case firewalldActive && ufwActive: + var readErr error + snapshot, readErr = adapter.readFirewalld(ctx, snapshot) + if readErr != nil { + return snapshot, readErr + } + ufwRules := parseUFWRules(ufwOutput) + snapshot.Backend = "firewalld+UFW" + snapshot.Conflicted = true + snapshot.ReadOnly = true + snapshot.Reason = "firewalld and UFW are both active; mutations are disabled until one manager is stopped." + snapshot.Rules = append(snapshot.Rules, prefixRules("UFW", ufwRules)...) + snapshot.RuntimeRules = append(snapshot.RuntimeRules, prefixRules("UFW", ufwRules)...) + snapshot.PersistentRules = append(snapshot.PersistentRules, prefixRules("UFW", ufwRules)...) + snapshot.Synchronized = false + case firewalldActive: + var readErr error + snapshot, readErr = adapter.readFirewalld(ctx, snapshot) + if readErr != nil { + return snapshot, readErr + } + case ufwActive: + snapshot = adapter.readUFW(ctx, snapshot, ufwOutput) + case firewalldErr == nil || ufwErr == nil: + snapshot.ReadOnly = true + snapshot.Synchronized = false + // A successful inactive status probe means the manager is installed, + // not that it owns the host. Only two active managers are a conflict; + // an inactive UFW must not prevent explicitly enabling firewalld. + if firewalldErr == nil { + snapshot.Backend = "firewalld" + snapshot.Reason = "firewalld is installed but did not report a running state." + } else { + snapshot.Backend = "UFW" + snapshot.Reason = "UFW is installed but did not report an active state." + } + default: + return snapshot, ErrFirewallUnavailable + } + + snapshot.Fingerprint = firewallFingerprint(snapshot) + return snapshot, nil +} + +func (adapter *firewallAdapter) readFirewalld(ctx context.Context, snapshot FirewallSnapshot) (FirewallSnapshot, error) { + snapshot.Backend = "firewalld" + snapshot.Active = true + if version, err := adapter.runner.Run(ctx, "firewall-cmd", "--version"); err == nil { + snapshot.Version = firstLine(version) + } + activeZones, err := adapter.runner.Run(ctx, "firewall-cmd", "--get-active-zones") + if err != nil { + return snapshot, fmt.Errorf("%w: active-zone inventory: %v", ErrFirewallUnavailable, err) + } + allZones, err := adapter.runner.Run(ctx, "firewall-cmd", "--get-zones") + if err != nil { + return snapshot, fmt.Errorf("%w: zone inventory: %v", ErrFirewallUnavailable, err) + } + snapshot.Zones = parseFirewalldZones(activeZones, allZones) + value, err := adapter.runner.Run(ctx, "firewall-cmd", "--get-default-zone") + if err != nil { + return snapshot, fmt.Errorf("%w: default zone: %v", ErrFirewallUnavailable, err) + } + snapshot.DefaultZone = strings.TrimSpace(firstLine(value)) + // firewalld documents --set-default-zone as a runtime and permanent + // operation. There is no separate persistent default-zone read to merge; + // recording the observed value in both views avoids a false drift signal. + snapshot.PersistentDefaultZone = snapshot.DefaultZone + for _, zone := range snapshot.Zones { + output, err := adapter.runner.Run(ctx, "firewall-cmd", "--zone="+zone, "--list-all") + if err != nil { + return snapshot, fmt.Errorf("%w: runtime zone %s: %v", ErrFirewallUnavailable, zone, err) + } + snapshot.RuntimeRules = append(snapshot.RuntimeRules, parseFirewalldRules(zone, output)...) + output, err = adapter.runner.Run(ctx, "firewall-cmd", "--permanent", "--zone="+zone, "--list-all") + if err != nil { + return snapshot, fmt.Errorf("%w: persistent zone %s: %v", ErrFirewallUnavailable, zone, err) + } + snapshot.PersistentRules = append(snapshot.PersistentRules, parseFirewalldRules(zone, output)...) + } + snapshot.RuntimeRules = sortedUnique(snapshot.RuntimeRules) + snapshot.PersistentRules = sortedUnique(snapshot.PersistentRules) + snapshot.Rules = append([]string(nil), snapshot.RuntimeRules...) + snapshot.Synchronized = snapshot.DefaultZone == snapshot.PersistentDefaultZone && equalStringSlices(snapshot.RuntimeRules, snapshot.PersistentRules) + return snapshot, nil +} + +func (adapter *firewallAdapter) readUFW(ctx context.Context, snapshot FirewallSnapshot, status string) FirewallSnapshot { + snapshot.Backend = "UFW" + snapshot.Active = true + if version, err := adapter.runner.Run(ctx, "ufw", "--version"); err == nil { + snapshot.Version = firstLine(version) + } + rules := parseUFWRules(status) + snapshot.Rules = append([]string(nil), rules...) + snapshot.RuntimeRules = append([]string(nil), rules...) + snapshot.PersistentRules = append([]string(nil), rules...) + snapshot.Synchronized = true + return snapshot +} + +func prefixRules(prefix string, rules []string) []string { + result := make([]string, 0, len(rules)) + for _, rule := range rules { + result = append(result, prefix+" "+rule) + } + return result +} + +func equalStringSlices(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func (adapter *firewallAdapter) Apply(ctx context.Context, operation FirewallOperation) (FirewallState, error) { + if err := ValidateFirewallOperation(operation); err != nil { + return FirewallState{}, err + } + snapshot, err := adapter.Read(ctx) + if err != nil { + return FirewallState{}, err + } + if operation.Backend == "auto" { + operation.Backend = snapshot.Backend + } + managerStart := operation.Action == "enable" + if snapshot.Conflicted || snapshot.Backend != operation.Backend || (!managerStart && (snapshot.ReadOnly || !snapshot.Active)) { + return FirewallState{}, ErrFirewallOwnership + } + if operation.ExpectedFingerprint != snapshot.Fingerprint { + return FirewallState{}, ErrFirewallConflict + } + + var warning string + switch operation.Backend { + case "firewalld": + warning, err = adapter.applyFirewalld(ctx, operation, snapshot) + case "UFW": + warning, err = adapter.applyUFW(ctx, operation) + default: + err = ErrFirewallUnavailable + } + if err != nil { + return FirewallState{}, err + } + updated, readErr := adapter.Read(ctx) + if readErr != nil { + return FirewallState{}, readErr + } + if updated.Backend != operation.Backend || updated.Conflicted || (managerStart && !updated.Active) { + return FirewallState{}, ErrFirewallOwnership + } + if err := verifyFirewallMutation(snapshot, updated, operation); err != nil { + return FirewallState{}, err + } + return FirewallState{Snapshot: updated, Action: operation.Action, Applied: true, Warning: warning}, nil +} + +func verifyFirewallMutation(before, after FirewallSnapshot, operation FirewallOperation) error { + switch operation.Action { + case "enable": + if !after.Active { + return ErrFirewallUnavailable + } + case "disable": + if after.Active { + return ErrFirewallUnavailable + } + case "default-zone": + if after.DefaultZone != operation.DefaultZone { + return ErrFirewallConflict + } + case "add-service", "remove-service", "add-port", "remove-port", "add-source", "remove-source": + wantPresent := strings.HasPrefix(operation.Action, "add-") + if operation.Backend == "firewalld" { + runtimePresent := firewallRuntimeRulePresent(after.RuntimeRules, operation.zoneOrDefault(after), operation) + beforeRuntimePresent := firewallRuntimeRulePresent(before.RuntimeRules, operation.zoneOrDefault(before), operation) + if beforeRuntimePresent != wantPresent && runtimePresent != wantPresent { + return ErrFirewallConflict + } + if operation.Persist { + persistentPresent := firewallRuntimeRulePresent(after.PersistentRules, operation.zoneOrDefault(after), operation) + if persistentPresent != wantPresent { + return ErrFirewallConflict + } + } + } else { + present := ufwRulePresent(after.Rules, operation) + if beforePresent := ufwRulePresent(before.Rules, operation); beforePresent != wantPresent && present != wantPresent { + return ErrFirewallConflict + } + } + } + return nil +} + +func (operation FirewallOperation) zoneOrDefault(snapshot FirewallSnapshot) string { + if operation.Zone != "" { + return operation.Zone + } + return snapshot.DefaultZone +} + +func ufwRulePresent(rules []string, operation FirewallOperation) bool { + needle := "" + switch { + case strings.HasSuffix(operation.Action, "service"): + needle = operation.Service + case strings.HasSuffix(operation.Action, "port"): + needle = operation.Port + case strings.HasSuffix(operation.Action, "source"): + needle = operation.Source + } + for _, rule := range rules { + fields := strings.Fields(rule) + for _, field := range fields { + if field == needle || ufwFieldMatchesService(field, needle) { + return true + } + } + } + return false +} + +func ufwFieldMatchesService(field, service string) bool { + if service == "" { + return false + } + for _, protocol := range []string{"tcp", "udp"} { + port, err := net.LookupPort(protocol, service) + if err == nil && field == strconv.Itoa(port)+"/"+protocol { + return true + } + } + return false +} + +func (adapter *firewallAdapter) applyFirewalld(ctx context.Context, operation FirewallOperation, snapshot FirewallSnapshot) (string, error) { + if operation.Action == "enable" || operation.Action == "disable" { + verb := "enable" + if operation.Action == "disable" { + verb = "disable" + } + if _, err := adapter.runner.Run(ctx, "systemctl", verb, "--now", "firewalld.service"); err != nil { + return "", err + } + return "Service state changed through systemd. Verify management access from a fresh session before continuing.", nil + } + + arguments, inverse, err := firewalldMutationArguments(operation, snapshot) + if err != nil { + return "", err + } + runtimeChanged := firewallRuntimeMutationChanges(snapshot, operation) + if _, err := adapter.runner.Run(ctx, "firewall-cmd", arguments...); err != nil { + return "", err + } + if operation.Action == "default-zone" { + return "firewalld updated the default zone in runtime and persistent configuration.", nil + } + if operation.Persist && operation.Action != "reload" && operation.Action != "default-zone" { + permanent := append([]string{"--permanent"}, arguments...) + if _, err := adapter.runner.Run(ctx, "firewall-cmd", permanent...); err != nil { + // The runtime mutation is ours and is therefore safe to undo. This + // targeted inverse preserves all unrelated operator rules. + if runtimeChanged { + _, _ = adapter.runner.Run(ctx, "firewall-cmd", inverse...) + } + return "", err + } + return "Runtime and persistent firewalld state were updated with targeted rules; unmanaged rules were preserved.", nil + } + if operation.Action == "reload" { + return "firewalld reloaded its configured policy; verify management access from a fresh session.", nil + } + return "Runtime firewalld state changed. Persistent state remains unchanged until persistence is explicitly requested.", nil +} + +func firewalldMutationArguments(operation FirewallOperation, snapshot FirewallSnapshot) ([]string, []string, error) { + zone := operation.Zone + if zone == "" { + zone = snapshot.DefaultZone + } + if operation.Action == "reload" { + return []string{"--reload"}, nil, nil + } + if operation.Action == "default-zone" { + return []string{"--set-default-zone=" + operation.DefaultZone}, []string{"--set-default-zone=" + snapshot.DefaultZone}, nil + } + if zone == "" { + return nil, nil, ErrFirewallUnavailable + } + var kind, value string + switch { + case strings.HasSuffix(operation.Action, "service"): + kind, value = "service", operation.Service + case strings.HasSuffix(operation.Action, "port"): + kind, value = "port", operation.Port + case strings.HasSuffix(operation.Action, "source"): + kind, value = "source", operation.Source + default: + return nil, nil, ErrInvalidFirewallOperation + } + verb := "add-" + kind + if strings.HasPrefix(operation.Action, "remove-") { + verb = "remove-" + kind + } + arguments := []string{"--zone=" + zone, "--" + verb + "=" + value} + inverseVerb := "add-" + kind + if strings.HasPrefix(operation.Action, "add-") { + inverseVerb = "remove-" + kind + } + inverse := []string{"--zone=" + zone, "--" + inverseVerb + "=" + value} + return arguments, inverse, nil +} + +func (adapter *firewallAdapter) applyUFW(ctx context.Context, operation FirewallOperation) (string, error) { + switch operation.Action { + case "enable", "disable", "reload": + if _, err := adapter.runner.Run(ctx, "ufw", operation.Action); err != nil { + return "", err + } + if operation.Action == "disable" { + return "UFW was disabled. Verify management access from a fresh session before continuing.", nil + } + return "UFW changed its active policy; verify management access from a fresh session.", nil + case "add-port": + if _, err := adapter.runner.Run(ctx, "ufw", "allow", operation.Port); err != nil { + return "", err + } + return "UFW stores this rule in its persistent policy and applied it to the active firewall.", nil + case "remove-port": + if _, err := adapter.runner.Run(ctx, "ufw", "delete", "allow", operation.Port); err != nil { + return "", err + } + return "UFW removed this rule from its persistent policy and active firewall.", nil + case "add-service": + if _, err := adapter.runner.Run(ctx, "ufw", "allow", operation.Service); err != nil { + return "", err + } + return "UFW stores this service rule in its persistent policy and applied it to the active firewall.", nil + case "remove-service": + if _, err := adapter.runner.Run(ctx, "ufw", "delete", "allow", operation.Service); err != nil { + return "", err + } + return "UFW removed this service rule from its persistent policy and active firewall.", nil + case "add-source": + if _, err := adapter.runner.Run(ctx, "ufw", "allow", "from", operation.Source); err != nil { + return "", err + } + return "UFW stores this source rule in its persistent policy and applied it to the active firewall.", nil + case "remove-source": + if _, err := adapter.runner.Run(ctx, "ufw", "delete", "allow", "from", operation.Source); err != nil { + return "", err + } + return "UFW removed this source rule from its persistent policy and active firewall.", nil + default: + return "", ErrFirewallUnavailable + } +} + +func firewallCommand(ctx context.Context, name string, arguments ...string) (string, error) { + payload, err := networkCommand(ctx, name, arguments...) + return string(payload), err +} diff --git a/apps/backend/internal/platform/firewall_adapter_test.go b/apps/backend/internal/platform/firewall_adapter_test.go new file mode 100644 index 0000000..604c0ea --- /dev/null +++ b/apps/backend/internal/platform/firewall_adapter_test.go @@ -0,0 +1,257 @@ +package platform + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" +) + +type firewallFixtureResult struct { + output string + err error +} + +type firewallFixtureRunner struct { + results map[string]firewallFixtureResult + calls []string + hook func(*firewallFixtureRunner, string) +} + +func (runner *firewallFixtureRunner) Run(_ context.Context, name string, arguments ...string) (string, error) { + key := name + " " + strings.Join(arguments, " ") + runner.calls = append(runner.calls, key) + if runner.hook != nil { + runner.hook(runner, key) + } + result, ok := runner.results[key] + if !ok { + return "", errors.New("unconfigured fixture command: " + key) + } + return result.output, result.err +} + +func firewalldFixture(runtime, permanent string) *firewallFixtureRunner { + errorResult := firewallFixtureResult{err: errors.New("command unavailable")} + results := map[string]firewallFixtureResult{ + "firewall-cmd --state": {output: "running\n"}, + "ufw status verbose": errorResult, + "firewall-cmd --version": {output: "firewalld 1.3.4\n"}, + "firewall-cmd --get-active-zones": {output: "public\n interfaces: eth0\n"}, + "firewall-cmd --get-zones": {output: "public home\n"}, + "firewall-cmd --get-default-zone": {output: "public\n"}, + "firewall-cmd --zone=public --list-all": {output: runtime}, + "firewall-cmd --permanent --zone=public --list-all": {output: permanent}, + "firewall-cmd --zone=home --list-all": {output: "home\n target: default\n services: \n ports: \n"}, + "firewall-cmd --permanent --zone=home --list-all": {output: "home\n target: default\n services: \n ports: \n"}, + } + return &firewallFixtureRunner{results: results} +} + +func TestFirewallReadInventoriesRuntimeAndPersistentFirewalldRules(t *testing.T) { + runtime := `public (active) + target: default + services: dhcpv6-client ssh + ports: 8080/tcp + rich rules: + rule family="ipv4" source address="192.0.2.0/24" accept` + permanent := `public + target: default + services: ssh + ports: + rich rules:` + runner := firewalldFixture(runtime, permanent) + snapshot, err := NewFirewallStrategy(runner).Read(context.Background()) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if snapshot.Backend != "firewalld" || !snapshot.Active || snapshot.Synchronized { + t.Fatalf("unexpected manager state: %#v", snapshot) + } + if !contains(snapshot.Rules, `public services: dhcpv6-client ssh`) || !contains(snapshot.Rules, "public ports: 8080/tcp") { + t.Fatalf("runtime inventory omitted actual rules: %#v", snapshot.Rules) + } + if !contains(snapshot.PersistentRules, "public services: ssh") || contains(snapshot.PersistentRules, "public ports: 8080/tcp") { + t.Fatalf("persistent inventory incorrect: %#v", snapshot.PersistentRules) + } + if len(snapshot.Fingerprint) != 64 { + t.Fatalf("fingerprint length = %d", len(snapshot.Fingerprint)) + } + + // Zone and list ordering must not cause a fingerprint change. + runner.results["firewall-cmd --get-active-zones"] = firewallFixtureResult{output: "public\n interfaces: eth0\n"} + runner.results["firewall-cmd --get-zones"] = firewallFixtureResult{output: "home public\n"} + runner.results["firewall-cmd --zone=public --list-all"] = firewallFixtureResult{output: runtime} + runner.results["firewall-cmd --permanent --zone=public --list-all"] = firewallFixtureResult{output: permanent} + second, err := NewFirewallStrategy(runner).Read(context.Background()) + if err != nil { + t.Fatalf("second Read() error = %v", err) + } + if second.Fingerprint != snapshot.Fingerprint { + t.Fatalf("fingerprint changed with equivalent ordering: %s != %s", second.Fingerprint, snapshot.Fingerprint) + } +} + +func TestFirewallStateErrorsDoNotLookLikeActiveFirewalld(t *testing.T) { + runner := &firewallFixtureRunner{results: map[string]firewallFixtureResult{ + "firewall-cmd --state": {output: "running\n", err: errors.New("dbus unavailable")}, + "ufw status verbose": {err: errors.New("ufw unavailable")}, + }} + if _, err := NewFirewallStrategy(runner).Read(context.Background()); !errors.Is(err, ErrFirewallUnavailable) { + t.Fatalf("state command failure error = %v", err) + } +} + +func TestFirewallEnableStartsInactiveFirewalldThroughSystemd(t *testing.T) { + runner := firewalldFixture("public\n target: default\n services: ssh\n ports:\n", "public\n target: default\n services: ssh\n ports:\n") + runner.results["firewall-cmd --state"] = firewallFixtureResult{output: "not running\n"} + runner.results["systemctl enable --now firewalld.service"] = firewallFixtureResult{output: ""} + runner.hook = func(r *firewallFixtureRunner, key string) { + if key == "systemctl enable --now firewalld.service" { + r.results["firewall-cmd --state"] = firewallFixtureResult{output: "running\n"} + } + } + before, err := NewFirewallStrategy(runner).Read(context.Background()) + if err != nil { + t.Fatalf("inactive Read() error = %v", err) + } + operation := FirewallOperation{Backend: "firewalld", Action: "enable", ExpectedFingerprint: before.Fingerprint, Confirmation: "CONFIRM FIREWALL CHANGE"} + state, err := NewFirewallStrategy(runner).Apply(context.Background(), operation) + if err != nil { + t.Fatalf("enable error = %v", err) + } + if !state.Applied || !state.Snapshot.Active { + t.Fatalf("enable state = %#v", state) + } + if !contains(runner.calls, "systemctl enable --now firewalld.service") { + t.Fatalf("systemd enable command not used: %#v", runner.calls) + } + for _, call := range runner.calls { + if strings.Contains(call, "set-log-denied") { + t.Fatalf("enable changed logging instead of service state: %#v", runner.calls) + } + } +} + +func TestInactiveUFWDoesNotConflictWithExplicitFirewalldEnable(t *testing.T) { + runner := firewalldFixture("public\n target: default\n services: ssh\n ports:\n", "public\n target: default\n services: ssh\n ports:\n") + runner.results["firewall-cmd --state"] = firewallFixtureResult{output: "not running\n"} + runner.results["ufw status verbose"] = firewallFixtureResult{output: "Status: inactive\n"} + runner.results["systemctl enable --now firewalld.service"] = firewallFixtureResult{} + runner.hook = func(r *firewallFixtureRunner, key string) { + if key == "systemctl enable --now firewalld.service" { + r.results["firewall-cmd --state"] = firewallFixtureResult{output: "running\n"} + } + } + before, err := NewFirewallStrategy(runner).Read(context.Background()) + if err != nil || before.Backend != "firewalld" || before.Conflicted { + t.Fatalf("inactive manager state = %#v, error = %v", before, err) + } + if _, err := NewFirewallStrategy(runner).Apply(context.Background(), FirewallOperation{Backend: "firewalld", Action: "enable", ExpectedFingerprint: before.Fingerprint, Confirmation: "CONFIRM FIREWALL CHANGE"}); err != nil { + t.Fatalf("explicit enable with inactive UFW error = %v", err) + } +} + +func TestFirewallPersistentMutationUsesBothStoresAndPreservesUnmanagedRules(t *testing.T) { + runtime := "public\n target: default\n services: ssh\n ports:\n" + permanent := "public\n target: default\n services: ssh\n ports:\n" + runner := firewalldFixture(runtime, permanent) + runner.results["firewall-cmd --zone=public --add-port=8443/tcp"] = firewallFixtureResult{output: "success\n"} + runner.results["firewall-cmd --permanent --zone=public --add-port=8443/tcp"] = firewallFixtureResult{output: "success\n"} + addedRuntime, addedPermanent := false, false + runner.hook = func(r *firewallFixtureRunner, key string) { + switch key { + case "firewall-cmd --zone=public --add-port=8443/tcp": + addedRuntime = true + case "firewall-cmd --permanent --zone=public --add-port=8443/tcp": + addedPermanent = true + } + if strings.Contains(key, "--zone=public --list-all") && !strings.Contains(key, "--permanent") { + value := runtime + if addedRuntime { + value = "public\n target: default\n services: ssh\n ports: 8443/tcp\n" + } + r.results[key] = firewallFixtureResult{output: value} + } + if strings.Contains(key, "--permanent --zone=public --list-all") { + value := permanent + if addedPermanent { + value = "public\n target: default\n services: ssh\n ports: 8443/tcp\n" + } + r.results[key] = firewallFixtureResult{output: value} + } + } + before, err := NewFirewallStrategy(runner).Read(context.Background()) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + state, err := NewFirewallStrategy(runner).Apply(context.Background(), FirewallOperation{ + Backend: "firewalld", Action: "add-port", Port: "8443/tcp", Persist: true, + ExpectedFingerprint: before.Fingerprint, Confirmation: "CONFIRM FIREWALL CHANGE", + }) + if err != nil { + t.Fatalf("persistent add error = %v", err) + } + if !state.Applied || !contains(state.Snapshot.PersistentRules, "public ports: 8443/tcp") { + t.Fatalf("persistent add state = %#v", state) + } + if !contains(runner.calls, "firewall-cmd --zone=public --add-port=8443/tcp") || !contains(runner.calls, "firewall-cmd --permanent --zone=public --add-port=8443/tcp") { + t.Fatalf("runtime and permanent commands missing: %#v", runner.calls) + } + + // A pre-existing runtime rule must never be removed by rollback when the + // permanent command fails. + preexisting := firewalldFixture("public\n target: default\n services: ssh\n ports: 22/tcp\n", permanent) + preexisting.results["firewall-cmd --zone=public --add-port=22/tcp"] = firewallFixtureResult{output: "success\n"} + preexisting.results["firewall-cmd --permanent --zone=public --add-port=22/tcp"] = firewallFixtureResult{err: errors.New("permanent write failed")} + before, err = NewFirewallStrategy(preexisting).Read(context.Background()) + if err != nil { + t.Fatalf("pre-existing Read() error = %v", err) + } + _, err = NewFirewallStrategy(preexisting).Apply(context.Background(), FirewallOperation{ + Backend: "firewalld", Action: "add-port", Port: "22/tcp", Persist: true, + ExpectedFingerprint: before.Fingerprint, Confirmation: "CONFIRM FIREWALL CHANGE", + }) + if err == nil { + t.Fatal("permanent failure was reported as success") + } + for _, call := range preexisting.calls { + if call == "firewall-cmd --zone=public --remove-port=22/tcp" { + t.Fatalf("rollback removed a pre-existing rule: %#v", preexisting.calls) + } + } +} + +func TestUFWServiceAndSourceCommandsAreBounded(t *testing.T) { + runner := &firewallFixtureRunner{results: map[string]firewallFixtureResult{}} + runner.results["ufw allow ssh"] = firewallFixtureResult{output: "Rule added\n"} + runner.results["ufw delete allow from 192.0.2.0/24"] = firewallFixtureResult{output: "Rule deleted\n"} + adapter := NewFirewallStrategy(runner).(*firewallAdapter) + if _, err := adapter.applyUFW(context.Background(), FirewallOperation{Action: "add-service", Service: "ssh"}); err != nil { + t.Fatalf("add service error = %v", err) + } + if _, err := adapter.applyUFW(context.Background(), FirewallOperation{Action: "remove-source", Source: "192.0.2.0/24"}); err != nil { + t.Fatalf("remove source error = %v", err) + } + want := []string{"ufw allow ssh", "ufw delete allow from 192.0.2.0/24"} + if !reflect.DeepEqual(runner.calls, want) { + t.Fatalf("commands = %#v, want %#v", runner.calls, want) + } + if err := ValidateFirewallOperation(FirewallOperation{Backend: "UFW", Action: "add-source", Source: "192.0.2.0/24", ExpectedFingerprint: "fingerprint", Confirmation: "CONFIRM FIREWALL CHANGE"}); err != nil { + t.Fatalf("UFW source validation error = %v", err) + } +} + +func TestFirewalldDefaultZoneUsesDocumentedCombinedOperation(t *testing.T) { + runner := &firewallFixtureRunner{results: map[string]firewallFixtureResult{ + "firewall-cmd --set-default-zone=home": {output: "success\n"}, + }} + adapter := NewFirewallStrategy(runner).(*firewallAdapter) + if _, err := adapter.applyFirewalld(context.Background(), FirewallOperation{Action: "default-zone", DefaultZone: "home", Persist: true}, FirewallSnapshot{Backend: "firewalld", Active: true, DefaultZone: "public"}); err != nil { + t.Fatalf("default-zone error = %v", err) + } + if !reflect.DeepEqual(runner.calls, []string{"firewall-cmd --set-default-zone=home"}) { + t.Fatalf("default-zone commands = %#v", runner.calls) + } +} From 1fb706af9d5b467dee9a0f79c7faeaf3865b0a00 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:56:54 +0530 Subject: [PATCH 16/88] fix(platform): harden SELinux and AppArmor reads and mutations Bound security command output, add AppArmor profile modes and denial findings, and strictly validate inspect/boolean/restorecon/enforce/load actions. Pin restorecon through the file root with matchpathcon checks and load AppArmor profiles only from trusted directories. --- apps/backend/internal/platform/security.go | 375 +++++++++++------- .../internal/platform/security_adapter.go | 242 +++++++++++ 2 files changed, 469 insertions(+), 148 deletions(-) create mode 100644 apps/backend/internal/platform/security_adapter.go diff --git a/apps/backend/internal/platform/security.go b/apps/backend/internal/platform/security.go index 54103ae..d909faa 100644 --- a/apps/backend/internal/platform/security.go +++ b/apps/backend/internal/platform/security.go @@ -2,10 +2,11 @@ package platform import ( "context" + "encoding/json" "errors" - "os" "path/filepath" "regexp" + "sort" "strings" ) @@ -35,10 +36,11 @@ type SELinuxStatus struct { } type AppArmorStatus struct { - KernelPresent bool `json:"kernelPresent"` - Userspace bool `json:"userspace"` - Profiles []string `json:"profiles"` - Denials []string `json:"denials"` + KernelPresent bool `json:"kernelPresent"` + Userspace bool `json:"userspace"` + Profiles []string `json:"profiles"` + ProfileModes map[string]string `json:"profileModes,omitempty"` + Denials []string `json:"denials"` } type SecurityStatus struct { @@ -60,131 +62,78 @@ type SecurityOperation struct { Confirmation string `json:"confirmation,omitempty"` } +var ( + securityNamePattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`) + profileNamePattern = regexp.MustCompile(`^[A-Za-z0-9_./:@+-]+$`) + getseboolPattern = regexp.MustCompile(`^\s*([A-Za-z0-9_]+)\s+-->\s+(on|off)\s*$`) + semanageBoolPattern = regexp.MustCompile(`^\s*([A-Za-z0-9_]+)\s+\(\s*(on|off)\s*,\s*(on|off)\s*\)`) +) + func ReadSecurityStatus(ctx context.Context) (SecurityStatus, error) { - status := SecurityStatus{Findings: []SecurityFinding{}} - status.SELinux = readSELinuxStatus(ctx) - status.AppArmor = readAppArmorStatus(ctx) - if status.SELinux.Mode != "" && status.SELinux.Mode != "Disabled" { - status.Active = "SELinux" - } - if len(status.AppArmor.Profiles) > 0 { - if status.Active != "" { - status.Active = "SELinux+AppArmor" - } else { - status.Active = "AppArmor" - } - } - if status.SELinux.KernelPresent && !status.SELinux.Userspace { - status.Findings = append(status.Findings, SecurityFinding{Framework: "SELinux", Kind: "userspace", Subject: "SELinux tools", Message: "SELinux kernel support is present but userspace inspection tools are unavailable.", Severity: "warning", Guidance: "Install policycoreutils to inspect labels and booleans."}) - } - if status.AppArmor.KernelPresent && !status.AppArmor.Userspace { - status.Findings = append(status.Findings, SecurityFinding{Framework: "AppArmor", Kind: "userspace", Subject: "AppArmor tools", Message: "AppArmor kernel support is present but aa-status is unavailable.", Severity: "warning", Guidance: "Install apparmor-utils to inspect profiles."}) - } - status.Fingerprint = fingerprintBytes([]byte(status.Active + "|" + status.SELinux.Mode + "|" + strings.Join(status.AppArmor.Profiles, "\n"))) - return status, nil + return NewSecurityStrategy(nil).Read(ctx) } -func readSELinuxStatus(ctx context.Context) SELinuxStatus { - status := SELinuxStatus{KernelPresent: fileExists("/sys/fs/selinux"), Booleans: []string{}, Denials: []string{}} - output, err := securityCommand(ctx, "getenforce") - if err == nil { - status.Userspace = true - status.Mode = strings.TrimSpace(firstLine(output)) - } - if output, err := securityCommand(ctx, "sestatus"); err == nil { - for _, line := range strings.Split(output, "\n") { - fields := strings.SplitN(line, ":", 2) - if len(fields) != 2 { - continue - } - switch strings.TrimSpace(fields[0]) { - case "SELinux policy": - status.Policy = strings.TrimSpace(fields[1]) - case "Current mode": - if status.Mode == "" { - status.Mode = strings.TrimSpace(fields[1]) - } - } - } - } - if output, err := securityCommand(ctx, "semanage", "boolean", "-l"); err == nil { - for _, line := range boundedLines(output, 512) { - fields := strings.Fields(line) - if len(fields) >= 2 && (fields[1] == "on" || fields[1] == "off") { - status.Booleans = append(status.Booleans, fields[0]+"="+fields[1]) - } - } +func ValidateSecurityOperation(operation SecurityOperation) error { + actions := map[string]bool{ + "inspect": true, + "selinux-boolean": true, + "selinux-restorecon": true, + "apparmor-enforce": true, + "apparmor-complain": true, + "apparmor-load": true, } - return status -} - -func readAppArmorStatus(ctx context.Context) AppArmorStatus { - status := AppArmorStatus{KernelPresent: fileExists("/sys/module/apparmor"), Profiles: []string{}, Denials: []string{}} - output, err := securityCommand(ctx, "aa-status", "--profiled") - if err == nil { - status.Userspace = true - for _, line := range boundedLines(output, 2048) { - line = strings.TrimSpace(line) - if line != "" { - status.Profiles = append(status.Profiles, line) - } - } + if !actions[operation.Action] || (operation.Framework != "SELinux" && operation.Framework != "AppArmor") { + return ErrInvalidSecurityOperation } - return status -} - -func ValidateSecurityOperation(operation SecurityOperation) error { - actions := map[string]bool{"inspect": true, "selinux-boolean": true, "selinux-restorecon": true, "apparmor-enforce": true, "apparmor-complain": true, "apparmor-load": true} - if !actions[operation.Action] || (operation.Framework != "SELinux" && operation.Framework != "AppArmor") || len(operation.Boolean) > 128 || len(operation.Path) > 4096 || len(operation.Profile) > 256 || len(operation.ExpectedFingerprint) > 128 || strings.ContainsAny(operation.Boolean+operation.Path+operation.Profile, "\x00\r\n") { + if len(operation.Boolean) > 128 || len(operation.Path) > 4096 || len(operation.Profile) > 256 || len(operation.ExpectedFingerprint) > 128 || len(operation.Confirmation) > 128 || strings.ContainsAny(operation.Boolean+operation.Path+operation.Profile+operation.ExpectedFingerprint+operation.Confirmation, "\x00\r\n") { return ErrInvalidSecurityOperation } if operation.Action == "inspect" { + if operation.Boolean != "" || operation.Path != "" || operation.Profile != "" { + return ErrInvalidSecurityOperation + } return nil } if operation.ExpectedFingerprint == "" || operation.Confirmation != "CONFIRM NARROW SECURITY CHANGE" { return ErrInvalidSecurityOperation } - if operation.Action == "selinux-boolean" { - if !regexp.MustCompile(`^[a-zA-Z0-9_]+$`).MatchString(operation.Boolean) { + switch operation.Action { + case "selinux-boolean": + if operation.Framework != "SELinux" || !securityNamePattern.MatchString(operation.Boolean) || operation.Path != "" || operation.Profile != "" { return ErrInvalidSecurityOperation } - } - if operation.Action == "selinux-restorecon" { - if operation.Path == "" || operation.Path == "/" || filepath.Clean(operation.Path) != operation.Path { + case "selinux-restorecon": + if operation.Framework != "SELinux" || !safeRestoreconPath(operation.Path) || operation.Boolean != "" || operation.Profile != "" { return ErrSecurityUnsafe } - } - if strings.HasPrefix(operation.Action, "apparmor-") && operation.Action != "apparmor-load" && !regexp.MustCompile(`^[a-zA-Z0-9_./-]+$`).MatchString(operation.Profile) { + case "apparmor-enforce", "apparmor-complain": + if operation.Framework != "AppArmor" || !profileNamePattern.MatchString(operation.Profile) || strings.Contains(operation.Profile, "..") || strings.HasPrefix(operation.Profile, "-") || operation.Path != "" || operation.Boolean != "" { + return ErrInvalidSecurityOperation + } + case "apparmor-load": + if operation.Framework != "AppArmor" || operation.Boolean != "" || operation.Profile != "" || !trustedAppArmorProfile(operation.Path) { + return ErrSecurityUnsafe + } + default: return ErrInvalidSecurityOperation } - if operation.Action == "apparmor-load" && !trustedAppArmorProfile(operation.Path) { - return ErrSecurityUnsafe - } return nil } -func trustedAppArmorProfile(path string) bool { - if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path { - return false - } - resolved, err := filepath.EvalSymlinks(path) - if err != nil { - return false - } - info, err := os.Stat(resolved) - if err != nil || !info.Mode().IsRegular() || info.Size() > 1<<20 { +func safeRestoreconPath(path string) bool { + if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" || strings.ContainsAny(path, "*?[]{}") { return false } - for _, root := range []string{"/etc/apparmor.d", "/usr/lib/apparmor.d", "/lib/apparmor.d"} { - if filePathWithin(root, resolved) { - return true + for _, blocked := range []string{"/proc", "/sys", "/dev", "/run"} { + if filePathWithin(blocked, path) { + return false } } - return false + return true } func PreviewSecurityOperation(ctx context.Context, operation SecurityOperation) (SecurityStatus, error) { - operation.Action = "inspect" + operation = SecurityOperation{Action: "inspect", Framework: operation.Framework} if err := ValidateSecurityOperation(operation); err != nil { return SecurityStatus{}, err } @@ -192,68 +141,198 @@ func PreviewSecurityOperation(ctx context.Context, operation SecurityOperation) } func ApplySecurityOperation(ctx context.Context, operation SecurityOperation) (SecurityStatus, error) { - if err := ValidateSecurityOperation(operation); err != nil { - return SecurityStatus{}, err + return NewSecurityStrategy(nil).Apply(ctx, operation) +} + +type securityFingerprintInput struct { + SELinux SELinuxStatus + AppArmor AppArmorStatus + Active string +} + +func securityFingerprint(status SecurityStatus) string { + status.SELinux.Booleans = sortedUnique(status.SELinux.Booleans) + status.SELinux.Denials = nil + status.AppArmor.Profiles = sortedUnique(status.AppArmor.Profiles) + status.AppArmor.Denials = nil + payload, _ := json.Marshal(securityFingerprintInput{SELinux: status.SELinux, AppArmor: status.AppArmor, Active: status.Active}) + return fingerprintBytes(payload) +} + +func normalizeSELinuxMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "enforcing": + return "Enforcing" + case "permissive": + return "Permissive" + case "disabled": + return "Disabled" + default: + return strings.TrimSpace(value) } - current, err := ReadSecurityStatus(ctx) - if err != nil { - return SecurityStatus{}, err +} + +func parseSELinuxBooleans(getsebool, semanage string) []string { + values := map[string]string{} + for _, line := range strings.Split(getsebool, "\n") { + match := getseboolPattern.FindStringSubmatch(line) + if len(match) == 3 { + values[match[1]] = match[2] + } } - if current.Fingerprint != operation.ExpectedFingerprint { - return SecurityStatus{}, ErrSecurityConflict + for _, line := range strings.Split(semanage, "\n") { + match := semanageBoolPattern.FindStringSubmatch(line) + if len(match) == 4 { + if _, exists := values[match[1]]; !exists { + values[match[1]] = match[2] + } + } } - var command string - var arguments []string - switch operation.Action { - case "selinux-boolean": - if current.SELinux.Mode == "Disabled" || !current.SELinux.Userspace { - return SecurityStatus{}, ErrSecurityUnavailable + result := make([]string, 0, len(values)) + for name, value := range values { + result = append(result, name+"="+value) + } + sort.Strings(result) + return result +} + +func parsePolicyDenials(output string, framework string) []string { + result := []string{} + seen := map[string]bool{} + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue } - known := false - for _, boolean := range current.SELinux.Booleans { - if strings.HasPrefix(boolean, operation.Boolean+"=") { - known = true - break - } + lower := strings.ToLower(line) + matched := false + if framework == "SELinux" { + matched = strings.Contains(lower, "type=avc") || strings.Contains(lower, "avc:") && strings.Contains(lower, "denied") + } else { + matched = strings.Contains(lower, "apparmor=") && strings.Contains(lower, "denied") || strings.Contains(lower, "apparmor.*denied") } - if !known { - return SecurityStatus{}, ErrSecurityUnsafe + if !matched { + continue } - command, arguments = "setsebool", []string{"-P", operation.Boolean, map[bool]string{true: "on", false: "off"}[operation.Value]} - case "selinux-restorecon": - if !current.SELinux.Userspace { - return SecurityStatus{}, ErrSecurityUnavailable + line = strings.Join(strings.Fields(line), " ") + if len(line) > 1024 { + line = line[:1024] } - command, arguments = "restorecon", []string{"-v", "--", operation.Path} - case "apparmor-enforce", "apparmor-complain": - if !current.AppArmor.Userspace { - return SecurityStatus{}, ErrSecurityUnavailable + if !seen[line] { + seen[line] = true + result = append(result, line) } - if !contains(current.AppArmor.Profiles, operation.Profile) { - return SecurityStatus{}, ErrSecurityUnsafe + if len(result) >= 256 { + break } - command, arguments = "aa-"+strings.TrimPrefix(operation.Action, "apparmor-"), []string{"--", operation.Profile} - case "apparmor-load": - if !current.AppArmor.Userspace || operation.Path == "" { - return SecurityStatus{}, ErrSecurityUnavailable + } + return result +} + +func readSELinuxStatus(ctx context.Context) SELinuxStatus { + return readSELinuxStatusWithRunner(ctx, systemSecurityCommandRunner{}) +} + +func readSELinuxStatusWithRunner(ctx context.Context, runner SecurityCommandRunner) SELinuxStatus { + status := SELinuxStatus{KernelPresent: fileExists("/sys/fs/selinux"), Booleans: []string{}, Denials: []string{}} + getenforceOutput, getenforceErr := runner.Run(ctx, "getenforce") + if getenforceErr == nil { + mode := normalizeSELinuxMode(firstLine(getenforceOutput)) + if mode != "" { + status.Userspace = true + status.Mode = mode } - command, arguments = "apparmor_parser", []string{"-r", "--", operation.Path} - default: - return SecurityStatus{}, ErrInvalidSecurityOperation } - if _, err := securityCommand(ctx, command, arguments...); err != nil { - return SecurityStatus{}, err + if output, err := runner.Run(ctx, "sestatus"); err == nil { + status.Userspace = true + for _, line := range strings.Split(output, "\n") { + fields := strings.SplitN(line, ":", 2) + if len(fields) != 2 { + continue + } + key := strings.ToLower(strings.TrimSpace(fields[0])) + value := strings.TrimSpace(fields[1]) + switch key { + case "selinux status": + if strings.EqualFold(value, "disabled") { + status.Mode = "Disabled" + } + case "current mode": + status.Mode = normalizeSELinuxMode(value) + case "loaded policy name", "selinux policy": + status.Policy = value + } + } } - updated, err := ReadSecurityStatus(ctx) - if err != nil { - return SecurityStatus{}, err + getsebool, _ := runner.Run(ctx, "getsebool", "-a") + semanage, _ := runner.Run(ctx, "semanage", "boolean", "-l") + status.Booleans = parseSELinuxBooleans(getsebool, semanage) + status.Denials = readSecurityDenials(ctx, runner, "SELinux") + return status +} + +func readAppArmorStatus(ctx context.Context) AppArmorStatus { + return readAppArmorStatusWithRunner(ctx, systemSecurityCommandRunner{}) +} + +func readAppArmorStatusWithRunner(ctx context.Context, runner SecurityCommandRunner) AppArmorStatus { + status := AppArmorStatus{KernelPresent: fileExists("/sys/module/apparmor"), Profiles: []string{}, ProfileModes: map[string]string{}, Denials: []string{}} + if output, err := runner.Run(ctx, "aa-status", "--json"); err == nil { + var document struct { + Profiles map[string]string `json:"profiles"` + } + if json.Unmarshal([]byte(output), &document) == nil && document.Profiles != nil { + status.Userspace = true + for profile, mode := range document.Profiles { + status.Profiles = append(status.Profiles, profile) + status.ProfileModes[profile] = mode + } + } } - return updated, nil + sort.Strings(status.Profiles) + status.Denials = readSecurityDenials(ctx, runner, "AppArmor") + return status } -func securityCommand(ctx context.Context, name string, arguments ...string) (string, error) { - payload, err := networkCommand(ctx, name, arguments...) - return string(payload), err +func parseAppArmorProfiles(output string) []string { + result := []string{} + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.Contains(line, "profiles are ") || strings.Contains(line, "processes are ") { + continue + } + if strings.HasSuffix(line, ":") { + continue + } + result = append(result, line) + } + return sortedUnique(result) +} + +func readSecurityDenials(ctx context.Context, runner SecurityCommandRunner, framework string) []string { + if framework == "SELinux" { + if output, err := runner.Run(ctx, "ausearch", "-m", "avc", "-ts", "recent", "-i"); err == nil { + if denials := parsePolicyDenials(output, framework); len(denials) > 0 { + return denials + } + } + output, _ := runner.Run(ctx, "journalctl", "-k", "--no-pager", "-g", "avc:.*denied", "-n", "256") + return parsePolicyDenials(output, framework) + } + output, _ := runner.Run(ctx, "journalctl", "-k", "--no-pager", "-g", `apparmor="DENIED"`, "-n", "256") + return parsePolicyDenials(output, framework) +} + +func trustedAppArmorProfile(path string) bool { + if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.ContainsAny(path, "\x00\r\n") { + return false + } + for _, root := range []string{"/etc/apparmor.d", "/usr/lib/apparmor.d"} { + if path != root && filePathWithin(root, path) { + return true + } + } + return false } func securityErrorCode(err error) string { diff --git a/apps/backend/internal/platform/security_adapter.go b/apps/backend/internal/platform/security_adapter.go new file mode 100644 index 0000000..b1e25ea --- /dev/null +++ b/apps/backend/internal/platform/security_adapter.go @@ -0,0 +1,242 @@ +package platform + +import ( + "context" + "golang.org/x/sys/unix" + "io" + "os/exec" + "strings" + "syscall" + "time" +) + +// SecurityCommandRunner is the command seam for SELinux and AppArmor. Tests +// inject fixture responses; production uses the same bounded executor as the +// other host adapters. +type SecurityCommandRunner interface { + Run(context.Context, string, ...string) (string, error) +} + +type SecurityStrategy interface { + Read(context.Context) (SecurityStatus, error) + Apply(context.Context, SecurityOperation) (SecurityStatus, error) +} + +type securityAdapter struct { + runner SecurityCommandRunner +} + +type systemSecurityCommandRunner struct{} + +func (systemSecurityCommandRunner) Run(ctx context.Context, name string, arguments ...string) (string, error) { + return runSecurityCommand(ctx, nil, name, arguments...) +} + +// NewSecurityStrategy returns a security adapter backed by runner. A nil +// runner selects the bounded production command executor. +func NewSecurityStrategy(runner SecurityCommandRunner) SecurityStrategy { + if runner == nil { + runner = systemSecurityCommandRunner{} + } + return &securityAdapter{runner: runner} +} + +func (adapter *securityAdapter) Read(ctx context.Context) (SecurityStatus, error) { + status := SecurityStatus{Findings: []SecurityFinding{}} + status.SELinux = readSELinuxStatusWithRunner(ctx, adapter.runner) + status.AppArmor = readAppArmorStatusWithRunner(ctx, adapter.runner) + if status.SELinux.Mode == "Enforcing" || status.SELinux.Mode == "Permissive" { + status.Active = "SELinux" + } + if status.AppArmor.Userspace && (status.AppArmor.KernelPresent || len(status.AppArmor.Profiles) > 0) { + if status.Active != "" { + status.Active += "+AppArmor" + } else { + status.Active = "AppArmor" + } + } + if status.SELinux.KernelPresent && !status.SELinux.Userspace { + status.Findings = append(status.Findings, SecurityFinding{ + Framework: "SELinux", Kind: "userspace", Subject: "SELinux tools", + Message: "SELinux kernel support is present but userspace inspection tools are unavailable.", + Severity: "warning", Guidance: "Install policycoreutils to inspect labels and booleans.", + }) + } + if status.SELinux.Mode == "Permissive" { + status.Findings = append(status.Findings, SecurityFinding{ + Framework: "SELinux", Kind: "mode", Subject: "SELinux", + Message: "SELinux is installed but running in permissive mode.", + Severity: "warning", Guidance: "Enable enforcing mode through the host's documented policy workflow.", + }) + } + if len(status.SELinux.Denials) > 0 { + status.Findings = append(status.Findings, SecurityFinding{ + Framework: "SELinux", Kind: "denial", Subject: "Recent AVC denials", + Message: "Recent SELinux AVC denials require policy review before remediation.", + Severity: "warning", Guidance: "Review the denial subject and verify the requested access is expected.", + }) + } + if status.AppArmor.KernelPresent && !status.AppArmor.Userspace { + status.Findings = append(status.Findings, SecurityFinding{ + Framework: "AppArmor", Kind: "userspace", Subject: "AppArmor tools", + Message: "AppArmor kernel support is present but aa-status is unavailable.", + Severity: "warning", Guidance: "Install apparmor-utils to inspect profiles.", + }) + } + if len(status.AppArmor.Denials) > 0 { + status.Findings = append(status.Findings, SecurityFinding{ + Framework: "AppArmor", Kind: "denial", Subject: "Recent AppArmor denials", + Message: "Recent AppArmor denials require profile review before remediation.", + Severity: "warning", Guidance: "Review the denied operation and update only the owning profile.", + }) + } + status.Fingerprint = securityFingerprint(status) + return status, nil +} + +func (adapter *securityAdapter) Apply(ctx context.Context, operation SecurityOperation) (SecurityStatus, error) { + if err := ValidateSecurityOperation(operation); err != nil { + return SecurityStatus{}, err + } + current, err := adapter.Read(ctx) + if err != nil { + return SecurityStatus{}, err + } + if current.Fingerprint != operation.ExpectedFingerprint { + return SecurityStatus{}, ErrSecurityConflict + } + + command, arguments, err := adapter.mutationCommand(current, operation) + if err != nil { + return SecurityStatus{}, err + } + if err := adapter.execute(ctx, operation, command, arguments); err != nil { + return SecurityStatus{}, err + } + updated, err := adapter.Read(ctx) + if err != nil { + return SecurityStatus{}, err + } + return updated, nil +} + +func (adapter *securityAdapter) mutationCommand(current SecurityStatus, operation SecurityOperation) (string, []string, error) { + switch operation.Action { + case "selinux-boolean": + if current.SELinux.Mode == "Disabled" || !current.SELinux.Userspace { + return "", nil, ErrSecurityUnavailable + } + known := false + for _, boolean := range current.SELinux.Booleans { + if strings.HasPrefix(boolean, operation.Boolean+"=") { + known = true + break + } + } + if !known { + return "", nil, ErrSecurityUnsafe + } + value := "off" + if operation.Value { + value = "on" + } + return "setsebool", []string{"-P", operation.Boolean, value}, nil + case "selinux-restorecon": + if !current.SELinux.Userspace || !safeRestoreconFilesystemPath(operation.Path) { + return "", nil, ErrSecurityUnavailable + } + return "restorecon", []string{"-v", "--", operation.Path}, nil + case "apparmor-enforce", "apparmor-complain": + if !current.AppArmor.Userspace { + return "", nil, ErrSecurityUnavailable + } + if !contains(current.AppArmor.Profiles, operation.Profile) { + return "", nil, ErrSecurityUnsafe + } + return "aa-" + strings.TrimPrefix(operation.Action, "apparmor-"), []string{"--", operation.Profile}, nil + case "apparmor-load": + if !current.AppArmor.Userspace { + return "", nil, ErrSecurityUnavailable + } + if !trustedAppArmorProfile(operation.Path) { + return "", nil, ErrSecurityUnsafe + } + return "apparmor_parser", []string{"-r", "-I", "/etc/apparmor.d"}, nil + default: + return "", nil, ErrInvalidSecurityOperation + } +} + +func safeRestoreconFilesystemPath(path string) bool { return safeRestoreconPath(path) } + +func securityCommand(ctx context.Context, name string, arguments ...string) (string, error) { + return runSecurityCommand(ctx, nil, name, arguments...) +} + +func runSecurityCommand(ctx context.Context, input io.Reader, name string, arguments ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + command := exec.CommandContext(ctx, name, arguments...) + command.Stdin = input + output, err := command.StdoutPipe() + if err != nil { + return "", err + } + if err = command.Start(); err != nil { + return "", err + } + payload, readErr := readBounded(output, 2<<20) + if readErr != nil { + _ = command.Process.Kill() + } + err = command.Wait() + if readErr != nil { + return "", readErr + } + if ctx.Err() != nil { + return "", ctx.Err() + } + return string(payload), err +} + +func (adapter *securityAdapter) execute(ctx context.Context, operation SecurityOperation, command string, arguments []string) error { + if operation.Action != "apparmor-load" && operation.Action != "selinux-restorecon" { + _, err := adapter.runner.Run(ctx, command, arguments...) + return err + } + root, err := openFileRoot("/") + if err != nil { + return err + } + defer root.Close() + file, err := root.Open(strings.TrimPrefix(operation.Path, "/")) + if err != nil { + return err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + if operation.Action == "selinux-restorecon" { + if !info.Mode().IsRegular() && !info.IsDir() { + return ErrSecurityUnsafe + } + label, err := adapter.runner.Run(ctx, "matchpathcon", "-n", "--", operation.Path) + if err != nil { + return err + } + label = strings.TrimSpace(label) + if label == "" || len(label) > 4096 || strings.ContainsAny(label, "\x00\r\n") || label == "<>" { + return ErrSecurityUnsafe + } + return unix.Fsetxattr(int(file.Fd()), "security.selinux", []byte(label+"\x00"), 0) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !info.Mode().IsRegular() || info.Size() > 1<<20 || info.Mode().Perm()&0o022 != 0 || !ok || stat.Uid != 0 { + return ErrSecurityUnsafe + } + // Parsing stdin keeps the checked file pinned while policy includes retain their native base. + _, err = runSecurityCommand(ctx, io.LimitReader(file, (1<<20)+1), command, arguments...) + return err +} From 8c2486d6f8b5545b14b1ad5e5045922f59319a48 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:56:54 +0530 Subject: [PATCH 17/88] feat(network): gate mutations behind D-Bus checkpoints with reconnect tokens Add NetworkManager checkpoint create/destroy/rollback over D-Bus, require checkpoint tokens for commit/rollback, and apply connection changes persistently under a checkpoint with reconnect-required tracking. Coordinate checkpoint lifetime in sessiond with automatic rollback on expiry or close. --- apps/backend/internal/platform/network.go | 218 ++++-- .../platform/network_file_adapters.go | 642 ++++++++++++++++++ apps/backend/internal/platform/network_nm.go | 394 +++++++++++ .../internal/platform/network_nm_test.go | 103 +++ .../backend/internal/sessiond/host_runtime.go | 9 +- .../internal/sessiond/network_backend.go | 156 +++++ apps/backend/internal/sessiond/sessiond.go | 2 + 7 files changed, 1452 insertions(+), 72 deletions(-) create mode 100644 apps/backend/internal/platform/network_file_adapters.go create mode 100644 apps/backend/internal/platform/network_nm.go create mode 100644 apps/backend/internal/platform/network_nm_test.go diff --git a/apps/backend/internal/platform/network.go b/apps/backend/internal/platform/network.go index ef53344..13c937e 100644 --- a/apps/backend/internal/platform/network.go +++ b/apps/backend/internal/platform/network.go @@ -13,16 +13,21 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" ) -const maxNetworkOutput = 2 << 20 +const ( + maxNetworkOutput = 2 << 20 + networkCheckpointTTL = 120 * time.Second +) var ( ErrInvalidNetworkOperation = errors.New("invalid network operation") ErrNetworkConflict = errors.New("network state changed") ErrNetworkOwnership = errors.New("network ownership is conflicted") ErrNetworkUnavailable = errors.New("network adapter unavailable") + ErrNetworkCheckpoint = errors.New("network checkpoint is invalid or expired") ) type NetworkAddress struct { @@ -61,10 +66,17 @@ type NetworkOperation struct { Interface string `json:"interface,omitempty"` Connection string `json:"connection,omitempty"` Address string `json:"address,omitempty"` + Addresses []string `json:"addresses,omitempty"` Gateway string `json:"gateway,omitempty"` DNS []string `json:"dns,omitempty"` Route string `json:"route,omitempty"` Metric int `json:"metric,omitempty"` + IPv4Method string `json:"ipv4Method,omitempty"` + IPv4Address string `json:"ipv4Address,omitempty"` + IPv4Gateway string `json:"ipv4Gateway,omitempty"` + IPv6Method string `json:"ipv6Method,omitempty"` + IPv6Address string `json:"ipv6Address,omitempty"` + IPv6Gateway string `json:"ipv6Gateway,omitempty"` ExpectedFingerprint string `json:"expectedFingerprint,omitempty"` Confirmation string `json:"confirmation,omitempty"` ReconnectToken string `json:"reconnectToken,omitempty"` @@ -72,12 +84,39 @@ type NetworkOperation struct { } type NetworkState struct { - Snapshot NetworkSnapshot `json:"snapshot"` - Action string `json:"action"` - Checkpoint string `json:"checkpoint,omitempty"` - Committed bool `json:"committed"` - Rollback bool `json:"rollback"` - Warning string `json:"warning,omitempty"` + Snapshot NetworkSnapshot `json:"snapshot"` + Action string `json:"action"` + Checkpoint string `json:"checkpoint,omitempty"` + Committed bool `json:"committed"` + Rollback bool `json:"rollback"` + ReconnectRequired bool `json:"reconnectRequired,omitempty"` + ReconnectToken string `json:"reconnectToken,omitempty"` + RollbackDeadline time.Time `json:"rollbackDeadline,omitempty"` + Warning string `json:"warning,omitempty"` +} + +type networkCommandFunc func(context.Context, string, ...string) ([]byte, error) + +var ( + networkCommandMu sync.RWMutex + networkRunner networkCommandFunc = execNetworkCommand +) + +// setNetworkCommandRunner is package-private so platform tests can install a +// bounded command fake without changing production command execution. +func setNetworkCommandRunner(runner networkCommandFunc) func() { + networkCommandMu.Lock() + previous := networkRunner + if runner == nil { + runner = execNetworkCommand + } + networkRunner = runner + networkCommandMu.Unlock() + return func() { + networkCommandMu.Lock() + networkRunner = previous + networkCommandMu.Unlock() + } } func NetworkSnapshotRead(ctx context.Context) (NetworkSnapshot, error) { @@ -137,6 +176,13 @@ func NetworkSnapshotRead(ctx context.Context) (NetworkSnapshot, error) { } func networkCommand(ctx context.Context, name string, arguments ...string) ([]byte, error) { + networkCommandMu.RLock() + runner := networkRunner + networkCommandMu.RUnlock() + return runner(ctx, name, arguments...) +} + +func execNetworkCommand(ctx context.Context, name string, arguments ...string) ([]byte, error) { commandCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() command := exec.CommandContext(commandCtx, name, arguments...) @@ -214,33 +260,75 @@ func detectNetworkOwnership() NetworkOwnership { } func networkFingerprint(snapshot NetworkSnapshot) string { + type device struct { + Name string + Hardware string + MTU int + Manager string + } + devices := make([]device, 0, len(snapshot.Interfaces)) + for _, item := range snapshot.Interfaces { + devices = append(devices, device{item.Name, item.Hardware, item.MTU, item.Manager}) + } + sort.Slice(devices, func(i, j int) bool { return devices[i].Name < devices[j].Name }) + addresses := append([]NetworkAddress(nil), snapshot.Addresses...) + sort.Slice(addresses, func(i, j int) bool { + return addresses[i].Interface+addresses[i].Address < addresses[j].Interface+addresses[j].Address + }) + routes := append([]NetworkRoute(nil), snapshot.Routes...) + sort.Slice(routes, func(i, j int) bool { + a, _ := json.Marshal(routes[i]) + b, _ := json.Marshal(routes[j]) + return string(a) < string(b) + }) payload, _ := json.Marshal(struct { - Interfaces []Interface - Addresses []NetworkAddress - Routes []NetworkRoute - DNS []string - Ownership NetworkOwnership - }{snapshot.Interfaces, snapshot.Addresses, snapshot.Routes, snapshot.DNS, snapshot.Ownership}) + Devices []device + Addresses []NetworkAddress + Routes []NetworkRoute + DNS []string + Ownership NetworkOwnership + }{devices, addresses, routes, snapshot.DNS, snapshot.Ownership}) return fingerprintBytes(payload) } func ValidateNetworkOperation(operation NetworkOperation) error { - validBackends := map[string]bool{"NetworkManager": true, "Netplan": true, "systemd-networkd": true} + validBackends := map[string]bool{"NetworkManager": true, "Netplan": true, "systemd-networkd": true, "ifupdown": true} validActions := map[string]bool{"preview": true, "dhcp": true, "static": true, "dns": true, "route-add": true, "route-remove": true, "checkpoint": true, "commit": true, "rollback": true} - if !validBackends[operation.Backend] || !validActions[operation.Action] || len(operation.Interface) > 256 || len(operation.Connection) > 256 || len(operation.Address) > 128 || len(operation.Gateway) > 128 || len(operation.Route) > 128 || len(operation.ReconnectToken) > 256 || len(operation.ExpectedFingerprint) > 128 || strings.ContainsAny(operation.Interface+operation.Connection+operation.Address+operation.Gateway+operation.Route, "\x00\r\n") { + joined := operation.Interface + operation.Connection + operation.Address + operation.Gateway + operation.Route + operation.IPv4Address + operation.IPv4Gateway + operation.IPv6Address + operation.IPv6Gateway + operation.Checkpoint + operation.ReconnectToken + operation.ExpectedFingerprint + if !validBackends[operation.Backend] || !validActions[operation.Action] || len(joined) > 4096 || len(operation.Interface) > 256 || len(operation.Connection) > 256 || len(operation.Address) > 128 || len(operation.Gateway) > 128 || len(operation.Route) > 128 || len(operation.Checkpoint) > 256 || len(operation.ReconnectToken) > 256 || len(operation.ExpectedFingerprint) > 128 || strings.ContainsAny(joined, "\x00\r\n") { + return ErrInvalidNetworkOperation + } + if operation.Action != "preview" && operation.Action != "checkpoint" && operation.Action != "commit" && operation.Action != "rollback" && operation.ExpectedFingerprint == "" { return ErrInvalidNetworkOperation } - if operation.Action != "preview" && operation.Action != "checkpoint" && operation.ExpectedFingerprint == "" { + if operation.Action != "preview" && operation.Action != "commit" && operation.Action != "rollback" && !validNetworkInterface(operation.Interface) { + return ErrInvalidNetworkOperation + } + if operation.Connection != "" && !validNetworkConnection(operation.Connection) { return ErrInvalidNetworkOperation } if operation.Action == "static" { - if _, _, err := net.ParseCIDR(operation.Address); err != nil { + addresses := networkOperationAddresses(operation) + if len(addresses) == 0 { return ErrInvalidNetworkOperation } + for _, address := range addresses { + if _, _, err := net.ParseCIDR(address); err != nil { + return ErrInvalidNetworkOperation + } + } + } + if operation.IPv4Method != "" && !validNetworkMethod(operation.IPv4Method) { + return ErrInvalidNetworkOperation } - if operation.Gateway != "" && net.ParseIP(operation.Gateway) == nil { + if operation.IPv6Method != "" && !validNetworkMethod(operation.IPv6Method) { return ErrInvalidNetworkOperation } + for _, gateway := range []string{operation.Gateway, operation.IPv4Gateway, operation.IPv6Gateway} { + if gateway != "" && net.ParseIP(gateway) == nil { + return ErrInvalidNetworkOperation + } + } if operation.Action == "dns" && (len(operation.DNS) == 0 || len(operation.DNS) > 8) { return ErrInvalidNetworkOperation } @@ -250,14 +338,19 @@ func ValidateNetworkOperation(operation NetworkOperation) error { } } if operation.Action == "route-add" || operation.Action == "route-remove" { - if _, _, err := net.ParseCIDR(operation.Route); err != nil { + if operation.Route != "default" { + if _, _, err := net.ParseCIDR(operation.Route); err != nil { + return ErrInvalidNetworkOperation + } + } + if operation.Metric < 0 || operation.Metric > 65535 { return ErrInvalidNetworkOperation } } - if operation.Action != "preview" && operation.Confirmation != "CONFIRM NETWORK CHANGE" { + if operation.Action != "preview" && operation.Confirmation != "CONFIRM NETWORK CHANGE" && operation.Confirmation != "CONFIRM NETWORK RECONNECT" { return ErrInvalidNetworkOperation } - if (operation.Action == "commit" || operation.Action == "rollback") && operation.Checkpoint == "" { + if (operation.Action == "commit" || operation.Action == "rollback") && (operation.Checkpoint == "" || operation.ReconnectToken == "") { return ErrInvalidNetworkOperation } return nil @@ -276,6 +369,10 @@ func PreviewNetworkOperation(ctx context.Context, operation NetworkOperation) (N } func ApplyNetworkOperation(ctx context.Context, operation NetworkOperation) (NetworkState, error) { + // File-backed adapters remain disabled until ownership and rollback validation is complete. + if operation.Backend != "NetworkManager" { + return NetworkState{}, ErrNetworkUnavailable + } if err := ValidateNetworkOperation(operation); err != nil { return NetworkState{}, err } @@ -283,85 +380,66 @@ func ApplyNetworkOperation(ctx context.Context, operation NetworkOperation) (Net if err != nil { return NetworkState{}, err } - if snapshot.Ownership.Conflicted || snapshot.Ownership.ActiveOwner != operation.Backend { + if snapshot.Ownership.Conflicted || !networkBackendOwnsSnapshot(operation.Backend, snapshot.Ownership) { return NetworkState{}, ErrNetworkOwnership } if operation.ExpectedFingerprint != "" && operation.ExpectedFingerprint != snapshot.Fingerprint { return NetworkState{}, ErrNetworkConflict } - if operation.Backend == "Netplan" { - return applyNetplanOperation(ctx, operation, snapshot) - } - if operation.Backend != "NetworkManager" { - return NetworkState{}, ErrNetworkUnavailable - } if operation.Action == "checkpoint" { - payload, checkpointErr := networkCommand(ctx, "nmcli", "device", "checkpoint", operation.Interface, "--timeout", "120", "--persist", "no") + checkpoint, checkpointErr := beginNetworkCheckpoint(ctx, operation) if checkpointErr != nil { return NetworkState{}, checkpointErr } - checkpoint := firstLine(string(payload)) - return NetworkState{Snapshot: snapshot, Action: "checkpoint", Checkpoint: checkpoint, Rollback: checkpoint != "", Warning: "Checkpoint expires automatically; commit only after a fresh connection is verified."}, nil + token := operation.ReconnectToken + if token == "" { + token = newNetworkToken("reconnect") + } + return NetworkState{Snapshot: snapshot, Action: "checkpoint", Checkpoint: checkpoint, Rollback: checkpoint != "", ReconnectRequired: checkpoint != "", ReconnectToken: token, RollbackDeadline: time.Now().UTC().Add(networkCheckpointTTL), Warning: "Checkpoint expires automatically; commit only after a fresh connection is verified."}, nil } if operation.Action == "commit" || operation.Action == "rollback" { - argument := "--" + operation.Action - if _, commandErr := networkCommand(ctx, "nmcli", "device", "checkpoint", operation.Checkpoint, argument); commandErr != nil { + if operation.Backend != "NetworkManager" { + return completeFileNetworkCheckpoint(ctx, operation, snapshot) + } + var commandErr error + if operation.Action == "commit" { + commandErr = networkManagerCheckpointDestroy(ctx, operation.Checkpoint) + } else { + commandErr = networkManagerCheckpointRollback(ctx, operation.Checkpoint) + } + if commandErr != nil { return NetworkState{}, commandErr } updated, readErr := NetworkSnapshotRead(ctx) if readErr != nil { return NetworkState{}, readErr } - return NetworkState{Snapshot: updated, Action: operation.Action, Committed: operation.Action == "commit", Rollback: operation.Action == "rollback", Checkpoint: operation.Checkpoint}, nil + warning := "The network change was rolled back." + if operation.Action == "commit" { + warning = "The network change was committed after reconnect confirmation." + } + return NetworkState{Snapshot: updated, Action: operation.Action, Committed: operation.Action == "commit", Rollback: operation.Action == "rollback", Checkpoint: operation.Checkpoint, ReconnectToken: operation.ReconnectToken, Warning: warning}, nil } - checkpointPayload, checkpointErr := networkCommand(ctx, "nmcli", "device", "checkpoint", operation.Interface, "--timeout", "120", "--persist", "no") + checkpoint, checkpointErr := beginNetworkCheckpoint(ctx, operation) if checkpointErr != nil { return NetworkState{}, checkpointErr } - checkpoint := firstLine(string(checkpointPayload)) - arguments := []string{"device"} - switch operation.Action { - case "dhcp": - arguments = append(arguments, "modify", operation.Interface, "ipv4.method", "auto", "ipv4.addresses", "", "ipv4.gateway", "", "ipv4.dns", "") - case "static": - arguments = append(arguments, "modify", operation.Interface, "ipv4.method", "manual", "ipv4.addresses", operation.Address, "ipv4.gateway", operation.Gateway) - case "dns": - arguments = append(arguments, "modify", operation.Interface, "ipv4.dns", strings.Join(operation.DNS, ",")) - case "route-add": - arguments = append(arguments, "modify", operation.Interface, "+ipv4.routes", operation.Route) - case "route-remove": - arguments = append(arguments, "modify", operation.Interface, "-ipv4.routes", operation.Route) - case "commit": - arguments = append(arguments, "connect", operation.Interface) - case "rollback", "checkpoint": - return NetworkState{}, ErrNetworkUnavailable - default: - return NetworkState{}, ErrInvalidNetworkOperation - } - if _, err := networkCommand(ctx, "nmcli", arguments...); err != nil { + if err := applyNetworkMutation(ctx, operation); err != nil { if checkpoint != "" { - _, _ = networkCommand(ctx, "nmcli", "device", "checkpoint", checkpoint, "--rollback") + _ = rollbackNetworkCheckpoint(ctx, operation.Backend, checkpoint) } return NetworkState{}, err } - if operation.Action == "commit" || operation.Action == "dhcp" || operation.Action == "static" || operation.Action == "dns" || strings.HasPrefix(operation.Action, "route-") { - if _, err := networkCommand(ctx, "nmcli", "device", "connect", operation.Interface); err != nil { - if checkpoint != "" { - _, _ = networkCommand(ctx, "nmcli", "device", "checkpoint", checkpoint, "--rollback") - } - return NetworkState{}, err - } - } updated, err := NetworkSnapshotRead(ctx) if err != nil { + _ = rollbackNetworkCheckpoint(ctx, operation.Backend, checkpoint) return NetworkState{}, err } - if checkpoint != "" { - if _, commitErr := networkCommand(ctx, "nmcli", "device", "checkpoint", checkpoint, "--commit"); commitErr != nil { - return NetworkState{Snapshot: updated, Action: operation.Action, Checkpoint: checkpoint, Committed: false, Rollback: true, Warning: "The change applied but checkpoint commit needs explicit operator verification."}, commitErr - } + token := operation.ReconnectToken + if token == "" { + token = newNetworkToken("reconnect") } - return NetworkState{Snapshot: updated, Action: operation.Action, Checkpoint: checkpoint, Committed: true, Rollback: false, Warning: "Reconnect verification completed before checkpoint commit."}, nil + return NetworkState{Snapshot: updated, Action: operation.Action, Checkpoint: checkpoint, Rollback: true, ReconnectRequired: true, ReconnectToken: token, RollbackDeadline: time.Now().UTC().Add(networkCheckpointTTL), Warning: "The profile was persisted and activated under a bounded rollback checkpoint; reconnect and confirm before committing."}, nil } func applyNetplanOperation(ctx context.Context, operation NetworkOperation, snapshot NetworkSnapshot) (NetworkState, error) { diff --git a/apps/backend/internal/platform/network_file_adapters.go b/apps/backend/internal/platform/network_file_adapters.go new file mode 100644 index 0000000..f64253b --- /dev/null +++ b/apps/backend/internal/platform/network_file_adapters.go @@ -0,0 +1,642 @@ +package platform + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" +) + +// networkConfigRoot is empty on a host and may be set by package tests to a +// temporary root. All paths below are validated before they reach the file +// system; no user supplied path is ever joined directly. +var networkConfigRoot string + +const maxNetworkConfigOutput = 1 << 20 + +type networkFileBackup struct { + path string + data []byte + mode os.FileMode + existed bool +} + +type networkFileCheckpoint struct { + backend string + files []networkFileBackup +} + +type desiredNetworkState struct { + IPv4Method string + IPv6Method string + Addresses []string + Gateways []string + DNS []string + Routes []NetworkRoute +} + +var networkFileCheckpoints = struct { + sync.Mutex + values map[string]networkFileCheckpoint +}{values: make(map[string]networkFileCheckpoint)} + +func beginNetworkCheckpoint(ctx context.Context, operation NetworkOperation) (string, error) { + if operation.Interface == "" || !validNetworkInterface(operation.Interface) { + return "", ErrInvalidNetworkOperation + } + if operation.Backend == "NetworkManager" { + return networkManagerCheckpointCreate(ctx, operation.Interface) + } + paths := []string{} + switch operation.Backend { + case "Netplan": + matches, err := filepath.Glob(networkConfigPath("/etc/netplan/*.yaml")) + if err != nil { + return "", err + } + paths = append(paths, matches...) + if len(paths) == 0 { + paths = append(paths, netplanFallbackPath()) + } + case "systemd-networkd": + paths = []string{networkdManagedPath(operation.Interface)} + case "ifupdown": + paths = []string{ifupdownManagedPath(operation.Interface)} + default: + return "", ErrNetworkUnavailable + } + checkpoint := newNetworkToken("tako-network") + backups := make([]networkFileBackup, 0, len(paths)) + for _, path := range paths { + backup, err := readNetworkBackup(path) + if err != nil { + return "", err + } + backups = append(backups, backup) + } + networkFileCheckpoints.Lock() + networkFileCheckpoints.values[checkpoint] = networkFileCheckpoint{backend: operation.Backend, files: backups} + networkFileCheckpoints.Unlock() + return checkpoint, nil +} + +func applyNetworkMutation(ctx context.Context, operation NetworkOperation) error { + switch operation.Backend { + case "NetworkManager": + return applyNetworkManagerPersistent(ctx, operation) + case "Netplan": + return applyNetplanPersistent(ctx, operation) + case "systemd-networkd": + return applyNetworkdPersistent(ctx, operation) + case "ifupdown": + return applyIfupdownPersistent(ctx, operation) + default: + return ErrNetworkUnavailable + } +} + +func commitNetworkCheckpoint(ctx context.Context, backend, checkpoint string) error { + if backend == "NetworkManager" { + return networkManagerCheckpointDestroy(ctx, checkpoint) + } + networkFileCheckpoints.Lock() + entry, ok := networkFileCheckpoints.values[checkpoint] + if ok { + delete(networkFileCheckpoints.values, checkpoint) + } + networkFileCheckpoints.Unlock() + if !ok || entry.backend != backend { + return ErrNetworkCheckpoint + } + return nil +} + +func rollbackNetworkCheckpoint(ctx context.Context, backend, checkpoint string) error { + if backend == "NetworkManager" { + return networkManagerCheckpointRollback(ctx, checkpoint) + } + networkFileCheckpoints.Lock() + entry, ok := networkFileCheckpoints.values[checkpoint] + if ok { + delete(networkFileCheckpoints.values, checkpoint) + } + networkFileCheckpoints.Unlock() + if !ok || entry.backend != backend { + return ErrNetworkCheckpoint + } + for _, backup := range entry.files { + if err := restoreNetworkBackup(backup); err != nil { + return err + } + } + return applyFileNetworkBackend(ctx, backend, "") +} + +func completeFileNetworkCheckpoint(ctx context.Context, operation NetworkOperation, snapshot NetworkSnapshot) (NetworkState, error) { + var err error + if operation.Action == "commit" { + err = commitNetworkCheckpoint(ctx, operation.Backend, operation.Checkpoint) + } else { + err = rollbackNetworkCheckpoint(ctx, operation.Backend, operation.Checkpoint) + } + if err != nil { + return NetworkState{}, err + } + updated, err := NetworkSnapshotRead(ctx) + if err != nil { + return NetworkState{}, err + } + warning := "The network change was rolled back." + if operation.Action == "commit" { + warning = "The network change was committed after reconnect confirmation." + } + return NetworkState{Snapshot: updated, Action: operation.Action, Checkpoint: operation.Checkpoint, Committed: operation.Action == "commit", Rollback: operation.Action == "rollback", ReconnectToken: operation.ReconnectToken, Warning: warning}, nil +} + +func applyNetplanPersistent(ctx context.Context, operation NetworkOperation) error { + if !validNetworkInterface(operation.Interface) { + return ErrInvalidNetworkOperation + } + key := "ethernets." + operation.Interface + arguments := []string{"set"} + switch operation.Action { + case "dhcp": + ipv4Method, ipv6Method := operation.IPv4Method, operation.IPv6Method + if ipv4Method == "" { + ipv4Method = "true" + } else { + ipv4Method = strconv.FormatBool(ipv4Method == "auto") + } + if ipv6Method == "" { + ipv6Method = "true" + } else { + ipv6Method = strconv.FormatBool(ipv6Method == "auto") + } + arguments = append(arguments, key+".dhcp4="+ipv4Method, key+".dhcp6="+ipv6Method) + case "static": + addresses := networkOperationAddresses(operation) + ipv4, ipv6 := splitNetworkAddresses(addresses) + if len(ipv4) > 0 { + arguments = append(arguments, key+".dhcp4=false", key+".addresses=["+strings.Join(ipv4, ",")+"]") + if gateway := networkGatewayForFamily(operation, false); gateway != "" { + arguments = append(arguments, key+".routes=[{to=default,via="+gateway+"}]") + } + } + if len(ipv6) > 0 { + arguments = append(arguments, key+".dhcp6=false", key+".addresses=["+strings.Join(ipv6, ",")+"]") + if gateway := networkGatewayForFamily(operation, true); gateway != "" { + arguments = append(arguments, key+".routes=[{to=::/0,via="+gateway+"}]") + } + } + case "dns": + arguments = append(arguments, key+".nameservers.addresses=["+strings.Join(operation.DNS, ",")+"]") + case "route-add": + route := operation.Route + if route == "default" { + if strings.Contains(networkGatewayForFamily(operation, true), ":") { + route = "::/0" + } else { + route = "0.0.0.0/0" + } + } + value := "{to=" + route + if gateway := networkGatewayForFamily(operation, strings.Contains(route, ":")); gateway != "" { + value += ",via=" + gateway + } + if operation.Metric > 0 { + value += ",metric=" + strconv.Itoa(operation.Metric) + } + arguments = append(arguments, key+".routes=["+value+"}]") + case "route-remove": + // Netplan's CLI cannot remove one arbitrary route from a merged YAML + // list without replacing the complete list. Refuse this action rather + // than deleting routes owned by another configuration file. + return ErrNetworkUnavailable + default: + return ErrInvalidNetworkOperation + } + if _, err := networkCommand(ctx, "netplan", arguments...); err != nil { + return err + } + _, err := networkCommand(ctx, "netplan", "apply") + return err +} + +func applyNetworkdPersistent(ctx context.Context, operation NetworkOperation) error { + path := networkdManagedPath(operation.Interface) + if conflictingNetworkdFile(operation.Interface, path) { + return ErrNetworkOwnership + } + state, err := readNetworkdState(path) + if err != nil { + return err + } + if err := updateDesiredNetworkState(&state, operation); err != nil { + return err + } + if err := writeNetworkdState(path, operation.Interface, state); err != nil { + return err + } + return applyFileNetworkBackend(ctx, "systemd-networkd", operation.Interface) +} + +func applyIfupdownPersistent(ctx context.Context, operation NetworkOperation) error { + path := ifupdownManagedPath(operation.Interface) + if conflictingIfupdownConfig(operation.Interface, path) { + return ErrNetworkOwnership + } + state, err := readIfupdownState(path) + if err != nil { + return err + } + if err := updateDesiredNetworkState(&state, operation); err != nil { + return err + } + if err := writeIfupdownState(path, operation.Interface, state); err != nil { + return err + } + return applyFileNetworkBackend(ctx, "ifupdown", operation.Interface) +} + +func applyFileNetworkBackend(ctx context.Context, backend, iface string) error { + switch backend { + case "Netplan": + if _, err := networkCommand(ctx, "netplan", "generate"); err != nil { + return err + } + _, err := networkCommand(ctx, "netplan", "apply") + return err + case "systemd-networkd": + if _, err := networkCommand(ctx, "networkctl", "reload"); err != nil { + return err + } + _, err := networkCommand(ctx, "networkctl", "reconfigure", iface) + return err + case "ifupdown": + // An already-down device makes ifdown fail; the subsequent ifup is the + // operation that establishes the new persistent state. + _, _ = networkCommand(ctx, "ifdown", "--force", iface) + _, err := networkCommand(ctx, "ifup", "--force", iface) + return err + default: + return ErrNetworkUnavailable + } +} + +func networkConfigPath(path string) string { + if networkConfigRoot == "" { + return path + } + return filepath.Join(networkConfigRoot, strings.TrimPrefix(path, string(filepath.Separator))) +} + +func netplanFallbackPath() string { + return networkConfigPath("/etc/netplan/99-tako.yaml") +} + +func networkdManagedPath(iface string) string { + return networkConfigPath("/etc/systemd/network/99-tako-" + iface + ".network") +} + +func ifupdownManagedPath(iface string) string { + return networkConfigPath("/etc/network/interfaces.d/99-tako-" + iface) +} + +func readNetworkBackup(path string) (networkFileBackup, error) { + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return networkFileBackup{path: path}, nil + } + if err != nil { + return networkFileBackup{}, err + } + if !info.Mode().IsRegular() || info.Size() > maxNetworkConfigOutput { + return networkFileBackup{}, ErrNetworkUnavailable + } + data, err := os.ReadFile(path) + if err != nil { + return networkFileBackup{}, err + } + return networkFileBackup{path: path, data: data, mode: info.Mode().Perm(), existed: true}, nil +} + +func restoreNetworkBackup(backup networkFileBackup) error { + if !backup.existed { + if err := os.Remove(backup.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil + } + return writeNetworkFile(backup.path, backup.data, backup.mode) +} + +func writeNetworkFile(path string, data []byte, mode os.FileMode) error { + if len(data) > maxNetworkConfigOutput { + return ErrNetworkUnavailable + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".tako-network-*") + if err != nil { + return err + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(mode); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryName, path) +} + +func conflictingNetworkdFile(iface, managed string) bool { + matches, _ := filepath.Glob(networkConfigPath("/etc/systemd/network/*.network")) + for _, path := range matches { + if path == managed { + continue + } + data, err := os.ReadFile(path) + if err == nil && strings.Contains(string(data), "Name="+iface) { + return true + } + } + return false +} + +func conflictingIfupdownConfig(iface, managed string) bool { + paths := []string{networkConfigPath("/etc/network/interfaces")} + matches, _ := filepath.Glob(networkConfigPath("/etc/network/interfaces.d/*")) + paths = append(paths, matches...) + for _, path := range paths { + if path == managed { + continue + } + data, err := os.ReadFile(path) + if err == nil && strings.Contains(string(data), "iface "+iface+" ") { + return true + } + } + return false +} + +func (operation NetworkOperation) String() string { + return fmt.Sprintf("%s/%s/%s", operation.Backend, operation.Action, operation.Interface) +} + +func readNetworkdState(path string) (desiredNetworkState, error) { + state := desiredNetworkState{} + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return state, nil + } + if err != nil { + return state, err + } + if len(data) > maxNetworkConfigOutput { + return state, ErrNetworkUnavailable + } + var route *NetworkRoute + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "DHCP="): + value := strings.TrimPrefix(line, "DHCP=") + if value == "ipv4" || value == "yes" { + state.IPv4Method = "auto" + } + if value == "ipv6" || value == "yes" { + state.IPv6Method = "auto" + } + case strings.HasPrefix(line, "Address="): + state.Addresses = append(state.Addresses, strings.TrimPrefix(line, "Address=")) + case strings.HasPrefix(line, "Gateway="): + state.Gateways = append(state.Gateways, strings.TrimPrefix(line, "Gateway=")) + case strings.HasPrefix(line, "DNS="): + state.DNS = append(state.DNS, strings.TrimPrefix(line, "DNS=")) + case line == "[Route]": + state.Routes = append(state.Routes, NetworkRoute{}) + route = &state.Routes[len(state.Routes)-1] + case route != nil && strings.HasPrefix(line, "Destination="): + route.Destination = strings.TrimPrefix(line, "Destination=") + case route != nil && strings.HasPrefix(line, "Gateway="): + route.Gateway = strings.TrimPrefix(line, "Gateway=") + case route != nil && strings.HasPrefix(line, "Metric="): + route.Metric, _ = strconv.Atoi(strings.TrimPrefix(line, "Metric=")) + } + } + return state, nil +} + +func writeNetworkdState(path, iface string, state desiredNetworkState) error { + var builder strings.Builder + builder.WriteString("[Match]\nName=") + builder.WriteString(iface) + builder.WriteString("\n\n[Network]\n") + if state.IPv4Method == "auto" && state.IPv6Method == "auto" { + builder.WriteString("DHCP=yes\n") + } else { + if state.IPv4Method == "auto" { + builder.WriteString("DHCP=ipv4\n") + } + if state.IPv6Method == "auto" { + builder.WriteString("DHCP=ipv6\n") + } + } + for _, address := range sortedNetworkStrings(state.Addresses) { + builder.WriteString("Address=") + builder.WriteString(address) + builder.WriteByte('\n') + } + for _, gateway := range sortedNetworkStrings(state.Gateways) { + builder.WriteString("Gateway=") + builder.WriteString(gateway) + builder.WriteByte('\n') + } + for _, dns := range sortedNetworkStrings(state.DNS) { + builder.WriteString("DNS=") + builder.WriteString(dns) + builder.WriteByte('\n') + } + for _, route := range state.Routes { + builder.WriteString("\n[Route]\nDestination=") + builder.WriteString(route.Destination) + builder.WriteByte('\n') + if route.Gateway != "" { + builder.WriteString("Gateway=") + builder.WriteString(route.Gateway) + builder.WriteByte('\n') + } + if route.Metric > 0 { + builder.WriteString("Metric=") + builder.WriteString(strconv.Itoa(route.Metric)) + builder.WriteByte('\n') + } + } + return writeNetworkFile(path, []byte(builder.String()), 0o644) +} + +func readIfupdownState(path string) (desiredNetworkState, error) { + state := desiredNetworkState{} + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return state, nil + } + if err != nil { + return state, err + } + if len(data) > maxNetworkConfigOutput { + return state, ErrNetworkUnavailable + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) >= 3 && fields[0] == "iface" && fields[2] == "dhcp" { + if fields[1] == "iface" { + state.IPv4Method = "auto" + } else if fields[1] == "iface6" { + state.IPv6Method = "auto" + } + } + if len(fields) >= 4 && fields[0] == "iface" && fields[2] == "inet6" && fields[3] == "auto" { + state.IPv6Method = "auto" + } + if len(fields) >= 2 && fields[0] == "address" { + state.Addresses = append(state.Addresses, fields[1]) + } + if len(fields) >= 2 && fields[0] == "gateway" { + state.Gateways = append(state.Gateways, fields[1]) + } + if len(fields) >= 2 && fields[0] == "dns-nameservers" { + state.DNS = append(state.DNS, fields[1:]...) + } + } + return state, nil +} + +func writeIfupdownState(path, iface string, state desiredNetworkState) error { + var builder strings.Builder + builder.WriteString("auto ") + builder.WriteString(iface) + builder.WriteByte('\n') + ipv4, ipv6 := splitNetworkAddresses(state.Addresses) + if state.IPv4Method == "auto" || len(ipv4) == 0 { + builder.WriteString("iface ") + builder.WriteString(iface) + builder.WriteString(" inet dhcp\n") + } else { + builder.WriteString("iface ") + builder.WriteString(iface) + builder.WriteString(" inet static\n") + builder.WriteString(" address ") + builder.WriteString(ipv4[0]) + builder.WriteByte('\n') + } + if state.IPv6Method == "auto" || len(ipv6) == 0 { + builder.WriteString("iface ") + builder.WriteString(iface) + builder.WriteString(" inet6 auto\n") + } else { + builder.WriteString("iface ") + builder.WriteString(iface) + builder.WriteString(" inet6 static\n") + builder.WriteString(" address ") + builder.WriteString(ipv6[0]) + builder.WriteByte('\n') + } + for _, gateway := range sortedNetworkStrings(state.Gateways) { + builder.WriteString(" gateway ") + builder.WriteString(gateway) + builder.WriteByte('\n') + } + if len(state.DNS) > 0 { + builder.WriteString(" dns-nameservers ") + builder.WriteString(strings.Join(sortedNetworkStrings(state.DNS), " ")) + builder.WriteByte('\n') + } + for _, route := range state.Routes { + builder.WriteString(" up ip route add ") + builder.WriteString(route.Destination) + if route.Gateway != "" { + builder.WriteString(" via ") + builder.WriteString(route.Gateway) + } + builder.WriteByte('\n') + builder.WriteString(" down ip route del ") + builder.WriteString(route.Destination) + if route.Gateway != "" { + builder.WriteString(" via ") + builder.WriteString(route.Gateway) + } + builder.WriteByte('\n') + } + return writeNetworkFile(path, []byte(builder.String()), 0o644) +} + +func updateDesiredNetworkState(state *desiredNetworkState, operation NetworkOperation) error { + switch operation.Action { + case "dhcp": + state.IPv4Method = nonEmptyNetworkValue(operation.IPv4Method, "auto") + state.IPv6Method = nonEmptyNetworkValue(operation.IPv6Method, "auto") + case "static": + addresses := networkOperationAddresses(operation) + if len(addresses) == 0 { + return ErrInvalidNetworkOperation + } + state.Addresses = append(state.Addresses, addresses...) + for _, address := range addresses { + ip, _, _ := net.ParseCIDR(address) + if ip != nil && ip.To4() == nil { + state.IPv6Method = nonEmptyNetworkValue(operation.IPv6Method, "manual") + } else { + state.IPv4Method = nonEmptyNetworkValue(operation.IPv4Method, "manual") + } + } + if gateway := networkGatewayForFamily(operation, false); gateway != "" { + state.Gateways = append(state.Gateways, gateway) + } + if gateway := networkGatewayForFamily(operation, true); gateway != "" { + state.Gateways = append(state.Gateways, gateway) + } + case "dns": + state.DNS = sortedNetworkStrings(operation.DNS) + case "route-add": + route := NetworkRoute{Destination: operation.Route, Gateway: operation.Gateway, Metric: operation.Metric} + for _, existing := range state.Routes { + if existing == route { + return nil + } + } + state.Routes = append(state.Routes, route) + case "route-remove": + kept := state.Routes[:0] + for _, existing := range state.Routes { + if existing.Destination == operation.Route && (operation.Gateway == "" || existing.Gateway == operation.Gateway) { + continue + } + kept = append(kept, existing) + } + state.Routes = kept + default: + return ErrInvalidNetworkOperation + } + state.Addresses = sortedNetworkStrings(state.Addresses) + state.Gateways = sortedNetworkStrings(state.Gateways) + state.DNS = sortedNetworkStrings(state.DNS) + return nil +} diff --git a/apps/backend/internal/platform/network_nm.go b/apps/backend/internal/platform/network_nm.go new file mode 100644 index 0000000..6e70cda --- /dev/null +++ b/apps/backend/internal/platform/network_nm.go @@ -0,0 +1,394 @@ +package platform + +import ( + "context" + "crypto/rand" + "errors" + "net" + "sort" + "strconv" + "strings" + "time" + + "github.com/godbus/dbus/v5" +) + +const ( + networkManagerService = "org.freedesktop.NetworkManager" + networkManagerPath = dbus.ObjectPath("/org/freedesktop/NetworkManager") + networkManagerFlags = uint32(0x06) // delete new connections and disconnect new devices on rollback +) + +type networkManagerCheckpointClient interface { + Create(context.Context, string, uint32) (string, error) + Destroy(context.Context, string) error + Rollback(context.Context, string) error + Close() +} + +type dbusNetworkManagerCheckpointClient struct { + connection *dbus.Conn +} + +var networkManagerCheckpointFactory = func() (networkManagerCheckpointClient, error) { + connection, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + return &dbusNetworkManagerCheckpointClient{connection: connection}, nil +} + +// setNetworkManagerCheckpointFactory is package-private so tests can model +// NetworkManager's checkpoint lifecycle without a system bus. +func setNetworkManagerCheckpointFactory(factory func() (networkManagerCheckpointClient, error)) func() { + previous := networkManagerCheckpointFactory + if factory == nil { + networkManagerCheckpointFactory = func() (networkManagerCheckpointClient, error) { + connection, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + return &dbusNetworkManagerCheckpointClient{connection: connection}, nil + } + } else { + networkManagerCheckpointFactory = factory + } + return func() { networkManagerCheckpointFactory = previous } +} + +func (client *dbusNetworkManagerCheckpointClient) Close() { + if client != nil && client.connection != nil { + _ = client.connection.Close() + } +} + +func (client *dbusNetworkManagerCheckpointClient) Create(ctx context.Context, iface string, timeout uint32) (string, error) { + if client == nil || client.connection == nil || !validNetworkInterface(iface) { + return "", ErrInvalidNetworkOperation + } + var device dbus.ObjectPath + if err := client.connection.Object(networkManagerService, networkManagerPath).CallWithContext(ctx, networkManagerService+".GetDeviceByIpIface", 0, iface).Store(&device); err != nil { + return "", err + } + var checkpoint dbus.ObjectPath + if err := client.connection.Object(networkManagerService, networkManagerPath).CallWithContext(ctx, networkManagerService+".CheckpointCreate", 0, []dbus.ObjectPath{device}, timeout, networkManagerFlags).Store(&checkpoint); err != nil { + return "", err + } + value := string(checkpoint) + if !validNetworkCheckpoint(value) { + return "", ErrNetworkCheckpoint + } + return value, nil +} + +func (client *dbusNetworkManagerCheckpointClient) Destroy(ctx context.Context, checkpoint string) error { + if client == nil || client.connection == nil || !validNetworkCheckpoint(checkpoint) { + return ErrNetworkCheckpoint + } + return client.connection.Object(networkManagerService, networkManagerPath).CallWithContext(ctx, networkManagerService+".CheckpointDestroy", 0, dbus.ObjectPath(checkpoint)).Err +} + +func (client *dbusNetworkManagerCheckpointClient) Rollback(ctx context.Context, checkpoint string) error { + if client == nil || client.connection == nil || !validNetworkCheckpoint(checkpoint) { + return ErrNetworkCheckpoint + } + result := map[string]uint32{} + return client.connection.Object(networkManagerService, networkManagerPath).CallWithContext(ctx, networkManagerService+".CheckpointRollback", 0, dbus.ObjectPath(checkpoint)).Store(&result) +} + +func networkManagerCheckpointCreate(ctx context.Context, iface string) (string, error) { + client, err := networkManagerCheckpointFactory() + if err != nil { + return "", fmtNetworkManagerUnavailable(err) + } + defer client.Close() + checkpoint, err := client.Create(ctx, iface, uint32(networkCheckpointTTL/time.Second)) + if err != nil { + return "", fmtNetworkManagerUnavailable(err) + } + return checkpoint, nil +} + +func networkManagerCheckpointDestroy(ctx context.Context, checkpoint string) error { + if !strings.HasPrefix(checkpoint, "/org/freedesktop/NetworkManager/Checkpoint/") || !validNetworkCheckpoint(checkpoint) { + return ErrNetworkCheckpoint + } + client, err := networkManagerCheckpointFactory() + if err != nil { + return fmtNetworkManagerUnavailable(err) + } + defer client.Close() + if err := client.Destroy(ctx, checkpoint); err != nil { + return fmtNetworkManagerUnavailable(err) + } + return nil +} + +func networkManagerCheckpointRollback(ctx context.Context, checkpoint string) error { + if !strings.HasPrefix(checkpoint, "/org/freedesktop/NetworkManager/Checkpoint/") || !validNetworkCheckpoint(checkpoint) { + return ErrNetworkCheckpoint + } + client, err := networkManagerCheckpointFactory() + if err != nil { + return fmtNetworkManagerUnavailable(err) + } + defer client.Close() + if err := client.Rollback(ctx, checkpoint); err != nil { + return fmtNetworkManagerUnavailable(err) + } + return nil +} + +func fmtNetworkManagerUnavailable(err error) error { + if errors.Is(err, ErrInvalidNetworkOperation) || errors.Is(err, ErrNetworkCheckpoint) { + return err + } + return errors.Join(ErrNetworkUnavailable, err) +} + +func applyNetworkManagerPersistent(ctx context.Context, operation NetworkOperation) error { + target := operation.Connection + if target == "" { + target = operation.Interface + } + arguments, err := networkManagerModifyArguments(operation, target) + if err != nil { + return err + } + if _, err := networkCommand(ctx, "nmcli", arguments...); err != nil { + return err + } + up := []string{"connection", "up", target} + if operation.Interface != "" { + up = append(up, "ifname", operation.Interface) + } + _, err = networkCommand(ctx, "nmcli", up...) + return err +} + +func networkManagerModifyArguments(operation NetworkOperation, target string) ([]string, error) { + arguments := []string{"connection", "modify", target} + appendProperty := func(name, value string) { arguments = append(arguments, name, value) } + switch operation.Action { + case "dhcp": + ipv4Method, ipv6Method := operation.IPv4Method, operation.IPv6Method + if ipv4Method == "" { + ipv4Method = "auto" + } + if ipv6Method == "" { + ipv6Method = "auto" + } + appendProperty("ipv4.method", ipv4Method) + appendProperty("ipv6.method", ipv6Method) + if ipv4Method != "manual" { + appendProperty("ipv4.addresses", "") + appendProperty("ipv4.gateway", "") + appendProperty("ipv4.dns", "") + } + if ipv6Method != "manual" { + appendProperty("ipv6.addresses", "") + appendProperty("ipv6.gateway", "") + appendProperty("ipv6.dns", "") + } + case "static": + addresses := networkOperationAddresses(operation) + ipv4, ipv6 := splitNetworkAddresses(addresses) + if len(ipv4) > 0 { + appendProperty("ipv4.method", nonEmptyNetworkValue(operation.IPv4Method, "manual")) + appendProperty("ipv4.addresses", strings.Join(ipv4, ",")) + appendProperty("ipv4.gateway", networkGatewayForFamily(operation, false)) + } + if len(ipv6) > 0 { + appendProperty("ipv6.method", nonEmptyNetworkValue(operation.IPv6Method, "manual")) + appendProperty("ipv6.addresses", strings.Join(ipv6, ",")) + appendProperty("ipv6.gateway", networkGatewayForFamily(operation, true)) + } + case "dns": + ipv4, ipv6 := splitNetworkIPs(operation.DNS) + if len(ipv4) > 0 { + appendProperty("ipv4.dns", strings.Join(ipv4, ",")) + } + if len(ipv6) > 0 { + appendProperty("ipv6.dns", strings.Join(ipv6, ",")) + } + case "route-add", "route-remove": + ipv6 := strings.Contains(operation.Route, ":") || strings.Contains(networkGatewayForFamily(operation, true), ":") + family := "ipv4" + if ipv6 { + family = "ipv6" + } + route := operation.Route + if route == "default" { + if ipv6 { + route = "::/0" + } else { + route = "0.0.0.0/0" + } + } + value := route + if gateway := networkGatewayForFamily(operation, ipv6); gateway != "" { + value += " " + gateway + } + if operation.Metric > 0 { + value += " " + strconv.Itoa(operation.Metric) + } + prefix := "+" + if operation.Action == "route-remove" { + prefix = "-" + } + appendProperty(prefix+family+".routes", value) + default: + return nil, ErrInvalidNetworkOperation + } + return arguments, nil +} + +func networkOperationAddresses(operation NetworkOperation) []string { + values := append([]string(nil), operation.Addresses...) + if operation.Address != "" { + values = append(values, operation.Address) + } + if operation.IPv4Address != "" { + values = append(values, operation.IPv4Address) + } + if operation.IPv6Address != "" { + values = append(values, operation.IPv6Address) + } + return sortedNetworkStrings(values) +} + +func networkGatewayForFamily(operation NetworkOperation, ipv6 bool) string { + if ipv6 && operation.IPv6Gateway != "" { + return operation.IPv6Gateway + } + if !ipv6 && operation.IPv4Gateway != "" { + return operation.IPv4Gateway + } + if operation.Gateway == "" { + return "" + } + ip := net.ParseIP(operation.Gateway) + if ip == nil || (ip.To4() == nil) != ipv6 { + return "" + } + return operation.Gateway +} + +func splitNetworkAddresses(values []string) (ipv4, ipv6 []string) { + for _, value := range sortedNetworkStrings(values) { + ip, _, err := net.ParseCIDR(value) + if err != nil { + continue + } + if ip.To4() == nil { + ipv6 = append(ipv6, value) + } else { + ipv4 = append(ipv4, value) + } + } + return ipv4, ipv6 +} + +func splitNetworkIPs(values []string) (ipv4, ipv6 []string) { + for _, value := range sortedNetworkStrings(values) { + ip := net.ParseIP(value) + if ip == nil { + continue + } + if ip.To4() == nil { + ipv6 = append(ipv6, value) + } else { + ipv4 = append(ipv4, value) + } + } + return ipv4, ipv6 +} + +func sortedNetworkStrings(values []string) []string { + result := append([]string(nil), values...) + sort.Strings(result) + write := 0 + for _, value := range result { + if value == "" || (write > 0 && result[write-1] == value) { + continue + } + result[write] = value + write++ + } + return result[:write] +} + +func nonEmptyNetworkValue(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func validNetworkMethod(method string) bool { + return method == "auto" || method == "manual" || method == "disabled" || method == "ignore" +} + +func validNetworkInterface(value string) bool { + if value == "" || value == "." || value == ".." || len(value) > 15 { + return false + } + for _, character := range value { + if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && !strings.ContainsRune("._:@-", character) { + return false + } + } + return true +} + +func validNetworkConnection(value string) bool { + return value != "" && value != "." && value != ".." && !strings.ContainsAny(value, "/\\\x00\r\n") +} + +func validNetworkCheckpoint(value string) bool { + if strings.HasPrefix(value, "/org/freedesktop/NetworkManager/Checkpoint/") { + name := strings.TrimPrefix(value, "/org/freedesktop/NetworkManager/Checkpoint/") + if name == "" { + return false + } + for _, character := range name { + if character < '0' || character > '9' { + return false + } + } + return true + } + return validNetworkConnection(value) +} + +func validNetworkToken(value string) bool { + return value != "" && len(value) <= 256 && !strings.ContainsAny(value, "\x00\r\n /\\") +} + +func networkBackendOwnsSnapshot(backend string, ownership NetworkOwnership) bool { + if backend == "systemd-networkd" && contains(ownership.Detected, "Netplan") { + return false + } + if ownership.ActiveOwner == backend { + return true + } + return backend == "Netplan" && ownership.ActiveOwner == "systemd-networkd" && contains(ownership.Detected, "Netplan") +} + +func newNetworkToken(prefix string) string { + var value [18]byte + if _, err := rand.Read(value[:]); err != nil { + return prefix + "-" + fingerprintBytes([]byte(time.Now().UTC().String()))[:24] + } + return prefix + "-" + hexNetwork(value[:]) +} + +func hexNetwork(value []byte) string { + const digits = "0123456789abcdef" + result := make([]byte, len(value)*2) + for index, item := range value { + result[index*2] = digits[item>>4] + result[index*2+1] = digits[item&0x0f] + } + return string(result) +} diff --git a/apps/backend/internal/platform/network_nm_test.go b/apps/backend/internal/platform/network_nm_test.go new file mode 100644 index 0000000..54818b0 --- /dev/null +++ b/apps/backend/internal/platform/network_nm_test.go @@ -0,0 +1,103 @@ +package platform + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" +) + +type fakeNetworkManagerCheckpoint struct { + created []string + destroyed []string + rolled []string +} + +func (fake *fakeNetworkManagerCheckpoint) Create(_ context.Context, iface string, timeout uint32) (string, error) { + fake.created = append(fake.created, iface+":"+string(rune(timeout))) + return "/org/freedesktop/NetworkManager/Checkpoint/7", nil +} + +func (fake *fakeNetworkManagerCheckpoint) Destroy(_ context.Context, checkpoint string) error { + fake.destroyed = append(fake.destroyed, checkpoint) + return nil +} + +func (fake *fakeNetworkManagerCheckpoint) Rollback(_ context.Context, checkpoint string) error { + fake.rolled = append(fake.rolled, checkpoint) + return nil +} + +func (*fakeNetworkManagerCheckpoint) Close() {} + +func TestNetworkFingerprintExcludesInterfaceCounters(t *testing.T) { + left := NetworkSnapshot{ + Interfaces: []Interface{{Name: "eno1", Index: 2, MTU: 1500, Hardware: "00:11:22:33:44:55", Manager: "NetworkManager", RX: 10, TX: 20}}, + Addresses: []NetworkAddress{{Interface: "eno1", Address: "192.0.2.10/24", Family: "inet"}}, + Routes: []NetworkRoute{{Destination: "default", Gateway: "192.0.2.1", Device: "eno1"}}, + DNS: []string{"192.0.2.53"}, + Ownership: NetworkOwnership{ActiveOwner: "NetworkManager", Detected: []string{"NetworkManager"}}, + } + right := left + right.Interfaces = []Interface{{Name: "eno1", Index: 2, MTU: 1500, Hardware: "00:11:22:33:44:55", Manager: "NetworkManager", RX: 99999, TX: 88888}} + if networkFingerprint(left) != networkFingerprint(right) { + t.Fatal("traffic counters changed the network mutation fingerprint") + } +} + +func TestNetworkManagerModifyIsPersistentAndSupportsBothFamilies(t *testing.T) { + arguments, err := networkManagerModifyArguments(NetworkOperation{ + Action: "static", + Interface: "eno1", + Connection: "Wired connection 1", + IPv4Address: "192.0.2.10/24", + IPv4Gateway: "192.0.2.1", + IPv6Address: "2001:db8::10/64", + IPv6Gateway: "2001:db8::1", + }, "Wired connection 1") + if err != nil { + t.Fatal(err) + } + if len(arguments) < 3 || !reflect.DeepEqual(arguments[:3], []string{"connection", "modify", "Wired connection 1"}) { + t.Fatalf("unexpected persistent command: %#v", arguments) + } + joined := strings.Join(arguments, " ") + for _, value := range []string{"ipv4.method", "ipv4.addresses", "ipv6.method", "ipv6.addresses"} { + if !strings.Contains(joined, value) { + t.Fatalf("%q missing from persistent command: %s", value, joined) + } + } + if strings.Contains(joined, "device modify") { + t.Fatalf("runtime-only device modify was used: %s", joined) + } +} + +func TestNetworkManagerCheckpointUsesDBusLifecycle(t *testing.T) { + fake := &fakeNetworkManagerCheckpoint{} + restore := setNetworkManagerCheckpointFactory(func() (networkManagerCheckpointClient, error) { return fake, nil }) + defer restore() + checkpoint, err := networkManagerCheckpointCreate(context.Background(), "eno1") + if err != nil { + t.Fatal(err) + } + if err := networkManagerCheckpointDestroy(context.Background(), checkpoint); err != nil { + t.Fatal(err) + } + if err := networkManagerCheckpointRollback(context.Background(), checkpoint); err != nil { + t.Fatal(err) + } + if len(fake.created) != 1 || fake.created[0][:4] != "eno1" || len(fake.destroyed) != 1 || len(fake.rolled) != 1 { + t.Fatalf("checkpoint lifecycle = %#v", fake) + } +} + +func TestNetworkManagerCheckpointRejectsUntrustedPath(t *testing.T) { + fake := &fakeNetworkManagerCheckpoint{} + restore := setNetworkManagerCheckpointFactory(func() (networkManagerCheckpointClient, error) { return fake, nil }) + defer restore() + if err := networkManagerCheckpointDestroy(context.Background(), "/tmp/other"); !errors.Is(err, ErrNetworkCheckpoint) { + t.Fatalf("untrusted checkpoint error = %v", err) + } +} + diff --git a/apps/backend/internal/sessiond/host_runtime.go b/apps/backend/internal/sessiond/host_runtime.go index f16f1b0..f7e8a3e 100644 --- a/apps/backend/internal/sessiond/host_runtime.go +++ b/apps/backend/internal/sessiond/host_runtime.go @@ -16,6 +16,7 @@ const ( type hostRuntime struct { sampler *metrics.Sampler processTracker *platform.ProcessTracker + network *networkCoordinator certificatePath string updates *platform.UpdateService } @@ -34,7 +35,7 @@ func newHostRuntime(settings ...time.Duration) *hostRuntime { } sampler := metrics.NewSampler(capacity) sampler.Configure(defaultInterval, retention) - return &hostRuntime{sampler: sampler, processTracker: platform.NewProcessTracker()} + return &hostRuntime{sampler: sampler, processTracker: platform.NewProcessTracker(), network: newNetworkCoordinator()} } func (runtime *hostRuntime) run(ctx context.Context) { @@ -43,7 +44,11 @@ func (runtime *hostRuntime) run(ctx context.Context) { } runtime.sampler.Run(ctx, 0) } -func (runtime *hostRuntime) close() {} +func (runtime *hostRuntime) close() { + if runtime != nil && runtime.network != nil { + runtime.network.Close() + } +} func (runtime *hostRuntime) updateObservation(_ context.Context) platform.UpdateObservation { if runtime == nil || runtime.updates == nil { return platform.UpdateObservation{Progress: platform.UpdateProgress{Phase: "idle", Percent: -1, Message: "No update is running."}, Output: []platform.UpdateOutput{}} diff --git a/apps/backend/internal/sessiond/network_backend.go b/apps/backend/internal/sessiond/network_backend.go index 9697d97..04c1509 100644 --- a/apps/backend/internal/sessiond/network_backend.go +++ b/apps/backend/internal/sessiond/network_backend.go @@ -1,11 +1,165 @@ package sessiond import ( + "context" "errors" + "sync" + "time" "github.com/velopulent/tako/internal/platform" ) +const defaultNetworkRollbackDeadline = 120 * time.Second + +type networkPendingTransaction struct { + backend string + checkpoint string + token string + deadline time.Time + timer *time.Timer +} + +// networkCoordinator lives for the lifetime of sessiond rather than a single +// browser request. A dropped browser connection therefore cannot abandon a +// NetworkManager checkpoint; the deadline rolls it back and a later request +// must present the same reconnect token before it can commit. +type networkCoordinator struct { + mu sync.Mutex + pending map[string]networkPendingTransaction + deadline time.Duration + now func() time.Time + apply func(context.Context, platform.NetworkOperation) (platform.NetworkState, error) +} + +func newNetworkCoordinator(deadline ...time.Duration) *networkCoordinator { + value := defaultNetworkRollbackDeadline + if len(deadline) > 0 && deadline[0] > 0 { + value = deadline[0] + } + return &networkCoordinator{ + pending: make(map[string]networkPendingTransaction), + deadline: value, + now: time.Now, + apply: platform.ApplyNetworkOperation, + } +} + +func (coordinator *networkCoordinator) Apply(ctx context.Context, operation platform.NetworkOperation) (platform.NetworkState, error) { + if coordinator == nil { + return platform.ApplyNetworkOperation(ctx, operation) + } + if operation.Action == "commit" || operation.Action == "rollback" { + pending, ok := coordinator.takePending(operation.ReconnectToken, operation.Checkpoint, operation.Backend) + if !ok { + return platform.NetworkState{}, platform.ErrNetworkCheckpoint + } + if operation.Checkpoint == "" { + operation.Checkpoint = pending.checkpoint + } + state, err := coordinator.apply(ctx, operation) + if err != nil { + coordinator.restorePending(pending) + return platform.NetworkState{}, err + } + return state, nil + } + state, err := coordinator.apply(ctx, operation) + if err != nil { + return platform.NetworkState{}, err + } + if state.ReconnectRequired && state.Checkpoint != "" && state.ReconnectToken != "" { + coordinator.track(state, operation.Backend) + } + return state, nil +} + +func (coordinator *networkCoordinator) track(state platform.NetworkState, backend string) { + deadline := coordinator.now().Add(coordinator.deadline) + pending := networkPendingTransaction{backend: backend, checkpoint: state.Checkpoint, token: state.ReconnectToken, deadline: deadline} + pending.timer = time.AfterFunc(coordinator.deadline, func() { + coordinator.expire(state.ReconnectToken) + }) + coordinator.mu.Lock() + if previous, ok := coordinator.pending[pending.token]; ok && previous.timer != nil { + previous.timer.Stop() + } + coordinator.pending[pending.token] = pending + coordinator.mu.Unlock() +} + +func (coordinator *networkCoordinator) takePending(token, checkpoint, backend string) (networkPendingTransaction, bool) { + if token == "" { + return networkPendingTransaction{}, false + } + coordinator.mu.Lock() + pending, ok := coordinator.pending[token] + if ok && (pending.backend != backend || (checkpoint != "" && pending.checkpoint != checkpoint) || !coordinator.now().Before(pending.deadline)) { + ok = false + } + if ok { + delete(coordinator.pending, token) + if pending.timer != nil { + pending.timer.Stop() + } + } + coordinator.mu.Unlock() + return pending, ok +} + +func (coordinator *networkCoordinator) restorePending(pending networkPendingTransaction) { + if pending.token == "" || !coordinator.now().Before(pending.deadline) { + return + } + pending.timer = time.AfterFunc(time.Until(pending.deadline), func() { + coordinator.expire(pending.token) + }) + coordinator.mu.Lock() + coordinator.pending[pending.token] = pending + coordinator.mu.Unlock() +} + +func (coordinator *networkCoordinator) expire(token string) { + coordinator.mu.Lock() + pending, ok := coordinator.pending[token] + if ok { + delete(coordinator.pending, token) + } + coordinator.mu.Unlock() + if !ok { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + _, _ = coordinator.apply(ctx, platform.NetworkOperation{ + Backend: pending.backend, + Action: "rollback", + Checkpoint: pending.checkpoint, + ReconnectToken: pending.token, + Confirmation: "CONFIRM NETWORK RECONNECT", + }) +} + +func (coordinator *networkCoordinator) Close() { + if coordinator == nil { + return + } + coordinator.mu.Lock() + pending := make([]networkPendingTransaction, 0, len(coordinator.pending)) + for token, item := range coordinator.pending { + delete(coordinator.pending, token) + if item.timer != nil { + item.timer.Stop() + } + pending = append(pending, item) + } + coordinator.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + for _, item := range pending { + _, _ = coordinator.apply(ctx, platform.NetworkOperation{Backend: item.backend, Action: "rollback", Checkpoint: item.checkpoint, ReconnectToken: item.token, Confirmation: "CONFIRM NETWORK RECONNECT"}) + } +} + func networkErrorCode(err error) string { switch { case errors.Is(err, platform.ErrInvalidNetworkOperation): @@ -14,6 +168,8 @@ func networkErrorCode(err error) string { return "network-conflict" case errors.Is(err, platform.ErrNetworkOwnership): return "network-ownership-conflict" + case errors.Is(err, platform.ErrNetworkCheckpoint): + return "network-checkpoint-invalid" case errors.Is(err, platform.ErrNetworkUnavailable): return "network-unavailable" default: diff --git a/apps/backend/internal/sessiond/sessiond.go b/apps/backend/internal/sessiond/sessiond.go index ebbd511..e75b50d 100644 --- a/apps/backend/internal/sessiond/sessiond.go +++ b/apps/backend/internal/sessiond/sessiond.go @@ -1069,6 +1069,8 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe var operationErr error if operation.Action == "preview" { state, operationErr = platform.PreviewNetworkOperation(networkCtx, operation) + } else if runtime != nil && runtime.network != nil { + state, operationErr = runtime.network.Apply(networkCtx, operation) } else { state, operationErr = platform.ApplyNetworkOperation(networkCtx, operation) } From 0ea1a83632ced6842edff3b740cb45fdae40b85d Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:56:59 +0530 Subject: [PATCH 18/88] fix(platform): reap journal children, stabilize log IDs, track mount read-only Kill and wait on journal child processes on read errors, derive stable log entry IDs from cursor hashes, and parse per-mount read-only flags into filesystems instead of first-measurement only. --- apps/backend/internal/platform/journal.go | 5 +++++ apps/backend/internal/platform/modules.go | 7 +++++++ apps/backend/internal/platform/storage.go | 15 +++++++++------ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/backend/internal/platform/journal.go b/apps/backend/internal/platform/journal.go index 2eee3fc..80a543f 100644 --- a/apps/backend/internal/platform/journal.go +++ b/apps/backend/internal/platform/journal.go @@ -170,6 +170,9 @@ func QueryLogsAs(ctx context.Context, query JournalQuery, cred *syscall.Credenti return JournalPage{}, err } output, readErr := readBounded(stdout, maxJournalOutput) + if readErr != nil { + _ = command.Process.Kill() + } waitErr := command.Wait() if readErr != nil { return JournalPage{}, readErr @@ -279,6 +282,8 @@ func FollowJournalAs(ctx context.Context, query JournalQuery, cred *syscall.Cred } } if err := scanner.Err(); err != nil { + _ = command.Process.Kill() + _ = command.Wait() return err } err = command.Wait() diff --git a/apps/backend/internal/platform/modules.go b/apps/backend/internal/platform/modules.go index 9a3b36c..ef8afbd 100644 --- a/apps/backend/internal/platform/modules.go +++ b/apps/backend/internal/platform/modules.go @@ -3,6 +3,8 @@ package platform import ( "bufio" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "net" @@ -390,6 +392,7 @@ func unitNameFromObjectPath(path dbus.ObjectPath) string { } type LogEntry struct { + ID string `json:"id"` Timestamp string `json:"timestamp"` Priority string `json:"priority"` Unit string `json:"unit"` @@ -431,6 +434,10 @@ func parseLogEntryWithDetails(payload []byte, details bool) (LogEntry, bool) { unit = stringValue(row["_SYSTEMD_USER_UNIT"]) } entry := LogEntry{Timestamp: time.UnixMicro(micros).UTC().Format(time.RFC3339Nano), Priority: stringValue(row["PRIORITY"]), Unit: unit, Message: stringValue(row["MESSAGE"]), Cursor: stringValue(row["__CURSOR"])} + if entry.Cursor != "" { + digest := sha256.Sum256([]byte(entry.Cursor)) + entry.ID = hex.EncodeToString(digest[:16]) + } if details { const maxDetailBytes = 64 << 10 const maxDetailValue = 16 << 10 diff --git a/apps/backend/internal/platform/storage.go b/apps/backend/internal/platform/storage.go index 8e0179c..f90f9c5 100644 --- a/apps/backend/internal/platform/storage.go +++ b/apps/backend/internal/platform/storage.go @@ -14,8 +14,9 @@ import ( var errStatfsUnavailable = errors.New("statfs failed") type MountPoint struct { - Target string `json:"target"` - Root string `json:"root,omitempty"` + Target string `json:"target"` + Root string `json:"root,omitempty"` + ReadOnly bool `json:"readOnly"` } type Filesystem struct { @@ -95,14 +96,14 @@ func parseMountInfo(reader io.Reader) ([]mountEntry, error) { } left := strings.Fields(line[:separator]) right := strings.Fields(line[separator+3:]) - if len(left) < 5 || len(right) < 2 { + if len(left) < 6 || len(right) < 2 { continue } entries = append(entries, mountEntry{ majorMinor: left[2], root: unescapeMount(left[3]), target: unescapeMount(left[4]), - options: left[5:], + options: strings.Split(left[5], ","), fsType: right[0], source: unescapeMount(right[1]), }) @@ -133,16 +134,18 @@ func buildFilesystems(entries []mountEntry, statfs statfsFunc) []Filesystem { MajorMinor: entry.majorMinor, Network: network, Device: entry.source, + ReadOnly: true, }, targets: map[string]MountPoint{}} groups[entry.majorMinor] = group order = append(order, entry.majorMinor) } - group.targets[entry.target] = MountPoint{Target: entry.target, Root: entry.root} + readOnly := hasReadOnlyOption(entry.options) + group.targets[entry.target] = MountPoint{Target: entry.target, Root: entry.root, ReadOnly: readOnly} + group.filesystem.ReadOnly = group.filesystem.ReadOnly && readOnly if !group.measured { var stat syscall.Statfs_t if statfs(entry.target, &stat) == nil { fillUsage(group.filesystem, &stat) - group.filesystem.ReadOnly = hasReadOnlyOption(entry.options) group.measured = true } } From 9947da4e5d07c266e5db7a94cf2854070cd6bcc6 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:56:59 +0530 Subject: [PATCH 19/88] fix(sessiond): bound journal queries and follows per UID Add per-UID slot limits for log queries and follows, time-box follow streams, close connections on exit, and re-check authorization on a ticker so revoked grants stop streaming. --- apps/backend/internal/sessiond/journal.go | 68 ++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/apps/backend/internal/sessiond/journal.go b/apps/backend/internal/sessiond/journal.go index e25b7e3..4753061 100644 --- a/apps/backend/internal/sessiond/journal.go +++ b/apps/backend/internal/sessiond/journal.go @@ -7,6 +7,7 @@ import ( "net" "os/user" "strconv" + "sync" "syscall" "time" @@ -24,15 +25,38 @@ func handleJournal(conn net.Conn, encoder *json.Encoder, request auth.Request, g _ = encoder.Encode(auth.Response{Error: "invalid-journal-query"}) return } - _, cred, errCode := journalIdentity(request, grants) + identity, cred, errCode := journalIdentity(request, grants) if errCode != "" { _ = encoder.Encode(auth.Response{Error: errCode}) return } + release, allowed := journalSlots.acquire(identity.UID, follow) + if !allowed { + _ = encoder.Encode(auth.Response{Error: "logs-busy"}) + return + } + defer release() if follow { _ = conn.SetDeadline(time.Time{}) - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() + defer conn.Close() + go func() { var buffer [1]byte; _, _ = conn.Read(buffer[:]); cancel() }() + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, _, code := journalIdentity(request, grants); code != "" { + cancel() + return + } + } + } + }() err := platform.FollowJournalAs(ctx, query, cred, func(entry platform.LogEntry) error { entry.Cursor = "" return encoder.Encode(auth.Response{LogEntry: &entry}) @@ -83,3 +107,43 @@ func journalIdentity(request auth.Request, grants *grantStore) (auth.Identity, * } return identity, cred, "" } + +type journalSlotKey struct { + uid int + follow bool +} + +var journalSlots = journalSlotLimiter{counts: make(map[journalSlotKey]int)} + +type journalSlotLimiter struct { + sync.Mutex + counts map[journalSlotKey]int + total int +} + +func (l *journalSlotLimiter) acquire(uid int, follow bool) (func(), bool) { + l.Lock() + defer l.Unlock() + key := journalSlotKey{uid, follow} + limit := 8 + if follow { + limit = 4 + } + if l.total >= 64 || l.counts[key] >= limit { + return nil, false + } + l.counts[key]++ + l.total++ + var once sync.Once + return func() { + once.Do(func() { + l.Lock() + defer l.Unlock() + l.counts[key]-- + l.total-- + if l.counts[key] == 0 { + delete(l.counts, key) + } + }) + }, true +} From baacefbe41d845ff761fa840424bf05a8cd9de9a Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:56:59 +0530 Subject: [PATCH 20/88] feat(gateway): scope file access, stream downloads, bound login attempts Add home/system file scope with elevation enforcement and absolute path rewriting for administrative scope. Support paginated listing, fingerprint-pinned full-download streaming, and strict resumable-upload range checks. Stop logging query strings and cap the login limiter with periodic sweeps. --- apps/backend/internal/app/app.go | 113 ++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 11 deletions(-) diff --git a/apps/backend/internal/app/app.go b/apps/backend/internal/app/app.go index 5d667a9..553df75 100644 --- a/apps/backend/internal/app/app.go +++ b/apps/backend/internal/app/app.go @@ -734,7 +734,6 @@ func (server *Server) logRequest(next http.Handler) http.Handler { zap.String("request_id", middleware.GetReqID(request.Context())), zap.String("method", request.Method), zap.String("path", request.URL.Path), - zap.String("query", request.URL.RawQuery), zap.Int("status", wrapped.Status()), zap.Int("bytes", wrapped.BytesWritten()), zap.String("remote_ip", request.RemoteAddr), @@ -1420,7 +1419,17 @@ func writeSecurityOperationError(writer http.ResponseWriter, err error) { } func (server *Server) filesList(writer http.ResponseWriter, request *http.Request) { - operation := platform.FileOperation{Action: "list", Path: request.URL.Query().Get("path"), ShowHidden: request.URL.Query().Get("hidden") == "true"} + operation := platform.FileOperation{Action: "list", Path: request.URL.Query().Get("path"), ShowHidden: request.URL.Query().Get("hidden") == "true", ExpectedFingerprint: request.URL.Query().Get("fingerprint")} + for name, target := range map[string]*int64{"offset": &operation.Offset, "limit": &operation.Limit} { + if raw := request.URL.Query().Get(name); raw != "" { + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value < 0 { + problem(writer, http.StatusBadRequest, "invalid-file-range", "Directory page is invalid") + return + } + *target = value + } + } if operation.Path == "" { operation.Path = "." } @@ -1613,9 +1622,37 @@ func (server *Server) fileContent(writer http.ResponseWriter, request *http.Requ } writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", result.Offset, end, result.Total)) } - writer.Header().Set("Content-Length", strconv.Itoa(len(result.Content))) + fullDownload := status == http.StatusOK && request.URL.Query().Get("offset") == "" && request.URL.Query().Get("limit") == "" + length := int64(len(result.Content)) + if fullDownload { + length = result.Total + } + writer.Header().Set("Content-Length", strconv.FormatInt(length, 10)) writer.WriteHeader(status) - _, _ = writer.Write(result.Content) + if _, err := writer.Write(result.Content); err != nil { + return + } + for next := int64(len(result.Content)); fullDownload && next < result.Total; { + current, exists := request.Context().Value(sessionKey{}).(session.Session) + if !exists { + return + } + operation := platform.FileOperation{Action: "read", Path: path, Offset: next, Limit: platform.MaxFileChunk, ExpectedFingerprint: result.Fingerprint, Scope: request.URL.Query().Get("scope")} + fileRequest := auth.FileRequest{Operation: operation, Token: current.Identity.BridgeToken} + if operation.Scope == "system" { + fileRequest.Token = "" + fileRequest.Administrative = true + fileRequest.AdminToken = current.Identity.AdminToken + } + chunk, err := server.hostBroker().ApplyFileOperation(request.Context(), fileRequest) + if err != nil || len(chunk.Content) == 0 { + return + } + if _, err := writer.Write(chunk.Content); err != nil { + return + } + next += int64(len(chunk.Content)) + } } func (server *Server) fileTextWindow(writer http.ResponseWriter, request *http.Request) { @@ -1673,7 +1710,7 @@ func (server *Server) fileUpload(writer http.ResponseWriter, request *http.Reque if raw := request.URL.Query().Get("total"); raw != "" && err == nil { total, err = strconv.ParseInt(raw, 10, 64) } - if err != nil || offset < 0 || total < 0 || (total > 0 && offset > total) { + if err != nil || offset < 0 || total <= 0 || total > 1<<40 || offset > total { problem(writer, http.StatusBadRequest, "invalid-file-range", "The resumable upload range is invalid") return } @@ -1683,8 +1720,12 @@ func (server *Server) fileUpload(writer http.ResponseWriter, request *http.Reque problem(writer, http.StatusRequestEntityTooLarge, "file-chunk-too-large", "Upload chunks are bounded to 4 MiB") return } + if int64(len(content)) > total-offset || len(content) == 0 { + problem(writer, http.StatusBadRequest, "invalid-file-range", "Chunk exceeds declared upload size") + return + } checksum := request.Header.Get("X-Content-SHA256") - operation := platform.FileOperation{Action: "write-chunk", Path: path, Offset: offset, TotalSize: total, Content: content, ContentSHA256: checksum} + operation := platform.FileOperation{Action: "write-chunk", Path: path, Offset: offset, TotalSize: total, Content: content, ContentSHA256: checksum, UploadID: request.URL.Query().Get("uploadId"), ExpectedFingerprint: request.Header.Get("X-File-Fingerprint")} result, ok := server.applyFileOperation(writer, request, operation) if ok { writer.Header().Set("Upload-Offset", strconv.FormatInt(result.Offset, 10)) @@ -1699,7 +1740,19 @@ func (server *Server) applyFileOperation(writer http.ResponseWriter, request *ht return platform.FileResult{}, false } fileRequest := auth.FileRequest{Operation: operation} - administrative := current.Identity.AdminToken != "" && time.Now().Before(current.AdminUntil) + if operation.Scope == "" { + operation.Scope = request.URL.Query().Get("scope") + } + if operation.Scope != "" && operation.Scope != "home" && operation.Scope != "system" { + problem(writer, http.StatusBadRequest, "invalid-file-scope", "File scope is invalid") + return platform.FileResult{}, false + } + fileRequest.Operation = operation + administrative := operation.Scope == "system" + if administrative && (current.Identity.AdminToken == "" || !time.Now().Before(current.AdminUntil)) { + problem(writer, http.StatusForbidden, "elevation-required", "Elevate before opening system files") + return platform.FileResult{}, false + } if administrative { fileRequest.AdminToken = current.Identity.AdminToken fileRequest.Administrative = true @@ -1716,6 +1769,32 @@ func (server *Server) applyFileOperation(writer http.ResponseWriter, request *ht server.recordOperation(request.Context(), current.Identity.Username, "file/"+operation.Action, startedAt, "failed", err.Error(), administrative) return platform.FileResult{}, false } + if administrative { + absolutePath := func(path string) string { + if path == "." { + return "/" + } + return "/" + strings.TrimPrefix(path, "/") + } + absolute := func(entry *platform.FileEntry) { + if entry != nil { + entry.Path = absolutePath(entry.Path) + } + } + absolute(result.Entry) + if result.Directory != nil { + result.Directory.Path = absolutePath(result.Directory.Path) + result.Directory.Parent = absolutePath(result.Directory.Parent) + for i := range result.Directory.Entries { + absolute(&result.Directory.Entries[i]) + } + } + if result.Search != nil { + for i := range result.Search.Entries { + absolute(&result.Search.Entries[i]) + } + } + } if operation.Action != "list" && operation.Action != "stat" && operation.Action != "read" && operation.Action != "read-window" && operation.Action != "search" { server.recordOperation(request.Context(), current.Identity.Username, "file/"+operation.Action, startedAt, "succeeded", "", administrative) } @@ -2606,10 +2685,11 @@ type loginAttempt struct { } type loginLimiter struct { - mu sync.Mutex - limit int - window time.Duration - attempts map[string]loginAttempt + mu sync.Mutex + limit int + window time.Duration + attempts map[string]loginAttempt + nextSweep time.Time } func newLoginLimiter(limit int, window time.Duration) *loginLimiter { @@ -2624,6 +2704,17 @@ func (limiter *loginLimiter) Allow(address string) bool { now := time.Now() limiter.mu.Lock() defer limiter.mu.Unlock() + if now.After(limiter.nextSweep) { + for key, value := range limiter.attempts { + if now.After(value.until) { + delete(limiter.attempts, key) + } + } + limiter.nextSweep = now.Add(time.Minute) + } + if _, exists := limiter.attempts[host]; !exists && len(limiter.attempts) >= 8192 { + return false + } attempt := limiter.attempts[host] if now.After(attempt.until) { attempt = loginAttempt{until: now.Add(limiter.window)} From c626235f630f45e03f21b4cdae2e51a47bcd0dfd Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:56:59 +0530 Subject: [PATCH 21/88] fix(metrics): harden sampler concurrency and subscription intervals Guard sampler configuration and subscriptions with the mutex, clamp capacity and intervals to sane minima, make unsubscribe idempotent, and dedupe samples within retention buckets. --- apps/backend/internal/metrics/sampler.go | 36 ++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/backend/internal/metrics/sampler.go b/apps/backend/internal/metrics/sampler.go index 97f9621..b2958dd 100644 --- a/apps/backend/internal/metrics/sampler.go +++ b/apps/backend/internal/metrics/sampler.go @@ -61,17 +61,25 @@ type subscription struct { } func NewSampler(capacity int) *Sampler { - return &Sampler{capacity: capacity, subscribers: make(map[chan Sample]subscription), wake: make(chan struct{}, 1), defaultInterval: time.Minute, retention: 24 * time.Hour} + return &Sampler{capacity: max(capacity, 2), subscribers: make(map[chan Sample]subscription), wake: make(chan struct{}, 1), defaultInterval: time.Minute, retention: 24 * time.Hour} } func (sampler *Sampler) Configure(defaultInterval, retention time.Duration) { - sampler.defaultInterval = defaultInterval - sampler.retention = retention + sampler.mu.Lock() + defer sampler.mu.Unlock() + if defaultInterval >= time.Second { + sampler.defaultInterval = defaultInterval + } + if retention > 0 { + sampler.retention = retention + } } func (sampler *Sampler) Run(ctx context.Context, interval time.Duration) { - if interval > 0 { + if interval >= time.Second { + sampler.mu.Lock() sampler.defaultInterval = interval + sampler.mu.Unlock() } sampler.collect() for { @@ -142,10 +150,14 @@ func (sampler *Sampler) Current() (Sample, bool) { } func (sampler *Sampler) Subscribe() (<-chan Sample, func()) { - return sampler.SubscribeEvery(sampler.defaultInterval) + sampler.mu.RLock() + interval := sampler.defaultInterval + sampler.mu.RUnlock() + return sampler.SubscribeEvery(interval) } func (sampler *Sampler) SubscribeEvery(interval time.Duration) (<-chan Sample, func()) { + interval = max(interval, time.Second) channel := make(chan Sample, 4) sampler.mu.Lock() sampler.subscribers[channel] = subscription{interval: interval} @@ -156,8 +168,10 @@ func (sampler *Sampler) SubscribeEvery(interval time.Duration) (<-chan Sample, f } return channel, func() { sampler.mu.Lock() - delete(sampler.subscribers, channel) - close(channel) + if _, exists := sampler.subscribers[channel]; exists { + delete(sampler.subscribers, channel) + close(channel) + } sampler.mu.Unlock() select { case sampler.wake <- struct{}{}: @@ -201,7 +215,13 @@ func (sampler *Sampler) collect() { for len(sampler.samples) > 0 && sampler.samples[0].Timestamp.Before(cutoff) { sampler.samples = sampler.samples[1:] } - if len(sampler.samples) == sampler.capacity { + historyInterval := sampler.retention / time.Duration(sampler.capacity-1) + if historyInterval < time.Second { + historyInterval = time.Second + } + if len(sampler.samples) > 0 && sampler.samples[len(sampler.samples)-1].Timestamp.Truncate(historyInterval) == now.Truncate(historyInterval) { + sampler.samples[len(sampler.samples)-1] = sample + } else if len(sampler.samples) == sampler.capacity { copy(sampler.samples, sampler.samples[1:]) sampler.samples[len(sampler.samples)-1] = sample } else { From fb1032a125df910a881482e515beacd684c6bfcf Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:57:05 +0530 Subject: [PATCH 22/88] style(dashboard): align advisory markdown spacing with flex convention Replace space-y-2 with flex flex-col gap-2 without functional or markup changes. --- apps/dashboard/src/components/advisory-markdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/advisory-markdown.tsx b/apps/dashboard/src/components/advisory-markdown.tsx index 2f97af3..208833d 100644 --- a/apps/dashboard/src/components/advisory-markdown.tsx +++ b/apps/dashboard/src/components/advisory-markdown.tsx @@ -5,7 +5,7 @@ import ReactMarkdown from "react-markdown" // repositories can never inject markup. export function AdvisoryMarkdown({ text }: { text: string }) { return ( -
+
( From 36123a50c81fe856867a81c622379f80aa340531 Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:57:05 +0530 Subject: [PATCH 23/88] fix(dashboard): virtualize only large tables and expose scroll position Gate row virtualization on row count so small tables render without absolute positioning, and plumb a scroll-position callback through ScrollArea for scroll-aware consumers. Stub getAnimations in test setup for Radix components under jsdom. --- apps/dashboard/src/components/data-table.tsx | 21 ++++++++++++++----- .../src/components/ui/scroll-area.tsx | 3 +++ apps/dashboard/src/test/setup.ts | 4 ++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/components/data-table.tsx b/apps/dashboard/src/components/data-table.tsx index 40ea843..9493cbc 100644 --- a/apps/dashboard/src/components/data-table.tsx +++ b/apps/dashboard/src/components/data-table.tsx @@ -90,7 +90,9 @@ export function DataTable({ toolbar, search, onSearchChange, + onScrollPosition, }: { + onScrollPosition?: (top: number) => void data: T[] columns: ColumnDef[] searchPlaceholder?: string @@ -130,14 +132,18 @@ export function DataTable({ }, }) const rows = table.getRowModel().rows + const virtualized = rows.length > 40 const virtualizer = useVirtualizer({ + enabled: virtualized, count: rows.length, getScrollElement: () => viewport.current, estimateSize: () => 42, overscan: 12, measureElement: (element) => element.getBoundingClientRect().height, }) - const virtualRows = virtualizer.getVirtualItems() + const virtualRows = virtualized + ? virtualizer.getVirtualItems() + : rows.map((_, index) => ({ index, start: 0 })) const totalSize = table.getTotalSize() return (
@@ -187,6 +193,9 @@ export function DataTable({
+ onScrollPosition?.(event.currentTarget.scrollTop) + } className="rounded-lg border" style={{ height }} > @@ -249,7 +258,7 @@ export function DataTable({ @@ -260,7 +269,7 @@ export function DataTable({ key={row.id} data-index={virtualRow.index} ref={(node) => { - virtualizer.measureElement(node) + if (virtualized) virtualizer.measureElement(node) }} tabIndex={onRowClick ? 0 : undefined} className={cn("flex", onRowClick && "cursor-pointer")} @@ -273,8 +282,10 @@ export function DataTable({ onRowClick(row.original) }} style={{ - position: "absolute", - transform: `translateY(${virtualRow.start}px)`, + position: virtualized ? "absolute" : "relative", + transform: virtualized + ? `translateY(${virtualRow.start}px)` + : undefined, width: "100%", minWidth: totalSize, display: "flex", diff --git a/apps/dashboard/src/components/ui/scroll-area.tsx b/apps/dashboard/src/components/ui/scroll-area.tsx index c9efacf..5cab208 100644 --- a/apps/dashboard/src/components/ui/scroll-area.tsx +++ b/apps/dashboard/src/components/ui/scroll-area.tsx @@ -8,9 +8,11 @@ function ScrollArea({ className, children, viewportRef, + onViewportScroll, ...props }: ScrollAreaPrimitive.Root.Props & { viewportRef?: React.Ref + onViewportScroll?: React.UIEventHandler }) { return ( diff --git a/apps/dashboard/src/test/setup.ts b/apps/dashboard/src/test/setup.ts index a24491d..c1a5dbb 100644 --- a/apps/dashboard/src/test/setup.ts +++ b/apps/dashboard/src/test/setup.ts @@ -13,6 +13,10 @@ class ResizeObserverStub implements ResizeObserver { globalThis.ResizeObserver = ResizeObserverStub +if (!Element.prototype.getAnimations) { + Element.prototype.getAnimations = () => [] as Animation[] +} + if (!Element.prototype.hasPointerCapture) { Element.prototype.hasPointerCapture = () => false Element.prototype.setPointerCapture = () => {} From 0699a67e8a6c28d191b8729372cfad355bf62b1c Mon Sep 17 00:00:00 2001 From: Krishna Santosh <75202541+krishna-santosh@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:57:12 +0530 Subject: [PATCH 24/88] feat(dashboard): scoped file browser with pagination, search, resumable uploads Rebuild the file browser on DataTable with home/system scope, server-side pagination, subfolder search, and full CRUD dialogs including trash/restore and system-only chmod. Add resumable 4 MiB chunked uploads with per-chunk SHA-256, pause/retry/discard, and scope-aware media preview and text editor with dirty tracking. Extend the FileResult API contract with upload and pagination fields. --- .../dashboard/src/components/file-browser.tsx | 1025 +++++++++++------ .../src/components/media-preview.tsx | 10 +- apps/dashboard/src/components/text-editor.tsx | 80 +- apps/dashboard/src/lib/api.ts | 4 + apps/dashboard/src/routes/files.tsx | 5 +- 5 files changed, 778 insertions(+), 346 deletions(-) diff --git a/apps/dashboard/src/components/file-browser.tsx b/apps/dashboard/src/components/file-browser.tsx index 57923c7..71330ee 100644 --- a/apps/dashboard/src/components/file-browser.tsx +++ b/apps/dashboard/src/components/file-browser.tsx @@ -1,134 +1,230 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import type { ColumnDef } from "@tanstack/react-table" import { - CopyIcon, - DownloadIcon, + ArrowUpIcon, FileIcon, FolderIcon, MoreHorizontalIcon, + PlusIcon, RefreshCwIcon, - Trash2Icon, UploadIcon, } from "lucide-react" import * as React from "react" +import { DataTable, type DataTableFeatures } from "@/components/data-table" import { MediaPreview } from "@/components/media-preview" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu" + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@/components/ui/empty" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" +import { Progress } from "@/components/ui/progress" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" import { Skeleton } from "@/components/ui/skeleton" import { api, type FileEntry, type FileResult } from "@/lib/api" +import { bytes } from "@/lib/page" -const encodePath = (path: string) => encodeURIComponent(path) - -function formatBytes(size: number) { - if (size < 1024) return `${size} B` - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB` - if (size < 1024 * 1024 * 1024) - return `${(size / (1024 * 1024)).toFixed(1)} MiB` - return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GiB` +type Action = { action: string; entry?: FileEntry; kind?: string } +type Transfer = { + file: File + path: string + scope: string + offset: number + id: string } - -function parentPath(path: string) { - const parts = path.split("/").filter(Boolean) +const parentPath = (path: string) => { + const absolute = path.startsWith("/") + const parts = path.split("/").filter((part) => part && part !== ".") parts.pop() - return parts.length ? `/${parts.join("/")}` : "." + return parts.length + ? `${absolute ? "/" : ""}${parts.join("/")}` + : absolute + ? "/" + : "." } +const joinPath = (path: string, name: string) => + path === "." ? name : `${path.replace(/\/$/, "")}/${name}` -export function FileBrowser({ csrfToken }: { csrfToken: string }) { - const queryClient = useQueryClient() +export function FileBrowser({ + csrfToken, + administrative = false, +}: { + csrfToken: string + administrative?: boolean +}) { + const client = useQueryClient() + const [scope, setScope] = React.useState("home") const [path, setPath] = React.useState(".") - const [showHidden, setShowHidden] = React.useState(false) - const [query, setQuery] = React.useState("") + const [location, setLocation] = React.useState(".") + const [hidden, setHidden] = React.useState(false) + const [pages, setPages] = React.useState([0]) + const [fingerprint, setFingerprint] = React.useState("") + const [filter, setFilter] = React.useState("") + const [search, setSearch] = React.useState("") const [selected, setSelected] = React.useState(null) + const [dirty, setDirty] = React.useState(false) + const [discardPreview, setDiscardPreview] = React.useState(false) + const [action, setAction] = React.useState(null) + const [value, setValue] = React.useState("") + const [error, setError] = React.useState("") + const [transfer, setTransfer] = React.useState(null) + const transferRef = React.useRef(null) const [uploading, setUploading] = React.useState(false) - const inputRef = React.useRef(null) - const files = useQuery({ - queryKey: ["files", path, showHidden], - queryFn: () => + const controller = React.useRef(null) + const input = React.useRef(null) + const offset = pages[pages.length - 1] + const query = useQuery({ + queryKey: ["files", scope, path, hidden, offset, search], + queryFn: ({ signal }) => api( - `/files?path=${encodePath(path)}&hidden=${String(showHidden)}` + search + ? `/files/search?${new URLSearchParams({ scope, path, query: search, maxEntries: "500" })}` + : `/files?${new URLSearchParams({ scope, path, hidden: String(hidden), offset: String(offset), limit: "200", ...(offset ? { fingerprint } : {}) })}`, + { signal } ), }) + const refresh = () => { + setPages([0]) + setFingerprint("") + void client.invalidateQueries({ queryKey: ["files"] }) + } + const navigate = (next: string) => { + setSelected(null) + setPath(next) + setLocation(next) + setPages([0]) + setFingerprint("") + setSearch("") + setFilter("") + setError("") + } const mutation = useMutation({ mutationFn: (operation: Record) => api("/files", { method: "POST", headers: { "X-CSRF-Token": csrfToken }, - body: JSON.stringify(operation), + body: JSON.stringify({ ...operation, scope }), }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["files"] }) + setAction(null) + setSelected(null) + refresh() }, }) - - const entries = (files.data?.directory?.entries ?? []).filter((entry) => - entry.name.toLowerCase().includes(query.toLowerCase()) - ) - const open = (entry: FileEntry) => { - if (entry.permissionDenied) return - if (entry.kind === "directory") { + React.useEffect(() => () => controller.current?.abort(), []) + React.useEffect(() => { + if (scope === "system" && !administrative) { + setScope("home") + setPath(".") + setLocation(".") + setPages([0]) setSelected(null) - setPath(entry.path) - } else setSelected(entry) - } + } + }, [scope, administrative]) + const items = + query.data?.search?.entries ?? query.data?.directory?.entries ?? [] const download = (entry: FileEntry) => { const anchor = document.createElement("a") - const token = entry.previewToken - ? `&token=${encodePath(entry.previewToken)}` - : "" - anchor.href = `/api/v1/files/content?path=${encodePath(entry.path)}${token}` + anchor.href = `/api/v1/files/content?${new URLSearchParams({ path: entry.path, scope, ...(entry.previewToken ? { token: entry.previewToken } : {}) })}` anchor.download = entry.name anchor.click() } - const upload = async (file: File) => { + const open = (entry: FileEntry) => { + if (entry.permissionDenied) return + if (entry.kind === "directory") navigate(entry.path) + else setSelected(entry) + } + const choose = (next: Action) => { + mutation.reset() + setAction(next) + setValue( + next.action === "metadata" + ? (next.entry?.mode.toString(8) ?? "644") + : next.action === "rename" + ? (next.entry?.name ?? "") + : "" + ) + } + const runAction = () => { + if (!action) return + const operation: Record = { + action: action.action, + path: action.entry?.path ?? joinPath(path, value), + expectedFingerprint: action.entry?.fingerprint, + confirmation: "CONFIRM FILE OPERATION", + } + if (action.action === "create") operation.kind = action.kind + if (["rename", "move", "copy"].includes(action.action)) + operation.destination = + action.action === "rename" + ? joinPath(parentPath(action.entry?.path ?? path), value) + : value + if (action.action === "metadata") operation.mode = Number.parseInt(value, 8) + if (action.action === "delete") { + operation.permanent = true + operation.recursive = action.entry?.kind === "directory" + } + mutation.mutate(operation) + } + const upload = async (task: Transfer) => { + controller.current = new AbortController() setUploading(true) + setError("") + transferRef.current = task + setTransfer({ ...task }) try { - const destination = path === "." ? file.name : `${path}/${file.name}` - if (file.size === 0) { + if (task.file.size === 0) { await api("/files", { method: "POST", headers: { "X-CSRF-Token": csrfToken }, body: JSON.stringify({ action: "create", - path: destination, kind: "file", + path: task.path, + scope: task.scope, }), }) - void queryClient.invalidateQueries({ queryKey: ["files"] }) - return } - let offset = 0 - const chunkSize = 4 * 1024 * 1024 - while (offset < file.size || (file.size === 0 && offset === 0)) { - const chunk = file.slice(offset, offset + chunkSize) - const payload = await chunk.arrayBuffer() - const digest = await crypto.subtle.digest("SHA-256", payload) - const checksum = Array.from(new Uint8Array(digest)) - .map((value) => value.toString(16).padStart(2, "0")) - .join("") + while (task.offset < task.file.size) { + const payload = await task.file + .slice(task.offset, task.offset + 4 * 1024 * 1024) + .arrayBuffer() + const hash = await crypto.subtle.digest("SHA-256", payload) + const checksum = Array.from(new Uint8Array(hash), (byte) => + byte.toString(16).padStart(2, "0") + ).join("") const response = await fetch( - `/api/v1/files/upload?path=${encodePath(destination)}&offset=${offset}&total=${file.size}`, + `/api/v1/files/upload?${new URLSearchParams({ path: task.path, scope: task.scope, offset: String(task.offset), total: String(task.file.size), uploadId: task.id })}`, { method: "POST", credentials: "same-origin", + signal: controller.current.signal, headers: { "X-CSRF-Token": csrfToken, "X-Content-SHA256": checksum, @@ -137,277 +233,562 @@ export function FileBrowser({ csrfToken }: { csrfToken: string }) { body: payload, } ) - if (!response.ok) throw new Error("upload failed") - const result = (await response.json()) as { offset?: number } - offset = result.offset ?? offset + payload.byteLength - if (file.size === 0) break + const result = await response.json() + if (!response.ok) throw new Error(result.detail ?? "Upload failed") + if ( + !Number.isFinite(result.offset) || + result.offset <= task.offset || + result.offset > task.file.size + ) + throw new Error("Server returned an invalid upload offset") + task.id = result.uploadId + task.offset = result.offset + setTransfer({ ...task }) } - void queryClient.invalidateQueries({ queryKey: ["files"] }) + setTransfer(null) + transferRef.current = null + refresh() + } catch (cause) { + setError( + cause instanceof Error && cause.name === "AbortError" + ? "Upload paused. Retry to resume or discard it." + : cause instanceof Error + ? cause.message + : "Upload failed" + ) } finally { setUploading(false) - if (inputRef.current) inputRef.current.value = "" + if (input.current) input.current.value = "" } } - - return ( - - -
- Files - - Browse under your authenticated UNIX authority. Locked entries stay - visible without leaking content. - -
-
- + ), + }, + { + accessorKey: "size", + header: "Size", + size: 110, + cell: ({ row }) => + row.original.kind === "directory" ? "—" : bytes(row.original.size), + }, + { + accessorKey: "modifiedAt", + header: "Modified", + size: 180, + cell: ({ row }) => new Date(row.original.modifiedAt).toLocaleString(), + }, + { + accessorKey: "mode", + header: "Permissions", + size: 120, + cell: ({ row }) => + row.original.permissionDenied ? ( + Locked + ) : ( + {row.original.mode.toString(8).padStart(3, "0")} + ), + }, + { + id: "actions", + header: "", + size: 60, + cell: ({ row }) => ( + + + } > - + + + open(row.original)} + > + Open + + download(row.original)} + > + Download + + {[ + "rename", + "move", + "copy", + "trash", + ...(path.includes(".local/share/Trash/files") + ? ["restore", "delete"] + : []), + ...(scope === "system" ? ["metadata"] : []), + ].map((name) => ( + choose({ action: name, entry: row.original })} + > + {name === "metadata" + ? "Permissions" + : name === "trash" + ? "Move to trash" + : name[0].toUpperCase() + name.slice(1)} + + ))} + + + + ), + }, + ] + const needsValue = + action && + ["create", "rename", "move", "copy", "metadata"].includes(action.action) + const validValue = + !needsValue || + (value.trim() !== "" && + (action.action !== "metadata" || /^[0-7]{3,4}$/.test(value))) + return ( +
+
+ + {scope === "system" ? "System · elevated" : "Home"} + + + +
+ + + + }> + + New + + + + choose({ action: "create", kind: "directory" })} + > + Folder + + choose({ action: "create", kind: "file" })} + > + File + + + + + { + const file = event.target.files?.[0] + if (file) + void upload({ + file, + path: joinPath(path, file.name), + scope, + offset: 0, + id: "", + }) + }} + /> + +
+
{ + event.preventDefault() + navigate(location.trim() || ".") + }} + > + + + Location + setLocation(event.target.value)} /> + + +
+ + {transfer && ( +
+
+ + {transfer.file.name} · {bytes(transfer.offset)} /{" "} + {bytes(transfer.file.size)} + + {uploading ? ( + + ) : ( + <> + + + + )} +
+ +
+ )} + {error && ( + + Transfer interrupted + {error} + + )} + {query.isPending && } + {query.isError && ( + + Files unavailable + + {query.error.message} + + + + )} + {!query.isPending && !query.isError && items.length === 0 && ( + + + This folder is empty + + {search + ? "No matching names found. Clear search or choose another folder." + : "Create a folder or upload a file to get started."} + + + + )} + {!query.isPending && !query.isError && ( + + + {search && ( + + )} + + } + /> + )} + {query.data?.search?.limited && ( +

+ Search limit reached. Choose a narrower folder. +

+ )} +
+ + {items.length} items on this page + +
-
- - -
- - - {path} - - setQuery(event.target.value)} - aria-label="Filter files" - />
- {files.isPending && } - {files.isError && ( - - Files unavailable - - The authenticated UNIX bridge could not read this folder. Try - refreshing or use the terminal. - - - )} - {!files.isPending && !files.isError && entries.length === 0 && ( -
- This folder is empty or no entries match the filter. +
+ { + if (!open) { + if (dirty) setDiscardPreview(true) + else setSelected(null) + } + }} + > + + + {selected?.name ?? "File details"} + {selected?.path} + +
+
- )} - {!files.isPending && !files.isError && entries.length > 0 && ( - <> - {/* biome-ignore lint/a11y/useSemanticElements: entries are interactive rows (keyboard operable, context menu), not tabular data with headers; a table/treegrid conversion is out of scope */} -
+ + + + + Discard unsaved changes? + + Your edits have not been saved. + + + + + - {entry.permissionDenied && ( - - Locked - - )} - - {formatBytes(entry.size)} - - - - } - > - - - open(entry)} - disabled={entry.permissionDenied} - > - - download(entry)} - disabled={ - entry.permissionDenied || - entry.kind === "directory" - } - > - - - mutation.mutate({ - action: "copy", - path: entry.path, - destination: `${entry.path}.copy`, - expectedFingerprint: entry.fingerprint, - }) - } - disabled={ - entry.permissionDenied || - entry.kind === "directory" - } - > - - - mutation.mutate({ - action: "trash", - path: entry.path, - expectedFingerprint: entry.fingerprint, - }) - } - disabled={entry.permissionDenied} - > - - - -
- - - open(entry)} - disabled={entry.permissionDenied} - > - - download(entry)} - disabled={ - entry.permissionDenied || entry.kind === "directory" - } - > - - - mutation.mutate({ - action: "copy", - path: entry.path, - destination: `${entry.path}.copy`, - expectedFingerprint: entry.fingerprint, - }) - } - disabled={ - entry.permissionDenied || entry.kind === "directory" - } - > - - - mutation.mutate({ - action: "trash", - path: entry.path, - expectedFingerprint: entry.fingerprint, - }) - } - disabled={entry.permissionDenied} - > - - - - ))} -
- - )} - - {mutation.isError && ( - - File action failed - - Refresh the folder and retry after checking the item is still - present. - - - )} -

-

- - + Discard changes + + + + + { + if (!open && !mutation.isPending) setAction(null) + }} + > + + + + {action?.action === "create" + ? `New ${action.kind}` + : action?.action === "metadata" + ? "Change permissions" + : `${action?.action ?? "File action"} ${action?.entry?.name ?? ""}`} + + + {action?.action === "delete" + ? "This permanently deletes the item and cannot be undone." + : action?.action === "trash" + ? "Move this item to trash. Restore it later from Trash." + : "Review the destination and apply this file change."} + + +
{ + event.preventDefault() + runAction() + }} + > + {needsValue && ( + + + + {action.action === "metadata" + ? "Permissions (octal)" + : ["move", "copy"].includes(action.action) + ? "Destination path" + : "Name"} + + setValue(event.target.value)} + required + /> + + + )} + {mutation.isError && ( + + File action failed + {mutation.error.message} + + )} + + + + +
+
+
+
) } diff --git a/apps/dashboard/src/components/media-preview.tsx b/apps/dashboard/src/components/media-preview.tsx index efcb1b6..9efbe6e 100644 --- a/apps/dashboard/src/components/media-preview.tsx +++ b/apps/dashboard/src/components/media-preview.tsx @@ -7,7 +7,11 @@ import type { FileEntry } from "@/lib/api" export function MediaPreview({ entry, csrfToken, + scope = "home", + onDirtyChange, }: { + onDirtyChange?: (dirty: boolean) => void + scope?: string entry: FileEntry | null csrfToken: string }) { @@ -15,7 +19,7 @@ export function MediaPreview({ const token = entry.previewToken ? `&token=${encodeURIComponent(entry.previewToken)}` : "" - const source = `/api/v1/files/content?path=${encodeURIComponent(entry.path)}${token}` + const source = `/api/v1/files/content?path=${encodeURIComponent(entry.path)}&scope=${scope}${token}` const mime = entry.mime ?? "application/octet-stream" return ( @@ -63,6 +67,10 @@ export function MediaPreview({ mime === "application/json" || mime === "application/javascript") && ( void + scope?: string + size?: number path: string previewToken?: string csrfToken: string }) { + const queryClient = useQueryClient() + const queryKey = ["file-content", scope, path, previewToken] const [draft, setDraft] = React.useState(undefined) const content = useQuery({ - queryKey: ["file-content", path, previewToken], - queryFn: async () => { - const token = previewToken - ? `&token=${encodeURIComponent(previewToken)}` - : "" + queryKey, + queryFn: async ({ signal }) => { + if (size > 5 * 1024 * 1024) + throw new Error("File is too large to edit. Download it instead.") const response = await fetch( - `/api/v1/files/content?path=${encodeURIComponent(path)}${token}`, - { credentials: "same-origin" } + `/api/v1/files/content?path=${encodeURIComponent(path)}&scope=${scope}`, + { credentials: "same-origin", signal } ) if (!response.ok) throw new Error("file read failed") return { @@ -42,33 +50,49 @@ export function TextEditor({ }, }) const value = draft ?? content.data?.text ?? "" + React.useEffect(() => { + onDirtyChange?.(draft !== undefined && draft !== content.data?.text) + }, [draft, content.data?.text, onDirtyChange]) + React.useEffect(() => () => onDirtyChange?.(false), [onDirtyChange]) + React.useEffect(() => { + if (draft === undefined) return + const warn = (event: BeforeUnloadEvent) => { + event.preventDefault() + } + window.addEventListener("beforeunload", warn) + return () => window.removeEventListener("beforeunload", warn) + }, [draft]) const save = useMutation({ - mutationFn: () => + mutationFn: (saved: string) => api("/files", { method: "POST", headers: { "X-CSRF-Token": csrfToken }, body: JSON.stringify({ action: "write-text", + scope, path, - content: encodeContent(value), + content: encodeContent(saved), expectedFingerprint: content.data?.fingerprint, }), }), - onSuccess: () => void content.refetch(), + onSuccess: (result, saved) => { + queryClient.setQueryData(queryKey, { + text: saved, + fingerprint: result.entry?.fingerprint ?? "", + }) + setDraft((current) => (current === saved ? undefined : current)) + }, }) - if (content.isPending) - return
+ if (content.isPending) return if (content.isError) return ( Text unavailable - - The file could not be read under the current UNIX authority. - + {content.error.message} ) return ( -
+