diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..e12b081 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +self-hosted-runner: + labels: + - tako-vm-*-amd64 + - tako-vm-*-arm64 +config-variables: null diff --git a/.github/actions/setup-linux-backend/action.yml b/.github/actions/setup-linux-backend/action.yml new file mode 100644 index 0000000..fd19057 --- /dev/null +++ b/.github/actions/setup-linux-backend/action.yml @@ -0,0 +1,15 @@ +name: Set up Linux backend dependencies +description: Install the native libraries required to build and test Tako's Go backend +runs: + using: composite + steps: + - name: Install Linux backend dependencies + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends \ + gcc \ + libpam0g-dev \ + libsystemd-dev \ + systemd diff --git a/.github/coverage-baseline.env b/.github/coverage-baseline.env new file mode 100644 index 0000000..940119b --- /dev/null +++ b/.github/coverage-baseline.env @@ -0,0 +1,2 @@ +# Informational until the first stable CI run records the hosted-runner value. +GO_STATEMENT_COVERAGE_BASELINE=42.6 diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml new file mode 100644 index 0000000..75a7273 --- /dev/null +++ b/.github/workflows/beta-release.yml @@ -0,0 +1,131 @@ +name: Beta release + +on: + push: + tags: + - "v*-beta*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + ci: + name: Required CI gates + uses: ./.github/workflows/ci.yml + permissions: + contents: read + + integration: + name: Required VM integration + needs: ci + uses: ./.github/workflows/integration.yml + permissions: + contents: read + + native-packages: + name: Native package matrix + needs: ci + uses: ./.github/workflows/native-packages.yml + permissions: + contents: read + + lifecycle-validation: + name: Representative lifecycle validation + needs: ci + uses: ./.github/workflows/packaging-lifecycle.yml + permissions: + contents: read + + vm-validation: + name: Disposable VM validation matrix + needs: native-packages + uses: ./.github/workflows/vm-validation.yml + permissions: + contents: read + actions: read + + evidence-gate: + name: Verify all VM evidence + needs: [ci, integration, native-packages, vm-validation, lifecycle-validation] + runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: apps/backend/go.mod + cache: true + cache-dependency-path: | + apps/backend/go.sum + go.work.sum + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - uses: actions/download-artifact@v4 + with: + pattern: package-* + path: incoming + merge-multiple: true + - uses: actions/download-artifact@v4 + with: + pattern: vm-evidence-* + path: incoming + merge-multiple: true + - name: Fail closed unless every manifest target passed in a VM + run: TAKO_VM_COMMIT="$GITHUB_SHA" ./tools/release-gate incoming incoming + - name: Assemble packages from the manifest + run: bun tools/release-assemble incoming release + - name: Record dependency inventory + run: bun tools/release-dependencies release/dependency-inventory.json + - name: Generate release checksums + run: | + set -euo pipefail + checksums_tmp=$(mktemp) + trap 'rm -f "$checksums_tmp"' EXIT + cd release + find . -maxdepth 1 -type f ! -name checksums.txt -print0 \ + | sort -z \ + | xargs -0 sha256sum > "$checksums_tmp" + mv "$checksums_tmp" checksums.txt + - uses: sigstore/cosign-installer@v3 + - name: Sign the release checksum manifest + run: | + cosign sign-blob --yes \ + --output-signature release/checksums.txt.sig \ + --output-certificate release/checksums.txt.pem \ + release/checksums.txt + - name: Attest release provenance + uses: actions/attest-build-provenance@v2 + with: + subject-path: release/* + - uses: actions/upload-artifact@v4 + with: + name: beta-release-inputs + path: release/ + if-no-files-found: error + retention-days: 30 + + publish: + name: Publish beta release + needs: evidence-gate + if: startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-beta') + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: beta-release-inputs + path: release + - name: Publish only after CI, native builds, and every VM evidence gate passed + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + prerelease: true + generate_release_notes: true + files: release/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8ec0d06 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,314 @@ +name: CI + +on: + push: + pull_request: + workflow_call: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + BUN_VERSION: 1.4.0 + +jobs: + go-unit: + name: Go unit tests and coverage + runs-on: ubuntu-24.04 + env: + TAKO_DISTRO: ubuntu + GOCACHE: /tmp/tako-go-ci-cache + GO_COVERAGE_ENFORCE: ${{ vars.GO_COVERAGE_ENFORCE || 'false' }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-linux-backend + - uses: actions/setup-go@v5 + with: + go-version-file: apps/backend/go.mod + cache: true + cache-dependency-path: | + apps/backend/go.sum + go.work.sum + - name: Run Go suite with coverage + working-directory: apps/backend + run: | + set -euo pipefail + mkdir -p "$GITHUB_WORKSPACE/coverage/go" + ../../tools/tako-go.sh test \ + -covermode=atomic \ + -coverprofile="$GITHUB_WORKSPACE/coverage/go/coverage.out" \ + ./... + go tool cover -func="$GITHUB_WORKSPACE/coverage/go/coverage.out" \ + | tee "$GITHUB_WORKSPACE/coverage/go/coverage.txt" + go tool cover -html="$GITHUB_WORKSPACE/coverage/go/coverage.out" \ + -o "$GITHUB_WORKSPACE/coverage/go/coverage.html" + - name: Report Go coverage baseline + working-directory: apps/backend + run: | + set -euo pipefail + baseline=$(sed -n 's/^GO_STATEMENT_COVERAGE_BASELINE=//p' "$GITHUB_WORKSPACE/.github/coverage-baseline.env") + current=$(awk '/^total:/ { gsub("%", "", $NF); print $NF }' "$GITHUB_WORKSPACE/coverage/go/coverage.txt") + test -n "$baseline" + test -n "$current" + enforcement="informational until the first stable CI baseline run" + if [ "${GO_COVERAGE_ENFORCE:-false}" = true ]; then + enforcement="blocking" + fi + { + echo "### Go coverage" + echo "- Current statement coverage: ${current}%" + echo "- Recorded baseline: ${baseline}%" + echo "- Enforcement: ${enforcement}" + } >> "$GITHUB_STEP_SUMMARY" + if [ "${GO_COVERAGE_ENFORCE:-false}" = true ] && awk "BEGIN { exit !($current < $baseline) }"; then + echo "Go coverage regressed from ${baseline}% to ${current}%" >&2 + exit 1 + fi + - name: Upload Go coverage + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: go-coverage + path: coverage/go/ + if-no-files-found: warn + retention-days: 14 + + go-race: + name: Go race tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-linux-backend + - uses: actions/setup-go@v5 + with: + go-version-file: apps/backend/go.mod + cache: true + cache-dependency-path: | + apps/backend/go.sum + go.work.sum + - name: Run Go race suite + working-directory: apps/backend + env: + TAKO_DISTRO: ubuntu + GOCACHE: /tmp/tako-go-race-cache + run: ../../tools/tako-go.sh test -race ./... + + go-quality: + name: Go vet and static analysis + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-linux-backend + - uses: actions/setup-go@v5 + with: + go-version-file: apps/backend/go.mod + cache: true + cache-dependency-path: | + apps/backend/go.sum + go.work.sum + - name: Install staticcheck + env: + GOBIN: ${{ runner.temp }}/bin + run: | + mkdir -p "$GOBIN" + go install honnef.co/go/tools/cmd/staticcheck@2026.1 + echo "$GOBIN" >> "$GITHUB_PATH" + - name: Run go vet + working-directory: apps/backend + env: + TAKO_DISTRO: ubuntu + GOCACHE: /tmp/tako-go-quality-cache + run: ../../tools/tako-go.sh vet ./... + - name: Run staticcheck + working-directory: apps/backend + env: + GOCACHE: /tmp/tako-go-quality-cache + STATICCHECK_CACHE: ${{ runner.temp }}/staticcheck-cache + # The first staticcheck baseline keeps pre-existing dead-code and + # package-comment findings informational; all other checks are gated. + run: staticcheck -checks=all,-U1000,-SA1019,-S1017,-S1016,-ST1000,-ST1020 -tags ubuntu ./... + + dashboard-tests: + name: Dashboard unit tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: bun install --frozen-lockfile + - run: bun nx test dashboard + + dashboard-quality: + name: Dashboard typecheck and lint + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: bun install --frozen-lockfile + - run: bun nx typecheck dashboard + - run: bun nx lint dashboard + + dashboard-coverage: + name: Dashboard coverage + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: bun install --frozen-lockfile + - run: bun --cwd apps/dashboard vitest run --coverage + - name: Upload dashboard coverage + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: dashboard-coverage + path: apps/dashboard/coverage/ + if-no-files-found: warn + retention-days: 14 + + web: + name: Web typecheck, lint, and build + runs-on: ubuntu-24.04 + env: + ASTRO_TELEMETRY_DISABLED: "1" + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: bun install --frozen-lockfile + - run: bun run web:check + - run: bun run web:build + + backend-build: + name: Backend binary build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-linux-backend + - uses: actions/setup-go@v5 + with: + go-version-file: apps/backend/go.mod + cache: true + cache-dependency-path: | + apps/backend/go.sum + go.work.sum + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: bun install --frozen-lockfile + - name: Build backend for Ubuntu + env: + TAKO_DISTRO: ubuntu + run: bun nx build backend + + packaging: + name: Packaging manifest and hook checks + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-linux-backend + - uses: actions/setup-go@v5 + with: + go-version-file: apps/backend/go.mod + cache: true + cache-dependency-path: | + apps/backend/go.sum + go.work.sum + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: bun install --frozen-lockfile + - name: Install GoReleaser for static configuration checks + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "v${{ vars.GORELEASER_VERSION || '2.18.0' }}" + install-only: true + - run: bun run package:check + - run: bun test tools/release-gate.test.ts + + openapi-contract: + name: OpenAPI contract validation + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: bunx --bun @redocly/cli@1.34.0 lint --config .redocly.yaml apps/backend/api/openapi.yaml + + workflow-quality: + name: GitHub workflow validation + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: apps/backend/go.mod + cache: true + cache-dependency-path: | + apps/backend/go.sum + go.work.sum + - name: Install actionlint + env: + GOBIN: ${{ runner.temp }}/bin + run: | + mkdir -p "$GOBIN" + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 + echo "$GOBIN" >> "$GITHUB_PATH" + - run: actionlint + + shell-quality: + name: Shell validation + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Install shellcheck + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends shellcheck + - name: Run shellcheck + run: >- + shellcheck --severity=error + tools/package + tools/package-check + tools/tako-go.sh + tools/tako-host-test + tools/tako-integration + apps/backend/packaging/*.sh + apps/backend/packaging/scripts/deb/*.sh + apps/backend/packaging/scripts/rpm/*.sh + apps/backend/packaging/scripts/arch/*.sh + + pr-gate: + name: PR gate + if: ${{ always() }} + needs: + - go-unit + - go-race + - go-quality + - dashboard-tests + - dashboard-quality + - dashboard-coverage + - web + - backend-build + - packaging + - openapi-contract + - workflow-quality + - shell-quality + runs-on: ubuntu-24.04 + steps: + - name: Fail if a required check failed, was cancelled, or skipped + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + run: exit 1 + - name: All required checks passed + run: echo "All required CI checks passed" diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..688d5ec --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,100 @@ +name: Backend integration + +on: + workflow_call: + workflow_dispatch: + schedule: + - cron: "17 2 * * 1-5" + +permissions: + contents: read + +jobs: + integration: + name: ${{ matrix.distro }} disposable VM integration + strategy: + fail-fast: false + matrix: + include: + - distro: ubuntu + vm_label: tako-vm-ubuntu2604-amd64 + - distro: fedora + vm_label: tako-vm-fedora44-amd64 + runs-on: [self-hosted, linux, "${{ matrix.vm_label }}"] + timeout-minutes: 30 + env: + TAKO_INTEGRATION_DISTRO: ${{ matrix.distro }} + TAKO_INTEGRATION_EVIDENCE_DIR: integration-evidence/${{ matrix.distro }} + GOCACHE: /tmp/tako-go-integration-cache + GOMODCACHE: /tmp/tako-go-integration-mod-cache + TAKO_TEST_PAM_USER: tako-ci + TAKO_TEST_BINARY: ${{ github.workspace }}/bin/tako-integration + TAKO_TEST_ACCOUNT_VM: "1" + TAKO_TEST_IDENTITY_VM: "1" + TAKO_TEST_JOURNAL_VM: "1" + TAKO_TEST_LOGIN_HISTORY_USER: tako-ci + TAKO_TEST_PROCESS_VM: "1" + TAKO_TEST_OVERRIDE_VM: "1" + TAKO_TEST_SSH_HOME: /home/tako-ci + TAKO_TEST_SSH_USER: tako-ci + TAKO_TEST_TIMER_VM: "1" + TAKO_TEST_PASSWORD_USER: tako-ci + TAKO_TEST_PASSWORD_SERVICE: passwd + TAKO_TEST_PAM_SERVICE: tako + steps: + - uses: actions/checkout@v4 + - name: Install backend prerequisites + run: | + set -euo pipefail + case "$TAKO_INTEGRATION_DISTRO" in + ubuntu) + sudo apt-get update + sudo apt-get install --yes --no-install-recommends \ + gcc libpam0g-dev libsystemd-dev systemd openssl python3 + ;; + fedora) + sudo dnf install --assumeyes \ + gcc pam-devel systemd systemd-devel openssl python3 + ;; + esac + - uses: actions/setup-go@v5 + with: + go-version-file: apps/backend/go.mod + cache: true + cache-dependency-path: | + apps/backend/go.sum + go.work.sum + - name: Generate disposable PAM password fixture + run: | + set -euo pipefail + old_password="tako-ci-$(openssl rand -hex 16)" + new_password="tako-ci-$(openssl rand -hex 16)" + echo "::add-mask::$old_password" + echo "::add-mask::$new_password" + { + printf 'TAKO_TEST_PASSWORD_OLD=%s\n' "$old_password" + printf 'TAKO_TEST_PASSWORD_NEW=%s\n' "$new_password" + printf 'TAKO_TEST_PAM_RESPONSES=["%s"]\n' "$old_password" + } >> "$GITHUB_ENV" + - name: Build integration binary + working-directory: apps/backend + env: + TAKO_DISTRO: ${{ matrix.distro }} + run: | + set -euo pipefail + mkdir -p "$GITHUB_WORKSPACE/bin" + go build -tags "$TAKO_DISTRO" -buildvcs=false \ + -o "$GITHUB_WORKSPACE/bin/tako-integration" ./cmd/tako + - name: Run tagged integration suite and VM preflight + run: | + set -euo pipefail + sudo --preserve-env=PATH,GOCACHE,GOMODCACHE,TAKO_INTEGRATION_DISTRO,TAKO_INTEGRATION_EVIDENCE_DIR,TAKO_TEST_BINARY,TAKO_TEST_PAM_USER,TAKO_TEST_PAM_RESPONSES,TAKO_TEST_PASSWORD_OLD,TAKO_TEST_PASSWORD_NEW,TAKO_TEST_ACCOUNT_VM,TAKO_TEST_IDENTITY_VM,TAKO_TEST_JOURNAL_VM,TAKO_TEST_LOGIN_HISTORY_USER,TAKO_TEST_PROCESS_VM,TAKO_TEST_OVERRIDE_VM,TAKO_TEST_SSH_HOME,TAKO_TEST_SSH_USER,TAKO_TEST_TIMER_VM,TAKO_TEST_PASSWORD_USER,TAKO_TEST_PASSWORD_SERVICE,TAKO_TEST_PAM_SERVICE \ + "$GITHUB_WORKSPACE/tools/tako-integration" + - name: Upload integration logs and diagnostics + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: integration-evidence-${{ matrix.distro }} + path: integration-evidence/${{ matrix.distro }}/ + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/native-packages.yml b/.github/workflows/native-packages.yml new file mode 100644 index 0000000..b8e9aa6 --- /dev/null +++ b/.github/workflows/native-packages.yml @@ -0,0 +1,64 @@ +name: Native packages + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + manifest: + name: Expand support manifest + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.targets.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - id: targets + run: | + matrix=$(bun -e ' + const manifest = await Bun.file("apps/backend/packaging/targets.json").json(); + console.log(JSON.stringify({include: manifest.targets.map((target) => ({ + id: target.id, + arch: target.arch, + image: target.image, + runner: target.arch === "arm64" ? "ubuntu-24.04-arm" : "ubuntu-24.04", + }))})); + ') + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + + build: + name: ${{ matrix.id }} native package + needs: manifest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.manifest.outputs.matrix) }} + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - name: Build in the target distro image + env: + TARGET_ID: ${{ matrix.id }} + TARGET_ARCH: ${{ matrix.arch }} + TARGET_IMAGE: ${{ matrix.image }} + GORELEASER_CURRENT_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} + run: | + set -euo pipefail + docker run --rm --platform "linux/${TARGET_ARCH}" \ + --volume "$GITHUB_WORKSPACE:/workspace" \ + --workdir /workspace \ + --env TAKO_PACKAGE_TARGET="$TARGET_ID" \ + --env GORELEASER_CURRENT_TAG \ + "$TARGET_IMAGE" \ + /bin/bash -c './apps/backend/packaging/native-build.sh' + - name: Upload package artifacts + uses: actions/upload-artifact@v4 + with: + name: package-${{ matrix.id }} + path: dist/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/packaging-lifecycle.yml b/.github/workflows/packaging-lifecycle.yml new file mode 100644 index 0000000..eb0e13b --- /dev/null +++ b/.github/workflows/packaging-lifecycle.yml @@ -0,0 +1,115 @@ +name: Packaging lifecycle + +on: + workflow_call: + workflow_dispatch: + schedule: + - cron: "37 3 * * 1-5" + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.target }} old and new packages + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - target: ubuntu2604-amd64 + arch: amd64 + image: ubuntu:26.04 + - target: fedora44-amd64 + arch: amd64 + image: fedora:44 + env: + TARGET_ID: ${{ matrix.target }} + TARGET_ARCH: ${{ matrix.arch }} + TARGET_IMAGE: ${{ matrix.image }} + steps: + - uses: actions/checkout@v4 + - name: Build old lifecycle package + run: | + set -euo pipefail + docker run --rm --platform "linux/${TARGET_ARCH}" \ + --volume "$GITHUB_WORKSPACE:/workspace" \ + --workdir /workspace \ + --env TAKO_PACKAGE_TARGET="$TARGET_ID" \ + --env GORELEASER_CURRENT_TAG="v0.0.1-ci.1.${GITHUB_RUN_ID}" \ + "$TARGET_IMAGE" \ + /bin/bash -c './apps/backend/packaging/native-build.sh' + package=$(find "$GITHUB_WORKSPACE/dist" -maxdepth 1 -type f \( \ + -name '*.deb' -o -name '*.rpm' -o -name '*.pkg.tar.zst' \ + \) -print -quit) + test -n "$package" + artifact_dir="$RUNNER_TEMP/lifecycle-artifacts/$TARGET_ID" + mkdir -p "$artifact_dir" + cp "$package" "$artifact_dir/old-$(basename "$package")" + - name: Build new lifecycle package + run: | + set -euo pipefail + docker run --rm --platform "linux/${TARGET_ARCH}" \ + --volume "$GITHUB_WORKSPACE:/workspace" \ + --workdir /workspace \ + --env TAKO_PACKAGE_TARGET="$TARGET_ID" \ + --env GORELEASER_CURRENT_TAG="v0.0.1-ci.2.${GITHUB_RUN_ID}" \ + "$TARGET_IMAGE" \ + /bin/bash -c './apps/backend/packaging/native-build.sh' + package=$(find "$GITHUB_WORKSPACE/dist" -maxdepth 1 -type f \( \ + -name '*.deb' -o -name '*.rpm' -o -name '*.pkg.tar.zst' \ + \) -print -quit) + test -n "$package" + artifact_dir="$RUNNER_TEMP/lifecycle-artifacts/$TARGET_ID" + mkdir -p "$artifact_dir" + cp "$package" "$artifact_dir/new-$(basename "$package")" + - uses: actions/upload-artifact@v4 + with: + name: lifecycle-packages-${{ matrix.target }} + path: ${{ runner.temp }}/lifecycle-artifacts/${{ matrix.target }}/ + if-no-files-found: error + retention-days: 14 + + validate: + name: ${{ matrix.target }} lifecycle VM + needs: build + strategy: + fail-fast: false + matrix: + include: + - target: ubuntu2604-amd64 + vm_label: tako-vm-ubuntu2604-amd64 + - target: fedora44-amd64 + vm_label: tako-vm-fedora44-amd64 + runs-on: [self-hosted, linux, "${{ matrix.vm_label }}"] + timeout-minutes: 45 + env: + TARGET_ID: ${{ matrix.target }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: lifecycle-packages-${{ matrix.target }} + path: lifecycle-packages + - name: Run lifecycle validation in disposable VM + run: | + set -euo pipefail + old_package=$(find lifecycle-packages -type f -name 'old-*' -print -quit) + new_package=$(find lifecycle-packages -type f -name 'new-*' -print -quit) + test -n "$old_package" + test -n "$new_package" + sudo env \ + GITHUB_SHA="$GITHUB_SHA" \ + TAKO_VM_TARGET="$TARGET_ID" \ + TAKO_VM_PACKAGE="$GITHUB_WORKSPACE/$new_package" \ + TAKO_VM_OLD_PACKAGE="$GITHUB_WORKSPACE/$old_package" \ + TAKO_VM_EVIDENCE_DIR="$GITHUB_WORKSPACE/lifecycle-evidence/$TARGET_ID" \ + TAKO_VM_MANIFEST="$GITHUB_WORKSPACE/apps/backend/packaging/targets.json" \ + "$GITHUB_WORKSPACE/apps/backend/packaging/vm-test.sh" + - uses: actions/upload-artifact@v4 + if: ${{ always() }} + with: + name: lifecycle-evidence-${{ matrix.target }} + path: lifecycle-evidence/${{ matrix.target }}/ + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/vm-validation.yml b/.github/workflows/vm-validation.yml new file mode 100644 index 0000000..3a36bc7 --- /dev/null +++ b/.github/workflows/vm-validation.yml @@ -0,0 +1,76 @@ +name: Packaging VM validation + +on: + workflow_call: + +permissions: + contents: read + actions: read + +jobs: + manifest: + name: Expand VM target manifest + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.targets.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - id: targets + run: | + matrix=$(bun -e ' + const manifest = await Bun.file("apps/backend/packaging/targets.json").json(); + console.log(JSON.stringify({include: manifest.targets.map((target) => ({ + id: target.id, + arch: target.arch, + format: target.package_format, + vm_label: target.vm_label, + }))})); + ') + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + + validate: + name: ${{ matrix.id }} disposable VM + needs: manifest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.manifest.outputs.matrix) }} + # These labels are an explicit operator prerequisite. A beta release stays + # blocked while a target VM runner is missing or offline. + runs-on: [self-hosted, linux, "${{ matrix.vm_label }}"] + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - name: Download native package + uses: actions/download-artifact@v4 + with: + name: package-${{ matrix.id }} + path: package + - name: Run installed-system smoke validation + env: + TARGET_ID: ${{ matrix.id }} + PACKAGE_FORMAT: ${{ matrix.format }} + run: | + case "$PACKAGE_FORMAT" in + deb) package=$(find package -type f -name '*.deb' -print -quit) ;; + rpm) package=$(find package -type f -name '*.rpm' -print -quit) ;; + archlinux) package=$(find package -type f -name '*.pkg.tar.zst' -print -quit) ;; + *) echo "unknown package format: $PACKAGE_FORMAT" >&2; exit 2 ;; + esac + test -n "$package" + sudo env \ + GITHUB_SHA="$GITHUB_SHA" \ + TAKO_VM_TARGET="$TARGET_ID" \ + TAKO_VM_PACKAGE="$GITHUB_WORKSPACE/$package" \ + TAKO_VM_EVIDENCE_DIR="$GITHUB_WORKSPACE/vm-evidence" \ + TAKO_VM_MANIFEST="$GITHUB_WORKSPACE/apps/backend/packaging/targets.json" \ + "$GITHUB_WORKSPACE/apps/backend/packaging/vm-test.sh" + - name: Upload VM evidence + uses: actions/upload-artifact@v4 + with: + name: vm-evidence-${{ matrix.id }} + path: vm-evidence/ + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore index 9684817..7326998 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /bin/ /dist/ /coverage/ +/apps/dashboard/coverage/ /apps/backend/internal/dashboard/dist/* !/apps/backend/internal/dashboard/dist/index.html node_modules/ @@ -10,4 +11,4 @@ node_modules/ *.test /out/ /package-zip.sh -.tanstack/ \ No newline at end of file +.tanstack/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1a1df41..4dde50d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -7,27 +7,69 @@ before: - bun nx build dashboard builds: - - id: tako + - &distro_build + id: debian13-amd64 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: debian13-arm64 + goarch: [arm64] + - <<: *distro_build + id: ubuntu2604-amd64 + tags: [ubuntu] + - <<: *distro_build + id: ubuntu2604-arm64 + goarch: [arm64] + tags: [ubuntu] + - <<: *distro_build + id: fedora44-amd64 + tags: [fedora] + - <<: *distro_build + id: fedora44-arm64 + goarch: [arm64] + tags: [fedora] + - <<: *distro_build + id: rhel102-amd64 + tags: [rhel] + - <<: *distro_build + id: rhel102-arm64 + goarch: [arm64] + tags: [rhel] + - <<: *distro_build + id: rocky102-amd64 + tags: [rocky] + - <<: *distro_build + id: rocky102-arm64 + goarch: [arm64] + tags: [rocky] + - <<: *distro_build + id: almalinux102-amd64 + tags: [almalinux] + - <<: *distro_build + id: almalinux102-arm64 + goarch: [arm64] + tags: [almalinux] + - <<: *distro_build + id: arch-rolling-amd64 + tags: [archlinux] + - <<: *distro_build + id: opensuse160-amd64 + tags: [opensuse] + - <<: *distro_build + id: opensuse160-arm64 + goarch: [arm64] + tags: [opensuse] archives: - - formats: - - tar.gz - format_overrides: - - goos: linux - formats: - - none + - formats: [none] + signs: [] sboms: [] @@ -36,14 +78,154 @@ checksum: algorithm: sha256 nfpms: - - id: tako - ids: - - tako + - &debian_package + id: debian-package + ids: [debian13-amd64, debian13-arm64] + 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: [ubuntu2604-amd64, ubuntu2604-arm64] + 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: [fedora44-amd64, fedora44-arm64] + 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: [rhel102-amd64, rhel102-arm64] + 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: [rocky102-amd64, rocky102-arm64] + 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: [almalinux102-amd64, almalinux102-arm64] + 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: [opensuse160-amd64, opensuse160-arm64] + 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.opensuse, 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: [arch-rolling-amd64] 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,102 +234,18 @@ nfpms: license: AGPL-3.0-only section: admin priority: optional + dependencies: [systemd, pam, dbus, polkit, pacman, pacman-contrib] + scripts: + postinstall: apps/backend/packaging/scripts/arch/post.sh + preremove: apps/backend/packaging/scripts/arch/preun.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 - 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 - - libpam0g - - dbus - - polkitd - - packagekit - - packagekit-tools - - init-system-helpers - 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 - - 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/.redocly.yaml b/.redocly.yaml new file mode 100644 index 0000000..a269d8e --- /dev/null +++ b/.redocly.yaml @@ -0,0 +1,8 @@ +extends: + - minimal + +rules: + operation-summary: off + security-defined: off + no-unused-components: off + operation-2xx-response: off diff --git a/README.md b/README.md index 7f9e2af..c8cf188 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,206 @@ +
+ +Tako mascot, an octopus + # Tako -Tako is a lightweight, modern Linux administration dashboard written in Go and React. The repository is an Nx monorepo managed with Bun. +**A Linux administration dashboard you host yourself. Log in with your UNIX account and check system health, services, logs, and updates from a browser.** + +[Quick start](#quick-start) · [What you can do](#what-you-can-do) · [How it works](#how-it-works) · [Configuration](#configuration) · [Development](./HACKING.md) + +
+ +> :warning: Tako is under active development and not yet at v1. Expect breaking changes. See [status and scope](#status-and-scope). + +Tako is one Go binary plus an embedded React dashboard. It runs on the machine it manages and exposes that machine over HTTPS. You log in with your normal UNIX username and password through PAM. There is no separate Tako account database and no cloud component. + +The gateway that faces the browser is unprivileged. Anything that needs host authority goes through a small privileged helper over a local socket, and per-user work runs as your own UID. The code keeps those three roles in separate processes so a bug in the web layer does not become root access. + +Linux only. Packages cover Debian, Ubuntu, Fedora, RHEL, Rocky Linux, AlmaLinux, openSUSE, and Arch Linux. -## Development +## Why Tako -Requirements: +SSH plus `systemctl`, `journalctl`, and your distro package manager already do everything Tako does. Tako does not replace them. It puts the read paths and the common safe writes behind one login so you can check a server from a phone or a borrowed laptop without remembering flags. -- Go 1.26+ -- Bun 1.3+ -- systemd, Linux procfs, a PAM session stack, and a user D-Bus/systemd-user manager for user-scoped features -- pam-devel (or distro equivalent) and optional system D-Bus / Polkit services +If you manage one or a few Linux hosts and want a quick visual overview with the option to restart a service, follow logs, or preview updates, Tako fits. If you need fleet management, config enforcement, or an external monitoring service, something else is a better match. -Linux only (Arch, Fedora, Debian/Ubuntu). Full workflow is documented in [`HACKING.md`](HACKING.md). +## What you can do + +All of this is in the dashboard after login. Read paths work for any authenticated user. Writes need administrative access, which you elevate from inside the session with your password. + +* Overview: host info, current CPU, memory, disk and network samples, storage summary, recent logs and processes. +* Metrics: 24-hour in-memory history with charts, plus live updates over server-sent events at intervals from 1s to 5m. +* Logs: journal browser with filters for boot, unit, priority, executable, and text, live tail, and CSV or JSON export. +* Services: system and user units of type service, target, socket, timer, and path. Inspect status, relationships, and raw unit config. Start, stop, restart, reload, enable, disable, mask, and unmask through allowlisted actions with a preview step. +* Processes: full process list from procfs with detail views. Optional per-process network accounting where the eBPF helper is present. +* Terminal: browser PTY over WebSocket running as your UID and GID. +* Accounts: local users and groups, group membership, administrator role, SSH authorized keys, and password change or reset. Login history comes straight from the journal. Destructive operations show a preview before they apply. +* Updates: distro-native update inventory using apt, dnf, pacman, or zypper depending on the host. Preview changes, refresh metadata, and run updates as a serialized background job with live progress. RHEL hosts also get kernel live-patch status. +* Storage: mounted filesystems and physical devices, with UDisks2 operations behind preview and confirm. +* Network: interface inventory with NetworkManager operations, plus firewall rules (UFW or firewalld) behind preview and confirm. +* Host: hostname, timezone, NTP state, reboot and shutdown with inhibitor checks, and boot history. +* Files: browse, search, read, and upload in bounded chunks, scoped to what your session is allowed to see. +* Timers and overrides: create structured systemd timer pairs and manage Tako's own service drop-ins, both with preview. +* Incidents, notifications, and jobs: a correlated incident timeline, a small built-in notification list, and background diagnostic jobs such as host inventory. +* Security posture: SELinux and AppArmor status with findings. + +Capability reporting is honest about degraded hosts. If the user bus, user manager, NetworkManager, or another integration is missing, the affected page reports it instead of pretending. + +## Quick start + +You need a Linux host with systemd, Go 1.26+, Bun 1.3+, and PAM development headers (`pam-devel` on Fedora/Arch, `libpam0g-dev` on Debian/Ubuntu). Development mirrors production, so the steps below install real systemd units and a PAM policy pointed at your checkout. ```bash bun install -sudo ./tools/tako-host setup # once per machine +sudo ./tools/tako-host setup # one-time host setup bun run dev ``` -Open **https://127.0.0.1:9090** and log in with your UNIX username and password. -`bun run dev` builds `bin/tako`, runs the real systemd gateway + sessiond stack, -and watches the dashboard into the checkout overlay. That is the same process -model as packaging (PAM, PTY, bridge grants). Refresh the browser after Vite -rebuilds. +Open `https://127.0.0.1:9090` and log in with your UNIX username and password. + +`bun run dev` builds `bin/tako`, starts the gateway and session helper through systemd socket activation, and proxies the browser to Vite so you get hot reload on the gateway origin. After Go changes, reload without a full restart: + +```bash +./tools/tako-host reload +``` -For passwordless loopback UI-only work (no PAM, no terminals): +For layout work without login or terminals: ```bash bun run dev:ui ``` -Common workspace commands: +This starts `serve --dev` with a fake broker on port 9090 and Vite on `http://127.0.0.1:5173`. It skips PAM and disables the terminal. Use `bun run dev` for anything involving auth, elevation, or sessiond. + +Common commands: ```bash -bun run build -bun run test -bun run lint -bun run typecheck -bun run race -bun run graph -./tools/tako-host reload # after Go changes +bun run build # dashboard build, then Go binary at bin/tako +bun run test # Go tests plus dashboard typecheck +bun run lint # go vet plus dashboard checks +bun run race # Go race suite +./tools/tako-host status +./tools/tako-host stop +sudo ./tools/tako-host uninstall +``` + +Logs go to the journal in readable console form: + +```bash +journalctl -u tako.service -u tako-sessiond.service -f +``` + +Set `TAKO_LOG_LEVEL=debug` for caller locations and extra detail. + +## How it works + +One binary, three modes: + +``` +browser --HTTPS--> tako serve --unix socket--> tako sessiond --framed RPC--> tako bridge (as you) +``` + +* `tako serve` is the gateway. It runs as a systemd `DynamicUser` (`tako-gateway`), serves the embedded dashboard, terminates TLS, holds sessions in memory, and enforces CSRF and origin checks. It never touches host D-Bus or the filesystem as root. +* `tako sessiond` is the host-authority boundary. It owns the session socket at `/run/tako/session.sock` (`root:tako-session`, mode `0660`), does PAM authentication, and resolves typed operations. +* `tako bridge` runs once per login as the authenticated UID and GID. User-scoped reads and writes happen here. Administrative system changes go back through root sessiond adapters. + +Both services are socket-activated and exit after the configured idle interval when there is nothing left to do. Sockets stay listening so the next connection starts them again. + +The API contract is at [apps/backend/api/openapi.yaml](./apps/backend/api/openapi.yaml). It is versioned as an internal dashboard contract and may change before v1. + +## Installing on a server + +Releases are built with GoReleaser for amd64 and arm64 (Arch is amd64 only) as `.deb`, `.rpm`, and Arch Linux packages. Each package installs the same layout: + +* `/usr/bin/tako`, the multicall binary +* `tako.service` and `tako.socket` (unprivileged gateway) +* `tako-sessiond.service` and `tako-sessiond.socket` (privileged helper) +* `sysusers.d` entry for the persistent `tako-session` group, `tmpfiles.d` entry for `/run/tako` +* Polkit policy, an example config at `/usr/share/doc/tako/config.example.toml`, one distro branding image under `/usr/share/tako/branding/`, and a distro-specific PAM stack at `/etc/pam.d/tako` + +Fresh installs enable and start `tako-sessiond.socket` before the public `tako.socket`. Nothing listens until the first connection. + +Details on PAM variants, Polkit, sudoers guidance for the exact `NOPASSWD` probe, reverse-proxy setup, and the disposable-VM test harness are in [apps/backend/packaging/README.md](./apps/backend/packaging/README.md). + +To build a package locally: + +```bash +bun run package +TAKO_PACKAGE_TARGET= bun run package # one distro only +``` + +`bun run package:check` validates the toolchain without building. Local builds need Go 1.26+, GoReleaser 2.x, a C compiler, and the host PAM headers. + +## Configuration + +Copy [apps/backend/config.example.toml](./apps/backend/config.example.toml) to `/etc/tako/config.toml` and edit. Both the gateway and sessiond read the same file. + +```toml +[server] +address = ":9090" +data_dir = "/var/lib/tako" +session_socket = "/run/tako/session.sock" +service_idle_timeout = "10m" +# allowed_origin = "https://console.example.com" +# certificate = "/etc/tako/tako.crt" +# certificate_key = "/etc/tako/tako.key" + +[monitoring] +default_interval = "1m" +history_retention = "24h" + +[admin] +idle_timeout = "5m" +``` + +Things worth knowing: + +* Omit `allowed_origin` for direct same-origin use, including access by server IP or hostname. Set it to the exact public scheme, host, and port when running behind a reverse proxy with a different browser origin. +* For a reverse proxy, terminate public TLS at the proxy, forward only to loopback `127.0.0.1:9090`, preserve WebSocket upgrade headers for the terminal endpoint, and do not cache `/api/v1/*`. +* `service_idle_timeout` accepts any Go duration. `0` disables idle exit. When the service is idle and exits, the in-memory metric sampler stops too, so history shows gaps. +* Certificates in Tako's own state directory stay gateway-owned. External certificate paths are read by sessiond. +* Hosts must resolve systemd dynamic users: `passwd` (and usually `group`) in `nsswitch.conf` must include `systemd`. + +Certificates bootstrap on first start when no paths are configured, so `bun run dev` works without extra setup. + +## Project layout + ``` +apps/backend/ Go gateway, sessiond, bridge, platform adapters +apps/backend/api/ OpenAPI contract for the dashboard +apps/backend/packaging/ systemd units, PAM stacks, sysusers, packaging scripts +apps/dashboard/ React dashboard (Vite, TanStack Router and Query) +apps/web/ marketing site and docs placeholder (Astro) +tools/tako-host host-integrated dev setup for systemd and PAM +``` + +The backend keeps platform logic in small module-owned adapters under `apps/backend/internal/`. The dashboard keeps reusable controls in `apps/dashboard/src/components/ui/` and pages in `apps/dashboard/src/routes/`. Production dashboard builds embed into the Go binary through `go:embed`. -`bun run build` builds `apps/dashboard` first, embeds its output in `apps/backend`, and writes the multicall executable to `bin/tako`. +## Status and scope -Logs use human-readable console output in the service journal (`journalctl -u tako.service -u tako-sessiond.service -f`). Structured fields follow each message. Set `TAKO_LOG_LEVEL=debug` for more detail, including caller locations. +Tako is under active development and not yet at v1. The API explicitly offers no third-party compatibility promise during this phase. -## Implemented +What holds today: login and sessions, dashboard and metrics, logs, services, processes, terminal, accounts, updates, storage, network, firewall, host config and power, files, timers, incidents, and notifications, all behind the gateway/sessiond/bridge split described above. -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. +What Tako will not do: run privileged work in the gateway process, fall back to the gateway's own runtime bus or filesystem for host authority, or perform process, account, package, storage, or network mutations outside narrow authenticated sessiond operations. New destructive actions arrive as small adapters with distribution VM tests, not as gateway code. -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. +## Contributing -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. +The working flow is the host-integrated setup in [HACKING.md](./HACKING.md): `bun install`, `sudo ./tools/tako-host setup`, `bun run dev`, and `./tools/tako-host reload` after Go changes. -Ubuntu LTS and current Fedora are the primary integration targets. Network metadata auto-detects NetworkManager and systemd-networkd. Per-process network byte accounting is represented as an optional eBPF capability; hosts without the helper retain CPU, memory, threads, command, and disk-I/O process data. +Before opening a pull request, run the checks CI runs: + +```bash +bun run test +bun run lint +bun run race +bunx --bun @redocly/cli@1.34.0 lint --config .redocly.yaml apps/backend/api/openapi.yaml +``` -Other destructive administration actions remain adapter boundaries: Tako does not perform process, account, package, storage, or network mutations from the unprivileged gateway. Those actions must be added through narrow authenticated sessiond operations, using the user bridge for user-owned changes and root adapters for administrative changes, with distribution VM tests. +API changes should include handler tests and an update to `apps/backend/api/openapi.yaml`. UI changes should cover loading, error, empty, keyboard, mobile, and dark-mode states where they apply. Keep commits scoped with Conventional Commit prefixes such as `feat:`, `fix:`, or `docs:`, and note behavior and security impact in the pull request. -## Production layout +Issues live on GitHub. For anything security sensitive, do not open a public issue. Contact the maintainers privately and give them time to fix before disclosing. -Install the single `bin/tako` multicall executable, systemd units from `apps/backend/packaging/systemd`, `sysusers.d` for the `tako-session` group, PAM policy from `apps/backend/packaging/pam`, and read-only branding assets under `/usr/share/tako/branding`. Systemd socket-activates `tako serve` as a dynamic unprivileged gateway (`tako-gateway`) and `tako sessiond` as the PAM/host-authority boundary. Both exit after the configured idle interval when no sessions or work remain; their sockets stay active for the next connection. `tako bridge` is launched per login with the authenticated user's credentials. These remain isolated processes with distinct users and sandboxes; no gateway process retains root privileges. +## License -Distribution-specific PAM variants, the optional Polkit action, exact-command sudoers guidance, reverse-proxy notes, and the disposable-VM packaging smoke seam are documented in [`apps/backend/packaging/README.md`](apps/backend/packaging/README.md). +Tako is licensed under the GNU Affero General Public License v3.0 or later. See [LICENSE](./LICENSE) for the full text. Running a modified version on a network server requires offering users the corresponding source. diff --git a/apps/backend/api/openapi.yaml b/apps/backend/api/openapi.yaml index 2432743..4191d7e 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 @@ -686,7 +642,7 @@ paths: get: operationId: getService responses: - '200': { description: Unit status, runtime properties, and relationships } + '200': { description: "Unit status, runtime properties, and relationships" } '400': { $ref: '#/components/responses/Problem' } '503': { $ref: '#/components/responses/Problem' } /services/{scope}/{unit}/configuration: @@ -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: @@ -1030,7 +986,7 @@ paths: action: { type: string, enum: [reboot, shutdown] } expectedFingerprint: { type: string, minLength: 64, maxLength: 64 } responses: - '200': { description: Current capability, inhibitor, and confirmation requirements } + '200': { description: "Current capability, inhibitor, and confirmation requirements" } '400': { $ref: '#/components/responses/Problem' } '503': { $ref: '#/components/responses/Problem' } /storage: @@ -1038,17 +994,48 @@ paths: operationId: listStorage responses: '200': - description: One entry per distinct mounted filesystem, deduplicated by superblock device number + description: Mounted filesystem and bounded physical-device inventory content: application/json: schema: type: object - required: [items] + required: [items, devices, fingerprint, readOnly] properties: items: type: array maxItems: 4096 items: { $ref: '#/components/schemas/StorageFilesystem' } + devices: + type: array + maxItems: 256 + items: { $ref: '#/components/schemas/StorageDevice' } + fingerprint: { type: string, pattern: '^[a-fA-F0-9]{64}$' } + readOnly: { type: boolean } + reason: { type: string, maxLength: 512 } + post: + operationId: applyStorageOperation + requestBody: + required: true + content: + application/json: { schema: { $ref: '#/components/schemas/StorageOperation' } } + responses: + '200': { description: Verified storage state after UDisks2 operation, content: { application/json: { schema: { $ref: '#/components/schemas/StorageState' } } } } + '400': { $ref: '#/components/responses/Problem' } + '403': { $ref: '#/components/responses/Problem' } + '409': { $ref: '#/components/responses/Problem' } + '503': { $ref: '#/components/responses/Problem' } + + /storage/preview: + post: + operationId: previewStorageOperation + requestBody: + required: true + content: + application/json: { schema: { $ref: '#/components/schemas/StorageOperation' } } + responses: + '200': { description: Current storage state and operation safety preview, content: { application/json: { schema: { $ref: '#/components/schemas/StorageState' } } } } + '400': { $ref: '#/components/responses/Problem' } + '403': { $ref: '#/components/responses/Problem' } /network: get: operationId: listNetworkInterfaces @@ -1502,7 +1489,9 @@ components: additionalProperties: false required: [action] properties: - action: { type: string, enum: [list, stat, read, read-window, write, write-text, write-chunk, create, rename, move, copy, trash, delete, search, archive, extract, metadata] } + action: { type: string, enum: [list, stat, read, read-window, write, write-text, write-chunk, upload-status, create, rename, move, copy, trash, delete, search, archive, extract, metadata, cancel-upload, restore] } + scope: { type: string, enum: [home, system] } + uploadId: { type: string, pattern: '^[a-fA-F0-9]{32}$' } path: { type: string, maxLength: 4096 } destination: { type: string, maxLength: 4096 } kind: { type: string, enum: [file, directory] } @@ -1517,23 +1506,35 @@ components: showHidden: { type: boolean } recursive: { type: boolean } permanent: { type: boolean } + overwrite: { type: boolean } confirmation: { type: string, maxLength: 128 } query: { type: string, maxLength: 512 } maxEntries: { type: integer, minimum: 0, maximum: 10000 } archivePath: { type: string, maxLength: 4096 } - mode: { type: integer, minimum: 0, maximum: 511 } + mode: { type: integer, minimum: 0, maximum: 4095 } owner: { type: string, maxLength: 256 } group: { type: string, maxLength: 256 } + UploadInfo: + type: object + required: [uploadId, path, offset, total, expiresAt] + properties: + uploadId: { type: string, pattern: '^[a-fA-F0-9]{32}$' } + path: { type: string, maxLength: 4096 } + offset: { type: integer, minimum: 0 } + total: { type: integer, minimum: 1 } + expiresAt: { type: string, format: date-time } + completed: { type: boolean } NetworkOperation: type: object additionalProperties: false required: [backend, action] properties: - backend: { type: string, enum: [NetworkManager, Netplan, systemd-networkd] } + backend: { type: string, enum: [NetworkManager, Netplan, systemd-networkd, ifupdown] } action: { type: string, enum: [preview, dhcp, static, dns, route-add, route-remove, checkpoint, commit, rollback] } interface: { type: string, maxLength: 256 } connection: { type: string, maxLength: 256 } address: { type: string, maxLength: 128 } + addresses: { type: array, maxItems: 16, items: { type: string, maxLength: 128 } } gateway: { type: string, maxLength: 128 } dns: { type: array, maxItems: 8, items: { type: string } } route: { type: string, maxLength: 128 } @@ -1542,6 +1543,12 @@ components: confirmation: { type: string, maxLength: 128 } reconnectToken: { type: string, maxLength: 256 } checkpoint: { type: string, maxLength: 256 } + ipv4Method: { type: string, enum: [auto, manual, disabled, ignore] } + ipv4Address: { type: string, maxLength: 128 } + ipv4Gateway: { type: string, maxLength: 128 } + ipv6Method: { type: string, enum: [auto, manual, disabled, ignore] } + ipv6Address: { type: string, maxLength: 128 } + ipv6Gateway: { type: string, maxLength: 128 } NetworkState: type: object required: [snapshot, action, committed, rollback] @@ -1551,6 +1558,9 @@ components: checkpoint: { type: string } committed: { type: boolean } rollback: { type: boolean } + reconnectRequired: { type: boolean } + reconnectToken: { type: string, maxLength: 256 } + rollbackDeadline: { type: string, format: date-time } warning: { type: string } FirewallOperation: type: object @@ -1558,7 +1568,7 @@ components: required: [backend, action] properties: backend: { type: string, enum: [auto, firewalld, UFW] } - action: { type: string, enum: [preview, enable, disable, default-zone, add-service, remove-service, add-port, remove-port, add-source, remove-source, reload] } + action: { type: string, enum: [preview, enable, disable, default-zone, add-service, remove-service, add-port, remove-port, add-source, remove-source, reload, commit, rollback] } zone: { type: string, maxLength: 128 } service: { type: string, maxLength: 128 } port: { type: string, maxLength: 32 } @@ -1567,6 +1577,9 @@ components: expectedFingerprint: { type: string, maxLength: 128 } confirmation: { type: string, maxLength: 128 } persist: { type: boolean } + rollbackSeconds: { type: integer, minimum: 0, maximum: 600 } + checkpoint: { type: string, maxLength: 256 } + rollbackToken: { type: string, maxLength: 256 } FirewallState: type: object required: [snapshot, action, applied] @@ -1574,6 +1587,11 @@ components: snapshot: { type: object } action: { type: string } applied: { type: boolean } + committed: { type: boolean } + rollbackRequired: { type: boolean } + checkpoint: { type: string } + rollbackToken: { type: string } + rollbackDeadline: { type: string, format: date-time } warning: { type: string } SecurityOperation: type: object @@ -1588,6 +1606,54 @@ components: profile: { type: string, maxLength: 256 } expectedFingerprint: { type: string, maxLength: 128 } confirmation: { type: string, maxLength: 128 } + SecurityChange: + type: object + required: [field] + properties: + field: { type: string, maxLength: 512 } + before: { type: string, maxLength: 512 } + after: { type: string, maxLength: 512 } + SecurityFinding: + type: object + required: [framework, kind, subject, message, severity] + properties: + framework: { type: string, enum: [SELinux, AppArmor] } + kind: { type: string, maxLength: 128 } + subject: { type: string, maxLength: 256 } + message: { type: string, maxLength: 1024 } + severity: { type: string, enum: [info, warning, critical] } + guidance: { type: string, maxLength: 1024 } + SecurityStatus: + type: object + required: [selinux, apparmor, active, findings, fingerprint] + properties: + selinux: + type: object + required: [kernelPresent, userspace, mode, booleans, denials] + properties: + kernelPresent: { type: boolean } + userspace: { type: boolean } + mode: { type: string } + policy: { type: string } + booleans: { type: array, maxItems: 4096, items: { type: string, maxLength: 256 } } + denials: { type: array, maxItems: 256, items: { type: string, maxLength: 1024 } } + apparmor: + type: object + required: [kernelPresent, userspace, profiles, denials] + properties: + kernelPresent: { type: boolean } + userspace: { type: boolean } + profiles: { type: array, maxItems: 4096, items: { type: string, maxLength: 512 } } + profileModes: { type: object, additionalProperties: { type: string, maxLength: 32 } } + denials: { type: array, maxItems: 256, items: { type: string, maxLength: 1024 } } + active: { type: string } + findings: { type: array, maxItems: 256, items: { $ref: '#/components/schemas/SecurityFinding' } } + changes: { type: array, maxItems: 16, items: { $ref: '#/components/schemas/SecurityChange' } } + warnings: { type: array, maxItems: 16, items: { type: string, maxLength: 512 } } + stale: { type: boolean } + allowed: { type: boolean } + requiresConfirmation: { type: boolean } + fingerprint: { type: string, pattern: '^[a-fA-F0-9]{64}$' } IncidentEvent: type: object required: [id, timestamp, severity, kind, summary] @@ -1658,6 +1724,9 @@ components: type: object required: [path, entries, showHidden, fingerprint] properties: + parent: { type: string, maxLength: 4096 } + nextOffset: { type: integer, minimum: 0 } + hasMore: { type: boolean } path: { type: string } entries: type: array @@ -1697,7 +1766,11 @@ components: maxItems: 16 items: { type: string, maxLength: 256 } message: { type: string, maxLength: 512 } - reason: { type: string, maxLength: 512 } + uploadId: { type: string, pattern: '^[a-fA-F0-9]{32}$' } + uploads: + type: array + maxItems: 256 + items: { $ref: '#/components/schemas/UploadInfo' } UpdatePackage: type: object required: [name, candidateVersion] @@ -1721,45 +1794,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 +1847,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] @@ -2125,8 +2191,9 @@ components: nextCursor: { type: string, maxLength: 8192 } JournalEntry: type: object - required: [timestamp, priority, unit, message] + required: [id, timestamp, priority, unit, message] properties: + id: { type: string, maxLength: 128 } timestamp: { type: string, format: date-time } priority: { type: string, maxLength: 2 } unit: { type: string, maxLength: 256 } @@ -2621,12 +2688,90 @@ components: type: array maxItems: 4096 items: { $ref: '#/components/schemas/MountPoint' } + StoragePartition: + type: object + required: [path, size, readOnly, mountPoints] + properties: + path: { type: string, maxLength: 256 } + name: { type: string, maxLength: 256 } + size: { type: integer, minimum: 0 } + filesystem: { type: string, maxLength: 64 } + label: { type: string, maxLength: 256 } + uuid: { type: string, maxLength: 128 } + parent: { type: string, maxLength: 256 } + readOnly: { type: boolean } + mountPoints: { type: array, maxItems: 64, items: { $ref: '#/components/schemas/MountPoint' } } + StorageHealth: + type: object + required: [available] + properties: + available: { type: boolean } + passed: { type: boolean } + temperatureC: { type: integer } + powerOnHours: { type: integer, minimum: 0 } + failing: { type: boolean } + reason: { type: string, maxLength: 512 } + NVMeHealth: + type: object + required: [available] + properties: + available: { type: boolean } + temperatureC: { type: integer } + percentageUsed: { type: integer, minimum: 0, maximum: 255 } + criticalWarning: { type: integer, minimum: 0, maximum: 255 } + reason: { type: string, maxLength: 512 } + StorageDevice: + type: object + required: [path, name, type, size, readOnly, removable, partitions] + properties: + path: { type: string, maxLength: 256 } + name: { type: string, maxLength: 256 } + type: { type: string, maxLength: 64 } + model: { type: string, maxLength: 512 } + serial: { type: string, maxLength: 512 } + transport: { type: string, maxLength: 64 } + size: { type: integer, minimum: 0 } + readOnly: { type: boolean } + removable: { type: boolean } + partitions: { type: array, maxItems: 128, items: { $ref: '#/components/schemas/StoragePartition' } } + smart: { $ref: '#/components/schemas/StorageHealth' } + nvme: { $ref: '#/components/schemas/NVMeHealth' } + StorageSnapshot: + type: object + required: [filesystems, devices, fingerprint, readOnly] + properties: + filesystems: { type: array, maxItems: 4096, items: { $ref: '#/components/schemas/StorageFilesystem' } } + devices: { type: array, maxItems: 256, items: { $ref: '#/components/schemas/StorageDevice' } } + fingerprint: { type: string, pattern: '^[a-fA-F0-9]{64}$' } + readOnly: { type: boolean } + reason: { type: string, maxLength: 512 } + StorageOperation: + type: object + additionalProperties: false + required: [action, device] + properties: + action: { type: string, enum: [preview, mount, unmount, persistent-mount, persistent-unmount] } + device: { type: string, pattern: '^/dev/[A-Za-z0-9._/@+-]+$' } + target: { type: string, maxLength: 4096 } + filesystem: { type: string, maxLength: 64 } + options: { type: array, maxItems: 32, items: { type: string, maxLength: 128 } } + expectedFingerprint: { type: string, pattern: '^[a-fA-F0-9]{64}$' } + confirmation: { type: string, maxLength: 128 } + StorageState: + type: object + required: [snapshot, action, applied] + properties: + snapshot: { $ref: '#/components/schemas/StorageSnapshot' } + action: { type: string } + applied: { type: boolean } + warning: { type: string, maxLength: 1024 } MountPoint: type: object required: [target] properties: target: { type: string, maxLength: 4096 } root: { type: string, maxLength: 4096, description: Path of the mount inside its filesystem (bind or btrfs subvolume source) } + readOnly: { type: boolean } StorageSummary: type: object required: [filesystems, total, used, percent] 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..4bf3248 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 @@ -83,6 +81,7 @@ type Server struct { readProcessesFn func(context.Context) ([]platform.Process, error) readIdentityFn func(context.Context) (platform.IdentityInventory, error) readFilesystemsFn func(context.Context) ([]platform.Filesystem, error) + readStorageFn func(context.Context) (platform.StorageSnapshot, error) readNetworkFn func(context.Context) (platform.NetworkSnapshot, error) previewSignalFn func(context.Context, platform.SignalOperation) (platform.SignalPreview, error) readMetricHistoryFn func(context.Context, time.Time, int) ([]metrics.Sample, error) @@ -90,7 +89,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 +178,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) }, @@ -242,6 +233,9 @@ func New(cfg config.Config) (*Server, error) { server.readFilesystemsFn = func(ctx context.Context) ([]platform.Filesystem, error) { return server.hostBroker().ReadFilesystems(ctx, credentialsFromContext(ctx)) } + server.readStorageFn = func(ctx context.Context) (platform.StorageSnapshot, error) { + return server.hostBroker().ReadStorageSnapshot(ctx, credentialsFromContext(ctx)) + } server.readNetworkFn = func(ctx context.Context) (platform.NetworkSnapshot, error) { return server.hostBroker().ReadNetworkSnapshot(ctx, credentialsFromContext(ctx)) } @@ -264,10 +258,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 +479,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) @@ -502,6 +489,8 @@ func (server *Server) routes() http.Handler { router.With(server.requireCSRF).Post("/timers/preview", server.timerPreview) router.With(server.requireCSRF).Post("/timers", server.timerAction) router.Get("/storage", server.storage) + router.With(server.requireCSRF).Post("/storage/preview", server.storagePreview) + router.With(server.requireCSRF).Post("/storage", server.storageApply) router.Get("/network", server.network) router.With(server.requireCSRF).Post("/network/preview", server.networkPreview) router.With(server.requireCSRF).Post("/network", server.networkApply) @@ -751,7 +740,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), @@ -1159,12 +1147,103 @@ func (server *Server) groups(writer http.ResponseWriter, request *http.Request) } func (server *Server) storage(writer http.ResponseWriter, request *http.Request) { - if server.readFilesystemsFn == nil { + if server.readStorageFn == nil && server.readFilesystemsFn == nil { problem(writer, http.StatusServiceUnavailable, "storage-unavailable", "Storage inventory is unavailable") return } - items, err := server.readFilesystemsFn(request.Context()) - server.writeModule(writer, "storage", items, err) + var snapshot platform.StorageSnapshot + var err error + if server.readStorageFn != nil { + snapshot, err = server.readStorageFn(request.Context()) + } else { + var filesystems []platform.Filesystem + filesystems, err = server.readFilesystemsFn(request.Context()) + snapshot = platform.StorageSnapshotFromFilesystems(filesystems, "Hardware inventory is unavailable.") + } + if err != nil { + server.writeModule(writer, "storage", nil, err) + return + } + writeJSON(writer, http.StatusOK, map[string]any{ + "items": snapshot.Filesystems, "devices": snapshot.Devices, + "fingerprint": snapshot.Fingerprint, "readOnly": snapshot.ReadOnly, + "reason": snapshot.Reason, + }) +} + +func decodeStorageOperation(writer http.ResponseWriter, request *http.Request) (platform.StorageOperation, bool) { + request.Body = http.MaxBytesReader(writer, request.Body, 16<<10) + decoder := json.NewDecoder(request.Body) + decoder.DisallowUnknownFields() + var operation platform.StorageOperation + if err := decoder.Decode(&operation); err != nil { + problem(writer, http.StatusBadRequest, "invalid-storage-operation", "Storage operation is invalid") + return platform.StorageOperation{}, false + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + problem(writer, http.StatusBadRequest, "invalid-storage-operation", "Storage operation contains trailing data") + return platform.StorageOperation{}, false + } + return operation, true +} + +func (server *Server) storagePreview(writer http.ResponseWriter, request *http.Request) { + current := request.Context().Value(sessionKey{}).(session.Session) + if current.Identity.AdminToken == "" || !time.Now().Before(current.AdminUntil) { + problem(writer, http.StatusForbidden, "administrative-access-required", "Gain Administrative access before previewing storage changes") + return + } + operation, ok := decodeStorageOperation(writer, request) + if !ok { + return + } + operation.Action = "preview" + state, err := server.hostBroker().PreviewStorage(request.Context(), auth.StorageRequest{AdminToken: current.Identity.AdminToken, Operation: operation}) + if err != nil { + writeStorageOperationError(writer, err) + return + } + writeJSON(writer, http.StatusOK, state) +} + +func (server *Server) storageApply(writer http.ResponseWriter, request *http.Request) { + current := request.Context().Value(sessionKey{}).(session.Session) + if current.Identity.AdminToken == "" || !time.Now().Before(current.AdminUntil) { + problem(writer, http.StatusForbidden, "administrative-access-required", "Gain Administrative access before changing storage") + return + } + operation, ok := decodeStorageOperation(writer, request) + if !ok { + return + } + startedAt := time.Now().UTC() + state, err := server.hostBroker().ApplyStorage(request.Context(), auth.StorageRequest{AdminToken: current.Identity.AdminToken, Operation: operation}) + if err != nil { + writeStorageOperationError(writer, err) + server.recordOperation(request.Context(), current.Identity.Username, "storage/"+operation.Action, startedAt, "failed", err.Error(), true) + return + } + server.recordOperation(request.Context(), current.Identity.Username, "storage/"+operation.Action, startedAt, "succeeded", "", true) + writeJSON(writer, http.StatusOK, state) +} + +func writeStorageOperationError(writer http.ResponseWriter, err error) { + switch { + case errors.Is(err, platform.ErrInvalidStorageOperation): + problem(writer, http.StatusBadRequest, "invalid-storage-operation", "Storage operation is invalid") + case errors.Is(err, platform.ErrStorageConflict): + problem(writer, http.StatusConflict, "storage-conflict", "Storage state changed; preview again") + case errors.Is(err, platform.ErrStorageUnsafe): + problem(writer, http.StatusForbidden, "storage-unsafe", "The selected storage target is protected or read-only") + case errors.Is(err, platform.ErrStorageBusy): + problem(writer, http.StatusConflict, "storage-busy", "The storage target is busy") + case errors.Is(err, platform.ErrStorageUnavailable): + problem(writer, http.StatusServiceUnavailable, "storage-unavailable", "UDisks2 storage service is unavailable") + case errors.Is(err, auth.ErrServiceUnavailable): + problem(writer, http.StatusBadGateway, "storage-service-unavailable", "The privileged storage service is unavailable") + default: + problem(writer, http.StatusBadGateway, "storage-operation-failed", "The storage operation failed") + } } func (server *Server) network(writer http.ResponseWriter, request *http.Request) { @@ -1251,6 +1330,8 @@ func writeNetworkOperationError(writer http.ResponseWriter, err error) { problem(writer, http.StatusConflict, "network-conflict", "Network state changed; preview again") case errors.Is(err, platform.ErrNetworkOwnership): problem(writer, http.StatusConflict, "network-ownership-conflict", "Network ownership is conflicted; mutations are disabled") + case errors.Is(err, platform.ErrNetworkCheckpoint): + problem(writer, http.StatusConflict, "network-checkpoint-invalid", "Network reconnect checkpoint is invalid or expired") case errors.Is(err, platform.ErrNetworkUnavailable): problem(writer, http.StatusServiceUnavailable, "network-unavailable", "The selected network adapter is unavailable") case errors.Is(err, auth.ErrServiceUnavailable): @@ -1340,6 +1421,8 @@ func writeFirewallOperationError(writer http.ResponseWriter, err error) { problem(writer, http.StatusConflict, "firewall-ownership-conflict", "Conflicting firewall ownership detected") case errors.Is(err, platform.ErrFirewallAccessRisk): problem(writer, http.StatusForbidden, "firewall-access-risk", "This change could lock out management access") + case errors.Is(err, platform.ErrFirewallCheckpoint): + problem(writer, http.StatusConflict, "firewall-checkpoint-invalid", "Firewall rollback checkpoint is invalid or expired") case errors.Is(err, platform.ErrFirewallUnavailable): problem(writer, http.StatusServiceUnavailable, "firewall-unavailable", "No supported active firewall adapter is available") case errors.Is(err, auth.ErrServiceUnavailable): @@ -1389,7 +1472,6 @@ func (server *Server) securityPreview(writer http.ResponseWriter, request *http. if !ok { return } - operation.Action = "inspect" status, err := server.hostBroker().PreviewSecurity(request.Context(), auth.SecurityRequest{AdminToken: current.Identity.AdminToken, Operation: operation}) if err != nil { writeSecurityOperationError(writer, err) @@ -1437,7 +1519,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 = "." } @@ -1630,9 +1722,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) { @@ -1690,7 +1810,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 } @@ -1700,8 +1820,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)) @@ -1716,7 +1840,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 @@ -1733,6 +1869,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) } @@ -2623,10 +2785,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 { @@ -2641,6 +2804,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)} 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/fuzz_test.go b/apps/backend/internal/app/fuzz_test.go new file mode 100644 index 0000000..e247623 --- /dev/null +++ b/apps/backend/internal/app/fuzz_test.go @@ -0,0 +1,18 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func FuzzDecodeUpdateOperation(f *testing.F) { + f.Add(`{"expectedFingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","confirmed":true}`, false) + f.Add(`{"unexpected":true}`, true) + f.Fuzz(func(_ *testing.T, payload string, preview bool) { + request := httptest.NewRequest(http.MethodPost, "/api/v1/updates/preview", strings.NewReader(payload)) + recorder := httptest.NewRecorder() + decodeUpdateOperation(recorder, request, preview) + }) +} diff --git a/apps/backend/internal/app/host_broker.go b/apps/backend/internal/app/host_broker.go index 26fa921..10f1172 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" ) @@ -37,6 +36,9 @@ type HostBroker interface { SignalProcesses(context.Context, auth.SignalRequest) (platform.SignalResult, error) ReadIdentityInventory(context.Context, auth.HostReadCredentials) (platform.IdentityInventory, error) ReadFilesystems(context.Context, auth.HostReadCredentials) ([]platform.Filesystem, error) + ReadStorageSnapshot(context.Context, auth.HostReadCredentials) (platform.StorageSnapshot, error) + PreviewStorage(context.Context, auth.StorageRequest) (platform.StorageState, error) + ApplyStorage(context.Context, auth.StorageRequest) (platform.StorageState, error) ReadNetworkSnapshot(context.Context, auth.HostReadCredentials) (platform.NetworkSnapshot, error) PreviewNetwork(context.Context, auth.NetworkRequest) (platform.NetworkState, error) ApplyNetwork(context.Context, auth.NetworkRequest) (platform.NetworkState, error) @@ -50,9 +52,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) @@ -196,6 +195,18 @@ func (broker socketHostBroker) ReadFilesystems(ctx context.Context, credentials return auth.ReadFilesystems(ctx, broker.path, credentials) } +func (broker socketHostBroker) ReadStorageSnapshot(ctx context.Context, credentials auth.HostReadCredentials) (platform.StorageSnapshot, error) { + return auth.ReadStorageSnapshot(ctx, broker.path, credentials) +} + +func (broker socketHostBroker) PreviewStorage(ctx context.Context, request auth.StorageRequest) (platform.StorageState, error) { + return auth.PreviewStorage(ctx, broker.path, request) +} + +func (broker socketHostBroker) ApplyStorage(ctx context.Context, request auth.StorageRequest) (platform.StorageState, error) { + return auth.ApplyStorage(ctx, broker.path, request) +} + func (broker socketHostBroker) ReadNetworkSnapshot(ctx context.Context, credentials auth.HostReadCredentials) (platform.NetworkSnapshot, error) { return auth.ReadNetworkSnapshot(ctx, broker.path, credentials) } @@ -229,11 +240,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 +259,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) } @@ -431,6 +426,18 @@ func (fakeHostBroker) ReadFilesystems(context.Context, auth.HostReadCredentials) return []platform.Filesystem{}, nil } +func (fakeHostBroker) ReadStorageSnapshot(context.Context, auth.HostReadCredentials) (platform.StorageSnapshot, error) { + return platform.StorageSnapshotFromFilesystems([]platform.Filesystem{}, "Development storage inventory is empty."), nil +} + +func (fakeHostBroker) PreviewStorage(context.Context, auth.StorageRequest) (platform.StorageState, error) { + return platform.StorageState{}, nil +} + +func (fakeHostBroker) ApplyStorage(context.Context, auth.StorageRequest) (platform.StorageState, error) { + return platform.StorageState{}, nil +} + func (fakeHostBroker) ReadNetworkSnapshot(context.Context, auth.HostReadCredentials) (platform.NetworkSnapshot, error) { return platform.NetworkSnapshot{Interfaces: []platform.Interface{}, Addresses: []platform.NetworkAddress{}, Routes: []platform.NetworkRoute{}, DNS: []string{}}, nil } @@ -468,7 +475,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 +491,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/route_surface_test.go b/apps/backend/internal/app/route_surface_test.go new file mode 100644 index 0000000..9dc51c7 --- /dev/null +++ b/apps/backend/internal/app/route_surface_test.go @@ -0,0 +1,115 @@ +package app + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/velopulent/tako/internal/auth" + "github.com/velopulent/tako/internal/session" +) + +func TestProtectedRouteSurfaceRequiresSession(t *testing.T) { + server, err := New(testConfig(t)) + if err != nil { + t.Fatal(err) + } + defer server.cancel() + + cases := []struct { + name string + method string + path string + body string + }{ + {name: "session", method: http.MethodGet, path: "/api/v1/auth/session"}, + {name: "capabilities", method: http.MethodGet, path: "/api/v1/capabilities"}, + {name: "metrics", method: http.MethodGet, path: "/api/v1/metrics"}, + {name: "terminal status", method: http.MethodGet, path: "/api/v1/terminal"}, + {name: "jobs", method: http.MethodGet, path: "/api/v1/jobs"}, + {name: "storage", method: http.MethodGet, path: "/api/v1/storage"}, + {name: "network", method: http.MethodGet, path: "/api/v1/network"}, + {name: "firewall", method: http.MethodGet, path: "/api/v1/firewall"}, + {name: "security", method: http.MethodGet, path: "/api/v1/security"}, + {name: "incidents", method: http.MethodGet, path: "/api/v1/incidents"}, + {name: "notifications", method: http.MethodGet, path: "/api/v1/notifications"}, + {name: "certificates", method: http.MethodGet, path: "/api/v1/certificates"}, + {name: "files", method: http.MethodGet, path: "/api/v1/files?path=."}, + {name: "file search", method: http.MethodGet, path: "/api/v1/files/search?path=.&query=x"}, + {name: "file content", method: http.MethodGet, path: "/api/v1/files/content?path=x"}, + {name: "processes", method: http.MethodGet, path: "/api/v1/processes"}, + {name: "updates", method: http.MethodGet, path: "/api/v1/updates"}, + {name: "services", method: http.MethodGet, path: "/api/v1/services"}, + {name: "timers", method: http.MethodPost, path: "/api/v1/timers", body: `{}`}, + {name: "storage mutation", method: http.MethodPost, path: "/api/v1/storage", body: `{}`}, + {name: "network mutation", method: http.MethodPost, path: "/api/v1/network", body: `{}`}, + {name: "firewall mutation", method: http.MethodPost, path: "/api/v1/firewall", body: `{}`}, + {name: "security mutation", method: http.MethodPost, path: "/api/v1/security", body: `{}`}, + {name: "file mutation", method: http.MethodPost, path: "/api/v1/files", body: `{}`}, + {name: "notification mutation", method: http.MethodPost, path: "/api/v1/notifications/1/read", body: `{}`}, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(test.method, test.path, bytes.NewBufferString(test.body)) + request.RemoteAddr = "127.0.0.1:12345" + recorder := httptest.NewRecorder() + server.routes().ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("protected route returned %d: %s", recorder.Code, recorder.Body.String()) + } + }) + } +} + +func TestMutationRoutesRejectUnknownJSONFields(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: "octopus", + AdminToken: "test-admin-token", + BridgeToken: "test-bridge-token", + }) + if err != nil { + t.Fatal(err) + } + if !server.sessions.SetAdministrative(created.ID, created.Identity.AdminToken, time.Now().Add(time.Hour)) { + t.Fatal("could not grant test administrative session") + } + cookie := &http.Cookie{Name: session.CookieName, Value: created.ID} + csrf := created.CSRF + + cases := []struct { + name string + path string + }{ + {name: "storage", path: "/api/v1/storage/preview"}, + {name: "network", path: "/api/v1/network/preview"}, + {name: "firewall", path: "/api/v1/firewall/preview"}, + {name: "security", path: "/api/v1/security/preview"}, + {name: "updates", path: "/api/v1/updates/preview"}, + {name: "files", path: "/api/v1/files"}, + {name: "file upload", path: "/api/v1/files/upload"}, + {name: "service action", path: "/api/v1/services/user/demo.service/actions/preview"}, + {name: "service override", path: "/api/v1/services/user/demo.service/overrides/preview"}, + {name: "timer", path: "/api/v1/timers/preview"}, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewBufferString(`{"unknown":true}`)) + request.AddCookie(cookie) + request.Header.Set("X-CSRF-Token", csrf) + recorder := httptest.NewRecorder() + server.routes().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("unknown field returned %d: %s", recorder.Code, recorder.Body.String()) + } + }) + } +} 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/app/vite_test.go b/apps/backend/internal/app/vite_test.go index e42fb3c..07843b5 100644 --- a/apps/backend/internal/app/vite_test.go +++ b/apps/backend/internal/app/vite_test.go @@ -1,6 +1,7 @@ package app import ( + "net" "net/http" "net/http/httptest" "strings" @@ -32,9 +33,15 @@ func TestViteDevTargetAllowsOnlyLoopback(t *testing.T) { } func TestSpaHandlerProxiesToViteDev(t *testing.T) { - vite := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + vite := httptest.NewUnstartedServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Write([]byte("vite-dev:" + request.URL.Path)) })) + vite.Listener = listener + vite.Start() defer vite.Close() t.Setenv("TAKO_VITE_URL", vite.URL) t.Setenv("TAKO_DASHBOARD_DIR", "") diff --git a/apps/backend/internal/auth/auth.go b/apps/backend/internal/auth/auth.go index 3994473..86b730e 100644 --- a/apps/backend/internal/auth/auth.go +++ b/apps/backend/internal/auth/auth.go @@ -135,11 +135,11 @@ 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"` Network *platform.NetworkOperation `json:"network,omitempty"` + Storage *platform.StorageOperation `json:"storage,omitempty"` Firewall *platform.FirewallOperation `json:"firewall,omitempty"` Security *platform.SecurityOperation `json:"security,omitempty"` SupportReport *platform.SupportReportOperation `json:"supportReport,omitempty"` @@ -154,11 +154,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 +279,6 @@ type UpdateRequest struct { Operation platform.UpdateOperation } -type AutoUpdatesRequest struct { - AdminToken string - Operation platform.AutoUpdatesOperation -} - type KpatchRequest struct { AdminToken string Operation platform.KpatchOperation @@ -308,6 +302,11 @@ type NetworkRequest struct { Operation platform.NetworkOperation } +type StorageRequest struct { + AdminToken string + Operation platform.StorageOperation +} + type FirewallRequest struct { AdminToken string Operation platform.FirewallOperation @@ -613,32 +612,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 @@ -795,6 +768,36 @@ func ApplyNetwork(ctx context.Context, path string, request NetworkRequest) (pla return *response.NetworkState, nil } +func PreviewStorage(ctx context.Context, path string, request StorageRequest) (platform.StorageState, error) { + operation := request.Operation + operation.Action = "preview" + response, err := socketRequestWithLimit(ctx, path, Request{Operation: "storage", AdminToken: request.AdminToken, Storage: &operation}, 30*time.Second, 8<<20) + if err != nil { + return platform.StorageState{}, err + } + if response.Error != "" { + return platform.StorageState{}, storageResponseError(response.Error) + } + if response.StorageState == nil { + return platform.StorageState{}, ErrServiceUnavailable + } + return *response.StorageState, nil +} + +func ApplyStorage(ctx context.Context, path string, request StorageRequest) (platform.StorageState, error) { + response, err := socketRequestWithLimit(ctx, path, Request{Operation: "storage", AdminToken: request.AdminToken, Storage: &request.Operation}, 2*time.Minute, 8<<20) + if err != nil { + return platform.StorageState{}, err + } + if response.Error != "" { + return platform.StorageState{}, storageResponseError(response.Error) + } + if response.StorageState == nil { + return platform.StorageState{}, ErrServiceUnavailable + } + return *response.StorageState, nil +} + func PreviewFirewall(ctx context.Context, path string, request FirewallRequest) (platform.FirewallState, error) { operation := request.Operation operation.Action = "preview" @@ -827,7 +830,9 @@ func ApplyFirewall(ctx context.Context, path string, request FirewallRequest) (p func PreviewSecurity(ctx context.Context, path string, request SecurityRequest) (platform.SecurityStatus, error) { operation := request.Operation - operation.Action = "inspect" + if operation.Action == "" { + operation.Action = "inspect" + } response, err := socketRequestWithLimit(ctx, path, Request{Operation: "security", AdminToken: request.AdminToken, Security: &operation}, 30*time.Second, 512<<10) if err != nil { return platform.SecurityStatus{}, err @@ -887,6 +892,23 @@ func securityResponseError(code string) error { } } +func storageResponseError(code string) error { + switch code { + case "invalid-storage-operation": + return platform.ErrInvalidStorageOperation + case "storage-conflict": + return platform.ErrStorageConflict + case "storage-unsafe": + return platform.ErrStorageUnsafe + case "storage-busy": + return platform.ErrStorageBusy + case "storage-unavailable": + return platform.ErrStorageUnavailable + default: + return errors.New(code) + } +} + func firewallResponseError(code string) error { switch code { case "invalid-firewall-operation": @@ -897,6 +919,8 @@ func firewallResponseError(code string) error { return platform.ErrFirewallOwnership case "firewall-access-risk": return platform.ErrFirewallAccessRisk + case "firewall-checkpoint-invalid": + return platform.ErrFirewallCheckpoint case "firewall-unavailable": return platform.ErrFirewallUnavailable default: @@ -912,6 +936,8 @@ func networkResponseError(code string) error { return platform.ErrNetworkConflict case "network-ownership-conflict": return platform.ErrNetworkOwnership + case "network-checkpoint-invalid": + return platform.ErrNetworkCheckpoint case "network-unavailable": return platform.ErrNetworkUnavailable default: @@ -1107,12 +1133,13 @@ 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"` LogEntry *platform.LogEntry `json:"logEntry,omitempty"` NetworkState *platform.NetworkState `json:"networkState,omitempty"` + StorageState *platform.StorageState `json:"storageState,omitempty"` FirewallState *platform.FirewallState `json:"firewallState,omitempty"` SecurityStatus *platform.SecurityStatus `json:"securityStatus,omitempty"` SupportReport *platform.SupportReport `json:"supportReport,omitempty"` @@ -1128,11 +1155,11 @@ type Response struct { SignalPreview *platform.SignalPreview `json:"signalPreview,omitempty"` IdentityInventory *platform.IdentityInventory `json:"identityInventory,omitempty"` Filesystems []platform.Filesystem `json:"filesystems,omitempty"` + StorageSnapshot *platform.StorageSnapshot `json:"storageSnapshot,omitempty"` NetworkSnapshot *platform.NetworkSnapshot `json:"networkSnapshot,omitempty"` 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..2b2bd79 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: @@ -319,6 +310,20 @@ func ReadFilesystems(ctx context.Context, path string, credentials HostReadCrede return response.Filesystems, nil } +func ReadStorageSnapshot(ctx context.Context, path string, credentials HostReadCredentials) (platform.StorageSnapshot, error) { + response, err := readHostResponse(ctx, path, credentials, "storage.read", &StorageReadOperation{}, 20*time.Second, 8<<20) + if err != nil { + return platform.StorageSnapshot{}, err + } + if response.StorageSnapshot != nil { + return *response.StorageSnapshot, nil + } + if response.Filesystems != nil { + return platform.StorageSnapshotFromFilesystems(response.Filesystems, "Hardware inventory requires administrative access."), nil + } + return platform.StorageSnapshot{}, ErrServiceUnavailable +} + func ReadNetworkSnapshot(ctx context.Context, path string, credentials HostReadCredentials) (platform.NetworkSnapshot, error) { response, err := readHostResponse(ctx, path, credentials, "network.read", &NetworkReadOperation{}, 20*time.Second, 4<<20) if err != nil { @@ -341,6 +346,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 +381,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 +391,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/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/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/bridge_test.go b/apps/backend/internal/bridge/bridge_test.go index 00c3b28..714228f 100644 --- a/apps/backend/internal/bridge/bridge_test.go +++ b/apps/backend/internal/bridge/bridge_test.go @@ -36,6 +36,25 @@ func TestFrameRejectsOversizePayload(t *testing.T) { } } +func FuzzReadFrame(f *testing.F) { + f.Add([]byte{0, 0, 0, 1, '{'}) + f.Add([]byte{0, 0, 0, 0}) + f.Fuzz(func(_ *testing.T, payload []byte) { + _, _ = readFrame(bytes.NewReader(payload)) + }) +} + +func FuzzDecodeHostPayload(f *testing.F) { + f.Add(`{"scope":"system"}`) + f.Add(`{"scope":"system"}{}`) + f.Fuzz(func(_ *testing.T, payload string) { + var operation struct { + Scope string `json:"scope"` + } + _ = decodeHostPayload([]byte(payload), &operation) + }) +} + func TestTypedHostReadRejectsUnknownAndTrailingFields(t *testing.T) { input := bytes.NewBuffer(nil) if err := writeFrame(bufio.NewWriter(input), frame{ID: "host-1", Method: "services.read", Payload: json.RawMessage(`{"scope":"system","unknown":true}`)}); err != nil { 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/dashboard/dist/index.html b/apps/backend/internal/dashboard/dist/index.html index 03a7bbf..530730c 100644 --- a/apps/backend/internal/dashboard/dist/index.html +++ b/apps/backend/internal/dashboard/dist/index.html @@ -6,20 +6,21 @@ Tako - + + - + - - - - - - - - + + + + + + + +
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_integration_test.go b/apps/backend/internal/distro/updates_integration_test.go new file mode 100644 index 0000000..1a3e27a --- /dev/null +++ b/apps/backend/internal/distro/updates_integration_test.go @@ -0,0 +1,24 @@ +//go:build linux && integration + +package distro + +import ( + "context" + "testing" + + "github.com/velopulent/tako/internal/platform" +) + +// TestUpdatesAgainstHost exercises the real distro-specific update adapter in +// the 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 := NewUpdateService().Status(context.Background()) + if len(status.Packages) > platform.MaxUpdatePackages { + t.Fatalf("update inventory exceeded bound: %d", len(status.Packages)) + } + if status.Backend == "" || status.Contract == "" || status.Message == "" { + t.Fatalf("update status omitted runtime contract: %#v", status) + } +} 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_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/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/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 { 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..e7d75b6 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 { @@ -109,17 +108,20 @@ func (hostProbe) BusNames(ctx context.Context) (map[string]bool, error) { } var activatable []string if err := conn.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.ListActivatableNames", 0).Store(&activatable); err != nil { - return nil, err + return busNameInventory(names, nil), nil } - names = append(names, activatable...) - result := make(map[string]bool, len(names)) - for _, name := range names { + return busNameInventory(names, activatable), nil +} + +func busNameInventory(active, activatable []string) map[string]bool { + result := make(map[string]bool, len(active)+len(activatable)) + for _, name := range active { result["active:"+name] = true } for _, name := range activatable { result["available:"+name] = true } - return result, nil + return result } func (hostProbe) CommandVersion(ctx context.Context, name string, arguments ...string) (string, bool) { @@ -169,7 +171,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 +194,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 +206,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 +221,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,39 +283,21 @@ 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 { - networkManager := busErr == nil && busActive(names, "org.freedesktop.NetworkManager") - networkd := (busErr == nil && busActive(names, "org.freedesktop.network1")) || probe.FileExists("/run/systemd/netif") + networkManager := busErr == nil && busActive(names, networkManagerService) + networkd := busErr == nil && busActive(names, networkdBusName) if networkManager && networkd { return Capability{ID: "network", State: StateConflicted, Backend: "NetworkManager+networkd", Readable: true, Contract: "conflicted-read-only", Reason: "Multiple network managers are active; mutations fail closed", SetupGuidance: "Choose one network manager for each interface before editing in Tako."} } diff --git a/apps/backend/internal/platform/capabilities_test.go b/apps/backend/internal/platform/capabilities_test.go index a5102de..a81bfbf 100644 --- a/apps/backend/internal/platform/capabilities_test.go +++ b/apps/backend/internal/platform/capabilities_test.go @@ -23,6 +23,22 @@ func TestReadBoundedRejectsOversizedCommandOutput(t *testing.T) { } } +func TestBusNameInventorySeparatesActiveAndActivatableNames(t *testing.T) { + names := busNameInventory( + []string{"org.freedesktop.NetworkManager"}, + []string{"org.freedesktop.network1", "org.fedoraproject.FirewallD1"}, + ) + if !busActive(names, "org.freedesktop.NetworkManager") { + t.Fatal("active D-Bus name was not recorded") + } + if busActive(names, "org.freedesktop.network1") || busActive(names, "org.fedoraproject.FirewallD1") { + t.Fatalf("activatable D-Bus names were marked active: %#v", names) + } + if !busAvailable(names, "org.freedesktop.network1") || !busAvailable(names, "org.fedoraproject.FirewallD1") { + t.Fatalf("activatable D-Bus names were not recorded as available: %#v", names) + } +} + func TestInactiveUFWDoesNotConflictWithFirewalld(t *testing.T) { capabilities := detect(context.Background(), fakeProbe{ bus: map[string]bool{"org.fedoraproject.FirewallD1": true}, @@ -53,6 +69,15 @@ func TestActivatableManagersDoNotCreateOwnershipConflict(t *testing.T) { } } +func TestNetworkdRuntimeDirectoryDoesNotImplyRunningNetworkd(t *testing.T) { + capability := networkCapability(context.Background(), fakeProbe{ + files: map[string]bool{"/run/systemd/netif": true}, + }, map[string]bool{}, nil) + if capability.State != StateUnavailable || capability.Backend != "none" || capability.Mutable { + t.Fatalf("runtime directory caused a false networkd capability: %#v", capability) + } +} + func (probe fakeProbe) CommandOutput(_ context.Context, name string, arguments ...string) (string, bool) { key := name + " " + strings.Join(arguments, " ") if output, ok := probe.outputs[key]; ok { @@ -116,11 +141,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 +160,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/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..615c2d8 100644 --- a/apps/backend/internal/platform/files.go +++ b/apps/backend/internal/platform/files.go @@ -5,13 +5,16 @@ import ( "bufio" "compress/gzip" "context" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" + "io/fs" "mime" + "net/url" "os" "os/user" "path/filepath" @@ -63,6 +66,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 +95,17 @@ type FileResult struct { Warnings []string `json:"warnings,omitempty"` Message string `json:"message,omitempty"` Fingerprint string `json:"fingerprint,omitempty"` + UploadID string `json:"uploadId,omitempty"` + Uploads []UploadInfo `json:"uploads,omitempty"` +} + +type UploadInfo struct { + UploadID string `json:"uploadId"` + Path string `json:"path"` + Offset int64 `json:"offset"` + Total int64 `json:"total"` + ExpiresAt time.Time `json:"expiresAt"` + Completed bool `json:"completed,omitempty"` } // FileOperation is the narrow wire contract shared by the gateway, sessiond, @@ -97,6 +114,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"` @@ -111,6 +130,7 @@ type FileOperation struct { ShowHidden bool `json:"showHidden,omitempty"` Recursive bool `json:"recursive,omitempty"` Permanent bool `json:"permanent,omitempty"` + Overwrite bool `json:"overwrite,omitempty"` Confirmation string `json:"confirmation,omitempty"` Query string `json:"query,omitempty"` MaxEntries int `json:"maxEntries,omitempty"` @@ -121,8 +141,8 @@ 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} - 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") { + validActions := map[string]bool{"list": true, "stat": true, "read": true, "read-window": true, "write": true, "write-text": true, "write-chunk": true, "upload-status": 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 || len(operation.Owner) > 256 || len(operation.Group) > 256 || strings.ContainsAny(operation.Path+operation.Destination+operation.ArchivePath+operation.Owner+operation.Group, "\x00\r\n") { return ErrInvalidFileOperation } if operation.Path == "" && operation.Action != "archive" { @@ -132,6 +152,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 } @@ -147,12 +179,23 @@ func ValidateFileOperation(operation FileOperation) error { if operation.ExpectedFingerprint != "" && (len(operation.ExpectedFingerprint) != sha256.Size*2 || !isHex(operation.ExpectedFingerprint)) { return ErrInvalidFileOperation } + if operation.Mode != nil && *operation.Mode > 0o7777 { + return ErrInvalidFileOperation + } if operation.ContentSHA256 != "" && (len(operation.ContentSHA256) != sha256.Size*2 || !isHex(operation.ContentSHA256)) { return ErrInvalidFileOperation } if len(operation.Confirmation) > 128 || strings.ContainsAny(operation.Confirmation, "\x00\r\n") { return ErrInvalidFileOperation } + if operation.Overwrite { + if operation.Action != "rename" && operation.Action != "move" && operation.Action != "copy" { + return ErrInvalidFileOperation + } + if operation.Confirmation != "CONFIRM FILE OVERWRITE" { + return ErrInvalidFileOperation + } + } if operation.Permanent || operation.Recursive || operation.Action == "metadata" { if operation.Confirmation != "CONFIRM FILE OPERATION" { return ErrInvalidFileOperation @@ -163,7 +206,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 +250,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 +300,61 @@ 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 "upload-status": + return f.uploadStatus(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) + trashRoot := "." + if privileged { + trashRoot = "root" + } + return f.trashFile(path, trashRoot, operation) + case "restore": + return f.restoreFile(path, operation, privileged) 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 +396,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 +489,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 +566,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 +596,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 +609,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 +622,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 } @@ -543,63 +667,342 @@ func transferFile(action, source, destination string, operation FileOperation) ( if info.Mode()&os.ModeSymlink != 0 { return FileResult{}, errors.New("symlink transfers require explicit link handling") } + if info.IsDir() && filePathWithin(source, destination) { + return FileResult{}, ErrInvalidFileOperation + } + if operation.Overwrite { + if info.IsDir() || !info.Mode().IsRegular() { + return FileResult{}, ErrInvalidFileOperation + } + destinationInfo, destinationErr := f.root.Lstat(destination) + if destinationErr == nil { + if destinationInfo.Mode()&os.ModeSymlink != 0 || !destinationInfo.Mode().IsRegular() { + return FileResult{}, ErrInvalidFileOperation + } + } else if !errors.Is(destinationErr, os.ErrNotExist) { + return FileResult{}, destinationErr + } + } if action == "copy" { if info.IsDir() { - return FileResult{}, errors.New("directory copy requires archive or recursive operation") + if !operation.Recursive { + return FileResult{}, errors.New("directory copy requires recursive confirmation") + } + if err := f.copyDirectory(source, destination); err != nil { + return FileResult{}, err + } + } else if err := f.copyRegularFile(source, destination, info, nil, operation.Overwrite); err != nil { + return FileResult{}, err } - input, openErr := os.Open(source) - if openErr != nil { - return FileResult{}, openErr + } else { + if err := f.root.rename(source, destination, !operation.Overwrite); err != nil { + if errors.Is(err, syscall.EXDEV) && action == "move" { + if _, copyErr := f.transferFile("copy", source, destination, operation); copyErr != nil { + return FileResult{}, copyErr + } + removeErr := f.root.Remove(source) + if info.IsDir() { + removeErr = f.root.RemoveAll(source) + } + if removeErr != nil { + return FileResult{}, removeErr + } + } else { + return FileResult{}, err + } } - output, createErr := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm()) - if createErr != nil { - _ = input.Close() - return FileResult{}, createErr + } + entry, err := f.entry(destination, destination) + return FileResult{Entry: &entry}, err +} + +type fileCopyBudget struct { + entries int + bytes int64 +} + +func (f fileTree) copyDirectory(source, destination string) error { + budget := fileCopyBudget{} + if err := f.copyDirectoryContents(source, destination, 0, &budget); err != nil { + _ = f.root.RemoveAll(destination) + return err + } + return nil +} + +func (f fileTree) copyDirectoryContents(source, destination string, depth int, budget *fileCopyBudget) error { + if depth > MaxArchiveDepth { + return ErrArchiveLimit + } + info, err := f.root.Lstat(source) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return ErrFilePermission + } + if _, err := f.root.Lstat(destination); err == nil { + return os.ErrExist + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if err := f.root.Mkdir(destination, info.Mode().Perm()); err != nil { + return err + } + directory, err := f.root.Open(source) + if err != nil { + return err + } + defer directory.Close() + for { + entries, readErr := directory.ReadDir(128) + for _, item := range entries { + budget.entries++ + if budget.entries > MaxArchiveEntries { + return ErrArchiveLimit + } + childSource := filepath.Join(source, item.Name()) + childDestination := filepath.Join(destination, item.Name()) + childInfo, infoErr := f.root.Lstat(childSource) + if infoErr != nil { + return infoErr + } + if childInfo.Mode()&os.ModeSymlink != 0 || (!childInfo.IsDir() && !childInfo.Mode().IsRegular()) { + return ErrFilePermission + } + if childInfo.IsDir() { + if err := f.copyDirectoryContents(childSource, childDestination, depth+1, budget); err != nil { + return fmt.Errorf("copy directory %s: %w", childSource, err) + } + continue + } + if err := f.copyRegularFile(childSource, childDestination, childInfo, budget, false); err != nil { + return err + } } - written, copyErr := io.CopyN(output, input, MaxArchiveBytes+1) - _ = input.Close() - _ = output.Close() - if copyErr == nil || written > MaxArchiveBytes { - _ = os.Remove(destination) - return FileResult{}, ErrFileTooLarge + if errors.Is(readErr, io.EOF) { + break } - if copyErr != nil && !errors.Is(copyErr, io.EOF) { - _ = os.Remove(destination) - return FileResult{}, copyErr + if readErr != nil { + return readErr } - } else if err := os.Rename(source, destination); err != nil { - if errors.Is(err, syscall.EXDEV) && action == "move" { - if _, copyErr := transferFile("copy", source, destination, operation); copyErr != nil { - return FileResult{}, copyErr - } - if removeErr := os.Remove(source); removeErr != nil { - return FileResult{}, removeErr + } + return f.preserveMetadata(destination, info) +} + +func (f fileTree) copyRegularFile(source, destination string, info os.FileInfo, budget *fileCopyBudget, overwrite bool) error { + if info.Size() < 0 || info.Size() > MaxArchiveEntryBytes { + return ErrFileTooLarge + } + if budget != nil && (budget.bytes > MaxArchiveBytes-info.Size()) { + return ErrArchiveLimit + } + input, err := f.root.Open(source) + if err != nil { + return err + } + defer input.Close() + var output *os.File + outputPath := destination + if overwrite { + temporary, temporaryErr := f.temporary(filepath.Dir(destination)) + if temporaryErr != nil { + return temporaryErr + } + outputPath = filepath.Join(filepath.Dir(destination), filepath.Base(temporary.Name())) + defer f.root.Remove(outputPath) + output = temporary + } else { + output, err = f.root.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm()) + if err != nil { + return err + } + } + written, copyErr := io.CopyN(output, input, info.Size()) + closeErr := output.Close() + if copyErr == nil && closeErr == nil && written == info.Size() { + if err := f.preserveMetadata(outputPath, info); err != nil { + return err + } + if overwrite { + if err := f.root.rename(outputPath, destination, false); err != nil { + return err } - } else { - return FileResult{}, err } + if budget != nil { + budget.bytes += written + } + return nil } - entry, err := fileEntry(destination, destination) - return FileResult{Entry: &entry}, err + _ = f.root.Remove(outputPath) + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + return io.ErrUnexpectedEOF } -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 } - return FileResult{Message: "Item moved to the user trash."}, nil + trashRoot := filepath.Join(root, ".local", "share", "Trash") + if strings.HasPrefix(path, trashRoot+"/") { + return FileResult{}, ErrInvalidFileOperation + } + 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+".trashinfo") + record, err := f.root.OpenFile(recordPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return FileResult{}, err + } + original := path + if root == "." { + if home, homeErr := os.UserHomeDir(); homeErr == nil { + original = filepath.Join(home, path) + } + } else { + original = filepath.Join(string(filepath.Separator), path) + } + _, err = io.WriteString(record, "[Trash Info]\nPath="+escapeTrashPath(original)+"\nDeletionDate="+time.Now().UTC().Format("2006-01-02T15:04:05")+"\n") + 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, privileged bool) (FileResult, error) { + directory := ".local/share/Trash/files/" + if privileged { + directory = "root/.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(filepath.Dir(filepath.Dir(path)), "info", filepath.Base(path)+".trashinfo") + record, err := f.root.Open(recordPath) + legacy := false + if errors.Is(err, os.ErrNotExist) { + recordPath = filepath.Join(filepath.Dir(filepath.Dir(path)), "info", filepath.Base(path)+".json") + record, err = f.root.Open(recordPath) + legacy = true + } + if err != nil { + return FileResult{}, err + } + data, err := io.ReadAll(io.LimitReader(record, MaxFilePath+512)) + record.Close() + original, parseErr := parseTrashOriginal(data, legacy) + if err != nil || parseErr != nil { + return FileResult{}, ErrInvalidFileOperation + } + originalPath, err := f.restoreOriginalPath(original, privileged) + if err != nil { + return FileResult{}, err + } + if err := f.root.rename(path, originalPath, true); err != nil { + return FileResult{}, err + } + f.root.Remove(recordPath) + entry, err := f.entry(originalPath, originalPath) + return FileResult{Entry: &entry, Message: "Item restored."}, err +} + +func escapeTrashPath(path string) string { + return strings.ReplaceAll(url.QueryEscape(path), "+", "%20") +} + +func parseTrashOriginal(data []byte, legacy bool) (string, error) { + if legacy { + var record struct { + Path string `json:"path"` + } + if err := json.Unmarshal(data, &record); err != nil { + return "", err + } + return record.Path, nil + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "Path=") { + return url.QueryUnescape(strings.TrimPrefix(line, "Path=")) + } + } + return "", ErrInvalidFileOperation +} + +func (f fileTree) restoreOriginalPath(original string, privileged bool) (string, error) { + if original == "" || strings.ContainsAny(original, "\x00\r\n") { + return "", ErrInvalidFileOperation + } + if privileged { + if !filepath.IsAbs(original) { + if !filepath.IsLocal(original) || original == "." { + return "", ErrInvalidFileOperation + } + return original, nil + } + original = filepath.Clean(original) + if original == "/" { + return "", ErrInvalidFileOperation + } + return strings.TrimPrefix(original, "/"), nil + } + home, err := os.UserHomeDir() + if !filepath.IsAbs(original) { + if filepath.IsLocal(original) && original != "." { + return original, nil + } + return "", ErrInvalidFileOperation + } + if err != nil { + return "", ErrInvalidFileOperation + } + relative, err := filepath.Rel(filepath.Clean(home), filepath.Clean(original)) + if err != nil || !filepath.IsLocal(relative) || relative == "." { + return "", ErrInvalidFileOperation + } + return relative, nil +} + +func (f fileTree) deleteFile(path string, operation FileOperation) (FileResult, error) { + info, err := f.root.Lstat(path) if err != nil { return FileResult{}, err } @@ -610,32 +1013,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 +1057,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 +1081,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 +1121,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 } @@ -717,17 +1135,46 @@ func createArchive(ctx context.Context, source, destination string, operation Fi entries++ return nil }) - _ = tarWriter.Close() - _ = gzipWriter.Close() + tarCloseErr := tarWriter.Close() + gzipCloseErr := gzipWriter.Close() + syncErr := output.Sync() + outputCloseErr := output.Close() if err != nil { return FileResult{}, err } + if tarCloseErr != nil { + return FileResult{}, tarCloseErr + } + if gzipCloseErr != nil { + return FileResult{}, gzipCloseErr + } + if syncErr != nil { + return FileResult{}, syncErr + } + if outputCloseErr != nil { + return FileResult{}, outputCloseErr + } removePartial = false 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() + created := []string{} + completed := false + defer func() { + if completed { + return + } + for index := len(created) - 1; index >= 0; index-- { + _ = destinationRoot.RemoveAll(created[index]) + } + }() + input, err := f.root.Open(archivePath) if err != nil { return FileResult{}, err } @@ -752,45 +1199,78 @@ func extractArchive(ctx context.Context, archivePath, destination string, operat return FileResult{}, nextErr } entries++ - if entries > MaxArchiveEntries || header.Size > MaxArchiveEntryBytes || bytesRead+header.Size > MaxArchiveBytes { + if entries > MaxArchiveEntries || header.Size < 0 || header.Size > MaxArchiveEntryBytes || bytesRead+header.Size > MaxArchiveBytes { return FileResult{}, ErrArchiveLimit } 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 := ensureExtractDirectory(destinationRoot, target, &created); 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 := ensureExtractDirectory(destinationRoot, filepath.Dir(target), &created); 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 } + created = append(created, target) written, copyErr := io.CopyN(output, tarReader, header.Size) - _ = output.Close() + closeErr := output.Close() if copyErr != nil { return FileResult{}, copyErr } + if closeErr != nil { + return FileResult{}, closeErr + } bytesRead += written } + completed = true return FileResult{Message: fmt.Sprintf("Archive extracted with %d entries.", entries)}, nil } +func ensureExtractDirectory(root *fileRoot, path string, created *[]string) error { + if path == "." || path == "" { + return nil + } + current := "." + for _, part := range strings.Split(filepath.Clean(path), string(filepath.Separator)) { + if part == "" || part == "." { + continue + } + current = filepath.Join(current, part) + info, err := root.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + if err := root.Mkdir(current, 0o755); err != nil { + return err + } + *created = append(*created, current) + continue + } + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return ErrUnsafeArchive + } + } + return nil +} + 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 +1278,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 +1290,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 +1318,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 +1355,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..23e2de2 100644 --- a/apps/backend/internal/platform/files_test.go +++ b/apps/backend/internal/platform/files_test.go @@ -1,10 +1,15 @@ package platform import ( + "archive/tar" + "compress/gzip" "context" + "crypto/sha256" + "encoding/hex" "errors" "os" "path/filepath" + "strings" "testing" ) @@ -73,3 +78,237 @@ 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 TestExtractionRemovesPartialOutputAfterFailure(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "destination"), 0700); err != nil { + t.Fatal(err) + } + archive, err := os.Create(filepath.Join(root, "partial.tar.gz")) + if err != nil { + t.Fatal(err) + } + compressed := gzip.NewWriter(archive) + writer := tar.NewWriter(compressed) + if err := writer.WriteHeader(&tar.Header{Name: "created.txt", Mode: 0600, Size: 5}); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte("hello")); err != nil { + t.Fatal(err) + } + if err := writer.WriteHeader(&tar.Header{Name: "unsafe", Typeflag: tar.TypeSymlink, Linkname: "/tmp/outside"}); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := compressed.Close(); err != nil { + t.Fatal(err) + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + + _, err = applyFileOperation(context.Background(), FileOperation{ + Action: "extract", Path: "destination", ArchivePath: "partial.tar.gz", Confirmation: "CONFIRM FILE OPERATION", + }, root, false) + if err == nil { + t.Fatal("unsafe archive unexpectedly extracted") + } + if _, err := os.Stat(filepath.Join(root, "destination", "created.txt")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("partial output remained: %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) + } + status, err := applyFileOperation(context.Background(), FileOperation{Action: "upload-status", Path: "upload", UploadID: first.UploadID}, root, false) + if err != nil || len(status.Uploads) != 1 || !status.Uploads[0].Completed || status.Uploads[0].Offset != 6 { + t.Fatalf("completed upload status = %+v, err=%v", status.Uploads, err) + } +} + +func TestRecursiveDirectoryCopyAndUploadStatus(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "source", "nested"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "source", "nested", "value"), []byte("copy me"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := applyFileOperation(context.Background(), FileOperation{Action: "copy", Path: "source", Destination: "copied", Recursive: true, Confirmation: "CONFIRM FILE OPERATION"}, root, false); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(root, "copied", "nested", "value")) + if err != nil || string(data) != "copy me" { + t.Fatalf("copied content %q, err=%v", data, err) + } + if _, err := applyFileOperation(context.Background(), FileOperation{Action: "copy", Path: "source", Destination: "source/child", Recursive: true, Confirmation: "CONFIRM FILE OPERATION"}, root, false); !errors.Is(err, ErrInvalidFileOperation) { + t.Fatalf("copy into source error = %v", err) + } + + chunk := []byte("abc") + sum := sha256.Sum256(chunk) + first, err := applyFileOperation(context.Background(), FileOperation{Action: "write-chunk", Path: "upload", Offset: 0, TotalSize: 6, Content: chunk, ContentSHA256: hex.EncodeToString(sum[:])}, root, false) + if err != nil { + t.Fatal(err) + } + status, err := applyFileOperation(context.Background(), FileOperation{Action: "upload-status", Path: "upload"}, root, false) + if err != nil || len(status.Uploads) != 1 || status.Uploads[0].UploadID != first.UploadID || status.Uploads[0].Offset != 3 { + t.Fatalf("upload status = %+v, err=%v", status.Uploads, err) + } +} + +func TestFileOverwriteRequiresExplicitConfirmationAndReplacesRegularFiles(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "source"), []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "destination"), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + operation := FileOperation{Action: "copy", Path: "source", Destination: "destination", Overwrite: true} + if _, err := applyFileOperation(context.Background(), operation, root, false); !errors.Is(err, ErrInvalidFileOperation) { + t.Fatalf("missing overwrite confirmation error = %v", err) + } + operation.Confirmation = "CONFIRM FILE OVERWRITE" + if _, err := applyFileOperation(context.Background(), operation, root, false); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(root, "destination")) + if err != nil || string(content) != "new" { + t.Fatalf("overwritten content %q, err=%v", content, err) + } + + if err := os.Mkdir(filepath.Join(root, "directory"), 0o700); err != nil { + t.Fatal(err) + } + operation.Destination = "directory" + if _, err := applyFileOperation(context.Background(), operation, root, false); !errors.Is(err, ErrInvalidFileOperation) { + t.Fatalf("directory overwrite error = %v", err) + } +} + +func TestTrashUsesFreedesktopMetadataAndRestores(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "note"), []byte("trash"), 0o600); err != nil { + t.Fatal(err) + } + moved, err := applyFileOperation(context.Background(), FileOperation{Action: "trash", Path: "note"}, root, false) + if err != nil || moved.Entry == nil { + t.Fatalf("trash result = %+v, err=%v", moved, err) + } + metadata := filepath.Join(root, ".local/share/Trash/info", filepath.Base(moved.Entry.Path)+".trashinfo") + content, err := os.ReadFile(metadata) + if err != nil || !strings.Contains(string(content), "[Trash Info]") || !strings.Contains(string(content), "Path=") { + t.Fatalf("trash metadata = %q, err=%v", content, err) + } + restored, err := applyFileOperation(context.Background(), FileOperation{Action: "restore", Path: moved.Entry.Path}, root, false) + if err != nil || restored.Entry == nil { + t.Fatalf("restore result = %+v, err=%v", restored, err) + } + if _, err := os.Stat(filepath.Join(root, "note")); err != nil { + t.Fatalf("restored file missing: %v", 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..1c61b1a --- /dev/null +++ b/apps/backend/internal/platform/files_upload.go @@ -0,0 +1,278 @@ +package platform + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +type uploadRecord struct { + Path string `json:"path"` + Total int64 `json:"total"` + Fingerprint string `json:"fingerprint"` +} + +const uploadStagingTTL = 24 * time.Hour + +func uploadNames(path, id string) (string, string) { + data := filepath.Join(filepath.Dir(path), ".tako-upload-"+id) + return data, data + ".json" +} + +func uploadCompletedName(path, id string) string { + _, metadata := uploadNames(path, id) + return strings.TrimSuffix(metadata, ".json") + ".done.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[:]) + _ = f.cleanupStaleUploads(filepath.Dir(path)) + } + 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 + } + // Preserve a short-lived completion marker so a client that lost the + // final response can distinguish completion from expired staging. + _ = f.root.rename(metadata, uploadCompletedName(path, id), true) + 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) cleanupStaleUploads(directory string) error { + parent, err := f.root.Open(directory) + if err != nil { + return err + } + defer parent.Close() + entries, err := parent.ReadDir(256) + if err != nil && !errors.Is(err, io.EOF) { + return err + } + now := time.Now() + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, ".tako-upload-") || !strings.HasSuffix(name, ".json") { + continue + } + info, infoErr := entry.Info() + if infoErr != nil || now.Sub(info.ModTime()) < uploadStagingTTL { + continue + } + id := strings.TrimSuffix(strings.TrimPrefix(name, ".tako-upload-"), ".json") + if strings.HasSuffix(id, ".done") { + id = strings.TrimSuffix(id, ".done") + } + if len(id) != 32 || !isHex(id) { + continue + } + data, _ := uploadNames(filepath.Join(directory, "placeholder"), id) + _ = f.root.Remove(data) + _ = f.root.Remove(filepath.Join(directory, name)) + } + return nil +} + +func (f fileTree) uploadStatus(path string, operation FileOperation) (FileResult, error) { + if err := f.cleanupStaleUploads(filepath.Dir(path)); err != nil && !errors.Is(err, os.ErrNotExist) { + return FileResult{}, err + } + parent, err := f.root.Open(filepath.Dir(path)) + if err != nil { + return FileResult{}, err + } + defer parent.Close() + entries, err := parent.ReadDir(256) + if err != nil && !errors.Is(err, io.EOF) { + return FileResult{}, err + } + result := make([]UploadInfo, 0) + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, ".tako-upload-") || !strings.HasSuffix(name, ".json") { + continue + } + id := strings.TrimSuffix(strings.TrimPrefix(name, ".tako-upload-"), ".json") + completed := false + if strings.HasSuffix(id, ".done") { + completed = true + id = strings.TrimSuffix(id, ".done") + } + if len(id) != 32 || !isHex(id) || (operation.UploadID != "" && operation.UploadID != id) { + continue + } + metadataPath := filepath.Join(filepath.Dir(path), name) + metadata, openErr := f.root.Open(metadataPath) + if openErr != nil { + continue + } + var record uploadRecord + decodeErr := json.NewDecoder(io.LimitReader(metadata, 8192)).Decode(&record) + metadata.Close() + if decodeErr != nil || record.Path != path || record.Total <= 0 { + continue + } + dataPath, _ := uploadNames(path, id) + metaInfo, metaErr := f.root.Stat(metadataPath) + if metaErr != nil { + continue + } + dataInfo, statErr := f.root.Stat(dataPath) + if completed || errors.Is(statErr, os.ErrNotExist) { + entry, entryErr := f.root.Stat(record.Path) + if entryErr == nil && entry.Mode().IsRegular() && entry.Size() == record.Total { + result = append(result, UploadInfo{UploadID: id, Path: record.Path, Offset: record.Total, Total: record.Total, ExpiresAt: metaInfo.ModTime().Add(uploadStagingTTL).UTC(), Completed: true}) + } + continue + } + if statErr != nil || !dataInfo.Mode().IsRegular() { + continue + } + result = append(result, UploadInfo{UploadID: id, Path: record.Path, Offset: dataInfo.Size(), Total: record.Total, ExpiresAt: metaInfo.ModTime().Add(uploadStagingTTL).UTC()}) + } + sort.Slice(result, func(i, j int) bool { return result[i].UploadID < result[j].UploadID }) + return FileResult{Uploads: 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 +} diff --git a/apps/backend/internal/platform/firewall.go b/apps/backend/internal/platform/firewall.go index f6afbd1..2952e58 100644 --- a/apps/backend/internal/platform/firewall.go +++ b/apps/backend/internal/platform/firewall.go @@ -2,10 +2,14 @@ package platform import ( "context" + "encoding/json" "errors" "net" + "regexp" + "sort" "strconv" "strings" + "time" ) var ( @@ -14,19 +18,28 @@ var ( ErrFirewallOwnership = errors.New("firewall ownership is conflicted") ErrFirewallUnavailable = errors.New("firewall adapter unavailable") ErrFirewallAccessRisk = errors.New("firewall operation could lock out management access") + ErrFirewallCheckpoint = errors.New("firewall rollback checkpoint is invalid or expired") ) +// 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 { @@ -40,55 +53,60 @@ type FirewallOperation struct { ExpectedFingerprint string `json:"expectedFingerprint,omitempty"` Confirmation string `json:"confirmation,omitempty"` Persist bool `json:"persist,omitempty"` + RollbackSeconds int `json:"rollbackSeconds,omitempty"` + Checkpoint string `json:"checkpoint,omitempty"` + RollbackToken string `json:"rollbackToken,omitempty"` } type FirewallState struct { - Snapshot FirewallSnapshot `json:"snapshot"` - Action string `json:"action"` - Applied bool `json:"applied"` - Warning string `json:"warning,omitempty"` + Snapshot FirewallSnapshot `json:"snapshot"` + Action string `json:"action"` + Applied bool `json:"applied"` + Committed bool `json:"committed,omitempty"` + RollbackRequired bool `json:"rollbackRequired,omitempty"` + Checkpoint string `json:"checkpoint,omitempty"` + RollbackToken string `json:"rollbackToken,omitempty"` + RollbackDeadline time.Time `json:"rollbackDeadline,omitempty"` + 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, + "commit": true, + "rollback": 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 || len(operation.Checkpoint) > 256 || len(operation.RollbackToken) > 256 || operation.RollbackSeconds < 0 || operation.RollbackSeconds > 600 || strings.ContainsAny(operation.Zone+operation.Service+operation.Port+operation.Source+operation.DefaultZone+operation.ExpectedFingerprint+operation.Confirmation+operation.Checkpoint+operation.RollbackToken, "\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 +117,65 @@ 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 + } + case "commit", "rollback": + if operation.Checkpoint == "" || operation.RollbackToken == "" || operation.Zone != "" || operation.Service != "" || operation.Port != "" || operation.Source != "" || operation.DefaultZone != "" || operation.ExpectedFingerprint != "" { return ErrInvalidFirewallOperation } } - if operation.ExpectedFingerprint == "" && operation.Action != "preview" { + if operation.Backend == "UFW" && operation.Action == "default-zone" { + return ErrInvalidFirewallOperation + } + if operation.ExpectedFingerprint == "" && operation.Action != "preview" && operation.Action != "commit" && operation.Action != "rollback" { 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.RollbackSeconds != 0 && operation.RollbackSeconds < 30 { + return ErrFirewallAccessRisk + } + 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 +185,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) + } +} diff --git a/apps/backend/internal/platform/fuzz_test.go b/apps/backend/internal/platform/fuzz_test.go new file mode 100644 index 0000000..405541f --- /dev/null +++ b/apps/backend/internal/platform/fuzz_test.go @@ -0,0 +1,83 @@ +package platform + +import ( + "bytes" + "testing" +) + +func FuzzValidateFileOperation(f *testing.F) { + f.Add("read", "notes.txt", "", "", "", "", "", "", int64(0), int64(4096), int64(0), 100, 100, 100, false, false, false) + f.Add("archive", "source", "", "archive.tar.gz", "", "", "", "", int64(0), int64(0), int64(0), 0, 0, 0, false, false, false) + f.Fuzz(func(t *testing.T, action, path, destination, archivePath, query, content, contentSHA, expectedFingerprint string, offset, limit, totalSize int64, lineOffset, lineLimit, maxEntries int, overwrite, recursive, permanent bool) { + _ = ValidateFileOperation(FileOperation{ + Action: action, + Path: path, + Destination: destination, + ArchivePath: archivePath, + Query: query, + Content: []byte(content), + ContentSHA256: contentSHA, + ExpectedFingerprint: expectedFingerprint, + Offset: offset, + Limit: limit, + TotalSize: totalSize, + LineOffset: lineOffset, + LineLimit: lineLimit, + MaxEntries: maxEntries, + Overwrite: overwrite, + Recursive: recursive, + Permanent: permanent, + }) + }) +} + +func FuzzSafeArchiveName(f *testing.F) { + for _, seed := range []string{"file.txt", "nested/file.txt", "../escape", "/absolute", ""} { + f.Add(seed) + } + f.Fuzz(func(_ *testing.T, name string) { + _ = safeArchiveName(name) + }) +} + +func FuzzReadBounded(f *testing.F) { + f.Add([]byte("small output"), uint8(64)) + f.Add([]byte("oversized output"), uint8(4)) + f.Fuzz(func(_ *testing.T, payload []byte, limit uint8) { + _, _ = readBounded(bytes.NewReader(payload), int64(limit)) + }) +} + +func FuzzParseLogEntry(f *testing.F) { + f.Add([]byte(`{"__REALTIME_TIMESTAMP":"1704067200000000","MESSAGE":"ready","__CURSOR":"s=1"}`), true) + f.Add([]byte(`not-json`), false) + f.Fuzz(func(_ *testing.T, payload []byte, details bool) { + _, _ = parseLogEntryWithDetails(payload, details) + }) +} + +func FuzzValidateUpdateOperation(f *testing.F) { + f.Add("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "job-1", true, true) + f.Add("", "", false, false) + f.Fuzz(func(_ *testing.T, fingerprint, jobID string, confirmed, applying bool) { + _ = ValidateUpdateOperation(UpdateOperation{ + ExpectedFingerprint: fingerprint, + JobID: jobID, + Confirmed: confirmed, + }, applying) + }) +} + +func FuzzValidateTimerOperation(f *testing.F) { + f.Add("preview", "user", "nightly", "*-*-* 03:00:00", "/bin/true") + f.Add("delete", "system", "", "", "") + f.Fuzz(func(_ *testing.T, action, scope, name, schedule, command string) { + _ = ValidateTimerOperation(TimerOperation{ + Action: action, + Scope: scope, + Name: name, + OnCalendar: schedule, + Command: command, + }) + }) +} diff --git a/apps/backend/internal/platform/journal.go b/apps/backend/internal/platform/journal.go index 2eee3fc..51a2ec4 100644 --- a/apps/backend/internal/platform/journal.go +++ b/apps/backend/internal/platform/journal.go @@ -149,7 +149,7 @@ func QueryLogs(ctx context.Context, query JournalQuery) (JournalPage, error) { // QueryLogsAs runs journalctl with optional UNIX credentials. A nil credential // keeps the caller identity. sessiond passes the authenticated operator, or -// nil when an administrative grant is active (root journal, Cockpit-style). +// nil when an administrative grant is active (root journal access). func QueryLogsAs(ctx context.Context, query JournalQuery, cred *syscall.Credential) (JournalPage, error) { if query.Limit == 0 { query.Limit = 200 @@ -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/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/modules.go b/apps/backend/internal/platform/modules.go index 9a3b36c..529eec5 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" @@ -193,13 +195,7 @@ func Interfaces() ([]Interface, error) { for _, address := range addresses { values = append(values, address.String()) } - manager := "kernel" - if fileExists("/run/NetworkManager") { - manager = "NetworkManager" - } else if fileExists("/run/systemd/netif") { - manager = "systemd-networkd" - } - result = append(result, Interface{Name: item.Name, Index: item.Index, MTU: item.MTU, Hardware: item.HardwareAddr.String(), Addresses: values, Up: item.Flags&net.FlagUp != 0, RX: readUint(filepath.Join("/sys/class/net", item.Name, "statistics/rx_bytes")), TX: readUint(filepath.Join("/sys/class/net", item.Name, "statistics/tx_bytes")), Manager: manager}) + result = append(result, Interface{Name: item.Name, Index: item.Index, MTU: item.MTU, Hardware: item.HardwareAddr.String(), Addresses: values, Up: item.Flags&net.FlagUp != 0, RX: readUint(filepath.Join("/sys/class/net", item.Name, "statistics/rx_bytes")), TX: readUint(filepath.Join("/sys/class/net", item.Name, "statistics/tx_bytes")), Manager: "kernel"}) } return result, nil } @@ -390,6 +386,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 +428,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/network.go b/apps/backend/internal/platform/network.go index ef53344..984f60a 100644 --- a/apps/backend/internal/platform/network.go +++ b/apps/backend/internal/platform/network.go @@ -10,19 +10,23 @@ import ( "net" "os" "os/exec" - "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 +65,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 +83,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) { @@ -126,10 +164,15 @@ func NetworkSnapshotRead(ctx context.Context) (NetworkSnapshot, error) { } } snapshot.DNS = readDNSConfiguration() - snapshot.Ownership = detectNetworkOwnership() + snapshot.Ownership = detectNetworkOwnership(ctx) for index := range snapshot.Interfaces { - if snapshot.Ownership.ActiveOwner != "" { + if snapshot.Ownership.Conflicted { + snapshot.Interfaces[index].Conflict = true + snapshot.Interfaces[index].Reason = snapshot.Ownership.Reason + } + if snapshot.Ownership.ActiveOwner != "" && snapshot.Ownership.ActiveOwner != "kernel" { snapshot.Interfaces[index].Manager = snapshot.Ownership.ActiveOwner + snapshot.Interfaces[index].Owner = snapshot.Ownership.ActiveOwner } } snapshot.Fingerprint = networkFingerprint(snapshot) @@ -137,6 +180,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...) @@ -178,69 +228,76 @@ func readDNSConfiguration() []string { return result } -func detectNetworkOwnership() NetworkOwnership { - detected := []string{} - if fileExists("/run/NetworkManager") || fileExists("/run/NetworkManager/nm-dhcp-client.action") { - detected = append(detected, "NetworkManager") - } - if fileExists("/run/systemd/netif") || fileExists("/run/systemd/system/systemd-networkd.service") { - detected = append(detected, "systemd-networkd") - } - if fileExists("/etc/netplan") { - if entries, err := filepath.Glob("/etc/netplan/*.yaml"); err == nil && len(entries) > 0 { - detected = append(detected, "Netplan") - } - } - if fileExists("/etc/network/interfaces") { - detected = append(detected, "ifupdown") - } - sort.Strings(detected) - active := append([]string(nil), detected...) - if len(active) > 1 && contains(active, "Netplan") { - active = removeString(active, "Netplan") - } - ownership := NetworkOwnership{Detected: detected} - if len(active) == 1 { - ownership.ActiveOwner = active[0] - } else if len(active) > 1 { - ownership.Conflicted = true - ownership.Reason = "Multiple active network owners were detected; configuration mutations are disabled." - } else if len(detected) == 1 { - ownership.ActiveOwner = detected[0] - } else { - ownership.ActiveOwner = "kernel" - } - return ownership -} - 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 + strings.Join(operation.Addresses, ",") + operation.Gateway + operation.Route + strings.Join(operation.DNS, ",") + operation.IPv4Method + operation.IPv4Address + operation.IPv4Gateway + operation.IPv6Method + operation.IPv6Address + operation.IPv6Gateway + operation.Checkpoint + operation.ReconnectToken + operation.ExpectedFingerprint + operation.Confirmation + 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 != "commit" && operation.Action != "rollback" && !validNetworkInterface(operation.Interface) { return ErrInvalidNetworkOperation } - if operation.Action != "preview" && operation.Action != "checkpoint" && operation.ExpectedFingerprint == "" { + 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.Gateway != "" && net.ParseIP(operation.Gateway) == nil { + if operation.IPv4Method != "" && !validNetworkMethod(operation.IPv4Method) { return ErrInvalidNetworkOperation } + 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 +307,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 @@ -272,133 +334,83 @@ func PreviewNetworkOperation(ctx context.Context, operation NetworkOperation) (N if err != nil { return NetworkState{}, err } - return NetworkState{Snapshot: snapshot, Action: "preview", Rollback: snapshot.Ownership.ActiveOwner == "NetworkManager" && !snapshot.Ownership.Conflicted}, nil + return NetworkState{Snapshot: snapshot, Action: "preview", Rollback: networkBackendOwnsSnapshot(operation.Backend, snapshot.Ownership) && !snapshot.Ownership.Conflicted}, nil } func ApplyNetworkOperation(ctx context.Context, operation NetworkOperation) (NetworkState, error) { if err := ValidateNetworkOperation(operation); err != nil { return NetworkState{}, err } + if operation.Backend != "NetworkManager" { + return NetworkState{}, ErrNetworkUnavailable + } snapshot, err := NetworkSnapshotRead(ctx) 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") + if operation.ReconnectToken == "" { + operation.ReconnectToken = newNetworkToken("reconnect") + } + 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 + 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") + if operation.ReconnectToken == "" { + operation.ReconnectToken = newNetworkToken("reconnect") + } + 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, operation.ReconnectToken) } 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, operation.ReconnectToken) 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 -} - -func applyNetplanOperation(ctx context.Context, operation NetworkOperation, snapshot NetworkSnapshot) (NetworkState, error) { - if operation.Interface == "" || strings.ContainsAny(operation.Interface, "./\\") { - return NetworkState{}, ErrInvalidNetworkOperation - } - key := "ethernets." + operation.Interface - arguments := []string{"set"} - switch operation.Action { - case "dhcp": - arguments = append(arguments, key+".dhcp4=true") - case "static": - arguments = append(arguments, key+".dhcp4=false", key+".addresses=["+operation.Address+"]") - if operation.Gateway != "" { - arguments = append(arguments, key+".routes=[{to=default,via="+operation.Gateway+"}]") - } - case "dns": - arguments = append(arguments, key+".nameservers.addresses=["+strings.Join(operation.DNS, ",")+"]") - default: - return NetworkState{}, ErrNetworkUnavailable - } - if _, err := networkCommand(ctx, "netplan", arguments...); err != nil { - return NetworkState{}, err - } - if _, err := networkCommand(ctx, "netplan", "try", "--timeout", "30"); err != nil { - _, _ = networkCommand(ctx, "netplan", "rollback") - return NetworkState{}, err - } - if _, err := networkCommand(ctx, "netplan", "apply"); err != nil { - _, _ = networkCommand(ctx, "netplan", "rollback") - return NetworkState{}, err - } - updated, err := NetworkSnapshotRead(ctx) - if err != nil { - return NetworkState{}, err - } - return NetworkState{Snapshot: updated, Action: operation.Action, Committed: true, Rollback: false, Warning: "Netplan try completed and runtime state was re-read before reporting success."}, 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 contains(values []string, value string) bool { @@ -410,16 +422,6 @@ func contains(values []string, value string) bool { return false } -func removeString(values []string, value string) []string { - result := values[:0] - for _, item := range values { - if item != value { - result = append(result, item) - } - } - return result -} - func networkErrorCode(err error) string { switch { case errors.Is(err, ErrInvalidNetworkOperation): 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..700ea4e --- /dev/null +++ b/apps/backend/internal/platform/network_file_adapters.go @@ -0,0 +1,872 @@ +package platform + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// 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 + interfaceName string + token string + expiresAt time.Time + 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)} + +var networkCheckpointRoot = "/run/tako/network-checkpoints" + +type networkFileCheckpointDisk struct { + Backend string `json:"backend"` + InterfaceName string `json:"interface"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expiresAt"` + Files []networkFileBackupDisk `json:"files"` +} + +type networkFileBackupDisk struct { + Path string `json:"path"` + Data []byte `json:"data,omitempty"` + Mode os.FileMode `json:"mode"` + Existed bool `json:"existed"` +} + +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) + } + entry := networkFileCheckpoint{backend: operation.Backend, interfaceName: operation.Interface, token: operation.ReconnectToken, expiresAt: time.Now().UTC().Add(networkCheckpointTTL), files: backups} + if err := persistNetworkFileCheckpoint(checkpoint, entry); err != nil { + return "", err + } + networkFileCheckpoints.Lock() + networkFileCheckpoints.values[checkpoint] = entry + 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, token string) error { + if backend == "NetworkManager" { + return networkManagerCheckpointDestroy(ctx, checkpoint) + } + entry, ok := loadNetworkFileCheckpoint(checkpoint) + if !ok || entry.backend != backend || entry.token != token { + return ErrNetworkCheckpoint + } + entry, ok = takeNetworkFileCheckpoint(checkpoint) + if ok { + if entry.backend != backend { + return ErrNetworkCheckpoint + } + return removeNetworkFileCheckpoint(checkpoint) + } + entry, ok = loadNetworkFileCheckpoint(checkpoint) + if !ok || entry.backend != backend { + return ErrNetworkCheckpoint + } + return removeNetworkFileCheckpoint(checkpoint) +} + +func rollbackNetworkCheckpoint(ctx context.Context, backend, checkpoint, token string) error { + if backend == "NetworkManager" { + return networkManagerCheckpointRollback(ctx, checkpoint) + } + entry, ok := loadNetworkFileCheckpoint(checkpoint) + if !ok || entry.backend != backend || entry.token != token { + return ErrNetworkCheckpoint + } + entry, ok = takeNetworkFileCheckpoint(checkpoint) + if ok { + if entry.backend != backend { + return ErrNetworkCheckpoint + } + } + if !ok { + entry, ok = loadNetworkFileCheckpoint(checkpoint) + } + if !ok || entry.backend != backend { + return ErrNetworkCheckpoint + } + for _, backup := range entry.files { + if err := restoreNetworkBackup(backup); err != nil { + return err + } + } + if err := applyFileNetworkBackend(ctx, backend, entry.interfaceName); err != nil { + return err + } + return removeNetworkFileCheckpoint(checkpoint) +} + +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, operation.ReconnectToken) + } else { + err = rollbackNetworkCheckpoint(ctx, operation.Backend, operation.Checkpoint, operation.ReconnectToken) + } + 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, RollbackDeadline: time.Now().UTC().Add(networkCheckpointTTL), 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 networkCheckpointPath(checkpoint string) string { + if !validNetworkToken(checkpoint) { + return "" + } + return filepath.Join(networkCheckpointRoot, checkpoint+".json") +} + +func persistNetworkFileCheckpoint(checkpoint string, entry networkFileCheckpoint) error { + path := networkCheckpointPath(checkpoint) + if path == "" { + return ErrNetworkCheckpoint + } + if err := os.MkdirAll(networkCheckpointRoot, 0o700); err != nil { + return err + } + disk := networkFileCheckpointDisk{Backend: entry.backend, InterfaceName: entry.interfaceName, Token: entry.token, ExpiresAt: entry.expiresAt, Files: make([]networkFileBackupDisk, 0, len(entry.files))} + for _, backup := range entry.files { + disk.Files = append(disk.Files, networkFileBackupDisk{Path: backup.path, Data: backup.data, Mode: backup.mode, Existed: backup.existed}) + } + data, err := json.Marshal(disk) + if err != nil { + return err + } + temporary, err := os.CreateTemp(networkCheckpointRoot, ".tako-checkpoint-*") + if err != nil { + return err + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(0o600); 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 loadNetworkFileCheckpoint(checkpoint string) (networkFileCheckpoint, bool) { + path := networkCheckpointPath(checkpoint) + if path == "" { + return networkFileCheckpoint{}, false + } + data, err := os.ReadFile(path) + if err != nil || len(data) > 2<<20 { + return networkFileCheckpoint{}, false + } + var disk networkFileCheckpointDisk + if json.Unmarshal(data, &disk) != nil || disk.Backend == "" || !validNetworkToken(disk.Token) || disk.ExpiresAt.IsZero() || !time.Now().UTC().Before(disk.ExpiresAt) || len(disk.Files) == 0 { + return networkFileCheckpoint{}, false + } + entry := networkFileCheckpoint{backend: disk.Backend, interfaceName: disk.InterfaceName, token: disk.Token, expiresAt: disk.ExpiresAt, files: make([]networkFileBackup, 0, len(disk.Files))} + for _, backup := range disk.Files { + if !validNetworkConfigPath(backup.Path) || len(backup.Data) > maxNetworkConfigOutput { + return networkFileCheckpoint{}, false + } + entry.files = append(entry.files, networkFileBackup{path: backup.Path, data: backup.Data, mode: backup.Mode, existed: backup.Existed}) + } + return entry, true +} + +func RecoverNetworkFileCheckpoint(backend, checkpoint, token string) bool { + entry, ok := loadNetworkFileCheckpoint(checkpoint) + return ok && entry.backend == backend && entry.token == token +} + +func takeNetworkFileCheckpoint(checkpoint string) (networkFileCheckpoint, bool) { + networkFileCheckpoints.Lock() + entry, ok := networkFileCheckpoints.values[checkpoint] + if ok { + delete(networkFileCheckpoints.values, checkpoint) + } + networkFileCheckpoints.Unlock() + return entry, ok +} + +func removeNetworkFileCheckpoint(checkpoint string) error { + path := networkCheckpointPath(checkpoint) + if path == "" { + return ErrNetworkCheckpoint + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +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) { + if !validNetworkConfigPath(path) { + return networkFileBackup{}, ErrNetworkUnavailable + } + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return networkFileBackup{path: path}, nil + } + if err != nil { + return networkFileBackup{}, err + } + if info.Mode()&os.ModeSymlink != 0 || !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 !validNetworkConfigPath(path) || 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 validNetworkConfigPath(path string) bool { + clean := filepath.Clean(path) + if !filepath.IsAbs(clean) || clean != path || strings.ContainsAny(path, "\x00\r\n") { + return false + } + allowed := []string{ + networkConfigPath("/etc/netplan"), + networkConfigPath("/etc/systemd/network"), + networkConfigPath("/etc/network"), + } + within := false + for _, root := range allowed { + if filePathWithin(root, clean) { + within = true + break + } + } + if !within { + return false + } + current := string(filepath.Separator) + if networkConfigRoot != "" { + current = filepath.Clean(networkConfigRoot) + } + for _, component := range strings.Split(strings.TrimPrefix(clean, current), string(filepath.Separator)) { + if component == "" || component == "." { + continue + } + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if err == nil && info.Mode()&os.ModeSymlink != 0 { + return false + } + } + return true +} + +func conflictingNetworkdFile(iface, managed string) bool { + matches, _ := filepath.Glob(networkConfigPath("/etc/systemd/network/*.network")) + for _, path := range matches { + if path == managed { + continue + } + if !validNetworkConfigPath(path) { + return true + } + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + return true + } + 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) >= 4 && fields[0] == "iface" && fields[2] == "inet" { + if fields[3] == "dhcp" { + state.IPv4Method = "auto" + } else if fields[3] == "static" { + state.IPv4Method = "manual" + } + } + if len(fields) >= 4 && fields[0] == "iface" && fields[2] == "inet6" { + if fields[3] == "auto" { + state.IPv6Method = "auto" + } else if fields[3] == "static" { + state.IPv6Method = "manual" + } + } + 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:]...) + } + if len(fields) >= 4 && fields[0] == "up" && fields[1] == "ip" && fields[2] == "route" && fields[3] == "add" { + if route, ok := parseIfupdownRoute(fields[4:]); ok { + state.Routes = append(state.Routes, route) + } + } + } + return state, nil +} + +func parseIfupdownRoute(fields []string) (NetworkRoute, bool) { + if len(fields) == 0 { + return NetworkRoute{}, false + } + route := NetworkRoute{Destination: fields[0]} + for index := 1; index < len(fields); index++ { + switch fields[index] { + case "via": + if index+1 >= len(fields) { + return NetworkRoute{}, false + } + route.Gateway = fields[index+1] + index++ + case "metric": + if index+1 >= len(fields) { + return NetworkRoute{}, false + } + metric, err := strconv.Atoi(fields[index+1]) + if err != nil || metric < 0 || metric > 65535 { + return NetworkRoute{}, false + } + route.Metric = metric + index++ + default: + return NetworkRoute{}, false + } + } + return route, route.Destination != "" +} + +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) + } + if route.Metric > 0 { + builder.WriteString(" metric ") + builder.WriteString(strconv.Itoa(route.Metric)) + } + 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") + state.Addresses = nil + state.Gateways = nil + case "static": + addresses := networkOperationAddresses(operation) + if len(addresses) == 0 { + return ErrInvalidNetworkOperation + } + state.Addresses = append([]string(nil), addresses...) + state.Gateways = nil + 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_file_adapters_test.go b/apps/backend/internal/platform/network_file_adapters_test.go new file mode 100644 index 0000000..4cd7906 --- /dev/null +++ b/apps/backend/internal/platform/network_file_adapters_test.go @@ -0,0 +1,117 @@ +package platform + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNetworkFileCheckpointPersistsAndRestores(t *testing.T) { + root := t.TempDir() + previousConfig, previousCheckpoint := networkConfigRoot, networkCheckpointRoot + networkConfigRoot = root + networkCheckpointRoot = filepath.Join(root, "run", "checkpoints") + defer func() { + networkConfigRoot = previousConfig + networkCheckpointRoot = previousCheckpoint + }() + restoreRunner := setNetworkCommandRunner(func(_ context.Context, _ string, _ ...string) ([]byte, error) { return nil, nil }) + defer restoreRunner() + path := networkdManagedPath("eno1") + if err := writeNetworkFile(path, []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + checkpoint, err := beginNetworkCheckpoint(context.Background(), NetworkOperation{Backend: "systemd-networkd", Interface: "eno1", ReconnectToken: "reconnect-test"}) + if err != nil { + t.Fatal(err) + } + if !RecoverNetworkFileCheckpoint("systemd-networkd", checkpoint, "reconnect-test") { + t.Fatal("persisted checkpoint was not recoverable") + } + if err := writeNetworkFile(path, []byte("new\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := rollbackNetworkCheckpoint(context.Background(), "systemd-networkd", checkpoint, "reconnect-test"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "old\n" { + t.Fatalf("restored network file = %q, err=%v", data, err) + } + if _, err := os.Stat(networkCheckpointPath(checkpoint)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("checkpoint remains: %v", err) + } +} + +func TestNetworkFileCheckpointRequiresReconnectToken(t *testing.T) { + root := t.TempDir() + previousConfig, previousCheckpoint := networkConfigRoot, networkCheckpointRoot + networkConfigRoot = root + networkCheckpointRoot = filepath.Join(root, "run", "checkpoints") + defer func() { + networkConfigRoot = previousConfig + networkCheckpointRoot = previousCheckpoint + }() + if err := writeNetworkFile(networkdManagedPath("eno1"), []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + checkpoint, err := beginNetworkCheckpoint(context.Background(), NetworkOperation{Backend: "systemd-networkd", Interface: "eno1", ReconnectToken: "reconnect-test"}) + if err != nil { + t.Fatal(err) + } + if err := rollbackNetworkCheckpoint(context.Background(), "systemd-networkd", checkpoint, "wrong-token"); !errors.Is(err, ErrNetworkCheckpoint) { + t.Fatalf("wrong token error = %v", err) + } +} + +func TestNetworkFileAdaptersRejectConflictingOwnership(t *testing.T) { + root := t.TempDir() + previous := networkConfigRoot + networkConfigRoot = root + defer func() { networkConfigRoot = previous }() + path := networkConfigPath("/etc/systemd/network/10-other.network") + if err := writeNetworkFile(path, []byte("[Match]\nName=eno1\n"), 0o644); err != nil { + t.Fatal(err) + } + if !conflictingNetworkdFile("eno1", networkdManagedPath("eno1")) { + t.Fatal("unmanaged networkd match was not detected") + } + if networkBackendOwnsSnapshot("Netplan", NetworkOwnership{ActiveOwner: "systemd-networkd", Detected: []string{"Netplan", "systemd-networkd"}}) { + t.Fatal("Netplan was allowed to mutate a layered networkd configuration") + } +} + +func TestIfupdownStateRoundTripAndStaticReplacement(t *testing.T) { + root := t.TempDir() + previous := networkConfigRoot + networkConfigRoot = root + defer func() { networkConfigRoot = previous }() + path := ifupdownManagedPath("eno1") + state := desiredNetworkState{IPv4Method: "manual", IPv6Method: "manual", Addresses: []string{"192.0.2.1/24", "2001:db8::1/64"}, DNS: []string{"1.1.1.1"}, Routes: []NetworkRoute{{Destination: "default", Gateway: "192.0.2.1", Metric: 100}}} + if err := writeIfupdownState(path, "eno1", state); err != nil { + t.Fatal(err) + } + read, err := readIfupdownState(path) + if err != nil { + t.Fatal(err) + } + if read.IPv4Method != "manual" || read.IPv6Method != "manual" || len(read.Addresses) != 2 || len(read.DNS) != 1 || len(read.Routes) != 1 || read.Routes[0].Metric != 100 { + t.Fatalf("ifupdown state = %+v", read) + } + update := desiredNetworkState{IPv4Method: "auto", Addresses: []string{"192.0.2.10/24", "198.51.100.10/24"}, Gateways: []string{"192.0.2.1"}} + if err := updateDesiredNetworkState(&update, NetworkOperation{Action: "static", Addresses: []string{"203.0.113.10/24"}, IPv4Gateway: "203.0.113.1"}); err != nil { + t.Fatal(err) + } + if strings.Join(update.Addresses, ",") != "203.0.113.10/24" || strings.Join(update.Gateways, ",") != "203.0.113.1" { + t.Fatalf("static replacement = %+v", update) + } + if err := updateDesiredNetworkState(&update, NetworkOperation{Action: "dhcp"}); err != nil { + t.Fatal(err) + } + if len(update.Addresses) != 0 || len(update.Gateways) != 0 { + t.Fatalf("DHCP retained static state = %+v", update) + } +} diff --git a/apps/backend/internal/platform/network_nm.go b/apps/backend/internal/platform/network_nm.go new file mode 100644 index 0000000..1f0f03d --- /dev/null +++ b/apps/backend/internal/platform/network_nm.go @@ -0,0 +1,402 @@ +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 managed dbus.Variant + if err := client.connection.Object(networkManagerService, device).CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.Device", "Managed").Store(&managed); err != nil { + return "", err + } + if value, ok := managed.Value().(bool); !ok || !value { + return "", ErrNetworkOwnership + } + 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 == "" { + var err error + target, err = networkManagerConnectionForInterface(ctx, operation.Interface) + if err != nil { + return fmtNetworkManagerUnavailable(err) + } + } + 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) { + if !validNetworkConnection(target) { + return nil, ErrInvalidNetworkOperation + } + 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 { + return backend == "NetworkManager" && !ownership.Conflicted && ownership.ActiveOwner == "NetworkManager" +} + +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_settings.go b/apps/backend/internal/platform/network_nm_settings.go new file mode 100644 index 0000000..9ea1593 --- /dev/null +++ b/apps/backend/internal/platform/network_nm_settings.go @@ -0,0 +1,142 @@ +package platform + +import ( + "context" + "errors" + + "github.com/godbus/dbus/v5" +) + +var errNetworkManagerConnectionMissing = errors.New("interface has no active network manager connection") + +type networkManagerConnectionClient interface { + ConnectionForInterface(context.Context, string) (string, error) + Close() +} + +type dbusNetworkManagerConnectionClient struct { + connection *dbus.Conn +} + +func newDBusNetworkManagerConnectionClient() (networkManagerConnectionClient, error) { + connection, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + return &dbusNetworkManagerConnectionClient{connection: connection}, nil +} + +var networkManagerConnectionFactory = newDBusNetworkManagerConnectionClient + +func setNetworkManagerConnectionFactory(factory func() (networkManagerConnectionClient, error)) func() { + previous := networkManagerConnectionFactory + if factory == nil { + networkManagerConnectionFactory = newDBusNetworkManagerConnectionClient + } else { + networkManagerConnectionFactory = factory + } + return func() { networkManagerConnectionFactory = previous } +} + +func networkManagerConnectionForInterface(ctx context.Context, iface string) (string, error) { + if !validNetworkInterface(iface) { + return "", ErrInvalidNetworkOperation + } + client, err := networkManagerConnectionFactory() + if err != nil { + return "", err + } + if client == nil { + return "", errors.New("network manager connection client is unavailable") + } + defer client.Close() + return client.ConnectionForInterface(ctx, iface) +} + +func (client *dbusNetworkManagerConnectionClient) Close() { + if client != nil && client.connection != nil { + _ = client.connection.Close() + } +} + +func (client *dbusNetworkManagerConnectionClient) ConnectionForInterface(ctx context.Context, iface string) (string, error) { + if client == nil || client.connection == nil || !validNetworkInterface(iface) { + return "", ErrInvalidNetworkOperation + } + + device, err := client.deviceForInterface(ctx, iface) + if err != nil { + return "", err + } + active, err := client.objectProperty(ctx, device, "org.freedesktop.NetworkManager.Device", "ActiveConnection") + if err != nil { + return "", err + } + activePath, ok := active.Value().(dbus.ObjectPath) + if !ok || activePath == "/" { + return "", errNetworkManagerConnectionMissing + } + connection, err := client.objectProperty(ctx, activePath, "org.freedesktop.NetworkManager.Connection.Active", "Connection") + if err != nil { + return "", err + } + connectionPath, ok := connection.Value().(dbus.ObjectPath) + if !ok || connectionPath == "/" { + return "", errNetworkManagerConnectionMissing + } + + settings := map[string]map[string]dbus.Variant{} + if err := client.connection.Object(networkManagerService, connectionPath).CallWithContext(ctx, "org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&settings); err != nil { + return "", err + } + return networkManagerConnectionUUID(settings) +} + +func networkManagerConnectionUUID(settings map[string]map[string]dbus.Variant) (string, error) { + connection, ok := settings["connection"] + if !ok { + return "", errors.New("network manager connection settings are incomplete") + } + uuid, ok := connection["uuid"].Value().(string) + if !ok || !validNetworkManagerUUID(uuid) { + return "", errors.New("network manager connection UUID is invalid") + } + return uuid, nil +} + +func validNetworkManagerUUID(value string) bool { + if len(value) != 36 { + return false + } + for index, character := range value { + if index == 8 || index == 13 || index == 18 || index == 23 { + if character != '-' { + return false + } + continue + } + if (character < '0' || character > '9') && (character < 'a' || character > 'f') && (character < 'A' || character > 'F') { + return false + } + } + return true +} + +func (client *dbusNetworkManagerConnectionClient) deviceForInterface(ctx context.Context, iface string) (dbus.ObjectPath, error) { + var device dbus.ObjectPath + if err := client.connection.Object(networkManagerService, networkManagerPath).CallWithContext(ctx, networkManagerService+".GetDeviceByIpIface", 0, iface).Store(&device); err != nil { + return "", err + } + if device == "/" { + return "", errors.New("network manager device was not found") + } + return device, nil +} + +func (client *dbusNetworkManagerConnectionClient) objectProperty(ctx context.Context, object dbus.ObjectPath, interfaceName, property string) (dbus.Variant, error) { + var value dbus.Variant + if err := client.connection.Object(networkManagerService, object).CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, interfaceName, property).Store(&value); err != nil { + return dbus.Variant{}, err + } + return value, nil +} 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..85ae7d0 --- /dev/null +++ b/apps/backend/internal/platform/network_nm_test.go @@ -0,0 +1,175 @@ +package platform + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/godbus/dbus/v5" +) + +type fakeNetworkManagerCheckpoint struct { + created []string + destroyed []string + rolled []string +} + +type fakeNetworkManagerConnection struct { + target string + err error +} + +func (fake *fakeNetworkManagerConnection) ConnectionForInterface(context.Context, string) (string, error) { + return fake.target, fake.err +} + +func (*fakeNetworkManagerConnection) Close() {} + +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 TestNetworkManagerMutationResolvesActiveProfileWhenConnectionIsOmitted(t *testing.T) { + commands := make([][]string, 0, 2) + restoreRunner := setNetworkCommandRunner(func(_ context.Context, _ string, arguments ...string) ([]byte, error) { + commands = append(commands, append([]string(nil), arguments...)) + return nil, nil + }) + defer restoreRunner() + restoreResolver := setNetworkManagerConnectionFactory(func() (networkManagerConnectionClient, error) { + return &fakeNetworkManagerConnection{target: "9d3e3c2a-6f14-4a56-9f9e-2cc2a0a8d4f5"}, nil + }) + defer restoreResolver() + + err := applyNetworkManagerPersistent(context.Background(), NetworkOperation{ + Action: "dhcp", + Interface: "eno1", + }) + if err != nil { + t.Fatal(err) + } + if len(commands) != 2 { + t.Fatalf("NetworkManager commands = %#v", commands) + } + if len(commands[0]) < 3 || commands[0][0] != "connection" || commands[0][1] != "modify" || commands[0][2] != "9d3e3c2a-6f14-4a56-9f9e-2cc2a0a8d4f5" { + t.Fatalf("profile UUID was not used for modification: %#v", commands[0]) + } + if len(commands[1]) < 5 || commands[1][0] != "connection" || commands[1][1] != "up" || commands[1][2] != "9d3e3c2a-6f14-4a56-9f9e-2cc2a0a8d4f5" || commands[1][3] != "ifname" || commands[1][4] != "eno1" { + t.Fatalf("profile UUID was not used for activation: %#v", commands[1]) + } +} + +func TestNetworkManagerMutationRejectsMissingActiveProfile(t *testing.T) { + restoreResolver := setNetworkManagerConnectionFactory(func() (networkManagerConnectionClient, error) { + return &fakeNetworkManagerConnection{err: errNetworkManagerConnectionMissing}, nil + }) + defer restoreResolver() + + err := applyNetworkManagerPersistent(context.Background(), NetworkOperation{Action: "dhcp", Interface: "eno1"}) + if !errors.Is(err, ErrNetworkUnavailable) { + t.Fatalf("missing active profile error = %v", err) + } +} + +func TestNetworkManagerConnectionUUIDRequiresConnectionSettings(t *testing.T) { + if _, err := networkManagerConnectionUUID(nil); err == nil { + t.Fatal("missing connection settings were accepted") + } + if _, err := networkManagerConnectionUUID(map[string]map[string]dbus.Variant{ + "connection": {"uuid": dbus.MakeVariant("not a uuid")}, + }); err == nil { + t.Fatal("invalid connection UUID was accepted") + } + uuid := "9d3e3c2a-6f14-4a56-9f9e-2cc2a0a8d4f5" + got, err := networkManagerConnectionUUID(map[string]map[string]dbus.Variant{ + "connection": {"uuid": dbus.MakeVariant(uuid)}, + }) + if err != nil || got != uuid { + t.Fatalf("connection UUID = %q, err=%v", got, err) + } +} + +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/platform/network_ownership.go b/apps/backend/internal/platform/network_ownership.go new file mode 100644 index 0000000..db2c415 --- /dev/null +++ b/apps/backend/internal/platform/network_ownership.go @@ -0,0 +1,79 @@ +package platform + +import ( + "context" + "path/filepath" + "sort" +) + +const ( + networkdBusName = "org.freedesktop.network1" + + networkOwnershipConflictReason = "Multiple active network owners were detected; configuration mutations are disabled." +) + +func detectNetworkOwnership(ctx context.Context) NetworkOwnership { + names, err := (hostProbe{}).BusNames(ctx) + if err != nil { + names = nil + } + return resolveNetworkOwnership(names, configuredNetworkBackends()) +} + +func resolveNetworkOwnership(names map[string]bool, configured []string) NetworkOwnership { + active := activeNetworkBackends(names) + detected := append([]string(nil), active...) + detected = append(detected, configured...) + detected = uniqueSortedStrings(detected) + + ownership := NetworkOwnership{Detected: detected} + switch len(active) { + case 0: + ownership.ActiveOwner = "kernel" + case 1: + ownership.ActiveOwner = active[0] + default: + ownership.Conflicted = true + ownership.Reason = networkOwnershipConflictReason + } + return ownership +} + +func activeNetworkBackends(names map[string]bool) []string { + active := make([]string, 0, 2) + if busActive(names, networkManagerService) { + active = append(active, "NetworkManager") + } + if busActive(names, networkdBusName) { + active = append(active, "systemd-networkd") + } + sort.Strings(active) + return active +} + +func configuredNetworkBackends() []string { + configured := make([]string, 0, 2) + if matches, err := filepath.Glob("/etc/netplan/*.yaml"); err == nil && len(matches) > 0 { + configured = append(configured, "Netplan") + } + if fileExists("/etc/network/interfaces") { + configured = append(configured, "ifupdown") + } + return uniqueSortedStrings(configured) +} + +func uniqueSortedStrings(values []string) []string { + if len(values) == 0 { + return []string{} + } + sorted := append([]string(nil), values...) + sort.Strings(sorted) + result := sorted[:0] + for _, value := range sorted { + if value == "" || (len(result) > 0 && result[len(result)-1] == value) { + continue + } + result = append(result, value) + } + return result +} diff --git a/apps/backend/internal/platform/network_ownership_test.go b/apps/backend/internal/platform/network_ownership_test.go new file mode 100644 index 0000000..a51f98a --- /dev/null +++ b/apps/backend/internal/platform/network_ownership_test.go @@ -0,0 +1,69 @@ +package platform + +import ( + "context" + "errors" + "reflect" + "testing" +) + +func TestResolveNetworkOwnershipUsesOnlyActiveBusOwners(t *testing.T) { + ownership := resolveNetworkOwnership(map[string]bool{ + "active:" + networkManagerService: true, + "available:" + networkdBusName: true, + }, []string{"Netplan"}) + + if ownership.Conflicted || ownership.ActiveOwner != "NetworkManager" { + t.Fatalf("activatable backend changed ownership: %#v", ownership) + } + if !reflect.DeepEqual(ownership.Detected, []string{"Netplan", "NetworkManager"}) { + t.Fatalf("detected backends = %#v", ownership.Detected) + } +} + +func TestResolveNetworkOwnershipDoesNotInferRuntimeFromConfiguration(t *testing.T) { + ownership := resolveNetworkOwnership(nil, []string{"Netplan", "ifupdown"}) + + if ownership.Conflicted || ownership.ActiveOwner != "kernel" { + t.Fatalf("configuration files were treated as active owners: %#v", ownership) + } + if !reflect.DeepEqual(ownership.Detected, []string{"Netplan", "ifupdown"}) { + t.Fatalf("detected backends = %#v", ownership.Detected) + } +} + +func TestResolveNetworkOwnershipFailsClosedForMultipleActiveManagers(t *testing.T) { + ownership := resolveNetworkOwnership(map[string]bool{ + "active:" + networkManagerService: true, + "active:" + networkdBusName: true, + }, nil) + + if !ownership.Conflicted || ownership.ActiveOwner != "" { + t.Fatalf("multiple active owners were not rejected: %#v", ownership) + } + if ownership.Reason != networkOwnershipConflictReason { + t.Fatalf("conflict reason = %q", ownership.Reason) + } +} + +func TestNetworkMutationsRequireNetworkManager(t *testing.T) { + _, err := ApplyNetworkOperation(context.Background(), NetworkOperation{ + Backend: "systemd-networkd", + Action: "dhcp", + Interface: "eno1", + ExpectedFingerprint: "fingerprint", + Confirmation: "CONFIRM NETWORK CHANGE", + }) + if !errors.Is(err, ErrNetworkUnavailable) { + t.Fatalf("non-NetworkManager mutation error = %v", err) + } +} + +func TestNetworkManagerOwnershipIsNotConfusedByConfigurationLayer(t *testing.T) { + if !networkBackendOwnsSnapshot("NetworkManager", NetworkOwnership{ + ActiveOwner: "NetworkManager", + Detected: []string{"Netplan", "NetworkManager"}, + }) { + t.Fatal("active NetworkManager was blocked by a non-runtime configuration layer") + } +} diff --git a/apps/backend/internal/platform/security.go b/apps/backend/internal/platform/security.go index 54103ae..2b9e279 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,18 +36,30 @@ 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 { - SELinux SELinuxStatus `json:"selinux"` - AppArmor AppArmorStatus `json:"apparmor"` - Active string `json:"active"` - Findings []SecurityFinding `json:"findings"` - Fingerprint string `json:"fingerprint"` + SELinux SELinuxStatus `json:"selinux"` + AppArmor AppArmorStatus `json:"apparmor"` + Active string `json:"active"` + Findings []SecurityFinding `json:"findings"` + Changes []SecurityChange `json:"changes,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Stale bool `json:"stale,omitempty"` + Allowed bool `json:"allowed,omitempty"` + RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` + Fingerprint string `json:"fingerprint"` +} + +type SecurityChange struct { + Field string `json:"field"` + Before string `json:"before,omitempty"` + After string `json:"after,omitempty"` } type SecurityOperation struct { @@ -60,200 +73,377 @@ 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 { +func safeRestoreconPath(path string) bool { + if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" || strings.ContainsAny(path, "*?[]{}") { return false } - info, err := os.Stat(resolved) - if err != nil || !info.Mode().IsRegular() || info.Size() > 1<<20 { - 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" - if err := ValidateSecurityOperation(operation); err != nil { - return SecurityStatus{}, err + if operation.Action == "" { + operation.Action = "inspect" } - return ReadSecurityStatus(ctx) -} - -func ApplySecurityOperation(ctx context.Context, operation SecurityOperation) (SecurityStatus, error) { - if err := ValidateSecurityOperation(operation); err != nil { + if operation.Action == "inspect" { + operation = SecurityOperation{Action: "inspect", Framework: operation.Framework} + } + if err := ValidateSecurityPreviewOperation(operation); err != nil { return SecurityStatus{}, err } - current, err := ReadSecurityStatus(ctx) + status, err := ReadSecurityStatus(ctx) if err != nil { return SecurityStatus{}, err } - if current.Fingerprint != operation.ExpectedFingerprint { - return SecurityStatus{}, ErrSecurityConflict + status.Allowed = true + status.RequiresConfirmation = operation.Action != "inspect" + status.Changes, status.Warnings = securityPreviewChanges(status, operation) + allowed, eligibilityWarnings := securityPreviewEligibility(status, operation) + status.Allowed = allowed + status.Warnings = append(status.Warnings, eligibilityWarnings...) + if operation.ExpectedFingerprint != "" && operation.ExpectedFingerprint != status.Fingerprint { + status.Stale = true + status.Allowed = false + status.Warnings = append(status.Warnings, "Security policy changed since this preview was requested.") } - var command string - var arguments []string + return status, nil +} + +func securityPreviewEligibility(status SecurityStatus, operation SecurityOperation) (bool, []string) { + warnings := []string{} + allowed := true switch operation.Action { - case "selinux-boolean": - if current.SELinux.Mode == "Disabled" || !current.SELinux.Userspace { - return SecurityStatus{}, ErrSecurityUnavailable + case "selinux-boolean", "selinux-restorecon": + if !status.SELinux.Userspace || status.SELinux.Mode == "Disabled" { + allowed = false + warnings = append(warnings, "SELinux userspace is unavailable or disabled.") } + case "apparmor-enforce", "apparmor-complain", "apparmor-load": + if !status.AppArmor.Userspace { + allowed = false + warnings = append(warnings, "AppArmor userspace tooling is unavailable.") + } + } + if operation.Action == "selinux-boolean" && allowed { known := false - for _, boolean := range current.SELinux.Booleans { - if strings.HasPrefix(boolean, operation.Boolean+"=") { + for _, value := range status.SELinux.Booleans { + if strings.HasPrefix(value, operation.Boolean+"=") { known = true break } } if !known { - return SecurityStatus{}, ErrSecurityUnsafe + allowed = false + warnings = append(warnings, "The selected SELinux boolean was not reported by the current policy inventory.") } - 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 + } + if (operation.Action == "apparmor-enforce" || operation.Action == "apparmor-complain") && allowed && !contains(status.AppArmor.Profiles, operation.Profile) { + allowed = false + warnings = append(warnings, "The selected AppArmor profile was not reported by the current policy inventory.") + } + return allowed, warnings +} + +func ValidateSecurityPreviewOperation(operation SecurityOperation) error { + if operation.Action == "inspect" { + return ValidateSecurityOperation(operation) + } + copy := operation + if copy.ExpectedFingerprint == "" { + copy.ExpectedFingerprint = strings.Repeat("0", 64) + } + if copy.Confirmation == "" { + copy.Confirmation = "CONFIRM NARROW SECURITY CHANGE" + } + return ValidateSecurityOperation(copy) +} + +func securityPreviewChanges(status SecurityStatus, operation SecurityOperation) ([]SecurityChange, []string) { + changes := []SecurityChange{} + warnings := []string{} + switch operation.Action { + case "selinux-boolean": + before := "unknown" + for _, value := range status.SELinux.Booleans { + if strings.HasPrefix(value, operation.Boolean+"=") { + before = strings.TrimPrefix(value, operation.Boolean+"=") + break + } + } + after := "off" + if operation.Value { + after = "on" } - command, arguments = "restorecon", []string{"-v", "--", operation.Path} + changes = append(changes, SecurityChange{Field: "SELinux boolean " + operation.Boolean, Before: before, After: after}) + case "selinux-restorecon": + changes = append(changes, SecurityChange{Field: "SELinux label", After: operation.Path + " will be relabeled from matchpathcon"}) + warnings = append(warnings, "Target label is re-evaluated through a pinned descriptor during apply.") case "apparmor-enforce", "apparmor-complain": - if !current.AppArmor.Userspace { - return SecurityStatus{}, ErrSecurityUnavailable + before := status.AppArmor.ProfileModes[operation.Profile] + after := strings.TrimPrefix(operation.Action, "apparmor-") + changes = append(changes, SecurityChange{Field: "AppArmor profile " + operation.Profile, Before: before, After: after}) + case "apparmor-load": + changes = append(changes, SecurityChange{Field: "AppArmor profile", After: operation.Path + " will be parsed and replaced"}) + warnings = append(warnings, "Only root-owned, non-world-writable profiles under approved AppArmor directories are accepted.") + } + return changes, warnings +} + +func ApplySecurityOperation(ctx context.Context, operation SecurityOperation) (SecurityStatus, error) { + 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) + } +} + +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 !contains(current.AppArmor.Profiles, operation.Profile) { - return SecurityStatus{}, ErrSecurityUnsafe + } + 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] + } } - command, arguments = "aa-"+strings.TrimPrefix(operation.Action, "apparmor-"), []string{"--", operation.Profile} - case "apparmor-load": - if !current.AppArmor.Userspace || operation.Path == "" { - 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 + } + 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 !matched { + continue + } + line = strings.Join(strings.Fields(line), " ") + if len(line) > 1024 { + line = line[:1024] + } + if !seen[line] { + seen[line] = true + result = append(result, line) + } + if len(result) >= 256 { + break } - command, arguments = "apparmor_parser", []string{"-r", "--", operation.Path} - default: - return SecurityStatus{}, ErrInvalidSecurityOperation } - if _, err := securityCommand(ctx, command, arguments...); err != nil { - return SecurityStatus{}, err + 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 + } } - updated, err := ReadSecurityStatus(ctx) - if 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 + } + } + } + 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..efc55d5 --- /dev/null +++ b/apps/backend/internal/platform/security_adapter.go @@ -0,0 +1,275 @@ +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 + } + if err := verifySecurityMutation(updated, operation); err != nil { + return SecurityStatus{}, err + } + updated.Changes, updated.Warnings = securityPreviewChanges(current, operation) + updated.Allowed = true + updated.RequiresConfirmation = true + return updated, nil +} + +func verifySecurityMutation(status SecurityStatus, operation SecurityOperation) error { + switch operation.Action { + case "selinux-boolean": + want := "off" + if operation.Value { + want = "on" + } + for _, value := range status.SELinux.Booleans { + if strings.HasPrefix(value, operation.Boolean+"=") { + if strings.TrimPrefix(value, operation.Boolean+"=") == want { + return nil + } + return ErrSecurityConflict + } + } + return ErrSecurityConflict + case "apparmor-enforce", "apparmor-complain": + want := strings.TrimPrefix(operation.Action, "apparmor-") + if status.AppArmor.ProfileModes[operation.Profile] == want { + return nil + } + return ErrSecurityConflict + default: + return 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 +} diff --git a/apps/backend/internal/platform/security_adapter_test.go b/apps/backend/internal/platform/security_adapter_test.go new file mode 100644 index 0000000..41c674e --- /dev/null +++ b/apps/backend/internal/platform/security_adapter_test.go @@ -0,0 +1,86 @@ +package platform + +import ( + "context" + "errors" + "strings" + "testing" +) + +type securityFixtureRunner struct { + commands []string + boolean bool + mode string +} + +func (runner *securityFixtureRunner) Run(_ context.Context, name string, arguments ...string) (string, error) { + runner.commands = append(runner.commands, name+" "+strings.Join(arguments, " ")) + switch name { + case "getenforce": + return "Enforcing\n", nil + case "sestatus": + return "SELinux status: enabled\nCurrent mode: enforcing\nLoaded policy name: targeted\n", nil + case "getsebool": + value := "off" + if runner.boolean { + value = "on" + } + return "httpd_can_network_connect --> " + value + "\n", nil + case "semanage": + return "httpd_can_network_connect (off, off)\n", nil + case "aa-status": + return `{"profiles":{"usr.sbin.demo":"` + runner.mode + `"}}`, nil + case "ausearch", "journalctl": + return "", errors.New("no denials") + case "setsebool": + runner.boolean = true + return "", nil + default: + return "", nil + } +} + +func TestSecurityPreviewEligibilityRequiresKnownTargets(t *testing.T) { + status := SecurityStatus{ + SELinux: SELinuxStatus{Userspace: true, Mode: "Enforcing", Booleans: []string{"httpd_can_network_connect=off"}}, + AppArmor: AppArmorStatus{Userspace: true, Profiles: []string{"usr.sbin.demo"}}, + } + if allowed, _ := securityPreviewEligibility(status, SecurityOperation{Action: "selinux-boolean", Framework: "SELinux", Boolean: "missing"}); allowed { + t.Fatal("unknown SELinux boolean was previewable") + } + if allowed, _ := securityPreviewEligibility(status, SecurityOperation{Action: "selinux-boolean", Framework: "SELinux", Boolean: "httpd_can_network_connect"}); !allowed { + t.Fatal("known SELinux boolean was rejected") + } + if allowed, _ := securityPreviewEligibility(status, SecurityOperation{Action: "apparmor-enforce", Framework: "AppArmor", Profile: "missing"}); allowed { + t.Fatal("unknown AppArmor profile was previewable") + } +} + +func TestSecurityAdapterReadsAndAppliesKnownBoolean(t *testing.T) { + runner := &securityFixtureRunner{mode: "enforce"} + adapter := NewSecurityStrategy(runner) + status, err := adapter.Read(context.Background()) + if err != nil { + t.Fatal(err) + } + if status.SELinux.Mode != "Enforcing" || !status.SELinux.Userspace || len(status.SELinux.Booleans) != 1 { + t.Fatalf("SELinux status = %+v", status.SELinux) + } + if len(status.AppArmor.Profiles) != 1 || status.AppArmor.ProfileModes["usr.sbin.demo"] != "enforce" { + t.Fatalf("AppArmor status = %+v", status.AppArmor) + } + updated, err := adapter.Apply(context.Background(), SecurityOperation{ + Action: "selinux-boolean", + Framework: "SELinux", + Boolean: "httpd_can_network_connect", + Value: true, + ExpectedFingerprint: status.Fingerprint, + Confirmation: "CONFIRM NARROW SECURITY CHANGE", + }) + if err != nil { + t.Fatal(err) + } + if !updated.Allowed || len(updated.Changes) != 1 || !strings.Contains(updated.Changes[0].After, "on") { + t.Fatalf("updated status = %+v", updated) + } +} 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 } } diff --git a/apps/backend/internal/platform/storage_adapter.go b/apps/backend/internal/platform/storage_adapter.go new file mode 100644 index 0000000..b55958e --- /dev/null +++ b/apps/backend/internal/platform/storage_adapter.go @@ -0,0 +1,733 @@ +package platform + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +var ( + ErrInvalidStorageOperation = errors.New("invalid storage operation") + ErrStorageConflict = errors.New("storage state changed") + ErrStorageUnavailable = errors.New("storage adapter unavailable") + ErrStorageUnsafe = errors.New("storage operation is unsafe") + ErrStorageBusy = errors.New("storage target is busy") +) + +type StoragePartition struct { + Path string `json:"path"` + Name string `json:"name,omitempty"` + Size uint64 `json:"size"` + Filesystem string `json:"filesystem,omitempty"` + Label string `json:"label,omitempty"` + UUID string `json:"uuid,omitempty"` + Parent string `json:"parent,omitempty"` + ReadOnly bool `json:"readOnly"` + MountPoints []MountPoint `json:"mountPoints"` +} + +type StorageHealth struct { + Available bool `json:"available"` + Passed bool `json:"passed,omitempty"` + TemperatureC int `json:"temperatureC,omitempty"` + PowerOnHours uint64 `json:"powerOnHours,omitempty"` + Failing bool `json:"failing,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type NVMeHealth struct { + Available bool `json:"available"` + TemperatureC int `json:"temperatureC,omitempty"` + PercentageUsed int `json:"percentageUsed,omitempty"` + CriticalWarning int `json:"criticalWarning,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type StorageDevice struct { + Path string `json:"path"` + Name string `json:"name"` + Type string `json:"type"` + Model string `json:"model,omitempty"` + Serial string `json:"serial,omitempty"` + Transport string `json:"transport,omitempty"` + Size uint64 `json:"size"` + ReadOnly bool `json:"readOnly"` + Removable bool `json:"removable"` + Partitions []StoragePartition `json:"partitions"` + SMART *StorageHealth `json:"smart,omitempty"` + NVMe *NVMeHealth `json:"nvme,omitempty"` +} + +type StorageSnapshot struct { + Filesystems []Filesystem `json:"filesystems"` + Devices []StorageDevice `json:"devices"` + Fingerprint string `json:"fingerprint"` + ReadOnly bool `json:"readOnly"` + Reason string `json:"reason,omitempty"` +} + +type StorageOperation struct { + Action string `json:"action"` + Device string `json:"device"` + Target string `json:"target,omitempty"` + Filesystem string `json:"filesystem,omitempty"` + Options []string `json:"options,omitempty"` + ExpectedFingerprint string `json:"expectedFingerprint,omitempty"` + Confirmation string `json:"confirmation,omitempty"` +} + +type StorageState struct { + Snapshot StorageSnapshot `json:"snapshot"` + Action string `json:"action"` + Applied bool `json:"applied"` + Warning string `json:"warning,omitempty"` +} + +type StorageCommandRunner interface { + Run(context.Context, string, ...string) (string, error) +} + +type storageAdapter struct { + runner StorageCommandRunner + filesystems func() ([]Filesystem, error) +} + +type systemStorageCommandRunner struct{} + +func (systemStorageCommandRunner) Run(ctx context.Context, name string, arguments ...string) (string, error) { + return runStorageCommand(ctx, name, arguments...) +} + +func NewStorageStrategy(runner StorageCommandRunner) StorageStrategy { + if runner == nil { + runner = systemStorageCommandRunner{} + } + return &storageAdapter{runner: runner, filesystems: Filesystems} +} + +type StorageStrategy interface { + Read(context.Context) (StorageSnapshot, error) + Apply(context.Context, StorageOperation) (StorageState, error) +} + +func ReadStorageSnapshot(ctx context.Context) (StorageSnapshot, error) { + return NewStorageStrategy(nil).Read(ctx) +} + +func StorageSnapshotFromFilesystems(filesystems []Filesystem, reason string) StorageSnapshot { + snapshot := StorageSnapshot{Filesystems: append([]Filesystem(nil), filesystems...), Devices: []StorageDevice{}, ReadOnly: true, Reason: reason} + snapshot.Fingerprint = storageFingerprint(snapshot) + return snapshot +} + +func PreviewStorageOperation(ctx context.Context, operation StorageOperation) (StorageState, error) { + operation.Action = "preview" + if err := ValidateStorageOperation(operation); err != nil { + return StorageState{}, err + } + snapshot, err := ReadStorageSnapshot(ctx) + if err != nil { + return StorageState{}, err + } + return StorageState{Snapshot: snapshot, Action: "preview", Warning: storagePreviewWarning(snapshot, operation)}, nil +} + +func ApplyStorageOperation(ctx context.Context, operation StorageOperation) (StorageState, error) { + return NewStorageStrategy(nil).Apply(ctx, operation) +} + +func ValidateStorageOperation(operation StorageOperation) error { + actions := map[string]bool{ + "preview": true, "mount": true, "unmount": true, + "persistent-mount": true, "persistent-unmount": true, + } + if !actions[operation.Action] || !validStorageDevicePath(operation.Device) || len(operation.Filesystem) > 64 || len(operation.ExpectedFingerprint) > 128 || len(operation.Confirmation) > 128 { + return ErrInvalidStorageOperation + } + if operation.Target != "" && !safeStorageTarget(operation.Target) { + return ErrStorageUnsafe + } + if operation.Filesystem != "" && !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`).MatchString(operation.Filesystem) { + return ErrInvalidStorageOperation + } + if len(operation.Options) > 32 { + return ErrInvalidStorageOperation + } + for _, option := range operation.Options { + if len(option) == 0 || len(option) > 128 || !regexp.MustCompile(`^[A-Za-z0-9_=+.:@/-]+$`).MatchString(option) { + return ErrInvalidStorageOperation + } + } + if operation.ExpectedFingerprint != "" && (len(operation.ExpectedFingerprint) != 64 || !isHex(operation.ExpectedFingerprint)) { + return ErrInvalidStorageOperation + } + if operation.Action == "persistent-mount" && (operation.Target == "" || operation.Filesystem == "") { + return ErrInvalidStorageOperation + } + if operation.Action == "mount" && operation.Target != "" { + // UDisks2 chooses the authenticated user's mount location. A caller + // must use persistent-mount when an exact target is required. + return ErrInvalidStorageOperation + } + if operation.Action == "persistent-unmount" && operation.Target == "" { + return ErrInvalidStorageOperation + } + if operation.Action == "preview" { + return nil + } + if operation.ExpectedFingerprint == "" { + return ErrInvalidStorageOperation + } + if operation.Action == "persistent-mount" || operation.Action == "persistent-unmount" { + if operation.Confirmation != "CONFIRM PERSISTENT MOUNT" { + return ErrInvalidStorageOperation + } + } else if operation.Confirmation != "CONFIRM STORAGE CHANGE" { + return ErrInvalidStorageOperation + } + return nil +} + +func (adapter *storageAdapter) Read(ctx context.Context) (StorageSnapshot, error) { + readFilesystems := adapter.filesystems + if readFilesystems == nil { + readFilesystems = Filesystems + } + filesystems, err := readFilesystems() + if err != nil { + return StorageSnapshot{}, err + } + payload, err := adapter.runner.Run(ctx, "lsblk", "-J", "-b", "-o", "NAME,KNAME,PATH,TYPE,SIZE,RO,RM,MODEL,SERIAL,TRAN,FSTYPE,LABEL,UUID,MOUNTPOINTS,PKNAME") + if err != nil && strings.TrimSpace(payload) == "" { + return StorageSnapshot{}, errors.Join(ErrStorageUnavailable, err) + } + var document struct { + BlockDevices []storageBlockDevice `json:"blockdevices"` + } + if json.Unmarshal([]byte(payload), &document) != nil { + return StorageSnapshot{}, ErrStorageUnavailable + } + mountReadOnly := make(map[string]bool) + for _, filesystem := range filesystems { + for _, mount := range filesystem.Targets { + mountReadOnly[mount.Target] = mount.ReadOnly + } + } + devices := make([]StorageDevice, 0, len(document.BlockDevices)) + for _, raw := range document.BlockDevices { + if raw.Type != "disk" || !validStorageDevicePath(raw.Path) { + continue + } + device := StorageDevice{ + Path: raw.Path, Name: raw.Name, Type: raw.Type, Model: strings.TrimSpace(raw.Model), + Serial: strings.TrimSpace(raw.Serial), Transport: strings.TrimSpace(raw.Transport), Size: raw.Size, + ReadOnly: raw.RO != 0, Removable: raw.RM != 0, Partitions: []StoragePartition{}, + } + flattenStoragePartitions(&device.Partitions, raw.Children, raw.Path, mountReadOnly) + device.SMART = adapter.readSMART(ctx, device.Path) + if strings.EqualFold(device.Transport, "nvme") || strings.Contains(device.Path, "nvme") { + device.NVMe = adapter.readNVMe(ctx, device.Path) + } + devices = append(devices, device) + } + sort.Slice(devices, func(i, j int) bool { return devices[i].Path < devices[j].Path }) + snapshot := StorageSnapshot{Filesystems: filesystems, Devices: devices} + if _, statusErr := adapter.runner.Run(ctx, "udisksctl", "status"); statusErr != nil { + snapshot.ReadOnly = true + snapshot.Reason = "UDisks2 is unavailable; storage mutations are disabled." + } + snapshot.Fingerprint = storageFingerprint(snapshot) + return snapshot, nil +} + +type storageBlockDevice struct { + Name string `json:"name"` + KName string `json:"kname"` + Path string `json:"path"` + Type string `json:"type"` + Size uint64 `json:"size"` + RO int `json:"ro"` + RM int `json:"rm"` + Model string `json:"model"` + Serial string `json:"serial"` + Transport string `json:"tran"` + Filesystem string `json:"fstype"` + Label string `json:"label"` + UUID string `json:"uuid"` + MountPoints []string `json:"mountpoints"` + Children []storageBlockDevice `json:"children"` +} + +func flattenStoragePartitions(result *[]StoragePartition, children []storageBlockDevice, parent string, readOnly map[string]bool) { + for _, child := range children { + path := child.Path + if path == "" && child.KName != "" { + path = "/dev/" + child.KName + } + if validStorageDevicePath(path) && child.Type == "part" { + mounts := make([]MountPoint, 0, len(child.MountPoints)) + for _, target := range child.MountPoints { + if target != "" { + mounts = append(mounts, MountPoint{Target: target, ReadOnly: readOnly[target]}) + } + } + sort.Slice(mounts, func(i, j int) bool { return mounts[i].Target < mounts[j].Target }) + *result = append(*result, StoragePartition{Path: path, Name: child.Name, Size: child.Size, Filesystem: child.Filesystem, Label: child.Label, UUID: child.UUID, Parent: parent, ReadOnly: child.RO != 0, MountPoints: mounts}) + } + flattenStoragePartitions(result, child.Children, path, readOnly) + } +} + +type smartDocument struct { + SmartSupport struct { + Available bool `json:"available"` + } `json:"smart_support"` + SmartStatus struct { + Passed bool `json:"passed"` + } `json:"smart_status"` + Temperature struct { + Current int `json:"current"` + } `json:"temperature"` + PowerOnTime struct { + Hours uint64 `json:"hours"` + } `json:"power_on_time"` +} + +func (adapter *storageAdapter) readSMART(ctx context.Context, path string) *StorageHealth { + payload, err := adapter.runner.Run(ctx, "smartctl", "-H", "-A", "-j", "--", path) + health := &StorageHealth{} + var document smartDocument + if json.Unmarshal([]byte(payload), &document) == nil { + health.Available = document.SmartSupport.Available || document.SmartStatus.Passed + health.Passed = document.SmartStatus.Passed + health.TemperatureC = document.Temperature.Current + health.PowerOnHours = document.PowerOnTime.Hours + health.Failing = health.Available && !health.Passed + if health.Available { + return health + } + } + if err != nil { + health.Reason = "SMART data unavailable." + } else { + health.Reason = "SMART data was not reported by device." + } + return health +} + +type nvmeDocument struct { + CriticalWarning int `json:"critical_warning"` + Temperature int `json:"temperature"` + PercentageUsed int `json:"percentage_used"` +} + +func (adapter *storageAdapter) readNVMe(ctx context.Context, path string) *NVMeHealth { + payload, err := adapter.runner.Run(ctx, "nvme", "smart-log", "-o", "json", "--", path) + status := &NVMeHealth{} + var document nvmeDocument + if json.Unmarshal([]byte(payload), &document) == nil && (document.Temperature != 0 || document.PercentageUsed != 0 || document.CriticalWarning != 0) { + status.Available = true + status.TemperatureC = document.Temperature + status.PercentageUsed = document.PercentageUsed + status.CriticalWarning = document.CriticalWarning + return status + } + if err != nil { + status.Reason = "NVMe health data unavailable." + } else { + status.Reason = "NVMe health data was not reported by device." + } + return status +} + +func storageFingerprint(snapshot StorageSnapshot) string { + type partition struct { + Path, Filesystem, Label, UUID, Parent string + Size uint64 + ReadOnly bool + Mounts []string + } + type device struct { + Path, Name, Type, Model, Serial, Transport string + Size uint64 + ReadOnly, Removable bool + Partitions []partition + } + devices := make([]device, 0, len(snapshot.Devices)) + for _, item := range snapshot.Devices { + partitions := make([]partition, 0, len(item.Partitions)) + for _, part := range item.Partitions { + mounts := make([]string, 0, len(part.MountPoints)) + for _, mount := range part.MountPoints { + mounts = append(mounts, mount.Target) + } + sort.Strings(mounts) + partitions = append(partitions, partition{part.Path, part.Filesystem, part.Label, part.UUID, part.Parent, part.Size, part.ReadOnly, mounts}) + } + sort.Slice(partitions, func(i, j int) bool { return partitions[i].Path < partitions[j].Path }) + devices = append(devices, device{item.Path, item.Name, item.Type, item.Model, item.Serial, item.Transport, item.Size, item.ReadOnly, item.Removable, partitions}) + } + sort.Slice(devices, func(i, j int) bool { return devices[i].Path < devices[j].Path }) + payload, _ := json.Marshal(struct { + Filesystems []Filesystem + Devices []device + }{snapshot.Filesystems, devices}) + return fingerprintBytes(payload) +} + +func (adapter *storageAdapter) Apply(ctx context.Context, operation StorageOperation) (StorageState, error) { + if err := ValidateStorageOperation(operation); err != nil { + return StorageState{}, err + } + current, err := adapter.Read(ctx) + if err != nil { + return StorageState{}, err + } + if operation.Action == "preview" { + return StorageState{Snapshot: current, Action: "preview", Warning: storagePreviewWarning(current, operation)}, nil + } + if current.ReadOnly { + return StorageState{}, ErrStorageUnavailable + } + if current.Fingerprint != operation.ExpectedFingerprint { + return StorageState{}, ErrStorageConflict + } + partition := findStoragePartition(current, operation.Device) + if partition == nil { + return StorageState{}, ErrStorageUnavailable + } + if partition.ReadOnly || currentDeviceReadOnly(current, operation.Device) { + return StorageState{}, ErrStorageUnsafe + } + needsUnmount := false + if operation.Action == "unmount" || operation.Action == "persistent-unmount" { + target := operation.Target + if target == "" && len(partition.MountPoints) > 0 { + target = partition.MountPoints[0].Target + } + if target == "" || protectedStorageTarget(target) { + return StorageState{}, ErrStorageUnsafe + } + needsUnmount = storagePartitionHasMount(partition, target) + if operation.Action == "unmount" && !needsUnmount { + return StorageState{}, ErrStorageConflict + } + if needsUnmount && storageTargetBusy(ctx, adapter.runner, target) { + return StorageState{}, ErrStorageBusy + } + } + var restore func() error + mutationStarted := false + defer func() { + if err == nil { + return + } + if mutationStarted { + _, _ = adapter.runner.Run(ctx, "udisksctl", "unmount", "--no-user-interaction", "-b", operation.Device) + } + if restore != nil { + _ = restore() + } + }() + if operation.Action == "persistent-mount" { + restore, err = addStorageFstabEntry(operation, *partition) + if err != nil { + return StorageState{}, err + } + } else if operation.Action == "persistent-unmount" { + restore, err = removeStorageFstabEntry(operation, *partition) + if err != nil { + return StorageState{}, err + } + } + if operation.Action != "persistent-unmount" || needsUnmount { + arguments, argumentErr := storageMutationArguments(operation) + if argumentErr != nil { + return StorageState{}, argumentErr + } + if _, err = adapter.runner.Run(ctx, "udisksctl", arguments...); err != nil { + return StorageState{}, errors.Join(ErrStorageUnavailable, err) + } + mutationStarted = true + } + updated, err := adapter.Read(ctx) + if err != nil { + return StorageState{}, err + } + if err = verifyStorageMutation(current, updated, operation); err != nil { + return StorageState{}, err + } + warning := "Storage state changed and was re-read after UDisks2 completed." + if operation.Action == "persistent-mount" { + warning = "Mount was added to /etc/fstab with a Tako ownership marker and activated through UDisks2." + } + if operation.Action == "persistent-unmount" { + warning = "Tako-owned /etc/fstab entry was removed; the filesystem was unmounted through UDisks2 when it was active." + } + return StorageState{Snapshot: updated, Action: operation.Action, Applied: true, Warning: warning}, nil +} + +func storagePreviewWarning(snapshot StorageSnapshot, operation StorageOperation) string { + if snapshot.ReadOnly { + return snapshot.Reason + } + if operation.Action == "persistent-mount" || operation.Action == "persistent-unmount" { + return "Persistent mount changes edit only Tako-owned /etc/fstab entries and require an explicit confirmation." + } + return "Mount changes use UDisks2, reject protected targets, and verify state after completion." +} + +func storageMutationArguments(operation StorageOperation) ([]string, error) { + if operation.Action == "mount" || operation.Action == "persistent-mount" { + arguments := []string{"mount", "--no-user-interaction", "-b", operation.Device} + if len(operation.Options) > 0 { + arguments = append(arguments, "--options", strings.Join(operation.Options, ",")) + } + return arguments, nil + } + if operation.Action == "unmount" || operation.Action == "persistent-unmount" { + return []string{"unmount", "--no-user-interaction", "-b", operation.Device}, nil + } + return nil, ErrInvalidStorageOperation +} + +func verifyStorageMutation(before, after StorageSnapshot, operation StorageOperation) error { + beforePartition := findStoragePartition(before, operation.Device) + afterPartition := findStoragePartition(after, operation.Device) + if beforePartition == nil || afterPartition == nil { + return ErrStorageUnavailable + } + switch operation.Action { + case "mount", "persistent-mount": + if operation.Target != "" && !storagePartitionHasMount(afterPartition, operation.Target) { + return ErrStorageConflict + } + if len(afterPartition.MountPoints) == 0 { + return ErrStorageConflict + } + case "unmount", "persistent-unmount": + if operation.Target != "" && storagePartitionHasMount(afterPartition, operation.Target) { + return ErrStorageConflict + } + if operation.Target == "" && len(afterPartition.MountPoints) >= len(beforePartition.MountPoints) { + return ErrStorageConflict + } + } + return nil +} + +func findStoragePartition(snapshot StorageSnapshot, path string) *StoragePartition { + for deviceIndex := range snapshot.Devices { + for partitionIndex := range snapshot.Devices[deviceIndex].Partitions { + partition := &snapshot.Devices[deviceIndex].Partitions[partitionIndex] + if partition.Path == path { + return partition + } + } + } + return nil +} + +func storagePartitionHasMount(partition *StoragePartition, target string) bool { + for _, mount := range partition.MountPoints { + if mount.Target == target { + return true + } + } + return false +} + +func currentDeviceReadOnly(snapshot StorageSnapshot, path string) bool { + for _, device := range snapshot.Devices { + if device.Path == path { + return device.ReadOnly + } + } + return false +} + +func validStorageDevicePath(path string) bool { + return strings.HasPrefix(path, "/dev/") && filepath.Clean(path) == path && filepath.IsAbs(path) && !strings.ContainsAny(path, "\x00\r\n") && !strings.Contains(path, "..") +} + +func safeStorageTarget(path string) bool { + return filepath.IsAbs(path) && filepath.Clean(path) == path && !strings.ContainsAny(path, "\x00\r\n") && !protectedStorageTarget(path) && !filePathWithin("/proc", path) && !filePathWithin("/sys", path) && !filePathWithin("/dev", path) +} + +func protectedStorageTarget(path string) bool { + clean := filepath.Clean(path) + return clean == "/" || clean == "/boot" || clean == "/boot/efi" +} + +func storageTargetBusy(ctx context.Context, runner StorageCommandRunner, target string) bool { + payload, _ := runner.Run(ctx, "fuser", "-m", "--", target) + return strings.TrimSpace(payload) != "" +} + +var storageConfigRoot string + +func storageConfigPath(path string) string { + if storageConfigRoot == "" { + return path + } + return filepath.Join(storageConfigRoot, strings.TrimPrefix(path, string(filepath.Separator))) +} + +func readStorageFstab() ([]byte, error) { + path := storageConfigPath("/etc/fstab") + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return []byte{}, nil + } + if err != nil { + return nil, err + } + if len(data) > 1<<20 { + return nil, ErrStorageUnavailable + } + return data, nil +} + +func writeStorageFstab(data []byte) error { + path := storageConfigPath("/etc/fstab") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".tako-fstab-*") + if err != nil { + return err + } + name := temporary.Name() + defer os.Remove(name) + if err := temporary.Chmod(0o644); 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(name, path) +} + +func storageFstabSource(partition StoragePartition) string { + if partition.UUID != "" { + return "UUID=" + partition.UUID + } + return partition.Path +} + +func addStorageFstabEntry(operation StorageOperation, partition StoragePartition) (func() error, error) { + data, err := readStorageFstab() + if err != nil { + return nil, err + } + source := storageFstabSource(partition) + target := operation.Target + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) >= 4 && !strings.HasPrefix(fields[0], "#") && (fields[1] == target || fields[0] == source) { + return nil, ErrStorageConflict + } + } + options := append([]string{}, operation.Options...) + if len(options) == 0 { + options = []string{"defaults"} + } + options = append(options, "x-tako-managed") + line := source + "\t" + target + "\t" + operation.Filesystem + "\t" + strings.Join(options, ",") + "\t0 0\n" + old := append([]byte(nil), data...) + if len(data) > 0 && data[len(data)-1] != '\n' { + data = append(data, '\n') + } + data = append(data, []byte(line)...) + if err := writeStorageFstab(data); err != nil { + return nil, err + } + return func() error { return writeStorageFstab(old) }, nil +} + +func removeStorageFstabEntry(operation StorageOperation, partition StoragePartition) (func() error, error) { + data, err := readStorageFstab() + if err != nil { + return nil, err + } + source := storageFstabSource(partition) + lines := strings.Split(string(data), "\n") + kept := make([]string, 0, len(lines)) + found := false + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) >= 4 && fields[0] == source && fields[1] == operation.Target && strings.Contains(fields[3], "x-tako-managed") { + found = true + continue + } + kept = append(kept, line) + } + if !found { + return nil, ErrStorageConflict + } + old := append([]byte(nil), data...) + if err := writeStorageFstab([]byte(strings.Join(kept, "\n"))); err != nil { + return nil, err + } + return func() error { return writeStorageFstab(old) }, nil +} + +func runStorageCommand(ctx context.Context, name string, arguments ...string) (string, error) { + commandCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + command := exec.CommandContext(commandCtx, name, arguments...) + command.Stderr = io.Discard + stdout, err := command.StdoutPipe() + if err != nil { + return "", err + } + if err := command.Start(); err != nil { + return "", err + } + payload, readErr := readBounded(stdout, 2<<20) + if readErr != nil { + _ = command.Process.Kill() + } + waitErr := command.Wait() + if readErr != nil { + return "", readErr + } + if commandCtx.Err() != nil { + return "", commandCtx.Err() + } + return string(payload), waitErr +} + +func storageErrorCode(err error) string { + switch { + case errors.Is(err, ErrInvalidStorageOperation): + return "invalid-storage-operation" + case errors.Is(err, ErrStorageConflict): + return "storage-conflict" + case errors.Is(err, ErrStorageUnsafe): + return "storage-unsafe" + case errors.Is(err, ErrStorageBusy): + return "storage-busy" + case errors.Is(err, ErrStorageUnavailable): + return "storage-unavailable" + default: + return "storage-operation-failed" + } +} diff --git a/apps/backend/internal/platform/storage_adapter_test.go b/apps/backend/internal/platform/storage_adapter_test.go new file mode 100644 index 0000000..3556712 --- /dev/null +++ b/apps/backend/internal/platform/storage_adapter_test.go @@ -0,0 +1,135 @@ +package platform + +import ( + "context" + "errors" + "strings" + "testing" +) + +type storageFixtureRunner struct { + mounted bool + calls []string +} + +func (runner *storageFixtureRunner) Run(_ context.Context, name string, arguments ...string) (string, error) { + runner.calls = append(runner.calls, name+" "+strings.Join(arguments, " ")) + switch name { + case "lsblk": + mount := "null" + if runner.mounted { + mount = `"/mnt/data"` + } + return `{"blockdevices":[{"name":"sda","path":"/dev/sda","type":"disk","size":1000000,"ro":0,"rm":0,"tran":"sata","children":[{"name":"sda1","path":"/dev/sda1","type":"part","size":900000,"ro":0,"fstype":"ext4","uuid":"abcd-1234","mountpoints":[` + mount + `]}]}]}`, nil + case "smartctl": + return `{"smart_support":{"available":true},"smart_status":{"passed":true},"temperature":{"current":32},"power_on_time":{"hours":12}}`, nil + case "udisksctl": + if len(arguments) > 0 && arguments[0] == "mount" { + runner.mounted = true + } + return "", nil + case "fuser": + return "", nil + default: + return "", errors.New("unexpected command") + } +} + +func TestStorageReadInventoryAndHealth(t *testing.T) { + runner := &storageFixtureRunner{} + adapter := &storageAdapter{ + runner: runner, + filesystems: func() ([]Filesystem, error) { + return []Filesystem{{Device: "/dev/sda1", Type: "ext4", Targets: []MountPoint{}}}, nil + }, + } + snapshot, err := adapter.Read(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(snapshot.Devices) != 1 || len(snapshot.Devices[0].Partitions) != 1 { + t.Fatalf("inventory = %+v", snapshot.Devices) + } + partition := snapshot.Devices[0].Partitions[0] + if partition.Path != "/dev/sda1" || partition.Filesystem != "ext4" { + t.Fatalf("partition = %+v", partition) + } + if snapshot.Devices[0].SMART == nil || !snapshot.Devices[0].SMART.Passed || snapshot.Devices[0].SMART.TemperatureC != 32 { + t.Fatalf("SMART = %+v", snapshot.Devices[0].SMART) + } + if len(snapshot.Fingerprint) != 64 { + t.Fatalf("fingerprint = %q", snapshot.Fingerprint) + } +} + +func TestStorageApplyChecksFingerprintAndVerifiesMount(t *testing.T) { + runner := &storageFixtureRunner{} + adapter := &storageAdapter{runner: runner} + adapter.filesystems = func() ([]Filesystem, error) { + mounts := []MountPoint{} + if runner.mounted { + mounts = []MountPoint{{Target: "/mnt/data"}} + } + return []Filesystem{{Device: "/dev/sda1", Type: "ext4", Targets: mounts}}, nil + } + before, err := adapter.Read(context.Background()) + if err != nil { + t.Fatal(err) + } + state, err := adapter.Apply(context.Background(), StorageOperation{ + Action: "mount", + Device: "/dev/sda1", + ExpectedFingerprint: before.Fingerprint, + Confirmation: "CONFIRM STORAGE CHANGE", + }) + if err != nil { + t.Fatal(err) + } + if !state.Applied || len(state.Snapshot.Devices[0].Partitions[0].MountPoints) != 1 { + t.Fatalf("state = %+v", state) + } + if _, err := adapter.Apply(context.Background(), StorageOperation{ + Action: "mount", + Device: "/dev/sda1", + ExpectedFingerprint: strings.Repeat("0", 64), + Confirmation: "CONFIRM STORAGE CHANGE", + }); !errors.Is(err, ErrStorageConflict) { + t.Fatalf("stale fingerprint error = %v", err) + } +} + +func TestStorageFstabChangesAreTakoOwnedAndReversible(t *testing.T) { + root := t.TempDir() + previous := storageConfigRoot + storageConfigRoot = root + defer func() { storageConfigRoot = previous }() + partition := StoragePartition{Path: "/dev/sda1", UUID: "abcd-1234"} + operation := StorageOperation{Action: "persistent-mount", Device: partition.Path, Target: "/mnt/data", Filesystem: "ext4"} + restore, err := addStorageFstabEntry(operation, partition) + if err != nil { + t.Fatal(err) + } + data, err := readStorageFstab() + if err != nil || !strings.Contains(string(data), "UUID=abcd-1234\t/mnt/data\text4") || !strings.Contains(string(data), "x-tako-managed") { + t.Fatalf("fstab = %q, err=%v", data, err) + } + if err := restore(); err != nil { + t.Fatal(err) + } + operation.Action = "persistent-unmount" + if _, err := removeStorageFstabEntry(operation, partition); !errors.Is(err, ErrStorageConflict) { + t.Fatalf("unowned removal error = %v", err) + } +} + +func TestStorageValidationProtectsPersistentOperations(t *testing.T) { + if err := ValidateStorageOperation(StorageOperation{Action: "persistent-mount", Device: "/dev/sda1", Target: "/mnt/data", Confirmation: "CONFIRM PERSISTENT MOUNT"}); !errors.Is(err, ErrInvalidStorageOperation) { + t.Fatalf("missing filesystem error = %v", err) + } + if err := ValidateStorageOperation(StorageOperation{Action: "persistent-unmount", Device: "/dev/sda1", Confirmation: "CONFIRM PERSISTENT MOUNT"}); !errors.Is(err, ErrInvalidStorageOperation) { + t.Fatalf("missing target error = %v", err) + } + if err := ValidateStorageOperation(StorageOperation{Action: "persistent-mount", Device: "/dev/sda1", Target: "/boot", Filesystem: "ext4", ExpectedFingerprint: strings.Repeat("0", 64), Confirmation: "CONFIRM PERSISTENT MOUNT"}); !errors.Is(err, ErrStorageUnsafe) { + t.Fatalf("protected target error = %v", err) + } +} 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 deleted file mode 100644 index f50a7cd..0000000 --- a/apps/backend/internal/platform/updates_integration_test.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build linux && integration - -package platform - -import ( - "context" - "testing" -) - -// TestUpdatesAgainstHost exercises the real bounded read-only adapter in the -// disposable Linux VM. A host without PackageKit, APT, or DNF is a supported -// degraded result, but the response must always remain bounded and explicit. -func TestUpdatesAgainstHost(t *testing.T) { - status := Updates(context.Background()) - if len(status.Packages) > MaxUpdatePackages { - t.Fatalf("update inventory exceeded bound: %d", len(status.Packages)) - } - if status.Backend == "" || status.Contract == "" || status.Message == "" { - t.Fatalf("update status omitted runtime contract: %#v", status) - } -} 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/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/firewall_backend.go b/apps/backend/internal/sessiond/firewall_backend.go new file mode 100644 index 0000000..eb6532d --- /dev/null +++ b/apps/backend/internal/sessiond/firewall_backend.go @@ -0,0 +1,254 @@ +package sessiond + +import ( + "context" + "crypto/rand" + "encoding/hex" + "sync" + "time" + + "github.com/velopulent/tako/internal/platform" +) + +const defaultFirewallRollbackDeadline = 120 * time.Second + +type firewallPendingTransaction struct { + backend string + checkpoint string + token string + deadline time.Time + inverse platform.FirewallOperation + after string + timer *time.Timer +} + +type firewallCoordinator struct { + mu sync.Mutex + pending map[string]firewallPendingTransaction + deadline time.Duration + now func() time.Time + apply func(context.Context, platform.FirewallOperation) (platform.FirewallState, error) + read func(context.Context) (platform.FirewallSnapshot, error) +} + +func newFirewallCoordinator(deadline ...time.Duration) *firewallCoordinator { + value := defaultFirewallRollbackDeadline + if len(deadline) > 0 && deadline[0] > 0 { + value = deadline[0] + } + return &firewallCoordinator{ + pending: make(map[string]firewallPendingTransaction), + deadline: value, + now: time.Now, + apply: platform.ApplyFirewallOperation, + read: platform.ReadFirewallStatus, + } +} + +func (coordinator *firewallCoordinator) Apply(ctx context.Context, operation platform.FirewallOperation) (platform.FirewallState, error) { + if coordinator == nil { + return platform.ApplyFirewallOperation(ctx, operation) + } + if operation.Action == "commit" || operation.Action == "rollback" { + pending, ok := coordinator.take(operation.RollbackToken, operation.Checkpoint, operation.Backend) + if !ok { + return platform.FirewallState{}, platform.ErrFirewallCheckpoint + } + if operation.Action == "commit" { + snapshot, err := coordinator.read(ctx) + if err != nil { + coordinator.restore(pending) + return platform.FirewallState{}, err + } + if pending.after != "" && snapshot.Fingerprint != pending.after { + coordinator.restore(pending) + return platform.FirewallState{}, platform.ErrFirewallConflict + } + return platform.FirewallState{Snapshot: snapshot, Action: "commit", Applied: true, Committed: true, Checkpoint: pending.checkpoint, RollbackToken: pending.token, Warning: "Firewall change committed before timed rollback."}, nil + } + current, err := coordinator.read(ctx) + if err != nil { + coordinator.restore(pending) + return platform.FirewallState{}, err + } + if pending.after != "" && current.Fingerprint != pending.after { + coordinator.restore(pending) + return platform.FirewallState{}, platform.ErrFirewallConflict + } + inverse := pending.inverse + inverse.ExpectedFingerprint = current.Fingerprint + inverse.RollbackSeconds = 0 + state, err := coordinator.apply(ctx, inverse) + if err != nil { + coordinator.restore(pending) + return platform.FirewallState{}, err + } + state.Action = "rollback" + state.Checkpoint = pending.checkpoint + state.RollbackToken = pending.token + state.Warning = "Firewall change rolled back by explicit confirmation." + return state, nil + } + if operation.RollbackSeconds == 0 || !firewallRollbackable(operation) { + return coordinator.apply(ctx, operation) + } + before, err := coordinator.read(ctx) + if err != nil { + return platform.FirewallState{}, err + } + if operation.Backend == "auto" { + operation.Backend = before.Backend + } + state, err := coordinator.apply(ctx, operation) + if err != nil { + return platform.FirewallState{}, err + } + after := state.Snapshot.Fingerprint + if after == "" { + updated, readErr := coordinator.read(ctx) + if readErr != nil { + return platform.FirewallState{}, readErr + } + after = updated.Fingerprint + } + token := newFirewallToken() + checkpoint := "tako-firewall-" + token + pending := firewallPendingTransaction{ + backend: operation.Backend, checkpoint: checkpoint, token: token, + deadline: coordinator.now().Add(time.Duration(operation.RollbackSeconds) * time.Second), + inverse: firewallInverse(operation, before), after: after, + } + pending.timer = time.AfterFunc(time.Until(pending.deadline), func() { coordinator.expire(token) }) + coordinator.mu.Lock() + coordinator.pending[token] = pending + coordinator.mu.Unlock() + state.RollbackRequired = true + state.Checkpoint = checkpoint + state.RollbackToken = token + state.RollbackDeadline = pending.deadline + state.Warning = "Management-access guard active; commit after verifying a fresh session or rollback before deadline." + return state, nil +} + +func firewallRollbackable(operation platform.FirewallOperation) bool { + return operation.Action == "disable" || operation.Action == "default-zone" || operation.Action == "remove-service" || operation.Action == "remove-port" || operation.Action == "remove-source" || operation.Action == "add-service" || operation.Action == "add-port" || operation.Action == "add-source" +} + +func firewallInverse(operation platform.FirewallOperation, before platform.FirewallSnapshot) platform.FirewallOperation { + inverse := operation + switch operation.Action { + case "disable": + inverse.Action = "enable" + case "remove-service": + inverse.Action = "add-service" + case "remove-port": + inverse.Action = "add-port" + case "remove-source": + inverse.Action = "add-source" + case "add-service": + inverse.Action = "remove-service" + case "add-port": + inverse.Action = "remove-port" + case "add-source": + inverse.Action = "remove-source" + case "default-zone": + inverse.DefaultZone = before.DefaultZone + } + inverse.ExpectedFingerprint = "" + inverse.RollbackSeconds = 0 + inverse.Checkpoint = "" + inverse.RollbackToken = "" + inverse.Confirmation = "CONFIRM FIREWALL ACCESS" + return inverse +} + +func (coordinator *firewallCoordinator) take(token, checkpoint, backend string) (firewallPendingTransaction, bool) { + if token == "" || checkpoint == "" { + return firewallPendingTransaction{}, false + } + coordinator.mu.Lock() + pending, ok := coordinator.pending[token] + if ok && (pending.backend != backend || 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 *firewallCoordinator) restore(pending firewallPendingTransaction) { + 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 *firewallCoordinator) 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() + current, err := coordinator.read(ctx) + if err != nil { + return + } + if pending.after != "" && current.Fingerprint != pending.after { + return + } + inverse := pending.inverse + inverse.ExpectedFingerprint = current.Fingerprint + _, _ = coordinator.apply(ctx, inverse) +} + +func (coordinator *firewallCoordinator) Close() { + if coordinator == nil { + return + } + coordinator.mu.Lock() + pending := make([]firewallPendingTransaction, 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 { + current, err := coordinator.read(ctx) + if err != nil { + continue + } + if item.after != "" && current.Fingerprint != item.after { + continue + } + inverse := item.inverse + inverse.ExpectedFingerprint = current.Fingerprint + _, _ = coordinator.apply(ctx, inverse) + } +} + +func newFirewallToken() string { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + return hex.EncodeToString([]byte(time.Now().UTC().String()))[:24] + } + return hex.EncodeToString(value[:]) +} diff --git a/apps/backend/internal/sessiond/firewall_backend_test.go b/apps/backend/internal/sessiond/firewall_backend_test.go new file mode 100644 index 0000000..27981e7 --- /dev/null +++ b/apps/backend/internal/sessiond/firewall_backend_test.go @@ -0,0 +1,106 @@ +package sessiond + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/velopulent/tako/internal/platform" +) + +func TestFirewallCoordinatorRequiresCommitOrRollback(t *testing.T) { + coordinator := newFirewallCoordinator(time.Minute) + reads := []platform.FirewallSnapshot{ + {Backend: "UFW", Active: true, Fingerprint: "before"}, + {Backend: "UFW", Active: true, Fingerprint: "after"}, + } + coordinator.read = func(context.Context) (platform.FirewallSnapshot, error) { + value := reads[0] + reads = reads[1:] + return value, nil + } + var applied []platform.FirewallOperation + coordinator.apply = func(_ context.Context, operation platform.FirewallOperation) (platform.FirewallState, error) { + applied = append(applied, operation) + return platform.FirewallState{Snapshot: platform.FirewallSnapshot{Backend: "UFW", Active: true, Fingerprint: "after"}, Applied: true}, nil + } + state, err := coordinator.Apply(context.Background(), platform.FirewallOperation{ + Backend: "UFW", Action: "add-port", Port: "443/tcp", ExpectedFingerprint: "before", Confirmation: "CONFIRM FIREWALL CHANGE", RollbackSeconds: 60, + }) + if err != nil { + t.Fatal(err) + } + if !state.RollbackRequired || state.Checkpoint == "" || state.RollbackToken == "" { + t.Fatalf("guard state = %+v", state) + } + commit, err := coordinator.Apply(context.Background(), platform.FirewallOperation{ + Backend: "UFW", Action: "commit", Checkpoint: state.Checkpoint, RollbackToken: state.RollbackToken, Confirmation: "CONFIRM FIREWALL ACCESS", + }) + if err != nil { + t.Fatal(err) + } + if !commit.Committed || len(applied) != 1 { + t.Fatalf("commit state = %+v, applied = %+v", commit, applied) + } +} + +func TestFirewallCoordinatorCloseAppliesInverse(t *testing.T) { + coordinator := newFirewallCoordinator(time.Minute) + coordinator.read = func(context.Context) (platform.FirewallSnapshot, error) { + return platform.FirewallSnapshot{Backend: "UFW", Active: true, Fingerprint: "after"}, nil + } + var applied []platform.FirewallOperation + coordinator.apply = func(_ context.Context, operation platform.FirewallOperation) (platform.FirewallState, error) { + applied = append(applied, operation) + return platform.FirewallState{}, nil + } + state, err := coordinator.Apply(context.Background(), platform.FirewallOperation{ + Backend: "UFW", Action: "add-port", Port: "443/tcp", ExpectedFingerprint: "before", Confirmation: "CONFIRM FIREWALL CHANGE", RollbackSeconds: 60, + }) + if err != nil { + t.Fatal(err) + } + coordinator.Close() + if len(applied) != 2 || applied[1].Action != "remove-port" || applied[1].ExpectedFingerprint != "after" { + t.Fatalf("close rollback = %+v, state=%+v", applied, state) + } + if _, err := coordinator.Apply(context.Background(), platform.FirewallOperation{Backend: "UFW", Action: "commit", Checkpoint: state.Checkpoint, RollbackToken: state.RollbackToken, Confirmation: "CONFIRM FIREWALL ACCESS"}); !errors.Is(err, platform.ErrFirewallCheckpoint) { + t.Fatalf("closed checkpoint error = %v", err) + } +} + +func TestFirewallCoordinatorDoesNotUndoExternalChange(t *testing.T) { + coordinator := newFirewallCoordinator(time.Minute) + reads := []platform.FirewallSnapshot{ + {Backend: "UFW", Active: true, Fingerprint: "before"}, + {Backend: "UFW", Active: true, Fingerprint: "external"}, + } + coordinator.read = func(context.Context) (platform.FirewallSnapshot, error) { + if len(reads) == 0 { + return platform.FirewallSnapshot{Backend: "UFW", Active: true, Fingerprint: "external"}, nil + } + value := reads[0] + reads = reads[1:] + return value, nil + } + var applied []platform.FirewallOperation + coordinator.apply = func(_ context.Context, operation platform.FirewallOperation) (platform.FirewallState, error) { + applied = append(applied, operation) + return platform.FirewallState{Snapshot: platform.FirewallSnapshot{Backend: "UFW", Active: true, Fingerprint: "after"}, Applied: true}, nil + } + state, err := coordinator.Apply(context.Background(), platform.FirewallOperation{ + Backend: "UFW", Action: "add-port", Port: "443/tcp", ExpectedFingerprint: "before", Confirmation: "CONFIRM FIREWALL CHANGE", RollbackSeconds: 60, + }) + if err != nil { + t.Fatal(err) + } + if _, err := coordinator.Apply(context.Background(), platform.FirewallOperation{ + Backend: "UFW", Action: "rollback", Checkpoint: state.Checkpoint, RollbackToken: state.RollbackToken, Confirmation: "CONFIRM FIREWALL ACCESS", + }); !errors.Is(err, platform.ErrFirewallConflict) { + t.Fatalf("external-change rollback error = %v", err) + } + if len(applied) != 1 { + t.Fatalf("external change was overwritten: %+v", applied) + } +} diff --git a/apps/backend/internal/sessiond/host_reads.go b/apps/backend/internal/sessiond/host_reads.go index 5bb2f4e..b402876 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) { @@ -403,9 +398,18 @@ func dispatchHostOperation(ctx context.Context, request auth.Request, grants *gr result.response.IdentityInventory = &item case "storage.read": if administrative { - result.response.Filesystems, err = platform.Filesystems() + var snapshot platform.StorageSnapshot + snapshot, err = platform.ReadStorageSnapshot(ctx) + result.response.StorageSnapshot = &snapshot + result.response.Filesystems = snapshot.Filesystems } else { - result.response.Filesystems, err = reader.readFilesystems(ctx) + var filesystems []platform.Filesystem + filesystems, err = reader.readFilesystems(ctx) + if err == nil { + snapshot := platform.StorageSnapshotFromFilesystems(filesystems, "Hardware inventory requires administrative access.") + result.response.StorageSnapshot = &snapshot + result.response.Filesystems = filesystems + } } case "network.read": var item platform.NetworkSnapshot @@ -416,49 +420,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 +457,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 +572,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..f24343d 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,13 @@ 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 + network *networkCoordinator + firewall *firewallCoordinator certificatePath string - - updateMu sync.Mutex - updateClient *packagekit.Client - updateWatcher *packagekit.TransactionWatcher + updates *platform.UpdateService } func newHostRuntime(settings ...time.Duration) *hostRuntime { @@ -42,83 +36,26 @@ 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(), firewall: newFirewallCoordinator()} } 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 + if runtime != nil && runtime.network != nil { + runtime.network.Close() } - client, err := packagekit.New() - if err != nil || !client.Detect(ctx) { - if client != nil { - client.Close() - } - return nil + if runtime != nil && runtime.firewall != nil { + runtime.firewall.Close() } - 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) 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..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" @@ -15,7 +16,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 } @@ -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 +} diff --git a/apps/backend/internal/sessiond/network_backend.go b/apps/backend/internal/sessiond/network_backend.go index 9697d97..5b6ebf7 100644 --- a/apps/backend/internal/sessiond/network_backend.go +++ b/apps/backend/internal/sessiond/network_backend.go @@ -1,11 +1,168 @@ 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() + if !ok && backend != "NetworkManager" && checkpoint != "" && platform.RecoverNetworkFileCheckpoint(backend, checkpoint, token) { + return networkPendingTransaction{backend: backend, checkpoint: checkpoint, token: token, deadline: coordinator.now().Add(coordinator.deadline)}, true + } + 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 +171,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: @@ -21,6 +180,23 @@ func networkErrorCode(err error) string { } } +func storageErrorCode(err error) string { + switch { + case errors.Is(err, platform.ErrInvalidStorageOperation): + return "invalid-storage-operation" + case errors.Is(err, platform.ErrStorageConflict): + return "storage-conflict" + case errors.Is(err, platform.ErrStorageUnsafe): + return "storage-unsafe" + case errors.Is(err, platform.ErrStorageBusy): + return "storage-busy" + case errors.Is(err, platform.ErrStorageUnavailable): + return "storage-unavailable" + default: + return "storage-operation-failed" + } +} + func firewallErrorCode(err error) string { switch { case errors.Is(err, platform.ErrInvalidFirewallOperation): @@ -31,6 +207,8 @@ func firewallErrorCode(err error) string { return "firewall-ownership-conflict" case errors.Is(err, platform.ErrFirewallAccessRisk): return "firewall-access-risk" + case errors.Is(err, platform.ErrFirewallCheckpoint): + return "firewall-checkpoint-invalid" case errors.Is(err, platform.ErrFirewallUnavailable): return "firewall-unavailable" default: diff --git a/apps/backend/internal/sessiond/sessiond.go b/apps/backend/internal/sessiond/sessiond.go index f2def6b..9727708 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 } @@ -1083,8 +1048,39 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe handleJournal(conn, encoder, request, grants, request.Operation == "journal-follow") return } + if request.Operation == "storage" { + if request.Storage == 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 || request.Network != nil || request.Firewall != nil || request.Security != nil || request.SupportReport != nil { + _ = encoder.Encode(auth.Response{Error: "invalid-storage-operation"}) + return + } + if _, ok := grants.adminIdentity(request.AdminToken); !ok { + _ = encoder.Encode(auth.Response{Error: "invalid-admin-token"}) + return + } + operation := *request.Storage + request.Storage = nil + if err := platform.ValidateStorageOperation(operation); err != nil { + _ = encoder.Encode(auth.Response{Error: storageErrorCode(err)}) + return + } + storageCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + var state platform.StorageState + var operationErr error + if operation.Action == "preview" { + state, operationErr = platform.PreviewStorageOperation(storageCtx, operation) + } else { + state, operationErr = platform.ApplyStorageOperation(storageCtx, operation) + } + cancel() + if operationErr != nil { + _ = encoder.Encode(auth.Response{Error: storageErrorCode(operationErr)}) + return + } + _ = encoder.Encode(auth.Response{StorageState: &state}) + 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 } @@ -1104,6 +1100,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) } @@ -1117,7 +1115,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 } @@ -1136,6 +1134,8 @@ func handleWithAllBackendsAndUpdatesRuntime(conn net.Conn, service auth.PAMAuthe var operationErr error if operation.Action == "preview" { state, operationErr = platform.PreviewFirewallOperation(firewallCtx, operation) + } else if runtime != nil && runtime.firewall != nil { + state, operationErr = runtime.firewall.Apply(firewallCtx, operation) } else { state, operationErr = platform.ApplyFirewallOperation(firewallCtx, operation) } @@ -1148,7 +1148,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 +1179,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) - } -} 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") + } +} diff --git a/apps/backend/packaging/README.md b/apps/backend/packaging/README.md index 883c27f..54ee009 100644 --- a/apps/backend/packaging/README.md +++ b/apps/backend/packaging/README.md @@ -29,11 +29,11 @@ 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 the host's PAM development headers and linker files. `bun run package:check` validates those capabilities without assuming a distribution package manager. The default package command cross-packages every manifest distro target matching the host architecture; use `TAKO_PACKAGE_TARGET` to select one native manifest entry. The complete beta matrix and its external VM prerequisites are documented in [`RELEASE.md`](RELEASE.md). -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 and emit one package and binary per manifest distro for the host architecture, plus `artifacts.json` and `checksums.txt` in `dist/`. `TAKO_PACKAGE_TARGET= bun run package` emits one native package and binary. GoReleaser's embedded nFPM packager creates packages without requiring a separate package-builder tool. Snapshot packages do not publish releases. The beta workflow builds each manifest target in its own target image, signs the final checksum manifest, records a dependency inventory, and emits provenance only after the CI and VM evidence gates pass. Local cross-packages use the host's CGO, glibc, and PAM toolchain; native matrix builds provide target-runtime compatibility validation. -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`; Fedora, RHEL, Rocky, and AlmaLinux use `pam/tako.redhat`; openSUSE uses `pam/tako.opensuse`; 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,9 +46,27 @@ 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, 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. -Removal stops and disables Tako units, reloads systemd, and leaves configuration, state, the `tako-session` group, and administrator drop-ins in place. +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. + +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 +74,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 two `.deb`, two `.rpm`, or two Arch `.pkg.tar.zst` files. Dependencies must already be available in the VM. `packaging/vm-test.sh` records evidence only after this harness or the installed-system smoke test actually succeeds. diff --git a/apps/backend/packaging/RELEASE.md b/apps/backend/packaging/RELEASE.md new file mode 100644 index 0000000..7d8749e --- /dev/null +++ b/apps/backend/packaging/RELEASE.md @@ -0,0 +1,90 @@ +# Beta release process + +`targets.json` is the source of truth for the beta support matrix. It currently +contains the 15 native combinations below: + +| Family | Release | Architectures | +| --- | --- | --- | +| Debian | 13 | amd64, arm64 | +| Ubuntu | 26.04 LTS | amd64, arm64 | +| Fedora | 44 | amd64, arm64 | +| RHEL | 10.2 | amd64, arm64 | +| Rocky Linux | 10.2 | amd64, arm64 | +| AlmaLinux | 10.2 | amd64, arm64 | +| openSUSE Leap | 16.0 | amd64, arm64 | +| Arch Linux | rolling snapshot | amd64 | + +The table is descriptive; scripts and workflows read `targets.json` so adding a +target does not require changing artifact counts in a shell assertion. Every +target has its own GoReleaser build ID and is built with `CGO_ENABLED=1` inside +the target distribution image on a runner with the matching CPU architecture. +The release build lane deliberately does not use QEMU or cross-link against +another distribution's libc and PAM libraries. + +For local cross-distro packaging, install the host's PAM development package +and the tools listed by `bun run package:check`, then run: + +```bash +bun run package +``` + +This emits every manifest distro target matching host `GOARCH` (eight packages +on amd64, seven on arm64 with the current manifest). These are cross-CGO +packages built with the host's libc and PAM libraries. For one native local +build, select a manifest ID: + +```bash +TAKO_PACKAGE_TARGET=fedora44-amd64 bun run package +``` + +The selected native target must match `/etc/os-release` and `GOARCH`. The +resulting `dist/` directory contains one package, one binary, `artifacts.json`, +and a checksum file for that target. + +The `native-packages.yml` workflow expands the manifest and builds each target +in its declared image. The arm64 entries require an arm64 GitHub runner; an +amd64 runner cannot silently satisfy them. The RHEL image is the public UBI +image named by the manifest. If an image tag or hosted runner is unavailable, +the target remains pending or fails and the beta release stays blocked. + +The reusable `vm-validation.yml` workflow consumes native artifacts from the +same beta run (use the beta workflow for manual end-to-end validation). It runs `packaging/vm-test.sh` on a disposable +VM runner carrying the `vm_label` from the manifest. These labels describe an +operator-provided runner contract; this repository does not contain VM hosts, +credentials, or evidence that a VM has run. Each successful invocation writes +one release input record containing the tested package digest. A failed or +missing record is a hard failure. + +PR and push CI run on GitHub-hosted runners. Privileged VM integration runs +on its schedule, by manual dispatch, and as a required beta release gate; it +is not a PR gate because it requires operator-provided disposable runners. + +The beta workflow publishes only after all required CI jobs, VM integration, all native package +jobs, and every manifest target's VM evidence pass. It verifies each package digest against its VM evidence, then assembles packages, +writes `dependency-inventory.json`, creates `checksums.txt`, signs that checksum +manifest with keyless Sigstore signing, and emits GitHub build provenance. The +workflow uses the repository's ephemeral `GITHUB_TOKEN`; no package registry, +VM host, signing key, password, or deployment credential is stored in this +repository. + +Run the Arch lifecycle harness inside a disposable Arch VM when both package +versions are available: + +```bash +TAKO_DISPOSABLE_HOST=1 \ + apps/backend/packaging/lifecycle-test.sh old.pkg.tar.zst new.pkg.tar.zst +``` + +The harness exercises fresh install, socket activation, active upgrade, +disabled and masked socket preservation, removal, and reinstall. Debian and +RPM paths use their native package managers in the same harness. The unit-level +maintainer-hook tests also exercise Arch's install, upgrade, and removal hooks. + +`systemd-sysusers tako.conf` is intentional. `systemd-sysusers` treats a +basename as a lookup through the `sysusers.d` search path, and `tako.conf` is a +valid vendor filename. The package installs it under `/usr/lib/sysusers.d/`, +which leaves `/etc/sysusers.d/` available for administrator overrides. + +Tag builds preserve the release version in package metadata and the binary. +Local untagged builds retain GoReleaser snapshot versions. Release checksums +use relative filenames and can be checked from the downloaded asset directory. 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/apps/backend/packaging/lifecycle-test.sh b/apps/backend/packaging/lifecycle-test.sh new file mode 100755 index 0000000..c7f3890 --- /dev/null +++ b/apps/backend/packaging/lifecycle-test.sh @@ -0,0 +1,129 @@ +#!/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; } + ;; + *.pkg.tar.zst) + [[ "$old_package" == *.pkg.tar.zst ]] || { echo "Both packages must be Arch package files." >&2; exit 2; } + install_package() { pacman --noconfirm --upgrade "$1"; } + remove_package() { pacman --noconfirm --remove tako; } + purge_package() { pacman --noconfirm --remove tako >/dev/null 2>&1 || true; } + ;; + *) + echo "NEW_PACKAGE must end in .deb, .rpm, or .pkg.tar.zst." >&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/native-build.sh b/apps/backend/packaging/native-build.sh new file mode 100755 index 0000000..43925a2 --- /dev/null +++ b/apps/backend/packaging/native-build.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Build one target from targets.json inside its target distro image. The caller +# must provide a runner whose CPU matches the image; this script intentionally +# does not enable QEMU or cross-link against another distribution's libraries. +target="${TAKO_PACKAGE_TARGET:?TAKO_PACKAGE_TARGET is required}" +go_version="${TAKO_GO_VERSION:-1.26.7}" +goreleaser_version="${TAKO_GORELEASER_VERSION:-2.18.0}" +bun_version="${TAKO_BUN_VERSION:-1.4.0}" + +machine_arch="$(uname -m)" +case "$machine_arch" in + x86_64) go_arch=amd64; goreleaser_arch=x86_64 ;; + aarch64) go_arch=arm64; goreleaser_arch=arm64 ;; + *) echo "native-build: unsupported machine architecture $machine_arch" >&2; exit 2 ;; +esac + +if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends ca-certificates curl gcc g++ git make pkg-config libpam0g-dev libsystemd-dev systemd systemd-sysv unzip tar gzip +elif command -v dnf >/dev/null 2>&1; then + dnf install -y ca-certificates curl gcc gcc-c++ git make pkgconf-pkg-config pam-devel systemd systemd-devel tar gzip unzip +elif command -v zypper >/dev/null 2>&1; then + zypper --non-interactive refresh + zypper --non-interactive install -y ca-certificates curl gcc gcc-c++ git make pkg-config pam-devel systemd systemd-devel tar gzip unzip +elif command -v pacman >/dev/null 2>&1; then + pacman -Syu --noconfirm --needed ca-certificates curl gcc git make pkgconf pam systemd tar gzip unzip +else + echo "native-build: no supported package manager in target image" >&2 + exit 2 +fi + +if [[ ! -x /opt/go/bin/go ]]; then + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + curl --fail --location --retry 3 --output "$tmp/go.tgz" "https://go.dev/dl/go${go_version}.linux-${go_arch}.tar.gz" + rm -rf /opt/go + mkdir -p /opt + tar -C /opt -xzf "$tmp/go.tgz" +fi +export PATH="/opt/go/bin:/opt/bun/bin:/usr/local/bin:$PATH" +export GOCACHE="${GOCACHE:-/tmp/tako-go-cache}" +export GOTMPDIR="${GOTMPDIR:-/tmp/tako-go-tmp}" +mkdir -p "$GOCACHE" "$GOTMPDIR" + +if ! command -v bun >/dev/null 2>&1; then + mkdir -p /opt/bun + curl --fail --location --retry 3 https://bun.sh/install | BUN_INSTALL=/opt/bun bash -s -- "bun-v${bun_version}" +fi + +if ! command -v goreleaser >/dev/null 2>&1; then + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + curl --fail --location --retry 3 --output "$tmp/goreleaser.tgz" \ + "https://github.com/goreleaser/goreleaser/releases/download/v${goreleaser_version}/goreleaser_Linux_${goreleaser_arch}.tar.gz" + tar -C /usr/local/bin -xzf "$tmp/goreleaser.tgz" goreleaser + chmod 0755 /usr/local/bin/goreleaser +fi + +# The mounted checkout belongs to the hosted runner, while this container runs as root. +git config --global --add safe.directory "$PWD" + +go version +bun --version +goreleaser --version +bun install --frozen-lockfile +TAKO_PACKAGE_TARGET="$target" ./tools/package diff --git a/apps/backend/packaging/pam/tako.opensuse b/apps/backend/packaging/pam/tako.opensuse new file mode 100644 index 0000000..f82aab2 --- /dev/null +++ b/apps/backend/packaging/pam/tako.opensuse @@ -0,0 +1,5 @@ +# openSUSE Leap package variant. Install as /etc/pam.d/tako. +# Leap's generated default stacks are exposed as common-* files. +auth include common-auth +account include common-account +session include common-session diff --git a/apps/backend/packaging/scripts/arch/post.sh b/apps/backend/packaging/scripts/arch/post.sh new file mode 100755 index 0000000..3efa7ee --- /dev/null +++ b/apps/backend/packaging/scripts/arch/post.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu + +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 +} + +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_action="${1:-install}" +case "$install_action" in + install) + 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 + ;; + 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_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 + if [ "$gateway_service_active" = true ]; then + run_systemctl start tako.service + fi + ;; +esac diff --git a/apps/backend/packaging/scripts/arch/preun.sh b/apps/backend/packaging/scripts/arch/preun.sh new file mode 100755 index 0000000..0158a28 --- /dev/null +++ b/apps/backend/packaging/scripts/arch/preun.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +systemd_runtime_dir="${TAKO_SYSTEMD_RUNTIME_DIR:-/run/systemd/system}" +if [ "${1:-remove}" = remove ] && [ -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 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 diff --git a/apps/backend/packaging/scripts/test.sh b/apps/backend/packaging/scripts/test.sh new file mode 100755 index 0000000..a3cb1b5 --- /dev/null +++ b/apps/backend/packaging/scripts/test.sh @@ -0,0 +1,257 @@ +#!/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" +arch_post="$scripts_dir/arch/post.sh" +arch_preun="$scripts_dir/arch/preun.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" + +# Arch package hooks use pacman's install/upgrade action and preserve the same +# socket ordering and administrator state as the RPM path. +run_script "$arch_post" install +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 "$arch_post" upgrade +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_has "systemctl start tako-sessiond.service" +assert_has "systemctl start tako.service" + +run_script "$arch_post" upgrade +assert_lacks "systemctl restart" +assert_lacks "systemctl start" +assert_lacks "systemctl enable" +MOCK_FAILED_UNITS="tako.socket" run_script "$arch_post" upgrade +assert_has "systemctl restart tako.socket" + +run_script "$arch_preun" remove +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" + +echo "Maintainer script tests passed" 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" diff --git a/apps/backend/packaging/systemd/tako-sessiond.service b/apps/backend/packaging/systemd/tako-sessiond.service index cf58487..f9fb933 100644 --- a/apps/backend/packaging/systemd/tako-sessiond.service +++ b/apps/backend/packaging/systemd/tako-sessiond.service @@ -9,8 +9,8 @@ Restart=on-failure RestartSec=2s PrivateTmp=true # Sessiond setuid-execs `tako bridge` as the authenticated user. That child -# inherits this unit's sandbox, so the unit must look like a login helper -# (cockpit-session), not like the network gateway. It deliberately keeps +# inherits this unit's sandbox, so the unit must look like a login helper, +# not like the network gateway. It deliberately keeps # /home visible and does not use NoNewPrivileges: PAM and the user bridge need # the root helper to retain the authority to establish the authenticated UID. ProtectSystem=true diff --git a/apps/backend/packaging/targets.json b/apps/backend/packaging/targets.json new file mode 100644 index 0000000..49d051d --- /dev/null +++ b/apps/backend/packaging/targets.json @@ -0,0 +1,271 @@ +{ + "schema": 1, + "channel": "alpha", + "project": "tako", + "build": { + "goos": "linux", + "cgo": true, + "mode": "native", + "required_files": [ + "/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" + ], + "branding_directory": "/usr/share/tako/branding", + "pam_destination": "/etc/pam.d/tako" + }, + "targets": [ + { + "id": "debian13-amd64", + "distribution": "debian", + "release": "13", + "release_name": "trixie", + "image": "debian:13", + "arch": "amd64", + "goarch": "amd64", + "build_tag": "debian", + "build_id": "debian13-amd64", + "package_id": "debian-package", + "package_format": "deb", + "package_name": "debian_amd64", + "pam_source": "tako.debian", + "branding": "debian", + "vm_label": "tako-vm-debian13-amd64" + }, + { + "id": "debian13-arm64", + "distribution": "debian", + "release": "13", + "release_name": "trixie", + "image": "debian:13", + "arch": "arm64", + "goarch": "arm64", + "build_tag": "debian", + "build_id": "debian13-arm64", + "package_id": "debian-package", + "package_format": "deb", + "package_name": "debian_arm64", + "pam_source": "tako.debian", + "branding": "debian", + "vm_label": "tako-vm-debian13-arm64" + }, + { + "id": "ubuntu2604-amd64", + "distribution": "ubuntu", + "release": "26.04", + "release_name": "resolute", + "image": "ubuntu:26.04", + "arch": "amd64", + "goarch": "amd64", + "build_tag": "ubuntu", + "build_id": "ubuntu2604-amd64", + "package_id": "ubuntu-package", + "package_format": "deb", + "package_name": "ubuntu_amd64", + "pam_source": "tako.debian", + "branding": "ubuntu", + "vm_label": "tako-vm-ubuntu2604-amd64" + }, + { + "id": "ubuntu2604-arm64", + "distribution": "ubuntu", + "release": "26.04", + "release_name": "resolute", + "image": "ubuntu:26.04", + "arch": "arm64", + "goarch": "arm64", + "build_tag": "ubuntu", + "build_id": "ubuntu2604-arm64", + "package_id": "ubuntu-package", + "package_format": "deb", + "package_name": "ubuntu_arm64", + "pam_source": "tako.debian", + "branding": "ubuntu", + "vm_label": "tako-vm-ubuntu2604-arm64" + }, + { + "id": "fedora44-amd64", + "distribution": "fedora", + "release": "44", + "image": "fedora:44", + "arch": "amd64", + "goarch": "amd64", + "build_tag": "fedora", + "build_id": "fedora44-amd64", + "package_id": "fedora-package", + "package_format": "rpm", + "package_name": "fedora_amd64", + "pam_source": "tako.redhat", + "branding": "fedora", + "vm_label": "tako-vm-fedora44-amd64" + }, + { + "id": "fedora44-arm64", + "distribution": "fedora", + "release": "44", + "image": "fedora:44", + "arch": "arm64", + "goarch": "arm64", + "build_tag": "fedora", + "build_id": "fedora44-arm64", + "package_id": "fedora-package", + "package_format": "rpm", + "package_name": "fedora_arm64", + "pam_source": "tako.redhat", + "branding": "fedora", + "vm_label": "tako-vm-fedora44-arm64" + }, + { + "id": "rhel102-amd64", + "distribution": "rhel", + "release": "10.2", + "image": "registry.access.redhat.com/ubi10:10.2", + "arch": "amd64", + "goarch": "amd64", + "build_tag": "rhel", + "build_id": "rhel102-amd64", + "package_id": "rhel-package", + "package_format": "rpm", + "package_name": "rhel_amd64", + "pam_source": "tako.redhat", + "branding": "rhel", + "vm_label": "tako-vm-rhel102-amd64" + }, + { + "id": "rhel102-arm64", + "distribution": "rhel", + "release": "10.2", + "image": "registry.access.redhat.com/ubi10:10.2", + "arch": "arm64", + "goarch": "arm64", + "build_tag": "rhel", + "build_id": "rhel102-arm64", + "package_id": "rhel-package", + "package_format": "rpm", + "package_name": "rhel_arm64", + "pam_source": "tako.redhat", + "branding": "rhel", + "vm_label": "tako-vm-rhel102-arm64" + }, + { + "id": "rocky102-amd64", + "distribution": "rocky", + "release": "10.2", + "image": "rockylinux:10.2", + "arch": "amd64", + "goarch": "amd64", + "build_tag": "rocky", + "build_id": "rocky102-amd64", + "package_id": "rocky-package", + "package_format": "rpm", + "package_name": "rocky_amd64", + "pam_source": "tako.redhat", + "branding": "rocky", + "vm_label": "tako-vm-rocky102-amd64" + }, + { + "id": "rocky102-arm64", + "distribution": "rocky", + "release": "10.2", + "image": "rockylinux:10.2", + "arch": "arm64", + "goarch": "arm64", + "build_tag": "rocky", + "build_id": "rocky102-arm64", + "package_id": "rocky-package", + "package_format": "rpm", + "package_name": "rocky_arm64", + "pam_source": "tako.redhat", + "branding": "rocky", + "vm_label": "tako-vm-rocky102-arm64" + }, + { + "id": "almalinux102-amd64", + "distribution": "almalinux", + "release": "10.2", + "image": "almalinux:10.2", + "arch": "amd64", + "goarch": "amd64", + "build_tag": "almalinux", + "build_id": "almalinux102-amd64", + "package_id": "almalinux-package", + "package_format": "rpm", + "package_name": "almalinux_amd64", + "pam_source": "tako.redhat", + "branding": "almalinux", + "vm_label": "tako-vm-almalinux102-amd64" + }, + { + "id": "almalinux102-arm64", + "distribution": "almalinux", + "release": "10.2", + "image": "almalinux:10.2", + "arch": "arm64", + "goarch": "arm64", + "build_tag": "almalinux", + "build_id": "almalinux102-arm64", + "package_id": "almalinux-package", + "package_format": "rpm", + "package_name": "almalinux_arm64", + "pam_source": "tako.redhat", + "branding": "almalinux", + "vm_label": "tako-vm-almalinux102-arm64" + }, + { + "id": "opensuse160-amd64", + "distribution": "opensuse", + "release": "16.0", + "release_name": "leap", + "image": "opensuse/leap:16.0", + "arch": "amd64", + "goarch": "amd64", + "build_tag": "opensuse", + "build_id": "opensuse160-amd64", + "package_id": "opensuse-package", + "package_format": "rpm", + "package_name": "opensuse_amd64", + "pam_source": "tako.opensuse", + "branding": "opensuse", + "vm_label": "tako-vm-opensuse160-amd64" + }, + { + "id": "opensuse160-arm64", + "distribution": "opensuse", + "release": "16.0", + "release_name": "leap", + "image": "opensuse/leap:16.0", + "arch": "arm64", + "goarch": "arm64", + "build_tag": "opensuse", + "build_id": "opensuse160-arm64", + "package_id": "opensuse-package", + "package_format": "rpm", + "package_name": "opensuse_arm64", + "pam_source": "tako.opensuse", + "branding": "opensuse", + "vm_label": "tako-vm-opensuse160-arm64" + }, + { + "id": "arch-rolling-amd64", + "distribution": "archlinux", + "release": "rolling", + "image": "archlinux:base", + "arch": "amd64", + "goarch": "amd64", + "build_tag": "archlinux", + "build_id": "arch-rolling-amd64", + "package_id": "arch-package", + "package_format": "archlinux", + "package_name": "archlinux_amd64", + "pam_source": "tako", + "branding": "archlinux", + "vm_label": "tako-vm-arch-rolling-amd64" + } + ] +} diff --git a/apps/backend/packaging/vm-test.sh b/apps/backend/packaging/vm-test.sh new file mode 100755 index 0000000..6ed3702 --- /dev/null +++ b/apps/backend/packaging/vm-test.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# This is the only script that writes a VM validation result. It is intended to +# run as root in a disposable, booted target VM. The release workflow consumes +# the result and refuses to publish when a target has no passing evidence. +target_id="${TAKO_VM_TARGET:-}" +new_package="${TAKO_VM_PACKAGE:-}" +old_package="${TAKO_VM_OLD_PACKAGE:-}" +evidence_dir="${TAKO_VM_EVIDENCE_DIR:-vm-evidence}" +manifest="${TAKO_VM_MANIFEST:-apps/backend/packaging/targets.json}" +evidence_file="$evidence_dir/${target_id:-unknown}.json" +status=failed +started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) +log_file="" + +write_evidence() { + local exit_code=$? + mkdir -p "$evidence_dir" + python3 - "$evidence_file" "$target_id" "$status" "$exit_code" "$started_at" "$new_package" "$old_package" "$log_file" <<'PY' +import hashlib +import json +import os +import platform +import sys +from datetime import datetime, timezone + +path, target, status, exit_code, started, new_package, old_package, log_file = sys.argv[1:] + +def digest(value): + if not value: + return None + try: + with open(value, "rb") as stream: + return hashlib.sha256(stream.read()).hexdigest() + except OSError: + return None + +payload = { + "schema": 1, + "target": target, + "status": status, + "exit_code": int(exit_code), + "started_at": started, + "finished_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "commit": os.environ.get("GITHUB_SHA") or os.environ.get("TAKO_VM_COMMIT"), + "host": { + "machine": platform.machine(), + "system": platform.system(), + "release": platform.release(), + }, + "package": { + "new": os.path.basename(new_package) if new_package else None, + "new_sha256": digest(new_package), + "old": os.path.basename(old_package) if old_package else None, + "old_sha256": digest(old_package), + }, + "log": os.path.basename(log_file) if log_file else None, +} +with open(path, "w", encoding="utf-8") as stream: + json.dump(payload, stream, sort_keys=True, indent=2) + stream.write("\n") +PY + return "$exit_code" +} +trap write_evidence EXIT + +if [[ "$EUID" -ne 0 ]]; then + echo "vm-test: run as root in a disposable target VM" >&2 + exit 2 +fi +if [[ -z "$target_id" || -z "$new_package" ]]; then + echo "usage: TAKO_VM_TARGET=TARGET TAKO_VM_PACKAGE=PACKAGE $0" >&2 + exit 2 +fi +if [[ ! -f "$manifest" || ! -f "$new_package" ]]; then + echo "vm-test: manifest and package must be present in the VM" >&2 + exit 2 +fi + +mkdir -p "$evidence_dir" +log_file="$evidence_dir/${target_id}.log" +: >"$log_file" +exec > >(tee -a "$log_file") 2>&1 + +target_json=$(python3 - "$manifest" "$target_id" <<'PY' +import json +import sys +with open(sys.argv[1], encoding="utf-8") as stream: + manifest = json.load(stream) +for target in manifest["targets"]: + if target["id"] == sys.argv[2]: + print(json.dumps(target)) + break +else: + raise SystemExit("unknown target") +PY +) + +read_target() { + python3 -c 'import json,sys; print(json.loads(sys.argv[1])[sys.argv[2]])' "$target_json" "$1" +} + +expected_distro=$(read_target distribution) +expected_release=$(read_target release) +expected_arch=$(read_target goarch) +package_format=$(read_target package_format) +host_arch=$(go env GOARCH 2>/dev/null || true) +[[ -n "$host_arch" ]] || host_arch=$(case "$(uname -m)" in x86_64) echo amd64;; aarch64) echo arm64;; *) echo unknown;; esac) +[[ "$host_arch" == "$expected_arch" ]] || { echo "vm-test: host arch $host_arch does not match $expected_arch" >&2; exit 1; } + +declare -A os_release=() +while IFS='=' read -r key value; do + value=${value%\"} + value=${value#\"} + os_release["$key"]="$value" +done < /etc/os-release +host_distro=${os_release[ID]:-} +case "$host_distro" in + arch) host_distro=archlinux ;; + opensuse-leap|opensuse-tumbleweed|sles) host_distro=opensuse ;; +esac +[[ "$host_distro" == "$expected_distro" ]] || { echo "vm-test: host distro $host_distro does not match $expected_distro" >&2; exit 1; } +if [[ "$expected_release" != rolling ]]; then + host_release=${os_release[VERSION_ID]:-} + [[ "$host_release" == "$expected_release" || "$host_release" == "$expected_release."* ]] || { + echo "vm-test: host release $host_release does not match $expected_release" >&2 + exit 1 + } +fi + +if [[ -n "$old_package" ]]; then + [[ -f "$old_package" ]] || { echo "vm-test: old package does not exist" >&2; exit 1; } + TAKO_DISPOSABLE_HOST=1 apps/backend/packaging/lifecycle-test.sh "$old_package" "$new_package" +else + case "$package_format" in + deb) dpkg --install "$new_package" ;; + rpm) rpm --upgrade --verbose --hash "$new_package" ;; + archlinux) pacman --noconfirm --upgrade "$new_package" ;; + *) echo "vm-test: unsupported package format $package_format" >&2; exit 1 ;; + esac + TAKO_BINARY=/usr/bin/tako apps/backend/packaging/smoke-test.sh +fi + +status=passed +echo "VM validation passed for $target_id" diff --git a/apps/backend/project.json b/apps/backend/project.json index 336d9c8..35347aa 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", @@ -64,12 +64,25 @@ } } }, + "integration": { + "executor": "nx:run-commands", + "cache": false, + "options": { + "command": "../../tools/tako-go.sh test ./...", + "cwd": "apps/backend", + "env": { + "GOCACHE": "/tmp/tako-go-integration-cache", + "TAKO_DASHBOARD_DIR": "", + "TAKO_EXTRA_GO_TAGS": "integration" + } + } + }, "lint": { "executor": "nx:run-commands", "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/apps/dashboard/package.json b/apps/dashboard/package.json index ece86cf..84942c9 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -32,6 +32,7 @@ "@types/react": "^19", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^5.0.0", "jsdom": "^30.0.1", "typescript": "~7", "vite": "^8.2.2", diff --git a/apps/dashboard/project.json b/apps/dashboard/project.json index f5e06c3..2ff1fe0 100644 --- a/apps/dashboard/project.json +++ b/apps/dashboard/project.json @@ -25,6 +25,14 @@ "cwd": "apps/dashboard" } }, + "coverage": { + "executor": "nx:run-commands", + "cache": false, + "options": { + "command": "vitest run --coverage", + "cwd": "apps/dashboard" + } + }, "lint": { "executor": "nx:run-commands", "cache": true, diff --git a/apps/dashboard/src/App.test.tsx b/apps/dashboard/src/App.test.tsx new file mode 100644 index 0000000..1051104 --- /dev/null +++ b/apps/dashboard/src/App.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { afterEach, describe, expect, it, vi } from "vitest" + +import App from "./App" + +vi.mock("@tanstack/react-router", async () => { + const actual = await vi.importActual( + "@tanstack/react-router" + ) + const React = await import("react") + + return { + ...actual, + RouterProvider: () => + React.createElement("div", { "data-testid": "dashboard-shell" }), + } +}) + +function jsonResponse(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "Content-Type": "application/json" }, + }) +} + +async function renderApp() { + return render() +} + +afterEach(() => vi.unstubAllGlobals()) + +describe("App session gate", () => { + it("waits for auth before showing login, then enters the dashboard", async () => { + let resolveSession!: (response: Response) => void + const pendingSession = new Promise((resolve) => { + resolveSession = resolve + }) + const authenticatedSession = { + user: { + username: "octopus", + name: "Octopus", + uid: 1000, + gid: 1000, + }, + csrfToken: "csrf-token", + administrative: false, + } + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input) + void init + if (path.endsWith("/auth/session")) { + return pendingSession + } + if (path.endsWith("/branding")) { + return Promise.resolve( + jsonResponse({ + distribution: "linux", + hostname: "server-01", + backgroundUrl: "/branding/server-01.png", + }) + ) + } + if (path.endsWith("/auth/login")) { + return Promise.resolve(jsonResponse(authenticatedSession)) + } + return Promise.reject(new Error(`Unexpected request: ${path}`)) + }) + ) + + await renderApp() + + expect(screen.getByTestId("auth-pending-shell")).toBeTruthy() + expect(screen.queryByRole("status")).toBeNull() + expect(screen.queryByText(/Checking session/)).toBeNull() + expect(screen.queryByText(/Login to/)).toBeNull() + expect(screen.queryByLabelText("Username")).toBeNull() + + resolveSession(jsonResponse({ code: "unauthorized" }, 401)) + expect(await screen.findByLabelText("Username")).toBeTruthy() + expect(screen.getByText("Login to server-01")).toBeTruthy() + expect( + document.querySelector('img[src="/branding/server-01.png"]') + ).not.toBeNull() + + const user = userEvent.setup() + await user.type(screen.getByLabelText("Username"), "octopus") + await user.type(screen.getByLabelText("Password"), "secret") + await user.click(screen.getByRole("button", { name: "Sign in" })) + + expect(await screen.findByTestId("dashboard-shell")).toBeTruthy() + expect(screen.queryByLabelText("Username")).toBeNull() + expect(screen.queryByText(/Login to/)).toBeNull() + }) +}) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 0edd7ea..ecbefc4 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -47,11 +47,9 @@ function AuthenticatedApp() { if (session.isPending) { return ( - - queryClient.setQueryData(["session"], value) - } +
) } 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 ( -
+
( 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/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/directional-metrics.tsx b/apps/dashboard/src/components/directional-metrics.tsx index b3a7022..e6dcf9e 100644 --- a/apps/dashboard/src/components/directional-metrics.tsx +++ b/apps/dashboard/src/components/directional-metrics.tsx @@ -41,7 +41,7 @@ function DirectionalMetrics({ const text = copy[direction] if (pending) { return ( -
+
Loading {text.label} telemetry
diff --git a/apps/dashboard/src/components/file-browser.tsx b/apps/dashboard/src/components/file-browser.tsx index 57923c7..5d90e6d 100644 --- a/apps/dashboard/src/components/file-browser.tsx +++ b/apps/dashboard/src/components/file-browser.tsx @@ -1,134 +1,314 @@ 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 [selectedPaths, setSelectedPaths] = React.useState>( + new Set() + ) + const [dirty, setDirty] = React.useState(false) + const [discardPreview, setDiscardPreview] = React.useState(false) + const [action, setAction] = React.useState(null) + const [value, setValue] = React.useState("") + const [overwrite, setOverwrite] = React.useState(false) + const [owner, setOwner] = React.useState("") + const [group, setGroup] = 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("") + setSelectedPaths(new Set()) + void client.invalidateQueries({ queryKey: ["files"] }) + } + const navigate = (next: string) => { + setSelected(null) + setPath(next) + setLocation(next) + setPages([0]) + setFingerprint("") + setSearch("") + setFilter("") + setError("") + setSelectedPaths(new Set()) + } 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 bulkMutation = useMutation({ + mutationFn: async (operation: "trash" | "delete") => { + const entries = items.filter((entry) => selectedPaths.has(entry.path)) + for (const entry of entries) { + await api("/files", { + method: "POST", + headers: { "X-CSRF-Token": csrfToken }, + body: JSON.stringify({ + action: operation, + path: entry.path, + scope, + expectedFingerprint: entry.fingerprint, + confirmation: "CONFIRM FILE OPERATION", + permanent: operation === "delete", + recursive: entry.kind === "directory", + }), + }) + } + }, + onSuccess: refresh, + }) 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) + setOverwrite(false) + setValue( + next.action === "metadata" + ? (next.entry?.mode.toString(8) ?? "644") + : next.action === "rename" + ? (next.entry?.name ?? "") + : next.action === "archive" + ? joinPath(path, `${next.entry?.name ?? "archive"}.tar.gz`) + : next.action === "extract" + ? path + : "" + ) + setOwner("") + setGroup("") + } + const runAction = () => { + if (!action) return + const operation: Record = { + action: action.action, + path: + action.action === "extract" + ? value + : (action.entry?.path ?? joinPath(path, value)), + expectedFingerprint: action.entry?.fingerprint, + confirmation: overwrite + ? "CONFIRM FILE OVERWRITE" + : "CONFIRM FILE OPERATION", + } + if (overwrite) operation.overwrite = true + 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 === "archive") operation.archivePath = value + if (action.action === "extract") operation.archivePath = action.entry?.path + if (action.action === "metadata") { + operation.mode = Number.parseInt(value, 8) + operation.owner = owner.trim() || undefined + operation.group = group.trim() || undefined + } + if ( + ["move", "copy"].includes(action.action) && + action.entry?.kind === "directory" + ) { + operation.recursive = true + } + 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) { + const recovery = await api("/files", { + method: "POST", + headers: { "X-CSRF-Token": csrfToken }, + body: JSON.stringify({ + action: "upload-status", + path: task.path, + scope: task.scope, + ...(task.id ? { uploadId: task.id } : {}), + }), + }) + const candidates = + recovery.uploads?.filter((item) => item.total === task.file.size) ?? [] + const resumable = + candidates.find((item) => item.completed) ?? candidates[0] + if (resumable?.completed) { + setTransfer(null) + transferRef.current = null + refresh() + return + } + if (resumable) { + task.id = resumable.uploadId + task.offset = resumable.offset + } else if (task.id) { + // The staging file expired or was removed. A new upload must restart + // at offset zero; the server still refuses to replace an existing + // destination without its original fingerprint. + task.id = "" + task.offset = 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 +317,678 @@ 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. - -
-
- + const discard = async () => { + const task = transferRef.current + if (task?.id) { + try { + await api("/files", { + method: "POST", + headers: { "X-CSRF-Token": csrfToken }, + body: JSON.stringify({ + action: "cancel-upload", + path: task.path, + scope: task.scope, + uploadId: task.id, + }), + }) + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Could not discard upload" + ) + return + } + } + transferRef.current = null + setTransfer(null) + setError("") + } + const columns: ColumnDef[] = [ + { + id: "select", + header: "Select", + enableSorting: false, + size: 72, + cell: ({ row }) => { + const entry = row.original + return ( event.stopPropagation()} onChange={(event) => { - const file = event.target.files?.[0] - if (file) void upload(file) + setSelectedPaths((current) => { + const next = new Set(current) + if (event.target.checked) next.add(entry.path) + else next.delete(entry.path) + return next + }) }} /> - + ), + }, + { + 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", + ...(row.original.kind === "file" || + row.original.kind === "directory" + ? ["archive"] + : []), + ...(row.original.kind === "file" ? ["extract"] : []), + ...(path.includes(".local/share/Trash/files") + ? ["restore", "delete"] + : ["trash"]), + ...(scope === "system" ? ["metadata"] : []), + ].map((name) => ( + choose({ action: name, entry: row.original })} + > + {name === "metadata" + ? "Permissions" + : name === "trash" + ? "Move to trash" + : name === "archive" + ? "Create archive" + : name === "extract" + ? "Extract archive" + : name[0].toUpperCase() + name.slice(1)} + + ))} + + + + ), + }, + ] + const needsValue = + action && + [ + "create", + "rename", + "move", + "copy", + "metadata", + "archive", + "extract", + ].includes(action.action) + const canOverwrite = + action !== null && + ["rename", "move", "copy"].includes(action.action) && + action.entry?.kind === "file" + 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} + + )} + {bulkMutation.isError && ( + + Bulk file action interrupted + + {bulkMutation.error.message} Some selected items may already have + been changed; refresh before retrying. + + + )} + {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 && ( + + {selectedPaths.size > 0 && ( + <> + + {selectedPaths.size} selected + + + + + )} + + {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." + : action?.action === "archive" + ? "Create a bounded gzip tar archive without following symlinks." + : action?.action === "extract" + ? "Extract a bounded gzip tar archive into the selected destination." + : overwrite + ? "The destination file will be replaced. This cannot be undone." + : "Review the destination and apply this file change."} + + +
{ + event.preventDefault() + runAction() + }} + > + {needsValue && ( + + + + {action.action === "metadata" + ? "Permissions (octal)" + : ["archive", "extract"].includes(action.action) + ? action.action === "archive" + ? "Archive path" + : "Destination path" + : ["move", "copy"].includes(action.action) + ? "Destination path" + : "Name"} + + setValue(event.target.value)} + required + /> + + + )} + {action?.action === "metadata" && ( + + + Owner + setOwner(event.target.value)} + placeholder="username" + /> + + + Group + setGroup(event.target.value)} + placeholder="groupname" + /> + + + )} + {canOverwrite && ( + + )} + {mutation.isError && ( + + File action failed + {mutation.error.message} + + )} + + + + +
+
+
+
) } diff --git a/apps/dashboard/src/components/firewall-controls.test.ts b/apps/dashboard/src/components/firewall-controls.test.ts new file mode 100644 index 0000000..b175ddf --- /dev/null +++ b/apps/dashboard/src/components/firewall-controls.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest" + +import { groupFirewallRules } from "./firewall-controls" + +describe("groupFirewallRules", () => { + it("groups firewalld output by zone and keeps rule details", () => { + expect( + groupFirewallRules([ + "public services: ssh cockpit", + "public ports: 443/tcp", + "libvirt ports: 53/udp", + ]) + ).toEqual([ + { + zone: "public", + rules: ["services: ssh cockpit", "ports: 443/tcp"], + }, + { zone: "libvirt", rules: ["ports: 53/udp"] }, + ]) + }) + + it("uses a raw-rule fallback for unparsed backend output", () => { + expect( + groupFirewallRules(["22/tcp ALLOW Anywhere", "80/tcp ALLOW Anywhere"]) + ).toEqual([ + { + zone: "Unzoned rules", + rules: ["22/tcp ALLOW Anywhere", "80/tcp ALLOW Anywhere"], + }, + ]) + }) +}) diff --git a/apps/dashboard/src/components/firewall-controls.tsx b/apps/dashboard/src/components/firewall-controls.tsx index 88b6879..e826f0a 100644 --- a/apps/dashboard/src/components/firewall-controls.tsx +++ b/apps/dashboard/src/components/firewall-controls.tsx @@ -10,6 +10,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" +import { Checkbox } from "@/components/ui/checkbox" import { Field, FieldDescription, @@ -17,13 +18,80 @@ import { FieldLabel, } from "@/components/ui/field" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" import { api, + type FirewallOperation, type FirewallSnapshot, type FirewallState, type SessionResponse, } from "@/lib/api" +type FirewallMutation = Exclude< + FirewallOperation["action"], + "preview" | "commit" | "rollback" +> + +const actionLabels: Record = { + enable: "Enable firewall", + disable: "Disable firewall", + "default-zone": "Set default zone", + "add-service": "Allow service", + "remove-service": "Remove service", + "add-port": "Allow port", + "remove-port": "Remove port", + "add-source": "Allow source", + "remove-source": "Remove source", + reload: "Reload policy", +} + +export type FirewallRuleGroup = { + zone: string + rules: string[] +} + +export function groupFirewallRules( + rules: readonly string[] +): FirewallRuleGroup[] { + const groups = new Map() + + for (const rule of rules) { + const trimmed = rule.trim() + if (!trimmed) continue + const match = /^(\S+)\s+(.+)$/.exec(trimmed) + const detail = match?.[2] ?? "" + const isZonedRule = + Boolean(match) && (detail.includes(":") || detail.startsWith("rule ")) + const zone = isZonedRule ? (match?.[1] ?? "Unzoned rules") : "Unzoned rules" + const value = isZonedRule ? detail : trimmed + const existing = groups.get(zone) ?? [] + existing.push(value) + groups.set(zone, existing) + } + + return Array.from(groups, ([zone, groupedRules]) => ({ + zone, + rules: groupedRules, + })) +} + +function backendValue( + snapshot?: FirewallSnapshot +): FirewallOperation["backend"] { + if (snapshot?.backend === "firewalld" || snapshot?.backend === "UFW") { + return snapshot.backend + } + return "auto" +} + export function FirewallControls() { const client = useQueryClient() const session = useQuery({ @@ -34,37 +102,118 @@ export function FirewallControls() { queryKey: ["firewall"], queryFn: () => api("/firewall"), }) + const [action, setAction] = React.useState("add-port") + const [zone, setZone] = React.useState("") + const [service, setService] = React.useState("") const [port, setPort] = React.useState("") + const [source, setSource] = React.useState("") + const [defaultZone, setDefaultZone] = React.useState("") + const [persist, setPersist] = React.useState(true) + const [rollbackSeconds, setRollbackSeconds] = React.useState("120") const [confirmation, setConfirmation] = React.useState("") - const apply = useMutation({ + const [rollbackState, setRollbackState] = React.useState() + const selectedRisk = action === "disable" || action.startsWith("remove-") + const isRule = + action.includes("service") || + action.includes("port") || + action.includes("source") + const operation = React.useCallback( + (requestedAction: FirewallOperation["action"]): FirewallOperation => { + const checkpointAction = + requestedAction === "commit" || requestedAction === "rollback" + return { + backend: backendValue(status.data), + action: requestedAction, + zone: !checkpointAction && isRule ? zone || undefined : undefined, + service: + !checkpointAction && action.includes("service") + ? service || undefined + : undefined, + port: + !checkpointAction && action.includes("port") + ? port || undefined + : undefined, + source: + !checkpointAction && action.includes("source") + ? source || undefined + : undefined, + defaultZone: + !checkpointAction && action === "default-zone" + ? defaultZone || undefined + : undefined, + expectedFingerprint: + requestedAction === "commit" || requestedAction === "rollback" + ? undefined + : status.data?.fingerprint, + confirmation: confirmation || undefined, + persist: !checkpointAction && persist, + rollbackSeconds: checkpointAction + ? undefined + : Number(rollbackSeconds) || 0, + checkpoint: checkpointAction ? rollbackState?.checkpoint : undefined, + rollbackToken: checkpointAction + ? rollbackState?.rollbackToken + : undefined, + } + }, + [ + action, + confirmation, + defaultZone, + isRule, + persist, + port, + rollbackSeconds, + rollbackState, + service, + source, + status.data, + zone, + ] + ) + const preview = useMutation({ mutationFn: () => + api("/firewall/preview", { + method: "POST", + headers: { "X-CSRF-Token": session.data?.csrfToken ?? "" }, + body: JSON.stringify(operation(action)), + }), + }) + const apply = useMutation({ + mutationFn: (value: FirewallOperation) => api("/firewall", { method: "POST", headers: { "X-CSRF-Token": session.data?.csrfToken ?? "" }, - body: JSON.stringify({ - backend: status.data?.backend ?? "auto", - action: "add-port", - port, - expectedFingerprint: status.data?.fingerprint, - confirmation, - persist: true, - }), + body: JSON.stringify(value), }), - onSuccess: () => void client.invalidateQueries({ queryKey: ["firewall"] }), + onSuccess: (state) => { + setRollbackState(state.rollbackRequired ? state : undefined) + void client.invalidateQueries({ queryKey: ["firewall"] }) + }, }) + const valueMissing = + (action.includes("service") && !service) || + (action.includes("port") && !port) || + (action.includes("source") && !source) || + (action === "default-zone" && !defaultZone) + const blocked = + !status.data || + valueMissing || + !confirmation || + preview.isPending || + apply.isPending + return ( Firewall - Runtime and persistent rules are shown separately; changes preserve a - fresh fingerprint and explicit access confirmation. + Runtime and persistent firewalld state stay visible separately. Rule + changes use fresh fingerprints; access-risk changes can auto-rollback. - - {status.isPending && ( -
- )} + + {status.isPending && } {status.isError && ( Firewall unavailable @@ -80,28 +229,166 @@ export function FirewallControls() { {status.data.backend || "none"} ·{" "} {status.data.active ? "active" : "inactive"} + + {status.data.synchronized + ? "runtime/persistent aligned" + : "runtime/persistent differ"} + {status.data.conflicted && ( conflicted )} + {status.data.defaultZone && ( + + Runtime default: {status.data.defaultZone} + + )} + {status.data.persistentDefaultZone && ( + + Persistent default: {status.data.persistentDefaultZone} + + )} +
+
+ +
-
-              {status.data.rules.join("\n") || "No rules reported."}
-            
)} + {rollbackState?.rollbackRequired && ( + + Rollback guard active + + {rollbackState.warning} + + Deadline:{" "} + {rollbackState.rollbackDeadline + ? new Date(rollbackState.rollbackDeadline).toLocaleString() + : "pending"} + +
+ + +
+
+
+ )} - Port/protocol - setPort(event.target.value)} - placeholder="443/tcp" - /> - - Fixed grammar only: PORT/tcp or PORT/udp. - + Action + + {isRule && ( + + Zone + setZone(event.target.value)} + placeholder={status.data?.defaultZone || "public"} + /> + + Blank uses the active default zone. + + + )} + {action.includes("service") && ( + + Service + setService(event.target.value)} + placeholder="ssh" + /> + + )} + {action.includes("port") && ( + + Port/protocol + setPort(event.target.value)} + placeholder="443/tcp" + /> + + Fixed grammar: PORT/tcp or PORT/udp. + + + )} + {action.includes("source") && ( + + Source + setSource(event.target.value)} + placeholder="192.0.2.0/24" + /> + + )} + {action === "default-zone" && ( + + + Default zone + + setDefaultZone(event.target.value)} + placeholder="public" + /> + + )} Confirmation @@ -110,22 +397,67 @@ export function FirewallControls() { id="firewall-confirmation" value={confirmation} onChange={(event) => setConfirmation(event.target.value)} - placeholder="CONFIRM FIREWALL CHANGE" + placeholder={ + selectedRisk + ? "CONFIRM FIREWALL ACCESS" + : "CONFIRM FIREWALL CHANGE" + } /> + + + Rollback seconds + + setRollbackSeconds(event.target.value)} + /> + + 0 disables the timed guard; access-risk changes require 30–600. + + + + setPersist(checked === true)} + /> + + Persist firewalld rule changes + + - - {apply.isError && ( +
+ + +
+ {preview.data && ( + + Preview ready + {preview.data.warning} + + )} + {(preview.isError || apply.isError) && ( Rule rejected - The firewall owner, fingerprint, or access confirmation was not - acceptable. + Refresh the firewall state and verify owner, fingerprint, access + confirmation, and rollback settings. )} @@ -133,3 +465,69 @@ export function FirewallControls() {
) } + +function RuleList({ + title, + rules, + zones, + defaultZone, +}: { + title: string + rules: string[] + zones: string[] + defaultZone?: string +}) { + const groupedRules = groupFirewallRules(rules) + const knownZones = zones + .filter((zone) => !groupedRules.some((group) => group.zone === zone)) + .map((zone) => ({ zone, rules: [] })) + const groups = [...groupedRules, ...knownZones] + + return ( + + + {title} + + {groups.length > 0 + ? "Rules grouped by reported zone." + : "No zone or rule data reported."} + + + + {groups.map((group) => ( +
+
+ + {group.zone} + + {group.zone === defaultZone && ( + default + )} +
+ {group.rules.length > 0 ? ( +
    + {group.rules.map((rule, index) => ( +
  • + {rule} +
  • + ))} +
+ ) : ( +

+ No rules reported. +

+ )} +
+ ))} + {groups.length === 0 && ( +

No rules reported.

+ )} +
+
+ ) +} diff --git a/apps/dashboard/src/components/journal-browser.tsx b/apps/dashboard/src/components/journal-browser.tsx index 42702aa..54a7049 100644 --- a/apps/dashboard/src/components/journal-browser.tsx +++ b/apps/dashboard/src/components/journal-browser.tsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query" +import { useQuery, useQueryClient } from "@tanstack/react-query" import { getRouteApi, useNavigate } from "@tanstack/react-router" import type { ColumnDef } from "@tanstack/react-table" import { ArrowDownToLineIcon, EyeIcon, PauseIcon, PlayIcon } from "lucide-react" @@ -41,6 +41,23 @@ const journalPriorityItems = [ { value: "5..7", label: "Notice–Debug" }, ] +const priorities = [ + "Emergency", + "Alert", + "Critical", + "Error", + "Warning", + "Notice", + "Info", + "Debug", +] +const entryKey = (entry: LogEntry) => + entry.id ?? + `${entry.timestamp}:${entry.unit}:${entry.priority}:${entry.message}` +const uniqueEntries = (entries: LogEntry[]) => [ + ...new Map(entries.map((entry) => [entryKey(entry), entry])).values(), +] + const columns: ColumnDef[] = [ { accessorKey: "timestamp", @@ -51,7 +68,11 @@ const columns: ColumnDef[] = [ accessorKey: "priority", header: "Priority", cell: ({ row }) => ( - {row.original.priority || "-"} + + {priorities[Number(row.original.priority)] ?? "Unknown"} + ), }, { accessorKey: "unit", header: "Unit" }, @@ -142,11 +163,17 @@ export function JournalBrowser() { setDraft((current) => ({ ...current, executable: value })) const setText = (value: string) => setDraft((current) => ({ ...current, text: value })) + const [advanced, setAdvanced] = React.useState(false) + const [connection, setConnection] = React.useState("Connecting") + const [reading, setReading] = React.useState(false) + const readingRef = React.useRef(false) const [details, setDetails] = React.useState(false) const [cursor, setCursor] = React.useState("") const [following, setFollowing] = React.useState(true) const [atLatest, setAtLatest] = React.useState(true) const [pendingLive, setPendingLive] = React.useState(0) + const queryClient = useQueryClient() + const [scrollVersion, setScrollVersion] = React.useState(0) const [live, setLive] = React.useState([]) const [selected, setSelected] = React.useState(null) const atLatestRef = React.useRef(true) @@ -166,10 +193,12 @@ export function JournalBrowser() { details, } const query = useQuery({ + staleTime: 0, queryKey: ["logs", filterValues, cursor], - queryFn: () => + queryFn: ({ signal }) => api( - `/logs?${makeParams({ ...filterValues, cursor }).toString()}` + `/logs?${makeParams({ ...filterValues, cursor }).toString()}`, + { signal } ), }) const streamParams = makeParams(filterValues).toString() @@ -224,27 +253,50 @@ export function JournalBrowser() { React.useEffect(() => { if (!following || typeof EventSource === "undefined") return const source = new EventSource(`/api/v1/logs/stream?${streamParams}`) + let queue: LogEntry[] = [] + source.addEventListener("open", () => setConnection("Live")) + source.addEventListener("error", () => setConnection("Reconnecting")) source.addEventListener("log", (event) => { try { const entry = JSON.parse( (event as MessageEvent).data ) as LogEntry - if (!atLatestRef.current) { + if (!entry.timestamp || typeof entry.message !== "string") return + if (!atLatestRef.current || readingRef.current) { setPendingLive((current) => Math.min(current + 1, 500)) return } - setLive((current) => [entry, ...current].slice(0, 500)) + queue.push(entry) + if (queue.length > 500) queue = queue.slice(-500) } catch { - // Ignore malformed stream events; the bounded query remains usable. + /* Invalid events do not replace the last valid snapshot. */ } }) - return () => source.close() + const timer = window.setInterval(() => { + if (!queue.length) return + if (readingRef.current || !atLatestRef.current) { + setPendingLive((current) => Math.min(current + queue.length, 500)) + queue = [] + return + } + const batch = queue.reverse() + queue = [] + setLive((current) => uniqueEntries([...batch, ...current]).slice(0, 500)) + }, 200) + return () => { + window.clearInterval(timer) + source.close() + } }, [following, streamParams]) const items = atLatest - ? [...live, ...(query.data?.items ?? [])] + ? uniqueEntries([...live, ...(query.data?.items ?? [])]) : (query.data?.items ?? []) const jumpToLatest = () => { + readingRef.current = false + setReading(false) + void queryClient.invalidateQueries({ queryKey: ["logs"] }) + setScrollVersion((current) => current + 1) setLive([]) setPendingLive(0) setCursor("") @@ -285,7 +337,48 @@ export function JournalBrowser() { return ( <> -
+
+
+ + {following ? connection : "Paused"} + {reading ? " · reading history" : ""} + + + + + +
Message contains @@ -297,56 +390,60 @@ export function JournalBrowser() { placeholder="failed" /> - - Boot - setBoot(event.target.value)} - placeholder="current or boot ID" - /> - - - Since (RFC3339) - setSince(event.target.value)} - placeholder="2026-08-13T00:00:00Z" - /> - - - Until (RFC3339) - setUntil(event.target.value)} - placeholder="2026-08-13T23:59:59Z" - /> - - - Unit - setUnit(event.target.value)} - placeholder="worker.service" - /> - - - Executable - setExecutable(event.target.value)} - placeholder="/usr/bin/worker" - /> - + {advanced && ( + <> + + Boot + setBoot(event.target.value)} + placeholder="current or boot ID" + /> + + + Since (RFC3339) + setSince(event.target.value)} + placeholder="2026-08-13T00:00:00Z" + /> + + + Until (RFC3339) + setUntil(event.target.value)} + placeholder="2026-08-13T23:59:59Z" + /> + + + Unit + setUnit(event.target.value)} + placeholder="worker.service" + /> + + + Executable + setExecutable(event.target.value)} + placeholder="/usr/bin/worker" + /> + + + )} Priority + + Detected:{" "} + {network.data?.ownership?.detected?.join(", ") || "kernel-only"} + + + + Action + + Interface Use an interface listed above. - Static address - setAddress(event.target.value)} - placeholder="192.0.2.10/24" - /> - - - Gateway + + Connection profile + setGateway(event.target.value)} - placeholder="192.0.2.1" + id="network-connection" + value={connection} + onChange={(event) => setConnection(event.target.value)} + placeholder="Wired connection 1" /> + + Optional; blank uses the active profile for this interface. + - + {needsAddress && ( + + Addresses + setAddresses(event.target.value)} + placeholder="192.0.2.10/24 2001:db8::10/64" + /> + + )} + {(needsAddress || needsRoute) && ( + + Gateway + setGateway(event.target.value)} + placeholder="192.0.2.1" + /> + + )} + {needsDNS && ( + + DNS servers + setDNS(event.target.value)} + placeholder="1.1.1.1 9.9.9.9" + /> + + )} + {needsRoute && ( + <> + + Route + setRoute(event.target.value)} + placeholder="default or 192.0.2.0/24" + /> + + + Metric + setMetric(event.target.value)} + placeholder="100" + /> + + + )} + Confirmation setConfirmation(event.target.value)} placeholder="CONFIRM NETWORK CHANGE" /> - Required for any mutation. + + Reconnect confirmation is required before commit or rollback. +
+ {pending?.reconnectRequired && ( + + Reconnect checkpoint active + + {pending.warning} + + Deadline:{" "} + {pending.rollbackDeadline + ? new Date(pending.rollbackDeadline).toLocaleString() + : "pending"} + +
+ + +
+
+
+ )} {preview.data && ( Preview ready {preview.data.warning ?? - "Review ownership and reconnect before applying."} + "Review the owner and checkpoint before applying."} )} diff --git a/apps/dashboard/src/components/network-overview.test.tsx b/apps/dashboard/src/components/network-overview.test.tsx new file mode 100644 index 0000000..d27d08b --- /dev/null +++ b/apps/dashboard/src/components/network-overview.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import type { NetworkResponse } from "@/lib/api" +import { State } from "@/lib/page" +import { NetworkDetails, NetworkSummary } from "./network-overview" + +const snapshot: NetworkResponse = { + items: [ + { + name: "eno1", + index: 2, + mtu: 1500, + hardware: "00:11:22:33:44:55", + addresses: ["192.0.2.10/24"], + up: true, + rx: 10, + tx: 20, + manager: "NetworkManager", + owner: "NetworkManager", + }, + ], + addresses: [ + { + interface: "eno1", + address: "192.0.2.10/24", + family: "inet", + scope: "global", + }, + { + interface: "eno1", + address: "2001:db8::10/64", + family: "inet6", + scope: "global", + }, + ], + routes: [ + { + destination: "default", + gateway: "192.0.2.1", + device: "eno1", + metric: 100, + }, + ], + dns: ["192.0.2.53"], + ownership: { + activeOwner: "NetworkManager", + detected: ["NetworkManager"], + conflicted: true, + reason: "NetworkManager and netplan both reported ownership.", + }, +} + +describe("network overview", () => { + it("renders addresses, routes, DNS, ownership, and conflict state", () => { + render( + <> + + + + ) + + expect(screen.getByText("NetworkManager")).toBeTruthy() + expect(screen.getByText("1/1 up")).toBeTruthy() + expect(screen.getByText("1 IPv4 · 1 IPv6")).toBeTruthy() + expect(screen.getByText("192.0.2.10/24")).toBeTruthy() + expect(screen.getAllByText("global")).toHaveLength(2) + expect(screen.getByText("default")).toBeTruthy() + expect(screen.getByText("192.0.2.53")).toBeTruthy() + expect(screen.getByText("Ownership conflict")).toBeTruthy() + expect( + screen.getByText("NetworkManager and netplan both reported ownership.") + ).toBeTruthy() + }) + + it("keeps empty detail states explicit", () => { + render( + + ) + + expect(screen.getByText("No addresses reported.")).toBeTruthy() + expect(screen.getByText("No routes reported.")).toBeTruthy() + expect(screen.getByText("No DNS servers reported.")).toBeTruthy() + }) + + it("renders the shared error state used by the network tabs", () => { + render( + + unreachable + + ) + + expect(screen.getByText("Could not load data")).toBeTruthy() + expect(screen.queryByText("unreachable")).toBeNull() + }) +}) diff --git a/apps/dashboard/src/components/network-overview.tsx b/apps/dashboard/src/components/network-overview.tsx new file mode 100644 index 0000000..fd12ae8 --- /dev/null +++ b/apps/dashboard/src/components/network-overview.tsx @@ -0,0 +1,223 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import type { NetworkResponse } from "@/lib/api" + +function isIPv4Family(family: string) { + const normalized = family.toLowerCase() + return ( + normalized === "inet" || + normalized === "ipv4" || + normalized === "v4" || + normalized.endsWith("4") + ) +} + +function isIPv6Family(family: string) { + const normalized = family.toLowerCase() + return ( + normalized === "inet6" || + normalized === "ipv6" || + normalized === "v6" || + normalized.endsWith("6") + ) +} + +export function NetworkSummary({ snapshot }: { snapshot?: NetworkResponse }) { + const interfaces = snapshot?.items ?? [] + const addresses = snapshot?.addresses ?? [] + const routes = snapshot?.routes ?? [] + const dns = snapshot?.dns ?? [] + const up = interfaces.filter((item) => item.up).length + const ipv4 = addresses.filter((item) => isIPv4Family(item.family)).length + const ipv6 = addresses.filter((item) => isIPv6Family(item.family)).length + const owner = snapshot?.ownership?.activeOwner || "Unknown" + const detected = + snapshot?.ownership?.detected?.join(", ") || "No ownership metadata" + + return ( +
+ + + Network owner +

{owner}

+ Detected: {detected} +
+
+ + + Interfaces +

+ {up}/{interfaces.length} up +

+ + {interfaces.length} device(s) reported + +
+
+ + + IP addresses +

+ {addresses.length} +

+ + {ipv4} IPv4 · {ipv6} IPv6 + +
+
+ + + Routing and DNS +

+ {routes.length} routes +

+ {dns.length} DNS server(s) +
+
+ {snapshot?.ownership?.conflicted && ( + + Ownership conflict + + {snapshot.ownership.reason || + "Multiple network managers reported ownership of this host."} + + + )} +
+ ) +} + +export function NetworkDetails({ snapshot }: { snapshot: NetworkResponse }) { + const addresses = snapshot.addresses ?? [] + const routes = snapshot.routes ?? [] + const dns = snapshot.dns ?? [] + + return ( +
+ + + IP addresses + + Global and interface addresses by family and scope. + + + + {addresses.length > 0 ? ( + + + + Interface + Address + Family + Scope + + + + {addresses.map((item) => ( + + {item.interface} + {item.address} + {item.family} + {item.scope || "—"} + + ))} + +
+ ) : ( +

+ No addresses reported. +

+ )} +
+
+ + + Routes + + Kernel routes, gateways, devices, and metrics. + + + + {routes.length > 0 ? ( + + + + Destination + Gateway + Device + Metric + + + + {routes.map((item, index) => ( + + + {item.destination} + + + {item.gateway || "—"} + + {item.device || "—"} + {item.metric ?? "—"} + + ))} + +
+ ) : ( +

No routes reported.

+ )} +
+
+ + + DNS servers + + Resolver addresses reported by the host. + + + + {dns.length > 0 ? ( +
    + {dns.map((server, index) => ( +
  • + + {server} + +
  • + ))} +
+ ) : ( +

+ No DNS servers reported. +

+ )} +
+
+
+ ) +} diff --git a/apps/dashboard/src/components/pages/host-page.tsx b/apps/dashboard/src/components/pages/host-page.tsx new file mode 100644 index 0000000..a6860d8 --- /dev/null +++ b/apps/dashboard/src/components/pages/host-page.tsx @@ -0,0 +1,289 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import * as React from "react" +import { PowerControls } from "@/components/power-controls" +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 { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { + api, + type HostConfiguration, + type HostConfigurationPreview, + type PowerStatus, + type SessionResponse, +} from "@/lib/api" + +const ntpItems = [ + { value: "enabled", label: "Enabled" }, + { value: "disabled", label: "Disabled" }, +] + +type HostForm = Pick + +const emptyForm: HostForm = { hostname: "", timezone: "UTC", ntpEnabled: true } + +export function HostPage() { + const client = useQueryClient() + const session = useQuery({ + queryKey: ["session"], + queryFn: () => api("/auth/session"), + }) + const admin = useQuery({ + queryKey: ["admin"], + queryFn: () => api<{ administrative: boolean; until: string }>("/admin"), + refetchInterval: 10_000, + }) + const configuration = useQuery({ + queryKey: ["host-config"], + queryFn: () => api("/host/config"), + }) + const power = useQuery({ + queryKey: ["host-power"], + queryFn: () => api("/host/power"), + }) + const [draft, setDraft] = React.useState(null) + const form = + draft ?? + (configuration.data + ? { + hostname: configuration.data.hostname, + timezone: configuration.data.timezone, + ntpEnabled: configuration.data.ntpEnabled, + } + : emptyForm) + + const payload = configuration.data + ? { ...form, expectedFingerprint: configuration.data.fingerprint } + : undefined + const preview = useMutation({ + mutationFn: () => + api("/host/config/preview", { + method: "POST", + body: JSON.stringify(payload), + }), + }) + const update = useMutation({ + mutationFn: () => + api("/host/config", { + method: "PUT", + headers: { "X-CSRF-Token": session.data?.csrfToken ?? "" }, + body: JSON.stringify(payload), + }), + onSuccess: (value) => { + client.setQueryData(["host-config"], value) + setDraft(null) + preview.reset() + }, + }) + + if (configuration.isPending) + return ( +
+ +
+ ) + if (configuration.isError || !configuration.data) + return ( +
+ + Host configuration unavailable + + Hostname and time configuration could not be read. + + +
+ ) + + return ( +
+

+ Preview and apply hostname, timezone, and NTP changes through the + host's system service. Every write is fingerprint-checked and + verified. +

+ {!admin.data?.administrative && ( + + Administrative access required + + Gain Administrative access from the header before applying changes. + + + )} + {(preview.isError || update.isError) && ( + + Host configuration action failed + + {(preview.error || update.error)?.message} + + + )} + {update.isSuccess && ( + + Host configuration updated + + Changes were applied and verified. + + + )} + + +
+
+ Host identity and time + + Current fingerprint{" "} + {configuration.data.fingerprint.slice(0, 12)}… + +
+ + {admin.data?.administrative ? "Administrative" : "Read only"} + +
+
+ + + + Hostname + + setDraft((current) => ({ + ...(current ?? form), + hostname: event.target.value, + })) + } + /> + + A DNS-safe host name; changes affect the local system identity. + + + + Timezone + + setDraft((current) => ({ + ...(current ?? form), + timezone: event.target.value, + })) + } + /> + + Use an IANA timezone such as UTC or Asia/Kolkata. + + + + + Network time synchronization + + + + The system's timedate service controls this setting. + + + + {preview.data && ( + + + {preview.data.stale ? "Refresh required" : "Preview"} + + + {preview.data.stale + ? "Another browser changed this host. Load the current state before applying." + : preview.data.changes.length + ? `Changes: ${preview.data.changes.join(", ")}.` + : "No changes detected."} + + + )} +
+ + +
+
+
+ {power.isPending && } + {power.isError && ( + + Power controls unavailable + + The host power-management adapter could not be read. + + + )} + {power.data && ( + + )} +
+ ) +} diff --git a/apps/dashboard/src/components/pages/jobs-page.tsx b/apps/dashboard/src/components/pages/jobs-page.tsx new file mode 100644 index 0000000..63f3ce6 --- /dev/null +++ b/apps/dashboard/src/components/pages/jobs-page.tsx @@ -0,0 +1,222 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { ClipboardListIcon, DatabaseIcon, XCircleIcon } from "lucide-react" + +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 { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty" +import { Progress } from "@/components/ui/progress" +import { Skeleton } from "@/components/ui/skeleton" +import { api, type DiagnosticJob, type SessionResponse } from "@/lib/api" + +const activeStates = new Set(["pending", "running"]) + +function stateVariant(state: DiagnosticJob["state"]) { + if (state === "succeeded") return "secondary" as const + if (state === "failed" || state === "interrupted") + return "destructive" as const + return "outline" as const +} + +function stateLabel(state: DiagnosticJob["state"]) { + if (state === "interrupted") return "Interrupted — not retried" + return state[0].toUpperCase() + state.slice(1) +} + +function dateLabel(value?: string) { + return value ? new Date(value).toLocaleString() : "Not started" +} + +function JobCard({ + job, + csrfToken, + onCancel, + canceling, +}: { + job: DiagnosticJob + csrfToken: string + onCancel: (id: string) => void + canceling: boolean +}) { + const host = job.result?.host + return ( + + +
+
+ + + + {job.id} · {job.actor} · {dateLabel(job.createdAt)} + +
+ + {stateLabel(job.state)} + +
+
+ +
+
+ {job.message} + + {job.progress}% + +
+ +
+ {job.error &&

{job.error}

} + {host && ( +
+
+

Hostname

+

{host.hostname || "Unknown"}

+
+
+

Operating system

+

{host.operatingSystem}

+
+
+

Kernel

+

{host.kernel}

+
+
+

Capabilities

+

+ {job.result?.capabilities.length ?? 0} detected +

+
+
+ )} + {activeStates.has(job.state) && ( + + )} +
+
+ ) +} + +export function JobsPage() { + const queryClient = useQueryClient() + const session = useQuery({ + queryKey: ["session"], + queryFn: () => api("/auth/session"), + }) + const jobs = useQuery({ + queryKey: ["jobs"], + queryFn: () => api<{ items: DiagnosticJob[] }>("/jobs?limit=50"), + refetchInterval: (jobsQuery) => + jobsQuery.state.data?.items.some((job) => activeStates.has(job.state)) + ? 2000 + : false, + }) + const start = useMutation({ + mutationFn: () => + api<{ job: DiagnosticJob }>("/jobs/host-inventory", { + method: "POST", + headers: { "X-CSRF-Token": session.data?.csrfToken ?? "" }, + body: "{}", + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["jobs"] }), + }) + const cancel = useMutation({ + mutationFn: (id: string) => + api<{ job: DiagnosticJob }>(`/jobs/${id}/cancel`, { + method: "POST", + headers: { "X-CSRF-Token": session.data?.csrfToken ?? "" }, + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["jobs"] }), + }) + + const items = jobs.data?.items ?? [] + return ( +
+
+
+

+ In-memory diagnostics that keep running across navigation. +

+

+ Jobs clear on service restart and are never retried silently. +

+
+ +
+ {start.isError && ( + + Could not start diagnostic job + {start.error.message} + + )} + {jobs.isPending && } + {jobs.isError && ( + + Could not load diagnostic jobs + {jobs.error.message} + + )} + {!jobs.isPending && !jobs.isError && items.length === 0 && ( + + + + + + No diagnostic jobs yet + + Run a host inventory to capture an in-memory, reconnectable + report. + + + + )} + {items.length > 0 && ( +
+ {items.map((job) => ( + cancel.mutate(id)} + canceling={cancel.isPending && cancel.variables === job.id} + /> + ))} +
+ )} +
+ ) +} diff --git a/apps/dashboard/src/components/pages/settings-page.tsx b/apps/dashboard/src/components/pages/settings-page.tsx new file mode 100644 index 0000000..21f4484 --- /dev/null +++ b/apps/dashboard/src/components/pages/settings-page.tsx @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query" + +import { CapabilitySettings } from "@/components/settings/capability-settings" +import { MonitoringSettings } from "@/components/settings/monitoring-settings" +import { + useMonitoringPreference, + useSetMonitoringPreference, +} from "@/hooks/use-monitoring-preference" +import { api, type Capability } from "@/lib/api" + +export function SettingsPage() { + const preference = useMonitoringPreference() + const setPreference = useSetMonitoringPreference() + const capabilities = useQuery({ + queryKey: ["capabilities"], + queryFn: () => api<{ capabilities: Capability[] }>("/capabilities"), + }) + + return ( +
+

+ Browser preferences and host integration capabilities. +

+ void setPreference(value)} + error={preference.isError} + loadError={preference.isError} + pending={preference.isPending} + /> + +
+ ) +} diff --git a/apps/dashboard/src/components/pages/timers-page.tsx b/apps/dashboard/src/components/pages/timers-page.tsx new file mode 100644 index 0000000..61feffc --- /dev/null +++ b/apps/dashboard/src/components/pages/timers-page.tsx @@ -0,0 +1,199 @@ +import { useMutation, useQuery } from "@tanstack/react-query" +import * as React from "react" + +import { TimerForm } from "@/components/timer-form" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { + APIError, + api, + type SessionResponse, + type TimerOperation, + type TimerState, +} from "@/lib/api" + +const initialOperation: TimerOperation = { + action: "create", + scope: "user", + name: "nightly", + description: "Nightly task", + onCalendar: "*-*-* 03:00:00", + command: "/usr/local/bin/backup", + persistent: true, +} + +function errorMessage(error: unknown, fallback: string) { + if (error instanceof APIError) { + if (error.code === "timer-conflict") { + return "This timer changed in another browser. Preview it again before applying." + } + if (error.code === "administrative-access-required") { + return "Gain administrative access before changing a system timer." + } + if (error.code === "user-session-required") { + return "A user session is required for user timers." + } + if (error.message) return error.message + } + return fallback +} + +function TimerStateCard({ state }: { state: TimerState }) { + return ( + + + Current timer state + + {state.timerUnit} and {state.serviceUnit} are managed as one pair. + + + +
+ {state.exists ? "Pair exists" : "No timer files yet"} + {state.enabled ? "Enabled" : "Disabled"} +
+ {state.fingerprint && ( +

+ Fingerprint: {state.fingerprint} +

+ )} + {state.definition?.command && ( +

+ Command: {state.definition.command} +

+ )} +
+
+ ) +} + +export function TimersPage() { + const session = useQuery({ + queryKey: ["session"], + queryFn: () => api("/auth/session"), + }) + const [operation, setOperation] = React.useState(initialOperation) + const preview = useMutation({ + mutationFn: (value: TimerOperation) => + api("/timers/preview", { + method: "POST", + headers: { "X-CSRF-Token": session.data?.csrfToken ?? "" }, + body: JSON.stringify(value), + }), + }) + const apply = useMutation({ + mutationFn: (value: TimerOperation) => + api("/timers", { + method: "POST", + headers: { "X-CSRF-Token": session.data?.csrfToken ?? "" }, + body: JSON.stringify(value), + }), + onSuccess: () => preview.reset(), + }) + const pending = preview.isPending || apply.isPending || session.isPending + const state = apply.data ?? preview.data + + if (session.isPending) { + return ( +
+ + +
+ ) + } + if (session.isError || !session.data) { + return ( +
+ + Timer controls unavailable + + {errorMessage(session.error, "The session could not be loaded.")} + + +
+ ) + } + + return ( +
+
+

+ Systemd timers +

+

+ Create safe timer/service pairs with stale-write protection. No shell + unit text is accepted. +

+
+ {(preview.isError || apply.isError) && ( + + Timer operation failed + + {errorMessage( + preview.error ?? apply.error, + "Refresh the timer state and try again." + )} + + + )} + {!session.data.administrative && operation.scope === "system" && ( + + Read-only system scope + + System timer changes require temporary administrative access. User + scope remains available. + + + )} +
+ + + Timer definition + + Preview reads the current pair. Apply requires its fingerprint for + every existing pair. + + + + apply.mutate(operation)} + disabled={pending} + submitLabel={apply.isPending ? "Applying…" : "Apply timer"} + /> + + + + {state ? ( + + ) : ( + + + Preview + + Preview before applying to catch missing pairs and stale edits. + + + + )} +
+
+ ) +} diff --git a/apps/dashboard/src/components/security-controls.tsx b/apps/dashboard/src/components/security-controls.tsx index 1ee0d5e..fb10191 100644 --- a/apps/dashboard/src/components/security-controls.tsx +++ b/apps/dashboard/src/components/security-controls.tsx @@ -10,6 +10,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" +import { Checkbox } from "@/components/ui/checkbox" import { Field, FieldDescription, @@ -17,7 +18,23 @@ import { FieldLabel, } from "@/components/ui/field" import { Input } from "@/components/ui/input" -import { api, type SecurityStatus, type SessionResponse } from "@/lib/api" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { + api, + type SecurityOperation, + type SecurityStatus, + type SessionResponse, +} from "@/lib/api" + +type SecurityMutation = Exclude export function SecurityControls() { const queryClient = useQueryClient() @@ -29,51 +46,104 @@ export function SecurityControls() { queryKey: ["security"], queryFn: () => api("/security"), }) - const [framework, setFramework] = React.useState<"SELinux" | "AppArmor">( - "SELinux" - ) + const [framework, setFramework] = + React.useState("SELinux") + const [action, setAction] = + React.useState("selinux-boolean") const [booleanName, setBooleanName] = React.useState("") + const [booleanValue, setBooleanValue] = React.useState(false) const [profile, setProfile] = React.useState("") + const [path, setPath] = React.useState("") const [confirmation, setConfirmation] = React.useState("") - const operation = (action: string) => ({ - action, - framework, - boolean: booleanName || undefined, - profile: profile || undefined, - expectedFingerprint: status.data?.fingerprint, - confirmation, - }) + const [previewKey, setPreviewKey] = React.useState("") + const operation = React.useCallback( + (expectedFingerprint = status.data?.fingerprint): SecurityOperation => ({ + action, + framework, + boolean: + action === "selinux-boolean" ? booleanName || undefined : undefined, + value: action === "selinux-boolean" ? booleanValue : undefined, + path: + action === "selinux-restorecon" || action === "apparmor-load" + ? path || undefined + : undefined, + profile: + action === "apparmor-enforce" || action === "apparmor-complain" + ? profile || undefined + : undefined, + expectedFingerprint, + confirmation: confirmation || undefined, + }), + [ + action, + booleanName, + booleanValue, + confirmation, + framework, + path, + profile, + status.data?.fingerprint, + ] + ) + const currentKey = JSON.stringify(operation()) const preview = useMutation({ mutationFn: () => api("/security/preview", { method: "POST", headers: { "X-CSRF-Token": session.data?.csrfToken ?? "" }, - body: JSON.stringify({ action: "inspect", framework }), + body: JSON.stringify(operation()), }), + onSuccess: (value) => + setPreviewKey(JSON.stringify(operation(value.fingerprint))), }) const apply = useMutation({ - mutationFn: (action: string) => + mutationFn: () => api("/security", { method: "POST", headers: { "X-CSRF-Token": session.data?.csrfToken ?? "" }, - body: JSON.stringify(operation(action)), + body: JSON.stringify(operation()), }), - onSuccess: () => - void queryClient.invalidateQueries({ queryKey: ["security"] }), + onSuccess: () => { + setPreviewKey("") + void queryClient.invalidateQueries({ queryKey: ["security"] }) + }, }) + const previewReady = + Boolean(preview.data?.allowed) && + !preview.data?.stale && + previewKey === currentKey + const actionOptions: SecurityMutation[] = + framework === "SELinux" + ? ["selinux-boolean", "selinux-restorecon"] + : ["apparmor-enforce", "apparmor-complain", "apparmor-load"] + const actionLabels: Record< + Exclude, + string + > = { + "selinux-boolean": "Set SELinux boolean", + "selinux-restorecon": "Restore SELinux labels", + "apparmor-enforce": "Enforce AppArmor profile", + "apparmor-complain": "Use AppArmor complain mode", + "apparmor-load": "Load AppArmor profile", + } + const invalidInput = + (action === "selinux-boolean" && !booleanName) || + (action === "selinux-restorecon" && !path) || + ((action === "apparmor-enforce" || action === "apparmor-complain") && + !profile) || + (action === "apparmor-load" && !path) + return ( Security policy - Inspect active policy frameworks and apply one narrow, - fingerprint-checked remediation at a time. + Inspect active policy frameworks, preview one narrow remediation, then + apply only the exact fingerprinted change that was reviewed. - - {status.isPending && ( -
- )} + + {status.isPending && } {status.isError && ( Policy status unavailable @@ -96,7 +166,7 @@ export function SecurityControls() {
{status.data.findings.length > 0 && ( -
+
{status.data.findings.map((finding) => ( @@ -114,39 +184,166 @@ export function SecurityControls() { Framework - - - Capabilities are reported independently. - - - - SELinux boolean - setBooleanName(event.target.value)} - placeholder="httpd_can_network_connect" - /> + + + + + + SELinux + AppArmor + + + - AppArmor profile - setProfile(event.target.value)} - placeholder="usr.sbin.example" - /> + Remediation + + {action === "selinux-boolean" && ( + <> + + + SELinux boolean + + { + setBooleanName(event.target.value) + setPreviewKey("") + }} + placeholder="httpd_can_network_connect" + /> + + Must be present in the reported boolean inventory. + + + + { + setBooleanValue(checked === true) + setPreviewKey("") + }} + /> + + Enable boolean + + + + )} + {action === "selinux-restorecon" && ( + + Path + { + setPath(event.target.value) + setPreviewKey("") + }} + placeholder="/srv/app" + /> + + )} + {(action === "apparmor-enforce" || + action === "apparmor-complain") && ( + + + AppArmor profile + + + + )} + {action === "apparmor-load" && ( + + + Profile path + + { + setPath(event.target.value) + setPreviewKey("") + }} + placeholder="/etc/apparmor.d/usr.sbin.example" + /> + + )} Confirmation @@ -154,7 +351,10 @@ export function SecurityControls() { setConfirmation(event.target.value)} + onChange={(event) => { + setConfirmation(event.target.value) + setPreviewKey("") + }} placeholder="CONFIRM NARROW SECURITY CHANGE" /> @@ -163,28 +363,51 @@ export function SecurityControls() {
+ {preview.data && ( + + + {preview.data.stale ? "Preview is stale" : "Preview ready"} + + + {(preview.data.changes ?? []).map((change) => ( + + {change.field}: {change.before ?? "unknown"} →{" "} + {change.after ?? "no change"} + + ))} + {(preview.data.warnings ?? []).map((warning) => ( + + {warning} + + ))} + + + )} {(preview.isError || apply.isError) && ( Remediation rejected - The policy fingerprint, authority, or allowlisted operation did - not pass validation. + The policy fingerprint, authority, path, or allowlisted operation + did not pass validation. )} diff --git a/apps/dashboard/src/components/security-network-selects.test.tsx b/apps/dashboard/src/components/security-network-selects.test.tsx new file mode 100644 index 0000000..3732895 --- /dev/null +++ b/apps/dashboard/src/components/security-network-selects.test.tsx @@ -0,0 +1,149 @@ +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 { FirewallControls } from "./firewall-controls" +import { NetworkControls } from "./network-controls" +import { SecurityControls } from "./security-controls" + +function jsonResponse(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "Content-Type": "application/json" }, + }) +} + +function renderControls() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + + ) +} + +afterEach(() => vi.unstubAllGlobals()) + +describe("security and network selectors", () => { + it("uses shadcn controls without native selects or checkboxes", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const path = String(input) + if (path.endsWith("/auth/session")) { + return Promise.resolve( + jsonResponse({ + user: { username: "test", name: "Test", uid: 1000, gid: 1000 }, + csrfToken: "csrf", + }) + ) + } + if (path.endsWith("/security")) { + return Promise.resolve( + jsonResponse({ + active: "SELinux", + selinux: { mode: "Enforcing" }, + apparmor: { profiles: ["usr.sbin.demo"] }, + findings: [], + fingerprint: "a".repeat(64), + }) + ) + } + if (path.endsWith("/network")) { + return Promise.resolve( + jsonResponse({ items: [], fingerprint: "b".repeat(64) }) + ) + } + return Promise.resolve( + jsonResponse({ + backend: "firewalld", + active: true, + zones: ["public"], + defaultZone: "public", + persistentDefaultZone: "public", + rules: ["public services: ssh"], + runtimeRules: ["public services: ssh"], + persistentRules: ["public services: ssh"], + synchronized: true, + conflicted: false, + readOnly: false, + fingerprint: "c".repeat(64), + }) + ) + }) + ) + + const { container } = renderControls() + + expect( + await screen.findByRole("combobox", { name: "Framework" }) + ).toBeTruthy() + expect(container.querySelectorAll("select")).toHaveLength(0) + expect(container.querySelectorAll('[data-slot="checkbox"]')).toHaveLength(2) + expect( + screen.getByRole("checkbox", { name: /Enable boolean/i }) + ).toBeTruthy() + expect( + screen.getByRole("checkbox", { name: /Persist firewalld/i }) + ).toBeTruthy() + }) + + it("supports keyboard selection and resets dependent security action", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const path = String(input) + if (path.endsWith("/auth/session")) { + return Promise.resolve( + jsonResponse({ + user: { username: "test", name: "Test", uid: 1000, gid: 1000 }, + csrfToken: "csrf", + }) + ) + } + return Promise.resolve( + jsonResponse({ + active: "SELinux", + selinux: { mode: "Enforcing" }, + apparmor: { profiles: ["usr.sbin.demo"] }, + findings: [], + fingerprint: "a".repeat(64), + }) + ) + }) + ) + + const user = userEvent.setup() + render( + + + + ) + + const framework = await screen.findByRole("combobox", { + name: "Framework", + }) + framework.focus() + await user.keyboard("{Enter}") + await user.keyboard("AppArmor") + await user.keyboard("{Enter}") + + expect( + screen.getByRole("combobox", { name: "Remediation" }).textContent + ).toContain("Enforce AppArmor profile") + expect( + screen.getByRole("combobox", { name: "AppArmor profile" }) + ).toBeTruthy() + }) +}) diff --git a/apps/dashboard/src/components/site-header.tsx b/apps/dashboard/src/components/site-header.tsx index ff144ab..990c1ef 100644 --- a/apps/dashboard/src/components/site-header.tsx +++ b/apps/dashboard/src/components/site-header.tsx @@ -4,11 +4,12 @@ import { KeyRoundIcon, LockKeyholeIcon, LogOutIcon, + MonitorIcon, MoonIcon, SunIcon, } from "lucide-react" import * as React from "react" -import { useTheme } from "@/components/theme-provider" +import { type Theme, useTheme } from "@/components/theme-provider" import { Alert, AlertDescription } from "@/components/ui/alert" import { AlertDialog, @@ -25,7 +26,10 @@ import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, + DropdownMenuGroup, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { @@ -48,7 +52,7 @@ export function SiteHeader({ }) { const location = useLocation(), client = useQueryClient(), - { setTheme } = useTheme(), + { theme, setTheme } = useTheme(), [password, setPassword] = React.useState(""), [mfaResponse, setMfaResponse] = React.useState(""), [elevationError, setElevationError] = React.useState(""), @@ -196,15 +200,30 @@ export function SiteHeader({ Toggle theme - setTheme("light")}> - Light - - setTheme("dark")}> - Dark - - setTheme("system")}> - System - + + setTheme(value as Theme)} + > + + Tako Light + + + Tako Dark + + + + Standard Light + + + Standard Dark + + + + System + + + + +
+ {selected && ( +
+ + {selected.filesystem || "unknown fs"} + + + {selected.mountPoints.length + ? selected.mountPoints.map((mount) => mount.target).join(", ") + : "not mounted"} + +
+ )} + {preview.data && ( + + Preview ready + + {preview.data.warning ?? "Re-read the inventory before applying."} + + + )} + {(preview.isError || apply.isError) && ( + + Storage change unavailable + + Refresh inventory and verify authority, fingerprint, target, and + confirmation before retrying. + + + )} +
+
+ ) +} diff --git a/apps/dashboard/src/components/system-terminal.tsx b/apps/dashboard/src/components/system-terminal.tsx index ea09389..4b43df3 100644 --- a/apps/dashboard/src/components/system-terminal.tsx +++ b/apps/dashboard/src/components/system-terminal.tsx @@ -17,12 +17,22 @@ export function SystemTerminal() { "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontSize: 14, scrollback: 5000, - theme: { - background: "#09090b", - foreground: "#fafafa", - cursor: "#fafafa", - selectionBackground: "#3f3f4680", - }, + }) + const applyTheme = () => { + const styles = getComputedStyle(document.documentElement) + const token = (name: string) => styles.getPropertyValue(name).trim() + terminal.options.theme = { + background: token("--terminal"), + foreground: token("--terminal-foreground"), + cursor: token("--terminal-foreground"), + selectionBackground: token("--terminal-selection"), + } + } + applyTheme() + const themeObserver = new MutationObserver(applyTheme) + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], }) const fit = new FitAddon() terminal.loadAddon(fit) @@ -61,6 +71,7 @@ export function SystemTerminal() { observer.observe(container.current) return () => { + themeObserver.disconnect() observer.disconnect() input.dispose() resize.dispose() @@ -70,8 +81,8 @@ export function SystemTerminal() { }, []) return ( -
-
+
+
Authenticated shell {state}
diff --git a/apps/dashboard/src/components/text-editor.tsx b/apps/dashboard/src/components/text-editor.tsx index fb7e36d..935ada3 100644 --- a/apps/dashboard/src/components/text-editor.tsx +++ b/apps/dashboard/src/components/text-editor.tsx @@ -1,7 +1,8 @@ -import { useMutation, useQuery } from "@tanstack/react-query" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import * as React from "react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" import { Textarea } from "@/components/ui/textarea" import { api, type FileResult } from "@/lib/api" @@ -16,23 +17,30 @@ function encodeContent(value: string) { export function TextEditor({ path, + scope = "home", + onDirtyChange, + size = 0, previewToken, csrfToken, }: { + onDirtyChange?: (dirty: boolean) => 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 ( -
+