From aa7abcae606494031f0192914adb9fad45e84c5e Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Mon, 27 Jul 2026 18:31:12 +0800 Subject: [PATCH 1/4] feat: harden lifecycle and add official release mirror --- .github/dependabot.yml | 4 + .github/workflows/codeql.yml | 8 + .github/workflows/go.yml | 20 +- .github/workflows/mirror-release.yml | 568 +++ .github/workflows/release.yml | 205 +- .github/workflows/shellcheck.yml | 9 +- .github/workflows/stage-release.yml | 164 + CHANGELOG.md | 82 +- README.en.md | 173 +- README.md | 173 +- SECURITY.md | 9 +- cmd/linux-temp-admin/main.go | 10 + cmd/linux-temp-admin/main_test.go | 30 + cmd/lta-release/main.go | 532 ++- cmd/lta-release/main_test.go | 235 ++ deploy/nginx/linux-temp-admin.conf | 72 + docs/releasing.md | 1200 +++++- go.mod | 2 +- internal/audit/audit.go | 164 +- internal/audit/audit_test.go | 241 ++ internal/cli/cli.go | 296 +- internal/cli/cli_e2e_test.go | 162 +- internal/cli/cli_root_test.go | 383 ++ internal/cli/cli_test.go | 1160 +++++- internal/cli/commands.go | 497 ++- internal/cli/invite.go | 487 ++- internal/cli/invite_reuse_root_test.go | 380 +- internal/cli/manage_root_test.go | 9 +- internal/cli/narrow_terminal_test.go | 46 + internal/cli/revoke.go | 270 +- internal/cli/selfcmd.go | 314 +- internal/cli/uninstall.go | 325 +- internal/cli/uninstall_mount_root_test.go | 54 + internal/cli/uninstall_root_test.go | 573 ++- internal/config/config.go | 31 +- internal/executil/executil.go | 156 + internal/executil/executil_test.go | 65 + internal/fsutil/fsutil.go | 319 +- internal/fsutil/fsutil_root_test.go | 40 + internal/fsutil/fsutil_test.go | 248 ++ internal/lifecycle/lock.go | 63 + internal/lifecycle/lock_test.go | 29 + internal/netdetect/netdetect.go | 4 + internal/netdetect/netdetect_test.go | 11 + internal/prefs/prefs.go | 18 +- internal/prefs/prefs_root_test.go | 32 + internal/prefs/prefs_test.go | 59 + internal/registry/record.go | 81 +- internal/registry/record_test.go | 93 +- internal/registry/store.go | 175 +- .../registry/store_durability_root_test.go | 68 + internal/registry/store_root_test.go | 106 + internal/registry/store_test.go | 80 + internal/schedule/orphans.go | 44 +- internal/schedule/orphans_test.go | 49 +- internal/schedule/schedule.go | 293 +- internal/schedule/schedule_test.go | 304 +- internal/schedule/system.go | 212 +- internal/schedule/system_test.go | 252 ++ internal/schedule/valid.go | 198 + internal/schedule/valid_test.go | 371 ++ internal/selfmanage/pubkey.go | 27 +- internal/selfmanage/release_pipeline_test.go | 3455 +++++++++++++++++ internal/selfmanage/release_pubkey.hex | 13 +- internal/selfmanage/selfmanage.go | 931 ++++- internal/selfmanage/selfmanage_root_test.go | 342 +- internal/selfmanage/selfmanage_test.go | 692 +++- internal/sshdconf/sshdconf.go | 551 ++- internal/sshdconf/sshdconf_root_test.go | 170 +- internal/sshdconf/sshdconf_test.go | 414 +- internal/sshkey/sshkey.go | 141 +- internal/sshkey/sshkey_root_test.go | 82 +- internal/sshkey/sshkey_test.go | 38 + internal/sudoers/sudoers.go | 184 +- internal/sudoers/sudoers_root_test.go | 14 +- internal/sudoers/sudoers_test.go | 162 + internal/sysinfo/sshd.go | 210 +- internal/sysinfo/sshd_test.go | 236 ++ internal/sysinfo/sysinfo.go | 80 +- internal/sysinfo/sysinfo_test.go | 128 +- internal/table/table.go | 19 +- internal/table/table_test.go | 15 + internal/user/user.go | 570 ++- internal/user/user_root_test.go | 10 +- internal/user/user_test.go | 651 +++- internal/validate/validate.go | 116 +- internal/validate/validate_test.go | 64 +- internal/version/version.go | 50 +- internal/version/version_test.go | 8 +- scripts/install.sh | 983 ++++- scripts/mirror-receiver.py | 545 +++ scripts/mirror_receiver_test.py | 281 ++ scripts/offline-sign-release.sh | 296 ++ scripts/prepare-release.sh | 435 +++ scripts/publish-release.sh | 688 ++++ scripts/release.sh | 42 +- scripts/sign-release.sh | 108 +- 97 files changed, 23062 insertions(+), 1947 deletions(-) create mode 100644 .github/workflows/mirror-release.yml create mode 100644 .github/workflows/stage-release.yml create mode 100644 cmd/linux-temp-admin/main_test.go create mode 100644 cmd/lta-release/main_test.go create mode 100644 deploy/nginx/linux-temp-admin.conf create mode 100644 internal/cli/narrow_terminal_test.go create mode 100644 internal/cli/uninstall_mount_root_test.go create mode 100644 internal/executil/executil.go create mode 100644 internal/executil/executil_test.go create mode 100644 internal/prefs/prefs_root_test.go create mode 100644 internal/prefs/prefs_test.go create mode 100644 internal/registry/store_durability_root_test.go create mode 100644 internal/registry/store_test.go create mode 100644 internal/schedule/valid.go create mode 100644 internal/schedule/valid_test.go create mode 100644 internal/selfmanage/release_pipeline_test.go create mode 100644 scripts/mirror-receiver.py create mode 100644 scripts/mirror_receiver_test.py create mode 100755 scripts/offline-sign-release.sh create mode 100755 scripts/prepare-release.sh create mode 100755 scripts/publish-release.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7a6d439..b65d304 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,8 @@ updates: directory: / schedule: interval: weekly + cooldown: + default-days: 7 commit-message: prefix: ci groups: @@ -19,6 +21,8 @@ updates: directory: / schedule: interval: weekly + cooldown: + default-days: 7 commit-message: prefix: build groups: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6c80831..42886b8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -14,13 +14,21 @@ on: permissions: contents: read +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + jobs: analyze: + name: Analyze runs-on: ubuntu-latest permissions: + contents: read security-events: write # upload results to the Security tab steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.26.x' diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index cf496ad..fa5d0ce 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -7,15 +7,24 @@ on: permissions: contents: read +concurrency: + group: go-${{ github.ref }} + cancel-in-progress: true + jobs: build-test: + name: Build and test runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.26.x' check-latest: true + - name: Mirror receiver policy tests + run: python3 -B -m unittest -v scripts/mirror_receiver_test.py - name: Vet # -printf.funcs also checks our concatenating log helpers (printf/errorf/warnf) run: go vet -printf.funcs=printf,errorf,warnf ./... @@ -29,9 +38,12 @@ jobs: fi integration: + name: Root integration runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.26.x' @@ -40,12 +52,15 @@ jobs: run: sudo -E env "PATH=$PATH" go test -race -tags integration ./... static-cross-build: + name: Static cross-build (${{ matrix.goarch }}) runs-on: ubuntu-latest strategy: matrix: goarch: [amd64, arm64] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.26.x' @@ -58,9 +73,12 @@ jobs: run: go build -tags osusergo,netgo -ldflags '-s -w' -o /dev/null ./cmd/linux-temp-admin govulncheck: + name: Vulnerability scan runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '1.26.x' @@ -71,5 +89,5 @@ jobs: # 1.26.x, while go.mod pins the current security floor) or by an explicit, # documented suppression. run: | - go install golang.org/x/vuln/cmd/govulncheck@latest + go install golang.org/x/vuln/cmd/govulncheck@v1.6.0 "$(go env GOPATH)/bin/govulncheck" ./... diff --git a/.github/workflows/mirror-release.yml b/.github/workflows/mirror-release.yml new file mode 100644 index 0000000..f75a5f5 --- /dev/null +++ b/.github/workflows/mirror-release.yml @@ -0,0 +1,568 @@ +name: Mirror signed release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Immutable release tag to mirror, for example v2.8.0 + required: true + type: string + +permissions: + contents: read + +concurrency: + group: linux-temp-admin-release-mirror-stable + cancel-in-progress: false + +jobs: + verify-and-sync: + name: Verify and sync signed release + runs-on: ubuntu-latest + timeout-minutes: 25 + environment: release-mirror + outputs: + stable_updated: ${{ steps.stable.outputs.update_stable }} + version: ${{ steps.release.outputs.version }} + installer_sha256: ${{ steps.prepare.outputs.installer_sha256 }} + env: + LC_ALL: C + GH_HOST: github.com + GH_PROMPT_DISABLED: '1' + TAG: ${{ github.event.release.tag_name || inputs.tag }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + TRUSTED_WORKFLOW_SHA: ${{ github.workflow_sha }} + MIRROR_BASE_URL: https://dl.ll.cd/linux-temp-admin + MIRROR_HOST: ${{ vars.MIRROR_HOST }} + MIRROR_PORT: ${{ vars.MIRROR_PORT }} + MIRROR_USER: ${{ vars.MIRROR_USER }} + MIRROR_ENVIRONMENT_CONFIGURED: ${{ vars.LTA_RELEASE_MIRROR_ENVIRONMENT_CONFIGURED }} + + steps: + - name: Validate deployment configuration + shell: bash + run: | + set -Eeuo pipefail + [[ "$GITHUB_REPOSITORY" == xxvcc/linux-temp-admin ]] + [[ "$DEFAULT_BRANCH" == main ]] + [[ "$GITHUB_WORKFLOW_REF" == "$GITHUB_REPOSITORY/.github/workflows/mirror-release.yml@refs/heads/$DEFAULT_BRANCH" ]] + [[ "$TRUSTED_WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$MIRROR_ENVIRONMENT_CONFIGURED" == true ]] + [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z]+([.-][0-9A-Za-z]+)*))?$ ]] + (( ${#BASH_REMATCH[1]} > 1 || 10#${BASH_REMATCH[1]} >= 2 )) + [[ "$MIRROR_USER" =~ ^[A-Za-z_][A-Za-z0-9._-]{0,31}$ ]] + python3 - "$MIRROR_HOST" "$MIRROR_PORT" <<'PY' + import ipaddress + import re + import sys + + host, port = sys.argv[1:] + if not host or len(host) > 253 or ":" in host or host.endswith("."): + raise SystemExit("invalid MIRROR_HOST") + if re.fullmatch(r"[0-9.]+", host): + try: + if ipaddress.ip_address(host).version != 4: + raise ValueError + except ValueError: + raise SystemExit("invalid MIRROR_HOST") + else: + labels = host.rstrip(".").split(".") + if any(not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label) + for label in labels): + raise SystemExit("invalid MIRROR_HOST") + if (not re.fullmatch(r"[0-9]{1,5}", port) or + not 1 <= int(port) <= 65535 or port != str(int(port))): + raise SystemExit("invalid MIRROR_PORT") + PY + + - name: Require an immutable public release + id: release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -Eeuo pipefail + release_json="$(timeout -k 5 60 gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG")" + jq -e --arg tag "$TAG" ' + .tag_name == $tag and .draft == false and .immutable == true and + (.id | type == "number") and (.published_at | type == "string") + ' <<<"$release_json" >/dev/null || { + echo "release $TAG is not immutable" >&2 + exit 1 + } + version="${TAG#v}" + release_id="$(jq -r '.id' <<<"$release_json")" + published_at="$(jq -r '.published_at' <<<"$release_json")" + [[ "$release_id" =~ ^[1-9][0-9]*$ ]] + python3 - "$published_at" <<'PY' + import datetime + import re + import sys + + value = sys.argv[1] + pattern = r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?Z" + if re.fullmatch(pattern, value) is None: + raise SystemExit("published_at is not canonical UTC RFC3339") + datetime.datetime.fromisoformat(value[:-1] + "+00:00") + PY + printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" + { + printf 'VERSION=%s\n' "$version" + printf 'RELEASE_ID=%s\n' "$release_id" + printf 'PUBLISHED_AT=%s\n' "$published_at" + } >> "$GITHUB_ENV" + + - name: Checkout trusted mirror workflow revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Checkout released tag as untrusted data + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TAG }} + path: released-source + fetch-depth: 1 + persist-credentials: false + + - name: Bind signed tag and trusted release keyring + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -Eeuo pipefail + [[ "$(git rev-parse --verify 'HEAD^{commit}')" == "$TRUSTED_WORKFLOW_SHA" ]] + released_commit="$(git -C released-source rev-parse --verify 'HEAD^{commit}')" + [[ "$released_commit" =~ ^[0-9a-f]{40}$ ]] + installer_entry="$(git -C released-source ls-tree --format='%(objectmode) %(objecttype) %(path)' \ + "$released_commit" -- scripts/install.sh)" + [[ "$installer_entry" == '100644 blob scripts/install.sh' || \ + "$installer_entry" == '100755 blob scripts/install.sh' ]] + [[ -f released-source/scripts/install.sh && ! -L released-source/scripts/install.sh ]] + [[ "$(grep -Fxc 'MIRROR_ROOT=https://dl.ll.cd/linux-temp-admin' released-source/scripts/install.sh)" -eq 1 ]] + [[ "$(grep -Fxc 'GITHUB_RELEASE_ROOT=https://github.com/xxvcc/linux-temp-admin/releases' released-source/scripts/install.sh)" -eq 1 ]] + cmp scripts/install.sh released-source/scripts/install.sh + ref_json="$(timeout -k 5 60 gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG")" + tag_object="$(jq -r '.object.sha' <<<"$ref_json")" + [[ "$(jq -r '.ref' <<<"$ref_json")" == "refs/tags/$TAG" ]] + [[ "$(jq -r '.object.type' <<<"$ref_json")" == tag ]] + [[ "$tag_object" =~ ^[0-9a-f]{40}$ ]] + tag_json="$(timeout -k 5 60 gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_object")" + [[ "$(jq -r '.tag' <<<"$tag_json")" == "$TAG" ]] + [[ "$(jq -r '.object.type + " " + .object.sha' <<<"$tag_json")" == "commit $released_commit" ]] + [[ "$(jq -r '.verification.verified|tostring' <<<"$tag_json")" == true \ + && "$(jq -r '.verification.reason' <<<"$tag_json")" == valid \ + && "$(jq -r '.verification.signature' <<<"$tag_json")" == '-----BEGIN PGP SIGNATURE-----'* ]] + cmp internal/selfmanage/release_pubkey.hex released-source/internal/selfmanage/release_pubkey.hex + ancestry="$(timeout -k 5 60 gh api \ + "repos/$GITHUB_REPOSITORY/compare/$released_commit...$DEFAULT_BRANCH" --jq .status)" + [[ "$ancestry" == identical || "$ancestry" == ahead ]] + { + printf 'RELEASED_COMMIT=%s\n' "$released_commit" + printf 'TAG_OBJECT=%s\n' "$tag_object" + } >> "$GITHUB_ENV" + + - name: Download exact signed release assets + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -Eeuo pipefail + expected=$'SHA256SUMS\nlinux-temp-admin-linux-amd64\nlinux-temp-admin-linux-amd64.sig\nlinux-temp-admin-linux-arm64\nlinux-temp-admin-linux-arm64.sig' + release_json="$(timeout -k 5 60 gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG")" + [[ "$(jq -r '.id' <<<"$release_json")" == "$RELEASE_ID" ]] + [[ "$(jq -r '.immutable|tostring' <<<"$release_json")" == true ]] + actual="$(jq -r '.assets[].name' <<<"$release_json" | LC_ALL=C sort)" + [[ "$actual" == "$expected" ]] || { + diff -u <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") || true + echo "unexpected release asset set" >&2 + exit 1 + } + mkdir -m 0700 rel + timeout -k 10 300 gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir rel + expected_nodes="$(printf '%s\n' "$expected" | sed 's/$/\tf/')" + actual_nodes="$(find rel -mindepth 1 -maxdepth 1 -printf '%f\t%y\n' | LC_ALL=C sort)" + [[ "$actual_nodes" == "$expected_nodes" ]] + while IFS=$'\t' read -r name digest size; do + [[ "$digest" =~ ^sha256:([0-9a-f]{64})$ ]] + expected_digest="${BASH_REMATCH[1]}" + [[ "$size" =~ ^(0|[1-9][0-9]*)$ ]] + [[ "$(stat -c %s -- "rel/$name")" == "$size" ]] + [[ "$(sha256sum "rel/$name" | awk '{print $1}')" == "$expected_digest" ]] + done < <(jq -r '.assets[] | [.name, (.digest // ""), (.size | tostring)] | @tsv' <<<"$release_json") + + - name: Verify checksums and ed25519 signatures + shell: bash + run: | + set -Eeuo pipefail + [[ "$(wc -c < rel/SHA256SUMS)" -gt 0 && "$(wc -c < rel/SHA256SUMS)" -le 1048576 ]] + python3 - rel/SHA256SUMS <<'PY' + import pathlib + import re + import sys + + expected = [ + "linux-temp-admin-linux-amd64", + "linux-temp-admin-linux-amd64.sig", + "linux-temp-admin-linux-arm64", + "linux-temp-admin-linux-arm64.sig", + ] + data = pathlib.Path(sys.argv[1]).read_bytes() + if not data.endswith(b"\n") or b"\x00" in data: + raise SystemExit("SHA256SUMS is not canonical text") + try: + lines = data.decode("ascii").splitlines() + except UnicodeDecodeError as exc: + raise SystemExit("SHA256SUMS is not ASCII") from exc + parsed = [] + for line in lines: + match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9._-]+)", line) + if match is None: + raise SystemExit("SHA256SUMS contains a malformed line") + parsed.append(match.group(2)) + if parsed != expected: + raise SystemExit("SHA256SUMS does not name the exact signed asset set") + PY + for arch in amd64 arm64; do + binary="rel/linux-temp-admin-linux-$arch" + signature="$binary.sig" + [[ -f "$binary" && ! -L "$binary" && -s "$binary" && "$(wc -c < "$binary")" -le 67108864 ]] + [[ -f "$signature" && ! -L "$signature" && "$(wc -c < "$signature")" -eq 64 ]] + done + (cd rel && sha256sum -c --strict SHA256SUMS) + key_dir="$RUNNER_TEMP/release-keys" + install -d -m 0700 "$key_dir" + python3 - internal/selfmanage/release_pubkey.hex "$key_dir" <<'PY' + import base64 + import pathlib + import sys + + source, out_dir = map(pathlib.Path, sys.argv[1:]) + keys = [] + for raw in source.read_text(encoding="ascii").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if len(line) != 64 or any(c not in "0123456789abcdefABCDEF" for c in line): + raise SystemExit("invalid release keyring") + key = bytes.fromhex(line) + if key in keys: + raise SystemExit("duplicate release key") + keys.append(key) + if not keys: + raise SystemExit("empty release keyring") + prefix = bytes.fromhex("302a300506032b6570032100") + for i, key in enumerate(keys, 1): + body = base64.b64encode(prefix + key).decode("ascii") + pem = "-----BEGIN PUBLIC KEY-----\n" + body + "\n-----END PUBLIC KEY-----\n" + (out_dir / f"key-{i}.pem").write_text(pem, encoding="ascii") + PY + verify_signature() { + local binary=$1 signature=$2 verified=0 count=0 key + for key in "$key_dir"/*.pem; do + if openssl pkeyutl -verify -pubin -inkey "$key" -rawin \ + -in "$binary" -sigfile "$signature" >/dev/null 2>&1; then + verified=1 + count=$((count + 1)) + fi + done + [[ "$verified" -eq 1 && "$count" -eq 1 ]] + } + for arch in amd64 arm64; do + verify_signature "rel/linux-temp-admin-linux-$arch" "rel/linux-temp-admin-linux-$arch.sig" + done + printf 'RELEASE_KEY_DIR=%s\n' "$key_dir" >> "$GITHUB_ENV" + + - name: Verify released binaries + shell: bash + run: | + set -Eeuo pipefail + sudo apt-get -o Acquire::Retries=3 update + sudo apt-get -o Acquire::Retries=3 install -y --no-install-recommends binutils qemu-user-static + probe_dir="$(mktemp -d /tmp/linux-temp-admin-release-probe.XXXXXXXXXX)" + chmod 0755 "$probe_dir" + for arch in amd64 arm64; do + binary="rel/linux-temp-admin-linux-$arch" + chmod 0755 "$binary" + headers="$(readelf -W -l "$binary")" + dynamic="$(readelf -W -d "$binary")" + if grep -Eq '(^|[[:space:]])INTERP([[:space:]]|$)' <<<"$headers"; then + echo "$binary contains a dynamic interpreter" >&2 + exit 1 + fi + if grep -Fq '(NEEDED)' <<<"$dynamic"; then + echo "$binary contains a dynamic dependency" >&2 + exit 1 + fi + probe_binary="$probe_dir/linux-temp-admin-linux-$arch" + install -m 0755 "$binary" "$probe_binary" + if [[ "$arch" == amd64 ]]; then + command=("$probe_binary") + else + command=(/usr/bin/qemu-aarch64-static "$probe_binary") + fi + probe_out="$probe_dir/$arch.out" + probe_err="$probe_dir/$arch.err" + if ! timeout -k 2 15 sudo --non-interactive --user=nobody \ + /usr/bin/env -i HOME=/nonexistent PATH=/usr/bin:/bin LC_ALL=C \ + /bin/bash --noprofile --norc -c \ + 'ulimit -c 0 && ulimit -f 2 && exec "$@"' probe \ + "${command[@]}" version >"$probe_out" 2>"$probe_err"; then + cat "$probe_err" >&2 + echo "$binary failed its unprivileged version probe" >&2 + exit 1 + fi + [[ "$(wc -c < "$probe_out")" -le 256 ]] + [[ "$(cat "$probe_out")" == "$VERSION" ]] + done + find "$probe_dir" -mindepth 1 -delete + rmdir "$probe_dir" + + - name: Prepare immutable version directory and manifest + id: prepare + shell: bash + run: | + set -Eeuo pipefail + sh -n released-source/scripts/install.sh + mkdir -p "deploy/$TAG" + install -m 0644 rel/SHA256SUMS rel/*.sig "deploy/$TAG/" + install -m 0755 rel/linux-temp-admin-linux-amd64 rel/linux-temp-admin-linux-arm64 "deploy/$TAG/" + install -m 0644 released-source/scripts/install.sh "deploy/$TAG/install.sh" + installer_sha256="$(sha256sum "deploy/$TAG/install.sh" | awk '{print $1}')" + [[ "$installer_sha256" =~ ^[0-9a-f]{64}$ ]] + printf 'installer_sha256=%s\n' "$installer_sha256" >> "$GITHUB_OUTPUT" + python3 - "$VERSION" "$TAG" "$MIRROR_BASE_URL" "$PUBLISHED_AT" > latest.json <<'PY' + import json + import sys + + version, tag, root, published_at = sys.argv[1:] + json.dump({ + "version": version, + "tag": tag, + "base_url": f"{root}/{tag}", + "published_at": published_at, + }, sys.stdout, ensure_ascii=True, separators=(",", ":")) + sys.stdout.write("\n") + PY + + - name: Configure pinned SSH identity + env: + MIRROR_SSH_KEY: ${{ secrets.MIRROR_SSH_KEY }} + MIRROR_KNOWN_HOSTS: ${{ secrets.MIRROR_KNOWN_HOSTS }} + shell: bash + run: | + set -Eeuo pipefail + [[ -n "$MIRROR_SSH_KEY" && -n "$MIRROR_KNOWN_HOSTS" ]] + [[ "$MIRROR_SSH_KEY" != *$'\r'* && "$MIRROR_KNOWN_HOSTS" != *$'\r'* ]] + [[ -d "$HOME" && ! -L "$HOME" && "$(stat -c %u -- "$HOME")" == "$(id -u)" ]] + if [[ -e "$HOME/.ssh" || -L "$HOME/.ssh" ]]; then + [[ -d "$HOME/.ssh" && ! -L "$HOME/.ssh" ]] + [[ "$(stat -c %u -- "$HOME/.ssh")" == "$(id -u)" ]] + chmod 0700 "$HOME/.ssh" + else + install -d -m 0700 "$HOME/.ssh" + fi + for ssh_file in "$HOME/.ssh/mirror_key" "$HOME/.ssh/known_hosts"; do + [[ ! -e "$ssh_file" && ! -L "$ssh_file" ]] + done + printf '%s\n' "$MIRROR_SSH_KEY" > "$HOME/.ssh/mirror_key" + printf '%s\n' "$MIRROR_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts" + chmod 0600 "$HOME/.ssh/mirror_key" "$HOME/.ssh/known_hosts" + [[ -f "$HOME/.ssh/mirror_key" && ! -L "$HOME/.ssh/mirror_key" ]] + [[ -f "$HOME/.ssh/known_hosts" && ! -L "$HOME/.ssh/known_hosts" ]] + timeout -k 1 5 ssh-keygen -y -f "$HOME/.ssh/mirror_key" /dev/null + known_host="$MIRROR_HOST" + [[ "$MIRROR_PORT" == 22 ]] || known_host="[$MIRROR_HOST]:$MIRROR_PORT" + timeout -k 1 5 ssh-keygen -F "$known_host" -f "$HOME/.ssh/known_hosts" >/dev/null + printf 'RSYNC_RSH=ssh -i %s/.ssh/mirror_key -p %s -o BatchMode=yes -o ConnectTimeout=10 -o ConnectionAttempts=1 -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=%s/.ssh/known_hosts\n' \ + "$HOME" "$MIRROR_PORT" "$HOME" >> "$GITHUB_ENV" + + - name: Upload immutable version without overwriting existing files + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -Eeuo pipefail + release_json="$(timeout -k 5 60 gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG")" + [[ "$(jq -r '.id' <<<"$release_json")" == "$RELEASE_ID" ]] + [[ "$(jq -r '.tag_name' <<<"$release_json")" == "$TAG" ]] + [[ "$(jq -r '.draft|tostring' <<<"$release_json")" == false ]] + [[ "$(jq -r '.immutable|tostring' <<<"$release_json")" == true ]] + ref_json="$(timeout -k 5 60 gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG")" + [[ "$(jq -r '.object.type + " " + .object.sha' <<<"$ref_json")" == "tag $TAG_OBJECT" ]] + tag_json="$(timeout -k 5 60 gh api "repos/$GITHUB_REPOSITORY/git/tags/$TAG_OBJECT")" + [[ "$(jq -r '.tag' <<<"$tag_json")" == "$TAG" ]] + [[ "$(jq -r '.object.type + " " + .object.sha' <<<"$tag_json")" == "commit $RELEASED_COMMIT" ]] + [[ "$(jq -r '.verification.verified|tostring' <<<"$tag_json")" == true \ + && "$(jq -r '.verification.reason' <<<"$tag_json")" == valid ]] + timeout -k 10 300 rsync --archive --checksum --delay-updates --ignore-existing --chmod=D755,F644 \ + "deploy/$TAG/" "$MIRROR_USER@$MIRROR_HOST:$TAG/" + + - name: Verify immutable version through public mirror + shell: bash + run: | + set -Eeuo pipefail + mkdir public-check + for asset in SHA256SUMS linux-temp-admin-linux-amd64 linux-temp-admin-linux-amd64.sig \ + linux-temp-admin-linux-arm64 linux-temp-admin-linux-arm64.sig install.sh; do + curl -q --fail --location --max-redirs 0 --retry 3 --retry-all-errors \ + --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 \ + --max-filesize 67108864 \ + --output "public-check/$asset" \ + "$MIRROR_BASE_URL/$TAG/$asset?run=$GITHUB_RUN_ID" + cmp "deploy/$TAG/$asset" "public-check/$asset" + done + (cd public-check && sha256sum -c --strict SHA256SUMS) + for arch in amd64 arm64; do + verified=0 + count=0 + for key in "$RELEASE_KEY_DIR"/*.pem; do + if openssl pkeyutl -verify -pubin -inkey "$key" -rawin \ + -in "public-check/linux-temp-admin-linux-$arch" \ + -sigfile "public-check/linux-temp-admin-linux-$arch.sig" >/dev/null 2>&1; then + verified=1 + count=$((count + 1)) + fi + done + [[ "$verified" -eq 1 && "$count" -eq 1 ]] + done + + - name: Confirm GitHub Latest immediately before stable update + id: stable + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -Eeuo pipefail + release_json="$(timeout -k 5 60 gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG")" + [[ "$(jq -r '.id' <<<"$release_json")" == "$RELEASE_ID" ]] + [[ "$(jq -r '.immutable|tostring' <<<"$release_json")" == true ]] + latest_tag="$(timeout -k 5 60 gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" + update_stable=false + if [[ "$TAG" == "$latest_tag" ]]; then + update_stable=true + else + echo "mirrored $TAG without changing stable files; GitHub Latest is $latest_tag" + fi + printf 'update_stable=%s\n' "$update_stable" >> "$GITHUB_OUTPUT" + + - name: Publish stable installer + if: steps.stable.outputs.update_stable == 'true' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -Eeuo pipefail + latest_json="$(timeout -k 5 60 gh api "repos/$GITHUB_REPOSITORY/releases/latest")" + [[ "$(jq -r '.id' <<<"$latest_json")" == "$RELEASE_ID" ]] + [[ "$(jq -r '.tag_name' <<<"$latest_json")" == "$TAG" ]] + [[ "$(jq -r '.immutable|tostring' <<<"$latest_json")" == true ]] + timeout -k 10 120 rsync --archive --checksum --delay-updates --chmod=F644 \ + "deploy/$TAG/install.sh" "$MIRROR_USER@$MIRROR_HOST:install.sh" + curl -q --fail --location --max-redirs 0 --retry 3 --retry-all-errors \ + --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 \ + --max-filesize 1048576 \ + --output public-install.sh "$MIRROR_BASE_URL/install.sh?run=$GITHUB_RUN_ID" + cmp "deploy/$TAG/install.sh" public-install.sh + + - name: Publish latest manifest last + if: steps.stable.outputs.update_stable == 'true' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -Eeuo pipefail + latest_release="$(timeout -k 5 60 gh api "repos/$GITHUB_REPOSITORY/releases/latest")" + [[ "$(jq -r '.id' <<<"$latest_release")" == "$RELEASE_ID" ]] + [[ "$(jq -r '.tag_name' <<<"$latest_release")" == "$TAG" ]] + [[ "$(jq -r '.immutable|tostring' <<<"$latest_release")" == true ]] + timeout -k 10 120 rsync --archive --checksum --delay-updates --chmod=F644 \ + latest.json "$MIRROR_USER@$MIRROR_HOST:latest.json" + curl -q --fail --location --max-redirs 0 --retry 3 --retry-all-errors \ + --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 \ + --max-filesize 1048576 \ + --output public-latest.json "$MIRROR_BASE_URL/latest.json?run=$GITHUB_RUN_ID" + cmp latest.json public-latest.json + + - name: Remove SSH identity + if: always() + shell: bash + run: rm -f -- "$HOME/.ssh/mirror_key" "$HOME/.ssh/known_hosts" + + public-install-canary: + name: Public mirror install canary + needs: verify-and-sync + if: needs.verify-and-sync.outputs.stable_updated == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + env: + MIRROR_BASE_URL: https://dl.ll.cd/linux-temp-admin + VERSION: ${{ needs.verify-and-sync.outputs.version }} + INSTALLER_SHA256: ${{ needs.verify-and-sync.outputs.installer_sha256 }} + steps: + - name: Install from the public mirror as a real root client + shell: bash + run: | + set -Eeuo pipefail + installer="$RUNNER_TEMP/install.sh" + destination="/usr/local/lib/linux-temp-admin-mirror-canary-$GITHUB_RUN_ID" + managed_destination=/usr/local/sbin/linux-temp-admin + managed_marker="$RUNNER_TEMP/linux-temp-admin-mirror-canary-owned-$GITHUB_RUN_ID" + curl -q --fail --location --max-redirs 0 --retry 3 --retry-all-errors \ + --proto '=https' --proto-redir '=https' --connect-timeout 10 --max-time 120 \ + --max-filesize 1048576 \ + --output "$installer" "$MIRROR_BASE_URL/install.sh?run=$GITHUB_RUN_ID" + [[ "$INSTALLER_SHA256" =~ ^[0-9a-f]{64}$ ]] + [[ "$(sha256sum "$installer" | awk '{print $1}')" == "$INSTALLER_SHA256" ]] + if canary_output="$(sudo /usr/bin/env -i HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ + DEST="$destination" LTA_RELEASE=latest /bin/sh "$installer" 2>&1)"; then + printf '%s\n' "$canary_output" + else + status=$? + printf '%s\n' "$canary_output" >&2 + exit "$status" + fi + grep -Fqx 'downloaded the complete release set from the official mirror' <<<"$canary_output" + if grep -Fq 'falling back to GitHub' <<<"$canary_output"; then + echo "mirror canary used the GitHub transport fallback" >&2 + exit 1 + fi + [[ "$(sudo "$destination" version)" == "$VERSION" ]] + + [[ ! -e "$managed_destination" && ! -L "$managed_destination" ]] + [[ ! -e "$managed_marker" && ! -L "$managed_marker" ]] + : > "$managed_marker" + if upgrade_output="$(sudo /usr/bin/env -i \ + HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ + "$destination" --lang en upgrade --yes --force 2>&1)"; then + printf '%s\n' "$upgrade_output" + else + status=$? + printf '%s\n' "$upgrade_output" >&2 + exit "$status" + fi + grep -Fq 'downloaded and verified through the official mirror.' <<<"$upgrade_output" + if grep -Fq 'GitHub' <<<"$upgrade_output"; then + echo "self-upgrade canary used the GitHub transport fallback" >&2 + exit 1 + fi + [[ "$(sudo "$managed_destination" version)" == "$VERSION" ]] + sudo rm -f -- "$destination" + + - name: Remove canary binary after failure + if: always() + shell: bash + run: | + set -Eeuo pipefail + managed_marker="$RUNNER_TEMP/linux-temp-admin-mirror-canary-owned-$GITHUB_RUN_ID" + if [[ -f "$managed_marker" && ! -L "$managed_marker" ]]; then + sudo rm -f -- /usr/local/sbin/linux-temp-admin + fi + rm -f -- "$managed_marker" + sudo rm -f -- "/usr/local/lib/linux-temp-admin-mirror-canary-$GITHUB_RUN_ID" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac9ce6e..6ff6903 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,126 +1,203 @@ name: Release -# Build the v2 static binaries + checksums when a version tag is pushed, and -# stage them in a DRAFT GitHub Release. Signing is NOT done here: the ed25519 -# signing key stays offline. The maintainer runs scripts/sign-release.sh locally -# to sign the CI-built binaries and publish the release. See docs/releasing.md. - +# CI produces an unsigned Actions artifact only. A separate workflow_run +# workflow, whose definition comes from the default branch rather than the +# candidate tag, may copy that artifact into a new unsigned draft. on: push: tags: - 'v*' permissions: - contents: write + contents: read concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: - build: + gate-build: + name: Release gate and build runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + outputs: + go_release: ${{ steps.v.outputs.go_release }} + version: ${{ steps.v.outputs.version }} + prerelease: ${{ steps.v.outputs.prerelease }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.26.x' - check-latest: true + go-version: '1.26.5' + check-latest: false + cache: false - - name: Derive version and gate (v2+ tags only) + - name: Derive exact version and gate id: v + shell: bash run: | set -Eeuo pipefail version="${GITHUB_REF_NAME#v}" prerelease=false - echo "version=${version}" >> "$GITHUB_OUTPUT" - if [[ "${GITHUB_REF_NAME}" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)(-([0-9A-Za-z]+([.-][0-9A-Za-z]+)*))?$ ]] \ - && [[ "${BASH_REMATCH[1]}" -ge 2 ]]; then + if [[ "${GITHUB_REF_NAME}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z]+([.-][0-9A-Za-z]+)*))?$ ]] \ + && (( ${#BASH_REMATCH[1]} > 1 || 10#${BASH_REMATCH[1]} >= 2 )); then echo "go_release=true" >> "$GITHUB_OUTPUT" - if [[ -n "${BASH_REMATCH[4]}" ]]; then - prerelease=true - fi + [[ -z "${BASH_REMATCH[4]}" ]] || prerelease=true else echo "go_release=false" >> "$GITHUB_OUTPUT" - echo "::notice::${GITHUB_REF_NAME} is not a v2+ tag; skipping the release build." + echo "::notice::${GITHUB_REF_NAME} is not an exact v2+ release tag; skipping." fi + echo "version=${version}" >> "$GITHUB_OUTPUT" echo "prerelease=${prerelease}" >> "$GITHUB_OUTPUT" - name: Release test gate if: steps.v.outputs.go_release == 'true' + timeout-minutes: 30 + env: + GOCACHE: ${{ runner.temp }}/lta-test-cache + GOMODCACHE: ${{ runner.temp }}/lta-module-cache + GOPATH: ${{ runner.temp }}/lta-go-path + GOTMPDIR: ${{ runner.temp }}/lta-go-tmp + GOPROXY: https://proxy.golang.org + GOSUMDB: sum.golang.org + GONOSUMDB: '' + GOPRIVATE: '' + GONOPROXY: '' + GOINSECURE: '' + GOVCS: '*:off' + GOAUTH: 'off' + GOTELEMETRY: 'off' + GOEXPERIMENT: '' + GOFIPS140: 'off' + GO111MODULE: 'on' run: | set -Eeuo pipefail - go vet -printf.funcs=printf,errorf,warnf ./... - go test -race ./... - sudo -E env "PATH=$PATH" go test -count=1 -race -tags integration ./... + mkdir -p "$GOCACHE" "$GOMODCACHE" "$GOPATH" "$GOTMPDIR" \ + "${RUNNER_TEMP}/lta-root-test-cache" "${RUNNER_TEMP}/lta-root-module-cache" \ + "${RUNNER_TEMP}/lta-root-go-path" "${RUNNER_TEMP}/lta-root-go-tmp" + index_file="$(mktemp)" + git ls-files -s -z > "$index_file" + while IFS= read -r -d '' index_entry; do + mode="${index_entry%% *}" + [[ "$mode" != 120000 && "$mode" != 160000 ]] \ + || { echo "release source must not contain symlinks or submodules" >&2; exit 1; } + done < "$index_file" + env GOROOT= GOENV=off GOTOOLCHAIN=local GOFLAGS= GOWORK=off \ + go vet -mod=readonly -printf.funcs=printf,errorf,warnf ./... + env GOROOT= GOENV=off GOTOOLCHAIN=local GOFLAGS= GOWORK=off \ + go test -mod=readonly -count=1 -race ./... + sudo -E env "PATH=$PATH" GOROOT= GOENV=off GOTOOLCHAIN=local GOFLAGS= GOWORK=off \ + GO111MODULE=on GOEXPERIMENT= GOFIPS140=off GOTELEMETRY=off GOAUTH=off GOVCS='*:off' \ + GOPROXY="$GOPROXY" GOSUMDB="$GOSUMDB" GONOSUMDB= GOPRIVATE= GONOPROXY= GOINSECURE= \ + GOCACHE="${RUNNER_TEMP}/lta-root-test-cache" GOMODCACHE="${RUNNER_TEMP}/lta-root-module-cache" \ + GOPATH="${RUNNER_TEMP}/lta-root-go-path" GOTMPDIR="${RUNNER_TEMP}/lta-root-go-tmp" \ + go test -mod=readonly -count=1 -race -tags integration ./... test -z "$(gofmt -l .)" sh -n scripts/install.sh - bash -n scripts/release.sh scripts/sign-release.sh + bash -n scripts/*.sh + python3 -B -m unittest -v scripts/mirror_receiver_test.py if ! command -v shellcheck >/dev/null 2>&1; then sudo apt-get update sudo apt-get install -y shellcheck fi shellcheck -S warning scripts/*.sh + git diff --exit-code + test -z "$(git status --porcelain --untracked-files=all)" - - name: Build static binaries (amd64, arm64) + - name: Build reproducible static binaries if: steps.v.outputs.go_release == 'true' + timeout-minutes: 15 env: VERSION: ${{ steps.v.outputs.version }} + GOCACHE: ${{ runner.temp }}/lta-release-build-cache + GOMODCACHE: ${{ runner.temp }}/lta-module-cache + GOPATH: ${{ runner.temp }}/lta-go-path + GOTMPDIR: ${{ runner.temp }}/lta-go-tmp + GOPROXY: https://proxy.golang.org + GOSUMDB: sum.golang.org + GONOSUMDB: '' + GOPRIVATE: '' + GONOPROXY: '' + GOINSECURE: '' run: | set -Eeuo pipefail - rm -rf dist && mkdir -p dist + rm -rf dist + mkdir -p dist "$GOCACHE" "$GOMODCACHE" "$GOPATH" "$GOTMPDIR" for arch in amd64 arm64; do - out="dist/linux-temp-admin-linux-${arch}" - CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build \ - -trimpath -tags osusergo,netgo \ + unset GOAMD64 GOARM64 + if [[ "$arch" == amd64 ]]; then + export GOAMD64=v1 + else + export GOARM64=v8.0 + fi + env GOROOT= GOENV=off GOTOOLCHAIN=local GOFLAGS= GOWORK=off GO111MODULE=on \ + GOEXPERIMENT= GOFIPS140=off GOTELEMETRY=off GOAUTH=off GOVCS='*:off' \ + GOPROXY="$GOPROXY" GOSUMDB="$GOSUMDB" GONOSUMDB= GOPRIVATE= GONOPROXY= GOINSECURE= \ + GOCACHE="$GOCACHE" GOMODCACHE="$GOMODCACHE" GOPATH="$GOPATH" GOTMPDIR="$GOTMPDIR" \ + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" \ + go build -mod=readonly -buildvcs=false -trimpath -tags osusergo,netgo \ -ldflags "-s -w -X github.com/xxvcc/linux-temp-admin/internal/buildinfo.Version=${VERSION}" \ - -o "$out" ./cmd/linux-temp-admin - echo "built $out" + -o "dist/linux-temp-admin-linux-${arch}" ./cmd/linux-temp-admin + done + for asset in dist/linux-temp-admin-linux-amd64 dist/linux-temp-admin-linux-arm64; do + [[ -s "$asset" && "$(wc -c < "$asset")" -le 67108864 ]] \ + || { echo "release binary is empty or exceeds the 64 MiB client limit: $asset" >&2; exit 1; } done - ( cd dist && sha256sum linux-temp-admin-linux-* > SHA256SUMS ) - echo "----- SHA256SUMS -----"; cat dist/SHA256SUMS + ( cd dist && sha256sum linux-temp-admin-linux-amd64 linux-temp-admin-linux-arm64 > SHA256SUMS ) - - name: Sanity-check the amd64 binary + - name: Release vulnerability gate if: steps.v.outputs.go_release == 'true' + timeout-minutes: 15 env: - VERSION: ${{ steps.v.outputs.version }} + GOCACHE: ${{ runner.temp }}/lta-vuln-cache + GOMODCACHE: ${{ runner.temp }}/lta-module-cache + GOPATH: ${{ runner.temp }}/lta-go-path + GOTMPDIR: ${{ runner.temp }}/lta-go-tmp + GOBIN: ${{ runner.temp }}/lta-audit-bin + GOPROXY: https://proxy.golang.org + GOSUMDB: sum.golang.org + GONOSUMDB: '' + GOPRIVATE: '' + GONOPROXY: '' + GOINSECURE: '' run: | set -Eeuo pipefail - got="$(./dist/linux-temp-admin-linux-amd64 version)" - [ "$got" = "$VERSION" ] || { echo "version mismatch: got '$got', want '$VERSION'"; exit 1; } - file dist/linux-temp-admin-linux-amd64 | grep -q 'statically linked' \ - || { echo "binary is not statically linked"; exit 1; } - echo "ok: version=$got, statically linked" + mkdir -p "$GOCACHE" "$GOMODCACHE" "$GOPATH" "$GOTMPDIR" "$GOBIN" + env GOROOT= GOENV=off GOTOOLCHAIN=local GOFLAGS= GOWORK=off GO111MODULE=on \ + GOEXPERIMENT= GOFIPS140=off GOTELEMETRY=off GOAUTH=off GOVCS='*:off' \ + GOPROXY="$GOPROXY" GOSUMDB="$GOSUMDB" GONOSUMDB= GOPRIVATE= GONOPROXY= GOINSECURE= \ + GOCACHE="$GOCACHE" GOMODCACHE="$GOMODCACHE" GOPATH="$GOPATH" GOTMPDIR="$GOTMPDIR" GOBIN="$GOBIN" \ + go install golang.org/x/vuln/cmd/govulncheck@v1.6.0 + env GOROOT= GOENV=off GOTOOLCHAIN=local GOFLAGS= GOWORK=off GO111MODULE=on \ + GOEXPERIMENT= GOFIPS140=off GOTELEMETRY=off GOAUTH=off GOVCS='*:off' \ + GOPROXY="$GOPROXY" GOSUMDB="$GOSUMDB" GONOSUMDB= GOPRIVATE= GONOPROXY= GOINSECURE= \ + GOCACHE="$GOCACHE" GOMODCACHE="$GOMODCACHE" GOPATH="$GOPATH" GOTMPDIR="$GOTMPDIR" \ + "$GOBIN/govulncheck" ./... - - name: Stage DRAFT release with unsigned binaries + checksums + - name: Sanity-check amd64 release binary if: steps.v.outputs.go_release == 'true' + timeout-minutes: 2 env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - PRERELEASE: ${{ steps.v.outputs.prerelease }} + VERSION: ${{ steps.v.outputs.version }} run: | set -Eeuo pipefail - if gh release view "$TAG" >/dev/null 2>&1; then - is_draft="$(gh release view "$TAG" --json isDraft --jq '.isDraft')" - if [[ "$is_draft" != "true" ]]; then - echo "release $TAG is already published; refusing to replace public assets" >&2 - exit 1 - fi - echo "draft release $TAG exists; refreshing unsigned assets" - gh release upload "$TAG" \ - dist/linux-temp-admin-linux-amd64 \ - dist/linux-temp-admin-linux-arm64 \ - dist/SHA256SUMS --clobber - else - release_flags=(--draft --title "$TAG" --generate-notes) - if [[ "$PRERELEASE" == "true" ]]; then - release_flags+=(--prerelease) - fi - gh release create "$TAG" \ - dist/linux-temp-admin-linux-amd64 \ - dist/linux-temp-admin-linux-arm64 \ - dist/SHA256SUMS \ - "${release_flags[@]}" - fi - echo "Draft release staged. Sign & publish locally: LTA_SIGN_KEY=~/.lta/signing.key scripts/sign-release.sh $TAG" + got="$(./dist/linux-temp-admin-linux-amd64 version)" + [[ "$got" == "$VERSION" ]] + file dist/linux-temp-admin-linux-amd64 | grep -q 'statically linked' + + - name: Upload unsigned build artifact + if: steps.v.outputs.go_release == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: unsigned-release-${{ github.ref_name }} + path: | + dist/linux-temp-admin-linux-amd64 + dist/linux-temp-admin-linux-arm64 + dist/SHA256SUMS + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 9820f9d..e926ed6 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -7,12 +7,19 @@ on: permissions: contents: read +concurrency: + group: shellcheck-${{ github.ref }} + cancel-in-progress: true + jobs: shellcheck: + name: ShellCheck runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install ShellCheck run: | @@ -22,7 +29,7 @@ jobs: - name: Shell syntax run: | sh -n scripts/install.sh - bash -n scripts/release.sh scripts/sign-release.sh + bash -n scripts/*.sh - name: ShellCheck run: shellcheck -S warning scripts/*.sh diff --git a/.github/workflows/stage-release.yml b/.github/workflows/stage-release.yml new file mode 100644 index 0000000..b07ed06 --- /dev/null +++ b/.github/workflows/stage-release.yml @@ -0,0 +1,164 @@ +name: Stage Release Draft + +# workflow_run uses this workflow's definition from the default branch. The +# candidate tag can produce data, but cannot rewrite the job that receives the +# contents:write token. +on: + workflow_run: # zizmor: ignore[dangerous-triggers] -- guarded below before any write token is used + workflows: [Release] + types: [completed] + +permissions: + contents: read + +concurrency: + # Draft creation is repository-global. Per-tag groups would allow two + # contents:write jobs to race on release state. + group: stage-release-writer + cancel-in-progress: false + +jobs: + configuration-gate: + name: Require protected release configuration + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.path == '.github/workflows/release.yml' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Require reviewed release-environment configuration + env: + RELEASE_ENVIRONMENT_CONFIGURED: ${{ vars.LTA_RELEASE_ENVIRONMENT_CONFIGURED }} + shell: bash + run: | + set -Eeuo pipefail + [[ "$RELEASE_ENVIRONMENT_CONFIGURED" == true ]] || { + echo "LTA_RELEASE_ENVIRONMENT_CONFIGURED is not true; configure the protected release-staging environment first" >&2 + exit 1 + } + + stage-draft: + name: Stage unsigned release draft + needs: configuration-gate + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: release-staging + permissions: + actions: read # download only the artifact from the validated Release run + contents: write # create the new unsigned draft after all tag gates pass + steps: + - name: Validate triggering tag and commit + env: + GH_TOKEN: ${{ github.token }} + GH_HOST: github.com + GH_PROMPT_DISABLED: '1' + GH_PAGER: 'cat' + GH_REPO: ${{ github.repository }} + TAG: ${{ github.event.workflow_run.head_branch }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + shell: bash + run: | + set -Eeuo pipefail + [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z]+([.-][0-9A-Za-z]+)*))?$ ]] + major="${BASH_REMATCH[1]}" + (( ${#major} > 1 || 10#$major >= 2 )) \ + || { echo "release tags below v2 are not supported" >&2; exit 1; } + [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] + + ref_json="$(timeout -k 5 60 gh api "repos/${GH_REPO}/git/ref/tags/${TAG}")" + tag_object="$(jq -r '.object.sha' <<<"$ref_json")" + [[ "$(jq -r '.object.type' <<<"$ref_json")" == tag ]] + tag_json="$(timeout -k 5 60 gh api "repos/${GH_REPO}/git/tags/${tag_object}")" + [[ "$(jq -r '.tag' <<<"$tag_json")" == "$TAG" ]] + [[ "$(jq -r '.object.type + " " + .object.sha' <<<"$tag_json")" == "commit $HEAD_SHA" ]] + [[ "$(jq -r '.verification.verified|tostring' <<<"$tag_json")" == true \ + && "$(jq -r '.verification.reason' <<<"$tag_json")" == valid \ + && "$(jq -r '.verification.signature' <<<"$tag_json")" == '-----BEGIN PGP SIGNATURE-----'* ]] \ + || { echo "tag must have a valid GitHub-recognized OpenPGP signature" >&2; exit 1; } + + ancestry="$(timeout -k 5 60 gh api "repos/${GH_REPO}/compare/${HEAD_SHA}...main" --jq '.status')" + [[ "$ancestry" == identical || "$ancestry" == ahead ]] \ + || { echo "tag commit is not contained in main" >&2; exit 1; } + + - name: Download gated unsigned artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: unsigned-release-${{ github.event.workflow_run.head_branch }} + path: dist + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Validate transferred artifact + shell: bash + run: | + set -Eeuo pipefail + expected=$'SHA256SUMS\tf\nlinux-temp-admin-linux-amd64\tf\nlinux-temp-admin-linux-arm64\tf' + got="$(find dist -mindepth 1 -printf '%P\t%y\n' | LC_ALL=C sort)" + [[ "$got" == "$expected" ]] + [[ -f dist/SHA256SUMS && ! -L dist/SHA256SUMS \ + && "$(wc -c < dist/SHA256SUMS)" -gt 0 \ + && "$(wc -c < dist/SHA256SUMS)" -le 1048576 ]] \ + || { echo "SHA256SUMS is empty, special, or exceeds the 1 MiB metadata limit" >&2; exit 1; } + for asset in dist/linux-temp-admin-linux-amd64 dist/linux-temp-admin-linux-arm64; do + [[ -f "$asset" && ! -L "$asset" && -s "$asset" && "$(wc -c < "$asset")" -le 67108864 ]] \ + || { echo "release binary is empty, special, or exceeds the 64 MiB client limit: $asset" >&2; exit 1; } + done + [[ "$(awk 'NF {print $2}' dist/SHA256SUMS)" == $'linux-temp-admin-linux-amd64\nlinux-temp-admin-linux-arm64' ]] + ( cd dist && sha256sum -c --strict SHA256SUMS ) + + - name: Create a new unsigned DRAFT release + env: + GH_TOKEN: ${{ github.token }} + GH_HOST: github.com + GH_PROMPT_DISABLED: '1' + GH_PAGER: 'cat' + GH_REPO: ${{ github.repository }} + TAG: ${{ github.event.workflow_run.head_branch }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + shell: bash + run: | + set -Eeuo pipefail + lookup="$(mktemp)" + set +e + timeout -k 5 60 gh api --include "repos/${GH_REPO}/releases/tags/${TAG}" >"$lookup" 2>&1 + lookup_status=$? + set -e + if [[ "$lookup_status" -eq 0 ]]; then + echo "release or draft $TAG already exists; refusing to refresh any remote asset" >&2 + exit 1 + fi + [[ "$lookup_status" -eq 1 ]] \ + || { echo "release lookup failed with unexpected status $lookup_status" >&2; exit 1; } + [[ "$(grep -Ec '^HTTP/[0-9.]+ [0-9]{3}([[:space:]]|$)' "$lookup")" -eq 1 \ + && "$(grep -Ec '^HTTP/[0-9.]+ 404([[:space:]]|$)' "$lookup")" -eq 1 ]] || { + cat "$lookup" >&2 + echo "could not prove release $TAG is absent" >&2 + exit 1 + } + rm -f -- "$lookup" + + # Re-resolve the protected tag immediately before the first write so + # an approval delay cannot make the earlier validation stale. + ref_json="$(timeout -k 5 60 gh api "repos/${GH_REPO}/git/ref/tags/${TAG}")" + tag_object="$(jq -r '.object.sha' <<<"$ref_json")" + [[ "$(jq -r '.object.type' <<<"$ref_json")" == tag ]] + tag_json="$(timeout -k 5 60 gh api "repos/${GH_REPO}/git/tags/${tag_object}")" + [[ "$(jq -r '.tag' <<<"$tag_json")" == "$TAG" ]] + [[ "$(jq -r '.object.type + " " + .object.sha' <<<"$tag_json")" == "commit $HEAD_SHA" ]] + [[ "$(jq -r '.verification.verified|tostring' <<<"$tag_json")" == true \ + && "$(jq -r '.verification.reason' <<<"$tag_json")" == valid \ + && "$(jq -r '.verification.signature' <<<"$tag_json")" == '-----BEGIN PGP SIGNATURE-----'* ]] \ + || { echo "tag signature changed before draft creation" >&2; exit 1; } + ancestry="$(timeout -k 5 60 gh api "repos/${GH_REPO}/compare/${HEAD_SHA}...main" --jq '.status')" + [[ "$ancestry" == identical || "$ancestry" == ahead ]] \ + || { echo "tag commit left main before draft creation" >&2; exit 1; } + flags=(--draft --verify-tag --title "$TAG" --generate-notes) + [[ "$TAG" != *-* ]] || flags+=(--prerelease) + timeout -k 5 600 gh release create "$TAG" dist/linux-temp-admin-linux-amd64 \ + dist/linux-temp-admin-linux-arm64 dist/SHA256SUMS "${flags[@]}" + echo "Unsigned draft only. Continue with the trusted three-phase process in docs/releasing.md." diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc940e..4537edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,86 @@ All notable changes to this project are documented here. +## v2.8.0 - 2026-07-27 + +- Make account creation and revocation fail closed across UID reuse and process + races. New users remain explicitly pending until their UID is durably + registered, all four Linux process credential UIDs are checked, and pidfds + bind termination to the inspected process generation. `doctor` reports pidfd + availability and `invite` refuses to create an account when safe revocation + is unavailable. +- Keep interactive confirmation, release downloads, signature verification, and + candidate probing outside the global lifecycle lock. Destructive operations + rebuild and compare their complete inventory under the lock, while upgrades + recheck the installed version immediately before the atomic commit. +- Bound every privileged helper command by time and output, terminate its whole + process group on failure, use stable locale output where diagnostics are + classified, and cap registry, SSH configuration, installed-binary reads, + registry rewrites, individual audit records, and the retained audit log. +- Upgrade the registry to schema v3 with locked v2 migration, a 16 MiB file + ceiling, strict row width, and rejection of duplicate headers, usernames, and + malformed comments. Every new account, including one without auto-revoke, is + bound to a per-account generation in its GECOS marker; migrated fixed-marker + accounts remain legacy-unverified and are never auto- or bulk-deleted. Status + distinguishes pending, missing, marker-mismatch, UID-mismatch, untrusted-UID, + legacy-unverified, and active identities. +- Harden filesystem durability and uninstall behavior. Atomic writes and + removals fsync the required metadata, uncertain durability is reported, + recursive deletion refuses live mount boundaries, uninstall records its + fail-closed tombstone before removable state is deleted, and changed + inventories require a fresh operator confirmation. +- Validate interactive yes/no answers instead of treating typos as permanent + accounts, and treat EOF at the first-run language prompt as a clean + cancellation instead of entering a second blocking prompt. Validate stored + systemd/at schedules before reporting them healthy, recognize Alpine/musl's + exact GNU `id` missing-user diagnostic, keep key-only accounts usable on + Alpine while password authentication remains impossible, and tighten orphan, + preferences, audit, sudoers, sshd, and SSH-key cleanup. Successful account + deletion helpers are rechecked against the local passwd database, `at` + validation fails closed without a backend, sshd Include traversal has depth, + inode, and aggregate-byte budgets; pacman dependency installation is refused + because Arch requires a deliberate full-system upgrade. Cancelling a persistent + systemd timer also removes its per-unit timestamp under + `/var/lib/systemd/timers`, including during partial-enable rollback; uninstall + also sweeps timestamps stranded by older releases after their accounts and + unit files were already gone. +- Harden self-install and self-upgrade with HTTPS dial-point filtering, bounded + retries and probes, strict signature keyrings, missing-install repair, exact + target metadata checks, and explicit reporting when replacement is visible + but directory durability cannot be proven. Sensitive custom URLs can be read + from a root-only file instead of argv, GitHub-only cache recovery no longer + rewrites signed mirror queries, and the main process disables core dumps + before it handles one-time private keys or passwords. The bootstrap now + requires curl's HTTPS redirect policy, disables core dumps, enforces exact + byte limits across Bash/dash/BusyBox, can pin an exact `LTA_RELEASE` tag, and + refuses to run child commands when a kernel limit cannot be installed. A + verified bootstrap reinstall now delegates the managed-path activation to the + candidate so lifecycle locking and the uninstall tombstone protocol remain + authoritative. +- Make `https://dl.ll.cd/linux-temp-admin` the built-in official source for + normal installation and self-upgrade, with GitHub retained only as a + transport-failure fallback. Each attempted source must provide the complete + `SHA256SUMS`, binary, and detached-signature set; invalid mirror metadata, + checksums, signatures, or candidate versions fail closed without fallback, + and explicit `--url` or `--url-file` upgrades never switch sources. Mirror + endpoints must return canonical files directly without redirects, while the + GitHub fallback retains public HTTPS Release-CDN redirects. +- Replace the online one-step release signer with reproducible online + preparation, descriptor-pinned air-gapped ed25519 signing, and separately + protected publication. Candidate workflows are read-only; a default-branch + receiver stages immutable unsigned drafts, and publication verifies signed + tags, exact signer identities, manifests, remote assets, public downloads, and + monotonic Latest behavior. Trusted phases disable core dumps; clear inherited + proxy, TLS, Git, archive, and GitHub configuration; validate signer, source, + and output ancestry; hash exported source back to its Git blobs; and bound + final removable-media copies, permission changes, hashing, cleanup, and + verification. Exact REST status and command-exit parsing prevents mixed, + timed-out, or killed requests from becoming an empty Latest, while a read-only + resume can no longer demote a release that was already Latest before the run. + Draft staging and manual Latest recovery now accept a REST 404 only with the + GitHub CLI's exact HTTP-error status, and the staging pre-write recheck pins + the tag to a GitHub-recognized OpenPGP signature again. + ## v2.7.3 - Idempotent orphan schedule cleanup - Treat the exact `systemctl disable --now` "unit file does not exist" result as @@ -771,7 +851,7 @@ held up. Everything it did find was in the revoke path, and this release fixes i `Manager.Install` returns whether it wrote, mirroring `Upgrade`'s `("", nil)`. - **The interactive menu drops `install`.** Reaching the menu means a binary is already running as root, so `install` there was either the no-op above or a - one-time bootstrap better done as `sudo ./linux-temp-admin install`. That made + one-time bootstrap better done as `/usr/bin/sudo ./linux-temp-admin install`. That made it look like a duplicate of `upgrade`, which its old label ("Install/update the current binary...") reinforced. `upgrade` is now the menu's single, signature-verified update path; the prompt range follows the table, so entries diff --git a/README.en.md b/README.en.md index a7316c6..b925036 100644 --- a/README.en.md +++ b/README.en.md @@ -33,8 +33,69 @@ It ships as a **single static binary**: zero runtime dependencies, glibc/musl al ## Quick start (30 seconds) ```bash -curl -fsSL https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh | sudo sh -sudo linux-temp-admin invite --sudo +/usr/bin/sudo /usr/bin/env -i \ + HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ + /bin/sh <<'LTA_BOOTSTRAP' && +set -eu +umask 077 +fail() { echo "error: $*" >&2; exit 1; } +ulimit -c 0 || fail "cannot disable core dumps" +[ -d /tmp ] && [ ! -L /tmp ] || fail "/tmp is not a real directory" +tmp_meta=$(stat -Lc '%u %a' -- /tmp) || fail "cannot inspect /tmp" +case "$tmp_meta" in + "0 1"[0-7][0-7][0-7]) ;; + *) fail "/tmp must be root-owned, sticky, and free of special bits other than sticky" ;; +esac + +if ! FSIZE_BLOCK_BYTES=$( + ulimit -f 1 || exit 1 + awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } + END { if (!found) exit 1 }' /proc/self/limits +); then + fail "cannot determine the shell file-size limit unit" +fi +case "$FSIZE_BLOCK_BYTES" in + 512 | 1024) ;; + *) fail "unsupported shell file-size limit unit" ;; +esac +INSTALLER_MAX_BYTES=1048576 +INSTALLER_BLOCKS=$(( (INSTALLER_MAX_BYTES + FSIZE_BLOCK_BYTES - 1) / FSIZE_BLOCK_BYTES )) +installer=$(mktemp /tmp/.lta-bootstrap.XXXXXXXXXX) || fail "cannot create root-owned installer file" +cleanup() { rm -f -- "$installer"; } +trap cleanup 0 +trap 'exit 1' HUP INT TERM +installer_downloaded=0 +for installer_url in \ + https://dl.ll.cd/linux-temp-admin/install.sh \ + https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh +do + installer_download_rc=0 + ( + ulimit -f "$INSTALLER_BLOCKS" || exit 1 + exec timeout -k 5 70 curl -q --fail --silent --show-error --location --max-redirs 0 \ + --connect-timeout 10 --max-time 60 --max-filesize "$INSTALLER_MAX_BYTES" \ + --proto '=https' --proto-redir '=https' \ + --output "$installer" "$installer_url" + ) || installer_download_rc=$? + if [ "$installer_url" = https://dl.ll.cd/linux-temp-admin/install.sh ] && \ + [ "$installer_download_rc" -eq 47 ]; then + fail "official mirror installer redirected; refusing source-policy fallback" + fi + if [ "$installer_download_rc" -eq 0 ]; then + installer_size=$(wc -c < "$installer") || fail "cannot measure installer" + case "$installer_size" in + '' | *[!0-9]*) fail "invalid installer size" ;; + esac + if [ "$installer_size" -gt 0 ] && [ "$installer_size" -le "$INSTALLER_MAX_BYTES" ]; then + installer_downloaded=1 + break + fi + fi +done +[ "$installer_downloaded" -eq 1 ] || fail "installer download failed or exceeded its limit" +/bin/sh "$installer" +LTA_BOOTSTRAP +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo ``` That's it. The tool will: @@ -43,7 +104,7 @@ That's it. The tool will: 2. Print **an invite bundle** — forward it over private chat, and the recipient logs in by running the two commands inside it, **without needing to understand any of this**; 3. Delete that user, its home directory, and its key **automatically after 24 hours** by default. -> Running `sudo linux-temp-admin` with no subcommand opens an interactive menu. The menu is drawn on entry and whenever you press Enter, so each action's result stays on screen above the prompt instead of being scrolled away. The UI is bilingual; see [Language](#language). +> Running `/usr/bin/sudo /usr/local/sbin/linux-temp-admin` with no subcommand opens an interactive menu. The menu is drawn on entry and whenever you press Enter, so each action's result stays on screen above the prompt instead of being scrolled away. The UI is bilingual; see [Language](#language). ## What it solves @@ -57,7 +118,7 @@ Granting someone temporary SSH access usually goes wrong in these ways: This tool standardizes the whole flow: **create → print invite bundle → register → inspect → revoke → auto-delete on expiry**. -It does **not**: store the private key; generate or print any account/sudo password; modify the SSH server configuration; touch the firewall; or open any inbound port. +The default public-key flow does **not** store the private key, generate an account password, or modify sshd configuration. Only an explicit `--password-login` generates and prints an account password once, and only an explicit `--fix-sshd` writes an account-scoped sshd drop-in. The tool never sets a sudo password, touches the firewall, or opens an inbound port. ## Language @@ -77,46 +138,48 @@ Precedence: `--lang zh|en` > the `LINUX_TEMP_ADMIN_LANG` environment variable > **The system locale (`LANG`/`LC_ALL`) is deliberately not consulted.** What language a server was installed in says little about the language of the person holding the invite. So a box with `LANG=en_US.UTF-8` still defaults to Chinese until you choose English. ```bash -sudo linux-temp-admin --lang en invite --sudo # this run only -sudo -E linux-temp-admin invite --sudo # with LINUX_TEMP_ADMIN_LANG=en; note -E, sudo scrubs the environment by default +/usr/bin/sudo /usr/local/sbin/linux-temp-admin --lang en invite --sudo # this run only ``` -A non-interactive run (a script, CI, the auto-revoke timer) has nobody to ask, so it uses the remembered choice or falls back to Chinese; `--lang` and the environment variable always override. +A non-interactive run (a script, CI, the auto-revoke timer) has nobody to ask, so it uses the remembered choice or falls back to Chinese; `--lang` and the environment variable always override. Across sudo, prefer an explicit `--lang` instead of broadly preserving the caller's environment for one language variable. ## Install, upgrade, and doctor -The install script is the recommended path: it must run as root, downloads the latest released binary for your architecture (amd64 / arm64), **verifies its SHA-256 and a detached ed25519 signature against the release key embedded in the script**, and installs it to `/usr/local/sbin/linux-temp-admin` — failing closed on any mismatch (and, when openssl is unavailable, refusing to install unless `LTA_ALLOW_UNVERIFIED=1` is set). Downloads and redirects are HTTPS-only and each response is capped at 64 MiB; use curl, or wget with both `--https-only` and `--max-filesize` support. +The install script is the recommended path: it must run as root and requires curl, OpenSSL 3, sha256sum, and timeout. GitHub CDN fallback also requires either `getent` or `nslookup` so the script can validate and pin a public address before requesting each redirect hop. It downloads the latest released binary for your architecture (amd64 / arm64), **verifies its SHA-256 and detached ed25519 signature against the release keyring embedded in the script**, and installs it to `/usr/local/sbin/linux-temp-admin`. There is no unsigned downgrade path. Downloads and redirects are HTTPS-only, every transfer has a kernel-enforced file-size ceiling and bounded retries, and the verified candidate is probed under time/output limits in an unpredictable file inside a root-safe destination directory before the atomic replacement. For rollback resistance on a first install, add `LTA_RELEASE=vX.Y.Z` to the root-environment assignments on the `/usr/bin/sudo /usr/bin/env -i` command above; the script downloads that exact tag and requires the candidate to report the matching version. + +The compiled-in official release source is `https://dl.ll.cd/linux-temp-admin`. A `latest` install or upgrade reads the mirror index and pins its exact version; an explicitly pinned release goes directly to that tag. It then fetches `SHA256SUMS`, the current-architecture binary, and its signature from one source; mirror and GitHub files are never mixed. Only a **transport failure** such as DNS, TLS, timeout, HTTP, empty/oversized response, or an incomplete download discards that whole set and falls back to GitHub. When a valid mirror index was obtained, the GitHub fallback remains pinned to the same tag. Official mirror URLs must directly return the canonical single-line index, lowercase newline-terminated `SHA256SUMS`, and a raw 64-byte signature; redirects, mirror-index semantics, checksum, ed25519 signature, and candidate-version failures abort immediately without fallback. The GitHub fallback may still follow public HTTPS redirects required by the Release CDN. + +The convenience bootstrap tries the official mirror first and uses raw GitHub only if the installer transfer fails or returns an empty/oversized response; a mirror redirect aborts immediately. It trusts the TLS of the source ultimately used, plus either the mirror's stable-file deployment or GitHub's current `main`. A high-assurance first install should also pin an audited commit, verify the installer hash through an independent channel, and execute a root-owned copy; see the complete procedure in the [release guide](docs/releasing.md#host-install-and-upgrade). + +Run the [root-owned bootstrap in Quick start](#quick-start-30-seconds); it never hands `sudo` a temporary file that the invoking user can replace. Diagnose the completed installation separately: ```bash -curl -fsSL https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh | sudo sh -linux-temp-admin doctor +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor ``` Everyday maintenance: ```bash -sudo linux-temp-admin doctor # check dependencies, sudoers.d, package manager, init system, SSH port -sudo linux-temp-admin upgrade # verify the signature and upgrade the installed command from GitHub -sudo linux-temp-admin upgrade --yes # non-interactive confirmation -sudo linux-temp-admin uninstall # uninstall: accounts, grants, auto-delete tasks, state, command -sudo ./linux-temp-admin install # put the binary in hand into place (note the leading ./) +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor # check dependencies, sudoers.d, package manager, init system, SSH port +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade # prefer the official mirror; redownload from GitHub after transport failure +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade --yes # non-interactive confirmation +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall # uninstall: accounts, grants, auto-delete tasks, state, command +/usr/bin/sudo ./linux-temp-admin install # put the binary in hand into place (note the leading ./) ``` -- **`upgrade`** fetches a new binary from GitHub and installs it only **after the embedded ed25519 public key verifies it** (fail-closed); HTTPS only, capped at 64 MiB, overwrites only when the version is newer. The address actually dialed after a redirect cannot be private or reserved (including documentation, benchmarking, NAT64, and 6to4 ranges). To repair or pin a custom source, use `--force --url URL` (its signature is `URL.sig`). **Use this for routine updates.** -- **`install`** places a binary you **already have** (no network, no signature check) — for an air-gapped host or a self-built binary. It copies the binary inode that is *currently running* through `/proc/self/exe`, so replacing the launch pathname cannot change what root installs. It is only meaningful when you run a copy from elsewhere (`sudo ./linux-temp-admin install`, where the leading `./` is the point). It refuses to overwrite a *different* binary without `--force`. Because auto-delete jobs execute the installed path, an invite refuses an unsafe installed command or one whose version cannot be read; development builds install the exact bytes currently running. +- **`upgrade`** fetches a complete same-version `SHA256SUMS`, binary, and signature set from the official mirror by default, and redownloads the whole set from GitHub only if transport fails; files are never assembled across sources. A manifest-semantic, checksum, ed25519-signature, or candidate-version failure is fail-closed and never triggers fallback. Downloads are HTTPS-only, capped at 64 MiB, use bounded retries for transport failures and 408/425/429/5xx, and overwrite only when the version is newer. The address actually dialed after a redirect cannot be private or reserved (including documentation, benchmarking, NAT64, and 6to4 ranges), and candidate-version probing has time and output limits. Explicit `--url URL` and `--url-file /absolute/path` use only that custom source; no failure silently switches to the official mirror or GitHub. Use `--url URL` for a public custom source (its signature is `URL.sig`). Add `--force` only for an intentional same-version reinstall or downgrade, or to repair a target whose current version cannot be read. A URL containing credentials or signed query parameters must instead be stored in an absolute, root-owned `0600` file and passed with `--url-file`, keeping the secret out of shell history, sudo logs, and `/proc` command lines. The file's first line is the binary URL; an optional second line is an independent signature URL (each may retain its own presigned query). Only the one-line form derives `.sig` from the first line. The GitHub-specific cache bypass is applied only to official Release URLs and never rewrites a custom signed URL. **Use this for routine updates.** +- **`install`** places a binary you **already have** (no network, no signature check) — for an air-gapped host or a self-built binary. It copies the binary inode that is *currently running* through `/proc/self/exe`, so replacing the launch pathname cannot change what root installs. It is only meaningful when you run a copy from elsewhere (`/usr/bin/sudo ./linux-temp-admin install`, where the leading `./` is the point). It refuses to overwrite a *different* binary without `--force`. Even byte-identical content is a no-op only when the target is root:root, exactly `0755`, has no special bits, and its parent is safe; otherwise metadata is atomically repaired. Because auto-delete jobs execute the installed path, an invite refuses an unsafe installed command or one whose version cannot be read; development builds install the exact bytes currently running. ## Full walkthrough ### 1. Install -```bash -curl -fsSL https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh | sudo sh -``` +Use the [root-owned bootstrap above](#quick-start-30-seconds). For a high-assurance first install, use the [commit- and hash-pinned procedure in the release guide](docs/releasing.md#host-install-and-upgrade). ### 2. Create an invite ```bash -sudo linux-temp-admin invite --sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo ``` Interactive mode is short: a locally-detected public IP is used without asking (`--host` overrides for a domain or another address); sudo is granted by default (this is an admin tool — `--no-sudo` makes a plain account); it asks whether to auto-delete on expiry, and **only asks the lifetime when auto-delete is on**. It then shows a summary to confirm before printing the bundle. @@ -134,7 +197,7 @@ User: xxvcc-a1b2c3d4e5 Expires: 2030-01-02 12:00:00 CST Sudo: yes Login: SSH key only (verified against the effective sshd config) -Password login: locked +Password login: disabled Auto revoke: yes Auto revoke unit: linux-temp-admin-v2-revoke-xxvcc-a1b2c3d4e5 Sshd exception: none @@ -169,7 +232,7 @@ They only need two steps, **without installing anything or understanding this to ### 5. Revoke when done (or let it auto-delete on expiry) ```bash -sudo linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 ``` The user, home directory, and key are deleted automatically after 24 hours by default, but **revoking manually as soon as you are done is safest** — do not rely on expiry alone. @@ -179,32 +242,32 @@ The user, home directory, and key are deleted automatically after 24 hours by de Show status (registered temporary users, expiry, auto-delete timer): ```bash -sudo linux-temp-admin status -sudo linux-temp-admin status --user xxvcc-a1b2c3d4e5 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin status +/usr/bin/sudo /usr/local/sbin/linux-temp-admin status --user xxvcc-a1b2c3d4e5 ``` Revoke/delete (pick a number from the list, or name the user): ```bash -sudo linux-temp-admin revoke -sudo linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke +/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 ``` Clean up stale registry rows and orphaned grants: ```bash -sudo linux-temp-admin cleanup-expired --compact +/usr/bin/sudo /usr/local/sbin/linux-temp-admin cleanup-expired --compact ``` -**`uninstall`** removes everything this tool put on the host: the temporary accounts (with their home directories), their sudo grants and sshd exceptions, their auto-delete tasks, the state directory (v1's leftovers included), and — last — the command itself. +**`uninstall`** removes the temporary accounts (with their home directories), their sudo grants and sshd exceptions, their auto-delete tasks, the state directory (v1's leftovers included), and — last — the command itself. The lifecycle lock and uninstall marker deliberately remain so queued old processes cannot recreate state after teardown; the audit log is also retained by default. ```bash -sudo linux-temp-admin uninstall # interactive: shows an inventory, then asks for YES -sudo linux-temp-admin uninstall --yes --remove-users # non-interactive: --remove-users is required when accounts exist -sudo linux-temp-admin uninstall --yes --purge-audit # remove the audit log too +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall # interactive: shows an inventory, then asks for YES +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --remove-users # non-interactive: --remove-users is required when accounts exist +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --purge-audit # remove the audit log too ``` -- **The audit log is kept by default** at `/var/log/linux-temp-admin/audit.log`. It records who opened and closed root-capable accounts; erasing it on the way out is what covering your tracks looks like. `--purge-audit` removes it. +- **The audit log is kept by default** at `/var/log/linux-temp-admin/audit.log`. It records who opened and closed root-capable accounts; erasing it on the way out is what covering your tracks looks like. `--purge-audit` removes it. The logger stops at 64 MiB instead of consuming the filesystem indefinitely; archive or rotate the file when that limit is reached. - **If any account cannot be removed, neither the command nor the state directory is**, and the uninstall stops and names it. Leaving a sudo-capable account behind while deleting the only thing that manages it is worse than not uninstalling: its auto-delete task invokes that very command. - **Uninstalling the command and keeping the accounts is not an option.** `--force` no longer bypasses this; it keeps only its original meaning (remove a target that is not a safe root-owned regular file). - **Running it from a temporary account is refused** — the teardown would reap that account's own session partway through and leave the box half dismantled. Run it as root or another administrator. @@ -218,32 +281,32 @@ sudo linux-temp-admin uninstall --yes --purge-audit # remove the audit log too Set the lifetime in hours (1 to 8760): ```bash -sudo linux-temp-admin invite --sudo --hours 12 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --hours 12 ``` No sudo (create a plain account): ```bash -sudo linux-temp-admin invite --no-sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --no-sudo ``` Set the username prefix / host / port (the prefix allows lowercase letters, digits, underscores, and hyphens, up to 20 characters): ```bash -sudo linux-temp-admin invite --prefix ops --sudo -sudo linux-temp-admin invite --host 203.0.113.10 --port 22 --sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --prefix ops --sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --host 203.0.113.10 --port 22 --sudo ``` Create a permanent account (no expiry, no auto-delete — revoke by hand): ```bash -sudo linux-temp-admin invite --sudo --no-auto-revoke +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --no-auto-revoke ``` **Automation / non-interactive** (CI or scripts). Non-interactive runs must pass `--host`; `--sudo --yes` must re-confirm the username; and when stdout is not a terminal you must explicitly allow printing the private key: ```bash -sudo linux-temp-admin invite \ +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite \ --user xxvcc-a1b2c3d4e5 \ --host 203.0.113.10 --port 22 --hours 24 \ --sudo --install-deps --yes \ @@ -260,15 +323,16 @@ Some servers have key logins switched off (`PubkeyAuthentication no`), or redire **1. Open a door for this one account** (recommended): ```bash -sudo linux-temp-admin invite --sudo --fix-sshd +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --fix-sshd ``` -It writes a drop-in of its own, containing nothing but a `Match User` block: +It writes a dedicated drop-in containing an account-scoped `Match User` block, followed by `Match all` to restore global scope so later files expanded by the same Include glob are not accidentally captured: ```text # /etc/ssh/sshd_config.d/10-linux-temp-admin-xxvcc-a1b2c3d4e5.conf Match User xxvcc-a1b2c3d4e5 PubkeyAuthentication yes +Match all ``` - **The global policy is not edited at all.** Every other account keeps your baseline, byte for byte. @@ -280,7 +344,7 @@ An interactive run asks first. A `--yes` run never asks and never modifies sshd **2. Fall back to a password** (leaves sshd alone): ```bash -sudo linux-temp-admin invite --sudo --password-login +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --password-login ``` It first verifies that sshd really does accept passwords (and refuses otherwise), then issues a 24-character random password, shown once. **This is the weakest grant the tool issues**: the password is brute-forceable from anywhere for the account's whole lifetime and must be delivered in the clear. Prefer `--fix-sshd`. @@ -290,7 +354,7 @@ It first verifies that sshd really does accept passwords (and refuses otherwise) To find out where your server stands before you need an invite: ```bash -sudo linux-temp-admin doctor +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor ``` ## Reference @@ -299,15 +363,18 @@ sudo linux-temp-admin doctor - **Primary**: Debian / Ubuntu, common aaPanel Linux environments, RHEL / Rocky / AlmaLinux / Fedora - **Best effort**: Alpine, Arch Linux +- **Kernel requirement**: Linux 5.3 or newer, with `pidfd_open` and `pidfd_send_signal` allowed by the seccomp/container policy. This is required to avoid signalling an unrelated process after PID reuse; `doctor` probes the live environment and `invite` refuses to create an account when the capability is unavailable. ### Dependencies -The binary itself has no runtime dependencies. It only calls the system's **account-management tools**; when those are missing it can install them interactively (confirm, or pass `--install-deps`) via `apt-get` / `dnf` / `yum` / `apk` / `pacman`: +The binary itself has no runtime dependencies. It only calls the system's **account-management tools**; when those are missing it can install them interactively (confirm, or pass `--install-deps`) via `apt-get` / `dnf` / `yum` / `apk`: - `id`, `useradd` or `adduser`, `userdel` or `deluser`, `usermod`, `chage` - `sudo`: only needed when granting sudo -`doctor` shows **the running version and the installed command's version** (flagging a mismatch — the auto-delete task runs the installed one), checks each of the tools above, plus the package manager, the init system, the safety of `/etc/sudoers.d`, and the detected SSH port, and **rehearses whether a freshly created temporary account could log in by public key** (pointing you at `invite --fix-sshd` when sshd would refuse). It also reports **orphaned sudo grants, sshd exceptions, and auto-delete tasks** (their account gone but the artifact left behind), and accounts set to auto-delete with no task left to do it — pointing you at `cleanup-expired --compact` or `revoke`. +Arch's `pacman` does not support partial upgrades, while the safe `pacman -Syu` upgrades the whole system. This tool therefore never runs pacman automatically while creating an account. Run the prompted `pacman -Syu --needed ...` deliberately first, then retry the invite. + +`doctor` shows **the running version and the installed command's version** (flagging a mismatch — the auto-delete task runs the installed one), checks each of the tools above and the pidfd capability, plus the package manager, the init system, the safety of `/etc/sudoers.d`, and the detected SSH port, and **rehearses whether a freshly created temporary account could log in by public key** (pointing you at `invite --fix-sshd` when sshd would refuse). It also reports **orphaned sudo grants, sshd exceptions, and auto-delete tasks** (the account is absent or its identity is unverified while the artifact remains), and accounts set to auto-delete with no task left to do it — pointing you at `cleanup-expired --compact` or `revoke`. `at` / `atd` is the auto-delete fallback backend for hosts without systemd. It is **not part of the dependency check and is never auto-installed**. @@ -330,30 +397,32 @@ Two host notes: /var/lib/linux-temp-admin/v2/prefs # the remembered UI language (root:root 0600) /var/log/linux-temp-admin/audit.log # operation audit log (root:root 0600, dir 0700) /run/linux-temp-admin.lock # global account/install lifecycle lock +/run/linux-temp-admin.lock.uninstalled # completed-uninstall marker; cleared by an explicit install /etc/systemd/system/linux-temp-admin-v2-revoke-USER.service # with NoNewPrivileges and similar light confinement /etc/systemd/system/linux-temp-admin-v2-revoke-USER.timer /etc/sudoers.d/linux-temp-admin-USER # only when NOPASSWD sudo is enabled -/etc/ssh/sshd_config.d/10-linux-temp-admin-USER.conf # only with --fix-sshd; one Match User block, removed by revoke +/etc/ssh/sshd_config.d/10-linux-temp-admin-USER.conf # only with --fix-sshd; account block plus Match all reset, removed by revoke /home/USER/.ssh/authorized_keys # plus a fallback auto-delete job in the at queue when systemd is unavailable ``` ## Security notes -- The private key is shown once at creation and never stored on the server; the account password is locked by default, and no account/sudo password is ever printed. +- The private key is shown once at creation and never stored on the server. Key accounts use an unmatchable shadow value that disables password authentication without triggering Alpine/OpenSSH's whole-account lock check. Only explicit `--password-login` generates and prints an account password once; the tool never generates a sudo password. - The invite's `Login:` line is **a verified conclusion**: before creating anything, the tool reads `sshd -T -C user=` to confirm the account really can log in, and says `UNVERIFIED` when it cannot read the config or finds connection-scoped `Match` criteria such as `Address`, `Host`, `LocalAddress`, or `LocalPort`. It never asserts a login method it did not check. -- **sshd's global configuration is never edited.** `--fix-sshd` writes a separate drop-in holding a single `Match User` block (no other account's policy changes by one byte); it is syntax-checked with `sshd -t`, proved effective with `sshd -T`, and reloaded (never restarted). Any failure triggers cleanup plus an independent retry by the invite transaction; an inability to remove or restore is surfaced as a rollback failure. `revoke` deletes the drop-in. **An explicit `DenyUsers`/`DenyGroups` rule is never bypassed.** +- **sshd's global configuration is never edited.** `--fix-sshd` writes a separate drop-in whose `Match User` block contains only directives needed to lift detected blockers, followed by `Match all` to reset the Include stream's scope; other accounts keep their effective policy. It is syntax-checked with `sshd -t`, proved effective with `sshd -T`, and reloaded (never restarted). Any failure triggers cleanup plus an independent retry by the invite transaction; an inability to remove or restore is surfaced as a rollback failure. `revoke` deletes the drop-in. **An explicit `DenyUsers`/`DenyGroups` rule is never bypassed.** - `--password-login` is the weakest grant available (brute-forceable from anywhere, delivered in the clear). It is opt-in only, and refuses unless sshd is verified to accept passwords. - **NOPASSWD sudo is essentially root.** Grant it only to trusted parties. Revoking deletes the account itself; it does not clean up processes, cron jobs, systemd units, or SUID files that account left behind as root. - Deleting a user also deletes the home directory and SSH key. An SSH home must belong exactly to the target UID and can never be a root/UID-0 directory. If the system's delete command fails, the tool stops and tells you to check manually rather than pretending the revoke succeeded. -- **Guard against accidental deletion**: `revoke` normally requires a registry row, but a row and matching UID are still not identity proof; the current account must also retain the exact managed GECOS marker. A UID, marker, or scheduled generation mismatch refuses or skips deletion. Deleting an unregistered account with the exact marker requires explicit `--force`, plus `--confirm-force USER` when non-interactive. +- **Guard against accidental deletion**: every new invite, including a permanent account with auto-delete disabled, gets an independent random generation embedded in both its exact GECOS marker and registry row. `revoke` normally requires the UID, generation, and marker all to match; any mismatch refuses or skips deletion. The generation is a readable account-incarnation binding, not a secret and not a defense against an attacker who already has root. Deleting an unregistered account with the exact marker requires explicit `--force`, plus `--confirm-force USER` when non-interactive. +- Fixed-GECOS accounts migrated from the v2 registry are reported as `managed=false identity=legacy-unverified`. A same-name, same-UID replacement can copy that old shared marker, so scheduled revocation, bulk cleanup, and uninstall never auto-delete these accounts. Inspect one manually, then invoke `revoke --user USER --force` directly and type the full username (non-interactive use also requires `--yes --confirm-force USER`). - Even with `--force`, it refuses to delete root, well-known system accounts, UID 0, low-UID system accounts, and **any real account that this tool did not create (no exact marker)** — use the system's `userdel` for those. - A failure at any creation step attempts a full rollback of the schedule, sudoers grant, sshd exception, registry row, and newly created account. Any rollback failure is reported and returns nonzero instead of presenting partial success as success. -- Invite, revoke, cleanup, install, upgrade, and uninstall are serialized by one root-owned lifecycle lock outside removable state; their account, schedule, grant, registry, and binary transitions cannot interleave. Usernames are checked through both the local passwd database and NSS before creation, so a local invite cannot shadow an LDAP/SSSD identity. +- The **managed-state commits** of invite, revoke, cleanup, install, upgrade, and uninstall are serialized by one root-owned lifecycle lock outside removable state; account, schedule, grant, registry, and binary transitions cannot interleave. Human confirmation, dependency installation, and upgrade download/signature verification run outside the lock; after acquiring it, the command revalidates the account inventory or installed version before committing, so an interactive or network wait cannot delay an expired revoke. Usernames are checked through both the local passwd database and NSS before creation, so a local invite cannot shadow an LDAP/SSSD identity. - If a sudoers grant or sshd exception cannot be fully removed during revoke, the account and registry row are retained and login is disabled when possible, preventing a surviving name-scoped grant from re-arming after username reuse. Cleanup, registry, and scheduler errors also return nonzero. - The registry strictly validates its schema, fields, UID, and generation token. If it is corrupt or unreadable, `status`, `doctor`, cleanup, revoke, and uninstall fail closed instead of treating "unreadable" as "no accounts." -- Upgrades are HTTPS-only and ed25519-signature-enforced; a verification failure aborts, so an unsigned or mis-signed binary is never installed. -- Every privileged action (account create/delete, install/upgrade/uninstall) is appended as a JSON line to the root-owned `/var/log/linux-temp-admin/audit.log` (time, actor `SUDO_USER`, action, target, result). +- Default upgrades use the official mirror as one complete preferred source and redownload the whole set from GitHub only after a transport failure; manifest-semantic, checksum, signature, or candidate-version failures abort. An explicit custom URL never switches to an official source. +- Every privileged action (account create/delete, install/upgrade/uninstall) is appended as a JSON line to the root-owned `/var/log/linux-temp-admin/audit.log` (time, actor `SUDO_USER`, action, target, result). A record is capped at 64 KiB and the log at 64 MiB; at the limit, the operation continues with a visible audit warning until the operator archives or rotates the file. - When stdout is not a TTY, printing the private key is refused by default; pass `--allow-non-tty-private-key-output` only when the output channel is known to be safe. ## Development & license diff --git a/README.md b/README.md index 17964a4..e623377 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,69 @@ ## 30 秒上手 ```bash -curl -fsSL https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh | sudo sh -sudo linux-temp-admin invite --sudo +/usr/bin/sudo /usr/bin/env -i \ + HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ + /bin/sh <<'LTA_BOOTSTRAP' && +set -eu +umask 077 +fail() { echo "error: $*" >&2; exit 1; } +ulimit -c 0 || fail "cannot disable core dumps" +[ -d /tmp ] && [ ! -L /tmp ] || fail "/tmp is not a real directory" +tmp_meta=$(stat -Lc '%u %a' -- /tmp) || fail "cannot inspect /tmp" +case "$tmp_meta" in + "0 1"[0-7][0-7][0-7]) ;; + *) fail "/tmp must be root-owned, sticky, and free of special bits other than sticky" ;; +esac + +if ! FSIZE_BLOCK_BYTES=$( + ulimit -f 1 || exit 1 + awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } + END { if (!found) exit 1 }' /proc/self/limits +); then + fail "cannot determine the shell file-size limit unit" +fi +case "$FSIZE_BLOCK_BYTES" in + 512 | 1024) ;; + *) fail "unsupported shell file-size limit unit" ;; +esac +INSTALLER_MAX_BYTES=1048576 +INSTALLER_BLOCKS=$(( (INSTALLER_MAX_BYTES + FSIZE_BLOCK_BYTES - 1) / FSIZE_BLOCK_BYTES )) +installer=$(mktemp /tmp/.lta-bootstrap.XXXXXXXXXX) || fail "cannot create root-owned installer file" +cleanup() { rm -f -- "$installer"; } +trap cleanup 0 +trap 'exit 1' HUP INT TERM +installer_downloaded=0 +for installer_url in \ + https://dl.ll.cd/linux-temp-admin/install.sh \ + https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh +do + installer_download_rc=0 + ( + ulimit -f "$INSTALLER_BLOCKS" || exit 1 + exec timeout -k 5 70 curl -q --fail --silent --show-error --location --max-redirs 0 \ + --connect-timeout 10 --max-time 60 --max-filesize "$INSTALLER_MAX_BYTES" \ + --proto '=https' --proto-redir '=https' \ + --output "$installer" "$installer_url" + ) || installer_download_rc=$? + if [ "$installer_url" = https://dl.ll.cd/linux-temp-admin/install.sh ] && \ + [ "$installer_download_rc" -eq 47 ]; then + fail "official mirror installer redirected; refusing source-policy fallback" + fi + if [ "$installer_download_rc" -eq 0 ]; then + installer_size=$(wc -c < "$installer") || fail "cannot measure installer" + case "$installer_size" in + '' | *[!0-9]*) fail "invalid installer size" ;; + esac + if [ "$installer_size" -gt 0 ] && [ "$installer_size" -le "$INSTALLER_MAX_BYTES" ]; then + installer_downloaded=1 + break + fi + fi +done +[ "$installer_downloaded" -eq 1 ] || fail "installer download failed or exceeded its limit" +/bin/sh "$installer" +LTA_BOOTSTRAP +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo ``` 就这样。工具会: @@ -43,7 +104,7 @@ sudo linux-temp-admin invite --sudo 2. 在终端输出**一份邀请包**——私聊发给对方即可,对方照着里面两条命令就能登录,**不需要懂任何细节**; 3. 默认 **24 小时后自动删除**这个用户、家目录和密钥。 -> 不带子命令直接 `sudo linux-temp-admin` 会进入交互菜单。菜单只在进入时和你按回车时显示,所以每次操作的结果都留在提示符上方,不会被菜单顶走。界面中英双语,见下方[语言](#语言)。 +> 不带子命令直接 `/usr/bin/sudo /usr/local/sbin/linux-temp-admin` 会进入交互菜单。菜单只在进入时和你按回车时显示,所以每次操作的结果都留在提示符上方,不会被菜单顶走。界面中英双语,见下方[语言](#语言)。 ## 它解决什么问题 @@ -57,7 +118,7 @@ sudo linux-temp-admin invite --sudo 这个工具把整套流程标准化:**创建 → 输出邀请包 → 登记 → 查看 → 撤销 → 到期自动删**。 -它**不会**:保存私钥;生成或输出任何账号/Sudo 密码;修改 SSH 服务配置;改防火墙;开放任何入站端口。 +默认的密钥登录流程**不会**保存私钥、生成账号密码或修改 sshd 配置;只有显式使用 `--password-login` 才会生成并一次性显示账号密码,只有显式使用 `--fix-sshd` 才会写入仅作用于新账号的 sshd drop-in。工具不会设置 sudo 密码、改防火墙或开放任何入站端口。 ## 语言 @@ -77,46 +138,48 @@ Language / 语言: **系统 locale(`LANG`/`LC_ALL`)不再参与判断**——服务器装的是什么语言,跟拿着邀请的人说什么语言没多大关系。所以一台 `LANG=en_US.UTF-8` 的机器也默认中文,除非你选了英文。 ```bash -sudo linux-temp-admin --lang en invite --sudo # 只影响这一次 -sudo -E linux-temp-admin invite --sudo # 配合 LINUX_TEMP_ADMIN_LANG=en;注意 -E,sudo 默认会清掉环境变量 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin --lang en invite --sudo # 只影响这一次 ``` -非交互运行(脚本、CI、到期自动撤销的定时器)问不了,所以用记住的选择,没有就用中文;`--lang`/环境变量始终可覆盖。 +非交互运行(脚本、CI、到期自动撤销的定时器)问不了,所以用记住的选择,没有就用中文;`--lang`/环境变量始终可覆盖。通过 sudo 调用时优先显式传 `--lang`,不要为一个语言变量宽泛保留调用者环境。 ## 安装、升级与诊断 -推荐用安装脚本:它必须以 root 运行,按架构(amd64 / arm64)下载最新发布的二进制,**校验 SHA-256 并用脚本内嵌的发布公钥验证 ed25519 签名**后再装到 `/usr/local/sbin/linux-temp-admin`——验签失败即中止(openssl 不可用时默认拒装,除非设置 `LTA_ALLOW_UNVERIFIED=1`)。下载只允许 HTTPS(含重定向),单个响应上限 64 MiB;需使用 curl,或同时支持 `--https-only` 与 `--max-filesize` 的 wget。 +推荐用安装脚本:它必须以 root 运行,并要求 curl、OpenSSL 3、sha256sum 和 timeout;GitHub CDN 回退还要求系统至少提供 `getent` 或 `nslookup`,以便在请求每一跳前验证并固定公网地址。脚本按架构(amd64 / arm64)下载最新发布的二进制,**校验 SHA-256 并用脚本内嵌的发布公钥 keyring 验证 ed25519 签名**后再装到 `/usr/local/sbin/linux-temp-admin`。它没有未验签降级路径;下载和重定向只允许 HTTPS,每次下载都由内核文件大小限制硬封顶并做有界重试。验签后的候选会先在 root-safe 目标目录的随机临时文件中受限执行 `version`,成功后才原子替换目标。首次安装需要防回滚时,在上方 `/usr/bin/sudo /usr/bin/env -i` 的根环境赋值中加入 `LTA_RELEASE=vX.Y.Z`;脚本会下载该精确标签并要求候选报告完全一致的版本。 + +内置官方发布源是 `https://dl.ll.cd/linux-temp-admin`。安装或升级 `latest` 时会先读取镜像索引并锁定精确版本;显式固定版本则直接锁定该标签。随后从同一个来源取回 `SHA256SUMS`、当前架构二进制和签名,不会混用镜像与 GitHub 文件。只有 DNS、TLS、超时、HTTP、空/超限响应或下载不完整等**传输故障**才会丢弃这一整套下载并回退 GitHub;已有有效镜像索引时,GitHub 回退仍固定到同一标签。官方镜像 URL 必须直接返回规范单行索引、小写换行结尾的 `SHA256SUMS` 和 64 字节原始签名,不允许重定向;重定向、镜像索引语义、校验和、ed25519 签名或候选版本校验失败都立即中止,绝不回退。GitHub 回退仍允许 Release CDN 所需的公网 HTTPS 重定向。 + +上面的便利安装脚本先使用官方镜像;只有脚本传输失败、空响应或超限时才尝试 raw GitHub,镜像发生重定向则立即终止。它信任最终所用来源的 TLS,以及镜像稳定文件部署或 GitHub 当前 `main`。高保证首次安装还应固定审计过的 commit、通过独立渠道核对安装脚本哈希,并从 root 独占副本执行;完整流程见 [发版文档](docs/releasing.md#host-install-and-upgrade)。 + +执行[上方 30 秒上手](#30-秒上手)中的 root-owned bootstrap;它不会把当前用户可替换的临时文件交给 `sudo`。安装后可单独诊断: ```bash -curl -fsSL https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh | sudo sh -linux-temp-admin doctor +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor ``` 常用维护命令: ```bash -sudo linux-temp-admin doctor # 检查依赖、sudoers.d、包管理器、init 系统、SSH 端口 -sudo linux-temp-admin upgrade # 从 GitHub 验签升级已安装的命令 -sudo linux-temp-admin upgrade --yes # 非交互确认 -sudo linux-temp-admin uninstall # 卸载:账号、授权、自动删除任务、状态与命令 -sudo ./linux-temp-admin install # 把手头这个二进制装到位(注意前面的 ./) +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor # 检查依赖、sudoers.d、包管理器、init 系统、SSH 端口 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade # 官方镜像优先,传输失败时从 GitHub 重新下载 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade --yes # 非交互确认 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall # 卸载:账号、授权、自动删除任务、状态与命令 +/usr/bin/sudo ./linux-temp-admin install # 把手头这个二进制装到位(注意前面的 ./) ``` -- **升级 `upgrade`**:从 GitHub 取回新二进制,用内嵌 ed25519 公钥验签通过才安装(fail-closed,验签不过就中止);只接受 HTTPS、下载上限 64 MiB、仅版本更新时才覆盖。重定向后的实际拨号地址不能是私网或保留地址(含文档、基准测试、NAT64、6to4 等范围)。需要修复或指定自定义来源时用 `--force --url URL`(其签名为 `URL.sig`)。**日常更新用它。** -- **安装 `install`**:把你**手头已有**的二进制放到位(不联网、不验签),用于离线机器或自建二进制。目标已存在且内容不同时需显式 `--force`。它通过 `/proc/self/exe` 复制当前正在执行的二进制 inode,启动路径随后被替换也不会改变 root 实际安装的内容;因此只在你运行别处副本时才有意义(如 `sudo ./linux-temp-admin install`,前面的 `./` 是关键)。自动删除任务执行安装路径,因此邀请前会拒绝不安全、不可读取版本的已安装命令;开发版会安装当前运行文件的精确字节。 +- **升级 `upgrade`**:默认从官方镜像取回同一版本的完整 `SHA256SUMS`、二进制和签名套件,只有整套传输失败才从 GitHub 重新下载;文件不会跨来源拼接。索引语义、校验和、ed25519 签名或候选版本失败会 fail closed,不触发回退。下载只接受 HTTPS、上限 64 MiB,对传输错误及 408/425/429/5xx 做有界重试,仅版本更新时才覆盖;重定向后的实际拨号地址不能是私网或保留地址(含文档、基准测试、NAT64、6to4 等范围),候选版本探测也有时间和输出上限。显式使用 `--url URL` 或 `--url-file /absolute/path` 时只访问该自定义来源,任何失败都不会静默切换到官方镜像或 GitHub。公开自定义来源使用 `--url URL`(其签名为 `URL.sig`);只有明确要同版重装、降级,或在无法读取现有版本时修复目标,才同时使用 `--force`。含凭据或签名查询参数的 URL 必须放进 root 所有、`0600` 的文件,并使用 `--url-file`,避免秘密出现在 shell 历史、sudo 日志或 `/proc` 命令行中。该文件第一行是二进制 URL;可选第二行是独立的签名 URL(两行都可保留各自的预签名 query),只有单行格式才从第一行派生 `.sig`。GitHub 专用的缓存绕过只用于官方 Release URL,不会改写自定义签名 URL。**日常更新用它。** +- **安装 `install`**:把你**手头已有**的二进制放到位(不联网、不验签),用于离线机器或自建二进制。目标已存在且内容不同时需显式 `--force`。它通过 `/proc/self/exe` 复制当前正在执行的二进制 inode,启动路径随后被替换也不会改变 root 实际安装的内容;因此只在你运行别处副本时才有意义(如 `/usr/bin/sudo ./linux-temp-admin install`,前面的 `./` 是关键)。即使目标字节相同,也只有 root:root、精确 `0755`、无特殊位且父目录安全才会 no-op,否则会原子修复元数据。自动删除任务执行安装路径,因此邀请前会拒绝不安全、不可读取版本的已安装命令;开发版会安装当前运行文件的精确字节。 ## 完整流程 ### 1. 安装 -```bash -curl -fsSL https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh | sudo sh -``` +使用[上方 root-owned bootstrap](#30-秒上手);高保证首次安装使用[发版文档中的固定 commit 与哈希流程](docs/releasing.md#host-install-and-upgrade)。 ### 2. 创建邀请 ```bash -sudo linux-temp-admin invite --sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo ``` 交互模式很短:探测到公网 IP 就直接用(`--host` 可改域名/其他地址)、默认授予 sudo(这是个建管理员的工具,`--no-sudo` 可建普通账号)、问是否到期自动删除;**选了自动删除才问有效期**。最后列出摘要让你确认,再输出邀请包。 @@ -134,7 +197,7 @@ User: xxvcc-a1b2c3d4e5 Expires: 2030-01-02 12:00:00 CST Sudo: yes Login: SSH key only (verified against the effective sshd config) -Password login: locked +Password login: disabled Auto revoke: yes Auto revoke unit: linux-temp-admin-v2-revoke-xxvcc-a1b2c3d4e5 Sshd exception: none @@ -169,7 +232,7 @@ chmod 600 './xxvcc-a1b2c3d4e5.key' ### 5. 用完撤销(或等它到期自动删) ```bash -sudo linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 ``` 默认 24 小时后会自动删除用户、家目录和密钥;但**用完立即手动撤销最稳妥**,别只依赖到期兜底。 @@ -179,32 +242,32 @@ sudo linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 查看状态(登记的临时用户、过期时间、自动删除 timer): ```bash -sudo linux-temp-admin status -sudo linux-temp-admin status --user xxvcc-a1b2c3d4e5 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin status +/usr/bin/sudo /usr/local/sbin/linux-temp-admin status --user xxvcc-a1b2c3d4e5 ``` 撤销/删除(从列表选编号,或直接指定用户名): ```bash -sudo linux-temp-admin revoke -sudo linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke +/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 ``` 清理失效登记与孤儿授权: ```bash -sudo linux-temp-admin cleanup-expired --compact +/usr/bin/sudo /usr/local/sbin/linux-temp-admin cleanup-expired --compact ``` -**卸载 `uninstall`**:移除本工具在这台机器上留下的一切——临时账号(连同家目录)、它们的 sudo 授权与 sshd 例外、自动删除任务、状态目录(含 v1 遗留),最后才是命令本身。 +**卸载 `uninstall`**:移除临时账号(连同家目录)、它们的 sudo 授权与 sshd 例外、自动删除任务、状态目录(含 v1 遗留),最后才是命令本身。为串行化并阻止已排队的旧进程在卸载后重新创建状态,生命周期锁和卸载标记会有意保留;审计日志也默认保留。 ```bash -sudo linux-temp-admin uninstall # 交互:先列清单,再输 YES -sudo linux-temp-admin uninstall --yes --remove-users # 非交互:有账号时必须显式加 --remove-users -sudo linux-temp-admin uninstall --yes --purge-audit # 连审计日志一起删 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall # 交互:先列清单,再输 YES +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --remove-users # 非交互:有账号时必须显式加 --remove-users +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --purge-audit # 连审计日志一起删 ``` -- **审计日志默认保留**在 `/var/log/linux-temp-admin/audit.log`。它记录的是谁开过、谁删过 root 级账号;卸载顺手抹掉这份记录,正是入侵者会做的事。要删得显式 `--purge-audit`。 +- **审计日志默认保留**在 `/var/log/linux-temp-admin/audit.log`。它记录的是谁开过、谁删过 root 级账号;卸载顺手抹掉这份记录,正是入侵者会做的事。要删得显式 `--purge-audit`。日志达到 64 MiB 后停止继续写入,避免无限占满文件系统;此时应由管理员归档或轮转该文件。 - **只要有一个账号删不掉,命令和状态目录都不会被删**,卸载中止并点名那个账号。留着一个带 sudo 的账号、却删掉唯一能管理它的命令,比不卸载更糟:它的自动删除任务执行的就是这个命令。 - **不能只删命令、留下账号**。`--force` 不再绕过这一点(它现在只保留原意:目标不是安全的 root 属主普通文件时仍强删)。 - **从临时账号自己运行卸载会被拒绝**——它会在删到自己时把自己的会话一起收走,留下拆到一半的机器。请用 root 或别的管理员身份运行。 @@ -218,32 +281,32 @@ sudo linux-temp-admin uninstall --yes --purge-audit # 连审计日志一起删 指定有效期(小时,1 到 8760): ```bash -sudo linux-temp-admin invite --sudo --hours 12 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --hours 12 ``` 不授予 sudo(创建为普通账号): ```bash -sudo linux-temp-admin invite --no-sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --no-sudo ``` 指定用户名前缀 / Host / 端口(前缀仅允许小写字母、数字、下划线、连字符,最长 20 字符): ```bash -sudo linux-temp-admin invite --prefix ops --sudo -sudo linux-temp-admin invite --host 203.0.113.10 --port 22 --sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --prefix ops --sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --host 203.0.113.10 --port 22 --sudo ``` 不自动删除——创建**永久账号**(不设到期、不删除,需手动 `revoke`): ```bash -sudo linux-temp-admin invite --sudo --no-auto-revoke +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --no-auto-revoke ``` **自动化 / 非交互**(在 CI 或脚本里用)。非交互必须指定 `--host`;`--sudo --yes` 必须重复确认用户名;stdout 不是终端时还要显式允许输出私钥: ```bash -sudo linux-temp-admin invite \ +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite \ --user xxvcc-a1b2c3d4e5 \ --host 203.0.113.10 --port 22 --hours 24 \ --sudo --install-deps --yes \ @@ -260,15 +323,16 @@ sudo linux-temp-admin invite \ **① 只为这一个账号开一条口子**(推荐): ```bash -sudo linux-temp-admin invite --sudo --fix-sshd +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --fix-sshd ``` -它会写一个独立的 drop-in,内容只有一个 `Match User` 块: +它会写一个独立的 drop-in:先放置仅作用于该账号的 `Match User` 块,再用结尾的 `Match all` 恢复全局作用域,避免同一 Include 通配符中后续 drop-in 被意外纳入这个账号的作用域: ```text # /etc/ssh/sshd_config.d/10-linux-temp-admin-xxvcc-a1b2c3d4e5.conf Match User xxvcc-a1b2c3d4e5 PubkeyAuthentication yes +Match all ``` - **全局策略一个字节都不动**:其他所有账号的登录策略原封不动,该关的还是关着。 @@ -280,7 +344,7 @@ Match User xxvcc-a1b2c3d4e5 **② 改用密码登录**(不碰 sshd): ```bash -sudo linux-temp-admin invite --sudo --password-login +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --password-login ``` 先验证 sshd 真的接受密码登录(否则拒绝),然后生成一个 24 位随机密码,只打印一次。**这是本工具最弱的一种授权**:密码在账号整个生命周期里都能被全网爆破,而且必须明文交付。能用 `--fix-sshd` 就别用它。 @@ -290,7 +354,7 @@ sudo linux-temp-admin invite --sudo --password-login 想提前知道自己的服务器行不行,直接跑: ```bash -sudo linux-temp-admin doctor +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor ``` ## 参考 @@ -299,15 +363,18 @@ sudo linux-temp-admin doctor - **主要支持**:Debian / Ubuntu、宝塔常见 Linux 环境、RHEL / Rocky / AlmaLinux / Fedora - **尽力支持**:Alpine、Arch Linux +- **内核要求**:Linux 5.3 或更高版本,并且 seccomp / 容器策略允许 `pidfd_open` 与 `pidfd_send_signal`。这是避免 PID 复用时误杀无关进程所必需的;`doctor` 会实测当前环境,`invite` 会在能力不可用时拒绝创建账号。 ### 依赖 -二进制本身零运行时依赖。它只调用系统自带的**账号管理工具**;这些工具缺失时可交互安装(需确认或传 `--install-deps`),支持 `apt-get` / `dnf` / `yum` / `apk` / `pacman`: +二进制本身零运行时依赖。它只调用系统自带的**账号管理工具**;这些工具缺失时可交互安装(需确认或传 `--install-deps`),支持 `apt-get` / `dnf` / `yum` / `apk`: - `id`、`useradd` 或 `adduser`、`userdel` 或 `deluser`、`usermod`、`chage` - `sudo`:仅在选择授予 sudo 时需要 -`doctor` 会显示**运行中的版本与已安装命令的版本**(两者不一致会提示——自动删除任务执行的是已安装的那份),逐项检查上面这些工具,外加包管理器、init 系统、`/etc/sudoers.d` 的安全性、探测到的 SSH 端口,并**预演一个新建临时账号能否通过公钥登录**(sshd 会拒绝时给出 `invite --fix-sshd` 提示)。它还会报告**孤儿的 sudo 授权、sshd 例外和自动删除任务**(账号已不存在却残留),以及设置了自动删除却已无对应任务的账号——都指向 `cleanup-expired --compact` 或 `revoke` 处理。 +Arch 的 `pacman` 不支持部分升级,而安全的 `pacman -Syu` 会升级整个系统,因此本工具不会在创建账号时自动运行它。请先由管理员明确执行提示中的 `pacman -Syu --needed ...`,再重试邀请。 + +`doctor` 会显示**运行中的版本与已安装命令的版本**(两者不一致会提示——自动删除任务执行的是已安装的那份),逐项检查上面这些工具和 pidfd 能力,外加包管理器、init 系统、`/etc/sudoers.d` 的安全性、探测到的 SSH 端口,并**预演一个新建临时账号能否通过公钥登录**(sshd 会拒绝时给出 `invite --fix-sshd` 提示)。它还会报告**孤儿的 sudo 授权、sshd 例外和自动删除任务**(账号不存在或身份无法验证却残留),以及设置了自动删除却已无对应任务的账号——都指向 `cleanup-expired --compact` 或 `revoke` 处理。 `at` / `atd` 是 systemd 不可用时自动删除的备用后端,**不在依赖检查里,也不会被自动安装**。 @@ -330,30 +397,32 @@ sudo linux-temp-admin doctor /var/lib/linux-temp-admin/v2/prefs # 记住的界面语言(root:root 0600) /var/log/linux-temp-admin/audit.log # 操作审计日志(root:root 0600,目录 0700) /run/linux-temp-admin.lock # 全局账号/安装生命周期锁 +/run/linux-temp-admin.lock.uninstalled # 卸载完成标记;显式重新安装时删除 /etc/systemd/system/linux-temp-admin-v2-revoke-USER.service # 含 NoNewPrivileges 等轻量限制 /etc/systemd/system/linux-temp-admin-v2-revoke-USER.timer /etc/sudoers.d/linux-temp-admin-USER # 仅在启用免密 sudo 时 -/etc/ssh/sshd_config.d/10-linux-temp-admin-USER.conf # 仅在 --fix-sshd 时;只含一个 Match User 块,revoke 时删除 +/etc/ssh/sshd_config.d/10-linux-temp-admin-USER.conf # 仅在 --fix-sshd 时;账号作用域块及 Match all 重置,revoke 时删除 /home/USER/.ssh/authorized_keys # 以及在 systemd 不可用时,at 队列中的备用自动删除任务 ``` ## 安全说明 -- 私钥只在创建时显示一次,服务器不保存;账号密码默认锁定,不输出任何账号/Sudo 密码。 +- 私钥只在创建时显示一次,服务器不保存;公钥账号使用不可能匹配任何密码、且不会触发 Alpine/OpenSSH 整账号锁定判定的 shadow 值禁用密码认证。只有显式的 `--password-login` 会生成并一次性显示账号密码;工具不生成 sudo 密码。 - 邀请里的 `Login:` 是**验证过的结论**:创建前会读 `sshd -T -C user=<新账号>` 确认这个账号真能登进去;读不到配置,或发现 `Address`、`Host`、`LocalAddress`、`LocalPort` 等依赖连接属性的 `Match` 条件时会标 `UNVERIFIED`,绝不凭空断言。 -- **绝不修改 sshd 全局配置**。`--fix-sshd` 只写一个独立的、仅含 `Match User` 块的 drop-in(其他账号的策略一个字节不动),写入前 `sshd -t` 校验、写入后 `sshd -T` 证明生效、只 `reload` 不 `restart`。任一步失败都会触发清理,邀请事务还会独立重试;删除或恢复失败会作为回滚错误明确报告。`revoke` 会删掉该文件。**绝不绕过 `DenyUsers`/`DenyGroups` 这类显式拒绝规则。** +- **绝不修改 sshd 全局配置**。`--fix-sshd` 只写一个独立 drop-in,其中 `Match User` 块仅含解除已检测阻碍所需的指令,并以 `Match all` 重置 Include 流的作用域;其他账号的有效策略不变。写入前 `sshd -t` 校验、写入后 `sshd -T` 证明生效、只 `reload` 不 `restart`。任一步失败都会触发清理,邀请事务还会独立重试;删除或恢复失败会作为回滚错误明确报告。`revoke` 会删掉该文件。**绝不绕过 `DenyUsers`/`DenyGroups` 这类显式拒绝规则。** - `--password-login` 是最弱的授权方式(密码可被全网爆破、必须明文交付),只在显式要求时启用,且会先验证 sshd 确实接受密码登录。 - **NOPASSWD sudo 基本等同 root**,只给可信对象;撤销只删除该账号本身,不会清理它以 root 身份留下的进程、cron、systemd 单元或 SUID 文件。 - 删除用户会一并删除家目录和 SSH key;SSH 家目录必须严格属于目标 UID,绝不会把 root/UID 0 的目录当作目标家目录操作。如果系统删除命令失败,工具会停下并提示手动检查,不会假装撤销成功。 -- **防误删**:`revoke` 默认只接受登记目标,且登记行和匹配 UID 仍不足以证明身份;当前账号还必须保留本工具写入的精确 GECOS 标记。UID、标记或自动任务世代不匹配时拒绝/跳过删除。删除未登记但带精确标记的账号需显式 `--force`,非交互还需 `--confirm-force USER`。 +- **防误删**:每个新邀请(包括不开启自动删除的永久账号)都有独立的随机世代,并把它写入精确 GECOS 标记和登记行;`revoke` 默认只接受 UID、世代和标记全部匹配的登记目标,任一不匹配都会拒绝/跳过删除。世代是可读的账号代际关联标识,不是秘密,也不用于抵抗已经拥有 root 的攻击者。删除未登记但带精确标记的账号需显式 `--force`,非交互还需 `--confirm-force USER`。 +- 从 v2 登记表迁移来的固定 GECOS 账号会显示为 `managed=false identity=legacy-unverified`。因为同名同 UID 的替代账号可以复制旧固定标记,定时撤销、批量清理和卸载都不会自动删除这类账号;必须先人工核查,再直接运行 `revoke --user USER --force` 并输入完整用户名确认(非交互还需 `--yes --confirm-force USER`)。 - 即使使用 `--force`,也会拒绝删除 root、常见系统账号、UID 0、低 UID 系统账号,以及**任何非本工具创建(无精确标记)**的真实账号——这类账号请改用系统的 `userdel`。 - 创建过程中任一步失败都会尝试完整回滚自动撤销、sudoers、sshd 例外、登记记录和新建账号;任何回滚失败都会明确报告并返回非零,不会把部分成功伪装成成功。 -- invite、revoke、cleanup、install、upgrade、uninstall 共用一把位于可删除状态目录之外的 root 生命周期锁,账号、任务、授权、登记和二进制变更不会相互穿插。创建前同时检查本地 passwd 与 NSS,避免本地邀请覆盖 LDAP/SSSD 同名身份。 +- invite、revoke、cleanup、install、upgrade、uninstall 的**受管状态提交**共用一把位于可删除状态目录之外的 root 生命周期锁,账号、任务、授权、登记和二进制变更不会相互穿插。人工确认、依赖安装和升级的下载/验签在锁外完成;拿锁后会重新核验账号清单或已安装版本再提交,避免长时间阻塞到期撤销。创建前同时检查本地 passwd 与 NSS,避免本地邀请覆盖 LDAP/SSSD 同名身份。 - 撤销时若 sudoers 或 sshd 例外无法完全移除,会保留账号和登记并尝试禁用登录,避免残留的按用户名授权在账号复用后重新生效;清理、登记或调度错误同样返回非零。 - 登记表会严格校验 schema、字段、UID 和世代标识;损坏或不可读时,`status`、`doctor`、清理、撤销和卸载都会 fail closed,而不是把“读不到”当成“没有账号”。 -- 升级只接受 HTTPS 并强制 ed25519 验签,验签失败即中止,不会安装未签名或签名不符的二进制。 -- 每次特权操作(建/删账号、install/upgrade/uninstall)会以 JSON 行追加写入 root 属主的 `/var/log/linux-temp-admin/audit.log`(记录时间、操作者 `SUDO_USER`、动作、目标、结果)。 +- 默认升级把官方镜像作为首选完整来源,仅在传输故障时从 GitHub 重新下载整套文件;索引语义、校验和、签名或候选版本失败立即中止。显式自定义 URL 永不切换到官方来源。 +- 每次特权操作(建/删账号、install/upgrade/uninstall)会以 JSON 行追加写入 root 属主的 `/var/log/linux-temp-admin/audit.log`(记录时间、操作者 `SUDO_USER`、动作、目标、结果)。单条记录上限 64 KiB,日志总上限 64 MiB;达到上限后操作仍会继续,但会明确警告管理员先归档或轮转日志。 - stdout 不是 TTY 时默认拒绝输出私钥,只有确认输出通道安全时才用 `--allow-non-tty-private-key-output`。 - 不要把真实邀请包提交到 GitHub、Notion、工单或群聊;用完请立即 `revoke`,不要只依赖到期兜底。 diff --git a/SECURITY.md b/SECURITY.md index 8654cdb..41aa8f1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,8 @@ | Version | Supported | | --- | --- | -| 2.x (Go) | Yes | +| Latest 2.x release | Yes | +| Older 2.x releases | No | | < 2.0 | No | ## Reporting a Vulnerability @@ -49,4 +50,8 @@ Out of scope: - Keep `/usr/local/sbin/linux-temp-admin` a root-owned regular file, never a symlink, and not group/world writable. Invites refuse to schedule against a command that fails those checks or cannot report a valid version. - Keep `/var/lib/linux-temp-admin/v2/registry.tsv` root-owned and unmodified. Registry corruption or read failure is handled fail-closed; unattended revokes additionally require the recorded UID and random generation token to match, and every live deletion still requires the exact managed GECOS marker. - Treat revoke or rollback cleanup errors as unresolved incidents. The command returns nonzero and retains the account/registry when a name-scoped sudoers or sshd grant cannot be safely removed. -- v2: `upgrade` verifies an ed25519 signature against the embedded release key before installing (fails closed); the `install.sh` bootstrap verifies both the published SHA-256 checksum and a detached ed25519 signature (against the release key embedded in the script) over HTTPS, failing closed unless `LTA_ALLOW_UNVERIFIED=1` is set when openssl is unavailable. Report any way to bypass either check. +- The official installer is `https://dl.ll.cd/linux-temp-admin/install.sh`. Default install and `upgrade` operations prefer that official mirror and obtain the selected release's manifest/checksum data, binary, and detached ed25519 signature as a complete source set. They never combine mirror and GitHub files. +- GitHub is a fallback only for transport failures such as DNS, TLS, timeout, HTTP, empty/oversized-response, or incomplete-download failures. A valid mirror manifest pins fallback assets to the same tag. Manifest-semantic, checksum, signature, and candidate-version failures stop immediately without fallback. +- Official mirror URLs must return files directly without redirects. The mirror index is canonical single-line JSON, `SHA256SUMS` is lowercase NUL-free text ending in a newline, and detached signatures are exactly 64 raw bytes. A mirror redirect or noncanonical file is a source-policy failure and never triggers fallback; GitHub Release CDN redirects remain HTTPS-only and must resolve to public addresses. +- Explicit `upgrade --url` and `upgrade --url-file` requests use only the operator-selected source and never silently switch to the official mirror or GitHub. +- Both default paths fail closed. `upgrade` verifies the complete release set and detached ed25519 signature against the embedded keyring before installing; the `install.sh` bootstrap verifies the published SHA-256 checksum and detached signature, and requires OpenSSL 3 with no unsigned or checksum-only fallback. Report any way to bypass these checks or the source-selection rules above. diff --git a/cmd/linux-temp-admin/main.go b/cmd/linux-temp-admin/main.go index 6a8b749..c73901b 100644 --- a/cmd/linux-temp-admin/main.go +++ b/cmd/linux-temp-admin/main.go @@ -3,11 +3,21 @@ package main import ( + "fmt" "os" "github.com/xxvcc/linux-temp-admin/internal/cli" + "golang.org/x/sys/unix" ) func main() { + if err := disableCoreDumps(); err != nil { + fmt.Fprintln(os.Stderr, "cannot disable core dumps:", err) + os.Exit(1) + } os.Exit(cli.Run(os.Args[1:])) } + +func disableCoreDumps() error { + return unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{}) +} diff --git a/cmd/linux-temp-admin/main_test.go b/cmd/linux-temp-admin/main_test.go new file mode 100644 index 0000000..309f75e --- /dev/null +++ b/cmd/linux-temp-admin/main_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "os" + "os/exec" + "testing" + + "golang.org/x/sys/unix" +) + +func TestDisableCoreDumps(t *testing.T) { + if os.Getenv("LTA_CORE_LIMIT_HELPER") == "1" { + if err := disableCoreDumps(); err != nil { + t.Fatal(err) + } + var limit unix.Rlimit + if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil { + t.Fatal(err) + } + if limit.Cur != 0 || limit.Max != 0 { + t.Fatalf("RLIMIT_CORE=%+v, want both limits zero", limit) + } + return + } + cmd := exec.Command(os.Args[0], "-test.run=^TestDisableCoreDumps$") + cmd.Env = append(os.Environ(), "LTA_CORE_LIMIT_HELPER=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("core-limit helper: %v\n%s", err, out) + } +} diff --git a/cmd/lta-release/main.go b/cmd/lta-release/main.go index f1a90c6..2ec7afc 100644 --- a/cmd/lta-release/main.go +++ b/cmd/lta-release/main.go @@ -1,38 +1,45 @@ -// Command lta-release is a maintainer-only tool (not shipped) for the release -// pipeline: it generates the ed25519 signing keypair and signs release artifacts -// natively, so releasing needs no openssl. -// -// Usage: -// -// lta-release keygen generate a keypair; write the private -// key (hex, 0600) and print the PUBLIC key -// hex to paste into release_pubkey.hex -// lta-release sign write .sig (raw 64-byte ed25519 -// signature over ) -// lta-release pubkey print the public key hex for a private key -// lta-release verify -// verify over against the -// ed25519 public key in (a hex -// file like release_pubkey.hex; # and -// blank lines are skipped). Exit 0 if -// valid, 1 if not. +// Command lta-release is a small, network-incapable release signing tool. Build +// it once from an audited commit, record its SHA-256 offline, and use that fixed +// binary for every signing ceremony. Never build it from the candidate tag on +// the machine that holds the release private key. package main import ( + "bytes" "crypto/ed25519" "crypto/rand" + "crypto/x509" "encoding/hex" + "encoding/pem" + "errors" "fmt" + "io" "os" "path/filepath" "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +const ( + protocolVersion = "lta-release-offline-v1" + maxReleaseBinaryBytes = int64(64 << 20) + maxReleaseMetadataBytes = int64(1 << 20) ) func main() { + if err := unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{}); err != nil { + fmt.Fprintln(os.Stderr, "error: disable core dumps:", err) + os.Exit(1) + } if len(os.Args) < 2 { usage() } switch os.Args[1] { + case "version": + mustArgs(2) + fmt.Println(protocolVersion) case "keygen": mustArgs(3) keygen(os.Args[2]) @@ -41,110 +48,487 @@ func main() { sign(os.Args[2], os.Args[3]) case "pubkey": mustArgs(3) - fmt.Println(hex.EncodeToString(loadPriv(os.Args[2]).Public().(ed25519.PublicKey))) + printPublicKey(os.Args[2]) case "verify": mustArgs(5) verify(os.Args[2], os.Args[3], os.Args[4]) + case "pem": + mustArgs(3) + writePEMKeyring(os.Args[2]) default: usage() } } +func printPublicKey(privFile string) { + priv := loadPriv(privFile) + defer clear(priv) + fmt.Println(hex.EncodeToString(priv.Public().(ed25519.PublicKey))) +} + func usage() { - fmt.Fprintln(os.Stderr, "usage: lta-release keygen | sign | pubkey | verify ") + fmt.Fprintln(os.Stderr, "usage: lta-release version | keygen | sign | pubkey | verify | pem ") os.Exit(2) } func mustArgs(n int) { - if len(os.Args) < n { + if len(os.Args) != n { usage() } } func keygen(privOut string) { - pub, priv, err := ed25519.GenerateKey(rand.Reader) + pub, err := generateKey(privOut) check(err) - if dir := filepath.Dir(privOut); dir != "." { - check(os.MkdirAll(dir, 0o700)) - } - // A signing key is create-only. O_EXCL rejects both accidental overwrite and a - // planted symlink instead of truncating whatever path it resolves to. - f, err := os.OpenFile(privOut, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - check(err) - check(f.Chmod(0o600)) - _, werr := f.WriteString(hex.EncodeToString(priv) + "\n") - check(werr) - check(f.Close()) fmt.Fprintf(os.Stderr, "private key written to %s (keep offline)\n", privOut) - fmt.Fprintln(os.Stderr, "paste this public key into internal/selfmanage/release_pubkey.hex:") + fmt.Fprintln(os.Stderr, "add this public key as one line in internal/selfmanage/release_pubkey.hex:") fmt.Println(hex.EncodeToString(pub)) } +func generateKey(privOut string) (ed25519.PublicKey, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + defer clear(priv) + dirPath, name, err := splitOwnedTarget(privOut) + if err != nil { + return nil, err + } + dir, err := openOwnedDir(dirPath, true) + if err != nil { + return nil, err + } + defer dir.Close() + + // Create-only plus O_EXCL rejects both overwrite and a planted leaf symlink. + f, err := openFileAt(dir, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL, 0o600) + if err != nil { + return nil, err + } + complete := false + defer func() { + _ = f.Close() + if !complete { + _ = unix.Unlinkat(int(dir.Fd()), name, 0) + _ = syncReleaseDirectory(dir) + } + }() + if err := f.Chmod(0o600); err != nil { + return nil, err + } + if _, err = hex.NewEncoder(f).Write(priv); err != nil { + return nil, err + } + if _, err = f.Write([]byte{'\n'}); err != nil { + return nil, err + } + if err := f.Sync(); err != nil { + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + if err := syncReleaseDirectory(dir); err != nil { + return nil, fmt.Errorf("sync private-key directory: %w", err) + } + complete = true + return pub, nil +} + func sign(privFile, file string) { priv := loadPriv(privFile) - data, err := os.ReadFile(file) + defer clear(priv) + data, err := readBoundedRegularFile(file, maxReleaseBinaryBytes) check(err) - check(os.WriteFile(file+".sig", ed25519.Sign(priv, data), 0o644)) - fmt.Fprintf(os.Stderr, "wrote %s.sig\n", file) + dest := file + ".sig" + check(atomicWriteSignature(dest, ed25519.Sign(priv, data))) + fmt.Fprintf(os.Stderr, "wrote %s\n", dest) +} + +func atomicWriteSignature(dest string, signature []byte) error { + dirPath, name, err := splitOwnedTarget(dest) + if err != nil { + return err + } + dir, err := openOwnedDir(dirPath, false) + if err != nil { + return err + } + defer dir.Close() + if err := regularOrAbsentAt(dir, name); err != nil { + return err + } + tmp, tmpName, err := createTempFileAt(dir, ".lta-signature-") + if err != nil { + return err + } + renamed := false + cleanup := func() { + _ = tmp.Close() + if !renamed { + _ = unix.Unlinkat(int(dir.Fd()), tmpName, 0) + } + } + defer cleanup() + if _, err := tmp.Write(signature); err != nil { + return err + } + if err := tmp.Chmod(0o644); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := regularOrAbsentAt(dir, name); err != nil { + return err + } + if err := unix.Renameat(int(dir.Fd()), tmpName, int(dir.Fd()), name); err != nil { + return err + } + renamed = true + if err := syncReleaseDirectory(dir); err != nil { + return fmt.Errorf("signature %s committed but directory sync failed: %w", dest, err) + } + return nil } -// verify checks a raw ed25519 signature over file against the public key in -// pubHexFile (release_pubkey.hex format: hex on one line, # comments allowed). -// It fails closed: any read/decode/length error or bad signature exits non-zero. -func verify(pubHexFile, file, sigFile string) { - pub := loadPubHex(pubHexFile) - data, err := os.ReadFile(file) +func verify(keyringFile, file, sigFile string) { + keys, err := readPublicKeys(keyringFile) check(err) - sig, err := os.ReadFile(sigFile) + data, err := readBoundedRegularFile(file, maxReleaseBinaryBytes) + check(err) + sig, err := readBoundedRegularFile(sigFile, ed25519.SignatureSize) check(err) - // tolerate a single trailing newline on the .sig file - if n := len(sig); n == ed25519.SignatureSize+1 && sig[n-1] == '\n' { - sig = sig[:ed25519.SignatureSize] - } if len(sig) != ed25519.SignatureSize { fmt.Fprintf(os.Stderr, "invalid signature length: %d (want %d)\n", len(sig), ed25519.SignatureSize) os.Exit(1) } - if !ed25519.Verify(pub, data, sig) { - fmt.Fprintf(os.Stderr, "SIGNATURE INVALID: %s\n", file) - os.Exit(1) + for _, key := range keys { + if ed25519.Verify(key, data, sig) { + fmt.Fprintf(os.Stderr, "ok: %s verifies against %s\n", file, keyringFile) + return + } } - fmt.Fprintf(os.Stderr, "ok: %s verifies against %s\n", file, pubHexFile) + fmt.Fprintf(os.Stderr, "SIGNATURE INVALID: %s\n", file) + os.Exit(1) } -// loadPubHex reads an ed25519 public key from a hex file, skipping blank lines -// and lines beginning with '#'. It takes the first remaining hex line. -func loadPubHex(file string) ed25519.PublicKey { - b, err := os.ReadFile(file) - check(err) - var line string - for _, ln := range strings.Split(string(b), "\n") { - ln = strings.TrimSpace(ln) - if ln == "" || strings.HasPrefix(ln, "#") { +func readPublicKeys(file string) ([]ed25519.PublicKey, error) { + b, err := readBoundedRegularFile(file, maxReleaseMetadataBytes) + if err != nil { + return nil, err + } + var keys []ed25519.PublicKey + seen := make(map[string]struct{}) + for lineNo, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { continue } - line = ln - break + raw, err := hex.DecodeString(line) + if err != nil || len(raw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid ed25519 public key at %s:%d", file, lineNo+1) + } + id := string(raw) + if _, ok := seen[id]; ok { + return nil, fmt.Errorf("duplicate ed25519 public key at %s:%d", file, lineNo+1) + } + seen[id] = struct{}{} + keys = append(keys, ed25519.PublicKey(raw)) + } + if len(keys) == 0 { + return nil, fmt.Errorf("no ed25519 public keys in %s", file) + } + return keys, nil +} + +func readBoundedRegularFile(path string, maxBytes int64) ([]byte, error) { + if maxBytes < 0 { + return nil, fmt.Errorf("invalid read limit %d", maxBytes) + } + f, err := os.OpenFile(path, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + if errors.Is(err, unix.ELOOP) { + return nil, fmt.Errorf("%s is not a regular non-symlink file", path) + } + return nil, err } - raw, err := hex.DecodeString(line) + defer f.Close() + fi, err := f.Stat() + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, fmt.Errorf("%s is not a regular non-symlink file", path) + } + if fi.Size() > maxBytes { + return nil, fmt.Errorf("%s exceeds %d-byte limit", path, maxBytes) + } + b, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > maxBytes { + return nil, fmt.Errorf("%s exceeds %d-byte limit", path, maxBytes) + } + return b, nil +} + +func writePEMKeyring(file string) { + keys, err := readPublicKeys(file) check(err) - if len(raw) != ed25519.PublicKeySize { - fmt.Fprintln(os.Stderr, "invalid public key length") - os.Exit(1) + for _, key := range keys { + der, err := x509.MarshalPKIXPublicKey(key) + check(err) + check(pem.Encode(os.Stdout, &pem.Block{Type: "PUBLIC KEY", Bytes: der})) } - return ed25519.PublicKey(raw) } func loadPriv(file string) ed25519.PrivateKey { - b, err := os.ReadFile(file) - check(err) - raw, err := hex.DecodeString(strings.TrimSpace(string(b))) + key, err := readPrivateKey(file) check(err) + return key +} + +func readPrivateKey(file string) (ed25519.PrivateKey, error) { + dirPath, name, err := splitOwnedTarget(file) + if err != nil { + return nil, err + } + dir, err := openOwnedDir(dirPath, false) + if err != nil { + return nil, fmt.Errorf("unsafe private-key directory: %w", err) + } + defer dir.Close() + f, err := openFileAt(dir, name, unix.O_RDONLY|unix.O_NONBLOCK, 0) + if err != nil { + if errors.Is(err, unix.ELOOP) { + return nil, fmt.Errorf("private key %s is not a regular non-symlink file", file) + } + return nil, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, fmt.Errorf("private key %s is not a regular non-symlink file", file) + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return nil, fmt.Errorf("cannot stat private key %s", file) + } + if int(st.Uid) != os.Geteuid() { + return nil, fmt.Errorf("private key %s is owned by uid %d, want current uid %d", file, st.Uid, os.Geteuid()) + } + special := os.ModeSetuid | os.ModeSetgid | os.ModeSticky + if fi.Mode().Perm() != 0o600 || fi.Mode()&special != 0 { + return nil, fmt.Errorf("private key %s mode is %v, want exactly 0600", file, fi.Mode()) + } + b, err := io.ReadAll(io.LimitReader(f, 1025)) + if err != nil { + return nil, err + } + if len(b) > 1024 { + return nil, fmt.Errorf("private key %s is too large", file) + } + defer clear(b) + encoded := bytes.TrimSpace(b) + raw := make([]byte, hex.DecodedLen(len(encoded))) + n, err := hex.Decode(raw, encoded) + if err != nil { + clear(raw) + return nil, err + } + raw = raw[:n] if len(raw) != ed25519.PrivateKeySize { - fmt.Fprintln(os.Stderr, "invalid private key length") - os.Exit(1) + clear(raw) + return nil, fmt.Errorf("invalid private key length: %d", len(raw)) + } + return ed25519.PrivateKey(raw), nil +} + +var syncReleaseDirectory = func(dir *os.File) error { return dir.Sync() } + +func splitOwnedTarget(path string) (dir, name string, err error) { + if path == "" || strings.HasSuffix(path, string(filepath.Separator)) { + return "", "", fmt.Errorf("unsafe empty target name in %q", path) + } + name = filepath.Base(path) + if name == "" || name == "." || name == ".." || strings.ContainsRune(name, filepath.Separator) { + return "", "", fmt.Errorf("unsafe target name %q", name) + } + return filepath.Dir(path), name, nil +} + +// openOwnedDir traverses from / on pinned directory descriptors. In create mode +// it mkdirs missing components only after every existing ancestor has passed the +// ownership/mode policy, so a symlink can never redirect even a directory-creation +// side effect. The returned descriptor pins the final directory for later openat. +func openOwnedDir(dir string, create bool) (*os.File, error) { + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + clean := filepath.Clean(absDir) + rootFD, err := unix.Open(string(filepath.Separator), unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + current := os.NewFile(uintptr(rootFD), string(filepath.Separator)) + if current == nil { + _ = unix.Close(rootFD) + return nil, fmt.Errorf("open directory traversal root") + } + if clean == string(filepath.Separator) { + if err := validateOwnedDirectoryFD(clean, current, true); err != nil { + _ = current.Close() + return nil, err + } + return current, nil + } + if err := validateOwnedDirectoryFD(string(filepath.Separator), current, false); err != nil { + _ = current.Close() + return nil, err + } + + parts := strings.Split(strings.TrimPrefix(clean, string(filepath.Separator)), string(filepath.Separator)) + currentPath := string(filepath.Separator) + for i, part := range parts { + currentPath = filepath.Join(currentPath, part) + created := false + childFD, openErr := unix.Openat(int(current.Fd()), part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if errors.Is(openErr, unix.ENOENT) && create { + mkdirErr := unix.Mkdirat(int(current.Fd()), part, 0o700) + if mkdirErr == nil { + created = true + } else if !errors.Is(mkdirErr, unix.EEXIST) { + _ = current.Close() + return nil, fmt.Errorf("create directory component %s: %w", currentPath, mkdirErr) + } + childFD, openErr = unix.Openat(int(current.Fd()), part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + } + if openErr != nil { + _ = current.Close() + if errors.Is(openErr, unix.ELOOP) || errors.Is(openErr, unix.ENOTDIR) { + return nil, fmt.Errorf("%s is not a real directory", currentPath) + } + return nil, openErr + } + child := os.NewFile(uintptr(childFD), currentPath) + if child == nil { + _ = unix.Close(childFD) + _ = current.Close() + return nil, fmt.Errorf("open directory component %s", currentPath) + } + leaf := i == len(parts)-1 + if err := validateOwnedDirectoryFD(currentPath, child, leaf); err != nil { + _ = child.Close() + _ = current.Close() + return nil, err + } + if created { + if err := child.Chmod(0o700); err != nil { + _ = child.Close() + _ = current.Close() + return nil, err + } + if err := syncReleaseDirectory(child); err != nil { + _ = child.Close() + _ = current.Close() + return nil, fmt.Errorf("sync created directory %s: %w", currentPath, err) + } + if err := syncReleaseDirectory(current); err != nil { + _ = child.Close() + _ = current.Close() + return nil, fmt.Errorf("sync parent after creating %s: %w", currentPath, err) + } + } + if err := current.Close(); err != nil { + _ = child.Close() + return nil, err + } + current = child + } + return current, nil +} + +func validateOwnedDirectoryFD(path string, dir *os.File, leaf bool) error { + var stat unix.Stat_t + if err := unix.Fstat(int(dir.Fd()), &stat); err != nil { + return err + } + if stat.Mode&unix.S_IFMT != unix.S_IFDIR { + return fmt.Errorf("%s is not a real directory", path) + } + uid := int(stat.Uid) + perm := os.FileMode(stat.Mode & 0o777) + if leaf { + if uid != os.Geteuid() { + return fmt.Errorf("%s is not owned by current uid %d", path, os.Geteuid()) + } + if perm&0o022 != 0 { + return fmt.Errorf("%s is group/world writable (mode %o)", path, perm) + } + return nil + } + if uid != 0 && uid != os.Geteuid() { + return fmt.Errorf("ancestor %s is owned by unexpected uid %d", path, uid) + } + if perm&0o022 != 0 && stat.Mode&unix.S_ISVTX == 0 { + return fmt.Errorf("ancestor %s is writable without the sticky bit (mode %o)", path, perm) + } + return nil +} + +func openFileAt(dir *os.File, name string, flags int, mode uint32) (*os.File, error) { + fd, err := unix.Openat(int(dir.Fd()), name, flags|unix.O_CLOEXEC|unix.O_NOFOLLOW, mode) + if err != nil { + return nil, err + } + f := os.NewFile(uintptr(fd), filepath.Join(dir.Name(), name)) + if f == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("open %s", name) + } + return f, nil +} + +func regularOrAbsentAt(dir *os.File, name string) error { + var stat unix.Stat_t + err := unix.Fstatat(int(dir.Fd()), name, &stat, unix.AT_SYMLINK_NOFOLLOW) + if errors.Is(err, unix.ENOENT) { + return nil + } + if err != nil { + return err + } + if stat.Mode&unix.S_IFMT != unix.S_IFREG { + return fmt.Errorf("%s is not a regular non-symlink file", filepath.Join(dir.Name(), name)) + } + return nil +} + +func createTempFileAt(dir *os.File, prefix string) (*os.File, string, error) { + for range 128 { + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, "", err + } + name := prefix + hex.EncodeToString(nonce[:]) + f, err := openFileAt(dir, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL, 0o600) + if errors.Is(err, unix.EEXIST) { + continue + } + return f, name, err } - return ed25519.PrivateKey(raw) + return nil, "", fmt.Errorf("cannot allocate a unique temporary signature file") } func check(err error) { diff --git a/cmd/lta-release/main_test.go b/cmd/lta-release/main_test.go new file mode 100644 index 0000000..b64e108 --- /dev/null +++ b/cmd/lta-release/main_test.go @@ -0,0 +1,235 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestGenerateAndReadPrivateKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "release.key") + pub, err := generateKey(path) + if err != nil { + t.Fatal(err) + } + priv, err := readPrivateKey(path) + if err != nil { + t.Fatal(err) + } + if got := priv.Public().(ed25519.PublicKey); string(got) != string(pub) { + t.Fatal("generated public and private key do not match") + } + if _, err := generateKey(path); err == nil { + t.Fatal("key generation must be create-only") + } +} + +func TestReadPrivateKeyRejectsUnsafeMetadata(t *testing.T) { + dir := t.TempDir() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "release.key") + if err := os.WriteFile(path, []byte(hex.EncodeToString(priv)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + if _, err := readPrivateKey(path); err == nil || !strings.Contains(err.Error(), "0600") { + t.Fatalf("mode 0644 error=%v, want strict-mode rejection", err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing", path); err != nil { + t.Fatal(err) + } + if _, err := readPrivateKey(path); err == nil || !strings.Contains(err.Error(), "non-symlink") { + t.Fatalf("symlink error=%v, want rejection", err) + } +} + +func TestReadPrivateKeyRejectsFIFOWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "release.key") + if err := unix.Mkfifo(path, 0o600); err != nil { + t.Fatal(err) + } + if _, err := readPrivateKey(path); err == nil || !strings.Contains(err.Error(), "regular") { + t.Fatalf("FIFO error=%v, want non-regular-file rejection", err) + } +} + +func TestPrivateKeyPathRejectsUnsafeAncestor(t *testing.T) { + root := t.TempDir() + unsafe := filepath.Join(root, "unsafe") + dir := filepath.Join(unsafe, "keys") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(unsafe, 0o777); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "release.key") + if _, err := generateKey(path); err == nil || !strings.Contains(err.Error(), "writable without the sticky bit") { + t.Fatalf("unsafe-ancestor keygen error=%v, want rejection", err) + } + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("unsafe-ancestor keygen left a key behind: %v", err) + } +} + +func TestPrivateKeyPathRejectsSymlinkAncestor(t *testing.T) { + root := t.TempDir() + realDir := filepath.Join(root, "real") + if err := os.Mkdir(realDir, 0o700); err != nil { + t.Fatal(err) + } + linkDir := filepath.Join(root, "link") + if err := os.Symlink(realDir, linkDir); err != nil { + t.Fatal(err) + } + path := filepath.Join(linkDir, "release.key") + if _, err := generateKey(path); err == nil || !strings.Contains(err.Error(), "not a real directory") { + t.Fatalf("symlink-ancestor keygen error=%v, want rejection", err) + } +} + +func TestPrivateKeyPathRejectsSymlinkBeforeCreatingMissingSuffix(t *testing.T) { + root := t.TempDir() + realDir := filepath.Join(root, "real") + if err := os.Mkdir(realDir, 0o700); err != nil { + t.Fatal(err) + } + linkDir := filepath.Join(root, "link") + if err := os.Symlink(realDir, linkDir); err != nil { + t.Fatal(err) + } + path := filepath.Join(linkDir, "missing", "keys", "release.key") + if _, err := generateKey(path); err == nil || !strings.Contains(err.Error(), "not a real directory") { + t.Fatalf("symlink-with-missing-suffix keygen error=%v, want refusal", err) + } + if _, err := os.Lstat(filepath.Join(realDir, "missing")); !os.IsNotExist(err) { + t.Fatalf("keygen followed the symlink and created a directory in its target: %v", err) + } +} + +func TestGenerateKeySyncsDirectoryAndCleansUpOnSyncFailure(t *testing.T) { + dir := t.TempDir() + oldSync := syncReleaseDirectory + t.Cleanup(func() { syncReleaseDirectory = oldSync }) + + syncs := 0 + syncReleaseDirectory = func(*os.File) error { + syncs++ + return nil + } + first := filepath.Join(dir, "first.key") + if _, err := generateKey(first); err != nil { + t.Fatal(err) + } + if syncs != 1 { + t.Fatalf("private-key parent syncs = %d, want 1", syncs) + } + + wantErr := errors.New("forced directory sync failure") + syncReleaseDirectory = func(*os.File) error { return wantErr } + second := filepath.Join(dir, "second.key") + if _, err := generateKey(second); !errors.Is(err, wantErr) { + t.Fatalf("directory sync error = %v, want injected failure", err) + } + if _, err := os.Lstat(second); !os.IsNotExist(err) { + t.Fatalf("key survived a failed directory durability check: %v", err) + } +} + +func TestReadPublicKeysStrictKeyring(t *testing.T) { + dir := t.TempDir() + pub1, _, _ := ed25519.GenerateKey(rand.Reader) + pub2, _, _ := ed25519.GenerateKey(rand.Reader) + path := filepath.Join(dir, "keys.hex") + content := "# rotation overlap\n" + hex.EncodeToString(pub1) + "\n" + hex.EncodeToString(pub2) + "\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + keys, err := readPublicKeys(path) + if err != nil || len(keys) != 2 { + t.Fatalf("keys=%d err=%v, want two keys", len(keys), err) + } + if err := os.WriteFile(path, []byte(hex.EncodeToString(pub1)+"\n"+hex.EncodeToString(pub1)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readPublicKeys(path); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("duplicate key error=%v, want rejection", err) + } +} + +func TestReadBoundedRegularFileRejectsOversizeAndSpecialFiles(t *testing.T) { + dir := t.TempDir() + regular := filepath.Join(dir, "regular") + if err := os.WriteFile(regular, []byte("12345"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readBoundedRegularFile(regular, 4); err == nil || !strings.Contains(err.Error(), "4-byte limit") { + t.Fatalf("oversize error=%v, want bounded-read refusal", err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(regular, link); err != nil { + t.Fatal(err) + } + if _, err := readBoundedRegularFile(link, 8); err == nil || !strings.Contains(err.Error(), "non-symlink") { + t.Fatalf("symlink error=%v, want refusal", err) + } + fifo := filepath.Join(dir, "fifo") + if err := unix.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + if _, err := readBoundedRegularFile(fifo, 8); err == nil || !strings.Contains(err.Error(), "regular") { + t.Fatalf("FIFO error=%v, want refusal without blocking", err) + } +} + +func TestAtomicWriteSignatureRefusesSymlink(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "asset.sig") + if err := os.Symlink(filepath.Join(dir, "victim"), dest); err != nil { + t.Fatal(err) + } + if err := atomicWriteSignature(dest, make([]byte, ed25519.SignatureSize)); err == nil { + t.Fatal("signature writer must refuse a symlink destination") + } +} + +func TestAtomicWriteSignatureSyncsDirectoryAndReportsCommittedFailure(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "asset.sig") + signature := make([]byte, ed25519.SignatureSize) + oldSync := syncReleaseDirectory + wantErr := errors.New("forced directory sync failure") + syncReleaseDirectory = func(*os.File) error { return wantErr } + t.Cleanup(func() { syncReleaseDirectory = oldSync }) + + err := atomicWriteSignature(dest, signature) + if !errors.Is(err, wantErr) || !strings.Contains(err.Error(), "committed") { + t.Fatalf("signature sync error = %v, want committed durability error", err) + } + if got, readErr := os.ReadFile(dest); readErr != nil || string(got) != string(signature) { + t.Fatalf("committed signature content=%x err=%v", got, readErr) + } + entries, readErr := os.ReadDir(dir) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 1 || entries[0].Name() != filepath.Base(dest) { + t.Fatalf("signature write left temporary files: %v", entries) + } +} diff --git a/deploy/nginx/linux-temp-admin.conf b/deploy/nginx/linux-temp-admin.conf new file mode 100644 index 0000000..edefcbf --- /dev/null +++ b/deploy/nginx/linux-temp-admin.conf @@ -0,0 +1,72 @@ +location = /404.html { + internal; + gzip off; + add_header Cache-Control "no-store, no-cache, must-revalidate, no-transform" always; + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options "nosniff" always; +} + +location = /linux-temp-admin { + gzip off; + error_page 404 = @linux_temp_admin_not_found; + return 404; +} + +location = /linux-temp-admin/latest.json { + default_type application/json; + gzip off; + add_header Cache-Control "no-store, no-cache, must-revalidate, no-transform" always; + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options "nosniff" always; + limit_except GET { + deny all; + } + try_files $uri @linux_temp_admin_not_found; +} + +location = /linux-temp-admin/install.sh { + default_type text/plain; + charset utf-8; + gzip off; + add_header Cache-Control "no-store, no-cache, must-revalidate, no-transform" always; + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options "nosniff" always; + limit_except GET { + deny all; + } + try_files $uri @linux_temp_admin_not_found; +} + +location ~ ^/linux-temp-admin/v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?/(?:SHA256SUMS|linux-temp-admin-linux-(?:amd64|arm64)(?:\.sig)?|install\.sh)$ { + default_type application/octet-stream; + gzip off; + add_header Cache-Control "public, max-age=31536000, immutable, no-transform" always; + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options "nosniff" always; + limit_except GET { + deny all; + } + try_files $uri @linux_temp_admin_not_found; +} + +location ~ ^/linux-temp-admin/ { + autoindex off; + gzip off; + add_header Cache-Control "no-store, no-cache, must-revalidate, no-transform" always; + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options "nosniff" always; + error_page 404 = @linux_temp_admin_not_found; + limit_except GET { + deny all; + } + return 404; +} + +location @linux_temp_admin_not_found { + default_type text/plain; + gzip off; + add_header Cache-Control "no-store, no-cache, must-revalidate, no-transform" always; + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options "nosniff" always; + return 404; +} diff --git a/docs/releasing.md b/docs/releasing.md index e434600..7703c2b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,122 +1,1126 @@ -# Releasing linux-temp-admin v2 (Go) +# Releasing linux-temp-admin v2 -v2 ships signed static binaries. `upgrade` verifies an **ed25519 signature** -against a public key embedded in the binary, failing closed on any mismatch. -Building and running the release helpers requires Go 1.26.5 or newer. +Release binaries are reproducibly rebuilt and signed with ed25519. The release +private key is never present on a networked machine, candidate source is never +executed on the signing machine, and CI output is never signed merely because +its own checksum file matches. -## Signing key +## One-time trusted tooling setup -The release signing key is **already configured**: the public key is committed in -[`internal/selfmanage/release_pubkey.hex`](../internal/selfmanage/release_pubkey.hex), -and the private key is held offline by the maintainer (`~/.lta/signing.key`, mode -`0600`). `upgrade` verifies each release against it. If no key were embedded, -`upgrade` fails closed (signed upgrade disabled). +On an audited source commit, before preparing any candidate release, obtain its +full 40-hex commit ID through the independent review record, place a standalone +root-owned checkout at the path below, and build the small network-incapable +signer with the exact supported Go toolchain: -**Back up the private key** in ≥2 offline places and treat it like a root -credential: losing it means no future release will verify on existing installs -(they carry the old public key), and a leak lets anyone sign malicious updates. +```bash +TRUSTED_SIGNER_SOURCE=/opt/lta-reviewed-source +TRUSTED_SIGNER_COMMIT='replace-with-the-independently-recorded-40-hex-audited-commit' +/usr/bin/sudo /usr/bin/env -i \ + HOME=/root PATH=/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + LC_ALL=C TRUSTED_SIGNER_SOURCE="$TRUSTED_SIGNER_SOURCE" \ + TRUSTED_SIGNER_COMMIT="$TRUSTED_SIGNER_COMMIT" \ + /bin/bash -p <<'LTA_TRUSTED_SIGNER' +set -Eeuo pipefail +umask 077 +fail() { echo "error: $*" >&2; exit 1; } +ulimit -c 0 || fail "cannot disable core dumps" +unset TAR_OPTIONS GZIP BZIP2 BZIP XZ_OPT -To rotate, or set up on a fresh maintainer machine: + : "${TRUSTED_SIGNER_SOURCE:?set the root-owned audited source directory}" + : "${TRUSTED_SIGNER_COMMIT:?set the independently recorded audited commit}" + [[ "$TRUSTED_SIGNER_SOURCE" == /* && "$TRUSTED_SIGNER_SOURCE" != *$'\n'* ]] \ + || fail "TRUSTED_SIGNER_SOURCE must be an absolute single-line path" + [[ -d "$TRUSTED_SIGNER_SOURCE" && ! -L "$TRUSTED_SIGNER_SOURCE" ]] \ + || fail "trusted signer source is not a real directory" + [[ "$(readlink -f -- "$TRUSTED_SIGNER_SOURCE")" == "$TRUSTED_SIGNER_SOURCE" ]] \ + || fail "trusted signer source must be canonical and contain no symlinked ancestor" + [[ "$TRUSTED_SIGNER_COMMIT" =~ ^[0-9a-f]{40}$ ]] \ + || fail "TRUSTED_SIGNER_COMMIT must be exactly 40 lowercase hex characters" -```sh -go run ./cmd/lta-release keygen ~/.lta/signing.key # prints the PUBLIC key hex + check_safe_source_dir() { + local source_dir=$1 source_dir_meta source_dir_uid source_dir_mode source_dir_extra parent_dir + while :; do + [[ -d "$source_dir" && ! -L "$source_dir" ]] \ + || fail "trusted signer source ancestor is not a real directory: $source_dir" + source_dir_meta=$(stat -Lc '%u %a' -- "$source_dir") \ + || fail "cannot inspect trusted signer source ancestor: $source_dir" + read -r source_dir_uid source_dir_mode source_dir_extra <<< "$source_dir_meta" + [[ "$source_dir_meta" == "$source_dir_uid $source_dir_mode" && + "$source_dir_uid" == 0 && "$source_dir_mode" =~ ^[0-7]{3,4}$ && + -z "$source_dir_extra" ]] \ + || fail "trusted signer source ancestor has invalid metadata: $source_dir" + (( (8#$source_dir_mode & 8#7022) == 0 )) \ + || fail "trusted signer source ancestor is writable by another user or has special bits: $source_dir" + [[ "$source_dir" == / ]] && break + parent_dir=$(dirname -- "$source_dir") + [[ "$parent_dir" != "$source_dir" ]] || fail "cannot resolve trusted signer source ancestry" + source_dir=$parent_dir + done + } + check_safe_source_dir "$TRUSTED_SIGNER_SOURCE" + [[ -d "$TRUSTED_SIGNER_SOURCE/.git" && ! -L "$TRUSTED_SIGNER_SOURCE/.git" ]] \ + || fail "trusted signer source must be a standalone Git checkout" + for external_git_store in \ + "$TRUSTED_SIGNER_SOURCE/.git/commondir" \ + "$TRUSTED_SIGNER_SOURCE/.git/objects/info/alternates" \ + "$TRUSTED_SIGNER_SOURCE/.git/objects/info/http-alternates"; do + [[ ! -e "$external_git_store" && ! -L "$external_git_store" ]] \ + || fail "trusted signer source uses an external Git object or metadata store: $external_git_store" + done + + [[ -d /tmp && ! -L /tmp ]] || fail "/tmp is not a real directory" + tmp_meta=$(stat -Lc '%u %a' -- /tmp) || fail "cannot inspect /tmp" + [[ "$tmp_meta" =~ ^0\ 1[0-7]{3}$ ]] \ + || fail "/tmp must be root-owned, sticky, and free of other special bits" + build_root=$(mktemp -d /tmp/lta-trusted-signer-build.XXXXXXXXXX) + install_stage= + cleanup() { + timeout -k 5 120 rm -rf -- "$build_root" \ + || echo "warning: could not remove trusted build workspace" >&2 + [[ -z "$install_stage" ]] || timeout -k 5 60 rm -f -- "$install_stage" \ + || echo "warning: could not remove trusted install staging file" >&2 + } + trap cleanup EXIT + trap 'exit 1' HUP INT TERM + + source_nodes="$build_root/source-nodes" + if ! timeout -k 5 120 find "$TRUSTED_SIGNER_SOURCE" -print0 > "$source_nodes"; then + fail "cannot enumerate the complete trusted signer source tree" + fi + while IFS= read -r -d '' source_node; do + [[ ! -L "$source_node" && ( -d "$source_node" || -f "$source_node" ) ]] \ + || fail "trusted signer source contains a symlink or special file: $source_node" + source_meta=$(stat -Lc '%u %a' -- "$source_node") \ + || fail "cannot inspect trusted signer source: $source_node" + read -r source_uid source_mode source_extra <<< "$source_meta" + [[ "$source_meta" == "$source_uid $source_mode" && "$source_uid" == 0 && + "$source_mode" =~ ^[0-7]{3,4}$ && -z "$source_extra" ]] \ + || fail "trusted signer source has invalid metadata: $source_node" + (( (8#$source_mode & 8#7022) == 0 )) \ + || fail "trusted signer source is writable by another user or has special bits: $source_node" + done < "$source_nodes" + + cd -- "$TRUSTED_SIGNER_SOURCE" + PATH=/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + LC_ALL=C + export PATH LC_ALL + if ! trusted_go_version=$(env -i PATH="$PATH" LC_ALL=C HOME=/root GOROOT= GOENV=off \ + GOTOOLCHAIN=local GOFLAGS= GOWORK=off GOEXPERIMENT= GOFIPS140=off \ + GOTELEMETRY=off GOAUTH=off timeout -k 5 30 go version); then + fail "cannot execute the trusted Go toolchain" + fi + [[ "$(awk '{print $3}' <<<"$trusted_go_version")" == go1.26.5 ]] \ + || fail "trusted Go toolchain is not exactly go1.26.5" + signer_arch=$(env -i PATH="$PATH" LC_ALL=C HOME=/root GOROOT= GOENV=off \ + GOTOOLCHAIN=local GOFLAGS= GOWORK=off GOEXPERIMENT= GOFIPS140=off \ + GOTELEMETRY=off GOAUTH=off timeout -k 5 30 go env GOARCH) \ + || fail "cannot determine the trusted Go architecture" + case "$signer_arch" in + amd64) signer_tune=GOAMD64=v1 ;; + arm64) signer_tune=GOARM64=v8.0 ;; + *) echo "unsupported trusted-signer architecture: $signer_arch" >&2; exit 1 ;; + esac + git_env=(env -i PATH="$PATH" LC_ALL=C HOME=/root \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null \ + GIT_CONFIG_SYSTEM=/dev/null GIT_NO_REPLACE_OBJECTS=1 GIT_NO_LAZY_FETCH=1 \ + GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/false SSH_ASKPASS=/bin/false \ + GIT_PAGER=cat GIT_OPTIONAL_LOCKS=0) + git_options=(--git-dir="$TRUSTED_SIGNER_SOURCE/.git" --work-tree="$TRUSTED_SIGNER_SOURCE" \ + -c core.bare=false -c core.fsmonitor=false -c core.hooksPath=/dev/null \ + -c core.attributesFile=/dev/null -c core.pager=cat) + + if ! source_head=$("${git_env[@]}" timeout -k 5 60 git \ + "${git_options[@]}" \ + rev-parse --verify 'HEAD^{commit}'); then + fail "cannot resolve trusted signer source HEAD" + fi + [[ "$source_head" == "$TRUSTED_SIGNER_COMMIT" ]] \ + || fail "trusted signer source HEAD is not the independently audited commit" + + source_tree="$build_root/source-tree" + "${git_env[@]}" timeout -k 5 60 git \ + "${git_options[@]}" \ + ls-tree -r -z "$TRUSTED_SIGNER_COMMIT" > "$source_tree" \ + || fail "cannot enumerate audited commit tree" + while IFS= read -r -d '' tree_entry; do + tree_mode=${tree_entry%% *} + case "$tree_mode" in + 100644|100755) ;; + 120000|160000) fail "audited signer commit contains a symlink or submodule" ;; + *) fail "audited signer commit contains unsupported mode $tree_mode" ;; + esac + done < "$source_tree" + + if ! signer_fsize_block_bytes=$( + ulimit -f 1 || exit 1 + awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } + END { if (!found) exit 1 }' /proc/self/limits + ); then + fail "cannot determine the trusted Bash file-size limit unit" + fi + case "$signer_fsize_block_bytes" in + 512|1024) ;; + *) fail "unsupported trusted Bash file-size limit unit" ;; + esac + source_archive_max_bytes=134217728 + source_archive_blocks=$(( + (source_archive_max_bytes + signer_fsize_block_bytes - 1) / signer_fsize_block_bytes + )) + source_archive="$build_root/source.tar" + if ! ( + ulimit -f "$source_archive_blocks" || exit 1 + exec "${git_env[@]}" timeout -k 5 120 git \ + "${git_options[@]}" \ + archive --format=tar --output="$source_archive" "$TRUSTED_SIGNER_COMMIT" + ); then + fail "cannot export the bounded audited signer source snapshot" + fi + source_snapshot="$build_root/source" + mkdir -m 0700 "$source_snapshot" + timeout -k 5 120 tar --extract --file="$source_archive" --directory="$source_snapshot" \ + --no-same-owner --no-same-permissions \ + || fail "cannot extract audited signer source snapshot" + while IFS= read -r -d '' tree_entry; do + tree_header=${tree_entry%%$'\t'*} + tree_path=${tree_entry#*$'\t'} + read -r tree_mode tree_type tree_object <<<"$tree_header" + [[ "$tree_type" == blob && -f "$source_snapshot/$tree_path" && ! -L "$source_snapshot/$tree_path" ]] \ + || fail "exported signer source is missing a regular audited file: $tree_path" + extracted_object=$("${git_env[@]}" timeout -k 5 60 git "${git_options[@]}" \ + hash-object --no-filters -- "$source_snapshot/$tree_path") \ + || fail "cannot hash exported signer source: $tree_path" + [[ "$extracted_object" == "$tree_object" ]] \ + || fail "exported signer source differs from audited Git object: $tree_path" + done < "$source_tree" + cd -- "$source_snapshot" + + for build_id in a b; do + mkdir -m 0700 "$build_root/$build_id" "$build_root/$build_id/gocache" \ + "$build_root/$build_id/gomodcache" "$build_root/$build_id/gopath" \ + "$build_root/$build_id/gotmp" + env -i PATH="$PATH" LC_ALL=C HOME=/root GOROOT= GOENV=off GOTOOLCHAIN=local \ + GOFLAGS= GOWORK=off GO111MODULE=on GOEXPERIMENT= GOFIPS140=off \ + GOTELEMETRY=off GOAUTH=off GOVCS='*:off' GOPROXY=https://proxy.golang.org \ + GOSUMDB=sum.golang.org GONOSUMDB= GOPRIVATE= GONOPROXY= GOINSECURE= \ + GOCACHE="$build_root/$build_id/gocache" \ + GOMODCACHE="$build_root/$build_id/gomodcache" \ + GOPATH="$build_root/$build_id/gopath" GOTMPDIR="$build_root/$build_id/gotmp" \ + CGO_ENABLED=0 GOOS=linux GOARCH="$signer_arch" "$signer_tune" \ + timeout -k 30 900 go build -mod=readonly -buildvcs=false -trimpath \ + -o "$build_root/$build_id/lta-release" ./cmd/lta-release + done + timeout -k 5 60 cmp "$build_root/a/lta-release" "$build_root/b/lta-release" + [[ -d /opt && ! -L /opt && "$(readlink -f -- /opt)" == /opt ]] \ + || fail "/opt must be a canonical real directory" + check_safe_source_dir /opt + if [[ -e /opt/lta-release-tools || -L /opt/lta-release-tools ]]; then + check_safe_source_dir /opt/lta-release-tools + else + timeout -k 5 60 install -d -o 0 -g 0 -m 0700 /opt/lta-release-tools + check_safe_source_dir /opt/lta-release-tools + fi + [[ ( ! -e /opt/lta-release-tools/lta-release && ! -L /opt/lta-release-tools/lta-release ) \ + || ( -f /opt/lta-release-tools/lta-release && ! -L /opt/lta-release-tools/lta-release ) ]] \ + || fail "trusted signer destination is a symlink or special file" + install_stage=$(mktemp /opt/lta-release-tools/.lta-release.XXXXXXXXXX) + timeout -k 5 60 install -o 0 -g 0 -m 0755 "$build_root/a/lta-release" "$install_stage" + timeout -k 5 60 cmp "$build_root/a/lta-release" "$install_stage" + timeout -k 5 60 mv -Tf -- "$install_stage" /opt/lta-release-tools/lta-release + timeout -k 5 60 cmp "$build_root/a/lta-release" /opt/lta-release-tools/lta-release + timeout -k 5 60 sha256sum /opt/lta-release-tools/lta-release +LTA_TRUSTED_SIGNER +``` + +The preparation workstation must likewise have Go 1.26.5 installed as its +local toolchain. An automatically downloaded toolchain is not sufficient: the +version gate and every reproducible build use `GOENV=off GOTOOLCHAIN=local +GOFLAGS= GOWORK=off` so they cannot inspect one compiler and build with another +or inherit an unrelated workspace file. The trusted signer setup, CI, and +preparation all disable shared Go caches for release-critical builds, use fresh +private build/module caches, require the public Go module proxy and checksum +database, disable direct VCS fetching, and clear caller-controlled `GOROOT`, +experiment, FIPS, telemetry, and authentication settings. The signer is built +twice with independent caches and installed only after the outputs compare +byte-for-byte. Run the setup from a separately reviewed, canonical source tree +whose complete contents and ancestry are root-owned and not writable by another +account; the block verifies that boundary before Git or Go sees the tree. It also +enters a clean privileged Bash, disables caller Git configuration and replacement +objects, rejects shared object/metadata stores and submodules, requires `HEAD` to +equal the independently recorded commit, and validates `/tmp`. The actual builds +consume a bounded private `git archive` snapshot of that exact commit, then hash +every extracted file back to its Git blob before compiling. Worktree attributes, +filters, ignored files, and local Git configuration therefore cannot change the +build input. Two independent cache roots must produce identical signer bytes. + +Copy audited versions of `prepare-release.sh` and `publish-release.sh` to the +online release workstation. Copy `offline-sign-release.sh` and `lta-release` to +the air-gapped signing machine. Record the signer SHA-256 separately on that +machine. Do not replace those trusted copies from a candidate tag; candidate +files are inputs, never release tooling. + +The offline and publishing scripts pin the trusted signer/verifier through an +open `/proc` descriptor, then validate its owner and mode, hash it, and execute +that descriptor throughout the run. Replacing its pathname after the hash +therefore cannot change the executed inode. The trusted scripts use an absolute +Bash interpreter in privileged mode so exported shell functions and `BASH_ENV` +are ignored, replace the caller's `PATH` with root-controlled system locations, +and neutralize caller-controlled OpenSSL module configuration on the networked +phases. Execute the trusted scripts directly as shown below; invoking them as +`bash script.sh` is rejected because it bypasses the protected shebang. Keep +`/opt/lta-release-tools` root-owned and non-writable by other users; the +descriptor pin is defense in depth, not permission to use an attacker-writable +tool directory. The scripts independently reject a signer/verifier path with a +symlinked, foreign-owned, or group/world-writable ancestor. Preparation and +signing output paths must be new canonical paths whose existing parents are +protected by ownership and mode or by root-owned sticky-directory semantics; +the path is checked again immediately before creation. Network clients, local Git/GPG operations, removable-media +copies, compiler runs, and signer/verifier invocations all have hard timeouts. +Each trusted phase also disables core dumps before reading any release input; +the online phases also disable Git credential prompts and lazy object fetching, +GitHub CLI prompts/pagers, and GnuPG automatic key retrieval. + +All three trusted phases create private snapshots below `/tmp`. They refuse to +run unless `/tmp` is a real, root-owned directory with exactly the sticky special +bit set. This prevents another local account that owns a nonstandard temporary +directory from +renaming a validated private snapshot out from under the ceremony. Repair the +host configuration before proceeding; do not patch the trusted script or move +release inputs into an untrusted temporary tree to bypass this gate. The online +scripts overwrite and export `GH_HOST=github.com`, clear inherited proxy, TLS, +and Git overrides, and use a new private GitHub CLI configuration directory. +Authentication must come from an explicit short-lived `GH_TOKEN`, so caller +configuration cannot redirect authenticated release operations to another host. +In the commands below the already-sanitized root shell reads that token silently +from `/dev/tty`, validates it, and only then exports it to the trusted script. +The token therefore appears in neither shell history nor the `sudo`/`env` command +line. It remains available only in the environment of the root release process +and the GitHub CLI children that require it. + +Record the maintainer OpenPGP signing-key fingerprint through an independent +channel. Both online phases require that exact primary or signing-subkey +fingerprint from GnuPG's `VALIDSIG` status; a merely valid signature from another +key in the local keyring is rejected. + +Before enabling release staging, configure these repository controls. They are +part of the release trust boundary and cannot be enforced by files inside the +repository: + +1. Protect `main` with a ruleset that requires pull requests, independent + approval including CODEOWNERS for `.github/workflows/**`, `scripts/**`, + `cmd/lta-release/**`, and this document, required CI status checks, resolved + conversations, and blocks force pushes and deletion. Do not permit bypass by + ordinary release operators. +2. Protect `v*` tags with a ruleset that restricts creation, update, and deletion + to the designated release maintainers. The pipeline additionally requires an + annotated tag with a valid OpenPGP signature and independently pins its exact + signing fingerprint. +3. Create an environment named `release-staging`, require approval by a reviewer + other than the triggering operator, prevent self-review, disable administrator + bypass, and restrict deployments to the protected `main` branch. A + `workflow_run` receiver executes from the default branch even though it + validates and stages the triggering `v*` tag. +4. Only after verifying those controls, set the repository Actions variable + `LTA_RELEASE_ENVIRONMENT_CONFIGURED` to the exact value `true`. Missing or + different values fail closed before the write-capable job can run. Remove the + variable immediately if the environment or rulesets are weakened. +5. Keep the repository's default Actions token permission read-only and do not + allow Actions to approve pull requests. + +The staging workflow uses one fixed repository-wide concurrency group, so only +one draft writer runs at a time even when multiple tags are pushed. GitHub does +not provide an atomic compare-and-set operation spanning release enumeration and +the Latest pointer. The release coordinator must therefore also hold one +organization-wide publication lock for the entire preparation, offline signing, +and publication ceremony. Do not prepare or publish two tags concurrently from +different workstations or workflow runs. + +This tag-signing key is separate from the offline ed25519 release key. Before +the first release under this process, generate or import the maintainer OpenPGP +key, configure Git's `user.signingkey` for it, protect its private half, and +record the full fingerprint. Historical unsigned tags do not satisfy this gate. + +Generate the key only on the air-gapped machine: + +```bash +/opt/lta-release-tools/lta-release keygen /offline/keys/release-v1.key +``` + +The key creator/loader checks the complete directory ancestry by opening each +component relative to its already pinned parent descriptor with +`openat(O_DIRECTORY|O_NOFOLLOW)`, then opens the leaf with `openat(O_NOFOLLOW)`. +It refuses unsafe writable ancestors, +symlinks, non-regular files, another owner, special mode bits, and anything +other than mode `0600`. Back up the key in at least two offline places. A leak +permits forged root-level upgrades; losing it prevents unattended upgrades by +clients that trust only that key. + +## Keyring + +[`internal/selfmanage/release_pubkey.hex`](../internal/selfmanage/release_pubkey.hex) +is a keyring: every non-comment line is one complete 64-hex-character ed25519 +public key. Malformed and duplicate entries disable upgrade fail-closed. The +installer contains the same keys as PEM blocks; a Go test parses both and +requires exact ordered equality. + +After changing the keyring, generate the installer blocks with the fixed tool: + +```bash +/opt/lta-release-tools/lta-release pem internal/selfmanage/release_pubkey.hex +go test ./internal/selfmanage ``` -`keygen` is create-only: it refuses an existing path and any symlink instead of -overwriting key material. Move the old file aside deliberately before a rotation. +Paste the complete output between `LTA_RELEASE_KEYS_BEGIN` and +`LTA_RELEASE_KEYS_END` in `scripts/install.sh`. CI refuses a mismatch. + +## Release sequence -Replace the hex line in `internal/selfmanage/release_pubkey.hex` with the printed -public key and commit. (Rotation only takes effect on installs that upgrade to a -build carrying the new key.) +### 1. Create a reviewed signed tag + +The candidate commit must already be in `origin/main`. Use an OpenPGP-signed tag; +`prepare-release.sh` and `publish-release.sh` both verify its signature against +the independently pinned fingerprint and compare the local tag object with +GitHub. + +```bash +git switch main +git pull --ff-only +git tag -s v2.8.0 -m 'linux-temp-admin v2.8.0' +git push origin v2.8.0 +``` -## Cut a release +The `Release` workflow uses exactly Go 1.26.5. Its read-only `gate-build` job +runs vet, uncached race tests, root integration tests, formatting, shell checks, +the mirror receiver policy tests, a clean-worktree check, and +`govulncheck v1.6.0`, then builds static +amd64/arm64 binaries with fixed tuning, `GOWORK=off`, `-mod=readonly`, and +`-buildvcs=false`. The ordinary Go workflow uses the same pinned vulnerability +scanner version; a floating audit tool is not part of the release decision. A +separate `Stage Release Draft` `workflow_run`, whose definition comes from the +default branch rather than the candidate tag, receives only those artifacts and +uses its narrowly scoped write token to create a new unsigned draft. Candidate +tag workflows never receive a write token. The stage job requires GitHub to +recognize the annotated tag's OpenPGP signature; the online trusted phases still +pin and verify the exact signer fingerprint independently. Both workflows +enforce the clients' 64 MiB binary limit before staging. CI refuses to refresh any existing draft; +investigate and deliberately remove a bad draft before rerunning instead of +overwriting bytes that an offline ceremony may already have signed. -The build runs in CI; **signing stays offline** — the signing key never touches -GitHub Actions. Two steps: +### 2. Online preparation, with no private key -**1. Tag → CI tests and builds a draft.** Push an exact semantic-version tag with major 2 -or newer: `vMAJOR.MINOR.PATCH` or `vMAJOR.MINOR.PATCH-prerelease`. Four-component, -metadata-suffixed, malformed, and pre-v2 tags are rejected. The `Release` workflow -([`.github/workflows/release.yml`](../.github/workflows/release.yml)) builds the -first runs vet, ordinary race tests, root integration race tests, formatting, -shell syntax, and ShellCheck. Only after all gates pass does it build static -`linux/amd64` + `linux/arm64` binaries (`-trimpath`, version stamped from the -tag), write `SHA256SUMS`, and stage them in a **draft** GitHub Release with -generated final release notes. A rerun may refresh a draft, but refuses to -overwrite assets after publication. +Run the separately installed trusted preparation script. The source argument is +a repository containing the tag; the script disables local Git replacement +objects, checks the commit's ancestry against GitHub's `main` rather than a +caller-configured `origin`, rejects symlinks and submodules, and exports the +self-contained tree with `git archive`. Ignored/untracked files and candidate +scripts therefore cannot affect the build. It checks the successful Release +workflow and draft, rebuilds with exactly Go 1.26.5, `GOWORK=off`, and the CI +flags, then `cmp`s every CI binary byte-for-byte. Draft assets are fetched one +at a time through the authenticated Release Asset API after an advertised-size +preflight and under a kernel file-size limit. The source archive has its own size +limit, and Git/GPG, archive extraction, each GitHub operation, and each +architecture build are independently time-bounded. The prepared transfer +directory is assembled through bounded copies, and its final permission, +manifest-hash, and checksum verification operations are also time-bounded so a +stalled output medium cannot hold this trusted phase forever. -```sh -git tag -a v2.0.1 -m "linux-temp-admin v2.0.1" -git push origin v2.0.1 # CI builds + stages the draft +```bash +LTA_EXPECTED_TAG_SIGNER_FINGERPRINT='' +/usr/bin/sudo /usr/bin/env -i \ + HOME=/root PATH=/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ + LTA_EXPECTED_TAG_SIGNER_FINGERPRINT="$LTA_EXPECTED_TAG_SIGNER_FINGERPRINT" \ + /bin/bash -p <<'LTA_PREPARE_RELEASE' +set -Eeuo pipefail +umask 077 +fail() { echo "error: $*" >&2; exit 1; } +ulimit -c 0 || fail "cannot disable core dumps" +if ! IFS= read -r -s -p 'Short-lived github.com release token: ' GH_TOKEN /dev/tty || : + fail "cannot read GH_TOKEN from the controlling terminal" +fi +printf '\n' >/dev/tty +[[ -n "$GH_TOKEN" && "$GH_TOKEN" != *[[:space:]]* ]] \ + || fail "GH_TOKEN must be one non-empty token without whitespace" +export GH_TOKEN +exec /opt/lta-release-tools/prepare-release.sh \ + v2.8.0 /srv/linux-temp-admin /srv/release-transfer/v2.8.0-prepared +LTA_PREPARE_RELEASE ``` -**2. Sign offline → publish.** On the machine that holds the signing key, sign -the exact CI-built binaries and publish: +Record the printed tag, commit, and prepared-manifest SHA-256 through an +independent operator channel. Transfer the prepared directory to removable +media as data. A compromised CI can choose its binary and checksum together, +but it cannot make those bytes equal the trusted rebuild unless the reviewed +source/toolchain or preparation workstation is also compromised. -```sh -git fetch --tags origin -git switch --detach v2.0.1 -LTA_SIGN_KEY=~/.lta/signing.key scripts/sign-release.sh v2.0.1 +### 3. Air-gapped signing + +Disconnect networking and remove proxy/GitHub credentials. Invoke the trusted +offline script and fixed signer by their permanent paths, not paths copied from +the candidate or transfer media: + +```bash +LTA_SIGN_KEY=/offline/keys/release-v1.key +LTA_TRUSTED_SIGNER=/opt/lta-release-tools/lta-release +LTA_TRUSTED_SIGNER_SHA256='' +LTA_EXPECTED_TAG=v2.8.0 +LTA_EXPECTED_COMMIT='' +LTA_EXPECTED_PREPARED_MANIFEST_SHA256='' +LTA_EXPECTED_RELEASE_SIGNER_PUBKEY='' +/usr/bin/sudo /usr/bin/env -i \ + HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ + LTA_SIGN_KEY="$LTA_SIGN_KEY" LTA_TRUSTED_SIGNER="$LTA_TRUSTED_SIGNER" \ + LTA_TRUSTED_SIGNER_SHA256="$LTA_TRUSTED_SIGNER_SHA256" \ + LTA_EXPECTED_TAG="$LTA_EXPECTED_TAG" LTA_EXPECTED_COMMIT="$LTA_EXPECTED_COMMIT" \ + LTA_EXPECTED_PREPARED_MANIFEST_SHA256="$LTA_EXPECTED_PREPARED_MANIFEST_SHA256" \ + LTA_EXPECTED_RELEASE_SIGNER_PUBKEY="$LTA_EXPECTED_RELEASE_SIGNER_PUBKEY" \ + /opt/lta-release-tools/offline-sign-release.sh \ + /media/in/v2.8.0-prepared /media/out/v2.8.0-signed ``` -[`sign-release.sh`](../scripts/sign-release.sh) downloads the draft's binaries, -verifies their checksums, signs each (`.sig`, raw 64-byte ed25519), -**verifies every signature against the embedded public key (fails closed)**, -refreshes `SHA256SUMS` to cover the `.sig` files, uploads them, and flips the -release from draft to published. It signs the bytes CI actually published, so -the signature is valid for the exact assets users download — no reproducible -build assumption required. Before downloading anything it requires a clean -worktree at the tag, verifies the local tag object equals GitHub's tag object, -and refuses unless the release is still a draft. Prerelease tags remain marked -prerelease; only a stable tag becomes Latest. +The script copies the removable input into a size-bounded private local snapshot +under a hard copy timeout, so a replaced special file or stalled medium cannot +hold the signing ceremony forever or fill the machine before validation. It +strictly validates the tag/version and complete keyring as well as every +manifest and recorded value there, proves the private key's public half exactly +equals the independently selected release signer and is in the candidate +keyring, and calls only the descriptor-pinned signer on that snapshot. Every +signer operation is independently time-bounded. Creation of the signed transfer +directory, every final copy, permission change, manifest hash, and checksum +verification is independently time-bounded as well. +The selected public key is recorded inside the signed-bundle manifest, and every +signature is verified against that exact key rather than any key in the rotation +keyring. It never calls Go, Git, GitHub, curl, or a candidate executable. Remove +the private-key media before transferring the signed directory back online. +Record the printed signed-bundle manifest SHA-256 through an independent +operator channel; do not carry that value only beside the signed directory. + +### 4. Online publication, with no private key + +Use the fixed signer as a verifier only: + +```bash +LTA_TRUSTED_SIGNER=/opt/lta-release-tools/lta-release +LTA_TRUSTED_SIGNER_SHA256='' +LTA_EXPECTED_SIGNED_BUNDLE_MANIFEST_SHA256='' +LTA_EXPECTED_TAG_SIGNER_FINGERPRINT='' +LTA_EXPECTED_RELEASE_SIGNER_PUBKEY='' +/usr/bin/sudo /usr/bin/env -i \ + HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ + LTA_TRUSTED_SIGNER="$LTA_TRUSTED_SIGNER" \ + LTA_TRUSTED_SIGNER_SHA256="$LTA_TRUSTED_SIGNER_SHA256" \ + LTA_EXPECTED_SIGNED_BUNDLE_MANIFEST_SHA256="$LTA_EXPECTED_SIGNED_BUNDLE_MANIFEST_SHA256" \ + LTA_EXPECTED_TAG_SIGNER_FINGERPRINT="$LTA_EXPECTED_TAG_SIGNER_FINGERPRINT" \ + LTA_EXPECTED_RELEASE_SIGNER_PUBKEY="$LTA_EXPECTED_RELEASE_SIGNER_PUBKEY" \ + /bin/bash -p <<'LTA_PUBLISH_RELEASE' +set -Eeuo pipefail +umask 077 +fail() { echo "error: $*" >&2; exit 1; } +ulimit -c 0 || fail "cannot disable core dumps" +if ! IFS= read -r -s -p 'Short-lived github.com release token: ' GH_TOKEN /dev/tty || : + fail "cannot read GH_TOKEN from the controlling terminal" +fi +printf '\n' >/dev/tty +[[ -n "$GH_TOKEN" && "$GH_TOKEN" != *[[:space:]]* ]] \ + || fail "GH_TOKEN must be one non-empty token without whitespace" +export GH_TOKEN +exec /opt/lta-release-tools/publish-release.sh \ + /srv/release-transfer/v2.8.0-signed /srv/linux-temp-admin +LTA_PUBLISH_RELEASE +``` -The release is public only after step 2, so users never see an unsigned release. +The publisher first makes a size-bounded snapshot of the transferred directory +under a hard copy timeout and binds that private copy to the independently +recorded signed-bundle manifest hash, then verifies the manifest, pinned tag +signer, self-contained source tree, GitHub `main` ancestry, successful CI run, +keyring, checksums, and both +signatures. While the +release is still a draft it rejects any missing or extra assets, replaces all +expected assets with the exact signed bytes, checks the complete asset list +again, downloads the draft, compares every byte, and checks GitHub's SHA-256 +digest for every asset immediately before publication. Stable versions must be +strictly newer than every published stable release, and the current Latest must +already equal that maximum and remain unchanged during preparation; prereleases +never become Latest. It publishes every release initially with `--latest=false`, +downloads and verifies the public versioned assets with bounded retries and hard +file limits, and only then promotes a stable tag to Latest. Before the first +remote mutation it preflights every command needed by the remaining publication +and verification path, including `curl` and `timeout`. A final enumeration +detects a concurrently published higher stable tag and restores Latest to the +exact highest stable release other than the current tag. If there is no other +stable release, it clears Latest and confirms the REST Latest route is exactly a +404; authentication or transport errors never count as that empty state. The +Latest route is then independently compared, checksummed, and +signature-verified for both architectures, followed by another highest-version +check. An EXIT trap performs the same exact restoration after +any error or signal following a possibly applied promotion. These checks narrow +the race window but cannot make the GitHub API transactional; the mandatory +global publication lock above remains the control that prevents two authorized +publishers from overlapping. -### Fully local fallback +The publication command is deliberately resumable after the release has become +public. It accepts that state only when the tag, draft/prerelease flags, complete +asset-name set, sizes, GitHub SHA-256 digests, signed-bundle bytes, versioned +public downloads, checksums, and ed25519 signatures all still match exactly. It +does not clobber assets on a published release. This covers interruption after +publication, after Latest promotion, or during final CDN verification without +weakening the signed-bundle binding. If the release is already Latest when a +read-only resume begins, a later verification or transport failure leaves that +pre-existing state unchanged; automatic restoration is armed only before a +mutation attempted by the current run. -If CI is unavailable, build, sign, and publish entirely locally in one shot: +If public CDN verification fails after publication, treat the release as +incomplete: do not announce it. Diagnose transport separately from checksum or +signature failure. A `?download=1` retry is used only as a cache-bypass transport +attempt; it never bypasses cryptographic verification. -```sh -LTA_SIGN_KEY=~/.lta/signing.key scripts/release.sh 2.0.1 -gh release create v2.0.1 dist/linux-temp-admin-linux-* dist/SHA256SUMS --title v2.0.1 +Keep the signed bundle and all independently recorded values. After correcting +a transient GitHub/CDN or local-tool problem, rerun the identical +`publish-release.sh` command and environment shown above. The script either +finishes the exact release or fails closed before changing a mismatched public +release. Never delete, replace, or manually clobber assets on an already +published release to make a retry pass. + +If the script reports that automatic Latest restoration itself failed, keep the +release unannounced and retain the organization-wide publication lock. Restore +network/API access, rerun the same publisher first, and inspect its exact result. +For manual incident recovery only, use the following fail-closed procedure. It +uses decimal-string comparison rather than machine-sized arithmetic, rejects a +noncanonical published stable tag, excludes the failed `TAG`, and verifies the +exact resulting Latest state: + +```bash +TAG=v2.8.0 # the failed release; verify this value before running +/usr/bin/sudo /usr/bin/env -i \ + HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ + TAG="$TAG" /bin/bash -p <<'LTA_LATEST_RECOVERY' +set -Eeuo pipefail +umask 077 +fail() { echo "error: $*" >&2; exit 1; } +ulimit -c 0 || fail "cannot disable core dumps for Latest recovery" +if ! IFS= read -r -s -p 'Short-lived github.com release token: ' GH_TOKEN /dev/tty || : + fail "cannot read GH_TOKEN from the controlling terminal" +fi +printf '\n' >/dev/tty +[[ -n "$GH_TOKEN" && "$GH_TOKEN" != *[[:space:]]* ]] \ + || fail "GH_TOKEN must be one non-empty token without whitespace" +REPO=xxvcc/linux-temp-admin +[[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + +work="$(mktemp -d /root/.lta-latest-recovery.XXXXXX)" +GH_CONFIG_DIR="$work/gh-config" +mkdir -m 0700 -- "$GH_CONFIG_DIR" +GH_HOST=github.com +GH_PROMPT_DISABLED=1 +GH_PAGER='cat' +export GH_TOKEN GH_CONFIG_DIR GH_HOST GH_PROMPT_DISABLED GH_PAGER +cleanup() { + timeout -k 5 30 rm -rf -- "$work" \ + || echo "warning: could not remove private recovery workspace $work" >&2 +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +gh_with_timeout() { + timeout -k 5 300 gh "$@" +} + +decimal_gt() { + local left=$1 right=$2 + (( ${#left} > ${#right} )) && return 0 + (( ${#left} < ${#right} )) && return 1 + [[ "$left" > "$right" ]] +} +stable_gt() { + local newer=$1 older=$2 nmajor nminor npatch omajor ominor opatch pair left right + [[ "$newer" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + nmajor=${BASH_REMATCH[1]}; nminor=${BASH_REMATCH[2]}; npatch=${BASH_REMATCH[3]} + [[ "$older" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + omajor=${BASH_REMATCH[1]}; ominor=${BASH_REMATCH[2]}; opatch=${BASH_REMATCH[3]} + for pair in "$nmajor:$omajor" "$nminor:$ominor" "$npatch:$opatch"; do + left=${pair%%:*}; right=${pair#*:} + decimal_gt "$left" "$right" && return 0 + decimal_gt "$right" "$left" && return 1 + done + return 1 +} + +release_tags="$(gh_with_timeout api --paginate "repos/${REPO}/releases?per_page=100" \ + --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name')" +fallback= +while IFS= read -r candidate; do + [[ -n "$candidate" ]] || continue + [[ "$candidate" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] \ + || { echo "noncanonical stable tag: $candidate" >&2; exit 1; } + [[ "$candidate" != "$TAG" ]] || continue + if [[ -z "$fallback" ]] || stable_gt "$candidate" "$fallback"; then + fallback=$candidate + fi +done <<<"$release_tags" + +if [[ -n "$fallback" ]]; then + gh_with_timeout release edit "$fallback" --repo "$REPO" --latest + actual="$(gh_with_timeout release view --repo "$REPO" --json tagName --jq '.tagName')" + [[ "$actual" == "$fallback" ]] \ + || { echo "Latest is $actual, expected $fallback" >&2; exit 1; } +else + gh_with_timeout release edit "$TAG" --repo "$REPO" --latest=false + response="$work/latest-response" + set +e + gh_with_timeout api --include "repos/${REPO}/releases/latest" >"$response" 2>&1 + latest_status=$? + set -e + if [[ "$latest_status" -eq 0 ]]; then + echo "Latest still exists when no stable fallback should exist" >&2 + exit 1 + fi + [[ "$latest_status" -eq 1 ]] \ + || { cat "$response" >&2; echo "Latest query failed with unexpected status $latest_status" >&2; exit 1; } + [[ "$(grep -Ec '^HTTP/[0-9.]+ [0-9]{3}([[:space:]]|$)' "$response")" -eq 1 \ + && "$(grep -Ec '^HTTP/[0-9.]+ 404([[:space:]]|$)' "$response")" -eq 1 ]] \ + || { cat "$response" >&2; echo "Latest query did not return an exact 404" >&2; exit 1; } +fi +LTA_LATEST_RECOVERY ``` -`release.sh` uses the same `-trimpath` static build as CI, so it reproduces the -same binaries; it signs each and writes `SHA256SUMS` (covering the sigs too). - -## Install / upgrade on a host - -- **Install** (bootstrap): downloads over HTTPS and verifies both SHA-256 against - the published `SHA256SUMS` and a detached ed25519 signature against the release - key embedded in `install.sh`, before installing. It fails closed on any - mismatch; if openssl (>= 3.0) is unavailable it refuses to install unless - `LTA_ALLOW_UNVERIFIED=1` is set (checksum-only fallback). The script must run as - root, accepts HTTPS redirects only, caps each response at 64 MiB, and requires - curl or wget with both `--https-only` and `--max-filesize` support. - - ```sh - curl -fsSL https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh | sudo sh - ``` - -- **Upgrade** (signature-verified by the binary itself): - - ```sh - sudo linux-temp-admin upgrade - ``` - - Downloads `linux-temp-admin-linux-` + `.sig`, verifies the ed25519 - signature with the embedded key, confirms the downloaded version is newer, - then atomically replaces the installed binary. `--url URL` overrides the source - (its signature is `URL.sig`); `--force` reinstalls regardless of version. - -## Trust model - -- **Bootstrap install**: TLS + SHA-256 checksum + a detached ed25519 signature - verified against the release key embedded in `install.sh` (fails closed; the - same offline key as `upgrade`). All fetched over HTTPS. -- **Upgrades**: TLS + an ed25519 signature that only the offline private key can - produce, verified in-process before anything is written. -- **Release provenance**: binaries are built in CI (auditable workflow logs, - `-trimpath` reproducible) but signed offline — the signing key is never present - in GitHub Actions, so a compromised CI cannot mint a binary that passes - `upgrade`'s signature check. +Any other tag, success response in the no-candidate case, authentication error, +or transport error means recovery is not complete. Do not announce either +release until the publisher completes its full verification successfully. +The sanitized root shell intentionally carries only the prompted token and explicit tag; +it does not inherit proxy, custom CA, GitHub configuration, or credential-helper +settings from the operator environment. Every GitHub CLI operation has a hard +timeout and uses a private, transient configuration directory. + +### 5. Official mirror synchronization and announcement gate + +Publishing the GitHub Release is not the end of the release. The +[`Mirror signed release`](../.github/workflows/mirror-release.yml) workflow must +finish before announcement. It accepts only an immutable public GitHub Release +with the exact five-asset release set, rechecks the checksum manifest and both +ed25519 signatures against the trusted keyring, verifies the released binaries, +and copies one complete release into +`https://dl.ll.cd/linux-temp-admin/vX.Y.Z/`. The version directory also receives +`install.sh` from the released signed tag. Every public versioned file is read +back and compared byte-for-byte before a stable pointer is changed. Only a tag +that is still GitHub Latest may replace the stable `install.sh`; `latest.json` +is written last, then a separate job performs a real root installation and a +same-version forced self-upgrade from the public mirror. That canary first +verifies the stable installer hash and fails if either client reports that it +used the GitHub fallback. + +Create a protected GitHub Environment named `release-mirror`. Require an +independent reviewer, prevent self-review and administrator bypass, and allow +only protected `v*` tags plus the protected default branch used for an explicit +recovery dispatch. Enable immutable Releases for the repository; synchronization +fails closed when the selected GitHub Release is mutable. Configure exactly +these environment values: + +- Actions variables: `MIRROR_HOST`, `MIRROR_PORT`, `MIRROR_USER`, and + `LTA_RELEASE_MIRROR_ENVIRONMENT_CONFIGURED` with the exact value `true`; +- Actions secrets: a dedicated `MIRROR_SSH_KEY` and an independently pinned + `MIRROR_KNOWN_HOSTS` entry. + +Set the configuration gate only after independently verifying the Environment, +tag/default-branch rulesets, host-key pin, SSH receiver, document-root policy, +and public HTTPS configuration. Remove or change it immediately if any of those +controls is weakened. The workflow also requires its own definition to come +from the repository's default branch and refuses any other repository identity. + +#### Mirror host layout + +The production mirror uses the following exact layout. Treat a difference as +configuration drift and clear the Environment configuration gate until it has +been reviewed: + +- `ltamirror` is a password-locked, non-sudo account. Its login shell exists + only so sshd can execute the forced command; its sole authorized key cannot + start an interactive command, PTY, user rc, agent/X11 session, or forwarding. +- [`scripts/mirror-receiver.py`](../scripts/mirror-receiver.py) is installed as + `/usr/local/libexec/linux-temp-admin-mirror-receiver`, owned `root:root` and + mode `0755`. `/usr/bin/rrsync` must also be a canonical, root-owned, + non-writable executable. +- `/var/lib/linux-temp-admin-mirror` is owned `ltamirror:ltamirror`, mode + `0700`, and contains the persistent `.deploy.lock` plus private transient + transfer directories. +- `/www/wwwroot/dl.ll.cd/linux-temp-admin` is owned `ltamirror:www`, mode + `0755`. Its parent directories remain root-owned and not group/world + writable. +- [`deploy/nginx/linux-temp-admin.conf`](../deploy/nginx/linux-temp-admin.conf) + is installed as + `/www/server/panel/vhost/nginx/extension/dl.ll.cd/linux-temp-admin.conf`, + owned `root:root` and mode `0644`. The `dl.ll.cd` virtual host includes that + directory and permits only TLS 1.2 and TLS 1.3. + +`/home/ltamirror/.ssh/authorized_keys` is owned `ltamirror:ltamirror`, mode +`0600`, and contains exactly the dedicated deployment public key with these +options: + +```text +restrict,command="/usr/local/libexec/linux-temp-admin-mirror-receiver" ssh-ed25519 +``` + +The private half never belongs on the mirror host. OpenSSH `restrict` is part of +the boundary, but the forced receiver is what validates the command and content. +It permits only a canonical `vX.Y.Z/` or prerelease directory containing the +exact six expected files. Existing versioned bytes cannot be replaced or +deleted; a retry may only fill a missing file with bytes consistent with the +complete staged checksum set. It rejects reads, traversal, arbitrary rsync +modes, links, special files, and every other destination. Stable `install.sh` +must match a complete non-prerelease version, and canonical `latest.json` is +published last. The receiver refuses a stable downgrade or altered metadata for +the current version. The client-side `--ignore-existing` flag is not itself an +immutability boundary. + +The Nginx include claims the complete `/linux-temp-admin/` namespace before the +virtual host's generic regular-expression locations. It serves only the two +stable files and the six versioned files, disables compression at the origin, +adds `no-transform` and HSTS, disables directory listing, and rejects HTTP write +methods. Versioned objects use +`Cache-Control: public, max-age=31536000, immutable, no-transform`; stable and +unknown routes use `no-store, no-cache, must-revalidate, no-transform`. The CDN +must preserve these policies and the downloaded representation bytes. + +#### Maintenance and recovery + +Before installing a receiver change, run its tests without creating bytecode, +then install it through a root-owned temporary file and atomic rename. An active +SSH session may continue using the old inode, so wait for it to finish before +declaring the rollout complete: + +```bash +python3 -B -m unittest -v scripts/mirror_receiver_test.py +cmp scripts/mirror-receiver.py /usr/local/libexec/linux-temp-admin-mirror-receiver +``` + +The final `cmp` is a post-install drift check and must succeed. For an Nginx +change, preserve the prior root-owned include, install the reviewed repository +copy, run `nginx -t`, reload the `nginx` service only after the syntax check, and +repeat the public header, method, unknown-path, and byte-comparison probes. If +syntax, reload, or a public probe fails, restore the preserved include, retest, +reload, and keep the release gate closed. + +Recovery is deliberately narrow: + +1. If a transfer stopped before all six immutable files arrived, dispatch the + mirror workflow again for the same immutable GitHub tag. The receiver checks + every existing byte and fills only the missing files. +2. If `install.sh` changed but `latest.json` did not, rerun the current GitHub + Latest tag. The workflow republishes the installer first and the manifest + last; clients remain signature-verified during the interrupted state. +3. If any existing versioned byte differs, an extra path exists, a checksum or + signature fails, or the public response is transformed, stop synchronization + and announcement. Preserve the host and CDN evidence, disable or rotate the + deployment credential, and investigate. Do not delete or overwrite the + version directory as routine recovery. +4. A leftover `transfer-*` directory may be quarantined only after confirming + that no `ltamirror` receiver or rsync process is active. `.deploy.lock` is + persistent state and must not be treated as a stale transfer. +5. After total mirror loss, rebuild this empty layout, restore the reviewed + receiver and Nginx include, rotate the deployment key, and dispatch each + required immutable tag. Dispatch the current GitHub Latest tag after its + version bytes are present so stable files are reconstructed last. Repeat all + independent public checks before reopening the announcement gate. + +An intentional emergency downgrade is not normal deployment-key recovery: the +receiver blocks it. It requires a separately approved root-host incident +procedure, explicit client downgrade handling, and a new independent audit. + +Do not announce a release until both mirror workflow jobs are green and an +independent network check has fetched `latest.json`, the selected version's +checksum manifest, binaries, and signatures from the public hostname, compared +them with the immutable GitHub assets, verified both architectures, and run a +real mirror bootstrap plus a routine self-upgrade canary. A mirror transport +failure may be repaired and the exact immutable tag resynchronized with +`workflow_dispatch`; a checksum, signature, tag, version, or manifest mismatch +is an integrity incident, not a reason to announce with GitHub-only instructions. + +`scripts/sign-release.sh` and `scripts/release.sh` intentionally exit with an +error. The old online one-step signer and unchecked local fallback must not be +used. + +## Planned key rotation + +A single signature cannot be valid under two unrelated ed25519 keys. Rotation +therefore requires an overlap release and a migration window: + +1. Generate NEW on the air-gapped machine; retain OLD. +2. Add both public keys to the candidate keyring, ordered `OLD`, then `NEW`, and + add both installer PEM blocks. CI tests their equality. +3. Record OLD's complete 64-hex public key independently, set it as + `LTA_EXPECTED_RELEASE_SIGNER_PUBKEY`, and publish a transition release + containing both keys but signed with OLD. Every + existing client can install it; the resulting binary trusts both keys. +4. Keep publishing with OLD during the announced migration window. Preserve a + versioned transition release and explicit recovery instructions. +5. Switch both the private-key path and independently expected signer public key + to NEW while keeping both public keys for another support window. Only after + the supported fleet has crossed the transition may OLD be removed from new + binaries and the installer. + +A host offline for the whole migration window still trusts only OLD and cannot +authenticate a NEW-only latest release. It must install the preserved OLD-signed +transition release or use an independently verified manual bootstrap. This is a +cryptographic limitation, not something version comparison can solve. + +If OLD is compromised, do not use it for a transition: an attacker can create an +indistinguishable transition too. Existing OLD-only clients require an +out-of-band trust recovery and should be treated as potentially compromised. + +## Host install and upgrade + +Bootstrap installation requires root, OpenSSL 3, sha256sum, timeout, and curl: + +```bash +/usr/bin/sudo /usr/bin/env -i \ + HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ + /bin/sh <<'LTA_BOOTSTRAP' +set -eu +umask 077 +fail() { echo "error: $*" >&2; exit 1; } +ulimit -c 0 || fail "cannot disable core dumps" +[ -d /tmp ] && [ ! -L /tmp ] || fail "/tmp is not a real directory" +tmp_meta=$(stat -Lc '%u %a' -- /tmp) || fail "cannot inspect /tmp" +case "$tmp_meta" in + "0 1"[0-7][0-7][0-7]) ;; + *) fail "/tmp must be root-owned, sticky, and free of other special bits" ;; +esac + +if ! FSIZE_BLOCK_BYTES=$( + ulimit -f 1 || exit 1 + awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } + END { if (!found) exit 1 }' /proc/self/limits +); then + fail "cannot determine the shell file-size limit unit" +fi +case "$FSIZE_BLOCK_BYTES" in + 512 | 1024) ;; + *) fail "unsupported shell file-size limit unit" ;; +esac +INSTALLER_MAX_BYTES=1048576 +INSTALLER_BLOCKS=$(( (INSTALLER_MAX_BYTES + FSIZE_BLOCK_BYTES - 1) / FSIZE_BLOCK_BYTES )) +installer=$(mktemp /tmp/.lta-bootstrap.XXXXXXXXXX) \ + || fail "cannot create root-owned installer file" +cleanup() { rm -f -- "$installer"; } +trap cleanup 0 +trap 'exit 1' HUP INT TERM +installer_downloaded=0 +for installer_url in \ + https://dl.ll.cd/linux-temp-admin/install.sh \ + https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh +do + installer_download_rc=0 + ( + ulimit -f "$INSTALLER_BLOCKS" || exit 1 + exec timeout -k 5 70 curl -q --fail --silent --show-error --location --max-redirs 0 \ + --connect-timeout 10 --max-time 60 --max-filesize "$INSTALLER_MAX_BYTES" \ + --proto '=https' --proto-redir '=https' \ + --output "$installer" "$installer_url" + ) || installer_download_rc=$? + if [ "$installer_url" = https://dl.ll.cd/linux-temp-admin/install.sh ] && \ + [ "$installer_download_rc" -eq 47 ]; then + fail "official mirror installer redirected; refusing source-policy fallback" + fi + if [ "$installer_download_rc" -eq 0 ]; then + installer_size=$(wc -c < "$installer") || fail "cannot measure installer" + case "$installer_size" in + '' | *[!0-9]*) fail "invalid installer size" ;; + esac + if [ "$installer_size" -gt 0 ] && [ "$installer_size" -le "$INSTALLER_MAX_BYTES" ]; then + installer_downloaded=1 + break + fi + fi +done +[ "$installer_downloaded" -eq 1 ] || fail "installer download failed or exceeded its limit" +/bin/sh "$installer" +LTA_BOOTSTRAP +``` + +That convenience bootstrap tries the official mirror first and uses raw GitHub +only after an installer transport, empty-response, or size-limit failure. An +official-mirror redirect is a source-policy failure and aborts instead. It +trusts the TLS and mutable script route of the source ultimately used. The +mirrored installer is copied from the released signed tag, but is not itself an +offline-ed25519-signed GitHub Release asset. +For a high-assurance first install, obtain all three values below through +the review/signing record and an independent authenticated channel, then run: + +```bash +INSTALLER_COMMIT='replace-with-the-audited-40-hex-commit' +INSTALLER_SHA256='replace-with-the-independent-64-hex-script-hash' +LTA_RELEASE_TAG='v2.8.0' +/usr/bin/sudo /usr/bin/env -i \ + HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ + INSTALLER_COMMIT="$INSTALLER_COMMIT" INSTALLER_SHA256="$INSTALLER_SHA256" \ + LTA_RELEASE_TAG="$LTA_RELEASE_TAG" /bin/bash <<'LTA_BOOTSTRAP' +set -Eeuo pipefail +umask 077 +fail() { echo "error: $*" >&2; exit 1; } +ulimit -c 0 || fail "cannot disable core dumps" + + : "${INSTALLER_COMMIT:?set the audited 40-hex commit}" + : "${INSTALLER_SHA256:?set the independently verified 64-hex script hash}" + : "${LTA_RELEASE_TAG:?set the exact vX.Y.Z release tag}" + [[ "$INSTALLER_COMMIT" =~ ^[0-9a-f]{40}$ ]] \ + || fail "invalid INSTALLER_COMMIT" + [[ "$INSTALLER_SHA256" =~ ^[0-9a-f]{64}$ ]] \ + || fail "invalid INSTALLER_SHA256" + [[ "$LTA_RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] \ + || fail "invalid LTA_RELEASE_TAG" + + if [[ ! -d /tmp || -L /tmp ]]; then + fail "/tmp is not a real directory" + fi + if ! tmp_meta=$(stat -Lc '%u %a' -- /tmp); then + fail "cannot inspect /tmp metadata" + fi + [[ "$tmp_meta" =~ ^0\ 1[0-7]{3}$ ]] \ + || fail "/tmp must be root-owned, sticky, and free of other special bits" + + if ! FSIZE_BLOCK_BYTES=$( + ulimit -f 1 || exit 1 + awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } + END { if (!found) exit 1 }' /proc/self/limits + ); then + fail "cannot determine the shell file-size limit unit" + fi + case "$FSIZE_BLOCK_BYTES" in + 512 | 1024) ;; + *) fail "unsupported shell file-size limit unit" ;; + esac + INSTALLER_MAX_BYTES=1048576 + INSTALLER_BLOCKS=$(( (INSTALLER_MAX_BYTES + FSIZE_BLOCK_BYTES - 1) / FSIZE_BLOCK_BYTES )) + installer=$(mktemp /tmp/.lta-bootstrap.XXXXXXXXXX) \ + || fail "cannot create root-owned installer file" + cleanup() { rm -f -- "$installer"; } + trap cleanup EXIT + trap 'exit 1' HUP INT TERM + if ! ( + ulimit -f "$INSTALLER_BLOCKS" || exit 1 + exec timeout -k 5 70 curl -q --fail --silent --show-error --location \ + --connect-timeout 10 --max-time 60 --max-filesize "$INSTALLER_MAX_BYTES" \ + --proto '=https' --proto-redir '=https' \ + --output "$installer" \ + "https://raw.githubusercontent.com/xxvcc/linux-temp-admin/${INSTALLER_COMMIT}/scripts/install.sh" + ); then + fail "installer download failed or exceeded its limit" + fi + installer_size=$(wc -c < "$installer") || fail "cannot measure installer" + [[ "$installer_size" =~ ^[0-9]+$ ]] \ + || fail "invalid installer size" + (( installer_size > 0 && installer_size <= INSTALLER_MAX_BYTES )) \ + || fail "installer is empty or oversized" + printf '%s %s\n' "$INSTALLER_SHA256" "$installer" | sha256sum -c - + /usr/bin/env -i HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ + LTA_RELEASE="$LTA_RELEASE_TAG" /bin/sh "$installer" +LTA_BOOTSTRAP +``` + +Both downloads start only after a sanitized root shell is running. That root +process validates the pinned values, creates the unpredictable `0600` file in +an explicit trusted `/tmp`, downloads directly into it, checks its bounded size, +and (for the high-assurance flow) verifies the independent hash before execution. +No caller-owned inode is copied, opened, or executed by root. `/tmp` is accepted +only when it is a real root-owned directory with exactly the sticky special bit; +another unprivileged user then cannot replace or remove the root-owned file. +Root itself and the kernel/filesystem remain trusted. `/bin/sh` reads the file, +so a `noexec` mount does not prevent this procedure. The exact release selector +then prevents a release host from replaying a different still-valid signed version. + +Each bootstrap download disables `.curlrc`, limits both the initial and redirected +protocols to HTTPS, and runs curl only after setting a kernel `RLIMIT_FSIZE` +calculated from the active shell's measured 512- or 1024-byte unit. It completes +successfully before any downloaded text is interpreted as shell code. The +installer has no unsigned/checksum-only fallback. It drops imported shell +functions before fixing a trusted root `PATH`, disables core dumps, neutralizes +caller-controlled OpenSSL configuration/provider paths, and gives each download +explicit connect/total timeouts plus a kernel `RLIMIT_FSIZE`, so chunked +responses and old curl versions cannot bypass the hard cap. + +For `latest`, the installer first reads the official mirror's strict canonical +`latest.json` and pins its exact `vX.Y.Z` tag. It then downloads +`SHA256SUMS`, the selected architecture binary, and its detached signature as a +complete set from that version directory. A transport failure discards the +whole mirror set and downloads all three files again from the same GitHub tag; +only a transport failure while obtaining the mirror index itself uses GitHub +Latest. An explicit `LTA_RELEASE=vX.Y.Z` similarly tries that exact mirror +directory, then the same GitHub tag after transport failure. Manifest-semantic, +checksum, signature, and candidate-version failures stop immediately without +fallback, and files from the two sources are never combined. The GitHub path +uses bounded retries and adds `download=1` only on later GitHub attempts to +bypass a stuck Release CDN entry. Every official mirror URL must return its +file directly with status 200; a redirect is a source-policy failure and never +selects GitHub. `latest.json` must be the canonical single-line JSON emitted by +the workflow, `SHA256SUMS` must contain lowercase digests in NUL-free text that +ends with a newline, and detached signatures must be exactly 64 raw bytes. + +After verification, the installer validates every destination ancestor as +root-owned and not group/world writable, creates an unpredictable `0600` +staging file beside the destination, probes the signed candidate under +time/output limits, verifies the staging copy again, and only then atomically +renames it into place. Passing `LTA_RELEASE_TAG=vX.Y.Z` through the validated +high-assurance bootstrap selects that exact release and requires the signed +candidate to report exactly `X.Y.Z`. + +Routine self-upgrade is: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade +``` + +The Go upgrader follows the same complete-source rule. It obtains the strict +mirror manifest, pins its exact tag, and downloads `SHA256SUMS`, the binary, and +signature from the official mirror. Only a transport failure selects GitHub; +after a valid manifest, the fallback redownloads the complete same-tag set. +Manifest-semantic, checksum, signature, and candidate-version failures stop +without fallback. Explicit `--url` and `--url-file` requests retain their +operator-selected binary/signature source and never silently switch to either +official source. + +Every upgrader download accepts only HTTPS, rejects private/reserved redirect +targets at the actual dial point, and permits a private initial address only +for an explicit operator-selected custom URL. Official mirror requests do not +redirect; GitHub Release requests may follow public HTTPS redirects. Every +response is hard-limited and transport errors plus 408/425/429/5xx statuses are +boundedly retried. Structured +`download=1` cache bypass is limited to later official GitHub Release attempts +and never modifies a custom URL. The upgrader verifies checksums and the +detached signature against the embedded keyring, bounds the candidate version +process, compares versions, and atomically installs root:root `0755`. A +byte-identical existing target is a no-op only if its parent and all target +metadata are already safe; otherwise it is atomically repaired. + +## Trust boundaries and residual risk + +- The private key is protected from candidate code, CI, GitHub, and networked + preparation/publication. The air-gapped OS, fixed signer binary, trusted + offline script, and physical transfer procedure remain trusted. +- Reproducible comparison binds CI bytes to the reviewed tag under the fixed Go + toolchain. The reviewed source, signed-tag identity, trusted preparation copy, + Go distribution, and preparation workstation remain trusted. +- The convenience bootstrap obtains its script and embedded trust anchors over + TLS from the official mirror. The mirror takes that installer from the signed + Git tag, but the script is not currently an offline-ed25519-signed Release + asset. High-assurance bootstrap therefore still uses an audited 40-hex commit + in the raw GitHub URL and verifies the script hash through an independent + channel before running it. +- A fresh bootstrap has no previously installed version state, so a release-host + compromise can replay an older release that still has a valid offline + signature. Pin a reviewed installer commit and pass `LTA_RELEASE=vX.Y.Z` when + rollback resistance is required for first installation; the installer rejects + a candidate whose reported version does not exactly match that tag. +- Publication is not transactionally coupled to public CDN verification. If the + CDN remains unavailable after the bounded checks, the already-published + release needs explicit operator remediation and must not be announced. +- GitHub publication and mirror synchronization are separate operations. The + mirror host, TLS/CDN configuration, restricted deployment receiver, protected + `release-mirror` environment, and announcement gate remain trusted operational + controls; failure of the mirror workflow leaves the public GitHub Release + unannounced rather than weakening client verification. +- GitHub has no atomic compare-and-set across "find the highest stable release" + and "mark this release Latest". Repeated checks and automatic demotion detect + observed conflicts, but cannot eliminate an operation that starts immediately + after the last check. The protected environment and organization-wide + single-publisher lock are mandatory operational controls. diff --git a/go.mod b/go.mod index 6fcc716..1b3429f 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,4 @@ require ( golang.org/x/term v0.45.0 ) -require golang.org/x/sys v0.47.0 // indirect +require golang.org/x/sys v0.47.0 diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 116c718..a3eb9d5 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -12,13 +12,22 @@ package audit import ( "encoding/json" + "errors" "fmt" + "io" "os" + "path/filepath" "syscall" "time" "github.com/xxvcc/linux-temp-admin/internal/config" "github.com/xxvcc/linux-temp-admin/internal/fsutil" + "golang.org/x/sys/unix" +) + +const ( + maxAuditRecordBytes = 64 << 10 + maxAuditLogBytes = int64(64 << 20) ) // Event is a single auditable operation, supplied by the caller. @@ -50,6 +59,10 @@ type Logger struct { File string Now func() time.Time Actor func() (actor string, uid int) + + // write and sync are failure-injection hooks. Production leaves them nil. + write func(*os.File, []byte) (int, error) + sync func(*os.File) error } // Default returns a Logger writing to the configured audit-log path. @@ -69,9 +82,12 @@ func realActor() (string, int) { } // Log appends one event. It is best-effort from the caller's perspective (it -// returns any error so the caller can warn) but never partially writes: the JSON -// line is assembled in memory and written with a single append. A nil/empty-path -// Logger is a no-op, which disables auditing (e.g. in tests). +// returns any error so the caller can warn). New writers serialize with flock; +// a failed write is truncated back to its locked starting size and the completed +// line is synced before success. This sharply limits partial tails, but an on-host +// log cannot promise atomicity across a kernel/filesystem crash or a concurrent +// writer from an older build that does not honor the lock. A nil/empty-path Logger +// is a no-op, which disables auditing (e.g. in tests). func (l *Logger) Log(ev Event) error { if l == nil || l.Dir == "" || l.File == "" { return nil @@ -109,16 +125,146 @@ func (l *Logger) Log(ev Event) error { return err } line = append(line, '\n') - // Append-only, refusing to follow a symlink planted at the path. A single - // write of a bounded line is atomic under O_APPEND on a local filesystem. - f, err := os.OpenFile(l.File, os.O_WRONLY|os.O_CREATE|os.O_APPEND|syscall.O_NOFOLLOW, 0o600) + if len(line) > maxAuditRecordBytes { + return fmt.Errorf("audit record exceeds %d bytes", maxAuditRecordBytes) + } + // Append-only, refusing to follow a symlink planted at the path. Existing logs + // are repaired to the required metadata through the descriptor and then + // re-checked before any event is written. + f, created, err := openAuditFile(l.File) if err != nil { return fmt.Errorf("open audit log: %w", err) } defer f.Close() - _ = f.Chown(0, 0) // enforce root:root if we just created it - if _, err := f.Write(line); err != nil { - return err + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil { + return fmt.Errorf("lock audit log: %w", err) + } + defer func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) }() + + fi, err := f.Stat() + if err != nil { + return fmt.Errorf("stat locked audit log: %w", err) + } + start := fi.Size() + if start < 0 || start > maxAuditLogBytes-int64(len(line)) { + return fmt.Errorf("audit log reached its %d-byte limit; archive or rotate it before retrying", maxAuditLogBytes) + } + write := l.write + if write == nil { + write = func(f *os.File, p []byte) (int, error) { return f.Write(p) } + } + if err := writeAll(f, line, write); err != nil { + rollbackErr := f.Truncate(start) + if rollbackErr == nil { + rollbackErr = l.syncFile(f) + } + return errors.Join(fmt.Errorf("append audit record: %w", err), wrapIfErr("roll back partial audit record", rollbackErr)) + } + if err := l.syncFile(f); err != nil { + // The line is complete and visible, but its durability is unknown. Do not + // truncate it: a failed sync gives no guarantee that a rollback could be made + // durable either, and a complete possibly-durable record is the safer state. + return fmt.Errorf("sync audit record: %w", err) + } + if created { + if err := syncAuditDirectory(filepath.Dir(l.File)); err != nil { + return fmt.Errorf("sync new audit log directory entry: %w", err) + } } return nil } + +func (l *Logger) syncFile(f *os.File) error { + if l.sync != nil { + return l.sync(f) + } + return f.Sync() +} + +func writeAll(f *os.File, line []byte, write func(*os.File, []byte) (int, error)) error { + for written := 0; written < len(line); { + n, err := write(f, line[written:]) + if n < 0 || n > len(line)-written { + return fmt.Errorf("invalid write count %d", n) + } + written += n + if err != nil { + return err + } + if n == 0 { + return io.ErrNoProgress + } + } + return nil +} + +func wrapIfErr(prefix string, err error) error { + if err == nil { + return nil + } + return fmt.Errorf("%s: %w", prefix, err) +} + +func syncAuditDirectory(path string) error { + dir, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +func openAuditFile(path string) (*os.File, bool, error) { + var before *syscall.Stat_t + if fi, err := os.Lstat(path); err == nil { + if fi.Mode()&os.ModeSymlink != 0 || !fi.Mode().IsRegular() { + return nil, false, fmt.Errorf("%s is not a safe regular file", path) + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return nil, false, fmt.Errorf("cannot determine inode of %s", path) + } + copy := *st + before = © + } else if !os.IsNotExist(err) { + return nil, false, err + } + created := before == nil + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0o600) + if err != nil { + return nil, false, err + } + fail := func(err error) (*os.File, bool, error) { + _ = f.Close() + return nil, false, err + } + fi, err := f.Stat() + if err != nil { + return fail(err) + } + if !fi.Mode().IsRegular() { + return fail(fmt.Errorf("%s is not a regular file", path)) + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return fail(fmt.Errorf("cannot determine owner of %s", path)) + } + if before != nil && (before.Dev != st.Dev || before.Ino != st.Ino) { + return fail(fmt.Errorf("%s was replaced while opening it", path)) + } + if err := f.Chown(0, 0); err != nil { + return fail(fmt.Errorf("repair owner of %s: %w", path, err)) + } + if err := f.Chmod(0o600); err != nil { + return fail(fmt.Errorf("repair mode of %s: %w", path, err)) + } + fi, err = f.Stat() + if err != nil { + return fail(err) + } + st, ok = fi.Sys().(*syscall.Stat_t) + if !ok || !fi.Mode().IsRegular() || st.Uid != 0 || st.Gid != 0 || fi.Mode().Perm() != 0o600 { + return fail(fmt.Errorf("%s metadata remains unsafe after repair", path)) + } + return f, created, nil +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 1485ca0..40b992c 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -2,11 +2,16 @@ package audit import ( "encoding/json" + "errors" "os" "path/filepath" "strings" + "sync" + "syscall" "testing" "time" + + "golang.org/x/sys/unix" ) func TestLogWritesJSONLines(t *testing.T) { @@ -60,6 +65,87 @@ func TestLogWritesJSONLines(t *testing.T) { } } +func TestLogRepairsAndVerifiesExistingFileMetadata(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, "audit.log") + if err := os.WriteFile(file, []byte(""), 0o666); err != nil { + t.Fatal(err) + } + if err := os.Chown(file, 12345, 12345); err != nil { + // Some id-mapped/rootless test filesystems reject arbitrary numeric owners. + // Mode repair is still exercised there; owner repair is exercised wherever + // the filesystem supports constructing the unsafe fixture. + t.Logf("cannot create non-root owner fixture: %v", err) + } + l := &Logger{Dir: dir, File: file, Now: time.Now, Actor: func() (string, int) { return "root", 0 }} + if err := l.Log(Event{Action: "repair"}); err != nil { + t.Fatal(err) + } + fi, err := os.Lstat(file) + if err != nil { + t.Fatal(err) + } + st := fi.Sys().(*syscall.Stat_t) + if !fi.Mode().IsRegular() || st.Uid != 0 || st.Gid != 0 || fi.Mode().Perm() != 0o600 { + t.Fatalf("audit type=%v owner=%d:%d mode=%o, want regular root:root 0600", fi.Mode(), st.Uid, st.Gid, fi.Mode().Perm()) + } +} + +func TestLogRejectsExistingNonRegularFile(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, "audit.log") + if err := os.Mkdir(file, 0o700); err != nil { + t.Fatal(err) + } + l := &Logger{Dir: dir, File: file, Now: time.Now, Actor: func() (string, int) { return "root", 0 }} + if err := l.Log(Event{Action: "x"}); err == nil { + t.Fatal("Log accepted a directory in place of a regular audit file") + } +} + +func TestLogRejectsFIFOWithoutBlocking(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, "audit.log") + if err := unix.Mkfifo(file, 0o600); err != nil { + t.Fatal(err) + } + start := time.Now() + err := (&Logger{Dir: dir, File: file}).Log(Event{Action: "x"}) + if err == nil || !strings.Contains(err.Error(), "not a safe regular file") { + t.Fatalf("FIFO audit log error = %v, want special-file refusal", err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("FIFO audit log blocked for %s", elapsed) + } +} + func TestLogRefusesSymlinkTarget(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires root") @@ -87,6 +173,161 @@ func TestLogDisabledIsNoOp(t *testing.T) { } } +func TestLogBoundsRecordAndTotalFileSize(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, "audit.log") + l := &Logger{Dir: dir, File: file} + if err := l.Log(Event{Action: "oversized", Detail: strings.Repeat("x", maxAuditRecordBytes)}); err == nil || + !strings.Contains(err.Error(), "audit record exceeds") { + t.Fatalf("oversized record error = %v, want record-size refusal", err) + } + if _, err := os.Lstat(file); !os.IsNotExist(err) { + t.Fatalf("oversized record created a log file: %v", err) + } + + if err := os.WriteFile(file, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Truncate(file, maxAuditLogBytes); err != nil { + t.Fatal(err) + } + if err := l.Log(Event{Action: "at-cap"}); err == nil || !strings.Contains(err.Error(), "archive or rotate") { + t.Fatalf("full audit log error = %v, want total-size refusal", err) + } + fi, err := os.Stat(file) + if err != nil { + t.Fatal(err) + } + if fi.Size() != maxAuditLogBytes { + t.Fatalf("refused append changed audit size to %d, want %d", fi.Size(), maxAuditLogBytes) + } +} + +func TestLogRollsBackPartialWrite(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, "audit.log") + base := &Logger{Dir: dir, File: file} + if err := base.Log(Event{Action: "before"}); err != nil { + t.Fatal(err) + } + want, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + + failed := false + l := &Logger{Dir: dir, File: file} + l.write = func(f *os.File, p []byte) (int, error) { + if failed { + return 0, errors.New("injected write failure") + } + failed = true + n, err := f.Write(p[:len(p)/2]) + if err != nil { + return n, err + } + return n, errors.New("injected write failure") + } + if err := l.Log(Event{Action: "partial"}); err == nil || !strings.Contains(err.Error(), "injected write failure") { + t.Fatalf("Log error = %v, want injected write failure", err) + } + got, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("partial record was not rolled back:\n got %q\nwant %q", got, want) + } +} + +func TestLogSerializesConcurrentWriters(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, "audit.log") + const writers = 32 + var wg sync.WaitGroup + errs := make(chan error, writers) + for i := 0; i < writers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + errs <- (&Logger{Dir: dir, File: file}).Log(Event{Action: "concurrent"}) + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + b, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") + if len(lines) != writers { + t.Fatalf("audit line count = %d, want %d", len(lines), writers) + } + for i, line := range lines { + var rec record + if err := json.Unmarshal([]byte(line), &rec); err != nil { + t.Fatalf("line %d is partial or interleaved: %v: %q", i, err, line) + } + } +} + +func TestLogReportsSyncFailureAfterCompleteLine(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + l := &Logger{Dir: dir, File: filepath.Join(dir, "audit.log")} + l.sync = func(*os.File) error { return errors.New("injected sync failure") } + if err := l.Log(Event{Action: "complete"}); err == nil || !strings.Contains(err.Error(), "injected sync failure") { + t.Fatalf("Log error = %v, want sync failure", err) + } + b, err := os.ReadFile(l.File) + if err != nil { + t.Fatal(err) + } + if lines := strings.Split(strings.TrimSuffix(string(b), "\n"), "\n"); len(lines) != 1 || !json.Valid([]byte(lines[0])) { + t.Fatalf("sync failure left an incomplete audit record: %q", b) + } +} + func TestRealActor(t *testing.T) { t.Setenv("SUDO_USER", "bob") if a, _ := realActor(); a != "bob" { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 336e87a..096f4ee 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -8,6 +8,7 @@ import ( "bufio" "crypto/rand" "encoding/hex" + "errors" "fmt" "io" "os" @@ -61,17 +62,28 @@ type App struct { // teardown against the constants would delete the real ones. CI runs the // integration suite as root, so that is not a hypothetical — it would happen on // every push, to the runner and to whatever box a developer ran it on. - StateDir string - AuditLogDir string - Now func() time.Time - RandHex func(nBytes int) (string, error) - RandPassword func(nChars int) (string, error) - StdoutIsTTY func() bool - StdinIsTTY func() bool - Geteuid func() int + StateDir string + AuditLogDir string + Now func() time.Time + RandHex func(nBytes int) (string, error) + RandPassword func(nChars int) (string, error) + StdoutIsTTY func() bool + StdinIsTTY func() bool + TerminalWidth func() int + Geteuid func() int // Executable is a test hook. Production leaves it nil and reads /proc/self/exe, // which remains bound to the running inode if its original pathname is replaced. Executable func() (string, error) + // RemoveAll is a test hook for recursive teardown. Production uses + // os.RemoveAll; nil also falls back to os.RemoveAll for direct test Apps. + RemoveAll func(string) error + // TerminateProcesses is injectable so revoke's fail-closed handling can be + // exercised without signalling real processes in tests. + TerminateProcesses func(int) error + // LookupUser is the single passwd snapshot source for identity-sensitive CLI + // operations. Production uses user.Lookup; tests inject account replacement + // sequences without modifying the host account database. + LookupUser func(string) (user.Passwd, bool, error) inReader *bufio.Reader // lazily wraps In; reused so buffered stdin isn't lost between prompts } @@ -101,8 +113,32 @@ func NewApp(lang i18n.Lang) *App { RandPassword: randPassword, StdoutIsTTY: func() bool { return term.IsTerminal(int(os.Stdout.Fd())) }, StdinIsTTY: func() bool { return term.IsTerminal(int(os.Stdin.Fd())) }, - Geteuid: os.Geteuid, + TerminalWidth: func() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + return 0 + } + return width + }, + Geteuid: os.Geteuid, + RemoveAll: os.RemoveAll, + TerminateProcesses: user.TerminateProcesses, + LookupUser: user.Lookup, + } +} + +func (a *App) lookupUser(name string) (user.Passwd, bool, error) { + if a.LookupUser != nil { + return a.LookupUser(name) + } + return user.Lookup(name) +} + +func (a *App) terminateProcesses(uid int) error { + if a.TerminateProcesses != nil { + return a.TerminateProcesses(uid) } + return user.TerminateProcesses(uid) } func randHex(nBytes int) (string, error) { @@ -148,15 +184,39 @@ const EnvLang = "LINUX_TEMP_ADMIN_LANG" // Run is the process entry point: it resolves the language, then dispatches. func Run(args []string) int { syscall.Umask(0o077) + if err := setTrustedRootPath(os.Geteuid, os.Setenv); err != nil { + fmt.Fprintf(os.Stderr, "cannot set trusted PATH: %v\n", err) + return 1 + } lang, rest, err := extractLang(args) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } - app := NewApp(resolveLang(lang, os.Getenv(EnvLang), rest)) + resolved, remember, proceed := resolveLangChoice(lang, os.Getenv(EnvLang), rest) + if !proceed { + return 0 + } + app := NewApp(resolved) + if remember { + app.rememberLangChoice(resolved) + } return app.Dispatch(rest) } +const trustedRootPath = "/usr/sbin:/usr/bin:/sbin:/bin" + +// setTrustedRootPath prevents a root invocation from resolving privileged +// helper commands through a caller-controlled directory. Non-root invocations +// keep their environment unchanged; their mutating commands are rejected by +// requireRoot before any helper is run. +func setTrustedRootPath(geteuid func() int, setenv func(string, string) error) error { + if geteuid() != 0 { + return nil + } + return setenv("PATH", trustedRootPath) +} + // resolveLang picks the UI language: an explicit --lang, then the env override, // then what the operator chose last time, then — on a terminal that has never // been asked — the question itself, and finally Chinese. @@ -168,45 +228,126 @@ func Run(args []string) int { // tool asks that person once and remembers the answer instead of guessing from // the environment. func resolveLang(flag, env string, rest []string) i18n.Lang { + lang, _, _ := resolveLangChoice(flag, env, rest) + return lang +} + +func resolveLangChoice(flag, env string, rest []string) (i18n.Lang, bool, bool) { + return resolveLangChoiceWith(flag, env, rest, askLang) +} + +func resolveLangChoiceWith(flag, env string, rest []string, ask func([]string) (i18n.Lang, bool, bool)) (i18n.Lang, bool, bool) { for _, v := range []string{flag, env, prefs.Lang()} { if l, ok := i18n.Parse(v); ok { - return l + return l, false, true } } - if l, ok := askLang(rest); ok { - return l + if l, ok, prompted := ask(rest); prompted { + if !ok { + return i18n.ZH, false, false + } + return l, true, true } - return i18n.ZH + return i18n.ZH, false, true } -// askLang puts the language question to an operator who has never answered it, -// and remembers the answer. It returns ok=false whenever asking would be wrong: +// askLang puts the language question to an operator who has never answered it. +// Persistence happens later, under the lifecycle lock and uninstall marker gate. +// prompted is false whenever asking would be wrong: // no terminal to ask at (a script, a cron-fired auto-revoke), or a run that -// explicitly asked not to be prompted. Those get the default, and stay silent. -func askLang(rest []string) (i18n.Lang, bool) { - if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) { - return "", false +// explicitly asked not to be prompted. Those get the default and stay silent. +// When prompted is true but ok is false, the operator ended the prompt with EOF +// and the whole interactive run should be cancelled. +func askLang(rest []string) (lang i18n.Lang, ok, prompted bool) { + if !shouldAskLang(rest, + term.IsTerminal(int(os.Stdin.Fd())), + term.IsTerminal(int(os.Stderr.Fd())), + term.IsTerminal(int(os.Stdout.Fd()))) { + return "", false, false + } + lang, ok = askLangInput(os.Stdin, os.Stderr) + return lang, ok, true +} + +func shouldAskLang(rest []string, stdinTTY, stderrTTY, stdoutTTY bool) bool { + if !stdinTTY || !stderrTTY { + return false + } + for _, arg := range rest { + if arg == "--yes" || arg == "-y" { // an unattended run must not be stopped by a question + return false + } } - for _, a := range rest { - if a == "--yes" || a == "-y" { // an unattended run must not be stopped by a question + if len(rest) == 0 || (rest[0] != "invite" && rest[0] != "create") || stdoutTTY { + return true + } + for _, arg := range rest[1:] { + if arg == "--allow-non-tty-private-key-output" { + return true + } + } + // invite will refuse this run before any of its own prompts. Do not make a + // first-run language question the side effect that happens before that refusal. + return false +} + +// askLangInput contains the line-oriented part of the first-run language +// prompt. Keep one buffered reader for the whole exchange: constructing a new +// reader after invalid input could discard later lines it had already buffered. +func askLangInput(in io.Reader, out io.Writer) (i18n.Lang, bool) { + reader := bufio.NewReader(in) + for { + fmt.Fprint(out, "\nLanguage / 语言:\n 1) 中文 (默认)\n 2) English\n选择 / select [1-2]: ") + line, ok, err := readInteractiveLine(reader) + if errors.Is(err, errInteractiveLineTooLong) { + fmt.Fprintln(out, "输入过长,请重新输入 / input is too long; try again") + continue + } + if err != nil || !ok { // EOF cancels the interactive run. return "", false } + switch strings.TrimSpace(line) { + case "", "1": + return i18n.ZH, true + case "2": + return i18n.EN, true + default: + fmt.Fprintln(out, "无效选择,请输入 1 或 2 / invalid choice; enter 1 or 2") + } } - fmt.Fprint(os.Stderr, "\nLanguage / 语言:\n 1) 中文 (默认)\n 2) English\n选择 / select [1-2]: ") - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil && line == "" { // EOF: take the default rather than hang - return "", false +} + +// rememberLangChoice writes a convenience preference only while holding the +// same lifecycle lock as every privileged mutation. A completed uninstall owns +// the state namespace; in that state a language prompt must not recreate it. +func (a *App) rememberLangChoice(lang i18n.Lang) { + if a.Lifecycle == nil { + if err := prefs.SetLang(string(lang)); err != nil { + a.warnf("%s: %v", a.P.M("未能记住语言选择", "could not remember the language choice"), err) + } + return + } + release, err := a.Lifecycle.Acquire() + if err != nil { + a.warnf("%s: %v", a.P.M("未能锁定语言偏好", "could not lock the language preference"), err) + return } - lang := i18n.ZH - if strings.TrimSpace(line) == "2" { - lang = i18n.EN + defer func() { + if err := release(); err != nil { + a.warnf("%s: %v", a.P.M("无法释放生命周期锁", "cannot release the lifecycle lock"), err) + } + }() + uninstalled, err := a.Lifecycle.IsUninstalled() + if err != nil { + a.warnf("%s: %v", a.P.M("无法验证卸载状态,未保存语言选择", "could not verify uninstall state; the language choice was not saved"), err) + return + } + if uninstalled { + return } - // Remembering is a convenience: if it cannot be saved the run still proceeds in - // the chosen language, it will just ask again next time. if err := prefs.SetLang(string(lang)); err != nil { - fmt.Fprintf(os.Stderr, "(未能记住语言选择 / could not remember the language choice: %v)\n", err) + a.warnf("%s: %v", a.P.M("未能记住语言选择", "could not remember the language choice"), err) } - return lang, true } // extractLang pulls --lang/--lang=VAL from anywhere in args (an explicit flag @@ -312,6 +453,16 @@ func (a *App) requireRoot() bool { // withLifecycleLock serializes complete privileged state transitions. Tests that // construct App directly may leave Lifecycle nil; production NewApp never does. func (a *App) withLifecycleLock(fn func() int) int { + return a.withLifecycleLockMode(false, fn) +} + +// withLifecycleLockAllowUninstalled is reserved for explicit install and +// uninstall retry. Every other mutation must stop at a completed uninstall. +func (a *App) withLifecycleLockAllowUninstalled(fn func() int) int { + return a.withLifecycleLockMode(true, fn) +} + +func (a *App) withLifecycleLockMode(allowUninstalled bool, fn func() int) int { if a.Lifecycle == nil { return fn() } @@ -320,6 +471,20 @@ func (a *App) withLifecycleLock(fn func() int) int { a.errorf("%s: %v", a.P.M("无法获取生命周期锁", "cannot acquire the lifecycle lock"), err) return 1 } + if !allowUninstalled { + uninstalled, markerErr := a.Lifecycle.IsUninstalled() + if markerErr != nil { + _ = release() + a.errorf("%s: %v", a.P.M("无法验证卸载状态,拒绝修改主机", "cannot verify uninstall state; refusing to mutate the host"), markerErr) + return 1 + } + if uninstalled { + _ = release() + a.errorf("%s", a.P.M("本工具已卸载;如需重新启用,请先显式运行 install", + "this tool is uninstalled; run install explicitly before re-enabling mutations")) + return 1 + } + } rc := fn() if err := release(); err != nil { a.errorf("%s: %v", a.P.M("无法释放生命周期锁", "cannot release the lifecycle lock"), err) @@ -328,6 +493,13 @@ func (a *App) withLifecycleLock(fn func() int) int { return rc } +const ( + maxInteractiveLineBytes = 64 << 10 + rejectedInteractiveLine = "\x00" +) + +var errInteractiveLineTooLong = errors.New("interactive input line is too long") + // prompt reads a single line, printing the message to stderr first. // readLine reads one trimmed line. ok is false only at EOF with no data, letting // callers tell a blank Enter apart from end-of-input. @@ -335,11 +507,61 @@ func (a *App) readLine() (line string, ok bool) { if a.inReader == nil { a.inReader = bufio.NewReader(a.In) } - s, err := a.inReader.ReadString('\n') - if err != nil && s == "" { - return "", false + s, ok, err := readInteractiveLine(a.inReader) + if err != nil { + if errors.Is(err, errInteractiveLineTooLong) { + a.warnf("%s", a.P.M("输入行过长,已拒绝", "input line is too long and was rejected")) + } else { + a.warnf("%s: %v", a.P.M("读取输入失败", "reading input failed"), err) + } + return rejectedInteractiveLine, ok + } + return strings.TrimSpace(s), ok +} + +// readInteractiveLine consumes exactly one line while retaining at most a fixed +// amount of it. Once the limit is crossed it drains the rest of that same line, +// so a following answer remains aligned with its next prompt. +func readInteractiveLine(reader *bufio.Reader) (string, bool, error) { + var line strings.Builder + gotInput := false + tooLong := false + for { + fragment, err := reader.ReadSlice('\n') + if len(fragment) > 0 { + gotInput = true + if fragment[len(fragment)-1] == '\n' { + fragment = fragment[:len(fragment)-1] + } + if !tooLong { + remaining := maxInteractiveLineBytes - line.Len() + if len(fragment) > remaining { + tooLong = true + } else { + line.Write(fragment) + } + } + } + switch { + case err == nil: + if tooLong { + return "", true, errInteractiveLineTooLong + } + return line.String(), true, nil + case errors.Is(err, bufio.ErrBufferFull): + continue + case errors.Is(err, io.EOF): + if !gotInput { + return "", false, nil + } + if tooLong { + return "", true, errInteractiveLineTooLong + } + return line.String(), true, nil + default: + return "", gotInput, err + } } - return strings.TrimSpace(s), true } func (a *App) prompt(msg string) string { diff --git a/internal/cli/cli_e2e_test.go b/internal/cli/cli_e2e_test.go index 982a959..0f1d28f 100644 --- a/internal/cli/cli_e2e_test.go +++ b/internal/cli/cli_e2e_test.go @@ -5,6 +5,7 @@ package cli_test import ( "bytes" "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -57,9 +58,10 @@ func (unavailableSched) AtrmJob(string) error { return nil } func (unavailableSched) AtJobs() ([]schedule.AtJob, error) { return nil, nil } type trackingSched struct { - jobs map[string]string - next int - removeErr error + jobs map[string]string + next int + removeErr error + beforeSchedule func(string) error } func newTrackingSched() *trackingSched { return &trackingSched{jobs: map[string]string{}} } @@ -68,6 +70,11 @@ func (*trackingSched) HasSystemctl() bool { return false } func (*trackingSched) Systemctl(...string) error { return nil } func (*trackingSched) HasAt() bool { return true } func (s *trackingSched) ScheduleAt(command string, _ int) (string, error) { + if s.beforeSchedule != nil { + if err := s.beforeSchedule(command); err != nil { + return "", err + } + } s.next++ id := strconv.Itoa(s.next) s.jobs[id] = command @@ -100,6 +107,14 @@ type failingWriter struct{} func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("output unavailable") } +type partialFailingWriter struct{ wrote int } + +func (w *partialFailingWriter) Write(p []byte) (int, error) { + n := len(p) / 2 + w.wrote += n + return n, errors.New("output interrupted after a partial credential write") +} + func mustExternalUserExists(t *testing.T, name string) bool { t.Helper() exists, err := user.Exists(name) @@ -157,7 +172,7 @@ func TestInviteThenRevokeEndToEnd(t *testing.T) { In: strings.NewReader(""), P: i18n.Printer{Lang: i18n.EN}, Users: user.New(), - Sudoers: &sudoers.Manager{Dir: sudoDir, Validate: func(string) error { return nil }, Verify: func(string) error { return nil }}, + Sudoers: &sudoers.Manager{Dir: sudoDir, Validate: func([]byte) error { return nil }, Verify: func(string) error { return nil }}, Scheduler: &schedule.Scheduler{ SystemdDir: rootDir(t, 0o755), InstallPath: installPath, UnitPrefix: config.AutoRevokeUnitPrefix, Now: now, Sys: fakeSched{}, @@ -212,8 +227,12 @@ func TestInviteThenRevokeEndToEnd(t *testing.T) { if fi, _ := os.Lstat(ak); fi.Mode().Perm() != 0o600 { t.Errorf("authorized_keys mode = %o, want 600", fi.Mode().Perm()) } - if ok, _ := app.Registry.Contains(username); !ok { - t.Error("registry should contain the user after invite") + rec, found, err := app.Registry.Lookup(username) + if err != nil || !found { + t.Fatalf("registry should contain the user after invite: found=%v err=%v", found, err) + } + if !rec.IdentityBound || rec.Generation == "" || !user.MatchesManagedGeneration(pw, rec.Generation) { + t.Fatalf("invite identity is not generation-bound: rec=%+v passwd=%+v", rec, pw) } if _, err := os.Lstat(filepath.Join(sudoDir, "linux-temp-admin-"+username)); err != nil { t.Errorf("sudoers drop-in missing: %v", err) @@ -299,6 +318,135 @@ func TestInviteRollsBackWhenAutoDeleteCannotBeScheduled(t *testing.T) { } } +func TestInvitePersistsIdentityIntentBeforeScheduling(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("requires root") + } + const name = "lta-intent1" + remove := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + remove() + t.Cleanup(remove) + + a, _, _, _ := inviteApp(t) + tracker := newTrackingSched() + a.Scheduler.Sys = tracker + sawIntent := false + tracker.beforeSchedule = func(command string) error { + rec, found, err := a.Registry.Lookup(name) + if err != nil { + return err + } + if !found || !rec.AutoRevoke || rec.AutoUnit != "" || rec.UID < 1 || rec.Generation == "" { + return fmt.Errorf("missing durable scheduling intent: found=%v record=%+v", found, rec) + } + if !strings.Contains(command, "--expected-uid "+strconv.Itoa(rec.UID)) || + !strings.Contains(command, "--generation "+rec.Generation) { + return fmt.Errorf("scheduled identity does not match registry intent: %q vs %+v", command, rec) + } + sawIntent = true + return nil + } + + if rc := a.Dispatch([]string{"invite", "--user", name, "--host", "203.0.113.5", + "--hours", "1", "--no-sudo", "--no-fix-sshd", "--auto-revoke", "--yes"}); rc != 0 { + t.Fatalf("invite rc=%d\nstderr:\n%s", rc, a.Err.(*bytes.Buffer).String()) + } + if !sawIntent { + t.Fatal("scheduler ran before the registry intent was observed") + } + rec, found, err := a.Registry.Lookup(name) + if err != nil || !found || rec.AutoUnit != "at:1" { + t.Fatalf("final schedule was not committed to the registry: found=%v rec=%+v err=%v", found, rec, err) + } +} + +func TestInvitePersistsAccountIntentBeforeCredentialMutation(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("requires root") + } + const name = "lta-accountintent1" + remove := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + remove() + t.Cleanup(remove) + + a, _, _, _ := inviteApp(t) + probes := 0 + sawIntent := false + a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { + probes++ + if probes == 2 { + rec, found, err := a.Registry.Lookup(name) + if err != nil { + t.Fatal(err) + } + if !found || rec.UID < 1 || rec.User != name { + t.Fatalf("account identity was not durable before login confirmation: found=%v rec=%+v", found, rec) + } + pw, exists := mustExternalUserLookup(t, name) + if !exists { + t.Fatal("account did not exist at its post-create login confirmation") + } + if _, err := os.Lstat(filepath.Join(pw.Home, ".ssh", "authorized_keys")); !os.IsNotExist(err) { + t.Fatalf("credentials existed before durable identity intent: %v", err) + } + sawIntent = true + } + return sysinfo.ParseSSHD(sshdOK), nil + } + + if rc := a.Dispatch([]string{"invite", "--user", name, "--host", "203.0.113.5", + "--no-sudo", "--no-fix-sshd", "--no-auto-revoke", "--yes"}); rc != 0 { + t.Fatalf("invite rc=%d\nstderr:\n%s", rc, a.Err.(*bytes.Buffer).String()) + } + if !sawIntent { + t.Fatal("post-create login confirmation did not observe durable account intent") + } + rec, found, err := a.Registry.Lookup(name) + if err != nil || !found || !rec.IdentityBound || rec.Generation == "" { + t.Fatalf("permanent invite lacks a bound generation: found=%v rec=%+v err=%v", found, rec, err) + } + pw, exists := mustExternalUserLookup(t, name) + if !exists || !user.MatchesManagedGeneration(pw, rec.Generation) { + t.Fatalf("permanent invite passwd marker does not match registry generation: exists=%v pw=%+v rec=%+v", exists, pw, rec) + } + if rc := a.Dispatch([]string{"revoke", "--user", name, "--yes"}); rc != 0 { + t.Fatalf("cleanup revoke rc=%d\nstderr:\n%s", rc, a.Err.(*bytes.Buffer).String()) + } +} + +func TestInviteRollsBackWhenExplicitSudoGrantFails(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("requires root") + } + const name = "lta-sudofail2" + remove := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + remove() + t.Cleanup(remove) + + a, sudoMgr, _, _ := inviteApp(t) + sudoMgr.Validate = func([]byte) error { return errors.New("injected sudo validation failure") } + rc := a.Dispatch([]string{"invite", "--user", name, "--host", "203.0.113.5", + "--hours", "1", "--sudo", "--confirm-sudo", name, "--no-auto-revoke", "--yes"}) + if rc != 1 { + t.Fatalf("invite rc=%d, want failure for an explicitly requested sudo grant", rc) + } + if mustExternalUserExists(t, name) { + t.Fatal("account survived the failed sudo transaction as a silently downgraded user") + } + if ok, err := a.Registry.Contains(name); err != nil || ok { + t.Fatalf("registry contains rolled-back account: ok=%v err=%v", ok, err) + } + if _, err := os.Lstat(sudoMgr.FilePath(name)); !os.IsNotExist(err) { + t.Fatalf("failed sudo grant left a drop-in: %v", err) + } + if strings.Contains(a.Out.(*bytes.Buffer).String(), "BEGIN LINUX TEMP ADMIN INVITE") { + t.Fatal("credentials were printed after the requested sudo grant failed") + } + if !strings.Contains(a.Err.(*bytes.Buffer).String(), "refusing to create the account") { + t.Fatalf("failure did not preserve sudo transaction semantics: %s", a.Err.(*bytes.Buffer).String()) + } +} + // TestInviteFixSSHDThenRevokeEndToEnd covers the path this whole feature exists // for: a host whose sshd refuses public-key logins. The invite must write a // per-account exception, prove it, and print a verified invite -- and revoke must @@ -337,7 +485,7 @@ func TestInviteFixSSHDThenRevokeEndToEnd(t *testing.T) { Out: &out, Err: &errb, In: strings.NewReader(""), P: i18n.Printer{Lang: i18n.EN}, Users: user.New(), - Sudoers: &sudoers.Manager{Dir: sudoDir, Validate: func(string) error { return nil }, Verify: func(string) error { return nil }}, + Sudoers: &sudoers.Manager{Dir: sudoDir, Validate: func([]byte) error { return nil }, Verify: func(string) error { return nil }}, SSHD: &sshdconf.Manager{ Dir: sshdDir, Validate: func() error { return nil }, Effective: effective, Reload: func() error { reloads++; return nil }, diff --git a/internal/cli/cli_root_test.go b/internal/cli/cli_root_test.go index 08a8c09..dc33619 100644 --- a/internal/cli/cli_root_test.go +++ b/internal/cli/cli_root_test.go @@ -4,15 +4,34 @@ package cli import ( "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" + "sync" + "syscall" "testing" + "time" + "github.com/xxvcc/linux-temp-admin/internal/audit" "github.com/xxvcc/linux-temp-admin/internal/config" + "github.com/xxvcc/linux-temp-admin/internal/fsutil" + "github.com/xxvcc/linux-temp-admin/internal/i18n" + "github.com/xxvcc/linux-temp-admin/internal/lifecycle" + "github.com/xxvcc/linux-temp-admin/internal/prefs" "github.com/xxvcc/linux-temp-admin/internal/selfmanage" ) +type cliRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f cliRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + // rootOwnedDir returns a root-owned temp dir. Install writes through // fsutil.WriteRootFile, which refuses a target directory that is not root-owned // and then chowns the file to 0:0 -- so these tests cannot run unprivileged. @@ -31,6 +50,53 @@ func rootOwnedDir(t *testing.T) string { return d } +func TestInstallReactivatesExplicitlyAndRejectsUnsafeMarker(t *testing.T) { + dir := rootOwnedDir(t) + ip := filepath.Join(dir, "linux-temp-admin") + lockPath := filepath.Join(dir, "lifecycle.lock") + l := lifecycle.New(lockPath) + a, _, errb := newTestApp(t, "") + a.InstallPath = ip + a.Selfmanage = selfmanage.New(ip, config.MaxUpgradeBytes) + a.Lifecycle = l + + if rc := a.install(nil); rc != 0 { + t.Fatalf("initial install rc=%d: %s", rc, errb.String()) + } + if err := l.MarkUninstalled(); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + a.Out = &out + if rc := a.install(nil); rc != 0 { + t.Fatalf("reactivation rc=%d: %s", rc, errb.String()) + } + if !strings.Contains(out.String(), "reactivated the stable command") { + t.Fatalf("reactivation was reported as a no-op: %q", out.String()) + } + if stopped, err := l.IsUninstalled(); err != nil || stopped { + t.Fatalf("uninstall marker remained after explicit reactivation: stopped=%v err=%v", stopped, err) + } + + if err := os.Symlink("/dev/null", lockPath+".uninstalled"); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(ip) + if err != nil { + t.Fatal(err) + } + if rc := a.install([]string{"--force"}); rc != 1 { + t.Fatalf("install rc=%d, want refusal for an unsafe uninstall marker", rc) + } + after, err := os.ReadFile(ip) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("binary changed before the unsafe uninstall marker was rejected") + } +} + // TestInstallReportsNoOpWhenAlreadyStable: installing the binary that already sits // at InstallPath writes nothing, so it must not claim it installed anything -- the // message and the audit entry would both assert a privileged write that never @@ -82,3 +148,320 @@ func TestInstallReportsNoOpWhenAlreadyStable(t *testing.T) { t.Errorf("forced identical install should still be a no-op: %q", out3.String()) } } + +func TestInstallDurabilityFailureIsNonzeroAndAudited(t *testing.T) { + dir := rootOwnedDir(t) + installPath := filepath.Join(dir, "linux-temp-admin") + auditDir := filepath.Join(dir, "audit") + auditPath := filepath.Join(auditDir, "audit.log") + a, _, errb := newTestApp(t, "") + a.InstallPath = installPath + a.Selfmanage = selfmanage.New(installPath, config.MaxUpgradeBytes) + a.Selfmanage.WriteRootFile = func(path string, content []byte, mode os.FileMode) error { + if err := fsutil.WriteRootFile(path, content, mode); err != nil { + return err + } + return &fsutil.DurabilityError{Operation: "install test", Err: syscall.EIO} + } + a.Audit = &audit.Logger{ + Dir: auditDir, File: auditPath, Now: a.Now, + Actor: func() (string, int) { return "test", 0 }, + } + + if rc := a.install(nil); rc != 1 { + t.Fatalf("install rc=%d, want 1 for unknown replacement durability", rc) + } + if !strings.Contains(errb.String(), "replacement's durability is unknown") { + t.Fatalf("install did not report the visible-but-uncertain replacement: %q", errb.String()) + } + if _, err := os.Stat(installPath); err != nil { + t.Fatalf("replacement was not visible: %v", err) + } + b, err := os.ReadFile(auditPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"action":"install"`, `"result":"fail"`, "command replaced but durability unknown"} { + if !strings.Contains(string(b), want) { + t.Errorf("audit log missing %q: %s", want, b) + } + } +} + +func TestUpgradeDownloadDoesNotHoldLifecycleLock(t *testing.T) { + dir := rootOwnedDir(t) + installPath := filepath.Join(dir, "linux-temp-admin") + lockPath := filepath.Join(dir, "lifecycle.lock") + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + bin := []byte("#!/bin/sh\n[ \"$1\" = version ] && echo 9.9.9\n") + sig := ed25519.Sign(priv, bin) + started := make(chan struct{}) + unblock := make(chan struct{}) + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/bin": + select { + case <-started: + default: + close(started) + } + <-unblock + _, _ = w.Write(bin) + case "/bin.sig": + _, _ = w.Write(sig) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + var unblockOnce sync.Once + releaseDownload := func() { unblockOnce.Do(func() { close(unblock) }) } + t.Cleanup(releaseDownload) + + a, _, errb := newTestApp(t, "") + a.InstallPath = installPath + a.Lifecycle = lifecycle.New(lockPath) + a.Selfmanage = &selfmanage.Manager{ + InstallPath: installPath, + PublicKey: pub, + Client: srv.Client(), + MaxBytes: config.MaxUpgradeBytes, + } + done := make(chan commandResult, 1) + go func() { done <- a.upgradeResult([]string{"--url", srv.URL + "/bin", "--yes"}) }() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatalf("upgrade download did not start: %q", errb.String()) + } + + acquired := make(chan func() error, 1) + acquireErr := make(chan error, 1) + go func() { + release, err := lifecycle.New(lockPath).Acquire() + if err != nil { + acquireErr <- err + return + } + acquired <- release + }() + select { + case err := <-acquireErr: + t.Fatal(err) + case release := <-acquired: + if err := release(); err != nil { + t.Fatal(err) + } + case <-time.After(500 * time.Millisecond): + releaseDownload() + <-done + t.Fatal("upgrade held the lifecycle lock during download") + } + releaseDownload() + select { + case result := <-done: + if result.status != 0 || !result.applied { + t.Fatalf("upgrade result=%+v, want a successful applied replacement: %s", result, errb.String()) + } + case <-time.After(5 * time.Second): + t.Fatal("upgrade did not finish after download resumed") + } + + // A second run authenticates the same candidate but does not replace anything. + // It succeeds without becoming a terminal menu action. + again := a.upgradeResult([]string{"--url", srv.URL + "/bin", "--yes"}) + if again.status != 0 || again.applied { + t.Fatalf("already-current upgrade result=%+v, want successful but unapplied", again) + } +} + +func TestOfficialUpgradeMirrorFallbackBoundary(t *testing.T) { + asset := config.BinaryAssetPrefix + "amd64" + goodBin := []byte("#!/bin/sh\n[ \"$1\" = version ] && echo 2.8.0\n") + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + goodSig := ed25519.Sign(priv, goodBin) + otherBin := []byte("#!/bin/sh\n[ \"$1\" = version ] && echo 2.8.1\n") + otherSig := ed25519.Sign(priv, otherBin) + _, wrongPriv, _ := ed25519.GenerateKey(rand.Reader) + wrongSig := ed25519.Sign(wrongPriv, goodBin) + manifest := `{"version":"2.8.0","tag":"v2.8.0","base_url":"https://dl.ll.cd/linux-temp-admin/v2.8.0","published_at":"2026-07-27T05:00:00Z"}` + "\n" + + for _, tc := range []struct { + name string + manifestStatus int + manifestBody string + mirrorSigStatus int + mirrorBin []byte + mirrorSig []byte + badChecksum bool + wantErr bool + wantGitHub bool + wantGitHubLatest bool + }{ + {name: "mirror success", manifestStatus: http.StatusOK, manifestBody: manifest, mirrorSigStatus: http.StatusOK, mirrorSig: goodSig}, + {name: "mirror transport fallback", manifestStatus: http.StatusOK, manifestBody: manifest, mirrorSigStatus: http.StatusServiceUnavailable, mirrorSig: goodSig, wantGitHub: true}, + {name: "mirror checksum stops", manifestStatus: http.StatusOK, manifestBody: manifest, mirrorSigStatus: http.StatusOK, mirrorSig: goodSig, badChecksum: true, wantErr: true}, + {name: "mirror verification stops", manifestStatus: http.StatusOK, manifestBody: manifest, mirrorSigStatus: http.StatusOK, mirrorSig: wrongSig, wantErr: true}, + {name: "mirror version mismatch stops", manifestStatus: http.StatusOK, manifestBody: manifest, mirrorSigStatus: http.StatusOK, mirrorBin: otherBin, mirrorSig: otherSig, wantErr: true}, + {name: "manifest transport fallback", manifestStatus: http.StatusServiceUnavailable, manifestBody: manifest, mirrorSigStatus: http.StatusOK, mirrorSig: goodSig, wantGitHub: true, wantGitHubLatest: true}, + {name: "manifest semantics stop", manifestStatus: http.StatusOK, manifestBody: strings.Replace(manifest, `"tag":"v2.8.0"`, `"tag":"v2.8.1"`, 1), mirrorSigStatus: http.StatusOK, mirrorSig: goodSig, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := rootOwnedDir(t) + requests := make(map[string]int) + m := &selfmanage.Manager{ + InstallPath: filepath.Join(dir, "linux-temp-admin"), + PublicKey: pub, + MaxBytes: config.MaxUpgradeBytes, + RetryDelay: 0, + } + m.Client = &http.Client{Transport: cliRoundTripFunc(func(req *http.Request) (*http.Response, error) { + mirrorBin := tc.mirrorBin + if mirrorBin == nil { + mirrorBin = goodBin + } + key := req.URL.Host + req.URL.Path + requests[key]++ + status := http.StatusOK + var body []byte + switch { + case req.URL.Host == "dl.ll.cd" && req.URL.Path == "/linux-temp-admin/latest.json": + status, body = tc.manifestStatus, []byte(tc.manifestBody) + case req.URL.Host == "dl.ll.cd" && strings.HasSuffix(req.URL.Path, "/SHA256SUMS"): + if tc.badChecksum { + body = releaseSetSums(asset, append(append([]byte(nil), mirrorBin...), 'x'), tc.mirrorSig) + } else { + body = releaseSetSums(asset, mirrorBin, tc.mirrorSig) + } + case req.URL.Host == "dl.ll.cd" && strings.HasSuffix(req.URL.Path, "/"+asset): + body = mirrorBin + case req.URL.Host == "dl.ll.cd" && strings.HasSuffix(req.URL.Path, "/"+asset+".sig"): + status, body = tc.mirrorSigStatus, tc.mirrorSig + case req.URL.Host == "github.com" && strings.HasSuffix(req.URL.Path, "/SHA256SUMS"): + body = releaseSetSums(asset, goodBin, goodSig) + case req.URL.Host == "github.com" && strings.HasSuffix(req.URL.Path, "/"+asset): + body = goodBin + case req.URL.Host == "github.com" && strings.HasSuffix(req.URL.Path, "/"+asset+".sig"): + body = goodSig + default: + status = http.StatusNotFound + } + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(body)), Request: req}, nil + })} + a, _, _ := newTestApp(t, "") + a.Selfmanage = m + candidate, gotErr := a.prepareOfficialUpgrade() + if (gotErr != nil) != tc.wantErr { + t.Fatalf("candidate=%v err=%v, wantErr=%v", candidate, gotErr, tc.wantErr) + } + githubRequests := 0 + latestRequests := 0 + for key, count := range requests { + if strings.HasPrefix(key, "github.com") { + githubRequests += count + if strings.Contains(key, "/releases/latest/download/") { + latestRequests += count + } + } + } + if (githubRequests > 0) != tc.wantGitHub { + t.Fatalf("GitHub requests=%d, wantGitHub=%v; all=%v", githubRequests, tc.wantGitHub, requests) + } + if tc.wantGitHub && githubRequests != 3 { + t.Fatalf("fallback downloaded %d GitHub requests, want one complete three-file set; all=%v", githubRequests, requests) + } + if (latestRequests > 0) != tc.wantGitHubLatest { + t.Fatalf("GitHub Latest requests=%d, wantLatest=%v; all=%v", latestRequests, tc.wantGitHubLatest, requests) + } + }) + } +} + +func releaseSetSums(asset string, bin, sig []byte) []byte { + return []byte(fmt.Sprintf("%x %s\n%x %s.sig\n", sha256.Sum256(bin), asset, sha256.Sum256(sig), asset)) +} + +func TestUpgradeURLSecretsNeverReachTerminalOrAudit(t *testing.T) { + dir := rootOwnedDir(t) + auditDir := filepath.Join(dir, "audit") + auditPath := filepath.Join(auditDir, "audit.log") + markers := []string{ + "userinfo-marker-8d31", + "path-marker-4b72", + "query-marker-6c93", + "fragment-marker-1a54", + } + rawURL := "https://" + markers[0] + ":password@example.invalid/releases/" + markers[1] + + "?token=" + markers[2] + "#" + markers[3] + urlFile := filepath.Join(dir, "upgrade-url") + if err := os.WriteFile(urlFile, []byte(rawURL+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + a, out, errb := newTestApp(t, "") + a.Selfmanage = selfmanage.New(filepath.Join(dir, "linux-temp-admin"), config.MaxUpgradeBytes) + a.Selfmanage.RetryDelay = 0 + a.Selfmanage.Client = &http.Client{Transport: cliRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("transport echoed complete URL %s", req.URL.String()) + })} + a.Audit = &audit.Logger{ + Dir: auditDir, File: auditPath, Now: a.Now, + Actor: func() (string, int) { return "test", 0 }, + } + + if rc := a.upgrade([]string{"--url-file", urlFile, "--yes"}); rc != 1 { + t.Fatalf("failed upgrade rc=%d, want 1", rc) + } + auditBytes, err := os.ReadFile(auditPath) + if err != nil { + t.Fatal(err) + } + diagnostic := out.String() + errb.String() + string(auditBytes) + if !strings.Contains(diagnostic, "https://example.invalid") { + t.Errorf("terminal and audit diagnostics lost the safe endpoint: %q", diagnostic) + } + for _, marker := range markers { + if strings.Contains(diagnostic, marker) { + t.Errorf("terminal or audit diagnostics leaked %q: %q", marker, diagnostic) + } + } +} + +func TestLanguagePreferencesCannotRecreateStateAfterUninstall(t *testing.T) { + dir := rootOwnedDir(t) + stateDir := filepath.Join(dir, "state") + oldPrefs := prefs.File + prefs.File = filepath.Join(stateDir, "prefs") + t.Cleanup(func() { prefs.File = oldPrefs }) + l := lifecycle.New(filepath.Join(dir, "lifecycle.lock")) + release, err := l.Acquire() + if err != nil { + t.Fatal(err) + } + if err := l.MarkUninstalled(); err != nil { + _ = release() + t.Fatal(err) + } + if err := release(); err != nil { + t.Fatal(err) + } + + a, _, _ := newTestApp(t, "2\n") + a.Lifecycle = l + a.rememberLangChoice(i18n.EN) + if _, err := os.Lstat(stateDir); !os.IsNotExist(err) { + t.Fatalf("initial language choice recreated uninstalled state: %v", err) + } + if rc := a.switchLang(); rc != 1 { + t.Fatalf("switchLang rc=%d, want uninstall-marker refusal", rc) + } + if _, err := os.Lstat(stateDir); !os.IsNotExist(err) { + t.Fatalf("menu language switch recreated uninstalled state: %v", err) + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c75ba8b..daf17cd 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,16 +2,21 @@ package cli import ( "bytes" + "crypto/ed25519" "errors" "fmt" + "io" + "net/http" "os" "path/filepath" "strconv" "strings" + "sync" "testing" "time" "github.com/xxvcc/linux-temp-admin/internal/buildinfo" + "github.com/xxvcc/linux-temp-admin/internal/config" "github.com/xxvcc/linux-temp-admin/internal/i18n" "github.com/xxvcc/linux-temp-admin/internal/lifecycle" "github.com/xxvcc/linux-temp-admin/internal/prefs" @@ -19,9 +24,16 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/schedule" "github.com/xxvcc/linux-temp-admin/internal/selfmanage" "github.com/xxvcc/linux-temp-admin/internal/sshdconf" + "github.com/xxvcc/linux-temp-admin/internal/sshkey" "github.com/xxvcc/linux-temp-admin/internal/sysinfo" + "github.com/xxvcc/linux-temp-admin/internal/user" + "golang.org/x/sys/unix" ) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + type failingScheduleSystem struct{} func (failingScheduleSystem) HasSystemctl() bool { return false } @@ -34,6 +46,25 @@ func (failingScheduleSystem) AtJobs() ([]schedule.AtJob, error) { return nil, errors.New("at queue unreadable") } +type revokeRunner struct { + calls []string + failOn string +} + +func (r *revokeRunner) Run(name string, _ ...string) error { + r.calls = append(r.calls, name) + if name == r.failOn { + return fmt.Errorf("%s failed", name) + } + return nil +} + +func (r *revokeRunner) RunInput(_ string, name string, args ...string) error { + return r.Run(name, args...) +} + +func (*revokeRunner) Look(name string) bool { return name == "userdel" } + // newTestApp builds a minimal, root-free App: Geteuid is faked to 0 and the // registry points at a temp dir. Collaborators that only the mutating paths need // (Users/Sudoers/Scheduler/Selfmanage) are left nil; the tests here exercise @@ -56,6 +87,26 @@ func newTestApp(t *testing.T, in string) (*App, *bytes.Buffer, *bytes.Buffer) { return a, &out, &errb } +func TestPrintInviteClearsPrivateKeySource(t *testing.T) { + a, out, _ := newTestApp(t, "") + privatePEM := []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n-----END OPENSSH PRIVATE KEY-----\n") + err := a.printInvite(inviteBundle{ + user: "xxvcc-a1", host: "203.0.113.10", port: 22, expires: "soon", + kp: &sshkey.KeyPair{PrivatePEM: privatePEM}, verified: true, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "BEGIN OPENSSH PRIVATE KEY") { + t.Fatal("invite did not write the one-time private key") + } + for i, b := range privatePEM { + if b != 0 { + t.Fatalf("private key source byte %d was not cleared", i) + } + } +} + func TestExtractLang(t *testing.T) { cases := []struct { args []string @@ -86,6 +137,32 @@ func TestExtractLang(t *testing.T) { } } +func TestSetTrustedRootPath(t *testing.T) { + var key, value string + if err := setTrustedRootPath(func() int { return 0 }, func(k, v string) error { + key, value = k, v + return nil + }); err != nil { + t.Fatal(err) + } + if key != "PATH" || value != trustedRootPath { + t.Fatalf("root environment set (%q, %q), want PATH=%q", key, value, trustedRootPath) + } + + called := false + if err := setTrustedRootPath(func() int { return 1000 }, func(string, string) error { + called = true + return nil + }); err != nil || called { + t.Fatalf("non-root path changed: called=%v err=%v", called, err) + } + + wantErr := errors.New("setenv failed") + if err := setTrustedRootPath(func() int { return 0 }, func(string, string) error { return wantErr }); !errors.Is(err, wantErr) { + t.Fatalf("setenv error = %v, want %v", err, wantErr) + } +} + func TestReadLineEOFvsBlank(t *testing.T) { a := &App{In: strings.NewReader("hello\n\nx")} for _, want := range []struct { @@ -104,31 +181,201 @@ func TestReadLineEOFvsBlank(t *testing.T) { } } -func TestRevokeWaitsForLifecycleLockBeforeReadingState(t *testing.T) { +func TestReadLineRejectsAndDrainsOversizedInput(t *testing.T) { + var errb bytes.Buffer + a := &App{ + In: strings.NewReader(strings.Repeat("x", maxInteractiveLineBytes+1) + "\nYES\n"), + Err: &errb, + } + if got, ok := a.readLine(); got != rejectedInteractiveLine || !ok { + t.Fatalf("oversized readLine = (%q, %v), want rejected input", got, ok) + } + if got, ok := a.readLine(); got != "YES" || !ok { + t.Fatalf("line after oversized input = (%q, %v), want YES", got, ok) + } + if !strings.Contains(errb.String(), "input line is too long") { + t.Fatalf("oversized input warning missing: %q", errb.String()) + } +} + +type notifyingBuffer struct { + mu sync.Mutex + buf bytes.Buffer + needle string + once sync.Once + seen chan struct{} +} + +func newNotifyingBuffer(needle string) *notifyingBuffer { + return ¬ifyingBuffer{needle: needle, seen: make(chan struct{})} +} + +func (b *notifyingBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + n, err := b.buf.Write(p) + matched := strings.Contains(b.buf.String(), b.needle) + b.mu.Unlock() + if matched { + b.once.Do(func() { close(b.seen) }) + } + return n, err +} + +func (b *notifyingBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestRevokeConfirmationDoesNotHoldLifecycleLock(t *testing.T) { + path := filepath.Join(t.TempDir(), "lifecycle.lock") + a, _, _ := newTestApp(t, "") + a.Lifecycle = lifecycle.New(path) + reader, writer := io.Pipe() + t.Cleanup(func() { _ = reader.Close(); _ = writer.Close() }) + a.In = reader + a.inReader = nil + errOut := newNotifyingBuffer("type the full username root to confirm deletion") + a.Err = errOut + done := make(chan int, 1) + go func() { done <- a.revoke([]string{"--user", "root", "--force"}) }() + select { + case <-errOut.seen: + case <-time.After(2 * time.Second): + t.Fatalf("revoke did not reach its confirmation prompt: %q", errOut.String()) + } + + acquired := make(chan func() error, 1) + acquireErr := make(chan error, 1) + go func() { + release, err := lifecycle.New(path).Acquire() + if err != nil { + acquireErr <- err + return + } + acquired <- release + }() + select { + case err := <-acquireErr: + t.Fatal(err) + case release := <-acquired: + if err := release(); err != nil { + t.Fatal(err) + } + case <-time.After(500 * time.Millisecond): + _, _ = io.WriteString(writer, "root\n") + <-done + t.Fatal("revoke held the lifecycle lock while waiting for confirmation") + } + if _, err := io.WriteString(writer, "root\n"); err != nil { + t.Fatal(err) + } + select { + case rc := <-done: + if rc != 1 { + t.Fatalf("revoke rc=%d, want protected-root refusal", rc) + } + case <-time.After(2 * time.Second): + t.Fatal("revoke did not finish after confirmation") + } +} + +func TestUninstallConfirmationDoesNotHoldLifecycleLock(t *testing.T) { + base := t.TempDir() + path := filepath.Join(base, "lifecycle.lock") + a, _, _ := newTestApp(t, "") + a.Lifecycle = lifecycle.New(path) + a.StateDir = filepath.Join(base, "missing-state") + a.AuditLogDir = filepath.Join(base, "audit") + reader, writer := io.Pipe() + t.Cleanup(func() { _ = reader.Close(); _ = writer.Close() }) + a.In = reader + a.inReader = nil + errOut := newNotifyingBuffer("type YES to uninstall") + a.Err = errOut + done := make(chan int, 1) + go func() { done <- a.uninstall(nil) }() + select { + case <-errOut.seen: + case <-time.After(2 * time.Second): + t.Fatalf("uninstall did not reach its confirmation prompt: %q", errOut.String()) + } + + acquired := make(chan func() error, 1) + acquireErr := make(chan error, 1) + go func() { + release, err := lifecycle.New(path).Acquire() + if err != nil { + acquireErr <- err + return + } + acquired <- release + }() + select { + case err := <-acquireErr: + t.Fatal(err) + case release := <-acquired: + if err := release(); err != nil { + t.Fatal(err) + } + case <-time.After(500 * time.Millisecond): + _, _ = io.WriteString(writer, "NO\n") + <-done + t.Fatal("uninstall held the lifecycle lock while waiting for confirmation") + } + if _, err := io.WriteString(writer, "NO\n"); err != nil { + t.Fatal(err) + } + select { + case rc := <-done: + if rc != 0 { + t.Fatalf("cancelled uninstall rc=%d, want 0", rc) + } + case <-time.After(2 * time.Second): + t.Fatal("uninstall did not finish after cancellation") + } +} + +func TestQueuedLifecycleMutationStopsAfterUninstallMarker(t *testing.T) { path := filepath.Join(t.TempDir(), "lifecycle.lock") - release, err := lifecycle.New(path).Acquire() + l := lifecycle.New(path) + release, err := l.Acquire() if err != nil { t.Fatal(err) } a, _, _ := newTestApp(t, "") a.Lifecycle = lifecycle.New(path) + runs := make(chan struct{}, 1) done := make(chan int, 1) - go func() { done <- a.revoke([]string{"--user", "BAD!"}) }() + go func() { + done <- a.withLifecycleLock(func() int { + runs <- struct{}{} + return 0 + }) + }() select { - case <-done: - t.Fatal("revoke reached validation while another lifecycle mutation held the lock") + case <-runs: + t.Fatal("queued mutation ran while the lifecycle lock was held") case <-time.After(50 * time.Millisecond): } + if err := l.MarkUninstalled(); err != nil { + t.Fatal(err) + } if err := release(); err != nil { t.Fatal(err) } select { case rc := <-done: if rc != 1 { - t.Fatalf("revoke rc=%d, want validation failure after acquiring the lock", rc) + t.Fatalf("queued mutation rc=%d, want refusal", rc) } case <-time.After(2 * time.Second): - t.Fatal("revoke did not continue after the lifecycle lock was released") + t.Fatal("queued mutation did not finish after uninstall") + } + select { + case <-runs: + t.Fatal("queued mutation ran after uninstall marker was written") + default: } } @@ -146,6 +393,20 @@ func TestReadRunningBinaryUsesProcSelfExe(t *testing.T) { } } +func TestReadRunningBinaryRejectsOversizedInput(t *testing.T) { + path := filepath.Join(t.TempDir(), "oversized-binary") + if err := os.WriteFile(path, []byte("12345"), 0o700); err != nil { + t.Fatal(err) + } + a := &App{ + Executable: func() (string, error) { return path, nil }, + Selfmanage: &selfmanage.Manager{MaxBytes: 4}, + } + if _, err := a.readRunningBinary(); err == nil || !strings.Contains(err.Error(), "exceeds 4-byte") { + t.Fatalf("oversized running binary error = %v, want bounded-read refusal", err) + } +} + func TestDispatchRouting(t *testing.T) { a, out, _ := newTestApp(t, "") if rc := a.Dispatch([]string{"version"}); rc != 0 || !strings.Contains(out.String(), buildinfo.Version) { @@ -182,6 +443,48 @@ func TestOrphanScanErrorsAreNotHealthy(t *testing.T) { } } +func TestDoctorFailsWhenSSHDLoginCannotBeConfirmed(t *testing.T) { + t.Run("connection-dependent rule", func(t *testing.T) { + a, _, errb := newTestApp(t, "") + a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { + return sysinfo.ParseSSHD("pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\nallowusers xxvcc-doctor@203.0.113.0/24\n"), nil + } + + rep := a.checkKeyLogin(mustSSHDConfig(t, a, "xxvcc-doctor"), "xxvcc-doctor", []string{"xxvcc-doctor"}) + if !rep.OK() || rep.Certain() { + t.Fatalf("fixture report: OK=%v Certain=%v blockers=%v unverifiable=%v", rep.OK(), rep.Certain(), rep.Blockers, rep.Unverifiable) + } + if rc := a.doctor(nil); rc != 1 { + t.Fatalf("doctor rc=%d, want 1 for an unverifiable key-login policy", rc) + } + if got := strings.Count(errb.String(), "xxvcc-doctor@"); got != 1 { + t.Fatalf("connection-dependent rule reported %d times, want once:\n%s", got, errb.String()) + } + }) + + t.Run("effective config probe failure", func(t *testing.T) { + a, _, errb := newTestApp(t, "") + a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { + return nil, errors.New("injected sshd probe failure") + } + if rc := a.doctor(nil); rc != 1 { + t.Fatalf("doctor rc=%d, want 1 when sshd cannot be inspected", rc) + } + if !strings.Contains(errb.String(), "injected sshd probe failure") { + t.Fatalf("doctor hid the sshd probe failure: %q", errb.String()) + } + }) +} + +func mustSSHDConfig(t *testing.T, a *App, user string) *sysinfo.SSHDConfig { + t.Helper() + cfg, err := a.sshdConfig(user) + if err != nil { + t.Fatal(err) + } + return cfg +} + func TestEnsureStableInstalledRejectsUnsafeExistingCommand(t *testing.T) { t.Run("symlink", func(t *testing.T) { dir := t.TempDir() @@ -271,6 +574,128 @@ func TestMenuDoesNotRedrawAfterAction(t *testing.T) { } } +func TestMenuPreservesActionFailure(t *testing.T) { + original := menuItems + defer func() { menuItems = original }() + menuItems = []menuItem{ + {zh: "失败动作", en: "Failing action", run: func(*App) commandResult { return statusResult(7) }}, + {zh: "退出", en: "Exit"}, + } + a, _, _ := newTestApp(t, "1\n2\n") + if rc := a.menu(); rc != 7 { + t.Fatalf("menu rc=%d, want the action failure 7", rc) + } +} + +func TestMenuExitsAfterAppliedTerminalAction(t *testing.T) { + original := menuItems + defer func() { menuItems = original }() + calls := 0 + menuItems = []menuItem{ + {zh: "升级", en: "Upgrade", run: func(*App) commandResult { + calls++ + return commandResult{applied: true} + }, exitOnApply: true}, + {zh: "不应执行", en: "Must not run", run: func(*App) commandResult { + calls++ + return commandResult{} + }}, + {zh: "退出", en: "Exit"}, + } + a, _, _ := newTestApp(t, "1\n2\n") + if rc := a.menu(); rc != 0 { + t.Fatalf("menu rc=%d, want 0", rc) + } + if calls != 1 { + t.Fatalf("terminal action did not exit the menu: calls=%d, want 1", calls) + } +} + +func TestMenuContinuesAfterUnappliedTerminalAction(t *testing.T) { + original := menuItems + defer func() { menuItems = original }() + calls := 0 + menuItems = []menuItem{ + {zh: "取消的升级", en: "Cancelled upgrade", run: func(*App) commandResult { + calls++ + return commandResult{} + }, exitOnApply: true}, + {zh: "后续动作", en: "Following action", run: func(*App) commandResult { + calls++ + return commandResult{} + }}, + {zh: "退出", en: "Exit"}, + } + a, _, _ := newTestApp(t, "1\n2\n3\n") + if rc := a.menu(); rc != 0 { + t.Fatalf("menu rc=%d, want 0", rc) + } + if calls != 2 { + t.Fatalf("menu stopped after an unapplied terminal action: calls=%d, want 2", calls) + } +} + +func TestMenuCancelledUpgradeAndUninstallStayInMenu(t *testing.T) { + mainPrompt := fmt.Sprintf("select [1-%d] (Enter shows the menu): ", len(menuItems)) + for _, tc := range []struct { + name string + choice int + }{ + {name: "upgrade", choice: 4}, + {name: "uninstall", choice: 5}, + } { + t.Run(tc.name, func(t *testing.T) { + a, _, errb := newTestApp(t, fmt.Sprintf("%d\nNO\n%d\n", tc.choice, len(menuItems))) + a.StateDir = filepath.Join(t.TempDir(), "state") + a.AuditLogDir = filepath.Join(t.TempDir(), "audit") + if rc := a.menu(); rc != 0 { + t.Fatalf("menu rc=%d, want 0", rc) + } + if got := strings.Count(errb.String(), mainPrompt); got != 2 { + t.Fatalf("cancelled %s did not return to the menu prompt: got %d prompts\nstderr:\n%s", tc.name, got, errb.String()) + } + }) + } +} + +func TestMenuPromptChangesLanguageImmediately(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("language preference persistence requires root-owned fixtures") + } + oldPrefs := prefs.File + prefs.File = filepath.Join(t.TempDir(), "prefs") + defer func() { prefs.File = oldPrefs }() + + for _, tc := range []struct { + name string + initial i18n.Lang + selection string + target i18n.Lang + }{ + {name: "English to Chinese", initial: i18n.EN, selection: "1", target: i18n.ZH}, + {name: "Chinese to English", initial: i18n.ZH, selection: "2", target: i18n.EN}, + } { + t.Run(tc.name, func(t *testing.T) { + switchChoice := len(menuItems) - 1 + a, _, errb := newTestApp(t, fmt.Sprintf("%d\n%s\n%d\n", switchChoice, tc.selection, len(menuItems))) + a.P = i18n.Printer{Lang: tc.initial} + if rc := a.menu(); rc != 0 { + t.Fatalf("menu rc=%d, want 0", rc) + } + if a.P.Lang != tc.target { + t.Fatalf("language=%q, want %q", a.P.Lang, tc.target) + } + wantPrompt := fmt.Sprintf(map[i18n.Lang]string{ + i18n.ZH: "请选择 [1-%d](回车显示菜单): ", + i18n.EN: "select [1-%d] (Enter shows the menu): ", + }[tc.target], len(menuItems)) + if !strings.Contains(errb.String(), wantPrompt) { + t.Fatalf("menu did not use the switched language immediately; want %q in %q", wantPrompt, errb.String()) + } + }) + } +} + // TestMenuItemsAreTranslated guards the regression this table was built to fix: // entries once carried the bare English subcommand name in both languages, so a // zh run printed an English menu body. Asserting zh != en catches that directly, @@ -359,6 +784,10 @@ func TestInviteGuardsReject(t *testing.T) { {"bad host", []string{"--user", "xxvcc-a1", "--host", "bad host", "--no-sudo", "--no-auto-revoke", "--yes"}}, {"port zero", []string{"--user", "xxvcc-a1", "--host", "1.2.3.4", "--port", "0", "--no-sudo", "--no-auto-revoke", "--yes"}}, {"sudo yes needs confirm", []string{"--user", "xxvcc-a1", "--host", "1.2.3.4", "--sudo", "--yes"}}, + {"conflicting sudo", []string{"--sudo", "--no-sudo"}}, + {"conflicting sudo alias", []string{"--nopasswd-sudo", "--no-sudo"}}, + {"conflicting auto revoke", []string{"--auto-revoke", "--no-auto-revoke"}}, + {"conflicting dependency install", []string{"--install-deps", "--no-install-deps"}}, {"trailing arg", []string{"--user", "xxvcc-a1", "--host", "1.2.3.4", "--yes", "junk"}}, {"reserved user root", []string{"--user", "root", "--host", "1.2.3.4", "--no-sudo", "--no-auto-revoke", "--yes"}}, {"reserved user systemd-", []string{"--user", "systemd-abc", "--host", "1.2.3.4", "--no-sudo", "--no-auto-revoke", "--yes"}}, @@ -431,6 +860,158 @@ func TestRevokeGuardsReject(t *testing.T) { } } +func TestTeardownLocalAccountStopsWhenDisableLoginIsIncomplete(t *testing.T) { + for _, failedCommand := range []string{"chage", "usermod"} { + t.Run(failedCommand, func(t *testing.T) { + runner := &revokeRunner{failOn: failedCommand} + terminateCalls := 0 + a := &App{ + Users: &user.Manager{Runner: runner}, + TerminateProcesses: func(int) error { + terminateCalls++ + return nil + }, + } + + stage, err := a.teardownLocalAccount("xxvcc-a1", user.Passwd{Name: "xxvcc-a1", UID: 1001}) + if err == nil || stage != revokeDisableLogin { + t.Fatalf("teardownLocalAccount = stage %v, err %v; want disable failure", stage, err) + } + if terminateCalls != 0 { + t.Fatalf("TerminateProcesses called %d time(s) after incomplete login disable", terminateCalls) + } + if got, want := strings.Join(runner.calls, ","), "chage,usermod"; got != want { + t.Fatalf("account commands = %q, want %q and no userdel", got, want) + } + }) + } +} + +func TestTeardownLocalAccountReachesDeleteOnlyAfterDisableSucceeds(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + pw := user.Passwd{Name: "xxvcc-a1", UID: 1001, GID: 1001, Home: "/home/xxvcc-a1", Shell: "/bin/sh", GECOS: config.ManagedGenerationGECOSPrefix + generation} + runner := &revokeRunner{failOn: "userdel"} + terminateCalls := 0 + a := &App{ + Users: &user.Manager{Runner: runner}, + TerminateProcesses: func(uid int) error { + terminateCalls++ + if uid != 1001 { + t.Fatalf("TerminateProcesses uid = %d, want 1001", uid) + } + return nil + }, + LookupUser: func(string) (user.Passwd, bool, error) { return pw, true, nil }, + } + + stage, err := a.teardownLocalAccount("xxvcc-a1", pw) + if err == nil || stage != revokeDeleteAccount { + t.Fatalf("teardownLocalAccount = stage %v, err %v; want delete failure", stage, err) + } + if terminateCalls != 1 { + t.Fatalf("TerminateProcesses calls = %d, want 1", terminateCalls) + } + if got, want := strings.Join(runner.calls, ","), "chage,usermod,userdel"; got != want { + t.Fatalf("account commands = %q, want %q", got, want) + } +} + +func TestRollbackInviteAccountRequiresCompletedIdentity(t *testing.T) { + runner := &revokeRunner{} + terminateCalls := 0 + a := &App{ + Users: &user.Manager{Runner: runner}, + TerminateProcesses: func(int) error { + terminateCalls++ + return nil + }, + } + + for _, rec := range []registry.Record{ + {User: "xxvcc-a1", Pending: true}, + {User: "xxvcc-a1", UID: 0}, + } { + if err := a.rollbackInviteAccount("xxvcc-a1", rec, true); err == nil || !strings.Contains(err.Error(), "pending") { + t.Fatalf("rollbackInviteAccount(%+v) error = %v, want pending-identity refusal", rec, err) + } + } + if len(runner.calls) != 0 || terminateCalls != 0 { + t.Fatalf("pending identity reached destructive teardown: commands=%v terminateCalls=%d", runner.calls, terminateCalls) + } +} + +func TestRollbackInviteAccountUsesFailClosedTeardown(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + pw := user.Passwd{Name: "xxvcc-a1", UID: 1001, GID: 1001, Home: "/home/xxvcc-a1", Shell: "/bin/sh", GECOS: config.ManagedGenerationGECOSPrefix + generation} + rec := registry.Record{User: "xxvcc-a1", UID: 1001, Generation: generation, IdentityBound: true} + t.Run("success", func(t *testing.T) { + runner := &revokeRunner{} + terminateCalls := 0 + a := &App{ + Users: &user.Manager{Runner: runner}, + TerminateProcesses: func(uid int) error { + terminateCalls++ + if uid != 1001 { + t.Fatalf("TerminateProcesses uid = %d, want 1001", uid) + } + return nil + }, + LookupUser: func(string) (user.Passwd, bool, error) { return pw, true, nil }, + } + if err := a.rollbackInviteAccount("xxvcc-a1", rec, true); err != nil { + t.Fatal(err) + } + if terminateCalls != 1 || strings.Join(runner.calls, ",") != "chage,usermod,userdel" { + t.Fatalf("rollback order wrong: commands=%v terminateCalls=%d", runner.calls, terminateCalls) + } + }) + + t.Run("process uncertainty retains account", func(t *testing.T) { + runner := &revokeRunner{} + wantErr := errors.New("process scan incomplete") + a := &App{ + Users: &user.Manager{Runner: runner}, + TerminateProcesses: func(int) error { return wantErr }, + LookupUser: func(string) (user.Passwd, bool, error) { return pw, true, nil }, + } + err := a.rollbackInviteAccount("xxvcc-a1", rec, true) + if !errors.Is(err, wantErr) { + t.Fatalf("rollback error = %v, want %v", err, wantErr) + } + if got := strings.Join(runner.calls, ","); got != "chage,usermod" { + t.Fatalf("process uncertainty reached userdel: commands=%q", got) + } + }) + + t.Run("same UID replacement is retained", func(t *testing.T) { + runner := &revokeRunner{} + lookups := 0 + replacement := pw + replacement.GECOS = config.ManagedGenerationGECOSPrefix + "fedcba9876543210fedcba9876543210" + a := &App{ + Users: &user.Manager{Runner: runner}, + LookupUser: func(string) (user.Passwd, bool, error) { + lookups++ + if lookups == 1 { + return pw, true, nil + } + return replacement, true, nil + }, + TerminateProcesses: func(int) error { + t.Fatal("replacement reached process termination") + return nil + }, + } + err := a.rollbackInviteAccount("xxvcc-a1", rec, true) + if err == nil || !strings.Contains(err.Error(), "identity changed") { + t.Fatalf("rollback error = %v, want replacement refusal", err) + } + if got := strings.Join(runner.calls, ","); got != "chage,usermod" { + t.Fatalf("replacement reached delete: commands=%q", got) + } + }) +} + func TestUninstallRefusesOnRegistryReadError(t *testing.T) { a, _, errb := newTestApp(t, "") // Make the registry file a symlink so List() errors. @@ -442,6 +1023,42 @@ func TestUninstallRefusesOnRegistryReadError(t *testing.T) { } } +func TestRecursiveRemovalNeverAcceptsRootOrRelativePaths(t *testing.T) { + for _, path := range []string{"", ".", "relative/state", "/"} { + if err := safeRecursiveRemovalPath(path); err == nil { + t.Errorf("safeRecursiveRemovalPath(%q) unexpectedly allowed recursive removal", path) + } + } + if err := safeRecursiveRemovalPath("/var/lib/linux-temp-admin"); err != nil { + t.Fatalf("safe managed path rejected: %v", err) + } +} + +func TestRecursiveRemovalRejectsRootAndNestedMounts(t *testing.T) { + base := "28 1 254:4 / / rw,relatime - ext4 /dev/root rw\n" + for _, line := range []string{ + "40 28 0:40 / /var/lib/linux-temp-admin rw - tmpfs tmpfs rw\n", + "41 28 0:41 / /var/lib/linux-temp-admin/cache rw - tmpfs tmpfs rw\n", + "42 28 0:42 / /var/lib/linux-temp-admin/with\\040space rw - tmpfs tmpfs rw\n", + } { + if err := rejectMountsUnder(strings.NewReader(base+line), "/var/lib/linux-temp-admin"); err == nil { + t.Fatalf("mountinfo entry was accepted: %q", line) + } + } + outside := base + "43 28 0:43 / /var/lib/linux-temp-admin-old rw - tmpfs tmpfs rw\n" + if err := rejectMountsUnder(strings.NewReader(outside), "/var/lib/linux-temp-admin"); err != nil { + t.Fatalf("sibling mountpoint was mistaken for a child: %v", err) + } +} + +func TestMountInfoParserFailsClosed(t *testing.T) { + for _, input := range []string{"too short\n", "40 28 0:40 / /bad\\0 rw - x x rw\n"} { + if err := rejectMountsUnder(strings.NewReader(input), "/state"); err == nil { + t.Fatalf("malformed mountinfo was accepted: %q", input) + } + } +} + // TestInviteNonTTYRefusesBeforeAnyPrompt pins the ordering: a piped run must be // rejected before invite asks anything or probes the network for a host, so an // operator never answers prompts only to be refused at the end. @@ -514,6 +1131,346 @@ func TestInviteSurvivesAnUnwiredSSHDProbe(t *testing.T) { } } +func TestPasswordLoginFailsClosedWhenSSHDProbeFails(t *testing.T) { + a, _, errb := newTestApp(t, "") + a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { + return nil, errors.New("probe unavailable") + } + if plan, ok := a.planLogin("xxvcc-a1", true, "no", true); ok { + t.Fatalf("password plan unexpectedly accepted after probe failure: %+v", plan) + } + if !strings.Contains(errb.String(), "refusing a password login") { + t.Fatalf("password refusal did not name the fail-closed reason: %q", errb.String()) + } +} + +func TestPasswordLoginFailsClosedWhenConfirmationProbeFails(t *testing.T) { + a, _, _ := newTestApp(t, "") + a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { + return nil, errors.New("second probe unavailable") + } + plan := loginPlan{password: true, verified: true} + if a.confirmLogin("xxvcc-a1", []string{"xxvcc-a1"}, &plan) { + t.Fatal("password login remained accepted after its confirmation probe failed") + } +} + +func TestPasswordLoginFailsClosedWhenSSHDPolicyIsUnverifiable(t *testing.T) { + const conf = "passwordauthentication yes\nallowusers xxvcc-a1@203.0.113.5\n" + a, _, errb := newTestApp(t, "") + a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { + return sysinfo.ParseSSHD(conf), nil + } + if plan, ok := a.planLogin("xxvcc-a1", true, "no", true); ok { + t.Fatalf("password plan unexpectedly accepted an address-dependent policy: %+v", plan) + } + if !strings.Contains(errb.String(), "cannot prove") { + t.Fatalf("password refusal did not name the unverifiable policy: %q", errb.String()) + } +} + +func TestPasswordConfirmationFailsClosedWhenSSHDPolicyBecomesUnverifiable(t *testing.T) { + const conf = "passwordauthentication yes\nallowusers xxvcc-a1@203.0.113.5\n" + a, _, _ := newTestApp(t, "") + a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { + return sysinfo.ParseSSHD(conf), nil + } + plan := loginPlan{password: true, verified: true} + if a.confirmLogin("xxvcc-a1", []string{"xxvcc-a1"}, &plan) { + t.Fatal("password login remained accepted after its policy became address-dependent") + } +} + +func TestPasswordFallbackIsNotOfferedForUnverifiablePolicy(t *testing.T) { + const conf = "passwordauthentication yes\nallowusers xxvcc-a1@203.0.113.5\n" + a, errb := interactiveApp(t, "y\n", conf) + if plan, ok := a.offerPasswordFallback(sysinfo.ParseSSHD(conf), "xxvcc-a1", true); ok { + t.Fatalf("password fallback unexpectedly accepted an address-dependent policy: %+v", plan) + } + if !strings.Contains(errb.String(), "no password fallback") { + t.Fatalf("password fallback refusal did not explain the unverifiable policy: %q", errb.String()) + } +} + +func TestDetachedSignatureURLPreservesQueryAndFragment(t *testing.T) { + cases := map[string]string{ + "https://example.com/releases/lta": "https://example.com/releases/lta.sig", + "https://example.com/releases/lta?token=abc#download": "https://example.com/releases/lta.sig?token=abc#download", + "https://example.com/releases/a%2Fb?token=abc%2Fdef#asset": "https://example.com/releases/a%2Fb.sig?token=abc%2Fdef#asset", + } + for raw, want := range cases { + got, err := detachedSignatureURL(raw) + if err != nil { + t.Errorf("detachedSignatureURL(%q): %v", raw, err) + continue + } + if got != want { + t.Errorf("detachedSignatureURL(%q) = %q, want %q", raw, got, want) + } + } + if _, err := detachedSignatureURL("https://example.com/%zz"); err == nil { + t.Fatal("malformed escaped URL was accepted") + } +} + +func TestUpgradePromptRedactsCustomURLDetails(t *testing.T) { + const ( + userinfoMarker = "userinfo-marker-8d31" + pathMarker = "path-marker-4b72" + queryMarker = "query-marker-6c93" + fragmentMarker = "fragment-marker-1a54" + ) + rawURL := "https://" + userinfoMarker + ":password@example.invalid/releases/" + pathMarker + + "?token=" + queryMarker + "#" + fragmentMarker + urlFile := filepath.Join(t.TempDir(), "upgrade-url") + if err := os.WriteFile(urlFile, []byte(rawURL+"\n"), 0o600); err != nil { + t.Fatal(err) + } + a, out, errb := newTestApp(t, "NO\n") + if rc := a.upgrade([]string{"--url-file", urlFile}); rc != 0 { + t.Fatalf("cancelled upgrade rc=%d, want 0", rc) + } + display := out.String() + errb.String() + if !strings.Contains(display, "https://example.invalid/[details hidden]") { + t.Fatalf("custom upgrade prompt lost the safe endpoint: %q", display) + } + for _, marker := range []string{userinfoMarker, pathMarker, queryMarker, fragmentMarker} { + if strings.Contains(display, marker) { + t.Errorf("custom upgrade prompt leaked %q: %q", marker, display) + } + } +} + +func TestUpgradeMalformedURLDiagnosticDoesNotEchoInput(t *testing.T) { + markers := []string{ + "userinfo-marker-8d31", + "path-marker-4b72", + "query-marker-6c93", + "fragment-marker-1a54", + } + rawURL := "https://" + markers[0] + "@example.invalid/releases/" + markers[1] + + "/%zz?token=" + markers[2] + "#" + markers[3] + urlFile := filepath.Join(t.TempDir(), "upgrade-url") + if err := os.WriteFile(urlFile, []byte(rawURL+"\n"), 0o600); err != nil { + t.Fatal(err) + } + a, out, errb := newTestApp(t, "") + if rc := a.upgrade([]string{"--url-file", urlFile}); rc != 1 { + t.Fatalf("malformed upgrade URL rc=%d, want 1", rc) + } + diagnostic := out.String() + errb.String() + if !strings.Contains(diagnostic, "malformed URL syntax") { + t.Fatalf("malformed URL diagnostic is not useful: %q", diagnostic) + } + for _, marker := range markers { + if strings.Contains(diagnostic, marker) { + t.Errorf("malformed URL diagnostic leaked %q: %q", marker, diagnostic) + } + } +} + +func TestUpgradeRejectsSensitiveCommandLineURL(t *testing.T) { + markers := []string{"user-marker-18d2", "query-marker-8be1", "fragment-marker-c7f4"} + cases := []string{ + "https://" + markers[0] + ":password@example.invalid/releases/bin", + "https://example.invalid/releases/bin?token=" + markers[1], + "https://example.invalid/releases/bin#" + markers[2], + } + for _, rawURL := range cases { + a, out, errb := newTestApp(t, "") + if rc := a.upgrade([]string{"--url", rawURL, "--yes"}); rc != 1 { + t.Errorf("sensitive --url %q rc=%d, want 1", rawURL, rc) + } + diagnostic := out.String() + errb.String() + if !strings.Contains(diagnostic, "--url-file") { + t.Errorf("sensitive --url refusal lacks safe alternative: %q", diagnostic) + } + for _, marker := range markers { + if strings.Contains(diagnostic, marker) { + t.Errorf("sensitive --url refusal echoed %q: %q", marker, diagnostic) + } + } + } +} + +func TestUpgradeURLFileInputGuards(t *testing.T) { + write := func(t *testing.T, content string, mode os.FileMode) string { + t.Helper() + path := filepath.Join(t.TempDir(), "upgrade-url") + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } + return path + } + + valid := write(t, "https://example.invalid/releases/bin?token=secret#fragment\n", 0o600) + if got, err := readUpgradeURLFile(valid); err != nil || got.binary != "https://example.invalid/releases/bin?token=secret#fragment" || got.signature != "" { + t.Fatalf("valid URL file: got=%+v err=%v", got, err) + } + twoURLs := write(t, "https://example.invalid/bin?binary-token=one\nhttps://signatures.invalid/bin.sig?signature-token=two\n", 0o600) + if got, err := readUpgradeURLFile(twoURLs); err != nil || + got.binary != "https://example.invalid/bin?binary-token=one" || + got.signature != "https://signatures.invalid/bin.sig?signature-token=two" { + t.Fatalf("two-URL file: got=%+v err=%v", got, err) + } + t.Run("two maximum length URLs", func(t *testing.T) { + prefix := "https://example.invalid/" + maxURL := prefix + strings.Repeat("a", 2048-len(prefix)) + path := write(t, maxURL+"\n"+maxURL+"\n", 0o600) + got, err := readUpgradeURLFile(path) + if err != nil || got.binary != maxURL || got.signature != maxURL { + t.Fatalf("maximum URL file: got lengths=(%d,%d) err=%v", + len(got.binary), len(got.signature), err) + } + }) + for name, path := range map[string]string{ + "relative path": "relative-upgrade-url", + "three lines": write(t, "https://example.invalid/bin\nhttps://example.invalid/bin.sig\nhttps://example.invalid/extra\n", 0o600), + "group readable": write(t, "https://example.invalid/bin\n", 0o640), + "oversized": write(t, strings.Repeat("x", int(maxUpgradeURLFileBytes)+1), 0o600), + } { + t.Run(name, func(t *testing.T) { + if _, err := readUpgradeURLFile(path); err == nil { + t.Fatal("unsafe URL file unexpectedly accepted") + } + }) + } + t.Run("symlink", func(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("https://example.invalid/bin\n"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "upgrade-url") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := readUpgradeURLFile(link); err == nil { + t.Fatal("symlink URL file unexpectedly accepted") + } + }) + t.Run("fifo", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "upgrade-url") + if err := unix.Mkfifo(path, 0o600); err != nil { + t.Fatal(err) + } + if _, err := readUpgradeURLFile(path); err == nil { + t.Fatal("FIFO URL file unexpectedly accepted") + } + }) +} + +func TestUpgradeRejectsUnsafeURLBeforePromptOrDownload(t *testing.T) { + cases := []struct { + name string + content string + }{ + {name: "http binary", content: "http://example.invalid/bin\n"}, + {name: "missing host", content: "https:///bin\n"}, + {name: "oversized binary", content: "https://example.invalid/" + strings.Repeat("a", 2048) + "\n"}, + {name: "http signature", content: "https://example.invalid/bin\nhttp://example.invalid/bin.sig\n"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "upgrade-url") + if err := os.WriteFile(path, []byte(tt.content), 0o600); err != nil { + t.Fatal(err) + } + a, out, errb := newTestApp(t, "YES\n") + if rc := a.upgrade([]string{"--url-file", path}); rc != 1 { + t.Fatalf("unsafe URL rc=%d, want 1", rc) + } + diagnostic := out.String() + errb.String() + if strings.Contains(diagnostic, "type YES") || strings.Contains(diagnostic, "确认请输入 YES") { + t.Fatalf("unsafe URL reached confirmation prompt: %q", diagnostic) + } + }) + } +} + +func TestUpgradeURLFileUsesIndependentSignedURLs(t *testing.T) { + const ( + binaryURL = "https://binary.example.invalid/release?binary-token=one" + signatureURL = "https://signature.example.invalid/release.sig?signature-token=two" + ) + urlFile := filepath.Join(t.TempDir(), "upgrade-url") + if err := os.WriteFile(urlFile, []byte(binaryURL+"\n"+signatureURL+"\n"), 0o600); err != nil { + t.Fatal(err) + } + var requested []string + m := &selfmanage.Manager{ + InstallPath: filepath.Join(t.TempDir(), "linux-temp-admin"), + PublicKey: make(ed25519.PublicKey, ed25519.PublicKeySize), + MaxBytes: 1 << 20, + RetryDelay: time.Nanosecond, + Client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requested = append(requested, req.URL.String()) + if req.URL.String() == binaryURL { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("candidate")), + Request: req, + }, nil + } + return nil, errors.New("injected signature fetch stop") + })}, + } + a, _, _ := newTestApp(t, "") + a.Selfmanage = m + if rc := a.upgrade([]string{"--url-file", urlFile, "--yes"}); rc != 1 { + t.Fatalf("injected failed upgrade rc=%d, want 1", rc) + } + if len(requested) < 2 || requested[0] != binaryURL { + t.Fatalf("binary request sequence = %q", requested) + } + for _, got := range requested[1:] { + if got != signatureURL { + t.Fatalf("derived or wrong signature URL requested: got %q want %q; all=%q", got, signatureURL, requested) + } + } +} + +func TestUpgradePositionalArgumentDiagnosticDoesNotEchoSecret(t *testing.T) { + const marker = "positional-query-secret-7d21" + a, out, errb := newTestApp(t, "") + if rc := a.upgrade([]string{"https://example.invalid/bin?token=" + marker}); rc != 1 { + t.Fatalf("positional upgrade URL rc=%d, want 1", rc) + } + diagnostic := out.String() + errb.String() + if !strings.Contains(diagnostic, "does not accept positional arguments") { + t.Fatalf("generic positional diagnostic missing: %q", diagnostic) + } + if strings.Contains(diagnostic, marker) { + t.Fatalf("positional diagnostic leaked secret: %q", diagnostic) + } +} + +func TestUpgradeURLSelectorsAreMutuallyExclusive(t *testing.T) { + path := filepath.Join(t.TempDir(), "upgrade-url") + if err := os.WriteFile(path, []byte("https://example.invalid/bin\n"), 0o600); err != nil { + t.Fatal(err) + } + a, _, errb := newTestApp(t, "") + if rc := a.upgrade([]string{"--url", "https://example.invalid/bin", "--url-file", path, "--yes"}); rc != 1 { + t.Fatalf("mutually exclusive URL selectors rc=%d, want 1", rc) + } + if !strings.Contains(errb.String(), "mutually exclusive") { + t.Fatalf("mutual-exclusion diagnostic missing: %q", errb.String()) + } +} + +func TestUpgradePromptNamesOfficialMirrorAndTransportFallback(t *testing.T) { + a, out, _ := newTestApp(t, "NO\n") + if rc := a.upgrade(nil); rc != 0 { + t.Fatalf("cancelled default upgrade rc=%d, want 0", rc) + } + if display := out.String(); !strings.Contains(display, config.ReleaseMirrorBaseURL) || + !strings.Contains(display, config.GitHubReleaseRoot) { + t.Fatalf("default source and fallback were not shown: %q", display) + } +} + // interactiveApp is a root App wired for the interactive planLogin branches: a // TTY stdin fed from `in`, and an sshd effective config parsed from `sshdConf`. func interactiveApp(t *testing.T, in, sshdConf string) (*App, *bytes.Buffer) { @@ -546,6 +1503,20 @@ func TestPlanLoginOffersPasswordFallback(t *testing.T) { } } +func TestPlanLoginRejectsTyposAtLoginChoicePrompts(t *testing.T) { + const conf = "pubkeyauthentication no\npasswordauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\n" + a, errb := interactiveApp(t, "maybe\nn\nperhaps\ny\n", conf) + a.SSHD = &sshdconf.Manager{} + + plan, ok := a.planLogin("xxvcc-a1", false, "ask", false) + if !ok || !plan.password { + t.Fatalf("planLogin after corrected answers = (%+v, %v), want password fallback", plan, ok) + } + if got := strings.Count(errb.String(), "enter y or n"); got != 2 { + t.Fatalf("invalid login-choice answers produced %d validation messages, want 2: %q", got, errb.String()) + } +} + // TestPlanLoginPasswordFallbackDefaultsNo: the offer defaults to No, so a blank // answer leaves the operator refused rather than silently issuing a password. func TestPlanLoginPasswordFallbackDefaultsNo(t *testing.T) { @@ -587,6 +1558,70 @@ func TestPromptHours(t *testing.T) { } } +func TestPromptYesNoRejectsTypos(t *testing.T) { + a, _, errb := newTestApp(t, "never\nmaybe\nn\n") + a.StdinIsTTY = func() bool { return true } + if answer, ok := a.promptYesNo("Auto-delete? [Y/n]: ", true); !ok || answer { + t.Fatal("an eventual explicit no was not accepted") + } + if got := strings.Count(errb.String(), "enter y or n"); got != 2 { + t.Fatalf("invalid answers produced %d validation messages, want 2: %q", got, errb.String()) + } + a, _, _ = newTestApp(t, "\n") + if answer, ok := a.promptYesNo("Auto-delete? [Y/n]: ", true); !ok || !answer { + t.Fatal("blank answer did not accept the documented yes default") + } +} + +func TestPromptYesNoStopsAfterInvalidNonTTYInputAndEOF(t *testing.T) { + a, _, errb := newTestApp(t, "maybe\nmaybe\n") + if answer, ok := a.promptYesNo("Auto-delete? [Y/n]: ", true); ok || answer { + t.Fatal("invalid non-TTY input must abort instead of selecting a default") + } + if got := strings.Count(errb.String(), "enter y or n"); got != 1 { + t.Fatalf("invalid non-TTY input produced %d validation messages, want 1: %q", got, errb.String()) + } + + a, _, errb = newTestApp(t, "") + if answer, ok := a.promptYesNo("Auto-delete? [Y/n]: ", true); ok || answer { + t.Fatal("EOF must abort instead of selecting the yes default") + } + if !strings.Contains(errb.String(), "input ended; cancelled") { + t.Fatalf("EOF cancellation was not reported: %q", errb.String()) + } +} + +func TestClassifyRegisteredAccountIdentityStates(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + managed := user.Passwd{UID: 1001, GECOS: config.ManagedGenerationGECOSPrefix + generation} + legacy := user.Passwd{UID: 1001, GECOS: config.ManagedGECOS} + tests := []struct { + name string + rec registry.Record + pw user.Passwd + exists bool + err error + want registeredAccountState + }{ + {name: "missing", want: registeredMissing}, + {name: "lookup error", err: errors.New("passwd unreadable"), want: registeredUnknown}, + {name: "pending", rec: registry.Record{UID: 1001, Generation: generation, IdentityBound: true, Pending: true}, pw: managed, exists: true, want: registeredPending}, + {name: "no trusted UID", rec: registry.Record{}, pw: managed, exists: true, want: registeredIdentityUnverified}, + {name: "UID mismatch", rec: registry.Record{UID: 1002, Generation: generation, IdentityBound: true}, pw: managed, exists: true, want: registeredUIDMismatch}, + {name: "legacy", rec: registry.Record{UID: 1001}, pw: legacy, exists: true, want: registeredLegacyIdentity}, + {name: "marker mismatch", rec: registry.Record{UID: 1001, Generation: generation, IdentityBound: true}, pw: user.Passwd{UID: 1001}, exists: true, want: registeredMarkerMismatch}, + {name: "generation mismatch", rec: registry.Record{UID: 1001, Generation: "fedcba9876543210fedcba9876543210", IdentityBound: true}, pw: managed, exists: true, want: registeredMarkerMismatch}, + {name: "active", rec: registry.Record{UID: 1001, Generation: generation, IdentityBound: true}, pw: managed, exists: true, want: registeredActive}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := classifyRegisteredAccount(tc.rec, tc.pw, tc.exists, tc.err); got != tc.want { + t.Fatalf("state=%v, want %v", got, tc.want) + } + }) + } +} + func mustHours(t *testing.T, in string, def int) int { t.Helper() a, _, _ := newTestApp(t, in) @@ -598,14 +1633,39 @@ func mustHours(t *testing.T, in string, def int) int { // plan must refuse (returns false), never claiming an install it cannot do. func TestPlanDepsAllPresent(t *testing.T) { a, _, _ := newTestApp(t, "") - // The account tools exist on this test host, so nothing is missing and no - // package list is produced. + binDir := t.TempDir() + for _, name := range []string{"id", "useradd", "usermod", "chage", "userdel"} { + if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", binDir) + pkgs, ok := a.planDeps(false, false, false, true) if !ok || len(pkgs) != 0 { t.Errorf("planDeps = %v, %v; want nil,true when nothing is missing", pkgs, ok) } } +func TestPlanDepsRefusesAutomaticPacmanPartialUpgrade(t *testing.T) { + a, _, errb := newTestApp(t, "") + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "pacman"), []byte("#!/bin/sh\nexit 99\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + + pkgs, ok := a.planDeps(false, true, false, true) + if ok || len(pkgs) != 0 { + t.Fatalf("planDeps = %v, %v; want refusal on pacman", pkgs, ok) + } + got := errb.String() + if !strings.Contains(got, "Arch does not support partial upgrades") || + !strings.Contains(got, "pacman -Syu --needed coreutils shadow") { + t.Fatalf("pacman refusal did not explain the safe manual path: %q", got) + } +} + // A generated username is chosen before dependency planning. If `id` itself is // missing, that early step must still reach the dependency gate so --install-deps // can repair the host; the authoritative NSS collision check runs later, after @@ -715,10 +1775,88 @@ func TestResolveLangPrecedence(t *testing.T) { // a non-TTY run has nobody to ask. Neither may be stopped by the question. func TestAskLangSkipsUnattendedRuns(t *testing.T) { // stdin here is not a terminal, which alone is enough to skip. - if _, ok := askLang(nil); ok { + if _, ok, prompted := askLang(nil); ok || prompted { t.Error("askLang must not prompt without a terminal") } - if _, ok := askLang([]string{"invite", "--yes"}); ok { + if _, ok, prompted := askLang([]string{"invite", "--yes"}); ok || prompted { t.Error("askLang must not prompt during a --yes run") } } + +func TestResolveLangPromptEOFAbortsInteractiveRun(t *testing.T) { + dir := t.TempDir() + old := prefs.File + prefs.File = filepath.Join(dir, "prefs") + t.Cleanup(func() { prefs.File = old }) + + lang, remember, proceed := resolveLangChoiceWith("", "", nil, + func([]string) (i18n.Lang, bool, bool) { return "", false, true }) + if lang != i18n.ZH || remember || proceed { + t.Fatalf("language prompt EOF = (%q, remember=%v, proceed=%v), want (zh, false, false)", lang, remember, proceed) + } +} + +func TestShouldAskLangSkipsInviteThatCannotSafelyPrintCredential(t *testing.T) { + if shouldAskLang([]string{"invite"}, true, true, false) { + t.Fatal("invite with redirected stdout must reach its refusal without a language prompt") + } + if !shouldAskLang([]string{"invite", "--allow-non-tty-private-key-output"}, true, true, false) { + t.Fatal("the explicit non-TTY credential-output override should permit the language prompt") + } + if !shouldAskLang(nil, true, true, false) { + t.Fatal("a redirected menu is not a credential-output refusal and may still ask") + } + if shouldAskLang([]string{"invite"}, false, true, true) { + t.Fatal("non-TTY stdin must never be prompted") + } +} + +func TestAskLangInputValidatesAndPreservesBufferedAnswers(t *testing.T) { + var out bytes.Buffer + lang, ok := askLangInput(strings.NewReader("9\nwrong\n2\n"), &out) + if !ok || lang != i18n.EN { + t.Fatalf("askLangInput = (%q, %v), want (en, true)", lang, ok) + } + if got := strings.Count(out.String(), "选择 / select [1-2]"); got != 3 { + t.Errorf("prompt count = %d, want 3:\n%s", got, out.String()) + } + if got := strings.Count(out.String(), "invalid choice"); got != 2 { + t.Errorf("invalid warning count = %d, want 2:\n%s", got, out.String()) + } +} + +func TestAskLangInputRejectsOversizedLineAndKeepsNextAnswer(t *testing.T) { + var out bytes.Buffer + input := strings.Repeat("9", maxInteractiveLineBytes+1) + "\n2\n" + lang, ok := askLangInput(strings.NewReader(input), &out) + if !ok || lang != i18n.EN { + t.Fatalf("askLangInput after oversized line = (%q, %v), want (en, true)", lang, ok) + } + if !strings.Contains(out.String(), "input is too long") { + t.Fatalf("oversized language input warning missing: %q", out.String()) + } +} + +func TestAskLangInputDefaultsAndEOF(t *testing.T) { + tests := []struct { + name string + in string + lang i18n.Lang + ok bool + }{ + {name: "blank default", in: "\n", lang: i18n.ZH, ok: true}, + {name: "explicit Chinese", in: "1\n", lang: i18n.ZH, ok: true}, + {name: "English at EOF", in: "2", lang: i18n.EN, ok: true}, + {name: "empty EOF", in: "", ok: false}, + {name: "invalid EOF", in: "wrong", ok: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var out bytes.Buffer + lang, ok := askLangInput(strings.NewReader(tc.in), &out) + if lang != tc.lang || ok != tc.ok { + t.Errorf("askLangInput(%q) = (%q, %v), want (%q, %v)", tc.in, lang, ok, tc.lang, tc.ok) + } + }) + } +} diff --git a/internal/cli/commands.go b/internal/cli/commands.go index d9a046c..6806736 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -1,16 +1,13 @@ package cli import ( - "context" "errors" "flag" "fmt" - "os" - "os/exec" "sort" "strconv" "strings" - "time" + "unicode/utf8" "github.com/xxvcc/linux-temp-admin/internal/buildinfo" "github.com/xxvcc/linux-temp-admin/internal/config" @@ -18,6 +15,7 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/i18n" "github.com/xxvcc/linux-temp-admin/internal/prefs" "github.com/xxvcc/linux-temp-admin/internal/registry" + "github.com/xxvcc/linux-temp-admin/internal/selfmanage" "github.com/xxvcc/linux-temp-admin/internal/sysinfo" "github.com/xxvcc/linux-temp-admin/internal/table" "github.com/xxvcc/linux-temp-admin/internal/user" @@ -49,7 +47,7 @@ func (a *App) status(args []string) int { a.errorf("%s", a.P.M("用户名不合法:"+u, "invalid username: "+u)) return 1 } - pw, ok, err := user.Lookup(u) + pw, ok, err := a.lookupUser(u) if err != nil { a.errorf("%s: %v", a.P.M("读取账号数据库失败", "reading account database failed"), err) return 1 @@ -58,20 +56,33 @@ func (a *App) status(args []string) int { a.errorf("%s", a.P.M("用户不存在:"+u, "user does not exist: "+u)) return 1 } - managed, err := user.IsManaged(u) - if err != nil { - a.errorf("%s: %v", a.P.M("读取账号数据库失败", "reading account database failed"), err) - return 1 - } - a.printf("user=%s uid=%d gid=%d home=%s shell=%s managed=%v", - pw.Name, pw.UID, pw.GID, pw.Home, pw.Shell, managed) - unit, err := a.Registry.UnitFor(u) + rec, found, err := a.Registry.Lookup(u) if err != nil { a.errorf("%s: %v", a.P.M("读取注册表失败", "reading registry failed"), err) return 1 } - if unit != "" { - a.printf("auto-revoke unit=%s", unit) + managed := false + identity := "unregistered" + if found { + switch classifyRegisteredAccount(rec, pw, true, nil) { + case registeredActive: + managed, identity = true, "generation-bound" + case registeredLegacyIdentity: + identity = "legacy-unverified" + case registeredPending: + identity = "pending" + case registeredUIDMismatch: + identity = "uid-mismatch" + case registeredMarkerMismatch: + identity = "generation-marker-mismatch" + default: + identity = "unverified" + } + } + a.printf("user=%s uid=%d gid=%d home=%s shell=%s managed=%v identity=%s", + pw.Name, pw.UID, pw.GID, pw.Home, pw.Shell, managed, identity) + if found && rec.AutoUnit != "" { + a.printf("auto-revoke unit=%s", rec.AutoUnit) } return 0 } @@ -86,7 +97,7 @@ func (a *App) status(args []string) int { a.printf(" %s", a.P.M("(无)", "(none)")) return 0 } - a.printf("%s", a.usersTable(recs, false).String()) + a.printf("%s", a.usersView(recs, false)) return 0 } @@ -118,17 +129,8 @@ func (a *App) usersTable(recs []registry.Record, numbered bool) *table.Table { headers = append([]string{"#"}, headers...) } t := table.New(headers...) - yn := func(b bool) string { - return a.P.M(map[bool]string{true: "是", false: "否"}[b], map[bool]string{true: "yes", false: "no"}[b]) - } for i, r := range recs { - state := a.P.M("缺失", "missing") - if exists, err := user.Exists(r.User); err != nil { - state = a.P.M("未知", "unknown") - } else if exists { - state = a.P.M("在册", "active") - } - cells := []string{r.User, state, yn(r.Sudo), yn(r.AutoRevoke), r.Expires, r.Host, strconv.Itoa(r.Port)} + cells := a.userCells(r) if numbered { cells = append([]string{strconv.Itoa(i + 1)}, cells...) } @@ -137,6 +139,156 @@ func (a *App) usersTable(recs []registry.Record, numbered bool) *table.Table { return t } +func (a *App) userCells(r registry.Record) []string { + yn := func(value bool) string { + return a.P.M(map[bool]string{true: "是", false: "否"}[value], map[bool]string{true: "yes", false: "no"}[value]) + } + pw, exists, err := a.lookupUser(r.User) + var state string + switch classifyRegisteredAccount(r, pw, exists, err) { + case registeredActive: + state = a.P.M("在册", "active") + case registeredPending: + state = a.P.M("创建未完成", "pending") + case registeredIdentityUnverified: + state = a.P.M("身份未验证", "identity unverified") + case registeredLegacyIdentity: + state = a.P.M("旧版身份未验证", "legacy identity unverified") + case registeredUIDMismatch: + state = a.P.M("UID 不匹配", "UID mismatch") + case registeredMarkerMismatch: + state = a.P.M("标记不匹配", "marker mismatch") + case registeredUnknown: + state = a.P.M("未知", "unknown") + default: + state = a.P.M("缺失", "missing") + } + return []string{r.User, state, yn(r.Sudo), yn(r.AutoRevoke), r.Expires, r.Host, strconv.Itoa(r.Port)} +} + +// usersView keeps the comparison table on ordinary terminals and switches to a +// vertical record view when the table would be wider than the actual terminal. +func (a *App) usersView(recs []registry.Record, numbered bool) string { + full := a.usersTable(recs, numbered).String() + width := 0 + if a.TerminalWidth != nil { + width = a.TerminalWidth() + } + if width <= 0 || widestLine(full) <= width { + return full + } + + labels := []string{ + a.P.M("状态", "state"), + "sudo", + a.P.M("自动删除", "auto-delete"), + a.P.M("到期", "expires"), + a.P.M("主机", "host"), + a.P.M("端口", "port"), + } + var out strings.Builder + for i, rec := range recs { + cells := a.userCells(rec) + prefix := "- " + if numbered { + prefix = fmt.Sprintf("%d) ", i+1) + } + appendWrappedLine(&out, width, prefix, cells[0]) + for field := 1; field < len(cells); field++ { + appendWrappedLine(&out, width, " "+labels[field-1]+"=", cells[field]) + } + if i+1 < len(recs) { + out.WriteByte('\n') + } + } + return out.String() +} + +func widestLine(value string) int { + widest := 0 + for _, line := range strings.Split(value, "\n") { + if width := table.Width(line); width > widest { + widest = width + } + } + return widest +} + +func appendWrappedLine(out *strings.Builder, maxWidth int, prefix, value string) { + for { + available := maxWidth - table.Width(prefix) + if available < 1 { + available = 1 + } + part, rest := takeDisplayWidth(value, available) + out.WriteString(prefix) + out.WriteString(part) + out.WriteByte('\n') + if rest == "" { + return + } + value = rest + prefix = " " + } +} + +func takeDisplayWidth(value string, maxWidth int) (string, string) { + width, end := 0, 0 + for offset, r := range value { + runeWidth := table.Width(string(r)) + if width+runeWidth > maxWidth && end > 0 { + break + } + width += runeWidth + end = offset + len(string(r)) + if width >= maxWidth { + break + } + } + if end == 0 && value != "" { + _, size := utf8.DecodeRuneInString(value) + end = size + } + return value[:end], value[end:] +} + +type registeredAccountState uint8 + +const ( + registeredMissing registeredAccountState = iota + registeredUnknown + registeredPending + registeredIdentityUnverified + registeredLegacyIdentity + registeredUIDMismatch + registeredMarkerMismatch + registeredActive +) + +func classifyRegisteredAccount(rec registry.Record, pw user.Passwd, exists bool, lookupErr error) registeredAccountState { + switch { + case lookupErr != nil: + return registeredUnknown + case !exists: + return registeredMissing + case rec.Pending: + return registeredPending + case rec.UID < 1: + return registeredIdentityUnverified + case pw.UID != rec.UID: + return registeredUIDMismatch + case !rec.IdentityBound: + if user.IsLegacyManagedEntry(pw) { + return registeredLegacyIdentity + } + return registeredMarkerMismatch + case !user.MatchesManagedGeneration(pw, rec.Generation): + return registeredMarkerMismatch + default: + return registeredActive + } +} + // manageUsers is the menu's one screen for the temporary accounts: it shows the // table and offers the two things anyone does with it. // @@ -180,7 +332,7 @@ func (a *App) manageUsers() int { if len(recs) == 0 { a.printf(" %s", a.P.M("(无)", "(none)")) } else { - a.printf("%s", a.usersTable(recs, true).String()) + a.printf("%s", a.usersView(recs, true)) } // Orphans have no registry row, so the table above cannot show them — this is @@ -189,8 +341,8 @@ func (a *App) manageUsers() int { // Surface them here, on the very screen whose `c` sweeps them, so the cleanup is // discoverable instead of something you only learn about from doctor. if len(orphans) > 0 { - a.warnf("%s", a.P.M("另有无登记行的孤儿残留(账号已不存在;按 c 清理):", - "orphaned leftovers with no registry row (their account is gone; press c to clean):")) + a.warnf("%s", a.P.M("另有无登记行的孤儿残留(账号不存在或身份无法验证;按 c 清理):", + "orphaned leftovers with no registry row (the account is absent or its identity is unverified; press c to clean):")) for _, o := range orphans { a.printf(" %s (%s)", o.name, strings.Join(o.kinds, " ")) } @@ -260,7 +412,7 @@ func (a *App) cleanupExpired(args []string) int { return 1 } if len(recs) > 0 { - a.printf("%s", a.usersTable(recs, false).String()) + a.printf("%s", a.usersView(recs, false)) } if compact { return a.compact() @@ -268,9 +420,11 @@ func (a *App) cleanupExpired(args []string) int { return 0 } -// accountIsOursAndLive reports whether name is a temporary account this tool -// currently manages: the current passwd entry must carry the managed GECOS -// marker. A matching UID is not enough because Linux can reuse UIDs. +// accountIsOursAndLive reports whether name is still associated with a live +// registry row for orphan-scanning purposes. Generation-bound identities must +// match exactly. A migrated v2 row with its fixed legacy marker is also treated +// as live here so cleanup does not silently cancel a genuine legacy account's +// grants and timer; destructive paths still refuse that weaker identity. // // It is the predicate the orphan sweeps use instead of a bare user.Exists, // because a grant/exception/unit outlives its account in TWO ways, not one: the @@ -281,13 +435,50 @@ func (a *App) cleanupExpired(args []string) int { // cleanup. Requiring the account to be provably ours closes that: a name taken // over by something that is not ours makes the leftover an orphan again. // -// A managed account whose marker was erased is intentionally treated as -// unverifiable. That may require operator recovery, but it cannot transfer a -// name-scoped privilege to an unrelated replacement account. +// A managed account whose marker was erased, whose row was lost, or whose UID no +// longer matches is intentionally treated as unverifiable. That may require +// operator recovery, but it cannot transfer a name-scoped privilege to an +// unrelated replacement account. func (a *App) accountIsOursAndLive(name string) (bool, error) { - // UIDs can be reused after an out-of-band deletion. Only the marker on the - // current passwd entry proves that this name still belongs to a managed account. - return user.IsManaged(name) + if a.Registry == nil { + return false, fmt.Errorf("no registry available to verify %s", name) + } + rec, found, err := a.Registry.Lookup(name) + if err != nil || !found { + return false, err + } + pw, exists, err := a.lookupUser(name) + if err != nil { + return false, err + } + state := classifyRegisteredAccount(rec, pw, exists, nil) + return state == registeredActive || state == registeredLegacyIdentity, nil +} + +// completedAccountIdentity returns whether name currently resolves to the +// completed v2 identity recorded by this tool, and whether a local account with +// that name exists at all. The UID and marker are checked on the same passwd +// snapshot; splitting them across two lookups would let a concurrent name reuse +// splice facts from two different accounts into one apparent identity. +func (a *App) completedAccountIdentity(name string) (ours, live bool, err error) { + if a.Registry == nil { + return false, false, fmt.Errorf("no registry available to verify %s", name) + } + rec, found, err := a.Registry.Lookup(name) + if err != nil { + return false, false, err + } + pw, exists, err := a.lookupUser(name) + if err != nil { + return false, false, err + } + if !exists { + return false, false, nil + } + if !found || rec.Pending || rec.UID < 1 || pw.UID != rec.UID || !rec.IdentityBound { + return false, true, nil + } + return user.MatchesManagedGeneration(pw, rec.Generation), true, nil } // installedCommandVersion best-effort reads the version of the binary at @@ -303,22 +494,17 @@ func (a *App) installedCommandVersion() (string, string) { if a.InstallPath == "" { return "", "" } - if _, err := os.Lstat(a.InstallPath); err != nil { - return "", "absent" + m := a.Selfmanage + if m == nil { + m = selfmanage.New(a.InstallPath, 0) } - if err := fsutil.RootSafeFile(a.InstallPath); err != nil { - return "", "unreadable" + v, err := m.InstalledVersion() + if errors.Is(err, selfmanage.ErrNotInstalled) { + return "", "absent" } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - out, err := exec.CommandContext(ctx, a.InstallPath, "version").Output() if err != nil { return "", "unreadable" } - v := strings.TrimSpace(string(out)) - if !validate.InstalledVersion(v) { - return "", "unreadable" - } return v, "ok" } @@ -462,6 +648,11 @@ func (a *App) compactLocked() int { a.audit("schedule.cleanup", u, "ok", "orphaned auto-revoke unit removed", nil) } } + if rc != 0 { + a.warnf("%s", a.P.M("孤儿扫描或清理未完整成功;为保留恢复线索,本次不压缩注册表。", + "orphan scanning or cleanup did not complete; the registry was not compacted so recovery evidence is retained.")) + return rc + } removed, err := a.Registry.Compact(user.Exists) if err != nil { a.warnf("%v", err) @@ -494,14 +685,17 @@ func (a *App) doctor(args []string) int { case "absent": a.warnf("%s", a.P.M("已安装命令:未安装(自动删除任务需要它)", "installed command: not installed (the auto-delete task needs it)")) + rc = 1 case "unreadable": a.warnf("%s%s", a.P.M("无法读取已安装命令的版本:", "could not read the installed command's version: "), a.InstallPath) + rc = 1 case "ok": if v == buildinfo.Version { a.success(fmt.Sprintf(a.P.M("已安装命令版本:%s", "installed command version: %s"), v)) } else { a.warnf("%s", fmt.Sprintf(a.P.M("已安装命令版本 %s 与运行中的 %s 不一致(自动删除任务执行的是已安装的那份,可用 upgrade 或 install 对齐)", "installed command version %s differs from the running %s (the auto-delete task runs the installed one; align with upgrade or install)"), v, buildinfo.Version)) + rc = 1 } } if a.Geteuid() == 0 { @@ -509,6 +703,13 @@ func (a *App) doctor(args []string) int { } else { a.warnf("%s", a.P.M("当前不是 root;invite/revoke 需要 root。", "not running as root; invite/revoke require root.")) } + if err := user.CheckPidfd(); err != nil { + a.warnf("%s: %v", a.P.M("pidfd 不可用;无法在 PID 复用安全的前提下终止临时账号进程", + "pidfd is unavailable; temporary-account processes cannot be terminated safely across PID reuse"), err) + rc = 1 + } else { + a.success(a.P.M("pidfd 进程撤销能力可用。", "pidfd process revocation is available.")) + } for _, d := range sysinfo.RequiredDeps(true) { if d.Present { a.success(a.P.M("依赖存在:", "dependency found: ") + d.Label) @@ -534,13 +735,18 @@ func (a *App) doctor(args []string) int { if cfg, err := a.sshdConfig(probe); err != nil { a.warnf("%s (%v)", a.P.M("无法读取 sshd 有效配置;invite 无法验证公钥登录是否真的可用。", "cannot read the effective sshd config; invite cannot verify that a key login would work."), err) + rc = 1 } else { rep := a.checkKeyLogin(cfg, probe, []string{probe}) for _, w := range rep.Warnings { a.warnf("%s", w) } - if rep.OK() { + if rep.Certain() { a.success(a.P.M("sshd 接受公钥登录。", "sshd accepts public-key logins.")) + } else if rep.OK() { + a.warnf("%s", a.P.M("sshd 没有显示阻断公钥登录,但存在无法求值的连接条件,不能确认新邀请可登录。", + "sshd has no explicit key-login blocker, but connection-dependent rules could not be evaluated; a new invite cannot be confirmed healthy.")) + rc = 1 } else { a.warnf("%s", a.P.M("sshd 不会接受新建临时账号的公钥登录:", "sshd would not accept a public-key login for a freshly created temporary account:")) @@ -555,6 +761,57 @@ func (a *App) doctor(args []string) int { a.warnf("%s", u) } } + // Read the registry once and inspect the account identity recorded in every + // row. A pending or legacy row is recovery evidence, not authority to delete a + // live account; a UID/GECOS mismatch means the name now resolves to something + // other than the completed identity this tool recorded. + var registryRecords []registry.Record + registryReadable := a.Registry == nil + if a.Registry != nil { + var err error + registryRecords, err = a.Registry.List() + if err != nil { + a.warnf("%s: %v", a.P.M("无法读取注册表", "cannot read registry"), err) + rc = 1 + } else { + registryReadable = true + for _, rec := range registryRecords { + pw, exists, lookupErr := a.lookupUser(rec.User) + if lookupErr != nil { + a.warnf("%s %s: %v", a.P.M("无法验证登记账号身份:", "cannot verify registered account identity:"), rec.User, lookupErr) + rc = 1 + continue + } + switch { + case rec.Pending: + a.warnf("%s%s", a.P.M("登记仍是未完成的 pending 创建意图,不能证明当前账号身份:", + "registry row is still an incomplete pending creation intent and cannot prove the current account identity: "), rec.User) + rc = 1 + case !exists: + a.warnf("%s%s", a.P.M("登记指向已不存在的账号(可用 cleanup-expired --compact 清理):", + "registry row points to an absent account (remove it with cleanup-expired --compact): "), rec.User) + rc = 1 + case rec.UID < 1: + a.warnf("%s%s", a.P.M("活账号登记没有可信 UID,不能证明身份:", + "live account registry row has no trusted UID and cannot prove identity: "), rec.User) + rc = 1 + case pw.UID != rec.UID: + a.warnf("%s", fmt.Sprintf(a.P.M("登记账号 %s 的 UID 不匹配:记录为 %d,当前为 %d;拒绝自动删除。", + "registered account %s has a UID mismatch: recorded %d, current %d; automatic deletion is refused."), rec.User, rec.UID, pw.UID)) + rc = 1 + case !rec.IdentityBound: + a.warnf("%s%s", a.P.M("登记账号来自旧版固定身份标记,无法排除同名/同 UID 重用;自动和批量删除已禁用,请人工核查后用 revoke --force 处理:", + "registered account uses a legacy fixed identity marker, so same-name/same-UID reuse cannot be excluded; automatic and bulk deletion are disabled; inspect it and use revoke --force: "), rec.User) + rc = 1 + case !user.MatchesManagedGeneration(pw, rec.Generation): + a.warnf("%s%s", a.P.M("登记账号缺少与登记世代精确匹配的受管身份标记,可能已被替换或篡改:", + "registered account lacks a managed identity marker matching its recorded generation and may have been replaced or modified: "), rec.User) + rc = 1 + } + } + } + } + // An sshd exception that outlived its account is a standing loosening of the // host's policy, and it re-arms the moment the username is reused. Nothing else // looks for these, so doctor must. @@ -564,8 +821,8 @@ func (a *App) doctor(args []string) int { rc = 1 } else if len(orphans) > 0 { for _, u := range orphans { - a.warnf("%s%s", a.P.M("孤儿 sshd 例外(账号已不存在):", - "orphaned sshd exception (its account no longer exists): "), a.SSHD.FilePath(u)) + a.warnf("%s%s", a.P.M("孤儿 sshd 例外(账号不存在或身份无法验证):", + "orphaned sshd exception (the account is absent or its identity is unverified): "), a.SSHD.FilePath(u)) } a.warnf("%s", a.P.M("请用 `cleanup-expired --compact` 清理。", "remove them with `cleanup-expired --compact`.")) @@ -588,8 +845,8 @@ func (a *App) doctor(args []string) int { rc = 1 } else if len(orphans) > 0 { for _, u := range orphans { - a.warnf("%s%s", a.P.M("孤儿 sudo 授权(账号已不存在,NOPASSWD:ALL 仍在):", - "orphaned sudo grant (its account is gone; NOPASSWD:ALL still on disk): "), a.Sudoers.FilePath(u)) + a.warnf("%s%s", a.P.M("孤儿 sudo 授权(账号不存在或身份无法验证,NOPASSWD:ALL 仍在):", + "orphaned sudo grant (the account is absent or its identity is unverified; NOPASSWD:ALL is still on disk): "), a.Sudoers.FilePath(u)) } a.warnf("%s", a.P.M("请用 `cleanup-expired --compact` 清理。", "remove them with `cleanup-expired --compact`.")) @@ -606,55 +863,44 @@ func (a *App) doctor(args []string) int { rc = 1 } else if len(orphans) > 0 { for _, u := range orphans { - a.warnf("%s%s", a.P.M("孤儿自动删除任务(账号已不存在):", - "orphaned auto-delete task (its account no longer exists): "), u) + a.warnf("%s%s", a.P.M("孤儿自动删除任务(账号不存在或身份无法验证):", + "orphaned auto-delete task (the account is absent or its identity is unverified): "), u) } a.warnf("%s", a.P.M("请用 `cleanup-expired --compact` 清理。", "remove them with `cleanup-expired --compact`.")) rc = 1 } } - // The other direction: a registered account that asked to be auto-deleted, still - // exists, and has NO task on disk to do it. New invites refuse this state, so it - // means a task was removed out of band (or predates that invariant). chage -E is - // only a later, day-granularity lockout backstop; surface the missing exact-time - // mechanism immediately so the operator can revoke the account. - if a.Scheduler != nil && a.Registry != nil { - haveUnit := map[string]bool{} - schedulesKnown := true - if units, err := a.Scheduler.ScheduledUsers(); err != nil { - a.warnf("%s: %v", a.P.M("无法读取自动删除任务", "cannot read auto-delete tasks"), err) - rc = 1 - schedulesKnown = false - } else { - for _, u := range units { - haveUnit[u] = true - } - } - recs, registryErr := a.Registry.List() - if registryErr != nil { - a.warnf("%s: %v", a.P.M("无法读取注册表", "cannot read registry"), registryErr) - rc = 1 - } + // The other direction: prove that every live account marked for auto-revoke has + // the exact task recorded for its UID and generation. A matching username alone + // is not health: a stale unit/job can target another account generation, and a + // modified service body can run something else entirely. + if a.Scheduler != nil && a.Registry != nil && registryReadable { var stranded []string - for _, r := range recs { - if !schedulesKnown || registryErr != nil { - break - } + for _, r := range registryRecords { exists, existsErr := user.Exists(r.User) if existsErr != nil { a.warnf("%s %s: %v", a.P.M("无法确认账号状态:", "cannot determine account state:"), r.User, existsErr) rc = 1 continue } - if r.AutoRevoke && exists && !haveUnit[r.User] { + if !r.AutoRevoke || !exists { + continue + } + valid, err := a.Scheduler.ValidSchedule(r.User, r.UID, r.Generation, r.AutoUnit) + if err != nil { + a.warnf("%s %s: %v", a.P.M("无法验证自动删除任务:", "cannot verify auto-delete task:"), r.User, err) + rc = 1 + continue + } + if !valid { stranded = append(stranded, r.User) } } if len(stranded) > 0 { for _, u := range stranded { - a.warnf("%s%s", a.P.M("账号设置了自动删除但已无对应任务(chage 仅提供按天粒度的较晚兜底锁定):", - "account set to auto-delete but has no task left to do it (chage only provides a later, day-granularity lockout backstop): "), u) + a.warnf("%s%s", a.P.M("账号设置了自动删除但已无可验证的对应任务(任务必须匹配 UID、世代、记录的 unit 和正文;chage 仅提供按天粒度的较晚兜底锁定):", + "account set to auto-delete but has no valid task left to do it (the UID, generation, recorded unit, and body must all match; chage only provides a later, day-granularity lockout backstop): "), u) } a.warnf("%s", a.P.M("到期后请用 `revoke --user <名>` 手动删除。", "remove them with `revoke --user ` once expired.")) @@ -673,30 +919,43 @@ func (a *App) doctor(args []string) int { // for byte) or is a one-time bootstrap better done from the shell as // `sudo ./linux-temp-admin install`. Leaving it out makes `upgrade` the menu's // single, signature-verified update path. -var menuItems = []struct { - zh, en string - run func(*App) int -}{ - {"创建一次性临时管理员邀请", "Create one-time temp admin invite", func(a *App) int { return a.invite(nil) }}, +type menuItem struct { + zh, en string + run func(*App) commandResult + exitOnApply bool +} + +// commandResult separates a command's process status from whether it completed +// the terminal mutation the menu must stop running after. Cancellation and an +// already-current upgrade both succeed without applying anything. +type commandResult struct { + status int + applied bool +} + +func statusResult(status int) commandResult { return commandResult{status: status} } + +var menuItems = []menuItem{ + {"创建临时管理员邀请", "Create temp admin invite", func(a *App) commandResult { return statusResult(a.invite(nil)) }, false}, // One entry for the temporary accounts, because there was only ever one list. // It replaced three: revoke (which opened with a bare list of names to choose // from), the list itself, and a cleanup whose target — a registry row whose // account is gone — is a row of this very table, marked "missing". - {"管理临时用户(查看 / 撤销 / 清理)", "Temporary users (list / revoke / clean up)", func(a *App) int { return a.manageUsers() }}, - {"系统诊断", "Run system doctor", func(a *App) int { return a.doctor(nil) }}, + {"管理临时用户", "Manage temporary users", func(a *App) commandResult { return statusResult(a.manageUsers()) }, false}, + {"系统诊断", "Run system doctor", func(a *App) commandResult { return statusResult(a.doctor(nil)) }, false}, // Just 升级, like 卸载 below: the old label spelled out "verify-signed, from // GitHub, the stable command" — the whole mechanism — where the entry only needs // to name the act. The command itself still shows "will download, verify, and // upgrade from " and asks for YES before touching anything, so the // signature-verified part is stated where it matters, at the point of action, // not carried as ballast in a menu line. - {"升级", "Upgrade", func(a *App) int { return a.upgrade(nil) }}, + {"升级", "Upgrade", func(a *App) commandResult { return a.upgradeResult(nil) }, true}, // It says 卸载 with nothing qualifying it because it finally earns the word: it // removes the accounts, their grants, their auto-delete tasks, the state and the // command. The old label had to say "the stable command" — an opaque phrase for // "the copy at the install path" — precisely because the object was the only // honest part: uninstall deleted one file and left everything else on the host. - {"卸载", "Uninstall", func(a *App) int { return a.uninstall(nil) }}, + {"卸载", "Uninstall", func(a *App) commandResult { return a.uninstallResult(nil) }, true}, // Kept next to last, in front of Exit. When this entry was added it was appended // for a stronger reason — that appending changed no existing digit's meaning, // where slotting it in earlier would have pushed Exit from 8 to 9 and turned an @@ -705,8 +964,8 @@ var menuItems = []struct { // 2 anyway, which is the cost the v2.5.0 CHANGELOG entry owns rather than hides. // The habit it teaches survives its own arithmetic — a digit's meaning is the // interface, so moving one is a real cost to weigh, not a free tidy-up. - {"切换语言 / Switch language", "Switch language / 切换语言", func(a *App) int { return a.switchLang() }}, - {"退出", "Exit", nil}, + {"语言 / Language", "Language / 语言", func(a *App) commandResult { return statusResult(a.switchLang()) }, false}, + {"退出", "Exit", nil, false}, } // switchLang re-asks the language and remembers the answer, so the one-time @@ -726,15 +985,17 @@ func (a *App) switchLang() int { a.warnf("%s", a.P.M("无效选择,语言未改变", "invalid choice; language unchanged")) return 1 } - // Apply to this session first: the confirmation below should already read in the - // language just chosen, whether or not it can be persisted. - a.P = i18n.Printer{Lang: lang} - if err := prefs.SetLang(string(lang)); err != nil { - a.warnf("%s: %v", a.P.M("已切换,但未能记住(下次仍会用旧设置)", "switched, but could not be remembered (the next run will use the old setting)"), err) - return 1 - } - a.success(a.P.M("语言已切换为中文,并已记住。", "language switched to English and remembered.")) - return 0 + return a.withLifecycleLock(func() int { + // Apply to this session first: any persistence error and the confirmation + // should already read in the language just chosen. + a.P = i18n.Printer{Lang: lang} + if err := prefs.SetLang(string(lang)); err != nil { + a.warnf("%s: %v", a.P.M("已切换,但未能记住(下次仍会用旧设置)", "switched, but could not be remembered (the next run will use the old setting)"), err) + return 1 + } + a.success(a.P.M("语言已切换为中文,并已记住。", "language switched to English and remembered.")) + return 0 + }) } // menu drives the interactive loop. The menu is drawn on entry and only when @@ -745,8 +1006,8 @@ func (a *App) menu() int { if !a.requireRoot() { return 1 } - prompt := fmt.Sprintf(a.P.M("请选择 [1-%d](回车显示菜单): ", "select [1-%d] (Enter shows the menu): "), len(menuItems)) draw := true + status := 0 for { if draw { a.printf("\n%s", a.P.M("Linux 临时管理员管理器", "Linux Temporary Admin Manager")) @@ -755,10 +1016,12 @@ func (a *App) menu() int { } draw = false } - fmt.Fprint(a.Err, prompt) + // The language can change inside this loop, so resolve the prompt for every + // iteration instead of retaining the language that was active on entry. + fmt.Fprintf(a.Err, a.P.M("请选择 [1-%d](回车显示菜单): ", "select [1-%d] (Enter shows the menu): "), len(menuItems)) choice, ok := a.readLine() if !ok { - return 0 // EOF + return status // EOF } if choice == "" { // a blank line asks for the menu back draw = true @@ -777,15 +1040,25 @@ func (a *App) menu() int { } continue } - if run := menuItems[n-1].run; run != nil { + item := menuItems[n-1] + if item.run != nil { // Frame the result with blank lines. The leading one does not rely on // the terminal echoing the operator's Enter, so a piped or scripted run // reads the same as an interactive one. fmt.Fprintln(a.Out) - run(a) + result := item.run(a) + if result.status != 0 { + status = result.status + } fmt.Fprintln(a.Out) + // A completed upgrade replaced the executable, and a completed uninstall + // removed it. Do not continue servicing privileged actions from the old, + // now untracked process image. Cancellation and a no-op upgrade stay here. + if item.exitOnApply && result.applied { + return status + } } else { - return 0 + return status } } } diff --git a/internal/cli/invite.go b/internal/cli/invite.go index 8c4ef67..800b2ac 100644 --- a/internal/cli/invite.go +++ b/internal/cli/invite.go @@ -1,12 +1,11 @@ package cli import ( - "context" + "bytes" "errors" "flag" "fmt" "os" - "os/exec" "strconv" "strings" "time" @@ -16,6 +15,7 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/expiry" "github.com/xxvcc/linux-temp-admin/internal/fsutil" "github.com/xxvcc/linux-temp-admin/internal/registry" + "github.com/xxvcc/linux-temp-admin/internal/selfmanage" "github.com/xxvcc/linux-temp-admin/internal/sshdconf" "github.com/xxvcc/linux-temp-admin/internal/sshkey" "github.com/xxvcc/linux-temp-admin/internal/sysinfo" @@ -58,13 +58,28 @@ func (a *App) invite(args []string) int { a.errorf("%s %v", a.P.M("未知参数:", "unexpected arguments:"), fs.Args()) return 1 } - if fNopasswd { - fSudo = true + if (fSudo || fNopasswd) && fNoSudo { + a.errorf("%s", a.P.M("--sudo/--nopasswd-sudo 与 --no-sudo 互斥", + "--sudo/--nopasswd-sudo and --no-sudo are mutually exclusive")) + return 1 + } + if fAuto && fNoAuto { + a.errorf("%s", a.P.M("--auto-revoke 与 --no-auto-revoke 互斥", + "--auto-revoke and --no-auto-revoke are mutually exclusive")) + return 1 + } + if fInstallDeps && fNoInstallDeps { + a.errorf("%s", a.P.M("--install-deps 与 --no-install-deps 互斥", + "--install-deps and --no-install-deps are mutually exclusive")) + return 1 } if fFixSSHD && fNoFixSSHD { a.errorf("%s", a.P.M("--fix-sshd 与 --no-fix-sshd 互斥", "--fix-sshd and --no-fix-sshd are mutually exclusive")) return 1 } + if fNopasswd { + fSudo = true + } if fPasswordLogin && fFixSSHD { a.errorf("%s", a.P.M("--password-login 与 --fix-sshd 互斥:密码登录的前提正是不改动 sshd", "--password-login and --fix-sshd are mutually exclusive: password login exists precisely to leave sshd alone")) @@ -173,6 +188,11 @@ func (a *App) invite(args []string) int { "granting sudo via --sudo --yes also requires --confirm-sudo "+username)) return 1 } + if err := user.CheckPidfd(); err != nil { + a.errorf("%s: %v", a.P.M("当前内核或进程沙箱不支持安全的进程撤销,拒绝创建无法可靠清理的账号", + "the kernel or process sandbox does not support safe process revocation; refusing to create an account that cannot be reliably removed"), err) + return 1 + } // Settle how the invitee will log in FIRST. planLogin only reads (`sshd -T`) // and decides — it changes nothing — and it is the one question that can make @@ -230,8 +250,11 @@ func (a *App) invite(args []string) int { if fYes { autoRev = "yes" } else { - ans := a.prompt(a.P.M("是否到期后自动删除该用户?[Y/n]: ", "Auto-delete this user on expiry? [Y/n]: ")) - if ans == "" || yesish(ans) { + answer, answered := a.promptYesNo(a.P.M("是否到期后自动删除该用户?[Y/n]: ", "Auto-delete this user on expiry? [Y/n]: "), true) + if !answered { + return 1 + } + if answer { autoRev = "yes" } else { autoRev = "no" @@ -273,7 +296,7 @@ func (a *App) invite(args []string) int { } a.printf("\n%s\n user=%s host=%s port=%d %s sudo=%s auto-delete=%s\n login=%s\n", a.P.M("即将创建一次性临时账号:", "About to create a one-time temporary account:"), - username, host, port, lifetime, grantSudo, autoRev, a.loginSummary(plan, username)) + username, host, port, lifetime, a.choiceDisplay(grantSudo), a.choiceDisplay(autoRev), a.loginSummary(plan, username)) if len(depPkgs) > 0 { a.printf(" %s%s", a.P.M("确认后将安装依赖:", "dependencies to install on confirm: "), strings.Join(depPkgs, " ")) } @@ -294,6 +317,17 @@ func (a *App) invite(args []string) int { }) } +func (a *App) choiceDisplay(value string) string { + switch value { + case "yes": + return a.P.M("是", "yes") + case "no": + return a.P.M("否", "no") + default: + return value + } +} + // promptHours asks for the account lifetime, offering current as the default a // blank line accepts. It loops until the input is valid or blank. Callers must // gate it on a.StdinIsTTY(): a closed stdin reads empty and settles on the @@ -315,6 +349,33 @@ func (a *App) promptHours(current int) int { } } +// promptYesNo accepts only an explicit yes/no spelling (or a blank line for the +// documented default). A typo at the auto-delete prompt must not silently turn a +// temporary account into a permanent one. +func (a *App) promptYesNo(msg string, defaultYes bool) (bool, bool) { + for { + fmt.Fprint(a.Err, msg) + ans, ok := a.readLine() + if !ok { + a.warnf("%s", a.P.M("输入已结束,已取消", "input ended; cancelled")) + return false, false + } + if ans == "" { + return defaultYes, true + } + if yesish(ans) { + return true, true + } + if noish(ans) { + return false, true + } + a.warnf("%s", a.P.M("请输入 y 或 n", "enter y or n")) + if !a.StdinIsTTY() { + return false, false + } + } +} + // loginSummary is the confirmation prompt's one-line statement of how the // invitee will authenticate — and, when sshd has to be touched for that to work, // exactly which file will appear on the host. The operator should be able to see @@ -375,22 +436,29 @@ func (a *App) planLogin(username string, wantPassword bool, fix string, yes bool cfg, err := a.sshdConfig(username) if err != nil { - // A probe failure must warn, never refuse: sshd may be absent from PATH (or - // unreadable) on a host whose sshd is nonetheless running and perfectly - // willing to take the key. Refusing here would break invites that work - // today. The invite says plainly that the claim is unverified. - a.warnf("%s: %v", a.P.M("无法读取 sshd 有效配置,登录方式未经验证", - "cannot read the effective sshd config; the login method is unverified"), err) - const reason = "the effective sshd config could not be read" + // Password authentication exposes a reusable secret. Never issue one unless + // the effective configuration was read successfully and proved that sshd + // accepts it. Key-only invitations can remain explicitly UNVERIFIED. if wantPassword { - return loginPlan{password: true, unverified: reason}, true + a.errorf("%s: %v", a.P.M("无法读取 sshd 有效配置,拒绝创建密码登录", + "cannot read the effective sshd config; refusing a password login"), err) + return loginPlan{}, false } + a.warnf("%s: %v", a.P.M("无法读取 sshd 有效配置,公钥登录方式未经验证", + "cannot read the effective sshd config; the key login method is unverified"), err) + const reason = "the effective sshd config could not be read" return loginPlan{unverified: reason}, true } if wantPassword { rep := a.checkPasswordLogin(cfg, username, predicted) - if !rep.OK() { + if !rep.Certain() { + if rep.OK() { + a.errorf("%s", a.P.M("无法证明 sshd 会接受该账号的密码登录,拒绝创建密码登录:", + "cannot prove sshd would accept a password login for this account; refusing a password login:")) + a.reportUncertainty(rep) + return loginPlan{}, false + } a.errorf("%s", a.P.M("sshd 不接受该账号的密码登录:", "sshd would not accept a password login for this account:")) a.reportBlockers(rep) return loginPlan{}, false @@ -398,8 +466,7 @@ func (a *App) planLogin(username string, wantPassword bool, fix string, yes bool a.warnf("%s", a.P.M( "密码登录会削弱本工具的安全模型:密码在账号的整个生命周期内都可被全网爆破,且必须以明文交付。用完请立即撤销。", "password login weakens this tool's security model: the password is brute-forceable from anywhere for the account's whole lifetime and must be delivered in the clear. Revoke as soon as you are done.")) - a.reportUncertainty(rep) - return loginPlan{password: true, verified: rep.Certain(), unverified: uncertainReason(rep), report: rep}, true + return loginPlan{password: true, verified: true, report: rep}, true } rep := a.checkKeyLogin(cfg, username, predicted) @@ -464,8 +531,12 @@ func (a *App) planLogin(username string, wantPassword bool, fix string, yes bool a.warnf("%s", a.P.M( "可以只为该账号写一个 sshd 例外(Match User 块),不改动全局策略,撤销时随账号一并删除。", "A per-account sshd exception (a Match User block) can be written instead; it leaves the global policy untouched and is removed together with the account.")) - if yesish(a.prompt(a.P.M("是否只为该账号开启公钥登录?[y/N]: ", - "Enable a public-key login for this account only? [y/N]: "))) { + enableKey, answered := a.promptYesNo(a.P.M("是否只为该账号开启公钥登录?[y/N]: ", + "Enable a public-key login for this account only? [y/N]: "), false) + if !answered { + return loginPlan{}, false + } + if enableKey { return loginPlan{fixSSHD: true, report: rep}, true } // Declined the exception — offer the password before giving up. @@ -486,9 +557,10 @@ func (a *App) planLogin(username string, wantPassword bool, fix string, yes bool func (a *App) confirmLogin(username string, groups []string, plan *loginPlan) bool { cfg, err := a.sshdConfig(username) if err != nil { - if plan.fixSSHD { + if plan.fixSSHD || plan.password { // We were about to modify sshd on the strength of a reading we can no - // longer take. Refuse rather than write a change we cannot prove. + // longer take, or issue a reusable password whose login path can no + // longer be proved. Refuse and let the caller roll the account back. a.errorf("%s: %v", a.P.M("无法重新读取 sshd 有效配置", "cannot re-read the effective sshd config"), err) return false } @@ -499,6 +571,16 @@ func (a *App) confirmLogin(username string, groups []string, plan *loginPlan) bo rep := a.checkKeyLogin(cfg, username, groups) if plan.password { rep = a.checkPasswordLogin(cfg, username, groups) + if !rep.Certain() { + if rep.OK() { + a.errorf("%s", a.P.M("无法证明 sshd 会接受该账号的密码登录,拒绝签发密码:", + "cannot prove sshd would accept a password login for this account; refusing to issue a password:")) + a.reportUncertainty(rep) + } else { + a.reportBlockers(rep) + } + return false + } } switch { case rep.OK(): @@ -613,17 +695,22 @@ func (a *App) offerPasswordFallback(cfg *sysinfo.SSHDConfig, username string, in return loginPlan{}, false } rep := a.checkPasswordLogin(cfg, username, []string{username}) - if !rep.OK() { + if !rep.Certain() { + if rep.OK() { + a.warnf("%s", a.P.M("无法证明 sshd 会接受密码登录,因此不提供密码回退。", + "cannot prove sshd would accept a password login, so no password fallback is offered.")) + a.reportUncertainty(rep) + } return loginPlan{}, false } a.warnf("%s", a.P.M( "该账号无法用公钥登录,但 sshd 接受密码登录。密码在账号整个生命周期内可被全网爆破、且必须以明文交付,是本工具最弱的授权方式。", "this account cannot log in with a key, but sshd accepts a password. A password is brute-forceable from anywhere for the account's whole lifetime and must be delivered in the clear — the weakest grant this tool issues.")) - if !yesish(a.prompt(a.P.M("改用密码登录?[y/N]: ", "Issue a password login instead? [y/N]: "))) { + usePassword, answered := a.promptYesNo(a.P.M("改用密码登录?[y/N]: ", "Issue a password login instead? [y/N]: "), false) + if !answered || !usePassword { return loginPlan{}, false } - a.reportUncertainty(rep) - return loginPlan{password: true, verified: rep.Certain(), unverified: uncertainReason(rep), report: rep}, true + return loginPlan{password: true, verified: true, report: rep}, true } // reportUncertainty prints the notes and the could-not-evaluate rules that keep @@ -704,6 +791,12 @@ func (a *App) planDeps(needSudo, installDeps, noInstallDeps, yes bool) ([]string pkgs = append(pkgs, p) } } + if pm == "pacman" && len(pkgs) > 0 { + a.errorf("%s", a.P.M( + "检测到 pacman。Arch 不支持部分升级,本工具也不会在创建账号时无人值守升级整个系统。请先由管理员执行 `pacman -Syu --needed "+strings.Join(pkgs, " ")+"`,再重试。", + "pacman was detected. Arch does not support partial upgrades, and this tool will not upgrade the whole system unattended while creating an account. Run `pacman -Syu --needed "+strings.Join(pkgs, " ")+"` deliberately first, then retry.")) + return nil, false + } // We may install when there is a package manager and a resolvable package set, // and permission to proceed: --install-deps outright, or an interactive run // whose YES will be the consent. A --yes/--no-install-deps run that did not opt @@ -745,8 +838,8 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu // Both secrets are generated before anything is created, so a generation // failure cannot leave a half-made account behind. Exactly one is issued: a - // key account has its password locked, and a password account is never given - // a key it could not use. + // key account has password authentication disabled, and a password account is + // never given a key it could not use. var kp *sshkey.KeyPair var password string if plan.password { @@ -764,6 +857,58 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu } kp = k } + generation, err := a.RandHex(16) + if err != nil || !validate.Generation(generation) { + if err == nil { + err = fmt.Errorf("random source returned an invalid generation") + } + a.errorf("%s: %v", a.P.M("生成账号世代标识失败", "generating account generation failed"), err) + return 1 + } + fingerprint := "" + if kp != nil { + fingerprint = kp.Fingerprint + } + permanent := !wantAuto + expiresDisplay := a.P.M("永久(不会过期,也不会自动删除)", "never (does not expire or auto-delete)") + if !permanent { + expiresDisplay = expiry.DisplayLocal(a.Now(), hours) + } + rec := registry.Record{ + User: username, + Created: a.Now().Format("2006-01-02 15:04:05 MST"), + Expires: expiresDisplay, + Sudo: wantSudo, + Host: host, + Port: port, + Fingerprint: fingerprint, + AutoRevoke: wantAuto, + Generation: generation, + IdentityBound: true, + Pending: true, + } + registered := false + // A failed invite may delete the account only after every name-scoped privilege + // grant is confirmed gone. Grant closes its gate before it can write anything; + // only Manager.Remove returning success opens it again. Registry cleanup keys on + // the same gates so a crash-recovery witness survives even if the account + // disappears out of band. + sudoRemovalConfirmed := true + sshdRemovalConfirmed := true + confirmSudoRemoved := func() error { + err := a.removeSudoGrant(username) + if err == nil { + sudoRemovalConfirmed = true + } + return err + } + confirmSSHDRemoved := func() error { + err := a.removeSSHDException(username) + if err == nil { + sshdRemovalConfirmed = true + } + return err + } var cleanups []func() error rollback := func() error { @@ -812,20 +957,86 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu if err := errors.Join(a.removeSudoGrant(username), a.removeSSHDException(username)); err != nil { return failf("%s: %v", a.P.M("无法清除同名账号的遗留授权,拒绝创建", "cannot remove grants left by this username; refusing creation"), err) } + // A stale scheduled command is name-keyed, so it must be gone before useradd + // makes that name live again. Reading its recorded id before writing the new + // intent also preserves the only direct handle to an at job from an older run. + staleUnit, err := a.Registry.UnitFor(username) + if err != nil { + return failf("%s: %v", a.P.M("读取旧自动删除任务失败", "reading stale auto-delete task failed"), err) + } + if err := a.Scheduler.Cancel(username, staleUnit); err != nil { + return failf("%s: %v", a.P.M("无法确认旧自动删除任务已清除", "cannot confirm stale auto-delete tasks were removed"), err) + } - if err := a.Users.Create(username, resolveShell()); err != nil { - a.errorf("%s: %v", a.P.M("创建用户失败", "create user failed"), err) - return 1 + // Persist the account intent before useradd. A kill or power loss after account + // creation must leave a registry witness even if no sudo/sshd/schedule artifact + // exists. UID 0 means pending and is replaced immediately after lookup. + if err := a.Registry.Record(rec); err != nil { + return failf("%s: %v", a.P.M("登记账号创建意图失败", "recording account creation intent failed"), err) + } + registered = true + // This cleanup was registered first, so reverse-order rollback runs it last, + // after account deletion. If deletion failed, retain the row for recovery. + cleanups = append(cleanups, func() error { + var unconfirmed []error + if !sudoRemovalConfirmed { + unconfirmed = append(unconfirmed, fmt.Errorf("sudo removal is unconfirmed; keeping registry record")) + } + if !sshdRemovalConfirmed { + unconfirmed = append(unconfirmed, fmt.Errorf("sshd removal is unconfirmed; keeping registry record")) + } + if err := errors.Join(unconfirmed...); err != nil { + return err + } + exists, err := user.Exists(username) + if err != nil { + return err + } + if exists { + return fmt.Errorf("account still exists; keeping registry record") + } + return a.Registry.Remove(username) + }) + + if err := a.Users.CreatePending(username, resolveShell(), generation); err != nil { + return failf("%s: %v", a.P.M("创建用户失败", "create user failed"), err) } - cleanups = append(cleanups, func() error { return a.Users.Delete(username) }) + cleanups = append(cleanups, func() error { + var causes []error + if !sudoRemovalConfirmed { + causes = append(causes, fmt.Errorf("sudo removal is unconfirmed; account disabled and retained")) + } + if !sshdRemovalConfirmed { + causes = append(causes, fmt.Errorf("sshd removal is unconfirmed; account disabled and retained")) + } + mayDelete := sudoRemovalConfirmed && sshdRemovalConfirmed + return errors.Join(errors.Join(causes...), a.rollbackInviteAccount(username, rec, mayDelete)) + }) - pw, ok, lookupErr := user.Lookup(username) + pw, ok, lookupErr := a.lookupUser(username) if lookupErr != nil { return failf("%s: %v", a.P.M("读取新账号信息失败", "reading the new account failed"), lookupErr) } if !ok { return failf("%s", a.P.M("无法定位新用户家目录", "cannot locate new user's home")) } + rec.UID = pw.UID + // Persist the UID while the passwd entry still carries PendingGECOS. An older + // binary ignores the appended Pending field, but it does understand that this + // is not the managed marker and therefore refuses deletion. Once this write is + // durable, changing the marker is safe even for that older binary. + if err := a.Registry.Record(rec); err != nil { + return failf("%s: %v", a.P.M("登记新账号身份失败", "recording the new account identity failed"), err) + } + if err := a.Users.MarkManaged(username, generation); err != nil { + return failf("%s: %v", a.P.M("完成新账号身份标记失败", "finalizing the new account identity marker failed"), err) + } + completed := rec + completed.Pending = false + if err := a.Registry.Record(completed); err != nil { + return failf("%s: %v", a.P.M("完成新账号身份登记失败", "finalizing the new account identity record failed"), err) + } + rec = completed // The preflight had to PREDICT this account's groups, because it ran before the // account existed. Now they are real — and sshd decides AllowGroups/DenyGroups @@ -848,8 +1059,8 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu return failf("%s: %v", a.P.M("设置密码失败", "set password failed"), err) } } else { - if err := a.Users.LockPassword(username); err != nil { - return failf("%s: %v", a.P.M("锁定密码失败", "lock password failed"), err) + if err := a.Users.DisablePasswordForKeyLogin(username); err != nil { + return failf("%s: %v", a.P.M("禁用密码登录失败", "disable password login failed"), err) } if err := sshkey.WriteAuthorizedKeys(pw.Home, pw.UID, pw.GID, kp.AuthorizedKey); err != nil { return failf("%s: %v", a.P.M("写入 authorized_keys 失败", "write authorized_keys failed"), err) @@ -862,13 +1073,14 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu // independently before account rollback can free the username. sshdDropIn := "" if plan.fixSSHD { + sshdRemovalConfirmed = false res, err := a.SSHD.Grant(username, groups, plan.report) if err != nil { - cleanupErr := a.removeSSHDException(username) + cleanupErr := confirmSSHDRemoved() return failf("%s: %v", a.P.M("为该账号开启 sshd 公钥登录失败", "enabling the sshd public-key login for this account failed"), errors.Join(err, cleanupErr)) } sshdDropIn = res.Path - cleanups = append(cleanups, func() error { return a.SSHD.Remove(username) }) + cleanups = append(cleanups, confirmSSHDRemoved) a.success(a.P.M("已为该账号单独开启公钥登录(全局策略未改动):"+res.Path, "public-key login enabled for this account only (the global policy is untouched): "+res.Path)) // Two independent things must both hold before the invite may say "verified": @@ -899,30 +1111,29 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu // auto-deleting" now means; the old behaviour (login expires via chage but the // account is never deleted) was neither temporary nor permanent, and surprised // operators who read "no auto-delete" as "keep it". - permanent := !wantAuto - expiresDisplay := a.P.M("永久(不会过期,也不会自动删除)", "never (does not expire or auto-delete)") if !permanent { if err := a.Users.SetExpiry(username, expiry.Date(a.Now(), hours)); err != nil { return failf("%s: %v", a.P.M("设置到期失败", "set expiry failed"), err) } - expiresDisplay = expiry.DisplayLocal(a.Now(), hours) } sudoGranted := false if wantSudo { - if err := a.Sudoers.Grant(username); err == nil { - sudoGranted = true - cleanups = append(cleanups, func() error { return a.removeSudoGrant(username) }) - } else { + // Grant can make the drop-in live before a later verification step fails. + // Close the deletion gate before calling it so both that partial failure and a + // later transaction failure retain the username until removal is confirmed. + sudoRemovalConfirmed = false + if err := a.Sudoers.Grant(username); err != nil { // Grant may have written a live drop-in before its verification step // failed; remove it unconditionally so a failed grant can never leave an // unregistered NOPASSWD grant behind. Remove only ever touches the // managed-prefixed file for this user, so it is safe to call blindly. - if cleanupErr := a.removeSudoGrant(username); cleanupErr != nil { - return failf("%s: %v", a.P.M("sudo 授权失败且清理失败", "sudo grant failed and cleanup failed"), cleanupErr) - } - a.warnf("%s: %v", a.P.M("授予 sudo 失败,创建为普通账号", "sudo grant failed; created as a normal account"), err) + cleanupErr := confirmSudoRemoved() + return failf("%s: %v", a.P.M("sudo 授权失败,已拒绝创建账号", "sudo grant failed; refusing to create the account"), + errors.Join(err, cleanupErr)) } + sudoGranted = true + cleanups = append(cleanups, confirmSudoRemoved) } // Whoever revokes this account later runs the binary at InstallPath — the @@ -937,72 +1148,34 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu } } - // Clear any stale schedule left by a reused username before scheduling. - staleUnit, err := a.Registry.UnitFor(username) - if err != nil { - return failf("%s: %v", a.P.M("读取旧自动删除任务失败", "reading stale auto-delete task failed"), err) - } - if err := a.Scheduler.Cancel(username, staleUnit); err != nil { - return failf("%s: %v", a.P.M("无法确认旧自动删除任务已清除", "cannot confirm stale auto-delete tasks were removed"), err) + // Commit the final grant state before creating a scheduler task. For an + // auto-revoke account this row is the durable identity intent the task checks. + rec.Sudo = sudoGranted + if err := a.Registry.Record(rec); err != nil { + return failf("%s: %v", a.P.M("登记注册表失败", "registry record failed"), err) } + autoUnit := "" autoScheduled := false - generation := "" if wantAuto { - generation, err = a.RandHex(16) - if err != nil { - return failf("%s: %v", a.P.M("生成账号世代标识失败", "generating account generation failed"), err) - } // The auto-revoke task's ExecStart runs the installed stable command, so a - // binary must be present at InstallPath (ensured just above), otherwise the - // timer would fire and fail on a non-installed run. + // binary must be present at InstallPath (ensured above), otherwise the timer + // would fire and fail on a non-installed run. if err := fsutil.RootSafeFile(a.InstallPath); err != nil { return failf("%s: %v", a.P.M("稳定命令不安全", "the stable command is unsafe"), err) - } else if unit, err := a.Scheduler.Schedule(username, pw.UID, generation, hours); err == nil { - autoUnit = unit - autoScheduled = true - cleanups = append(cleanups, func() error { return a.Scheduler.Cancel(username, unit) }) - } else { - return failf("%s: %v", a.P.M("自动删除任务创建失败,已拒绝创建临时账号", "auto-delete scheduling failed; refusing to create the temporary account"), err) } - } - - fingerprint := "" - if kp != nil { - fingerprint = kp.Fingerprint - } - rec := registry.Record{ - User: username, - Created: a.Now().Format("2006-01-02 15:04:05 MST"), - Expires: expiresDisplay, - Sudo: sudoGranted, - Host: host, - Port: port, - Fingerprint: fingerprint, - AutoRevoke: autoScheduled, - AutoUnit: autoUnit, - // Pin the UID to detect contradictions during revoke. It is not identity proof - // by itself because Linux may reuse a UID after out-of-band account deletion; - // the current account must still carry the managed GECOS marker. - UID: pw.UID, - Generation: generation, - } - if err := a.Registry.Record(rec); err != nil { - return failf("%s: %v", a.P.M("登记注册表失败", "registry record failed"), err) - } - registered := true - // Registry cleanup runs last during rollback, after account deletion. If the - // account survived, keep the row as the witness needed for manual recovery. - cleanups = append([]func() error{func() error { - exists, err := user.Exists(username) + unit, err := a.Scheduler.Schedule(username, pw.UID, generation, hours) if err != nil { - return err + return failf("%s: %v", a.P.M("自动删除任务创建失败,已拒绝创建临时账号", "auto-delete scheduling failed; refusing to create the temporary account"), err) } - if exists { - return fmt.Errorf("account still exists; keeping registry record") + autoUnit = unit + autoScheduled = true + cleanups = append(cleanups, func() error { return a.Scheduler.Cancel(username, unit) }) + rec.AutoUnit = unit + if err := a.Registry.Record(rec); err != nil { + return failf("%s: %v", a.P.M("登记自动删除任务失败", "recording the auto-delete task failed"), err) } - return a.Registry.Remove(username) - }}, cleanups...) + } if err := a.printInvite(inviteBundle{ user: username, host: host, port: port, hours: hours, @@ -1031,6 +1204,47 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu return 0 } +// rollbackInviteAccount tears down an account created by a failed invite. It +// never deletes by name until the completed registry identity is available, all +// name-scoped grants are confirmed gone, login is disabled, and every process +// carrying the UID is confirmed terminated. Any uncertainty retains both the +// account and the registry witness for manual recovery. +func (a *App) rollbackInviteAccount(username string, rec registry.Record, mayDelete bool) error { + if rec.Pending || rec.UID < 1 || !rec.IdentityBound || !validate.Generation(rec.Generation) { + return fmt.Errorf("account identity is still pending; account and registry record retained") + } + pw, exists, err := a.lookupUser(username) + if err != nil { + return fmt.Errorf("verify rollback account identity: %w", err) + } + if !exists { + return nil + } + if pw.UID != rec.UID || !user.MatchesManagedGeneration(pw, rec.Generation) { + return fmt.Errorf("account identity changed before rollback; account and registry record retained") + } + if !mayDelete { + if err := a.Users.DisableLogin(username); err != nil { + return fmt.Errorf("disable retained account: %w", err) + } + if err := a.accountStillMatches(username, pw); err != nil { + return fmt.Errorf("verify retained account before process termination: %w", err) + } + if err := a.terminateProcesses(rec.UID); err != nil { + return fmt.Errorf("terminate retained account processes: %w", err) + } + if err := a.accountStillMatches(username, pw); err != nil { + return fmt.Errorf("verify retained account after process termination: %w", err) + } + return nil + } + stage, err := a.teardownLocalAccount(username, pw) + if err != nil { + return fmt.Errorf("fail-closed account teardown stopped at stage %d: %w", stage, err) + } + return nil +} + // ynStr renders a bool as "yes"/"no" for audit fields. func ynStr(b bool) string { if b { @@ -1090,14 +1304,18 @@ func (b inviteBundle) loginLine() string { } func (a *App) printInvite(b inviteBundle) error { - var out strings.Builder + var out bytes.Buffer + defer func() { clear(out.Bytes()) }() + if b.kp != nil { + defer clear(b.kp.PrivatePEM) + } yesno := func(v bool) string { if v { return "yes" } return "no" } - passwordLine := "locked" + passwordLine := "disabled" if b.byPassword() { passwordLine = "enabled (this invite's only credential)" } @@ -1131,7 +1349,7 @@ cat > './%s.key' <<'EOF_KEY' %sEOF_KEY chmod 600 './%s.key' `, - a.P.M("保存私钥命令:", "Save private key command:"), b.user, string(b.kp.PrivatePEM), b.user) + a.P.M("保存私钥命令:", "Save private key command:"), b.user, b.kp.PrivatePEM, b.user) } if b.sshdDropIn != "" { @@ -1157,7 +1375,7 @@ chmod 600 './%s.key' "安全提醒: "+secret+"只显示这一次、服务器不保存;仅通过可信私聊发送;用完立即撤销。", "Security notes: the "+secret+" is shown only once and not stored on the server; send only via trusted private chat; revoke immediately after use.")+ "\n\n----- END LINUX TEMP ADMIN INVITE -----\n") - _, err := fmt.Fprint(a.Out, out.String()) + _, err := a.Out.Write(out.Bytes()) return err } @@ -1174,7 +1392,9 @@ func triState(yes, no bool) string { } } -func yesish(s string) bool { return s == "y" || s == "Y" || s == "yes" || s == "YES" } +func yesish(s string) bool { return strings.EqualFold(s, "y") || strings.EqualFold(s, "yes") } + +func noish(s string) bool { return strings.EqualFold(s, "n") || strings.EqualFold(s, "no") } func orNone(s string) string { if s == "" { @@ -1203,14 +1423,8 @@ func (a *App) ensureStableInstalled() error { return fmt.Errorf("self-manager not configured") } force := false - if _, err := os.Lstat(a.InstallPath); err == nil { - if err := fsutil.RootSafeFile(a.InstallPath); err != nil { - return fmt.Errorf("installed command is unsafe: %w", err) - } - installed, err := a.installedVersion() - if err != nil { - return fmt.Errorf("probe installed command: %w", err) - } + installed, versionErr := a.Selfmanage.InstalledVersion() + if versionErr == nil { if strings.HasSuffix(buildinfo.Version, "-dev") { // A development build is not ordered against releases. Install these exact // bytes so its scheduled cleanup always runs the code creating the account. @@ -1223,8 +1437,8 @@ func (a *App) ensureStableInstalled() error { installed, buildinfo.Version)) force = true } - } else if !os.IsNotExist(err) { - return err + } else if !errors.Is(versionErr, selfmanage.ErrNotInstalled) { + return versionErr } bin, err := a.readRunningBinary() if err != nil { @@ -1233,10 +1447,7 @@ func (a *App) ensureStableInstalled() error { if _, err = a.Selfmanage.Install(bin, force); err != nil { return err } - if err := fsutil.RootSafeFile(a.InstallPath); err != nil { - return fmt.Errorf("installed command verification: %w", err) - } - installed, err := a.installedVersion() + installed, err = a.Selfmanage.InstalledVersion() if err != nil { return fmt.Errorf("verify installed command version: %w", err) } @@ -1246,30 +1457,6 @@ func (a *App) ensureStableInstalled() error { return nil } -// installedVersion asks the installed command what version it is. -// -// The timeout is not paranoia: this executes a binary at a path the operator -// controls, as root, at a point where the account already exists. A binary that -// never returns would hang the invite half-done, with the private key generated -// and the account created but nothing printed. Failing to read a version is -// handled (the caller then leaves the installed binary alone); hanging is not. -func (a *App) installedVersion() (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), installedVersionTimeout) - defer cancel() - out, err := exec.CommandContext(ctx, a.InstallPath, "version").Output() - if err != nil { - return "", err - } - v := strings.TrimSpace(string(out)) - if !validate.InstalledVersion(v) { - return "", fmt.Errorf("unrecognized version %q", v) - } - return v, nil -} - -// installedVersionTimeout bounds the ` version` probe. -const installedVersionTimeout = 5 * time.Second - func resolveShell() string { for _, s := range []string{config.DefaultShell, "/bin/sh"} { if fi, err := os.Stat(s); err == nil && fi.Mode()&0o111 != 0 { @@ -1297,8 +1484,12 @@ func (a *App) detectOrPromptHost() string { "using the detected public IP: %s (use --host for a domain or a different address)"), ip)) return ip } - if yesish(a.prompt(a.P.M("本机未探测到公网 IP。是否向外部服务查询?[y/N]: ", - "No public IP found locally. Ask an external service? [y/N]: "))) { + queryExternal, answered := a.promptYesNo(a.P.M("本机未探测到公网 IP。是否向外部服务查询?[y/N]: ", + "No public IP found locally. Ask an external service? [y/N]: "), false) + if !answered { + return "" + } + if queryExternal { if ip, ok := a.Detector.PublicIP(5 * time.Second); ok { return a.promptHost(ip) } diff --git a/internal/cli/invite_reuse_root_test.go b/internal/cli/invite_reuse_root_test.go index ab34f46..e892991 100644 --- a/internal/cli/invite_reuse_root_test.go +++ b/internal/cli/invite_reuse_root_test.go @@ -45,7 +45,7 @@ func inviteApp(t *testing.T) (*cli.App, *sudoers.Manager, *sshdconf.Manager, str now := func() time.Time { return time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) } auditFile := filepath.Join(rootDir(t, 0o700), "audit.log") - sudoMgr := &sudoers.Manager{Dir: sudoDir, Validate: func(string) error { return nil }, Verify: func(string) error { return nil }} + sudoMgr := &sudoers.Manager{Dir: sudoDir, Validate: func([]byte) error { return nil }, Verify: func(string) error { return nil }} sshdMgr := &sshdconf.Manager{ Dir: sshdDir, Validate: func() error { return nil }, Reload: func() error { return nil }, Effective: func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdOK), nil }, @@ -84,6 +84,23 @@ func inviteApp(t *testing.T) (*cli.App, *sudoers.Manager, *sshdconf.Manager, str return a, sudoMgr, sshdMgr, installPath } +func unusedHighIntegrationUID(t *testing.T) int { + t.Helper() + for uid := 59000; uid >= 58000; uid-- { + err := exec.Command("getent", "passwd", strconv.Itoa(uid)).Run() + if err == nil { + continue + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 2 { + return uid + } + t.Fatalf("probe integration-test UID %d: %v", uid, err) + } + t.Fatal("no unused high UID available for integration test") + return 0 +} + // TestInviteNoSudoDoesNotInheritAStaleGrant is the CRITICAL. invite unconditionally // clears a reused name's stale auto-revoke UNIT but not its stale sudo grant or // sshd exception, so a --no-sudo invite that reuses a name still carrying an @@ -172,6 +189,86 @@ func TestInviteRetriesSSHDGrantCleanupBeforeAccountRollback(t *testing.T) { } } +func TestInviteRetainsAccountAndRegistryWhenLaterSSHDRemovalIsUnconfirmed(t *testing.T) { + a, _, sshdMgr, _ := inviteApp(t) + const name = "xxvcc-sshhold1" + remove := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + remove() + t.Cleanup(remove) + + // Grant succeeds and reaches the running daemon. Scheduling then fails, forcing + // invite rollback; that rollback must not free the username when its removal + // reload fails. + a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdNoPubkey), nil } + a.Scheduler.Sys = unavailableSched{} + reloads := 0 + sshdMgr.Reload = func() error { + reloads++ + if reloads == 1 { + return nil + } + return errors.New("rollback reload failed") + } + + rc := a.Dispatch([]string{"invite", "--user", name, "--host", "203.0.113.5", + "--hours", "1", "--no-sudo", "--auto-revoke", "--fix-sshd", "--yes"}) + if rc != 1 { + t.Fatalf("invite rc=%d, want scheduling failure", rc) + } + if reloads != 2 { + t.Fatalf("reload calls = %d, want grant plus failed rollback", reloads) + } + if !mustExternalUserExists(t, name) { + t.Fatal("account was deleted while sshd removal was unconfirmed") + } + if ok, err := a.Registry.Contains(name); err != nil || !ok { + t.Fatalf("registry witness was cleared while sshd removal was unconfirmed: ok=%v err=%v", ok, err) + } + if _, err := os.Lstat(sshdMgr.FilePath(name)); !os.IsNotExist(err) { + t.Fatalf("rollback left active drop-in on disk: %v", err) + } + if _, err := os.Lstat(sshdMgr.FilePath(name) + ".remove-pending"); err != nil { + t.Fatalf("rollback lost pending daemon-reload evidence: %v", err) + } + if !strings.Contains(a.Err.(*bytes.Buffer).String(), "account disabled and retained") { + t.Fatalf("rollback did not report retained account:\n%s", a.Err.(*bytes.Buffer).String()) + } +} + +func TestRevokeRetainsAccountAndRegistryWithoutReloadMechanism(t *testing.T) { + a, _, sshdMgr, _ := inviteApp(t) + const name = "xxvcc-sshnoreload1" + remove := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + remove() + t.Cleanup(remove) + + if rc := a.Dispatch([]string{"invite", "--user", name, "--host", "203.0.113.5", + "--no-sudo", "--no-auto-revoke", "--no-fix-sshd", "--yes"}); rc != 0 { + t.Fatalf("fixture invite rc=%d\nstderr:\n%s", rc, a.Err.(*bytes.Buffer).String()) + } + dropIn := sshdMgr.FilePath(name) + if err := os.WriteFile(dropIn, []byte("Match User "+name+"\n PubkeyAuthentication yes\n"), 0o644); err != nil { + t.Fatal(err) + } + sshdMgr.Reload = func() error { return sshdconf.ErrNoReloadMechanism } + + if rc := a.Dispatch([]string{"revoke", "--user", name, "--yes"}); rc != 1 { + t.Fatalf("revoke rc=%d, want unconfirmed sshd removal failure", rc) + } + if !mustExternalUserExists(t, name) { + t.Fatal("revoke released the username without confirming daemon reload") + } + if ok, err := a.Registry.Contains(name); err != nil || !ok { + t.Fatalf("revoke cleared registry without confirming daemon reload: ok=%v err=%v", ok, err) + } + if _, err := os.Lstat(dropIn); !os.IsNotExist(err) { + t.Fatalf("revoke left the disk drop-in active: %v", err) + } + if _, err := os.Lstat(dropIn + ".remove-pending"); err != nil { + t.Fatalf("revoke lost pending reload evidence: %v", err) + } +} + // A scheduled deletion has no trustworthy identity when its registry row is // gone. It must exit successfully without touching either the account or its // name-scoped grant; chage expiry still blocks future login. @@ -211,47 +308,118 @@ func TestAutoRevokeSkipsWhenRegistryRowIsLost(t *testing.T) { } } -// A matching username and UID do not prove identity because Linux can reuse both -// after out-of-band deletion. The current account must still carry the managed -// marker even when the scheduled generation matches the stale registry row. -func TestAutoRevokeProtectsSameUIDUnmanagedReplacement(t *testing.T) { +// A matching username and UID do not prove identity because Linux can reuse both. +// The dynamic GECOS marker must match the exact registry generation; copying the +// released fixed marker or another valid generation must not authorize userdel. +func TestAutoRevokeProtectsSameUIDMarkerReplacement(t *testing.T) { + const generation = "22222222222222222222222222222222" + for _, tc := range []struct { + name string + replacementMarker string + }{ + {name: "ltarealacct1", replacementMarker: config.ManagedGECOS}, + {name: "ltarealacct2", replacementMarker: config.ManagedGenerationGECOSPrefix + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + } { + t.Run(tc.name, func(t *testing.T) { + a, _, _, installPath := inviteApp(t) + testUID := unusedHighIntegrationUID(t) + rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", tc.name).Run() } + rm() + t.Cleanup(rm) + originalMarker := config.ManagedGenerationGECOSPrefix + generation + if out, err := exec.Command("useradd", "-m", "-u", strconv.Itoa(testUID), "-s", "/bin/bash", "-c", originalMarker, tc.name).CombinedOutput(); err != nil { + t.Fatalf("useradd: %v: %s", err, out) + } + original, ok := mustExternalUserLookup(t, tc.name) + if !ok || original.UID != testUID { + t.Fatalf("created account = %+v found=%v, want UID %d", original, ok, testUID) + } + if err := a.Registry.Init(); err != nil { + t.Fatal(err) + } + if err := a.Registry.Record(registry.Record{ + User: tc.name, Port: 22, UID: original.UID, Generation: generation, + IdentityBound: true, AutoRevoke: true, + }); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("userdel", "-r", "--", tc.name).CombinedOutput(); err != nil { + t.Fatalf("userdel: %v: %s", err, out) + } + if out, err := exec.Command("useradd", "-m", "-u", strconv.Itoa(original.UID), "-s", "/bin/bash", "-c", tc.replacementMarker, tc.name).CombinedOutput(); err != nil { + t.Fatalf("replacement useradd: %v: %s", err, out) + } + replacement, ok := mustExternalUserLookup(t, tc.name) + if !ok { + t.Fatal("replacement account was not found") + } + sentinel := filepath.Join(replacement.Home, "replacement-data") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o600); err != nil { + t.Fatal(err) + } + a.TerminateProcesses = func(int) error { + t.Fatal("auto-revoke attempted to terminate replacement processes") + return nil + } + args := strings.Fields((&schedule.Scheduler{InstallPath: installPath}).RevokeCommand(tc.name, original.UID, generation))[1:] + if rc := a.Dispatch(args); rc == 0 { + t.Error("auto-revoke accepted a same-UID marker replacement") + } + if !mustExternalUserExists(t, tc.name) { + t.Error("auto-revoke deleted the replacement account") + } + if got, err := os.ReadFile(sentinel); err != nil || string(got) != "keep\n" { + t.Errorf("replacement home data changed: content=%q err=%v", got, err) + } + if ok, err := a.Registry.Contains(tc.name); err != nil || !ok { + t.Errorf("recovery registry row was not preserved: present=%v err=%v", ok, err) + } + }) + } +} + +func TestLegacyIdentityRequiresDirectForceConfirmation(t *testing.T) { a, _, _, installPath := inviteApp(t) - const name = "ltarealacct1" + const ( + name = "ltalegacyacct1" + generation = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ) rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } rm() t.Cleanup(rm) if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGECOS, name).CombinedOutput(); err != nil { t.Fatalf("useradd: %v: %s", err, out) } - original, ok := mustExternalUserLookup(t, name) + pw, ok := mustExternalUserLookup(t, name) if !ok { - t.Fatal("created account was not found") + t.Fatal("legacy account was not found") } - const generation = "22222222222222222222222222222222" if err := a.Registry.Init(); err != nil { t.Fatal(err) } - if err := a.Registry.Record(registry.Record{User: name, Port: 22, UID: original.UID, Generation: generation, AutoRevoke: true}); err != nil { + if err := a.Registry.Record(registry.Record{ + User: name, Port: 22, UID: pw.UID, Generation: generation, AutoRevoke: true, + }); err != nil { t.Fatal(err) } - if out, err := exec.Command("userdel", "-r", "--", name).CombinedOutput(); err != nil { - t.Fatalf("userdel: %v: %s", err, out) + scheduled := strings.Fields((&schedule.Scheduler{InstallPath: installPath}).RevokeCommand(name, pw.UID, generation))[1:] + if rc := a.Dispatch(scheduled); rc == 0 { + t.Fatal("scheduled revoke accepted a legacy fixed identity") } - // Recreate the same name with the exact same UID, but as a real unmanaged user. - if out, err := exec.Command("useradd", "-m", "-u", strconv.Itoa(original.UID), "-s", "/bin/bash", "-c", "Real Person", name).CombinedOutput(); err != nil { - t.Fatalf("replacement useradd: %v: %s", err, out) + if !mustExternalUserExists(t, name) { + t.Fatal("scheduled revoke deleted a legacy account") } - sched := &schedule.Scheduler{InstallPath: installPath} - args := strings.Fields(sched.RevokeCommand(name, original.UID, generation))[1:] - - if rc := a.Dispatch(args); rc == 0 { - t.Error("auto-revoke accepted an unmanaged replacement with the same UID") + if rc := a.Dispatch([]string{"revoke", "--user", name, "--yes", "--force"}); rc == 0 { + t.Fatal("legacy revoke without --confirm-force succeeded") } if !mustExternalUserExists(t, name) { - t.Error("auto-revoke deleted an unmanaged replacement account") + t.Fatal("unconfirmed legacy revoke deleted the account") } - if ok, err := a.Registry.Contains(name); err != nil || !ok { - t.Errorf("recovery registry row was not preserved: present=%v err=%v", ok, err) + if rc := a.Dispatch([]string{"revoke", "--user", name, "--yes", "--force", "--confirm-force", name}); rc != 0 { + t.Fatalf("direct confirmed legacy revoke rc=%d\nstderr:\n%s", rc, a.Err.(*bytes.Buffer).String()) + } + if mustExternalUserExists(t, name) { + t.Fatal("direct confirmed legacy revoke did not delete the account") } } @@ -328,6 +496,137 @@ func TestInviteOutputFailureRollsBackAllState(t *testing.T) { } } +func TestInviteOutputFailureRetainsAccountWhenProcessCleanupIsUncertain(t *testing.T) { + a, _, _, _ := inviteApp(t) + const name = "xxvcc-outputhold1" + rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + rm() + t.Cleanup(rm) + + partial := &partialFailingWriter{} + a.Out = partial + a.TerminateProcesses = func(int) error { return errors.New("injected rollback process uncertainty") } + + rc := a.Dispatch([]string{"invite", "--user", name, "--host", "203.0.113.5", + "--no-sudo", "--no-fix-sshd", "--no-auto-revoke", "--yes"}) + if rc != 1 { + t.Fatalf("invite rc=%d, want output/rollback failure", rc) + } + if partial.wrote == 0 { + t.Fatal("fixture did not expose any credential bytes before the output failure") + } + if !mustExternalUserExists(t, name) { + t.Fatal("rollback freed the UID even though process cleanup was uncertain") + } + if expires := passwdExpiryField(t, name); expires == "" { + t.Fatal("retained account was not disabled before rollback stopped") + } + if rec, found, err := a.Registry.Lookup(name); err != nil || !found || rec.Pending || rec.UID < 1 { + t.Fatalf("completed recovery identity was not retained: found=%v rec=%+v err=%v", found, rec, err) + } + if !strings.Contains(a.Err.(*bytes.Buffer).String(), "injected rollback process uncertainty") { + t.Fatalf("rollback uncertainty was not reported:\n%s", a.Err.(*bytes.Buffer).String()) + } +} + +func TestInviteRetainsAccountWhenFailedSudoGrantCannotBeRemoved(t *testing.T) { + a, sudoMgr, _, _ := inviteApp(t) + const name = "xxvcc-sudograntfail1" + rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + rm() + t.Cleanup(rm) + + // The preflight removal must still see an ordinary absent file. Once Grant has + // written the live drop-in, make both its own rollback and the CLI's independent + // retry fail. The username and registry witness must then remain reserved. + grantReachedVerify := false + removeCalls := 0 + sudoMgr.Verify = func(string) error { + grantReachedVerify = true + return errors.New("injected effective sudo policy failure") + } + sudoMgr.RemoveFile = func(path string) error { + if !grantReachedVerify { + return os.Remove(path) + } + removeCalls++ + return errors.New("injected read-only sudoers filesystem") + } + + rc := a.Dispatch([]string{"invite", "--user", name, "--host", "203.0.113.5", + "--sudo", "--confirm-sudo", name, "--no-auto-revoke", "--no-fix-sshd", "--yes"}) + if rc != 1 { + t.Fatalf("invite rc=%d, want failed sudo grant rollback\nstderr:\n%s", rc, a.Err.(*bytes.Buffer).String()) + } + if removeCalls != 2 { + t.Fatalf("sudo removal attempts=%d, want Grant rollback plus CLI retry\nstderr:\n%s", removeCalls, a.Err.(*bytes.Buffer).String()) + } + if !mustExternalUserExists(t, name) { + t.Fatal("invite freed the username while the sudo grant removal was unconfirmed") + } + if expires := passwdExpiryField(t, name); expires == "" { + t.Error("retained account was not disabled") + } + rec, found, err := a.Registry.Lookup(name) + if err != nil || !found || !rec.Sudo { + t.Fatalf("sudo recovery witness missing: found=%v record=%+v err=%v", found, rec, err) + } + if _, err := os.Lstat(sudoMgr.FilePath(name)); err != nil { + t.Fatalf("test did not retain the live sudo drop-in: %v", err) + } + if !strings.Contains(a.Err.(*bytes.Buffer).String(), "sudo removal is unconfirmed; account disabled and retained") { + t.Fatalf("rollback did not report the retained account:\n%s", a.Err.(*bytes.Buffer).String()) + } +} + +func TestInviteRetainsAccountWhenLaterSudoRollbackCannotRemoveGrant(t *testing.T) { + a, sudoMgr, _, _ := inviteApp(t) + a.Scheduler.Sys = unavailableSched{} + const name = "xxvcc-sudolaterfail1" + rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + rm() + t.Cleanup(rm) + + // Grant succeeds, then scheduling fails. Fail removal only after Verify proves + // that the drop-in was live, so preflight remains unaffected. + grantVerified := false + sudoMgr.Verify = func(string) error { + grantVerified = true + return nil + } + sudoMgr.RemoveFile = func(path string) error { + if !grantVerified { + return os.Remove(path) + } + return errors.New("injected sudo rollback failure") + } + + rc := a.Dispatch([]string{"invite", "--user", name, "--host", "203.0.113.5", + "--hours", "1", "--sudo", "--confirm-sudo", name, "--auto-revoke", "--no-fix-sshd", "--yes"}) + if rc != 1 { + t.Fatalf("invite rc=%d, want scheduling failure\nstderr:\n%s", rc, a.Err.(*bytes.Buffer).String()) + } + if !mustExternalUserExists(t, name) { + t.Fatalf("invite freed the username after its sudo rollback failed\nstderr:\n%s", a.Err.(*bytes.Buffer).String()) + } + if expires := passwdExpiryField(t, name); expires == "" { + t.Error("retained account was not disabled") + } + rec, found, err := a.Registry.Lookup(name) + if err != nil || !found || !rec.Sudo { + t.Fatalf("sudo recovery witness missing: found=%v record=%+v err=%v", found, rec, err) + } + if _, err := os.Lstat(sudoMgr.FilePath(name)); err != nil { + t.Fatalf("test did not retain the live sudo drop-in: %v", err) + } + if strings.Contains(a.Out.(*bytes.Buffer).String(), "BEGIN LINUX TEMP ADMIN INVITE") { + t.Fatal("credentials were printed for the failed invite") + } + if !strings.Contains(a.Err.(*bytes.Buffer).String(), "sudo removal is unconfirmed; account disabled and retained") { + t.Fatalf("rollback did not report the retained account:\n%s", a.Err.(*bytes.Buffer).String()) + } +} + func TestRevokeSudoCleanupFailureKeepsDisabledAccountAndRecoveryState(t *testing.T) { a, sudoMgr, _, _ := inviteApp(t) tracker := newTrackingSched() @@ -391,6 +690,41 @@ func TestRevokeScheduleCleanupFailureReturnsNonzeroAndKeepsRegistry(t *testing.T } } +func TestRevokeKeepsDisabledAccountWhenProcessesCannotBeCleared(t *testing.T) { + a, _, _, _ := inviteApp(t) + tracker := newTrackingSched() + a.Scheduler.Sys = tracker + const name = "xxvcc-procfail1" + rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + rm() + t.Cleanup(rm) + + if rc := a.Dispatch([]string{"invite", "--user", name, "--host", "203.0.113.5", + "--hours", "24", "--no-sudo", "--no-fix-sshd", "--auto-revoke", "--yes"}); rc != 0 { + t.Fatalf("invite rc=%d\nstderr:\n%s", rc, a.Err.(*bytes.Buffer).String()) + } + a.TerminateProcesses = func(int) error { return errors.New("injected survivor") } + + if rc := a.Dispatch([]string{"revoke", "--user", name, "--yes"}); rc != 1 { + t.Fatalf("revoke rc=%d, want failure when process termination is uncertain", rc) + } + if !mustExternalUserExists(t, name) { + t.Fatal("revoke freed the UID despite an unresolved process") + } + if expires := passwdExpiryField(t, name); expires == "" { + t.Error("retained account was not disabled with an expiry in the past") + } + if present, err := a.Registry.Contains(name); err != nil || !present { + t.Errorf("recovery registry row missing: present=%v err=%v", present, err) + } + if len(tracker.jobs) == 0 { + t.Error("auto-delete retry was removed despite incomplete process termination") + } + if !strings.Contains(a.Err.(*bytes.Buffer).String(), "injected survivor") { + t.Errorf("termination failure was not reported: %s", a.Err.(*bytes.Buffer).String()) + } +} + // TestInviteExistingLiveAccountDoesNotStripItsGrant is the regression the // pre-clear introduced. invite's explicit --user path has no existence guard, so // re-inviting a name that is a currently-LIVE managed account used to be a diff --git a/internal/cli/manage_root_test.go b/internal/cli/manage_root_test.go index 0560846..1881914 100644 --- a/internal/cli/manage_root_test.go +++ b/internal/cli/manage_root_test.go @@ -72,12 +72,12 @@ func newManageApp(t *testing.T, in string, users ...string) (*App, *bytes.Buffer dir := t.TempDir() a.Sudoers = &sudoers.Manager{ Dir: dir, - Validate: func(string) error { return nil }, + Validate: func([]byte) error { return nil }, Verify: func(string) error { return nil }, } a.Scheduler = &schedule.Scheduler{ SystemdDir: dir, InstallPath: a.InstallPath, UnitPrefix: "lta-test-", - Now: a.Now, Sys: fakeSys{}, UnderUnit: func(string) bool { return false }, + Now: a.Now, Sys: fakeSys{}, } // The store's dir has to be root-owned for its symlink-safety checks to pass; // t.TempDir() belongs to whoever runs the suite. @@ -288,10 +288,11 @@ func TestManageUsersDisplayedNumberIsTheOneThatActs(t *testing.T) { // account as UID-tampered before the confirmation gets to be what is under test. func newRealAccount(t *testing.T, a *App, name string) int { t.Helper() + const generation = "0123456789abcdef0123456789abcdef" rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } rm() t.Cleanup(rm) - if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGECOS, name).CombinedOutput(); err != nil { + if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGenerationGECOSPrefix+generation, name).CombinedOutput(); err != nil { t.Fatalf("useradd %s: %v: %s", name, err, out) } pw, ok := mustUserLookup(t, name) @@ -300,7 +301,7 @@ func newRealAccount(t *testing.T, a *App, name string) int { } if err := a.Registry.Record(registry.Record{ User: name, Created: "2026-07-07 12:00:00 UTC", Expires: "2026-07-08 12:00:00 UTC", - Host: "203.0.113.5", Port: 22, UID: pw.UID, + Host: "203.0.113.5", Port: 22, UID: pw.UID, Generation: generation, IdentityBound: true, }); err != nil { t.Fatal(err) } diff --git a/internal/cli/narrow_terminal_test.go b/internal/cli/narrow_terminal_test.go new file mode 100644 index 0000000..b049ec9 --- /dev/null +++ b/internal/cli/narrow_terminal_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/xxvcc/linux-temp-admin/internal/i18n" + "github.com/xxvcc/linux-temp-admin/internal/registry" + "github.com/xxvcc/linux-temp-admin/internal/table" +) + +func TestUsersViewFitsNarrowTerminal(t *testing.T) { + a := &App{ + P: i18n.Printer{Lang: i18n.EN}, + TerminalWidth: func() int { return 40 }, + } + view := a.usersView([]registry.Record{{ + User: "lta-narrow", Sudo: true, AutoRevoke: true, + Expires: "2026-07-26 13:55:46 UTC", + Host: strings.Repeat("host", 20) + ".example", + Port: 2222, + }}, true) + if strings.Contains(view, "┌") { + t.Fatalf("a table wider than the terminal was not changed to the compact view:\n%s", view) + } + for lineNo, line := range strings.Split(strings.TrimRight(view, "\n"), "\n") { + if width := table.Width(line); width > 40 { + t.Errorf("line %d is %d columns wide, want <= 40: %q", lineNo+1, width, line) + } + } + for _, want := range []string{"1) lta-narrow", "state=missing", "sudo=yes", "auto-delete=yes", "port=2222"} { + if !strings.Contains(view, want) { + t.Errorf("compact view missing %q:\n%s", want, view) + } + } +} + +func TestMenuLabelsFitFortyColumns(t *testing.T) { + for i, item := range menuItems { + for name, label := range map[string]string{"zh": item.zh, "en": item.en} { + if width := 4 + table.Width(label); width > 40 { + t.Errorf("menu item %d %s label is %d columns wide, want <= 40: %q", i+1, name, width, label) + } + } + } +} diff --git a/internal/cli/revoke.go b/internal/cli/revoke.go index 5040043..fa260f8 100644 --- a/internal/cli/revoke.go +++ b/internal/cli/revoke.go @@ -15,38 +15,97 @@ func (a *App) revoke(args []string) int { if !a.requireRoot() { return 1 } - return a.withLifecycleLock(func() int { return a.revokeLocked(args) }) + opts, ok := a.parseRevokeArgs(args) + if !ok { + return 1 + } + opts.manualInvocation = true + if opts.username == "" { + opts.username = a.selectUser() + } + if !validate.Username(opts.username) { + a.errorf("%s", a.P.M("用户名不合法,拒绝删除:"+opts.username, "invalid username; refusing deletion: "+opts.username)) + return 1 + } + if !opts.yes { + _, registered, err := a.Registry.Lookup(opts.username) + if err != nil { + a.errorf("%s: %v", a.P.M("读取注册表失败,拒绝继续", "reading registry failed; refusing to continue"), err) + return 1 + } + _, exists, err := a.lookupUser(opts.username) + if err != nil { + a.errorf("%s: %v", a.P.M("读取账号数据库失败,拒绝继续", "reading account database failed; refusing to continue"), err) + return 1 + } + if exists { + if opts.force && !registered { + a.warnf("%s", a.P.M("危险:用户 "+opts.username+" 未登记,--force 将删除真实系统用户及其家目录。", + "DANGER: "+opts.username+" is not registered; --force will delete a real system user and its home directory.")) + } + if a.prompt(a.P.M("请输入完整用户名 "+opts.username+" 以确认删除: ", + "type the full username "+opts.username+" to confirm deletion: ")) != opts.username { + a.warnf("%s", a.P.M("确认不匹配,已取消", "confirmation mismatch; cancelled")) + return 0 + } + opts.liveConfirmed = true + } + } + return a.withLifecycleLock(func() int { return a.revokeOptionsLocked(opts) }) } -// revokeLocked performs one complete revoke while the process-wide lifecycle -// lock is held. uninstall calls this form because it already holds that lock. -func (a *App) revokeLocked(args []string) int { +type revokeOptions struct { + username string + confirmForce string + expectedUID int + generation string + yes bool + force bool + liveConfirmed bool + manualInvocation bool +} + +func (a *App) parseRevokeArgs(args []string) (revokeOptions, bool) { fs := flag.NewFlagSet("revoke", flag.ContinueOnError) fs.SetOutput(a.Err) - userFlag := fs.String("user", "", "") - confirmForce := fs.String("confirm-force", "", "") - expectedUID := fs.Int("expected-uid", 0, "") - generation := fs.String("generation", "", "") - var fYes, fForce bool - fs.BoolVar(&fYes, "yes", false, "") - fs.BoolVar(&fYes, "y", false, "") - fs.BoolVar(&fForce, "force", false, "") + var opts revokeOptions + fs.StringVar(&opts.username, "user", "", "") + fs.StringVar(&opts.confirmForce, "confirm-force", "", "") + fs.IntVar(&opts.expectedUID, "expected-uid", 0, "") + fs.StringVar(&opts.generation, "generation", "", "") + fs.BoolVar(&opts.yes, "yes", false, "") + fs.BoolVar(&opts.yes, "y", false, "") + fs.BoolVar(&opts.force, "force", false, "") if err := fs.Parse(args); err != nil { - return 1 + return revokeOptions{}, false } if fs.NArg() > 0 { a.errorf("%s %v", a.P.M("未知参数:", "unexpected arguments:"), fs.Args()) - return 1 + return revokeOptions{}, false } + return opts, true +} - username := *userFlag - if username == "" { - username = a.selectUser() +// revokeLocked is the non-interactive form used by uninstall while it already +// owns the lifecycle lock. The caller must supply --user and --yes. +func (a *App) revokeLocked(args []string) int { + opts, ok := a.parseRevokeArgs(args) + if !ok { + return 1 } - if !validate.Username(username) { - a.errorf("%s", a.P.M("用户名不合法,拒绝删除:"+username, "invalid username; refusing deletion: "+username)) + if !opts.yes || !validate.Username(opts.username) { + a.errorf("%s", a.P.M("内部撤销必须提供合法用户名并禁用交互", "internal revoke requires a valid username and non-interactive confirmation")) return 1 } + opts.liveConfirmed = true + return a.revokeOptionsLocked(opts) +} + +// revokeOptionsLocked performs one complete revoke while the process-wide +// lifecycle lock is held. Every identity and registry fact is read again here; +// the lock-free preparation only collects operator intent. +func (a *App) revokeOptionsLocked(opts revokeOptions) int { + username := opts.username // One read gives every registry fact this path acts on: registration, the // creation UID used to detect replacement/tampering, the generation token, and @@ -59,30 +118,30 @@ func (a *App) revokeLocked(args []string) int { // New scheduled jobs are bound to one account generation. A stale job exits // successfully so systemd does not retry it against a replacement account. - if *generation != "" || *expectedUID != 0 { - if !validate.Generation(*generation) || *expectedUID < 1 { + if opts.generation != "" || opts.expectedUID != 0 { + if !validate.Generation(opts.generation) || !validate.AccountID(opts.expectedUID) { a.errorf("%s", a.P.M("自动撤销身份参数不完整或不合法", "invalid or incomplete auto-revoke identity")) return 1 } - if !registered || rec.Generation != *generation || rec.UID != *expectedUID { + if !registered || rec.Generation != opts.generation || rec.UID != opts.expectedUID { a.warnf("%s", a.P.M("陈旧的自动撤销任务已忽略:账号世代不再匹配", "ignored stale auto-revoke task: account generation no longer matches")) a.audit("account.delete", username, "skip", "stale scheduled generation", nil) return 0 } } - if !fForce && !registered { + if !opts.force && !registered { a.errorf("%s", a.P.M("拒绝删除未登记用户:"+username+"(如确需删除请加 --force)", "refusing to delete an unregistered user: "+username+" (use --force if intended)")) return 1 } - if fForce && !registered && fYes && *confirmForce != username { + if opts.force && !registered && opts.yes && opts.confirmForce != username { a.errorf("%s", a.P.M("通过 --force --yes 删除未登记用户需同时传入 --confirm-force "+username, "deleting an unregistered user via --force --yes also requires --confirm-force "+username)) return 1 } - pw, exists, err := user.Lookup(username) + pw, exists, err := a.lookupUser(username) if err != nil { a.errorf("%s: %v", a.P.M("读取账号数据库失败,拒绝清理状态", "reading account database failed; refusing state cleanup"), err) return 1 @@ -111,17 +170,31 @@ func (a *App) revokeLocked(args []string) int { a.audit("account.cleanup", username, "ok", "user absent; cleaned registry/sudoers/sshd/schedule", nil) return 0 } + if !opts.yes && !opts.liveConfirmed { + a.errorf("%s", a.P.M("确认后账号状态发生变化;拒绝删除,请重新运行并确认", + "the account appeared after confirmation; refusing deletion; rerun and confirm the current state")) + return 1 + } - if !fYes { - if fForce && !registered { - a.warnf("%s", a.P.M("危险:用户 "+username+" 未登记,--force 将删除真实系统用户及其家目录。", - "DANGER: "+username+" is not registered; --force will delete a real system user and its home directory.")) - } - if a.prompt(a.P.M("请输入完整用户名 "+username+" 以确认删除: ", - "type the full username "+username+" to confirm deletion: ")) != username { - a.warnf("%s", a.P.M("确认不匹配,已取消", "confirmation mismatch; cancelled")) - return 0 + // A pending row was written before useradd and never completed with a durable + // UID. The current account may be the half-created account, or an unrelated + // account that later reused the name; neither can be proved. Strip any + // name-scoped leftovers, but never turn this incomplete intent into authority to + // delete a live account and its home directory. + if registered && rec.Pending { + cleanupErr := errors.Join( + a.removeSudoGrant(username), + a.removeSSHDException(username), + a.Scheduler.Cancel(username, rec.AutoUnit), + ) + a.errorf("%s", a.P.M( + "该登记仍处于创建中的 pending 状态,无法证明当前同名账号的身份;已保留账号和登记,请人工核查后处理。", + "the registry row is still a pending creation intent, so the current account's identity cannot be proved; the account and registry record were retained for manual recovery.")) + if cleanupErr != nil { + a.errorf("%s: %v", a.P.M("清理 pending 账号的遗留授权或任务未完整完成", "cleanup of grants or schedules for the pending account did not complete"), cleanupErr) } + a.audit("account.delete", username, "fail", "pending creation identity is unverified", nil) + return 1 } // Strip the privilege grants FIRST — before the protection gate can refuse and @@ -134,14 +207,23 @@ func (a *App) revokeLocked(args []string) int { // NOPASSWD sudo and an sshd exception. grantErr := errors.Join(a.removeSudoGrant(username), a.removeSSHDException(username)) - protected, protectErr := user.IsProtectedRevokeTarget(username, registered, rec.UID) - if protectErr != nil { - a.errorf("%s: %v", a.P.M("无法确认目标账号身份,拒绝删除", "cannot verify target account identity; refusing deletion"), protectErr) - return 1 - } + // Released v2 rows used one fixed GECOS marker, so username+UID+marker still + // cannot distinguish the original account from a same-name/same-UID replacement. + // Only a direct operator invocation with --force and an explicit full-name + // confirmation may recover such an account. Scheduled and uninstall-internal + // invocations never receive this exception even though they carry --force. + allowLegacy := registered && !rec.IdentityBound && opts.manualInvocation && opts.force && + opts.generation == "" && opts.expectedUID == 0 && + ((!opts.yes && opts.liveConfirmed) || (opts.yes && opts.confirmForce == username)) + protected := user.IsProtectedRevokeEntry(username, pw, true, registered, rec.UID, rec.Generation, allowLegacy) if protected { a.errorf("%s", a.P.M("拒绝删除受保护或系统用户:"+username, "refusing to delete a protected or system user: "+username)) + if registered && !rec.IdentityBound && user.IsLegacyManagedEntry(pw) { + a.errorf("%s", a.P.M( + "该账号使用旧版固定身份标记,无法证明它仍是原账号。请人工核查后直接运行 revoke --user "+username+" --force,并输入完整用户名确认;非交互调用还必须传入 --yes --confirm-force "+username+"。", + "this account uses a legacy fixed identity marker and cannot be proved to be the original account. After manual inspection, invoke revoke --user "+username+" --force directly and type the full username; a non-interactive invocation must also pass --yes --confirm-force "+username+".")) + } // Name the tamper if that is why: an account that rewrote its own UID (most // dangerously to 0) is now protected by the very check meant to shield real // accounts, and the operator has to clean it up by hand. @@ -153,19 +235,44 @@ func (a *App) revokeLocked(args []string) int { if grantErr != nil { a.errorf("%s: %v", a.P.M("账号受保护且授权未完全移除", "the account is protected and its grants were not fully removed"), grantErr) } - a.warnf("%s", a.P.M("自动删除任务保留;请人工核查,旧的一次性任务不会自动重试。", - "the auto-delete task is retained; inspect manually because legacy one-shot jobs do not retry.")) + a.warnf("%s", a.P.M("自动删除任务保留;systemd 任务会按策略重试,at 和旧的一次性任务需人工核查。", + "the auto-delete task is retained; systemd jobs retry by policy, while at and legacy one-shot jobs require manual inspection.")) a.audit("account.delete", username, "fail", "protected target; grants stripped", nil) return 1 } + // Removing grants and reloading sshd can take long enough for an out-of-band + // administrator to replace the account. Do not disable one generation, signal + // another UID, and then userdel a third: require the complete passwd entry to be + // unchanged immediately before the destructive teardown. + current, stillExists, identityErr := a.lookupUser(username) + if identityErr != nil || !stillExists || current != pw { + if identityErr == nil { + identityErr = fmt.Errorf("account identity changed during revoke") + } + a.errorf("%s: %v", a.P.M("撤销期间账号身份发生变化,已移除可确认的授权但拒绝禁用、清场或删除账号", + "the account identity changed during revoke; confirmed grants were stripped, but login disable, process termination, and deletion were refused"), identityErr) + a.audit("account.delete", username, "fail", identityErr.Error(), nil) + return 1 + } + pw = current + if grantErr != nil { // Do not free the username while a name-scoped privilege file survives. disableErr := a.Users.DisableLogin(username) if disableErr == nil { - user.TerminateProcesses(pw.UID) + identityErr := a.accountStillMatches(username, pw) + if identityErr != nil { + a.errorf("%s: %v", a.P.M("授权未完全移除;账号身份在禁用登录后发生变化,拒绝按旧 UID 终止进程", + "grants were not fully removed; the account identity changed after login disable, so processes were not terminated under the old UID"), errors.Join(grantErr, identityErr)) + return 1 + } + terminateErr := a.terminateProcesses(pw.UID) + if terminateErr == nil { + terminateErr = a.accountStillMatches(username, pw) + } a.errorf("%s: %v", a.P.M("授权未完全移除;账号已禁用但不会删除,以免残留授权在用户名复用时重新生效", - "grants were not fully removed; the account was disabled but not deleted so a surviving name-scoped grant cannot re-arm on reuse"), grantErr) + "grants were not fully removed; the account was disabled but not deleted so a surviving name-scoped grant cannot re-arm on reuse"), errors.Join(grantErr, terminateErr)) } else { a.errorf("%s: %v", a.P.M("授权未完全移除,且禁用登录也失败;账号和登记均已保留,请立即人工处理", "grants were not fully removed and disabling login also failed; the account and registry were retained for immediate manual recovery"), errors.Join(grantErr, disableErr)) @@ -173,23 +280,33 @@ func (a *App) revokeLocked(args []string) int { return 1 } - // Shut the door before taking the account apart. Until this lands the account - // is still SSH-reachable, and a reconnect landing between the kill and the - // delete is exactly what used to make the delete fail. - if err := a.Users.DisableLogin(username); err != nil { - a.warnf("%s: %v", a.P.M("禁用登录失败,仍继续删除", "could not disable the login; continuing to delete anyway"), err) - } - user.TerminateProcesses(pw.UID) - if err := a.Users.Delete(username); err != nil { - a.errorf("%s: %v", a.P.M("删除用户失败", "delete user failed"), err) + // Shut the door before taking the account apart. Until both expiry and password + // locking land, the account may still be SSH-reachable: in particular, a failed + // chage leaves public-key login open even when usermod -L succeeded. Never create + // a scan-then-delete race by continuing from a partial disable. + stage, teardownErr := a.teardownLocalAccount(username, pw) + switch stage { + case revokeDisableLogin: + a.errorf("%s: %v", a.P.M("无法完整禁用登录;保留账号、登记和自动删除任务,未终止进程或删除账号,请立即人工处理", + "could not fully disable the login; the account, registry record, and auto-delete task were retained, and no processes were terminated or account deleted; inspect immediately"), teardownErr) + a.audit("account.delete", username, "fail", "disable login incomplete: "+teardownErr.Error(), nil) + return 1 + case revokeTerminateProcesses: + a.errorf("%s: %v", a.P.M("无法确认该 UID 的所有进程已终止;账号已禁用,保留账号、登记和自动删除任务,避免 UID 复用继承残留进程", + "could not confirm that every process for this UID was terminated; the account is disabled and its account, registry record, and auto-delete task were retained to prevent residual processes crossing a UID reuse"), teardownErr) + a.audit("account.delete", username, "fail", "process termination incomplete: "+teardownErr.Error(), nil) + return 1 + case revokeDeleteAccount: + a.errorf("%s: %v", a.P.M("删除用户失败", "delete user failed"), teardownErr) // The auto-revoke task is deliberately still armed: it is the fallback that // retries this deletion, and tearing it down on the way to a failure would // leave the account with nothing coming for it. The login is already // disabled, so the account cannot be used in the meantime. a.warnf("%s", a.P.M("登录已禁用;systemd 任务会按策略重试,at/旧任务需手动重试。", "the login is disabled; systemd jobs retry by policy, while at/legacy jobs require a manual retry.")) - a.audit("account.delete", username, "fail", err.Error(), nil) + a.audit("account.delete", username, "fail", teardownErr.Error(), nil) return 1 + case revokeAccountRemoved: } // Only now that the account is provably gone is the fallback safe to remove. @@ -201,11 +318,56 @@ func (a *App) revokeLocked(args []string) int { a.errorf("%s: %v", a.P.M("用户已删除,但清理登记失败", "user deleted, but registry cleanup failed"), err) return 1 } - a.audit("account.delete", username, "ok", "", map[string]string{"force": ynStr(fForce), "registered": ynStr(registered)}) + a.audit("account.delete", username, "ok", "", map[string]string{"force": ynStr(opts.force), "registered": ynStr(registered)}) a.success(a.P.M("已撤销并删除用户:"+username, "user revoked and deleted: "+username)) return 0 } +type revokeAccountStage uint8 + +const ( + revokeDisableLogin revokeAccountStage = iota + revokeTerminateProcesses + revokeDeleteAccount + revokeAccountRemoved +) + +// teardownLocalAccount preserves the ordering that makes UID reuse safe. A +// stage is returned with the error so revoke can explain precisely which recovery +// state was retained without repeating these security-sensitive calls. +func (a *App) teardownLocalAccount(username string, expected user.Passwd) (revokeAccountStage, error) { + if err := a.Users.DisableLogin(username); err != nil { + return revokeDisableLogin, err + } + if err := a.accountStillMatches(username, expected); err != nil { + return revokeTerminateProcesses, err + } + if err := a.terminateProcesses(expected.UID); err != nil { + return revokeTerminateProcesses, err + } + if err := a.accountStillMatches(username, expected); err != nil { + return revokeDeleteAccount, err + } + if err := a.Users.Delete(username); err != nil { + return revokeDeleteAccount, err + } + return revokeAccountRemoved, nil +} + +// accountStillMatches prevents a multi-stage teardown from carrying facts from +// the invited account across an out-of-band delete/recreate. userdel itself is +// name-based, so re-check immediately before it as well as before the UID sweep. +func (a *App) accountStillMatches(username string, expected user.Passwd) error { + current, exists, err := a.lookupUser(username) + if err != nil { + return fmt.Errorf("re-read account identity: %w", err) + } + if !exists || current != expected { + return fmt.Errorf("account identity changed during teardown") + } + return nil +} + // removeSudoGrant deletes any NOPASSWD drop-in this tool wrote for username. Like // removeSSHDException beside it, the path is derived from the username and the // manager only ever touches its own managed file, so it is called blindly. diff --git a/internal/cli/selfcmd.go b/internal/cli/selfcmd.go index e80297b..3720dfd 100644 --- a/internal/cli/selfcmd.go +++ b/internal/cli/selfcmd.go @@ -1,17 +1,30 @@ package cli import ( + "errors" "flag" "fmt" + "io" + "net/url" "os" + "path/filepath" "runtime" + "strings" "github.com/xxvcc/linux-temp-admin/internal/buildinfo" "github.com/xxvcc/linux-temp-admin/internal/config" + "github.com/xxvcc/linux-temp-admin/internal/fsutil" + "github.com/xxvcc/linux-temp-admin/internal/selfmanage" + "github.com/xxvcc/linux-temp-admin/internal/validate" + "golang.org/x/sys/unix" ) const procSelfExe = "/proc/self/exe" +// Two maximum-length URLs plus their line terminators must fit. The parser +// accepts a missing final newline as well. +const maxUpgradeURLFileBytes = int64(2*2048 + 2) + // readRunningBinary reads the inode this process is executing, not the mutable // pathname it was launched through. Executable exists only to point tests at a // fixture; production leaves it nil and uses Linux's stable /proc handle. @@ -24,10 +37,22 @@ func (a *App) readRunningBinary() ([]byte, error) { return nil, fmt.Errorf("locate test executable: %w", err) } } - bin, err := os.ReadFile(path) + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + maxBytes := int64(config.MaxUpgradeBytes) + if a.Selfmanage != nil && a.Selfmanage.MaxBytes > 0 { + maxBytes = a.Selfmanage.MaxBytes + } + bin, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) if err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } + if int64(len(bin)) > maxBytes { + return nil, fmt.Errorf("running binary exceeds %d-byte install limit", maxBytes) + } return bin, nil } @@ -42,7 +67,7 @@ func (a *App) install(args []string) int { if !a.parseFlags(fs, args) { return 1 } - return a.withLifecycleLock(func() int { return a.installLocked(force) }) + return a.withLifecycleLockAllowUninstalled(func() int { return a.installLocked(force) }) } func (a *App) installLocked(force bool) int { @@ -51,12 +76,38 @@ func (a *App) installLocked(force bool) int { a.errorf("%s: %v", a.P.M("无法读取当前运行程序", "cannot read the running binary"), err) return 1 } + wasUninstalled := false + if a.Lifecycle != nil { + wasUninstalled, err = a.Lifecycle.IsUninstalled() + if err != nil { + a.errorf("%s: %v", a.P.M("无法验证卸载状态,拒绝安装", "cannot verify uninstall state; refusing to install"), err) + return 1 + } + } installed, err := a.Selfmanage.Install(bin, force) if err != nil { - a.errorf("%v", err) + if installed { + a.errorf("%s: %v", a.P.M("命令已替换,但无法确认该替换已持久化", "the command was replaced, but the replacement's durability is unknown"), err) + a.audit("install", "", "fail", "command replaced but durability unknown: "+err.Error(), nil) + } else { + a.errorf("%v", err) + a.audit("install", "", "fail", "install failed before replacement: "+err.Error(), nil) + } return 1 } + if a.Lifecycle != nil && wasUninstalled { + if err := a.Lifecycle.ClearUninstalled(); err != nil { + a.errorf("%s: %v", a.P.M("命令已安装,但无法清除卸载状态标记", "the command is installed, but the uninstall-state marker could not be cleared"), err) + a.audit("install", "", "fail", "installed but uninstall marker cleanup failed: "+err.Error(), nil) + return 1 + } + } if !installed { + if wasUninstalled { + a.audit("install", "", "ok", "existing stable command reactivated", nil) + a.success(a.P.M("已重新启用稳定命令:"+a.InstallPath, "reactivated the stable command: "+a.InstallPath)) + return 0 + } // The running binary already *is* the stable command. Saying "installed" // here would claim a privileged write that never happened -- and would put // a matching lie in the audit log. @@ -70,48 +121,267 @@ func (a *App) installLocked(force bool) int { } func (a *App) upgrade(args []string) int { + return a.upgradeResult(args).status +} + +func (a *App) upgradeResult(args []string) commandResult { if !a.requireRoot() { - return 1 + return statusResult(1) } fs := flag.NewFlagSet("upgrade", flag.ContinueOnError) - fs.SetOutput(a.Err) + // Flag diagnostics can quote a malformed argument verbatim. Upgrade arguments + // may be mistaken secret URLs, so emit only our fixed diagnostics below. + fs.SetOutput(io.Discard) urlFlag := fs.String("url", "", "") + urlFileFlag := fs.String("url-file", "", "") var force, yes bool fs.BoolVar(&force, "force", false, "") fs.BoolVar(&yes, "yes", false, "") fs.BoolVar(&yes, "y", false, "") - if !a.parseFlags(fs, args) { - return 1 + if err := fs.Parse(args); err != nil { + a.errorf("%s", a.P.M("升级参数不合法", "invalid upgrade options")) + return statusResult(1) + } + if fs.NArg() != 0 { + a.errorf("%s", a.P.M("upgrade 不接受位置参数", "upgrade does not accept positional arguments")) + return statusResult(1) + } + if *urlFlag != "" && *urlFileFlag != "" { + a.errorf("%s", a.P.M("--url 与 --url-file 不能同时使用", "--url and --url-file are mutually exclusive")) + return statusResult(1) } + if *urlFlag != "" && !safeCommandLineUpgradeURL(*urlFlag) { + a.errorf("%s", a.P.M( + "--url 只接受不含用户信息、查询参数或片段的非敏感 URL;含凭据或令牌时请使用 --url-file", + "--url accepts only non-secret URLs without userinfo, query parameters, or fragments; use --url-file for credentials or tokens")) + return statusResult(1) + } + customURL := *urlFlag != "" || *urlFileFlag != "" binURL := *urlFlag - if binURL == "" { - binURL = config.ReleaseBaseURL + config.BinaryAssetPrefix + runtime.GOARCH + sigURL := "" + if *urlFileFlag != "" { + urls, err := readUpgradeURLFile(*urlFileFlag) + if err != nil { + a.errorf("%s: %v", a.P.M("无法读取升级 URL 文件", "cannot read upgrade URL file"), err) + return statusResult(1) + } + binURL, sigURL = urls.binary, urls.signature + } + if customURL { + if err := validUpgradeURL(binURL); err != nil { + a.errorf("%s: %v", a.P.M("升级 URL 不合法", "invalid upgrade URL"), err) + return statusResult(1) + } + if sigURL == "" { + var err error + sigURL, err = detachedSignatureURL(binURL) + if err != nil { + a.errorf("%s: %v", a.P.M("升级 URL 不合法", "invalid upgrade URL"), err) + return statusResult(1) + } + } else if err := validUpgradeURL(sigURL); err != nil { + a.errorf("%s: %v", a.P.M("签名 URL 不合法", "invalid signature URL"), err) + return statusResult(1) + } } - sigURL := binURL + ".sig" if !yes { - a.printf("%s\n %s\n %s", a.P.M("将下载并验签后升级:", "will download, verify, and upgrade from:"), binURL, sigURL) + if customURL { + displayBinURL := selfmanage.RedactedURL(binURL) + displaySigURL := selfmanage.RedactedURL(sigURL) + a.printf("%s\n %s\n %s", a.P.M("将下载并验签后升级:", "will download, verify, and upgrade from:"), displayBinURL, displaySigURL) + } else { + a.printf("%s\n %s\n %s", a.P.M( + "将优先从官方镜像下载并验签,传输失败时回退 GitHub:", + "will download and verify from the official mirror, with GitHub as a transport fallback:"), + config.ReleaseMirrorBaseURL, config.GitHubReleaseRoot) + } if a.prompt(a.P.M("确认请输入 YES: ", "type YES to confirm: ")) != "YES" { a.warnf("%s", a.P.M("已取消", "cancelled")) - return 0 + return statusResult(0) } } - return a.withLifecycleLock(func() int { - return a.upgradeLocked(binURL, sigURL, force) + var candidate *selfmanage.UpgradeCandidate + var err error + if customURL { + candidate, err = a.Selfmanage.PrepareUpgrade(binURL, sigURL) + } else { + candidate, err = a.prepareOfficialUpgrade() + } + if err != nil { + a.errorf("%s: %v", a.P.M("升级失败", "upgrade failed"), err) + a.audit("upgrade", "", "fail", "upgrade preparation failed before replacement: "+err.Error(), nil) + return statusResult(1) + } + result := commandResult{} + result.status = a.withLifecycleLock(func() int { + result = a.upgradePreparedLocked(candidate, force) + return result.status }) + return result } -func (a *App) upgradeLocked(binURL, sigURL string, force bool) int { - newVer, err := a.Selfmanage.Upgrade(binURL, sigURL, buildinfo.Version, force) +func (a *App) prepareOfficialUpgrade() (*selfmanage.UpgradeCandidate, error) { + asset := config.BinaryAssetPrefix + runtime.GOARCH + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + return nil, fmt.Errorf("official releases do not support architecture %s", runtime.GOARCH) + } + manifest, err := a.Selfmanage.FetchReleaseManifest( + config.ReleaseMirrorManifestURL, config.ReleaseMirrorBaseURL) if err != nil { - a.errorf("%s: %v", a.P.M("升级失败", "upgrade failed"), err) - return 1 + if !selfmanage.IsTransportFailure(err) { + return nil, fmt.Errorf("official mirror manifest failed validation: %w", err) + } + a.warnf("%s", a.P.M( + "官方镜像索引传输失败,正在回退 GitHub。", + "official mirror index transfer failed; falling back to GitHub.")) + return a.Selfmanage.PrepareReleaseUpgrade( + config.GitHubLatestReleaseBaseURL, asset, "") + } + candidate, err := a.Selfmanage.PrepareMirrorReleaseUpgrade(manifest.BaseURL, asset, manifest.Version) + if err == nil { + a.info(a.P.M("已通过官方镜像下载并验签。", "downloaded and verified through the official mirror.")) + return candidate, nil + } + if !selfmanage.IsTransportFailure(err) { + return nil, fmt.Errorf("official mirror release failed verification: %w", err) + } + a.warnf("%s", a.P.M( + "官方镜像版本资产传输不完整,正在从 GitHub 重新下载同一版本。", + "official mirror release transfer was incomplete; downloading the same version again from GitHub.")) + githubBase := config.GitHubReleaseRoot + "/download/" + manifest.Tag + return a.Selfmanage.PrepareReleaseUpgrade(githubBase, asset, manifest.Version) +} + +func safeCommandLineUpgradeURL(rawURL string) bool { + u, err := url.Parse(rawURL) + return err == nil && u.User == nil && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" +} + +type upgradeURLs struct { + binary string + signature string +} + +// readUpgradeURLFile keeps credentials and signed query values out of argv, +// shell history, sudo logs, and /proc. The first line is the binary URL; an +// optional second line is an independently signed signature URL. With one line, +// the signature URL is derived by appending .sig to the binary path. The file +// itself must be a root-only regular non-symlink; O_NONBLOCK makes special-file +// mistakes fail fast. +func readUpgradeURLFile(path string) (upgradeURLs, error) { + if !filepath.IsAbs(path) { + return upgradeURLs{}, errors.New("path must be absolute") + } + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + return upgradeURLs{}, errors.New("open failed") + } + f := os.NewFile(uintptr(fd), "upgrade-url") + if f == nil { + _ = unix.Close(fd) + return upgradeURLs{}, errors.New("open failed") + } + defer f.Close() + + var st unix.Stat_t + if err := unix.Fstat(fd, &st); err != nil { + return upgradeURLs{}, errors.New("metadata check failed") + } + if st.Mode&unix.S_IFMT != unix.S_IFREG || int(st.Uid) != os.Geteuid() || st.Mode&0o7777 != 0o600 { + return upgradeURLs{}, errors.New("file must be an owner-owned regular non-symlink with mode 0600") + } + b, err := io.ReadAll(io.LimitReader(f, maxUpgradeURLFileBytes+1)) + if err != nil { + return upgradeURLs{}, errors.New("read failed") + } + if int64(len(b)) > maxUpgradeURLFileBytes { + return upgradeURLs{}, errors.New("file is too large") + } + content := string(b) + content = strings.TrimSuffix(content, "\n") + lines := strings.Split(content, "\n") + if len(lines) < 1 || len(lines) > 2 { + return upgradeURLs{}, errors.New("file must contain one binary URL and at most one signature URL") + } + for i := range lines { + lines[i] = strings.TrimSuffix(lines[i], "\r") + if lines[i] == "" || strings.ContainsRune(lines[i], '\r') || strings.TrimSpace(lines[i]) != lines[i] { + return upgradeURLs{}, errors.New("each URL must occupy one non-empty line without surrounding whitespace") + } + } + urls := upgradeURLs{binary: lines[0]} + if len(lines) == 2 { + urls.signature = lines[1] + } + return urls, nil +} + +func validUpgradeURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return errors.New("malformed URL syntax") + } + if !validate.UpgradeURL(rawURL) || u.Scheme != "https" || + u.Opaque != "" || u.Host == "" || u.Hostname() == "" { + return errors.New("URL must be a valid HTTPS URL of at most 2048 bytes") + } + return nil +} + +// detachedSignatureURL adds .sig to the binary URL's path, not to its complete +// serialized form. Authentication query parameters and fragments therefore +// remain attached to the signature request instead of becoming part of the +// binary path ("?token=...sig"). +func detachedSignatureURL(binaryURL string) (string, error) { + u, err := url.Parse(binaryURL) + if err != nil { + // net/url parse errors quote the complete input. The value may carry basic + // auth or a signed query, so never propagate that text to the terminal. + return "", errors.New("malformed URL syntax") + } + u.Path += ".sig" + if u.RawPath != "" { + u.RawPath += ".sig" + } + return u.String(), nil +} + +func (a *App) upgradePreparedLocked(candidate *selfmanage.UpgradeCandidate, force bool) commandResult { + previous := "" + if v, err := a.Selfmanage.InstalledVersion(); err == nil { + previous = v + } else if !errors.Is(err, selfmanage.ErrNotInstalled) { + previous = "unknown" + } + newVer, err := a.Selfmanage.ApplyUpgrade(candidate, force) + if err != nil { + var durability *fsutil.DurabilityError + if newVer != "" && errors.As(err, &durability) { + a.errorf("%s: %v", a.P.M("命令已替换,但无法确认升级已持久化", "the command was replaced, but the upgrade's durability is unknown"), err) + a.audit("upgrade", "", "fail", versionTransition(previous, newVer)+" visible but durability unknown: "+err.Error(), nil) + } else { + a.errorf("%s: %v", a.P.M("升级失败", "upgrade failed"), err) + a.audit("upgrade", "", "fail", "upgrade failed before replacement: "+err.Error(), nil) + } + return statusResult(1) } if newVer == "" { - a.success(a.P.M("已是最新版本:"+buildinfo.Version, "already up to date: "+buildinfo.Version)) - return 0 + current, probeErr := a.Selfmanage.InstalledVersion() + if probeErr != nil { + current = buildinfo.Version + } + a.success(a.P.M("已是最新版本:"+current, "already up to date: "+current)) + return statusResult(0) } - a.audit("upgrade", "", "ok", buildinfo.Version+" -> "+newVer, nil) + a.audit("upgrade", "", "ok", versionTransition(previous, newVer), nil) a.success(a.P.M("已升级到 "+newVer, "upgraded to "+newVer)) - return 0 + return commandResult{applied: true} +} + +func versionTransition(previous, next string) string { + if previous == "" { + previous = "not installed" + } + return previous + " -> " + next } diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go index 7f9dcaf..679b942 100644 --- a/internal/cli/uninstall.go +++ b/internal/cli/uninstall.go @@ -5,9 +5,11 @@ import ( "errors" "flag" "fmt" + "io" "os" "path/filepath" "sort" + "strconv" "strings" "github.com/xxvcc/linux-temp-admin/internal/config" @@ -15,6 +17,7 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/table" "github.com/xxvcc/linux-temp-admin/internal/user" "github.com/xxvcc/linux-temp-admin/internal/validate" + "golang.org/x/sys/unix" ) // witness is a place that names an account, and the reason the teardown believes @@ -194,6 +197,12 @@ func (a *App) binaryBlocker(force bool) string { if err != nil { return err.Error() } + // Even --force only authorizes unlinking an unsafe file-like entry (for + // example a symlink). A directory is not an installed command, and a non-empty + // one would fail only after accounts and state had already been destroyed. + if fi.IsDir() { + return a.P.M("是目录;请先人工处理", "is a directory; remove or relocate it manually") + } // --force is exactly what makes an unsafe path removable (Selfmanage.Uninstall // skips the RootSafeFile check under force), so with it set there is no blocker // to report — saying "needs --force" while --force is present is just wrong. @@ -229,8 +238,11 @@ func (a *App) binaryBlocker(force bool) string { // the exact silent under-report the inventory's fatal-error gate exists to catch, // and this is the one witness the code itself calls the only record of an account // v1 made without a sudo grant. So a present-but-unreadable registry is an error. +const maxV1RegistryBytes = int64(16 << 20) + func (a *App) v1RegistryUsers() ([]string, error) { - f, err := os.Open(filepath.Join(a.StateDir, filepath.Base(config.V1RegistryFile))) + path := filepath.Join(a.StateDir, filepath.Base(config.V1RegistryFile)) + f, err := os.OpenFile(path, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) if os.IsNotExist(err) { return nil, nil } @@ -238,8 +250,19 @@ func (a *App) v1RegistryUsers() ([]string, error) { return nil, err } defer f.Close() + fi, err := f.Stat() + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, fmt.Errorf("v1 registry %s is not a regular file", path) + } + if fi.Size() > maxV1RegistryBytes { + return nil, fmt.Errorf("v1 registry exceeds %d-byte limit", maxV1RegistryBytes) + } var users []string - sc := bufio.NewScanner(f) + limited := &io.LimitedReader{R: f, N: maxV1RegistryBytes + 1} + sc := bufio.NewScanner(limited) lineNo := 0 for sc.Scan() { lineNo++ @@ -258,6 +281,9 @@ func (a *App) v1RegistryUsers() ([]string, error) { // would let the caller act on an inventory it was just told is incomplete. return nil, err } + if limited.N == 0 { + return nil, fmt.Errorf("v1 registry exceeds %d-byte limit", maxV1RegistryBytes) + } return users, nil } @@ -314,8 +340,12 @@ func (a *App) printTeardownPlan(p teardownPlan) { func callerAccount() string { return os.Getenv("SUDO_USER") } func (a *App) uninstall(args []string) int { + return a.uninstallResult(args).status +} + +func (a *App) uninstallResult(args []string) commandResult { if !a.requireRoot() { - return 1 + return statusResult(1) } fs := flag.NewFlagSet("uninstall", flag.ContinueOnError) fs.SetOutput(a.Err) @@ -326,16 +356,40 @@ func (a *App) uninstall(args []string) int { fs.BoolVar(&removeUsers, "remove-users", false, "") fs.BoolVar(&purgeAudit, "purge-audit", false, "") if !a.parseFlags(fs, args) { - return 1 + return statusResult(1) + } + plan := a.teardownPlan(purgeAudit, force) + if !a.authorizeUninstall(plan, yes, removeUsers) { + return statusResult(1) } - return a.withLifecycleLock(func() int { - return a.uninstallLocked(force, yes, removeUsers, purgeAudit) + if !yes { + if a.prompt(a.P.M("确认卸载请输入 YES: ", "type YES to uninstall: ")) != "YES" { + a.warnf("%s", a.P.M("已取消", "cancelled")) + return statusResult(0) + } + } + result := commandResult{} + result.status = a.withLifecycleLockAllowUninstalled(func() int { + current := a.teardownPlan(purgeAudit, force) + if current.inventoryErr != nil { + a.errorf("%s: %v", a.P.M("确认后无法重新读取完整卸载清单,拒绝执行", + "cannot rebuild a complete uninstall inventory after confirmation; refusing to proceed"), current.inventoryErr) + return 1 + } + if !sameTeardownPlan(plan, current) { + a.errorf("%s", a.P.M("确认后卸载清单发生变化;未修改主机,请重新运行并确认最新清单", + "the uninstall inventory changed after confirmation; the host was not modified; rerun and confirm the current inventory")) + a.audit("uninstall", "", "fail", "inventory changed after confirmation", nil) + return 1 + } + status := a.teardown(current, force, purgeAudit) + result = commandResult{status: status, applied: status == 0} + return status }) + return result } -func (a *App) uninstallLocked(force, yes, removeUsers, purgeAudit bool) int { - plan := a.teardownPlan(purgeAudit, force) - +func (a *App) authorizeUninstall(plan teardownPlan, yes, removeUsers bool) bool { // A witness that could not be read is fatal, not advisory. Every way of failing // to read one makes accounts vanish from the inventory rather than announce // themselves, and an inventory that under-reports is how a teardown deletes the @@ -349,7 +403,7 @@ func (a *App) uninstallLocked(force, yes, removeUsers, purgeAudit bool) int { a.warnf("%s", a.P.M( "清单不全就卸载,会删掉命令、留下它没看见的账号——而它们的自动删除任务执行的正是这个命令。请先修好上面的问题再重试。", "uninstalling on a partial inventory removes the command and leaves behind accounts it never saw. Repair the account database or managed state before retrying.")) - return 1 + return false } a.printTeardownPlan(plan) @@ -367,7 +421,7 @@ func (a *App) uninstallLocked(force, yes, removeUsers, purgeAudit bool) int { plan.binaryPath, plan.binaryBlocker) a.warnf("%s", a.P.M("先处理该路径(或用 --force 明确接受),再重试——否则卸载会删光账号与状态却卡在最后一步。", "resolve that path (or pass --force to accept it explicitly) and retry — otherwise the uninstall would remove every account and all state, then stop at the last step.")) - return 1 + return false } // Refuse before anything is touched, not partway through. @@ -377,7 +431,7 @@ func (a *App) uninstallLocked(force, yes, removeUsers, purgeAudit bool) int { a.errorf("%s", a.P.M( "你正以临时账号 "+who+" 的身份运行卸载,而卸载会删除这个账号。请改用 root 或其他管理员登录后重试。", "you are running this as the temporary account "+who+", which the uninstall would delete. Log in as root or another administrator and retry.")) - return 1 + return false } } } @@ -395,18 +449,29 @@ func (a *App) uninstallLocked(force, yes, removeUsers, purgeAudit bool) int { fmt.Sprintf("a non-interactive run will not delete accounts. This host has %d managed by this tool, and the uninstall must remove them first; pass --remove-users to say so.", len(plan.accounts)))) a.warnf("%s", a.P.M("(不能只卸载命令、留下账号:它们的自动删除任务执行的就是这个命令,删掉命令它们就再也不会过期。)", "(uninstalling the command and keeping the accounts is not an option: their auto-delete tasks invoke this very command, so removing it means they never expire.)")) - return 1 + return false } } + return true +} - if !yes { - if a.prompt(a.P.M("确认卸载请输入 YES: ", "type YES to uninstall: ")) != "YES" { - a.warnf("%s", a.P.M("已取消", "cancelled")) - return 0 +func sameTeardownPlan(a, b teardownPlan) bool { + if a.stateDir != b.stateDir || a.auditPath != b.auditPath || a.auditKept != b.auditKept || + a.binaryPath != b.binaryPath || a.binaryBlocker != b.binaryBlocker || len(a.accounts) != len(b.accounts) { + return false + } + for i := range a.accounts { + left, right := a.accounts[i], b.accounts[i] + if left.name != right.name || left.exists != right.exists || len(left.witnesses) != len(right.witnesses) { + return false + } + for j := range left.witnesses { + if left.witnesses[j] != right.witnesses[j] { + return false + } } } - - return a.teardown(plan, force, purgeAudit) + return true } // teardown executes the plan. Order is the whole design: every step leaves the @@ -432,11 +497,33 @@ func (a *App) teardown(plan teardownPlan, force, purgeAudit bool) int { // revoke's protections (protected targets, the UID proof) are UNaffected by // --force and still refuse a real non-managed account — that is what the // survivor check below is for. + var failedRevokes []string for _, acc := range plan.accounts { - a.revokeLocked([]string{"--user", acc.name, "--yes", "--force", "--confirm-force", acc.name}) + ours, live, identityErr := a.completedAccountIdentity(acc.name) + if identityErr != nil { + a.errorf("%s %s: %v", a.P.M("无法重新验证活账号身份,拒绝自动删除:", + "cannot re-verify the live account identity; refusing automatic deletion:"), acc.name, identityErr) + failedRevokes = append(failedRevokes, acc.name) + continue + } + if live && !ours { + // Every filesystem artifact and v1 row is name-scoped: it proves that this + // tool once managed the name, not that today's account is the same account + // generation. A pending/legacy v2 row is not identity either, and the GECOS + // marker is user-writable. Bulk uninstall therefore requires a completed, + // generation-bound identity and the same passwd snapshot to match its UID and + // marker; an operator can inspect and revoke an unverifiable account explicitly. + a.errorf("%s %s", a.P.M("缺少当前世代绑定身份登记,拒绝自动删除活账号:", + "refusing to auto-delete a live account without a current generation-bound identity record:"), acc.name) + failedRevokes = append(failedRevokes, acc.name) + continue + } + if rc := a.revokeLocked([]string{"--user", acc.name, "--yes", "--force", "--confirm-force", acc.name}); rc != 0 { + failedRevokes = append(failedRevokes, acc.name) + } } - // Re-inventory from scratch, do not trust the plan or revoke's rc. Two things + // Re-inventory from scratch and also retain every revoke failure. Two things // the point-in-time plan and a user.Exists check both miss: // - an artifact revoke could not remove — a NOPASSWD grant wedged with // chattr +i, an EPERM, a path swapped for a non-empty dir. The account is @@ -448,7 +535,8 @@ func (a *App) teardown(plan teardownPlan, force, purgeAudit bool) int { // auto-revoke task points at the binary we are about to remove. // A witness that names ANYTHING — an account, a grant, an exception, a unit — // blocks the binary, exactly as a surviving account does. An unreadable witness - // blocks too: we cannot prove nothing is left. + // blocks too. So does a failed revoke even when no disk artifact remains: a + // systemd timer can remain active in manager memory after its unit file vanished. residual := a.teardownPlan(purgeAudit, force) if residual.inventoryErr != nil { a.errorf("%s: %v", a.P.M("无法确认账号与授权已全部清除,卸载中止(命令与状态已保留)", @@ -469,7 +557,7 @@ func (a *App) teardown(plan teardownPlan, force, purgeAudit bool) int { blocking = append(blocking, acc) } } - if len(blocking) > 0 { + if len(blocking) > 0 || len(failedRevokes) > 0 { residual.accounts = blocking a.errorf("%s", a.P.M( "以下项未能清除(账号、sudo 授权、sshd 例外或自动删除任务仍在):", @@ -481,34 +569,45 @@ func (a *App) teardown(plan teardownPlan, force, purgeAudit bool) int { } a.printf(" %s (%s)", acc.name, strings.Join(ws, " ")) } + if len(failedRevokes) > 0 { + a.errorf("%s %s", a.P.M("以下账号的撤销操作失败:", "revoke failed for these accounts:"), strings.Join(failedRevokes, " ")) + } a.errorf("%s", a.P.M( "已保留已安装的命令和状态目录,卸载中止。留着一个带 sudo 的授权却删掉唯一能清理它的命令,比不卸载更糟。请先手动处理,再重试。", "the installed command and the state directory were kept, and the uninstall stopped. Leaving a sudo grant behind while deleting the only thing that can clean it up is worse than not uninstalling. Deal with these by hand and retry.")) - a.audit("uninstall", "", "fail", "residual: "+strings.Join(residual.names(), " "), nil) + a.audit("uninstall", "", "fail", "residual: "+strings.Join(residual.names(), " ")+"; failed revokes: "+strings.Join(failedRevokes, " "), nil) return 1 } - // Nothing is left, so removal is safe. Purge the audit log only AFTER a final - // record of the teardown — the record precedes the purge, or "purge" would - // recreate the file it deleted and quietly mean "leave exactly one line". - if purgeAudit { - a.audit("uninstall", "", "ok", a.InstallPath, map[string]string{"accounts": fmt.Sprint(len(plan.accounts)), "purged": "yes"}) - if err := os.RemoveAll(a.AuditLogDir); err != nil { - a.warnf("%s: %v", a.P.M("删除审计日志失败", "removing the audit log failed"), err) - } else { - a.info(a.P.M("已删除审计日志:"+a.AuditLogDir, "removed the audit log: "+a.AuditLogDir)) + // Releases before the persistent-timer cleanup fix could leave an inert + // stamp after both the account and its unit files were gone. There is no + // username witness left to feed through revoke, so sweep this tool's timer + // namespaces only after the fresh inventory above proved that no managed task + // remains live. A failure keeps the command and state available for a retry. + if a.Scheduler != nil { + if err := a.Scheduler.CleanupTimerStamps(); err != nil { + a.errorf("%s: %v", a.P.M("无法清除旧版 systemd 定时器时间戳,卸载中止(命令与状态已保留)", + "cannot remove legacy systemd timer timestamps; the uninstall stopped (command and state kept)"), err) + a.audit("uninstall", "", "fail", "timer timestamp cleanup failed: "+err.Error(), nil) + return 1 } - a.Audit = nil // nothing may audit after this: a.Audit would recreate the dir } - stateGone := true + if a.Lifecycle != nil { + if err := a.Lifecycle.MarkUninstalled(); err != nil { + a.errorf("%s: %v", a.P.M("无法写入卸载状态标记;状态与命令均已保留,以阻止排队中的旧进程重新启用工具", + "cannot record the uninstall-state marker; state and command were kept so a queued older process cannot re-enable the tool"), err) + a.audit("uninstall", "", "fail", "uninstall marker write failed: "+err.Error(), nil) + return 1 + } + } if err := a.removeStateDir(force); err != nil { - stateGone = false - a.warnf("%s: %v", a.P.M("删除状态目录失败(账号已全部移除,命令仍将卸载)", - "removing the state directory failed (every account is gone, so the command is still uninstalled)"), err) - } else { - a.info(a.P.M("已删除状态目录:"+a.StateDir, "removed the state directory: "+a.StateDir)) + a.errorf("%s: %v", a.P.M("删除状态目录失败;工具已标记为卸载并保留命令,以便修复后重试", + "removing the state directory failed; the tool is marked uninstalled and the command was kept so uninstall can be retried after repair"), err) + a.audit("uninstall", "", "fail", "state directory cleanup failed: "+err.Error(), nil) + return 1 } + a.info(a.P.M("已删除状态目录:"+a.StateDir, "removed the state directory: "+a.StateDir)) // The binary is the last thing removed and the first that can still fail here // (a symlinked path without --force). The "ok" audit is written only once it is @@ -519,17 +618,27 @@ func (a *App) teardown(plan teardownPlan, force, purgeAudit bool) int { a.audit("uninstall", "", "fail", "binary removal failed: "+err.Error(), nil) return 1 } - if !purgeAudit { + if purgeAudit { + // Record the complete outcome before purging; logging after a successful + // purge would recreate the directory and turn "purge" into "keep one line". + a.audit("uninstall", "", "pending", "core teardown complete; audit purge pending", + map[string]string{"accounts": fmt.Sprint(len(plan.accounts)), "purged": "requested"}) + if err := a.removeAuditDir(force); err != nil { + a.errorf("%s: %v", a.P.M("删除审计日志失败;命令已卸载但清理未完整完成", + "removing the audit log failed; the command is uninstalled but cleanup is incomplete"), err) + // Keep the logger live. A failed recursive removal may be partial, and this + // failure is exactly the event the surviving/recreated log must retain. + a.audit("uninstall", "", "fail", "audit purge failed: "+err.Error(), nil) + return 1 + } + a.info(a.P.M("已删除审计日志:"+a.AuditLogDir, "removed the audit log: "+a.AuditLogDir)) + a.Audit = nil + } else { a.audit("uninstall", "", "ok", a.InstallPath, map[string]string{"accounts": fmt.Sprint(len(plan.accounts)), "purged": "no"}) } - if stateGone { - a.success(a.P.M("已卸载:临时账号、授权、自动删除任务、状态与命令均已移除。", - "uninstalled: the temporary accounts, their grants, their auto-delete tasks, the state and the command are gone.")) - } else { - a.success(a.P.M("已卸载命令,账号与授权已清除;但状态目录未能删除(见上),请手动清理 "+a.StateDir, - "uninstalled the command; accounts and grants are gone, but the state directory could not be removed (see above) — remove "+a.StateDir+" by hand.")) - } + a.success(a.P.M("已卸载:临时账号、授权、自动删除任务、状态与命令均已移除。", + "uninstalled: the temporary accounts, their grants, their auto-delete tasks, the state and the command are gone.")) return 0 } @@ -540,16 +649,130 @@ func (a *App) teardown(plan teardownPlan, force, purgeAudit bool) int { // directory is root-owned by construction, so anything else standing at that path // is not ours to delete recursively. func (a *App) removeStateDir(force bool) error { - if a.StateDir == "" { - return fmt.Errorf("no state directory configured") + if err := safeRecursiveRemovalPath(a.StateDir); err != nil { + return fmt.Errorf("unsafe state directory: %w", err) } if _, err := os.Lstat(a.StateDir); os.IsNotExist(err) { return nil } + if err := refuseMountedRemoval(a.StateDir); err != nil { + return err + } if !force { if err := fsutil.RootSafeDir(a.StateDir); err != nil { return fmt.Errorf("refusing to remove an unsafe state directory: %w", err) } } - return os.RemoveAll(a.StateDir) + return a.removeAll(a.StateDir) +} + +func (a *App) removeAuditDir(force bool) error { + if err := safeRecursiveRemovalPath(a.AuditLogDir); err != nil { + return fmt.Errorf("unsafe audit directory: %w", err) + } + if _, err := os.Lstat(a.AuditLogDir); os.IsNotExist(err) { + return nil + } + if err := refuseMountedRemoval(a.AuditLogDir); err != nil { + return err + } + if !force { + if err := fsutil.RootSafeDir(a.AuditLogDir); err != nil { + return fmt.Errorf("refusing to remove an unsafe audit directory: %w", err) + } + } + return a.removeAll(a.AuditLogDir) +} + +func safeRecursiveRemovalPath(path string) error { + if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) == string(filepath.Separator) { + return fmt.Errorf("refusing recursive removal of %q", path) + } + return nil +} + +// refuseMountedRemoval prevents os.RemoveAll from crossing into a bind mount or +// child filesystem. Mount ownership is independent of pathname ownership, and a +// dedicated tool directory can be used as a mountpoint for unrelated data. This +// check is intentionally not bypassed by --force. +func refuseMountedRemoval(path string) error { + f, err := os.Open("/proc/self/mountinfo") + if err != nil { + return fmt.Errorf("cannot inspect mount boundaries: %w", err) + } + defer f.Close() + return rejectMountsUnder(f, filepath.Clean(path)) +} + +func rejectMountsUnder(r io.Reader, root string) error { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 4096), 1024*1024) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 5 { + return fmt.Errorf("malformed mountinfo line") + } + mountpoint, err := unescapeMountInfoPath(fields[4]) + if err != nil { + return err + } + rel, err := filepath.Rel(root, filepath.Clean(mountpoint)) + if err != nil { + return fmt.Errorf("compare mountpoint %q: %w", mountpoint, err) + } + if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) { + return fmt.Errorf("refusing recursive removal across mountpoint %s", mountpoint) + } + } + if err := sc.Err(); err != nil { + return fmt.Errorf("read mount boundaries: %w", err) + } + return nil +} + +func unescapeMountInfoPath(value string) (string, error) { + var out strings.Builder + for i := 0; i < len(value); i++ { + if value[i] != '\\' { + out.WriteByte(value[i]) + continue + } + if i+3 >= len(value) { + return "", fmt.Errorf("malformed mountinfo escape in %q", value) + } + n, err := strconv.ParseUint(value[i+1:i+4], 8, 8) + if err != nil { + return "", fmt.Errorf("malformed mountinfo escape in %q", value) + } + out.WriteByte(byte(n)) + i += 3 + } + return out.String(), nil +} + +func (a *App) removeAll(path string) error { + var err error + if a.RemoveAll != nil { + err = a.RemoveAll(path) + } else { + err = os.RemoveAll(path) + } + if err != nil { + return err + } + if _, err := os.Lstat(path); !os.IsNotExist(err) { + if err == nil { + return fmt.Errorf("recursive removal reported success but %s still exists", path) + } + return fmt.Errorf("verify recursive removal of %s: %w", path, err) + } + parent, err := os.Open(filepath.Dir(path)) + if err != nil { + return fmt.Errorf("open recursive-removal parent: %w", err) + } + defer parent.Close() + if err := parent.Sync(); err != nil { + return &fsutil.DurabilityError{Operation: "recursive removal", Err: err} + } + return nil } diff --git a/internal/cli/uninstall_mount_root_test.go b/internal/cli/uninstall_mount_root_test.go new file mode 100644 index 0000000..8c7a144 --- /dev/null +++ b/internal/cli/uninstall_mount_root_test.go @@ -0,0 +1,54 @@ +//go:build integration + +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestRemoveStateDirRefusesLiveBindMount(t *testing.T) { + base := t.TempDir() + source := filepath.Join(base, "source") + target := filepath.Join(base, "state") + for _, path := range []string{source, target} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + } + sentinel := filepath.Join(source, "must-survive") + if err := os.WriteFile(sentinel, []byte("unrelated data"), 0o600); err != nil { + t.Fatal(err) + } + if err := unix.Mount(source, target, "", unix.MS_BIND, ""); err != nil { + t.Fatalf("create isolated bind mount: %v", err) + } + t.Cleanup(func() { + if err := unix.Unmount(target, unix.MNT_DETACH); err != nil { + t.Errorf("unmount test bind: %v", err) + } + }) + + removeCalled := false + a := &App{ + StateDir: target, + RemoveAll: func(string) error { + removeCalled = true + return nil + }, + } + err := a.removeStateDir(true) + if err == nil || !strings.Contains(err.Error(), "mountpoint") { + t.Fatalf("mounted state removal error = %v, want mountpoint refusal", err) + } + if removeCalled { + t.Fatal("mounted state reached recursive removal") + } + if got, err := os.ReadFile(sentinel); err != nil || string(got) != "unrelated data" { + t.Fatalf("mounted data changed: content=%q err=%v", got, err) + } +} diff --git a/internal/cli/uninstall_root_test.go b/internal/cli/uninstall_root_test.go index 3b4855b..965bc01 100644 --- a/internal/cli/uninstall_root_test.go +++ b/internal/cli/uninstall_root_test.go @@ -3,14 +3,18 @@ package cli import ( + "errors" "os" "os/exec" "path/filepath" "strings" "testing" + "time" + "github.com/xxvcc/linux-temp-admin/internal/audit" "github.com/xxvcc/linux-temp-admin/internal/buildinfo" "github.com/xxvcc/linux-temp-admin/internal/config" + "github.com/xxvcc/linux-temp-admin/internal/lifecycle" "github.com/xxvcc/linux-temp-admin/internal/registry" "github.com/xxvcc/linux-temp-admin/internal/schedule" "github.com/xxvcc/linux-temp-admin/internal/selfmanage" @@ -56,9 +60,9 @@ func uninstallApp(t *testing.T, in string, users ...string) (*App, *strings.Buil a.Selfmanage = selfmanage.New(a.InstallPath, 0) a.SSHD = nil // no sshd is touched by these tests a.Scheduler = &schedule.Scheduler{ - SystemdDir: mk("systemd", 0o755), InstallPath: a.InstallPath, + SystemdDir: mk("systemd", 0o755), SystemdTimerStateDir: mk("systemd-timer-state", 0o755), InstallPath: a.InstallPath, UnitPrefix: config.AutoRevokeUnitPrefix, LegacyUnitPrefixes: []string{config.V1AutoRevokeUnitPrefix}, - Now: a.Now, Sys: fakeSys{}, UnderUnit: func(string) bool { return false }, + Now: a.Now, Sys: fakeUninstallSystem{}, } // Re-point the registry inside the state dir, so removing the state dir is the // same act it is in production. @@ -77,6 +81,20 @@ func mustWrite(t *testing.T, path, content string) { } } +type failingCancelSystem struct{ fakeSys } + +func (failingCancelSystem) RemoveAtJobsFor(string) error { + return errors.New("injected schedule cleanup failure") +} + +// uninstallApp gives every systemd path a private temporary directory and every +// systemctl operation a no-op implementation. Report systemctl as available so +// Cancel can prove the fake timer stopped before deleting its files, matching the +// production safety contract without ever contacting the host's PID 1. +type fakeUninstallSystem struct{ fakeSys } + +func (fakeUninstallSystem) HasSystemctl() bool { return true } + // TestTeardownNeverReadsTheRealPaths is the guard for every other test in this // file. If uninstall.go ever reaches for config.StateDir/config.AuditLogDir // again instead of the App's fields, this suite starts deleting the real @@ -100,6 +118,67 @@ func TestTeardownNeverReadsTheRealPaths(t *testing.T) { } } +func TestTeardownRemovesLegacyPersistentTimerStamps(t *testing.T) { + a, _, _ := uninstallApp(t, "") + managed := filepath.Join(a.Scheduler.SystemdTimerStateDir, + "stamp-"+config.AutoRevokeUnitPrefix+"oldgone.timer") + legacy := filepath.Join(a.Scheduler.SystemdTimerStateDir, + "stamp-"+config.V1AutoRevokeUnitPrefix+"oldergone.timer") + unrelated := filepath.Join(a.Scheduler.SystemdTimerStateDir, "stamp-apt-daily.timer") + for _, path := range []string{managed, legacy, unrelated} { + mustWrite(t, path, "") + } + + plan := a.teardownPlan(false, false) + if rc := a.teardown(plan, false, false); rc != 0 { + t.Fatal("teardown failed while removing legacy timer timestamps") + } + for _, path := range []string{managed, legacy} { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Errorf("managed timer timestamp survived uninstall: %s", path) + } + } + if _, err := os.Lstat(unrelated); err != nil { + t.Fatalf("unrelated systemd timer timestamp was removed: %v", err) + } +} + +func TestTeardownKeepsCommandAndStateWhenTimerStampCleanupFails(t *testing.T) { + a, _, _ := uninstallApp(t, "") + blocked := filepath.Join(a.Scheduler.SystemdTimerStateDir, + "stamp-"+config.AutoRevokeUnitPrefix+"blocked.timer") + if err := os.Mkdir(blocked, 0o700); err != nil { + t.Fatal(err) + } + + plan := a.teardownPlan(false, false) + if rc := a.teardown(plan, false, false); rc != 1 { + t.Fatalf("teardown rc=%d, want timer timestamp cleanup failure", rc) + } + if _, err := os.Stat(a.InstallPath); err != nil { + t.Fatal("binary was removed after timer timestamp cleanup failed") + } + if _, err := os.Stat(a.StateDir); err != nil { + t.Fatal("state was removed after timer timestamp cleanup failed") + } +} + +func TestTeardownStopsWhenRevokeFailsWithoutDiskResidue(t *testing.T) { + a, _, _ := uninstallApp(t, "", "ltafailedrevoke1") + a.Scheduler.Sys = failingCancelSystem{} + plan := a.teardownPlan(false, false) + + if rc := a.teardown(plan, false, false); rc != 1 { + t.Fatalf("teardown rc=%d, want failure after revoke failed", rc) + } + if _, err := os.Stat(a.InstallPath); err != nil { + t.Fatal("binary was removed after a failed revoke with no disk artifact") + } + if _, err := os.Stat(a.StateDir); err != nil { + t.Fatal("state was removed after a failed revoke") + } +} + // TestInventoryUnionsEveryWitness: the registry is a file, and every way it goes // wrong drops accounts silently rather than announcing them. So an account named // by ANY witness has to appear — especially one named only by its sudo grant, @@ -218,8 +297,9 @@ func TestUninstallRemovesEverythingItNamed(t *testing.T) { unit := filepath.Join(a.Scheduler.SystemdDir, config.AutoRevokeUnitPrefix+"ltafull-a1.timer") mustWrite(t, unit, "[Timer]\n") - if rc := a.uninstall([]string{"--yes", "--remove-users"}); rc != 0 { - t.Fatalf("rc=%d, want 0 (stdout: %s)", rc, out.String()) + result := a.uninstallResult([]string{"--yes", "--remove-users"}) + if result.status != 0 || !result.applied { + t.Fatalf("result=%+v, want a successful applied uninstall (stdout: %s)", result, out.String()) } for _, p := range []string{a.InstallPath, a.StateDir, a.Sudoers.FilePath("ltafull-a1"), unit} { if _, err := os.Lstat(p); !os.IsNotExist(err) { @@ -253,6 +333,123 @@ func TestUninstallKeepsTheAuditLogUnlessAskedTwice(t *testing.T) { } } +func TestPurgeAuditFailureIsNonzeroAndKeepsLogger(t *testing.T) { + a, _, errb := uninstallApp(t, "") + logPath := filepath.Join(a.AuditLogDir, "audit.log") + a.Audit = &audit.Logger{ + Dir: a.AuditLogDir, File: logPath, Now: a.Now, + Actor: func() (string, int) { return "integration", 0 }, + } + logger := a.Audit + wantErr := errors.New("injected audit purge failure") + a.RemoveAll = func(path string) error { + if path == a.AuditLogDir { + return wantErr + } + return os.RemoveAll(path) + } + + if rc := a.uninstall([]string{"--yes", "--purge-audit"}); rc != 1 { + t.Fatalf("rc=%d, want 1 when audit purge fails", rc) + } + if a.Audit != logger { + t.Fatal("audit logger was disabled after a failed purge") + } + if _, err := os.Lstat(a.InstallPath); !os.IsNotExist(err) { + t.Fatalf("binary should already be removed when purge fails: %v", err) + } + if !strings.Contains(errb.String(), wantErr.Error()) { + t.Fatalf("purge failure was not reported: %q", errb.String()) + } + b, err := os.ReadFile(logPath) + if err != nil || !strings.Contains(string(b), "audit purge failed") { + t.Fatalf("live logger did not retain the purge failure: err=%v log=%q", err, b) + } +} + +func TestStateCleanupFailureIsNonzeroAndKeepsBinary(t *testing.T) { + a, _, errb := uninstallApp(t, "") + a.Lifecycle = lifecycle.New(filepath.Join(t.TempDir(), "lifecycle.lock")) + logPath := filepath.Join(a.AuditLogDir, "audit.log") + a.Audit = &audit.Logger{ + Dir: a.AuditLogDir, File: logPath, Now: a.Now, + Actor: func() (string, int) { return "integration", 0 }, + } + wantErr := errors.New("injected state cleanup failure") + a.RemoveAll = func(path string) error { + if path == a.StateDir { + return wantErr + } + return os.RemoveAll(path) + } + + if rc := a.uninstall([]string{"--yes"}); rc != 1 { + t.Fatalf("rc=%d, want 1 when state cleanup is incomplete", rc) + } + if _, err := os.Stat(a.InstallPath); err != nil { + t.Fatalf("binary must remain available to retry state cleanup: %v", err) + } + if _, err := os.Stat(a.StateDir); err != nil { + t.Fatalf("failed state directory should remain for manual recovery: %v", err) + } + if stopped, err := a.Lifecycle.IsUninstalled(); err != nil || !stopped { + t.Fatalf("state cleanup failure did not leave the fail-closed uninstall marker: stopped=%v err=%v", stopped, err) + } + if !strings.Contains(errb.String(), wantErr.Error()) { + t.Fatalf("state cleanup failure was not reported: %q", errb.String()) + } + b, err := os.ReadFile(logPath) + if err != nil || !strings.Contains(string(b), `"result":"fail"`) || !strings.Contains(string(b), "state directory cleanup failed") { + t.Fatalf("audit did not record the partial uninstall as failure: err=%v log=%q", err, b) + } +} + +func TestUninstallRefusesIfInventoryChangesAfterConfirmation(t *testing.T) { + a, _, errb := uninstallApp(t, "") + lock := lifecycle.New(filepath.Join(t.TempDir(), "lifecycle.lock")) + release, err := lock.Acquire() + if err != nil { + t.Fatal(err) + } + a.Lifecycle = lock + shown := newNotifyingBuffer("The uninstall will remove") + a.Out = shown + done := make(chan int, 1) + go func() { done <- a.uninstall([]string{"--yes"}) }() + select { + case <-shown.seen: + case <-time.After(2 * time.Second): + _ = release() + t.Fatal("uninstall did not show its pre-lock inventory") + } + + const name = "ltaplanchange1" + mustWrite(t, a.Sudoers.FilePath(name), name+" ALL=(ALL) NOPASSWD:ALL\n") + if err := release(); err != nil { + t.Fatal(err) + } + select { + case rc := <-done: + if rc != 1 { + t.Fatalf("uninstall rc=%d, want changed-inventory refusal", rc) + } + case <-time.After(3 * time.Second): + t.Fatal("uninstall did not finish after the lifecycle lock was released") + } + if _, err := os.Stat(a.InstallPath); err != nil { + t.Fatalf("binary changed after inventory mismatch: %v", err) + } + if _, err := os.Stat(a.StateDir); err != nil { + t.Fatalf("state changed after inventory mismatch: %v", err) + } + if _, err := os.Stat(a.Sudoers.FilePath(name)); err != nil { + t.Fatalf("new witness was touched despite inventory mismatch: %v", err) + } + if !strings.Contains(errb.String(), "inventory changed after confirmation") { + t.Fatalf("inventory mismatch was not explained: %q", errb.String()) + } +} + // TestUninstallKeepsTheBinaryWhenAnAccountSurvives is the invariant the whole // design rests on: never remove the binary while a managed account it could not // remove is still there. Leaving a sudo-capable account behind while deleting the @@ -321,16 +518,15 @@ func TestUninstallRefusesFromTheAccountItWouldDelete(t *testing.T) { } } -// TestUninstallRemovesAWitnessOnlyAccount is the case the whole "union of -// witnesses" idea exists for, and the one revoke's own guard turns away. An -// account can be real and live yet have no registry row — the row was lost, or it -// is a v1 account, or only its sudo grant still names it. teardown must delete it. +// TestUninstallRemovesAWitnessOnlyArtifact is the case the whole "union of +// witnesses" idea exists for: the registry row and account are gone, but a +// passwordless sudo grant still names them. The stale grant must be found and +// removed before the installed command can safely disappear. // -// Bare `revoke --user X --yes` REFUSES an unregistered account ("use --force"), -// so a teardown that reuses revoke without --force strands exactly the account -// the inventory worked hardest to find, and the uninstall can then never complete -// (the survivor blocks the binary, correctly, forever). -func TestUninstallRemovesAWitnessOnlyAccount(t *testing.T) { +// A live account with only a name-scoped artifact is deliberately a different +// case: the name may have been reused, so only a completed v2 UID record can +// authorize deleting that account (see TestUninstallDoesNotDeleteLiveAccountNamedOnlyByArtifact). +func TestUninstallRemovesAWitnessOnlyArtifact(t *testing.T) { const name = "ltawitness1" a, _, _ := uninstallApp(t, "") a.Users = user.New() @@ -338,18 +534,11 @@ func TestUninstallRemovesAWitnessOnlyAccount(t *testing.T) { rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } rm() t.Cleanup(rm) - if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", "linux-temp-admin temporary admin", name).CombinedOutput(); err != nil { - t.Fatalf("useradd: %v: %s", err, out) - } - // No registry row. The account exists only because a sudo grant names it — the - // witness an account cannot drop without dropping the root it is keeping. + // No registry row or account. Only the durable privilege artifact remains. mustWrite(t, a.Sudoers.FilePath(name), name+" ALL=(ALL) NOPASSWD:ALL\n") if rc := a.uninstall([]string{"--yes", "--remove-users"}); rc != 0 { - t.Fatalf("rc=%d, want 0: a witness-only account must be removable, not a permanent blocker", rc) - } - if mustUserExists(t, name) { - t.Error("the witness-only account survived the uninstall") + t.Fatalf("rc=%d, want 0: a witness-only artifact must be removable, not a permanent blocker", rc) } if _, err := os.Stat(a.Sudoers.FilePath(name)); !os.IsNotExist(err) { t.Error("its NOPASSWD grant survived") @@ -399,6 +588,42 @@ func TestUninstallRefusesMalformedV1Registry(t *testing.T) { } } +func TestV1RegistryRefusesSymlinkAndOversizedInput(t *testing.T) { + t.Run("symlink", func(t *testing.T) { + a, _, _ := uninstallApp(t, "") + target := filepath.Join(t.TempDir(), "registry") + if err := os.WriteFile(target, []byte("xxvcc-a1\tdata\n"), 0o600); err != nil { + t.Fatal(err) + } + path := filepath.Join(a.StateDir, filepath.Base(config.V1RegistryFile)) + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if _, err := a.v1RegistryUsers(); err == nil { + t.Fatal("symlinked v1 registry was accepted") + } + }) + + t.Run("oversized", func(t *testing.T) { + a, _, _ := uninstallApp(t, "") + path := filepath.Join(a.StateDir, filepath.Base(config.V1RegistryFile)) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(maxV1RegistryBytes + 1); err != nil { + f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if _, err := a.v1RegistryUsers(); err == nil || !strings.Contains(err.Error(), "byte limit") { + t.Fatalf("oversized v1 registry error = %v, want bounded-read refusal", err) + } + }) +} + // TestUninstallBlocksOnAnUnremovableGrant is HIGH #2. The survivor check used to // key only on user.Exists, but sudoers.Remove documents that it reports failure // precisely so the teardown won't call itself done while a NOPASSWD:ALL file it @@ -488,6 +713,27 @@ func TestUninstallRefusesEarlyOnAnUnremovableBinary(t *testing.T) { } } +func TestUninstallForceRefusesDirectoryBeforeTeardown(t *testing.T) { + a, _, errb := uninstallApp(t, "") + if err := os.Remove(a.InstallPath); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(a.InstallPath, 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(a.InstallPath, "unrelated"), "keep\n") + + if rc := a.uninstall([]string{"--yes", "--force"}); rc != 1 { + t.Fatalf("rc=%d, want early refusal for a directory at the install path", rc) + } + if _, err := os.Stat(a.StateDir); err != nil { + t.Fatal("state directory was removed before the install-path directory refusal") + } + if !strings.Contains(errb.String(), "is a directory") { + t.Fatalf("stderr did not explain the directory blocker: %q", errb.String()) + } +} + // TestCompactSweepsOrphanedUnits is HIGH #5. Scheduler.Orphans mirrors the // sudoers/sshd sweeps, but until now nothing called it: doctor reported an // orphaned auto-revoke unit as clean and cleanup-expired --compact never removed @@ -516,6 +762,26 @@ func TestCompactSweepsOrphanedUnits(t *testing.T) { } } +func TestCompactRetainsRegistryWhenOrphanScanFails(t *testing.T) { + const name = "ltacompactwitness" + a, _, errb := uninstallApp(t, "", name) + blocked := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + a.Sudoers.Dir = blocked + + if rc := a.compact(); rc != 1 { + t.Fatalf("compact rc=%d, want 1 when an orphan inventory is unreadable", rc) + } + if found, err := a.Registry.Contains(name); err != nil || !found { + t.Fatalf("registry witness was compacted after a failed scan: found=%v err=%v", found, err) + } + if !strings.Contains(errb.String(), "registry was not compacted") { + t.Fatalf("compact did not explain that it retained recovery evidence: %q", errb.String()) + } +} + // TestCompactSweepsAGrantWhoseNameARealAccountReused is the MEDIUM name-reuse // detection gap. The orphan sweeps used a bare user.Exists, so a managed grant // whose temp account is gone but whose NAME a real, unmanaged account later took @@ -530,9 +796,9 @@ func TestCompactSweepsAGrantWhoseNameARealAccountReused(t *testing.T) { rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } rm() t.Cleanup(rm) - // A REAL, unmanaged account that happens to carry a temp-shaped name — no - // managed GECOS, no registry row vouching for it. - if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", "Real Person", name).CombinedOutput(); err != nil { + // A real replacement can set its own GECOS full-name field. Without a current + // registry identity, even an exact managed marker must not hide the stale grant. + if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGECOS, name).CombinedOutput(); err != nil { t.Fatalf("useradd: %v: %s", err, out) } grant := a.Sudoers.FilePath(name) @@ -548,6 +814,46 @@ func TestCompactSweepsAGrantWhoseNameARealAccountReused(t *testing.T) { } } +func TestCompactPreservesArtifactsForLiveLegacyIdentity(t *testing.T) { + const name = "ltalegacycompact1" + a, out, errb := uninstallApp(t, "") + a.Users = user.New() + rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + rm() + t.Cleanup(rm) + if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGECOS, name).CombinedOutput(); err != nil { + t.Fatalf("useradd: %v: %s", err, out) + } + pw, ok := mustUserLookup(t, name) + if !ok { + t.Fatal("legacy account was not found") + } + if err := a.Registry.Record(registry.Record{User: name, Host: "203.0.113.5", Port: 22, UID: pw.UID}); err != nil { + t.Fatal(err) + } + grant := a.Sudoers.FilePath(name) + mustWrite(t, grant, name+" ALL=(ALL) NOPASSWD:ALL\n") + if rc := a.status([]string{"--user", name}); rc != 0 { + t.Fatalf("status rc=%d", rc) + } + if !strings.Contains(out.String(), "managed=false identity=legacy-unverified") { + t.Fatalf("status hid the weak legacy identity: %q", out.String()) + } + _ = a.doctor(nil) + if !strings.Contains(errb.String(), "legacy fixed identity marker") { + t.Fatalf("doctor hid the weak legacy identity: %q", errb.String()) + } + if rc := a.compact(); rc != 0 { + t.Fatalf("compact rc=%d, want legacy account preserved", rc) + } + if _, err := os.Stat(grant); err != nil { + t.Fatalf("compact removed a live legacy account's grant: %v", err) + } + if found, err := a.Registry.Contains(name); err != nil || !found { + t.Fatalf("compact removed a live legacy registry row: found=%v err=%v", found, err) + } +} + // TestDoctorReportsAnAutoDeleteAccountWithNoTaskLeft covers the MEDIUM tidiness // gap: an account that asked to auto-delete, still exists, and whose unit was // removed out of band will never be deleted. chage is only a later day-granular @@ -573,7 +879,7 @@ func TestDoctorReportsAutoDeleteAccountsWithNoTaskLeft(t *testing.T) { if rc := a.doctor(nil); rc != 1 { t.Errorf("doctor rc=%d, want 1", rc) } - if !strings.Contains(errb.String(), "no task left") { + if !strings.Contains(errb.String(), "no valid task left") { t.Errorf("doctor did not surface the taskless auto-delete account: %q", errb.String()) } for _, name := range []string{systemdName, atName} { @@ -624,7 +930,9 @@ func TestDoctorShowsVersions(t *testing.T) { t.Run("installed version mismatch is warned", func(t *testing.T) { a, _, errb := uninstallApp(t, "") writeStub(t, a.InstallPath, "0.0.1-stale") - a.doctor(nil) + if rc := a.doctor(nil); rc != 1 { + t.Errorf("doctor rc=%d, want 1 for a version mismatch", rc) + } if !strings.Contains(errb.String(), "differs from the running") { t.Errorf("doctor did not flag the version mismatch: %q", errb.String()) } @@ -633,13 +941,61 @@ func TestDoctorShowsVersions(t *testing.T) { t.Run("no installed command is warned", func(t *testing.T) { a, _, errb := uninstallApp(t, "") writeStub(t, a.InstallPath, "") // remove it - a.doctor(nil) + if rc := a.doctor(nil); rc != 1 { + t.Errorf("doctor rc=%d, want 1 for a missing installed command", rc) + } if !strings.Contains(errb.String(), "not installed") { t.Errorf("doctor did not report the missing installed command: %q", errb.String()) } }) } +func TestDoctorReportsUntrustedRegistryIdentities(t *testing.T) { + const ( + pendingName = "ltadocpending" + markerName = "ltadocmarker" + legacyName = "ltadoclegacy" + ) + a, _, errb := uninstallApp(t, "") + a.Users = user.New() + + newRealAccount(t, a, pendingName) + rec, _, err := a.Registry.Lookup(pendingName) + if err != nil { + t.Fatal(err) + } + rec.Pending = true + if err := a.Registry.Record(rec); err != nil { + t.Fatal(err) + } + + newRealAccount(t, a, markerName) + if out, err := exec.Command("usermod", "-c", "Real Person", markerName).CombinedOutput(); err != nil { + t.Fatalf("usermod: %v: %s", err, out) + } + + newRealAccount(t, a, legacyName) + legacy, _, err := a.Registry.Lookup(legacyName) + if err != nil { + t.Fatal(err) + } + legacy.UID = 0 + legacy.IdentityBound = false + if err := a.Registry.Record(legacy); err != nil { + t.Fatal(err) + } + + if rc := a.doctor(nil); rc != 1 { + t.Fatalf("doctor rc=%d, want 1 for untrusted registry identities", rc) + } + got := errb.String() + for _, want := range []string{pendingName, markerName, legacyName, "pending creation", "managed identity marker", "no trusted UID"} { + if !strings.Contains(got, want) { + t.Errorf("doctor output missing %q: %q", want, got) + } + } +} + func buildinfoVersion() string { return buildinfo.Version } // TestUninstallCompletesWithAStaleV1RegistryRow is the regression the re-inventory @@ -666,3 +1022,164 @@ func TestUninstallCompletesWithAStaleV1RegistryRow(t *testing.T) { t.Error("the state dir (with the stale v1 row) should have been removed") } } + +func TestUninstallDoesNotDeleteLiveAccountNamedOnlyByStaleV1Row(t *testing.T) { + const name = "ltav1reuse1" + a, _, errb := uninstallApp(t, "") + a.Users = user.New() + rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + rm() + t.Cleanup(rm) + if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGECOS, name).CombinedOutput(); err != nil { + t.Fatalf("useradd: %v: %s", err, out) + } + mustWrite(t, filepath.Join(a.StateDir, filepath.Base(config.V1RegistryFile)), + name+"\t2020-01-01\tstale\n") + + if rc := a.uninstall([]string{"--yes", "--remove-users"}); rc != 1 { + t.Fatalf("uninstall rc=%d, want refusal for an identity-unverified live v1 name", rc) + } + if !mustUserExists(t, name) { + t.Fatal("live account was deleted solely because a stale v1 row reused its name") + } + if _, err := os.Stat(a.InstallPath); err != nil { + t.Fatal("binary was removed while the identity-unverified account remained") + } + if !strings.Contains(errb.String(), "without a current generation-bound identity record") { + t.Fatalf("refusal did not explain the v1 identity gap: %q", errb.String()) + } +} + +func TestUninstallDoesNotBulkDeleteLegacyV2Identity(t *testing.T) { + const ( + name = "ltalegacyuninst1" + generation = "cccccccccccccccccccccccccccccccc" + ) + a, _, errb := uninstallApp(t, "") + a.Users = user.New() + rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + rm() + t.Cleanup(rm) + if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGECOS, name).CombinedOutput(); err != nil { + t.Fatalf("useradd: %v: %s", err, out) + } + pw, ok := mustUserLookup(t, name) + if !ok { + t.Fatal("legacy account was not found") + } + if err := a.Registry.Record(registry.Record{ + User: name, Host: "203.0.113.5", Port: 22, UID: pw.UID, Generation: generation, + }); err != nil { + t.Fatal(err) + } + if rc := a.uninstall([]string{"--yes", "--remove-users"}); rc != 1 { + t.Fatalf("uninstall rc=%d, want legacy identity refusal", rc) + } + if !mustUserExists(t, name) { + t.Fatal("bulk uninstall deleted a legacy fixed-marker account") + } + if _, err := os.Stat(a.InstallPath); err != nil { + t.Fatal("binary was removed while a legacy account required manual recovery") + } + if !strings.Contains(errb.String(), "identity") { + t.Fatalf("uninstall did not explain the legacy identity blocker: %q", errb.String()) + } +} + +func TestUninstallDoesNotDeleteLiveAccountNamedOnlyByArtifact(t *testing.T) { + const name = "ltaartifact1" + a, _, errb := uninstallApp(t, "") + a.Users = user.New() + rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + rm() + t.Cleanup(rm) + if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGECOS, name).CombinedOutput(); err != nil { + t.Fatalf("useradd: %v: %s", err, out) + } + mustWrite(t, a.Sudoers.FilePath(name), name+" ALL=(ALL) NOPASSWD:ALL\n") + + if rc := a.uninstall([]string{"--yes", "--remove-users"}); rc != 1 { + t.Fatalf("uninstall rc=%d, want refusal for an artifact-only live name", rc) + } + if !mustUserExists(t, name) { + t.Fatal("live account was deleted solely because an old artifact reused its name") + } + if _, err := os.Stat(a.InstallPath); err != nil { + t.Fatal("binary was removed while the identity-unverified account remained") + } + if !strings.Contains(errb.String(), "without a current generation-bound identity record") { + t.Fatalf("refusal did not explain the missing identity record: %q", errb.String()) + } +} + +func TestRevokeDoesNotDeleteLiveAccountNamedByPendingIntent(t *testing.T) { + const name = "ltapending1" + a, _, errb := uninstallApp(t, "") + a.Users = user.New() + rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", name).Run() } + rm() + t.Cleanup(rm) + if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGECOS, name).CombinedOutput(); err != nil { + t.Fatalf("useradd: %v: %s", err, out) + } + if err := a.Registry.Record(registry.Record{User: name, Port: 22, Pending: true}); err != nil { + t.Fatal(err) + } + + if rc := a.revoke([]string{"--user", name, "--yes"}); rc != 1 { + t.Fatalf("revoke rc=%d, want refusal for pending identity", rc) + } + if !mustUserExists(t, name) { + t.Fatal("pending creation intent authorized deletion of a live account") + } + if _, found, err := a.Registry.Lookup(name); err != nil || !found { + t.Fatalf("pending recovery witness was not retained: found=%v err=%v", found, err) + } + if !strings.Contains(errb.String(), "pending creation intent") { + t.Fatalf("refusal did not explain pending identity: %q", errb.String()) + } +} + +func TestUninstallRequiresCompletedMatchingV2IdentityForLiveAccounts(t *testing.T) { + for _, tc := range []struct { + name string + pending bool + uid func(actual int) int + }{ + {name: "ltalowid1", uid: func(int) int { return 0 }}, + {name: "ltapending2", pending: true, uid: func(actual int) int { return actual }}, + } { + t.Run(tc.name, func(t *testing.T) { + a, _, errb := uninstallApp(t, "") + a.Users = user.New() + rm := func() { _ = exec.Command("userdel", "-r", "-f", "--", tc.name).Run() } + rm() + t.Cleanup(rm) + if out, err := exec.Command("useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGECOS, tc.name).CombinedOutput(); err != nil { + t.Fatalf("useradd: %v: %s", err, out) + } + pw, exists := mustUserLookup(t, tc.name) + if !exists { + t.Fatal("fixture account was not found") + } + if err := a.Registry.Record(registry.Record{ + User: tc.name, Port: 22, UID: tc.uid(pw.UID), Pending: tc.pending, + }); err != nil { + t.Fatal(err) + } + + if rc := a.uninstall([]string{"--yes", "--remove-users"}); rc != 1 { + t.Fatalf("uninstall rc=%d, want identity refusal", rc) + } + if !mustUserExists(t, tc.name) { + t.Fatal("incomplete v2 row authorized deletion of a live account") + } + if _, err := os.Stat(a.InstallPath); err != nil { + t.Fatal("binary was removed while an identity-unverified account remained") + } + if !strings.Contains(errb.String(), "without a current generation-bound identity record") { + t.Fatalf("identity refusal was not explained: %q", errb.String()) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index c1bb9bf..e815bed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,8 +19,18 @@ const ( // ManagedTag marks tool-managed accounts. ManagedTag = "linux-temp-admin" // ManagedGECOS is the exact GECOS an invite sets; user.IsManaged requires this - // full string, not a bare ManagedTag substring. + // full string for legacy accounts, not a bare ManagedTag substring. ManagedGECOS = ManagedTag + " temporary admin" + // ManagedGenerationGECOSPrefix begins the generation-bound marker written for + // every newly completed account. The 128-bit generation follows immediately. + ManagedGenerationGECOSPrefix = ManagedGECOS + " generation=" + // PendingGECOS is used only between useradd and durable UID registration. It + // deliberately does not match ManagedGECOS, so an older binary that ignores a + // newer registry Pending field still treats the incomplete account as protected. + PendingGECOS = ManagedTag + " pending account" + // PendingGenerationGECOSPrefix binds even the incomplete passwd entry to the + // creation intent that preceded it. + PendingGenerationGECOSPrefix = PendingGECOS + " generation=" // --- owned paths and namespaces --- @@ -62,7 +72,7 @@ const ( // expected to hand-edit or ship in a config-management repo. PrefsFile = RegistryDir + "/prefs" // RegistrySchema is written as the registry header's version marker. - RegistrySchema = 2 + RegistrySchema = 3 // AuditLogDir holds the append-only operation audit log (root:root, 0700). AuditLogDir = "/var/log/" + ManagedTag @@ -76,14 +86,21 @@ const ( LifecycleLockFile = "/run/" + ManagedTag + ".lock" // SystemdDir holds generated auto-revoke units. SystemdDir = "/etc/systemd/system" + // SystemdTimerStateDir holds Persistent=true timer timestamps. systemd does + // not remove these when a timer unit is disabled or deleted. + SystemdTimerStateDir = "/var/lib/systemd/timers" // AutoRevokeUnitPrefix namespaces generated systemd units. The "-v2-" infix is // load-bearing: it is baked into the unit filenames already written on // deployed hosts, so changing it would orphan their auto-revoke timers. AutoRevokeUnitPrefix = ManagedTag + "-v2-revoke-" - // ReleaseBaseURL is where signed release binaries are published; the upgrade - // binary is ReleaseBaseURL + BinaryAssetPrefix + GOARCH and its detached - // signature is that URL + ".sig". - ReleaseBaseURL = "https://github.com/xxvcc/linux-temp-admin/releases/latest/download/" - BinaryAssetPrefix = ManagedTag + "-linux-" + // ReleaseMirrorBaseURL is the official mirror used for normal installation and + // upgrades. latest.json selects one immutable version directory below it. + ReleaseMirrorBaseURL = "https://dl.ll.cd/linux-temp-admin" + ReleaseMirrorManifestURL = ReleaseMirrorBaseURL + "/latest.json" + // GitHub remains a transport-only fallback. A valid mirror manifest pins the + // fallback to the same tag instead of consulting a potentially newer Latest. + GitHubReleaseRoot = "https://github.com/xxvcc/linux-temp-admin/releases" + GitHubLatestReleaseBaseURL = GitHubReleaseRoot + "/latest/download" + BinaryAssetPrefix = ManagedTag + "-linux-" ) diff --git a/internal/executil/executil.go b/internal/executil/executil.go new file mode 100644 index 0000000..9a88e56 --- /dev/null +++ b/internal/executil/executil.go @@ -0,0 +1,156 @@ +// Package executil runs bounded local helper commands. Privileged workflows must +// not let a stuck helper, an inherited pipe held by a descendant, or unbounded +// diagnostic output hold the global lifecycle lock forever. +package executil + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sync" + "syscall" + "time" +) + +const ( + DefaultTimeout = 30 * time.Second + DefaultMaxOutput = int64(1 << 20) + defaultWaitDelay = time.Second +) + +// Options controls one helper invocation. Privileged helpers receive a fixed, +// minimal environment rather than caller-controlled values preserved by +// `sudo -E`; ExtraEnv adds purpose-specific values such as a stable locale. +type Options struct { + Context context.Context + Timeout time.Duration + MaxOutput int64 + Stdin io.Reader + ExtraEnv []string +} + +var ErrOutputLimit = errors.New("command output limit exceeded") + +// CombinedOutput mirrors exec.Cmd.CombinedOutput with bounded resources. +func CombinedOutput(name string, args []string, opts Options) ([]byte, error) { + return run(name, args, opts, true) +} + +// Output mirrors exec.Cmd.Output. Stderr is drained under the same independent +// bound so a noisy failing helper cannot block even though only stdout is returned. +func Output(name string, args []string, opts Options) ([]byte, error) { + return run(name, args, opts, false) +} + +// Run executes a command while still draining and bounding both output streams. +func Run(name string, args []string, opts Options) error { + _, err := run(name, args, opts, true) + return err +} + +func run(name string, args []string, opts Options, combined bool) ([]byte, error) { + timeout := opts.Timeout + if timeout <= 0 { + timeout = DefaultTimeout + } + maxOutput := opts.MaxOutput + if maxOutput <= 0 { + maxOutput = DefaultMaxOutput + } + parent := opts.Context + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return nil + } + return err + } + cmd.WaitDelay = defaultWaitDelay + cmd.Stdin = opts.Stdin + cmd.Env = append(helperEnvironment(), opts.ExtraEnv...) + + stdout := &boundedBuffer{max: maxOutput, cancel: cancel} + stderr := stdout + if !combined { + stderr = &boundedBuffer{max: maxOutput, cancel: cancel} + } + cmd.Stdout = stdout + cmd.Stderr = stderr + err := cmd.Run() + if stdout.exceededLimit() || stderr.exceededLimit() { + return stdout.bytes(), fmt.Errorf("%w (%d bytes)", ErrOutputLimit, maxOutput) + } + if ctx.Err() == context.DeadlineExceeded { + return stdout.bytes(), fmt.Errorf("command timed out after %s: %w", timeout, context.DeadlineExceeded) + } + return stdout.bytes(), err +} + +func helperEnvironment() []string { + return []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=/root", + "USER=root", + "LOGNAME=root", + "SHELL=/bin/sh", + "TERM=dumb", + } +} + +type boundedBuffer struct { + mu sync.Mutex + buf bytes.Buffer + max int64 + exceeded bool + cancel context.CancelFunc +} + +func (b *boundedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + remaining := b.max - int64(b.buf.Len()) + if remaining > 0 { + keep := int64(len(p)) + if keep > remaining { + keep = remaining + } + _, _ = b.buf.Write(p[:keep]) + } + if int64(len(p)) > remaining { + b.exceeded = true + } + exceeded := b.exceeded + b.mu.Unlock() + if exceeded && b.cancel != nil { + b.cancel() + } + // Report the full write as consumed. Cancellation kills the process group; + // returning a short write alone can leave a child that ignores SIGPIPE alive. + return len(p), nil +} + +func (b *boundedBuffer) exceededLimit() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.exceeded +} + +func (b *boundedBuffer) bytes() []byte { + b.mu.Lock() + defer b.mu.Unlock() + return append([]byte(nil), b.buf.Bytes()...) +} diff --git a/internal/executil/executil_test.go b/internal/executil/executil_test.go new file mode 100644 index 0000000..a0a93b1 --- /dev/null +++ b/internal/executil/executil_test.go @@ -0,0 +1,65 @@ +package executil + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func helper(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "helper") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func TestCombinedOutputBoundsTimeOutputAndDescendants(t *testing.T) { + t.Run("timeout", func(t *testing.T) { + start := time.Now() + _, err := CombinedOutput(helper(t, "sleep 30 & wait"), nil, Options{Timeout: 50 * time.Millisecond, MaxOutput: 32}) + if !errors.Is(err, contextDeadlineExceeded()) || time.Since(start) > 2*time.Second { + t.Fatalf("timeout error=%v elapsed=%s", err, time.Since(start)) + } + }) + t.Run("output", func(t *testing.T) { + out, err := CombinedOutput(helper(t, "while :; do printf 0123456789abcdef; done"), nil, Options{Timeout: time.Second, MaxOutput: 32}) + if !errors.Is(err, ErrOutputLimit) || len(out) != 32 { + t.Fatalf("output len=%d err=%v, want 32-byte limit", len(out), err) + } + }) + t.Run("environment and stdin", func(t *testing.T) { + t.Setenv("SYSTEMD_UNIT_PATH", "/tmp/attacker-units") + t.Setenv("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/tmp/attacker-bus") + t.Setenv("BASH_ENV", "/tmp/attacker-shell-init") + out, err := Output(helper(t, "read value; printf '%s:%s' \"$LC_ALL\" \"$value\""), nil, Options{ + Timeout: time.Second, MaxOutput: 64, Stdin: strings.NewReader("input\n"), ExtraEnv: []string{"LC_ALL=C"}, + }) + if err != nil || string(out) != "C:input" { + t.Fatalf("output=%q err=%v", out, err) + } + envOut, err := Output(helper(t, `printf '%s:%s:%s:%s' "${SYSTEMD_UNIT_PATH-unset}" "${DBUS_SYSTEM_BUS_ADDRESS-unset}" "${BASH_ENV-unset}" "$HOME"`), nil, Options{}) + if err != nil || string(envOut) != "unset:unset:unset:/root" { + t.Fatalf("privileged helper inherited unsafe environment: output=%q err=%v", envOut, err) + } + }) + t.Run("parent context", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + start := time.Now() + _, err := CombinedOutput(helper(t, "sleep 30"), nil, Options{ + Context: ctx, Timeout: time.Minute, MaxOutput: 32, + }) + if !errors.Is(err, context.Canceled) || time.Since(start) > time.Second { + t.Fatalf("cancelled-parent error=%v elapsed=%s", err, time.Since(start)) + } + }) +} + +// Kept behind a helper so the test does not need to compare error strings. +func contextDeadlineExceeded() error { return context.DeadlineExceeded } diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index 7f7d68d..68593de 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -5,15 +5,40 @@ package fsutil import ( + "crypto/rand" + "encoding/hex" + "errors" "fmt" "os" "path/filepath" + "strings" "syscall" + + "github.com/xxvcc/linux-temp-admin/internal/validate" + "golang.org/x/sys/unix" ) // notWritableByGroupOther is the mask for group/other write bits. const notWritableByGroupOther = 0o022 +// DurabilityError means a filesystem mutation became visible but syncing the +// parent directory failed. Callers must not retry as though nothing happened: +// they need to inspect, roll back, or otherwise reconcile the committed change. +type DurabilityError struct { + Operation string + Err error +} + +func (e *DurabilityError) Error() string { + op := e.Operation + if op == "" { + op = "filesystem mutation" + } + return fmt.Sprintf("%s committed but parent directory sync failed: %v", op, e.Err) +} + +func (e *DurabilityError) Unwrap() error { return e.Err } + // RootSafeDir verifies path is a real directory (not a symlink), owned by root, // and not group/world writable. func RootSafeDir(path string) error { @@ -31,7 +56,9 @@ func RootSafeDir(path string) error { } // RootSafeFile verifies path is a regular file (not a symlink), owned by root, -// and not group/world writable. +// not group/world writable, and carries no set-id/sticky special bits. A managed +// executable must never become a privilege-escalation entry point merely because +// its content and owner otherwise look safe. func RootSafeFile(path string) error { fi, err := os.Lstat(path) if err != nil { @@ -43,6 +70,9 @@ func RootSafeFile(path string) error { if !fi.Mode().IsRegular() { return fmt.Errorf("%s is not a regular file", path) } + if special := fi.Mode() & (os.ModeSetuid | os.ModeSetgid | os.ModeSticky); special != 0 { + return fmt.Errorf("%s has unsafe special mode bits (%v)", path, special) + } return checkRootOwnedNotWritable(path, fi) } @@ -51,8 +81,8 @@ func checkRootOwnedNotWritable(path string, fi os.FileInfo) error { if !ok { return fmt.Errorf("cannot stat %s", path) } - if st.Uid != 0 { - return fmt.Errorf("%s is not owned by root (uid %d)", path, st.Uid) + if st.Uid != 0 || st.Gid != 0 { + return fmt.Errorf("%s is not owned by root:root (owner %d:%d)", path, st.Uid, st.Gid) } if fi.Mode().Perm()¬WritableByGroupOther != 0 { return fmt.Errorf("%s is group/world writable (mode %o)", path, fi.Mode().Perm()) @@ -60,104 +90,289 @@ func checkRootOwnedNotWritable(path string, fi os.FileInfo) error { return nil } -// EnsureDir creates path (and parents) if needed and sets its owner/mode, all -// while refusing to follow a symlink at the leaf. Safe to call repeatedly. +// EnsureDir creates path (and parents) component by component, refusing symlinks +// anywhere in the path. A newly created directory is synced before its parent +// directory entry, and the leaf is synced after ownership/mode repair. func EnsureDir(path string, mode os.FileMode, uid, gid int) error { - if fi, err := os.Lstat(path); err == nil { - if fi.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("%s is a symlink; refusing", path) - } - if !fi.IsDir() { - return fmt.Errorf("%s exists and is not a directory", path) + if path == "" { + return fmt.Errorf("empty directory path") + } + if !validate.KernelID(uid) || !validate.KernelID(gid) { + return fmt.Errorf("invalid directory owner %d:%d", uid, gid) + } + clean := filepath.Clean(path) + if clean == "." || clean == string(filepath.Separator) { + return fmt.Errorf("refusing to change broad directory %q", path) + } + parts := strings.Split(strings.TrimPrefix(clean, string(filepath.Separator)), string(filepath.Separator)) + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return fmt.Errorf("unsafe directory component %q in %s", part, path) } - } else if !os.IsNotExist(err) { - return err } - if err := os.MkdirAll(path, mode); err != nil { - return err + + start := "." + if filepath.IsAbs(clean) { + start = string(filepath.Separator) } - // Reopen the leaf with O_NOFOLLOW so a swapped-in symlink can't redirect the - // chown/chmod; operate on the fd. - f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0) + rootFD, err := unix.Open(start, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if err != nil { - return fmt.Errorf("reopen dir %s: %w", path, err) + return fmt.Errorf("open directory traversal root %s: %w", start, err) } - defer f.Close() - if err := f.Chown(uid, gid); err != nil { - return err + parent := os.NewFile(uintptr(rootFD), start) + defer func() { _ = parent.Close() }() + + for i, part := range parts { + created := false + childFD, openErr := unix.Openat(int(parent.Fd()), part, + unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if openErr == unix.ENOENT { + if mkdirErr := unix.Mkdirat(int(parent.Fd()), part, uint32(mode.Perm())); mkdirErr == nil { + created = true + } else if mkdirErr != unix.EEXIST { + return fmt.Errorf("create directory component %s: %w", part, mkdirErr) + } + childFD, openErr = unix.Openat(int(parent.Fd()), part, + unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + } + if openErr != nil { + return fmt.Errorf("open directory component %s without following links: %w", part, openErr) + } + child := os.NewFile(uintptr(childFD), filepath.Join(parent.Name(), part)) + last := i == len(parts)-1 + if last { + if err := child.Chown(uid, gid); err != nil { + _ = child.Close() + return fmt.Errorf("set directory owner for %s: %w", path, err) + } + if err := child.Chmod(mode); err != nil { + _ = child.Close() + return fmt.Errorf("set directory mode for %s: %w", path, err) + } + } + if created || last { + if err := syncDirectory(child); err != nil { + _ = child.Close() + return &DurabilityError{Operation: "directory metadata update", Err: err} + } + } + if created { + if err := syncDirectory(parent); err != nil { + _ = child.Close() + return &DurabilityError{Operation: "mkdir", Err: err} + } + } + if err := parent.Close(); err != nil { + _ = child.Close() + return fmt.Errorf("close directory component: %w", err) + } + parent = child } - return f.Chmod(mode) + return nil } // WriteRootFile atomically writes a root:root file at path with mode. The parent // directory must be root-safe and the target, if it exists, must be a regular // non-symlink file. func WriteRootFile(path string, content []byte, mode os.FileMode) error { - if err := RootSafeDir(filepath.Dir(path)); err != nil { + dirPath := filepath.Dir(path) + dir, err := os.OpenFile(dirPath, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0) + if err != nil { + return fmt.Errorf("open target directory %s: %w", dirPath, err) + } + defer dir.Close() + if afterRootDirOpen != nil { + afterRootDirOpen() + } + if err := rootSafeDirectoryFD(dirPath, dir); err != nil { return fmt.Errorf("unsafe target directory: %w", err) } - if err := requireRegularOrAbsent(path); err != nil { - return err + return AtomicWriteFileAt(dir, filepath.Base(path), content, mode, 0, 0) +} + +// afterRootDirOpen is a deterministic path-swap hook used by the integration +// test. Production leaves it nil. +var afterRootDirOpen func() + +func rootSafeDirectoryFD(path string, dir *os.File) error { + var stat unix.Stat_t + if err := unix.Fstat(int(dir.Fd()), &stat); err != nil { + return fmt.Errorf("stat %s: %w", path, err) + } + if stat.Mode&unix.S_IFMT != unix.S_IFDIR { + return fmt.Errorf("%s is not a directory", path) + } + if stat.Uid != 0 || stat.Gid != 0 { + return fmt.Errorf("%s is not owned by root:root (owner %d:%d)", path, stat.Uid, stat.Gid) } - return AtomicWriteFileAs(path, content, mode, 0, 0) + if stat.Mode¬WritableByGroupOther != 0 { + return fmt.Errorf("%s is group/world writable (mode %o)", path, stat.Mode&0o7777) + } + return nil } -// AtomicWriteFileAs writes content to path atomically: a temp file is created in -// the same directory (O_EXCL via CreateTemp), its owner/mode are set on the fd, -// the target is re-checked for symlink safety, then the temp is renamed over it. +// AtomicWriteFileAs writes content to path atomically: the parent directory is +// pinned by fd, a temp file is created there with openat(O_EXCL), its owner/mode +// are set on the fd, and renameat replaces the re-checked target. // The destination is never chown/chmod'd by name afterward (rename preserves the // temp's owner/mode), so an attacker symlink at the target is never followed. // The caller is responsible for the parent directory's safety policy. func AtomicWriteFileAs(path string, content []byte, mode os.FileMode, uid, gid int) error { dir := filepath.Dir(path) - tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*") + dirFile, err := os.OpenFile(dir, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0) if err != nil { + return fmt.Errorf("open parent directory %s: %w", dir, err) + } + defer dirFile.Close() + return AtomicWriteFileAt(dirFile, filepath.Base(path), content, mode, uid, gid) +} + +// syncDirectory is indirected so a unit test can prove that a successful rename +// is followed by the directory fsync needed to make the new name durable. +var syncDirectory = func(dir *os.File) error { return dir.Sync() } + +// AtomicWriteFileAt atomically writes a single file relative to an already-open +// directory. openat/renameat keep every operation bound to the same directory +// inode even if an attacker renames or replaces its pathname concurrently. +func AtomicWriteFileAt(dir *os.File, name string, content []byte, mode os.FileMode, uid, gid int) error { + if dir == nil { + return fmt.Errorf("nil target directory") + } + if !validate.KernelID(uid) || !validate.KernelID(gid) { + return fmt.Errorf("invalid file owner %d:%d", uid, gid) + } + if name == "" || name == "." || name == ".." || filepath.Base(name) != name || strings.ContainsRune(name, os.PathSeparator) { + return fmt.Errorf("unsafe target name %q", name) + } + var dirStat unix.Stat_t + if err := unix.Fstat(int(dir.Fd()), &dirStat); err != nil { + return fmt.Errorf("stat target directory: %w", err) + } + if dirStat.Mode&unix.S_IFMT != unix.S_IFDIR { + return fmt.Errorf("target fd is not a directory") + } + if err := requireRegularOrAbsentAt(int(dir.Fd()), name); err != nil { return err } - tmpName := tmp.Name() - fail := func(e error) error { - tmp.Close() - os.Remove(tmpName) - return e + + tmpName, tmp, err := createTempAt(dir, name) + if err != nil { + return err } - if _, err := tmp.Write(content); err != nil { - return fail(err) + cleanup := true + defer func() { + _ = tmp.Close() + if cleanup { + _ = unix.Unlinkat(int(dir.Fd()), tmpName, 0) + } + }() + for written := 0; written < len(content); { + n, err := tmp.Write(content[written:]) + written += n + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("short write to temporary file") + } } if err := tmp.Chown(uid, gid); err != nil { - return fail(err) + return err } if err := tmp.Chmod(mode); err != nil { - return fail(err) + return err } if err := tmp.Sync(); err != nil { - return fail(err) + return err } if err := tmp.Close(); err != nil { - os.Remove(tmpName) return err } - if err := requireRegularOrAbsent(path); err != nil { - os.Remove(tmpName) + if err := requireRegularOrAbsentAt(int(dir.Fd()), name); err != nil { return err } - if err := os.Rename(tmpName, path); err != nil { - os.Remove(tmpName) + if err := unix.Renameat(int(dir.Fd()), tmpName, int(dir.Fd()), name); err != nil { return err } + cleanup = false + if err := syncDirectory(dir); err != nil { + return &DurabilityError{Operation: "rename", Err: err} + } return nil } -// requireRegularOrAbsent errors if path exists as a symlink or non-regular file. -func requireRegularOrAbsent(path string) error { - fi, err := os.Lstat(path) +// RemoveFile unlinks one non-directory entry relative to a pinned parent +// directory and syncs that directory before returning success. It never follows +// a symlink at either the parent or target. An absent target is already removed +// and is therefore success. +func RemoveFile(path string) error { + dirPath := filepath.Dir(path) + name := filepath.Base(path) + if name == "" || name == "." || name == ".." || filepath.Clean(path) == dirPath { + return fmt.Errorf("unsafe removal path %q", path) + } + dir, err := os.OpenFile(dirPath, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, os.ErrNotExist) { return nil } + return fmt.Errorf("open parent directory %s: %w", dirPath, err) + } + defer dir.Close() + + var st unix.Stat_t + if err := unix.Fstatat(int(dir.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { + if err == unix.ENOENT { + return nil + } + return fmt.Errorf("stat removal target %s: %w", path, err) + } + if st.Mode&unix.S_IFMT == unix.S_IFDIR { + return fmt.Errorf("refusing to unlink directory %s", path) + } + if err := unix.Unlinkat(int(dir.Fd()), name, 0); err != nil { + if err == unix.ENOENT { + return nil + } + return fmt.Errorf("unlink %s: %w", path, err) + } + if err := syncDirectory(dir); err != nil { + return &DurabilityError{Operation: "unlink", Err: err} + } + return nil +} + +func createTempAt(dir *os.File, target string) (string, *os.File, error) { + for i := 0; i < 128; i++ { + var suffix [8]byte + if _, err := rand.Read(suffix[:]); err != nil { + return "", nil, fmt.Errorf("generate temporary filename: %w", err) + } + name := "." + target + "." + hex.EncodeToString(suffix[:]) + fd, err := unix.Openat(int(dir.Fd()), name, + unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600) + if err == unix.EEXIST { + continue + } + if err != nil { + return "", nil, fmt.Errorf("create temporary file: %w", err) + } + return name, os.NewFile(uintptr(fd), name), nil + } + return "", nil, fmt.Errorf("could not allocate a unique temporary file") +} + +func requireRegularOrAbsentAt(dirFD int, name string) error { + var st unix.Stat_t + err := unix.Fstatat(dirFD, name, &st, unix.AT_SYMLINK_NOFOLLOW) + if err == unix.ENOENT { + return nil + } + if err != nil { return err } - if fi.Mode()&os.ModeSymlink != 0 || !fi.Mode().IsRegular() { - return fmt.Errorf("%s is not a safe regular file; refusing", path) + if st.Mode&unix.S_IFMT != unix.S_IFREG { + return fmt.Errorf("%s is not a safe regular file; refusing", name) } return nil } diff --git a/internal/fsutil/fsutil_root_test.go b/internal/fsutil/fsutil_root_test.go index f615f45..1bad8eb 100644 --- a/internal/fsutil/fsutil_root_test.go +++ b/internal/fsutil/fsutil_root_test.go @@ -3,6 +3,7 @@ package fsutil import ( + "errors" "os" "path/filepath" "syscall" @@ -77,12 +78,51 @@ func TestWriteRootFileRefusesSymlinkParent(t *testing.T) { } } +func TestWriteRootFilePinsValidatedParentDirectoryFD(t *testing.T) { + requireRoot(t) + base := t.TempDir() + dir := filepath.Join(base, "safe") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + moved := filepath.Join(base, "moved") + oldHook := afterRootDirOpen + afterRootDirOpen = func() { + if err := os.Rename(dir, moved); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o777); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { afterRootDirOpen = oldHook }) + + if err := WriteRootFile(filepath.Join(dir, "policy"), []byte("safe\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(filepath.Join(dir, "policy")); !os.IsNotExist(err) { + t.Fatalf("write escaped into replacement parent: %v", err) + } + if b, err := os.ReadFile(filepath.Join(moved, "policy")); err != nil || string(b) != "safe\n" { + t.Fatalf("pinned parent content=%q err=%v", b, err) + } +} + func TestAtomicWriteChownsToRequestedUID(t *testing.T) { requireRoot(t) dir := t.TempDir() p := filepath.Join(dir, "f") const uid, gid = 12345, 12345 if err := AtomicWriteFileAs(p, []byte("x"), 0o600, uid, gid); err != nil { + if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.EPERM) { + t.Skipf("test filesystem cannot represent uid %d: %v", uid, err) + } t.Fatal(err) } if u, g := ownerOf(t, p); u != uid || g != gid { diff --git a/internal/fsutil/fsutil_test.go b/internal/fsutil/fsutil_test.go index 77997a8..a97540a 100644 --- a/internal/fsutil/fsutil_test.go +++ b/internal/fsutil/fsutil_test.go @@ -1,8 +1,11 @@ package fsutil import ( + "errors" "os" "path/filepath" + "strconv" + "strings" "testing" ) @@ -27,6 +30,188 @@ func TestAtomicWriteFileAs(t *testing.T) { } } +func TestOwnershipMutationsRejectChownSentinel(t *testing.T) { + if strconv.IntSize < 64 { + t.Skip("int cannot represent the reserved uint32 chown sentinel") + } + reserved := int(uint64(^uint32(0))) + dir := t.TempDir() + if err := EnsureDir(filepath.Join(dir, "child"), 0o700, reserved, 1); err == nil { + t.Fatal("EnsureDir accepted chown's all-ones uid sentinel") + } + if _, err := os.Lstat(filepath.Join(dir, "child")); !os.IsNotExist(err) { + t.Fatalf("EnsureDir mutated the filesystem before rejecting the uid: %v", err) + } + if err := AtomicWriteFileAs(filepath.Join(dir, "file"), []byte("x"), 0o600, 1, reserved); err == nil { + t.Fatal("AtomicWriteFileAs accepted chown's all-ones gid sentinel") + } + if _, err := os.Lstat(filepath.Join(dir, "file")); !os.IsNotExist(err) { + t.Fatalf("AtomicWriteFileAs wrote before rejecting the gid: %v", err) + } +} + +func TestAtomicWriteReportsPostRenameSyncFailureAsCommitted(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "state") + wantErr := errors.New("forced directory sync failure") + old := syncDirectory + syncDirectory = func(*os.File) error { return wantErr } + t.Cleanup(func() { syncDirectory = old }) + + err := AtomicWriteFileAs(target, []byte("committed"), 0o600, os.Getuid(), os.Getgid()) + var committed *DurabilityError + if !errors.As(err, &committed) || !errors.Is(err, wantErr) { + t.Fatalf("AtomicWriteFileAs error = %v, want committed DurabilityError", err) + } + if b, readErr := os.ReadFile(target); readErr != nil || string(b) != "committed" { + t.Fatalf("post-rename target=%q err=%v", b, readErr) + } +} + +func TestAtomicWriteFileAsSyncsParentAfterRename(t *testing.T) { + dir := t.TempDir() + old := syncDirectory + called := 0 + syncDirectory = func(*os.File) error { + called++ + return nil + } + t.Cleanup(func() { syncDirectory = old }) + if err := AtomicWriteFileAs(filepath.Join(dir, "state"), []byte("durable"), 0o600, os.Getuid(), os.Getgid()); err != nil { + t.Fatal(err) + } + if called != 1 { + t.Fatalf("parent directory sync calls = %d, want 1 after rename", called) + } +} + +func TestRemoveFileSyncsParentAndDoesNotFollowSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + secret := filepath.Join(dir, "secret") + if err := os.WriteFile(secret, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink("secret", target); err != nil { + t.Fatal(err) + } + old := syncDirectory + called := 0 + syncDirectory = func(*os.File) error { + called++ + return nil + } + t.Cleanup(func() { syncDirectory = old }) + + if err := RemoveFile(target); err != nil { + t.Fatal(err) + } + if called != 1 { + t.Fatalf("parent directory sync calls = %d, want 1", called) + } + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("symlink target name survived: %v", err) + } + if b, err := os.ReadFile(secret); err != nil || string(b) != "keep" { + t.Fatalf("symlink destination changed: content=%q err=%v", b, err) + } +} + +func TestRemoveFileReportsCommittedSyncFailure(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + wantErr := errors.New("forced unlink sync failure") + old := syncDirectory + syncDirectory = func(*os.File) error { return wantErr } + t.Cleanup(func() { syncDirectory = old }) + + err := RemoveFile(target) + var committed *DurabilityError + if !errors.As(err, &committed) || !errors.Is(err, wantErr) || committed.Operation != "unlink" { + t.Fatalf("RemoveFile error = %v, want committed unlink DurabilityError", err) + } + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("unlink was not committed: %v", err) + } +} + +func TestRemoveFileRefusesDirectory(t *testing.T) { + dir := t.TempDir() + child := filepath.Join(dir, "child") + if err := os.Mkdir(child, 0o700); err != nil { + t.Fatal(err) + } + if err := RemoveFile(child); err == nil { + t.Fatal("RemoveFile accepted a directory") + } +} + +func TestRemoveFileMissingParentIsSuccess(t *testing.T) { + path := filepath.Join(t.TempDir(), "gone", "file") + if err := RemoveFile(path); err != nil { + t.Fatalf("already-absent path returned error: %v", err) + } +} + +func TestAtomicWriteFileAtPinsDirectoryFD(t *testing.T) { + base := t.TempDir() + dirPath := filepath.Join(base, "dir") + if err := os.Mkdir(dirPath, 0o700); err != nil { + t.Fatal(err) + } + dir, err := os.Open(dirPath) + if err != nil { + t.Fatal(err) + } + defer dir.Close() + moved := filepath.Join(base, "moved") + if err := os.Rename(dirPath, moved); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dirPath, 0o700); err != nil { + t.Fatal(err) + } + if err := AtomicWriteFileAt(dir, "authorized_keys", []byte("key\n"), 0o600, os.Getuid(), os.Getgid()); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(filepath.Join(dirPath, "authorized_keys")); !os.IsNotExist(err) { + t.Fatalf("write escaped into replacement directory: %v", err) + } + if b, err := os.ReadFile(filepath.Join(moved, "authorized_keys")); err != nil || string(b) != "key\n" { + t.Fatalf("pinned-directory content=%q err=%v", b, err) + } +} + +func TestAtomicWriteFileAtRefusesUnsafeNameAndSymlink(t *testing.T) { + dirPath := t.TempDir() + dir, err := os.Open(dirPath) + if err != nil { + t.Fatal(err) + } + defer dir.Close() + for _, name := range []string{"", ".", "../escape", "nested/file"} { + if err := AtomicWriteFileAt(dir, name, []byte("x"), 0o600, os.Getuid(), os.Getgid()); err == nil { + t.Errorf("AtomicWriteFileAt accepted unsafe name %q", name) + } + } + secret := filepath.Join(dirPath, "secret") + if err := os.WriteFile(secret, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink("secret", filepath.Join(dirPath, "authorized_keys")); err != nil { + t.Fatal(err) + } + if err := AtomicWriteFileAt(dir, "authorized_keys", []byte("changed"), 0o600, os.Getuid(), os.Getgid()); err == nil { + t.Fatal("AtomicWriteFileAt accepted a symlink destination") + } + if b, _ := os.ReadFile(secret); string(b) != "original" { + t.Fatalf("symlink target was modified: %q", b) + } +} + func TestAtomicWriteRefusesSymlinkTargetAndDoesNotFollow(t *testing.T) { dir := t.TempDir() secret := filepath.Join(dir, "secret") @@ -48,12 +233,37 @@ func TestAtomicWriteRefusesSymlinkTargetAndDoesNotFollow(t *testing.T) { } } +func TestRootSafeFileRejectsSpecialModeBits(t *testing.T) { + path := filepath.Join(t.TempDir(), "command") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + for _, bit := range []os.FileMode{os.ModeSetuid, os.ModeSetgid, os.ModeSticky} { + if err := os.Chmod(path, 0o755|bit); err != nil { + t.Fatal(err) + } + if err := RootSafeFile(path); err == nil || !strings.Contains(err.Error(), "special mode bits") { + t.Fatalf("RootSafeFile with mode bit %v error = %v, want special-bit refusal", bit, err) + } + } +} + func TestEnsureDir(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "sub", "leaf") + old := syncDirectory + syncs := 0 + syncDirectory = func(*os.File) error { + syncs++ + return nil + } + t.Cleanup(func() { syncDirectory = old }) if err := EnsureDir(p, 0o700, os.Getuid(), os.Getgid()); err != nil { t.Fatal(err) } + if syncs != 4 { + t.Fatalf("new nested directory sync calls=%d, want child+parent for both components", syncs) + } fi, err := os.Lstat(p) if err != nil || !fi.IsDir() || fi.Mode().Perm() != 0o700 { t.Fatalf("dir wrong: isdir=%v mode=%o err=%v", fi.IsDir(), fi.Mode().Perm(), err) @@ -62,6 +272,9 @@ func TestEnsureDir(t *testing.T) { if err := EnsureDir(p, 0o700, os.Getuid(), os.Getgid()); err != nil { t.Fatalf("second EnsureDir: %v", err) } + if syncs != 5 { + t.Fatalf("existing leaf metadata sync calls=%d, want one additional call", syncs) + } } func TestEnsureDirRefusesSymlinkLeaf(t *testing.T) { @@ -78,3 +291,38 @@ func TestEnsureDirRefusesSymlinkLeaf(t *testing.T) { t.Fatal("expected EnsureDir to refuse a symlink leaf") } } + +func TestEnsureDirRefusesSymlinkIntermediateComponent(t *testing.T) { + dir := t.TempDir() + real := filepath.Join(dir, "real") + if err := os.Mkdir(real, 0o700); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + if err := EnsureDir(filepath.Join(link, "leaf"), 0o700, os.Getuid(), os.Getgid()); err == nil { + t.Fatal("expected EnsureDir to refuse a symlink in an intermediate component") + } + if _, err := os.Lstat(filepath.Join(real, "leaf")); !os.IsNotExist(err) { + t.Fatalf("EnsureDir followed the intermediate symlink: %v", err) + } +} + +func TestEnsureDirReportsVisibleDirectoryOnSyncFailure(t *testing.T) { + p := filepath.Join(t.TempDir(), "new") + wantErr := errors.New("forced directory sync failure") + old := syncDirectory + syncDirectory = func(*os.File) error { return wantErr } + t.Cleanup(func() { syncDirectory = old }) + + err := EnsureDir(p, 0o700, os.Getuid(), os.Getgid()) + var durability *DurabilityError + if !errors.As(err, &durability) || !errors.Is(err, wantErr) { + t.Fatalf("EnsureDir error=%v, want DurabilityError", err) + } + if fi, statErr := os.Lstat(p); statErr != nil || !fi.IsDir() { + t.Fatalf("created directory is not visible: info=%v err=%v", fi, statErr) + } +} diff --git a/internal/lifecycle/lock.go b/internal/lifecycle/lock.go index a3f0c0e..fc81e8e 100644 --- a/internal/lifecycle/lock.go +++ b/internal/lifecycle/lock.go @@ -2,9 +2,13 @@ package lifecycle import ( + "bytes" "fmt" + "io" "os" "syscall" + + "github.com/xxvcc/linux-temp-admin/internal/fsutil" ) // Lock is an advisory process lock. Path must live outside removable application @@ -17,6 +21,10 @@ type Lock struct { // New returns a lifecycle lock at path. func New(path string) *Lock { return &Lock{Path: path} } +const tombstoneContent = "uninstalled-v1\n" + +func (l *Lock) tombstonePath() string { return l.Path + ".uninstalled" } + // Acquire blocks until the lifecycle lock is held. The returned release function // must be called exactly once. func (l *Lock) Acquire() (func() error, error) { @@ -65,3 +73,58 @@ func (l *Lock) Acquire() (func() error, error) { return nil }, nil } + +// MarkUninstalled records that a completed teardown owns the lifecycle. It must +// be called while the lock is held, after accounts and grants are gone but before +// removable state or the stable binary is removed. The marker lives beside the +// lock, outside removable application state, so a crash cannot leave state gone +// without stopping already-running processes when they later acquire the lock. +func (l *Lock) MarkUninstalled() error { + if l == nil || l.Path == "" { + return nil + } + return fsutil.AtomicWriteFileAs(l.tombstonePath(), []byte(tombstoneContent), 0o600, os.Geteuid(), os.Getegid()) +} + +// IsUninstalled validates and reads the marker. Unsafe marker metadata is an +// error, not "installed": a caller must fail closed rather than let an attacker +// bypass the lifecycle gate with a malformed file. +func (l *Lock) IsUninstalled() (bool, error) { + if l == nil || l.Path == "" { + return false, nil + } + path := l.tombstonePath() + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("open uninstall marker: %w", err) + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + return false, fmt.Errorf("stat uninstall marker: %w", err) + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok || !fi.Mode().IsRegular() || int(st.Uid) != os.Geteuid() || int(st.Gid) != os.Getegid() || fi.Mode().Perm() != 0o600 { + return false, fmt.Errorf("uninstall marker %s has unsafe metadata", path) + } + b, err := io.ReadAll(io.LimitReader(f, int64(len(tombstoneContent)+1))) + if err != nil { + return false, fmt.Errorf("read uninstall marker: %w", err) + } + if !bytes.Equal(b, []byte(tombstoneContent)) { + return false, fmt.Errorf("uninstall marker %s has invalid content", path) + } + return true, nil +} + +// ClearUninstalled re-enables mutations after an explicit successful install. +// It must be called while the lifecycle lock is held. +func (l *Lock) ClearUninstalled() error { + if l == nil || l.Path == "" { + return nil + } + return fsutil.RemoveFile(l.tombstonePath()) +} diff --git a/internal/lifecycle/lock_test.go b/internal/lifecycle/lock_test.go index 4fa1f93..b018a6b 100644 --- a/internal/lifecycle/lock_test.go +++ b/internal/lifecycle/lock_test.go @@ -72,3 +72,32 @@ func TestLockRejectsSymlinkAndLooseMode(t *testing.T) { t.Fatalf("loose lock error = %v", err) } } + +func TestUninstallMarkerRoundTrip(t *testing.T) { + l := New(filepath.Join(t.TempDir(), "lifecycle.lock")) + if stopped, err := l.IsUninstalled(); err != nil || stopped { + t.Fatalf("initial marker: stopped=%v err=%v", stopped, err) + } + if err := l.MarkUninstalled(); err != nil { + t.Fatal(err) + } + if stopped, err := l.IsUninstalled(); err != nil || !stopped { + t.Fatalf("marked state: stopped=%v err=%v", stopped, err) + } + if err := l.ClearUninstalled(); err != nil { + t.Fatal(err) + } + if stopped, err := l.IsUninstalled(); err != nil || stopped { + t.Fatalf("cleared marker: stopped=%v err=%v", stopped, err) + } +} + +func TestUninstallMarkerRejectsSymlink(t *testing.T) { + l := New(filepath.Join(t.TempDir(), "lifecycle.lock")) + if err := os.Symlink("/etc/passwd", l.tombstonePath()); err != nil { + t.Fatal(err) + } + if _, err := l.IsUninstalled(); err == nil { + t.Fatal("symlink uninstall marker was accepted") + } +} diff --git a/internal/netdetect/netdetect.go b/internal/netdetect/netdetect.go index 060b343..21f8de5 100644 --- a/internal/netdetect/netdetect.go +++ b/internal/netdetect/netdetect.go @@ -39,8 +39,12 @@ type Detector struct { // New returns a Detector with the default services and a redirect-free client. func New() *Detector { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + return &Detector{ Client: &http.Client{ + Transport: transport, // Never auto-follow redirects for a metadata/echo probe. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }, diff --git a/internal/netdetect/netdetect_test.go b/internal/netdetect/netdetect_test.go index a9ec455..eac59d3 100644 --- a/internal/netdetect/netdetect_test.go +++ b/internal/netdetect/netdetect_test.go @@ -7,6 +7,17 @@ import ( "time" ) +func TestNewDisablesEnvironmentProxy(t *testing.T) { + d := New() + transport, ok := d.Client.Transport.(*http.Transport) + if !ok { + t.Fatalf("Client.Transport = %T; want *http.Transport", d.Client.Transport) + } + if transport.Proxy != nil { + t.Fatal("default detector must not route probes through environment proxies") + } +} + func TestPublicIPReturnsPublicAndSkipsPrivate(t *testing.T) { // A service echoing a private IP must be skipped (it is not a public IP), even // though it is a syntactically valid host; detection falls through to the next diff --git a/internal/prefs/prefs.go b/internal/prefs/prefs.go index 054c159..8a2d3fa 100644 --- a/internal/prefs/prefs.go +++ b/internal/prefs/prefs.go @@ -9,8 +9,11 @@ package prefs import ( + "io" "os" + "path/filepath" "strings" + "syscall" "github.com/xxvcc/linux-temp-admin/internal/config" "github.com/xxvcc/linux-temp-admin/internal/fsutil" @@ -23,14 +26,25 @@ var File = config.PrefsFile // second preference can be added without a migration. const langKey = "lang" +const maxPrefsBytes = 4 << 10 + // Lang returns the remembered language selector ("zh"/"en"), or "" if none is // remembered — the file is absent, unreadable, or has no language line. The // caller validates the value; this package does not know what a language is. func Lang() string { - b, err := os.ReadFile(File) + f, err := os.OpenFile(File, os.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) if err != nil { return "" } + defer f.Close() + fi, err := f.Stat() + if err != nil || !fi.Mode().IsRegular() { + return "" + } + b, err := io.ReadAll(io.LimitReader(f, maxPrefsBytes+1)) + if err != nil || len(b) > maxPrefsBytes { + return "" + } for _, line := range strings.Split(string(b), "\n") { k, v, ok := strings.Cut(strings.TrimSpace(line), "=") if ok && strings.TrimSpace(k) == langKey { @@ -43,7 +57,7 @@ func Lang() string { // SetLang remembers lang for future runs. The value is written as-is, so callers // must pass an already-validated selector. func SetLang(lang string) error { - if err := fsutil.EnsureDir(config.RegistryDir, 0o700, 0, 0); err != nil { + if err := fsutil.EnsureDir(filepath.Dir(File), 0o700, 0, 0); err != nil { return err } return fsutil.WriteRootFile(File, []byte(langKey+"="+lang+"\n"), 0o600) diff --git a/internal/prefs/prefs_root_test.go b/internal/prefs/prefs_root_test.go new file mode 100644 index 0000000..3f18e48 --- /dev/null +++ b/internal/prefs/prefs_root_test.go @@ -0,0 +1,32 @@ +//go:build integration + +package prefs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSetLangCreatesTheConfiguredFileParent(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires root") + } + root := t.TempDir() + if err := os.Chown(root, 0, 0); err != nil { + t.Fatal(err) + } + old := File + File = filepath.Join(root, "custom-state", "prefs") + t.Cleanup(func() { File = old }) + + if err := SetLang("en"); err != nil { + t.Fatal(err) + } + if got := Lang(); got != "en" { + t.Fatalf("Lang=%q, want en", got) + } + if _, err := os.Stat(filepath.Dir(File)); err != nil { + t.Fatalf("configured preference parent was not created: %v", err) + } +} diff --git a/internal/prefs/prefs_test.go b/internal/prefs/prefs_test.go new file mode 100644 index 0000000..7eccea0 --- /dev/null +++ b/internal/prefs/prefs_test.go @@ -0,0 +1,59 @@ +package prefs + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" +) + +func usePrefsFile(t *testing.T, path string) { + t.Helper() + old := File + File = path + t.Cleanup(func() { File = old }) +} + +func TestLangReadsBoundedRegularFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "prefs") + usePrefsFile(t, path) + if err := os.WriteFile(path, []byte("other=value\nlang=en\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := Lang(); got != "en" { + t.Fatalf("Lang() = %q, want en", got) + } + if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, maxPrefsBytes+1), 0o600); err != nil { + t.Fatal(err) + } + if got := Lang(); got != "" { + t.Fatalf("oversized Lang() = %q, want empty", got) + } +} + +func TestLangIgnoresSymlinkAndFIFO(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("lang=en\n"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + usePrefsFile(t, link) + if got := Lang(); got != "" { + t.Fatalf("symlink Lang() = %q, want empty", got) + } + + fifo := filepath.Join(dir, "fifo") + if err := unix.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + File = fifo + if got := Lang(); got != "" { + t.Fatalf("FIFO Lang() = %q, want empty", got) + } +} diff --git a/internal/registry/record.go b/internal/registry/record.go index 8c4fcd8..8cb8cd3 100644 --- a/internal/registry/record.go +++ b/internal/registry/record.go @@ -14,17 +14,17 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/validate" ) -// Header is the first line of a v2 registry file; it also carries the schema -// version so a future format change is detectable. -const Header = "# linux-temp-admin registry v2" +// Header is the current registry schema. v3 makes the identity and pending +// columns mandatory so an older writer cannot silently discard them. +const Header = "# linux-temp-admin registry v3" -// fieldCount is the minimum number of tab-separated fields a record line must -// have to be parsed. It stays at 9 deliberately: fields added since are appended -// and read only when present, so a registry written by an older build still -// parses here, and a registry written here still parses under an older build -// (which ignores the trailing extras). Never raise this — it would strand every -// deployed host's existing rows, leaving those accounts unrevocable. -const fieldCount = 9 +const legacyHeaderV2 = "# linux-temp-admin registry v2" + +const ( + legacyFieldCount = 9 + legacyMaxFieldCount = 11 + currentFieldCount = 13 +) // Record is one managed temporary account. type Record struct { @@ -48,6 +48,14 @@ type Record struct { // 0 is unambiguous as the unknown marker. UID int Generation string + // IdentityBound is true only for an account created with Generation embedded + // in its passwd GECOS marker. Migrated v2 rows remain false even when they have + // a generation column: released v2 accounts used one shared fixed marker. + IdentityBound bool + // Pending marks a creation intent written before useradd. It is cleared only + // after the new account's UID has been read and durably recorded. A live account + // named by a pending row has no proven identity and must never be auto-deleted. + Pending bool } var fieldSanitizer = strings.NewReplacer("\t", " ", "\r", " ", "\n", " ") @@ -62,9 +70,11 @@ func boolYN(b bool) string { return "no" } -// uidField and generationField are append-only compatibility columns. +// Column indexes are retained while migrating deployed v2 rows to v3. const uidField = 9 const generationField = 10 +const pendingField = 11 +const identityBoundField = 12 // TSV renders the record as one tab-separated line (no trailing newline). func (r Record) TSV() string { @@ -80,20 +90,35 @@ func (r Record) TSV() string { sanitize(r.AutoUnit), strconv.Itoa(r.UID), // appended; older builds ignore this trailing field sanitize(r.Generation), + boolYN(r.Pending), + boolYN(r.IdentityBound), }, "\t") } -// ParseLine parses one registry line into a Record. It returns ok=false only for -// the header and blank lines. Malformed non-empty rows are errors. Fields appended -// after the original nine are read only when present, so an older registry parses -// with them zero-valued. +// ParseLine parses a current-schema registry line. It returns ok=false only for +// the exact current header and blank lines. Every other non-empty line, including +// one beginning with '#', must be a valid 13-column record or is corruption. func ParseLine(line string) (Record, bool, error) { - if line == "" || strings.HasPrefix(line, "#") { + if line == "" || line == Header { + return Record{}, false, nil + } + return parseFields(line, currentFieldCount, currentFieldCount) +} + +func parseLegacyV2Line(line string) (Record, bool, error) { + if line == "" { return Record{}, false, nil } + return parseFields(line, legacyFieldCount, legacyMaxFieldCount) +} + +func parseFields(line string, minFields, maxFields int) (Record, bool, error) { f := strings.Split(line, "\t") - if len(f) < fieldCount { - return Record{}, false, fmt.Errorf("record has %d fields, want at least %d", len(f), fieldCount) + if len(f) < minFields || len(f) > maxFields { + if minFields == maxFields { + return Record{}, false, fmt.Errorf("record has %d fields, want exactly %d", len(f), minFields) + } + return Record{}, false, fmt.Errorf("record has %d fields, want %d..%d", len(f), minFields, maxFields) } if !validate.Username(f[0]) { return Record{}, false, fmt.Errorf("invalid username %q", f[0]) @@ -118,7 +143,7 @@ func ParseLine(line string) (Record, bool, error) { } if len(f) > uidField { rec.UID, err = strconv.Atoi(f[uidField]) - if err != nil || rec.UID < 0 { + if err != nil || !validate.KernelID(rec.UID) { return Record{}, false, fmt.Errorf("invalid uid %q", f[uidField]) } } @@ -128,5 +153,23 @@ func ParseLine(line string) (Record, bool, error) { return Record{}, false, fmt.Errorf("invalid generation %q", rec.Generation) } } + if len(f) > pendingField { + if f[pendingField] != "yes" && f[pendingField] != "no" { + return Record{}, false, fmt.Errorf("invalid pending field %q", f[pendingField]) + } + rec.Pending = f[pendingField] == "yes" + } + if len(f) > identityBoundField { + if f[identityBoundField] != "yes" && f[identityBoundField] != "no" { + return Record{}, false, fmt.Errorf("invalid identity-bound field %q", f[identityBoundField]) + } + rec.IdentityBound = f[identityBoundField] == "yes" + } + if rec.IdentityBound && !validate.Generation(rec.Generation) { + return Record{}, false, fmt.Errorf("identity-bound record has no valid generation") + } + if rec.IdentityBound && !rec.Pending && !validate.AccountID(rec.UID) { + return Record{}, false, fmt.Errorf("completed identity-bound record has no valid uid") + } return rec, true, nil } diff --git a/internal/registry/record_test.go b/internal/registry/record_test.go index cd547a6..6cec2f4 100644 --- a/internal/registry/record_test.go +++ b/internal/registry/record_test.go @@ -1,23 +1,26 @@ package registry import ( + "strconv" "strings" "testing" ) func TestRoundTrip(t *testing.T) { in := Record{ - User: "xxvcc-a1b2c3", - Created: "2026-07-07 12:00:00 UTC", - Expires: "2026-07-08 12:00:00 UTC", - Sudo: true, - Host: "server-1.example.com", - Port: 22, - Fingerprint: "SHA256:abcdef", - AutoRevoke: true, - AutoUnit: "linux-temp-admin-v2-revoke-xxvcc-a1b2c3", - UID: 1001, - Generation: "0123456789abcdef0123456789abcdef", + User: "xxvcc-a1b2c3", + Created: "2026-07-07 12:00:00 UTC", + Expires: "2026-07-08 12:00:00 UTC", + Sudo: true, + Host: "server-1.example.com", + Port: 22, + Fingerprint: "SHA256:abcdef", + AutoRevoke: true, + AutoUnit: "linux-temp-admin-v2-revoke-xxvcc-a1b2c3", + UID: 1001, + Generation: "0123456789abcdef0123456789abcdef", + IdentityBound: true, + Pending: true, } line := in.TSV() if strings.Contains(line, "\n") { @@ -39,8 +42,7 @@ func TestSanitizeFlattensControlChars(t *testing.T) { in := Record{User: "userx", Host: "a\nb\rc", Port: 22} line := in.TSV() // A field value must never be able to add fields of its own. The count is - // derived from what TSV writes today (fieldCount is only the parser's MINIMUM, - // deliberately frozen at the original nine so older rows still parse), so this + // derived from what TSV writes today, so this // keeps testing the injection property rather than the column count. if n := len(strings.Split(line, "\t")); n != len(strings.Split(Record{}.TSV(), "\t")) { t.Errorf("embedded control chars broke the layout: %d fields (%q)", n, line) @@ -58,13 +60,15 @@ func TestSanitizeFlattensControlChars(t *testing.T) { } func TestParseLineRejectsNonRecords(t *testing.T) { - for _, line := range []string{"", Header, "# comment"} { + for _, line := range []string{"", Header} { if _, ok, err := ParseLine(line); ok || err != nil { t.Errorf("ParseLine(%q) = ok=%v err=%v, want ignored", line, ok, err) } } - if _, _, err := ParseLine("too\tfew\tfields"); err == nil { - t.Error("malformed record must return an error") + for _, line := range []string{"too\tfew\tfields", "# comment", legacyHeaderV2} { + if _, _, err := ParseLine(line); err == nil { + t.Errorf("malformed/non-current line %q must return an error", line) + } } } @@ -72,10 +76,12 @@ func TestParseLineRejectsCorruptFields(t *testing.T) { valid := strings.Split(Record{User: "xxvcc-a1", Port: 22}.TSV(), "\t") tests := map[string][]string{} for name, mutate := range map[string]func([]string){ - "boolean": func(f []string) { f[3] = "maybe" }, - "port": func(f []string) { f[5] = "not-a-port" }, - "uid": func(f []string) { f[9] = "broken" }, - "generation": func(f []string) { f[10] = "too-short" }, + "boolean": func(f []string) { f[3] = "maybe" }, + "port": func(f []string) { f[5] = "not-a-port" }, + "uid": func(f []string) { f[9] = "broken" }, + "generation": func(f []string) { f[10] = "too-short" }, + "pending": func(f []string) { f[11] = "maybe" }, + "identity bound": func(f []string) { f[12] = "maybe" }, } { fields := append([]string(nil), valid...) mutate(fields) @@ -90,6 +96,24 @@ func TestParseLineRejectsCorruptFields(t *testing.T) { } } +func TestParseLineRequiresGenerationForBoundIdentity(t *testing.T) { + line := Record{User: "xxvcc-a1", Port: 22, UID: 1001, IdentityBound: true}.TSV() + if _, _, err := ParseLine(line); err == nil || !strings.Contains(err.Error(), "no valid generation") { + t.Fatalf("ParseLine error = %v, want missing generation refusal", err) + } +} + +func TestParseLineRejectsReservedLinuxUID(t *testing.T) { + if strconv.IntSize < 64 { + t.Skip("int cannot represent the reserved uint32 uid sentinel") + } + fields := strings.Split(Record{User: "xxvcc-a1", Port: 22}.TSV(), "\t") + fields[uidField] = strconv.FormatUint(uint64(^uint32(0)), 10) + if _, _, err := ParseLine(strings.Join(fields, "\t")); err == nil || !strings.Contains(err.Error(), "invalid uid") { + t.Fatalf("reserved uid error = %v, want refusal", err) + } +} + // TestParseLineAcceptsLegacyNineFieldRow pins the compatibility contract that // makes appending the UID safe. A row written by a build from before the field // existed MUST still parse — if it did not, every account already on a deployed @@ -99,7 +123,7 @@ func TestParseLineAcceptsLegacyNineFieldRow(t *testing.T) { "xxvcc-a1", "2026-07-07 12:00:00 UTC", "2026-07-08 12:00 CST", "yes", "203.0.113.5", "22", "SHA256:abc", "yes", "unit.timer", }, "\t") - got, ok, err := ParseLine(legacy) + got, ok, err := parseLegacyV2Line(legacy) if err != nil { t.Fatal(err) } @@ -112,23 +136,24 @@ func TestParseLineAcceptsLegacyNineFieldRow(t *testing.T) { if got.UID != 0 { t.Errorf("UID = %d, want 0 (the 'not recorded' marker) for a legacy row", got.UID) } + if got.Pending { + t.Error("a legacy row must not be interpreted as a pending creation intent") + } } -// TestTSVIsReadableByAnOlderParser pins the other direction: a row written now -// must still parse under a build that knows only the original nine fields, so a -// downgraded binary can still revoke what this one created. -func TestTSVIsReadableByAnOlderParser(t *testing.T) { - line := Record{User: "xxvcc-a1", Port: 22, UID: 1001, AutoUnit: "u.timer"}.TSV() +// TestV3SchemaStopsV2Writers pins the forward-compatibility boundary. v3 rows +// carry fields a v2 writer would discard, so the header and exact row width must +// make that writer fail closed instead of accepting and truncating them. +func TestV3SchemaStopsV2Writers(t *testing.T) { + line := Record{User: "xxvcc-a1", Port: 22, UID: 1001, AutoUnit: "u.timer", Pending: true}.TSV() f := strings.Split(line, "\t") - if len(f) < 9 { - t.Fatalf("row has %d fields; an older parser requires at least 9", len(f)) + if Header == legacyHeaderV2 { + t.Fatal("current and legacy registry headers must differ") } - // Simulate the old parser: it reads f[0..8] and ignores anything after. - if f[0] != "xxvcc-a1" || f[5] != "22" || f[8] != "u.timer" { - t.Errorf("the original nine columns moved: %q", f[:9]) + if len(f) != currentFieldCount { + t.Fatalf("v3 row has %d fields, want %d", len(f), currentFieldCount) } - // And the UID must be the appended one, not a repurposed old column. - if f[9] != "1001" { - t.Errorf("UID column = %q, want it appended last", f[9]) + if _, _, err := parseLegacyV2Line(line); err == nil { + t.Fatal("v2 parser accepted a v3 row and could silently discard pending state") } } diff --git a/internal/registry/store.go b/internal/registry/store.go index 2da9bad..4348ac2 100644 --- a/internal/registry/store.go +++ b/internal/registry/store.go @@ -2,6 +2,7 @@ package registry import ( "fmt" + "io" "os" "strings" "syscall" @@ -10,6 +11,8 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/fsutil" ) +const maxRegistryBytes = int64(16 << 20) + // Store is the flock-guarded, root-owned registry of managed accounts. Paths are // fields so tests can point them at a temporary directory. type Store struct { @@ -18,7 +21,7 @@ type Store struct { Lock string } -// Default returns a Store using the configured v2 registry paths. +// Default returns a Store using the configured registry paths. func Default() *Store { return &Store{Dir: config.RegistryDir, File: config.RegistryFile, Lock: config.RegistryLockFile} } @@ -45,30 +48,60 @@ func (s *Store) Init() error { if err := ensureFile(s.File, []byte(Header+"\n")); err != nil { return err } - return ensureFile(s.Lock, nil) + if err := ensureFile(s.Lock, nil); err != nil { + return err + } + // Upgrade a deployed v2 registry only while holding its lock. New writes use a + // v3 header that old binaries reject, preventing them from dropping UID, + // generation, or pending state during a delayed rewrite. + return s.withLock(func() error { + recs, header, err := s.readAllWithHeader() + if err != nil { + return err + } + if header == legacyHeaderV2 { + return s.writeAll(recs) + } + return nil + }) } func ensureFile(path string, initial []byte) error { - fi, err := os.Lstat(path) - if err == nil { - if fi.Mode()&os.ModeSymlink != 0 || !fi.Mode().IsRegular() { - return fmt.Errorf("%s is not a safe regular file", path) + f, err := os.OpenFile(path, os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) + if err != nil { + if os.IsNotExist(err) { + return fsutil.WriteRootFile(path, initial, 0o600) } - return nil + return err } - if !os.IsNotExist(err) { + defer f.Close() + if err := requireRegularFD(path, f); err != nil { return err } - return fsutil.WriteRootFile(path, initial, 0o600) + if err := f.Chown(0, 0); err != nil { + return fmt.Errorf("repair owner of %s: %w", path, err) + } + if err := f.Chmod(0o600); err != nil { + return fmt.Errorf("repair mode of %s: %w", path, err) + } + if err := syncRegistryFile(f); err != nil { + return &fsutil.DurabilityError{Operation: "registry metadata repair", Err: err} + } + return requireRootFileFD(path, f, 0o600) } +var syncRegistryFile = func(f *os.File) error { return f.Sync() } + // withLock runs fn while holding an exclusive advisory lock on the lock file. func (s *Store) withLock(fn func() error) error { - f, err := os.OpenFile(s.Lock, os.O_RDWR|syscall.O_NOFOLLOW, 0) + f, err := os.OpenFile(s.Lock, os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) if err != nil { return fmt.Errorf("open registry lock: %w", err) } defer f.Close() + if err := requireRootFileFD(s.Lock, f, 0o600); err != nil { + return err + } if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { return fmt.Errorf("flock registry: %w", err) } @@ -79,34 +112,91 @@ func (s *Store) withLock(fn func() error) error { // readAll reads and parses the registry (unlocked; reads see a consistent inode // even across a concurrent atomic rewrite). Missing file yields no records. func (s *Store) readAll() ([]Record, error) { - if fi, err := os.Lstat(s.File); err == nil { - if fi.Mode()&os.ModeSymlink != 0 || !fi.Mode().IsRegular() { - return nil, fmt.Errorf("registry file %s is unsafe", s.File) + recs, _, err := s.readAllWithHeader() + return recs, err +} + +func (s *Store) readAllWithHeader() ([]Record, string, error) { + f, err := os.OpenFile(s.File, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) + if err != nil { + if os.IsNotExist(err) { + return nil, "", nil } - } else if os.IsNotExist(err) { - return nil, nil - } else { - return nil, err + return nil, "", err + } + defer f.Close() + if err := requireRootFileFD(s.File, f, 0o600); err != nil { + return nil, "", err } - b, err := os.ReadFile(s.File) + b, err := io.ReadAll(io.LimitReader(f, maxRegistryBytes+1)) if err != nil { - return nil, err + return nil, "", err + } + if int64(len(b)) > maxRegistryBytes { + return nil, "", fmt.Errorf("registry exceeds %d bytes", maxRegistryBytes) } lines := strings.Split(string(b), "\n") - if len(lines) == 0 || lines[0] != Header { - return nil, fmt.Errorf("registry header is missing or unsupported") + if len(lines) == 0 || (lines[0] != Header && lines[0] != legacyHeaderV2) { + return nil, "", fmt.Errorf("registry header is missing or unsupported") } + header := lines[0] var recs []Record + seenUsers := make(map[string]int) for i, line := range lines[1:] { - r, ok, err := ParseLine(line) + lineNumber := i + 2 + if line == Header || line == legacyHeaderV2 { + return nil, "", fmt.Errorf("registry line %d: duplicate schema header", lineNumber) + } + var r Record + var ok bool + var err error + if header == Header { + r, ok, err = ParseLine(line) + } else { + r, ok, err = parseLegacyV2Line(line) + } if err != nil { - return nil, fmt.Errorf("registry line %d: %w", i+2, err) + return nil, "", fmt.Errorf("registry line %d: %w", lineNumber, err) } if ok { + if firstLine, exists := seenUsers[r.User]; exists { + return nil, "", fmt.Errorf("registry line %d: duplicate username %q (first seen on line %d)", lineNumber, r.User, firstLine) + } + seenUsers[r.User] = lineNumber recs = append(recs, r) } } - return recs, nil + return recs, header, nil +} + +func requireRegularFD(path string, f *os.File) error { + fi, err := f.Stat() + if err != nil { + return fmt.Errorf("stat %s: %w", path, err) + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("%s is not a safe regular file", path) + } + return nil +} + +func requireRootFileFD(path string, f *os.File, mode os.FileMode) error { + if err := requireRegularFD(path, f); err != nil { + return err + } + fi, err := f.Stat() + if err != nil { + return fmt.Errorf("stat %s: %w", path, err) + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("cannot determine owner of %s", path) + } + if st.Uid != 0 || st.Gid != 0 || fi.Mode().Perm() != mode.Perm() { + return fmt.Errorf("%s metadata is unsafe: owner %d:%d mode %o, want root:root %o", + path, st.Uid, st.Gid, fi.Mode().Perm(), mode.Perm()) + } + return nil } // writeAll atomically rewrites the registry from recs (header + one line each). @@ -115,7 +205,12 @@ func (s *Store) writeAll(recs []Record) error { b.WriteString(Header) b.WriteByte('\n') for _, r := range recs { - b.WriteString(r.TSV()) + row := r.TSV() + remaining := maxRegistryBytes - int64(b.Len()) + if remaining < 1 || int64(len(row)) > remaining-1 { + return fmt.Errorf("registry output exceeds %d bytes", maxRegistryBytes) + } + b.WriteString(row) b.WriteByte('\n') } return fsutil.WriteRootFile(s.File, []byte(b.String()), 0o600) @@ -147,6 +242,13 @@ func (s *Store) Record(rec Record) error { // Remove deletes the entry for user (no error if absent). func (s *Store) Remove(user string) error { + absent, err := s.completelyAbsent() + if err != nil { + return err + } + if absent { + return nil + } return s.withLock(func() error { recs, err := s.readAll() if err != nil { @@ -222,8 +324,15 @@ func (s *Store) UnitFor(user string) (string, error) { // lose its fresh entry. exists reports whether an account is still present. // Returns the number of entries pruned. func (s *Store) Compact(exists func(user string) (bool, error)) (int, error) { + absent, err := s.completelyAbsent() + if err != nil { + return 0, err + } + if absent { + return 0, nil + } removed := 0 - err := s.withLock(func() error { + err = s.withLock(func() error { recs, err := s.readAll() if err != nil { return err @@ -250,3 +359,17 @@ func (s *Store) Compact(exists func(user string) (bool, error)) (int, error) { } return removed, nil } + +// completelyAbsent recognizes only a fully absent store. A missing data file +// paired with an existing lock is a valid empty store; an existing data file +// without its lock is damaged and must still fail in withLock. +func (s *Store) completelyAbsent() (bool, error) { + for _, path := range []string{s.File, s.Lock} { + if _, err := os.Lstat(path); err == nil { + return false, nil + } else if !os.IsNotExist(err) { + return false, err + } + } + return true, nil +} diff --git a/internal/registry/store_durability_root_test.go b/internal/registry/store_durability_root_test.go new file mode 100644 index 0000000..cd198b0 --- /dev/null +++ b/internal/registry/store_durability_root_test.go @@ -0,0 +1,68 @@ +//go:build integration + +package registry + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/xxvcc/linux-temp-admin/internal/fsutil" +) + +func TestEnsureFileSyncsRepairedMetadataAndReportsFailure(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "registry.tsv") + if err := os.WriteFile(path, []byte(Header+"\n"), 0o666); err != nil { + t.Fatal(err) + } + + old := syncRegistryFile + t.Cleanup(func() { syncRegistryFile = old }) + calls := 0 + syncRegistryFile = func(*os.File) error { + calls++ + return nil + } + if err := ensureFile(path, nil); err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("registry metadata sync calls=%d, want 1", calls) + } + + wantErr := errors.New("forced registry metadata sync failure") + syncRegistryFile = func(*os.File) error { return wantErr } + err := ensureFile(path, nil) + var durability *fsutil.DurabilityError + if !errors.As(err, &durability) || !errors.Is(err, wantErr) || durability.Operation != "registry metadata repair" { + t.Fatalf("ensureFile error=%v, want registry metadata DurabilityError", err) + } + fi, statErr := os.Stat(path) + if statErr != nil { + t.Fatalf("metadata repair target is missing: %v", statErr) + } + if fi.Mode().Perm() != 0o600 { + t.Fatalf("metadata repair mode=%v, want 0600", fi.Mode()) + } + if st := fiStat(t, path); st.Uid != 0 || st.Gid != 0 { + t.Fatalf("metadata repair owner=%d:%d, want root:root", st.Uid, st.Gid) + } +} + +func fiStat(t *testing.T, path string) *syscall.Stat_t { + t.Helper() + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return fi.Sys().(*syscall.Stat_t) +} diff --git a/internal/registry/store_root_test.go b/internal/registry/store_root_test.go index 84a3a02..519dd74 100644 --- a/internal/registry/store_root_test.go +++ b/internal/registry/store_root_test.go @@ -6,7 +6,9 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" + "syscall" "testing" "github.com/xxvcc/linux-temp-admin/internal/registry" @@ -35,6 +37,90 @@ func newStore(t *testing.T) *registry.Store { return s } +func TestInitRepairsExistingRegistryFileAndLockMetadata(t *testing.T) { + s := newStore(t) + for _, path := range []string{s.File, s.Lock} { + if err := os.Chown(path, 12345, 12345); err != nil { + t.Logf("cannot create non-root owner fixture for %s: %v", path, err) + } + if err := os.Chmod(path, 0o666); err != nil { + t.Fatal(err) + } + } + if err := s.Init(); err != nil { + t.Fatalf("Init repair: %v", err) + } + for _, path := range []string{s.File, s.Lock} { + fi, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + st := fi.Sys().(*syscall.Stat_t) + if !fi.Mode().IsRegular() || st.Uid != 0 || st.Gid != 0 || fi.Mode().Perm() != 0o600 { + t.Errorf("%s type=%v owner=%d:%d mode=%o, want regular root:root 0600", path, fi.Mode(), st.Uid, st.Gid, fi.Mode().Perm()) + } + } +} + +func TestInitMigratesV2RegistryToV3UnderLock(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + s := ®istry.Store{Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock")} + v2row := strings.Join([]string{ + "xxvcc-v2", "2026-07-07 12:00:00 UTC", "2026-07-08 12:00:00 UTC", + "yes", "203.0.113.5", "22", "SHA256:abc", "yes", "unit.timer", + "1001", "0123456789abcdef0123456789abcdef", + }, "\t") + if err := os.WriteFile(s.File, []byte("# linux-temp-admin registry v2\n"+v2row+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(s.Lock, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := s.Init(); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(s.File) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(b), registry.Header+"\n") { + t.Fatalf("registry was not migrated to v3: %q", b) + } + recs, err := s.List() + if err != nil || len(recs) != 1 || recs[0].UID != 1001 || recs[0].Pending || recs[0].IdentityBound { + t.Fatalf("migrated records=%+v err=%v", recs, err) + } +} + +func TestInitRejectsExistingNonRegularRegistryFiles(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + s := ®istry.Store{Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock")} + if err := os.Mkdir(s.File, 0o700); err != nil { + t.Fatal(err) + } + if err := s.Init(); err == nil { + t.Fatal("Init accepted a directory in place of the registry file") + } +} + func TestStoreRecordUpsertRemove(t *testing.T) { s := newStore(t) rec := registry.Record{User: "xxvcc-a1", Host: "h", Port: 22, Sudo: true, AutoRevoke: true, AutoUnit: "u"} @@ -126,6 +212,26 @@ func TestStoreRejectsCorruptRegistry(t *testing.T) { if _, err := s.List(); err == nil { t.Fatal("an unsupported registry header must be rejected") } + if err := os.WriteFile(s.File, []byte(registry.Header+"\n# corrupted row\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := s.List(); err == nil { + t.Fatal("a non-header # line must be reported as corruption") + } + rec := registry.Record{User: "xxvcc-duplicate", Port: 22}.TSV() + for name, body := range map[string]string{ + "duplicate username": registry.Header + "\n" + rec + "\n" + rec + "\n", + "duplicate header": registry.Header + "\n" + registry.Header + "\n", + } { + t.Run(name, func(t *testing.T) { + if err := os.WriteFile(s.File, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if _, err := s.List(); err == nil { + t.Fatalf("registry accepted %s", name) + } + }) + } } func TestStoreRejectsInvalidRecordBeforeWriting(t *testing.T) { diff --git a/internal/registry/store_test.go b/internal/registry/store_test.go new file mode 100644 index 0000000..ea8f7f4 --- /dev/null +++ b/internal/registry/store_test.go @@ -0,0 +1,80 @@ +package registry + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +func TestReadAllRejectsFIFOWithoutBlocking(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "registry.tsv") + if err := unix.Mkfifo(path, 0o600); err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { + _, err := (&Store{File: path}).readAll() + done <- err + }() + + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "not a safe regular file") { + t.Fatalf("FIFO registry error = %v, want special-file refusal", err) + } + case <-time.After(time.Second): + t.Fatal("registry read blocked while opening a FIFO") + } +} + +func TestMissingStoreRemovalAndCompactAreNoOps(t *testing.T) { + dir := filepath.Join(t.TempDir(), "missing") + s := &Store{ + Dir: dir, + File: filepath.Join(dir, "registry.tsv"), + Lock: filepath.Join(dir, "registry.lock"), + } + if err := s.Remove("xxvcc-a1"); err != nil { + t.Fatalf("Remove on a fully absent store: %v", err) + } + called := false + removed, err := s.Compact(func(string) (bool, error) { + called = true + return false, nil + }) + if err != nil || removed != 0 || called { + t.Fatalf("Compact on absent store: removed=%d called=%v err=%v", removed, called, err) + } +} + +func TestExistingRegistryWithoutLockStillFailsClosed(t *testing.T) { + dir := t.TempDir() + s := &Store{ + Dir: dir, + File: filepath.Join(dir, "registry.tsv"), + Lock: filepath.Join(dir, "registry.lock"), + } + if err := os.WriteFile(s.File, []byte(Header+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := s.Remove("xxvcc-a1"); err == nil { + t.Fatal("Remove accepted an existing registry whose lock was missing") + } +} + +func TestWriteAllRejectsOutputAboveRegistryLimit(t *testing.T) { + s := &Store{File: filepath.Join(t.TempDir(), "registry.tsv")} + rec := Record{Host: strings.Repeat("x", int(maxRegistryBytes))} + if err := s.writeAll([]Record{rec}); err == nil || !strings.Contains(err.Error(), "registry output exceeds") { + t.Fatalf("writeAll error = %v, want output-size refusal", err) + } + if _, err := os.Lstat(s.File); !os.IsNotExist(err) { + t.Fatalf("oversized registry write created output: %v", err) + } +} diff --git a/internal/schedule/orphans.go b/internal/schedule/orphans.go index 1676cf8..e31e3fb 100644 --- a/internal/schedule/orphans.go +++ b/internal/schedule/orphans.go @@ -1,7 +1,8 @@ package schedule import ( - "path/filepath" + "fmt" + "os" "sort" "strings" @@ -28,13 +29,19 @@ import ( // Globbing only the v2 prefix walks straight past it. func (s *Scheduler) UnitUsers() ([]string, error) { seen := map[string]bool{} - for _, prefix := range s.unitPrefixes() { - matches, err := filepath.Glob(filepath.Join(s.SystemdDir, prefix+"*")) - if err != nil { - return nil, err - } - for _, path := range matches { - base := filepath.Base(path) + entries, err := readSystemdDir(s.SystemdDir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read systemd unit directory %s: %w", s.SystemdDir, err) + } + for _, entry := range entries { + for _, prefix := range s.unitPrefixes() { + base := entry.Name() + if !strings.HasPrefix(base, prefix) { + continue + } // Units come in .service/.timer pairs; both name the same account. base = strings.TrimSuffix(strings.TrimSuffix(base, ".timer"), ".service") user := strings.TrimPrefix(base, prefix) @@ -53,6 +60,8 @@ func (s *Scheduler) UnitUsers() ([]string, error) { return users, nil } +var readSystemdDir = os.ReadDir + // ScheduledUsers returns accounts named by either systemd units or queued at // jobs. This is the complete uninstall inventory even when registry rows vanish. func (s *Scheduler) ScheduledUsers() ([]string, error) { @@ -64,19 +73,22 @@ func (s *Scheduler) ScheduledUsers() ([]string, error) { for _, user := range users { seen[user] = true } + // `at` is optional. No installed backend footprint means there cannot be a + // runnable queue to inventory; a partial installation still calls AtJobs and + // fails closed below because it may leave live jobs hidden from teardown. + if !s.Sys.HasAt() { + return users, nil + } jobs, err := s.Sys.AtJobs() if err != nil { return nil, err } - prefix := s.InstallPath + " revoke --user " for _, job := range jobs { - i := strings.Index(job.Body, prefix) - if i < 0 { - continue - } - fields := strings.Fields(job.Body[i+len(prefix):]) - if len(fields) > 0 && validate.Username(fields[0]) { - seen[fields[0]] = true + for _, line := range strings.Split(job.Body, "\n") { + command, ok := parseAtRevokeCommand(line, s.InstallPath) + if ok { + seen[command.user] = true + } } } users = users[:0] diff --git a/internal/schedule/orphans_test.go b/internal/schedule/orphans_test.go index c4dd696..bc8c4a1 100644 --- a/internal/schedule/orphans_test.go +++ b/internal/schedule/orphans_test.go @@ -1,11 +1,26 @@ package schedule import ( + "errors" "os" "path/filepath" + "strings" "testing" ) +func TestUnitUsersPropagatesDirectoryReadFailure(t *testing.T) { + old := readSystemdDir + readSystemdDir = func(string) ([]os.DirEntry, error) { + return nil, errors.New("injected directory I/O failure") + } + t.Cleanup(func() { readSystemdDir = old }) + + s := newFinder(t) + if _, err := s.UnitUsers(); err == nil || !strings.Contains(err.Error(), "injected directory I/O failure") { + t.Fatalf("UnitUsers error = %v, want directory failure", err) + } +} + func newFinder(t *testing.T, files ...string) *Scheduler { t.Helper() dir := t.TempDir() @@ -113,8 +128,8 @@ func TestOrphansAreUnitsWhoseAccountIsGone(t *testing.T) { func TestScheduledUsersIncludesAtJobsWithoutRegistry(t *testing.T) { s := newFinder(t) - s.Sys = &fakeSystem{atJobs: []AtJob{ - {ID: "7", Body: "/usr/local/sbin/linux-temp-admin revoke --user queueduser --yes --force\n"}, + s.Sys = &fakeSystem{hasAt: true, atJobs: []AtJob{ + {ID: "7", Body: "/usr/local/sbin/linux-temp-admin revoke --user queueduser --yes --force --confirm-force queueduser\n"}, {ID: "8", Body: "/bin/echo unrelated\n"}, }} users, err := s.ScheduledUsers() @@ -123,3 +138,33 @@ func TestScheduledUsersIncludesAtJobsWithoutRegistry(t *testing.T) { } eq(t, users, "queueduser") } + +func TestScheduledUsersOnlyAcceptsKnownStandaloneRevokeCommands(t *testing.T) { + s := newFinder(t) + s.Sys = &fakeSystem{hasAt: true, atJobs: []AtJob{ + {ID: "1", Body: "# /usr/local/sbin/linux-temp-admin revoke --user comment --yes\n"}, + {ID: "2", Body: "echo /usr/local/sbin/linux-temp-admin revoke --user echoed --yes\n"}, + {ID: "3", Body: "/tmp/usr/local/sbin/linux-temp-admin revoke --user wrongpath --yes\n"}, + {ID: "4", Body: "/usr/local/sbin/linux-temp-admin revoke --user badsuffix --yes --unknown\n"}, + {ID: "5", Body: "/usr/local/sbin/linux-temp-admin revoke --user legacy --yes\n"}, + {ID: "6", Body: "/usr/local/sbin/linux-temp-admin revoke --user forced --yes --force --confirm-force forced\n"}, + {ID: "7", Body: "/usr/local/sbin/linux-temp-admin revoke --user current --yes --force --confirm-force current --expected-uid 1001 --generation 0123456789abcdef0123456789abcdef\n"}, + }} + + users, err := s.ScheduledUsers() + if err != nil { + t.Fatal(err) + } + eq(t, users, "current", "forced", "legacy") +} + +func TestScheduledUsersAllowsCompletelyAbsentAtBackend(t *testing.T) { + s := newFinder(t, "linux-temp-admin-v2-revoke-unituser.timer") + s.Sys = &fakeSystem{atJobsErr: os.ErrNotExist} + + users, err := s.ScheduledUsers() + if err != nil { + t.Fatalf("systemd-only inventory failed because optional at is absent: %v", err) + } + eq(t, users, "unituser") +} diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go index adcd55f..e9ae184 100644 --- a/internal/schedule/schedule.go +++ b/internal/schedule/schedule.go @@ -15,21 +15,25 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/config" "github.com/xxvcc/linux-temp-admin/internal/fsutil" + "github.com/xxvcc/linux-temp-admin/internal/validate" ) // System abstracts the external schedulers so orchestration is testable. type System interface { HasSystemctl() bool Systemctl(args ...string) error + // HasAt reports any installed at-backend footprint. A completely absent + // backend is not an inventory error; a partial backend is and must fail closed. HasAt() bool // ScheduleAt queues command to run in `hours` hours and returns the job id. ScheduleAt(command string, hours int) (jobID string, err error) - // RemoveAtJobsFor atrm's every queued job whose body contains command. + // RemoveAtJobsFor removes queued jobs matching a known standalone revoke + // command selected by command's legacy-compatible prefix. RemoveAtJobsFor(command string) error // AtrmJob removes a specific at job by id. An already-absent job is success. AtrmJob(id string) error // AtJobs returns queued job bodies so uninstall can inventory jobs whose - // registry row has been lost. + // registry row has been lost. Missing inventory commands are an error. AtJobs() ([]AtJob, error) } @@ -40,9 +44,10 @@ type AtJob struct { // Scheduler writes units / queues jobs. Paths and time source are fields for tests. type Scheduler struct { - SystemdDir string - InstallPath string - UnitPrefix string + SystemdDir string + SystemdTimerStateDir string + InstallPath string + UnitPrefix string // LegacyUnitPrefixes are older namespaces whose units this Scheduler must still // be able to FIND (see UnitUsers) though it never writes them. It is a field // rather than a constant so a test can point the whole namespace at a temp dir @@ -50,43 +55,24 @@ type Scheduler struct { LegacyUnitPrefixes []string Now func() time.Time Sys System - // UnderUnit reports whether the current process is executing inside the given - // systemd service (i.e. the firing auto-revoke run for that unit); when true, - // Cancel leaves that .service file in place rather than deleting the file it is - // running from. Defaults to a /proc/self/cgroup check; injectable for tests. - UnderUnit func(unit string) bool } // New returns a Scheduler backed by real systemctl/at. func New() *Scheduler { return &Scheduler{ - SystemdDir: config.SystemdDir, - InstallPath: config.InstallPath, - UnitPrefix: config.AutoRevokeUnitPrefix, + SystemdDir: config.SystemdDir, + SystemdTimerStateDir: config.SystemdTimerStateDir, + InstallPath: config.InstallPath, + UnitPrefix: config.AutoRevokeUnitPrefix, // v1's units are still findable, never written. v1 installed to the same path // this binary occupies, so its timers invoke THIS code and its accounts strand // exactly like v2's would. LegacyUnitPrefixes: []string{config.V1AutoRevokeUnitPrefix}, Now: time.Now, Sys: realSystem{}, - UnderUnit: runningUnderFiringUnit, } } -// runningUnderFiringUnit reports whether the current process is executing inside -// the systemd service .service — i.e. this run is the auto-revoke task -// firing for this very unit, so Cancel must not delete the .service file it is -// running from. It reads /proc/self/cgroup, whose path for a service contains -// ".service". If that is unavailable it falls back to the coarse "any -// systemd scope" signal (INVOCATION_ID), erring toward leaving the file rather -// than risking removal of a live unit. -func runningUnderFiringUnit(unit string) bool { - if b, err := os.ReadFile("/proc/self/cgroup"); err == nil { - return strings.Contains(string(b), unit+".service") - } - return os.Getenv("INVOCATION_ID") != "" -} - // UnitName is the deterministic systemd unit basename for user (validated // usernames are already safe as a plain unit name). func (s *Scheduler) UnitName(user string) string { return s.UnitPrefix + user } @@ -103,10 +89,9 @@ func (s *Scheduler) RevokeCommand(user string, uid int, generation string) strin s.InstallPath, user, user, uid, generation) } -// revokeAtNeedle is the stable substring used to FIND this account's queued at -// job, as opposed to the full command used to queue it. It must match jobs queued -// by any version of this tool, so it stops at "--yes" — the part every past and -// present RevokeCommand shares — and does not include the newer --force tokens. +// revokeAtNeedle is the stable selector used to find this account's queued at +// job. The matcher accepts only complete command forms emitted by known releases, +// but this selector stops at "--yes" so it covers all of those forms. func (s *Scheduler) revokeAtNeedle(user string) string { return fmt.Sprintf("%s revoke --user %s --yes", s.InstallPath, user) } @@ -159,6 +144,21 @@ WantedBy=timers.target // systemctl or at available", sending the operator to debug a missing tool that // was in fact present. func (s *Scheduler) Schedule(user string, uid int, generation string, hours int) (string, error) { + if !validate.Username(user) { + return "", fmt.Errorf("invalid temporary username %q", user) + } + if !validate.AccountID(uid) { + return "", fmt.Errorf("invalid Linux account UID %d", uid) + } + if !validate.Generation(generation) { + return "", fmt.Errorf("invalid account generation %q", generation) + } + if !validate.Hours(hours) { + return "", fmt.Errorf("invalid account lifetime %d hours", hours) + } + if s == nil || s.Sys == nil { + return "", fmt.Errorf("no scheduler backend configured") + } var systemdErr error if s.Sys.HasSystemctl() { unit, err := s.scheduleSystemd(user, uid, generation, hours) @@ -166,6 +166,10 @@ func (s *Scheduler) Schedule(user string, uid int, generation string, hours int) return unit, nil } systemdErr = err + var rollbackErr *systemdRollbackError + if errors.As(err, &rollbackErr) { + return "", fmt.Errorf("systemd: %w", err) + } } unit, atErr := s.scheduleAt(user, uid, generation, hours) if atErr != nil && systemdErr != nil { @@ -182,27 +186,83 @@ func (s *Scheduler) scheduleSystemd(user string, uid int, generation string, hou servicePath := filepath.Join(s.SystemdDir, unit+".service") timerPath := filepath.Join(s.SystemdDir, unit+".timer") if err := fsutil.WriteRootFile(servicePath, []byte(s.serviceContent(user, uid, generation)), 0o644); err != nil { + var committed *fsutil.DurabilityError + if errors.As(err, &committed) { + return "", systemdWriteRollback(err, servicePath) + } return "", err } oc := OnCalendar(s.Now(), hours) if err := fsutil.WriteRootFile(timerPath, []byte(timerContent(unit, oc)), 0o644); err != nil { - _ = os.Remove(servicePath) - return "", err + var committed *fsutil.DurabilityError + if errors.As(err, &committed) { + return "", systemdWriteRollback(err, timerPath, servicePath) + } + return "", systemdWriteRollback(err, servicePath) } if err := s.Sys.Systemctl("daemon-reload"); err != nil { - _ = os.Remove(servicePath) - _ = os.Remove(timerPath) - return "", err + return "", systemdWriteRollback(err, timerPath, servicePath) } if err := s.Sys.Systemctl("enable", "--now", unit+".timer"); err != nil { - _ = os.Remove(servicePath) - _ = os.Remove(timerPath) - _ = s.Sys.Systemctl("daemon-reload") - return "", err + return "", s.rollbackFailedEnable(unit, servicePath, timerPath, err) } return unit, nil } +func systemdWriteRollback(cause error, paths ...string) error { + errs := []error{cause} + rollbackFailed := false + for _, path := range paths { + if err := fsutil.RemoveFile(path); err != nil { + errs = append(errs, fmt.Errorf("remove partially committed file %s: %w", path, err)) + rollbackFailed = true + } + } + joined := errors.Join(errs...) + if rollbackFailed { + return &systemdRollbackError{err: joined} + } + return joined +} + +type systemdRollbackError struct{ err error } + +func (e *systemdRollbackError) Error() string { return e.err.Error() } +func (e *systemdRollbackError) Unwrap() error { return e.err } + +func (s *Scheduler) rollbackFailedEnable(unit, servicePath, timerPath string, enableErr error) error { + errs := []error{fmt.Errorf("enable systemd timer: %w", enableErr)} + rollbackFailed := false + timerUnit := unit + ".timer" + if err := s.Sys.Systemctl("disable", "--now", timerUnit); err != nil && !systemctlUnitFileMissing(err, timerUnit) { + errs = append(errs, fmt.Errorf("rollback disable systemd timer: %w", err)) + // enable --now may have started the timer before returning its error. If + // stopping it cannot be confirmed, keep both files as durable inventory and + // retry evidence; deleting them can leave the only surviving timer hidden in + // systemd's in-memory state. + return &systemdRollbackError{err: errors.Join(errs...)} + } + if err := s.removeSystemdTimerStamp(timerUnit); err != nil { + errs = append(errs, err) + rollbackFailed = true + } + for _, path := range []string{timerPath, servicePath} { + if err := fsutil.RemoveFile(path); err != nil { + errs = append(errs, fmt.Errorf("rollback remove %s: %w", path, err)) + rollbackFailed = true + } + } + if err := s.Sys.Systemctl("daemon-reload"); err != nil { + errs = append(errs, fmt.Errorf("rollback daemon-reload: %w", err)) + rollbackFailed = true + } + joined := errors.Join(errs...) + if rollbackFailed { + return &systemdRollbackError{err: joined} + } + return joined +} + func (s *Scheduler) scheduleAt(user string, uid int, generation string, hours int) (string, error) { if !s.Sys.HasAt() { return "", fmt.Errorf("no systemctl or at available") @@ -216,12 +276,14 @@ func (s *Scheduler) scheduleAt(user string, uid int, generation string, hours in // Cancel removes the auto-revoke task for user. It always sweeps a matching at // job AND cleans the systemd units, regardless of which was recorded, so a -// reused username never leaves a stale task behind. Only when this run is the -// firing service for THIS unit (UnderUnit) is the .service file left and -// daemon-reload skipped, so it never deletes the file it is executing from; a -// manual revoke — even from another systemd scope — cleans the .service up. +// reused username never leaves a stale task behind. A firing oneshot may unlink +// its own unit file safely: systemd has already +// loaded the unit and the file is not the running process image. Removing both +// files and reloading prevents every successful automatic revoke from leaving a +// permanent orphaned .service behind. func (s *Scheduler) Cancel(user, recordedUnit string) error { var errs []error + hasSystemctl := s.Sys.HasSystemctl() // Remove a specifically-recorded at job even where atq is unavailable (so // RemoveAtJobsFor's body sweep can't run). if strings.HasPrefix(recordedUnit, "at:") { @@ -240,7 +302,6 @@ func (s *Scheduler) Cancel(user, recordedUnit string) error { // the v2 name alone would leave it armed. There is normally at most one unit per // account, so the extra names are no-ops on a pure-v2 host. reloadNeeded := false - skipReload := false for _, prefix := range s.unitPrefixes() { unit := prefix + user if strings.ContainsAny(unit, "/ ") { @@ -248,37 +309,48 @@ func (s *Scheduler) Cancel(user, recordedUnit string) error { } timerPath := filepath.Join(s.SystemdDir, unit+".timer") servicePath := filepath.Join(s.SystemdDir, unit+".service") - _, timerErr := os.Lstat(timerPath) - _, serviceErr := os.Lstat(servicePath) - hadUnit := timerErr == nil || serviceErr == nil - if s.Sys.HasSystemctl() { + if !hasSystemctl { + hasUnitEvidence := recordedUnit == unit + for _, path := range []string{timerPath, servicePath} { + exists, err := schedulePathExists(path) + if err != nil { + errs = append(errs, err) + hasUnitEvidence = true + } else if exists { + hasUnitEvidence = true + } + } + if hasUnitEvidence { + errs = append(errs, fmt.Errorf("systemctl is unavailable; cannot confirm %s.timer is stopped, preserving its unit files and registry evidence", unit)) + continue + } + } + if hasSystemctl { timerUnit := unit + ".timer" err := s.Sys.Systemctl("disable", "--now", timerUnit) - if err != nil && hadUnit && !systemctlUnitFileMissing(err, timerUnit) { + if err != nil && !systemctlUnitFileMissing(err, timerUnit) { errs = append(errs, err) + // Preserve both files as retry/inventory evidence. Deleting them after + // a stop failure can leave a timer active only in systemd's memory. + continue } _ = s.Sys.Systemctl("reset-failed", unit+".timer", unit+".service") + if err := s.removeSystemdTimerStamp(timerUnit); err != nil { + errs = append(errs, err) + } } if removed, err := removeIfNotSymlink(timerPath); err != nil { errs = append(errs, err) } else if removed { reloadNeeded = true } - // Never delete the .service this very run is executing from (a firing v2 - // auto-revoke); a manual revoke, even from another systemd scope, does clean - // it. The firing unit is always the v2 one, so this only guards that name. - underUnit := s.UnderUnit != nil && s.UnderUnit(unit) - if underUnit { - skipReload = true - } else { - if removed, err := removeIfNotSymlink(servicePath); err != nil { - errs = append(errs, err) - } else if removed { - reloadNeeded = true - } + if removed, err := removeIfNotSymlink(servicePath); err != nil { + errs = append(errs, err) + } else if removed { + reloadNeeded = true } } - if reloadNeeded && !skipReload && s.Sys.HasSystemctl() { + if reloadNeeded && hasSystemctl { if err := s.Sys.Systemctl("daemon-reload"); err != nil { errs = append(errs, err) } @@ -286,6 +358,97 @@ func (s *Scheduler) Cancel(user, recordedUnit string) error { return errors.Join(errs...) } +func (s *Scheduler) removeSystemdTimerStamp(timerUnit string) error { + stateDir, configured, err := s.systemdTimerStateDirectory() + if err != nil { + return err + } + if !configured { + return nil + } + if filepath.Base(timerUnit) != timerUnit || !strings.HasSuffix(timerUnit, ".timer") { + return fmt.Errorf("invalid systemd timer unit %q", timerUnit) + } + stampPath := filepath.Join(stateDir, "stamp-"+timerUnit) + if err := fsutil.RemoveFile(stampPath); err != nil { + return fmt.Errorf("remove systemd timer timestamp %s: %w", stampPath, err) + } + return nil +} + +// CleanupTimerStamps removes persistent-timer timestamps left by older +// releases after their unit and registry evidence had already disappeared. It +// is intended for the final uninstall sweep, after callers have proved no +// managed timer remains active. Removing a stamp while its timer is live would +// alter systemd's catch-up behavior after a reboot, so ordinary cleanup uses +// Cancel instead. +func (s *Scheduler) CleanupTimerStamps() error { + stateDir, configured, err := s.systemdTimerStateDirectory() + if err != nil { + return err + } + if !configured { + return nil + } + entries, err := os.ReadDir(stateDir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read systemd timer state directory %s: %w", stateDir, err) + } + + var errs []error + for _, entry := range entries { + if !s.managedTimerStamp(entry.Name()) { + continue + } + path := filepath.Join(stateDir, entry.Name()) + if err := fsutil.RemoveFile(path); err != nil { + errs = append(errs, fmt.Errorf("remove systemd timer timestamp %s: %w", path, err)) + } + } + return errors.Join(errs...) +} + +func (s *Scheduler) systemdTimerStateDirectory() (string, bool, error) { + if s == nil || s.SystemdTimerStateDir == "" { + return "", false, nil + } + stateDir := filepath.Clean(s.SystemdTimerStateDir) + if !filepath.IsAbs(stateDir) || stateDir == string(filepath.Separator) { + return "", false, fmt.Errorf("unsafe systemd timer state directory %q", s.SystemdTimerStateDir) + } + return stateDir, true, nil +} + +func (s *Scheduler) managedTimerStamp(name string) bool { + if !strings.HasSuffix(name, ".timer") { + return false + } + for _, prefix := range s.unitPrefixes() { + if prefix == "" || filepath.Base(prefix) != prefix || strings.ContainsAny(prefix, "/ ") { + continue + } + stem := strings.TrimSuffix(strings.TrimPrefix(name, "stamp-"+prefix), ".timer") + if strings.HasPrefix(name, "stamp-"+prefix) && stem != "" { + return true + } + } + return false +} + +func schedulePathExists(path string) (bool, error) { + _, err := os.Lstat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("inspect schedule file %s: %w", path, err) +} + func removeIfNotSymlink(path string) (bool, error) { fi, err := os.Lstat(path) if os.IsNotExist(err) { @@ -297,7 +460,7 @@ func removeIfNotSymlink(path string) (bool, error) { if fi.Mode()&os.ModeSymlink != 0 { return false, fmt.Errorf("refusing to remove symlinked schedule file %s", path) } - if err := os.Remove(path); err != nil { + if err := fsutil.RemoveFile(path); err != nil { return false, err } return true, nil diff --git a/internal/schedule/schedule_test.go b/internal/schedule/schedule_test.go index 2e4936e..09019b9 100644 --- a/internal/schedule/schedule_test.go +++ b/internal/schedule/schedule_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -47,11 +48,12 @@ func (f *fakeSystem) AtJobs() ([]AtJob, error) { return f.atJobs, f.atJobsErr } func newScheduler(dir string, sys System) *Scheduler { return &Scheduler{ - SystemdDir: dir, - InstallPath: "/usr/local/sbin/linux-temp-admin", - UnitPrefix: "linux-temp-admin-v2-revoke-", - Now: func() time.Time { return time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) }, - Sys: sys, + SystemdDir: dir, + SystemdTimerStateDir: filepath.Join(dir, "timer-state"), + InstallPath: "/usr/local/sbin/linux-temp-admin", + UnitPrefix: "linux-temp-admin-v2-revoke-", + Now: func() time.Time { return time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) }, + Sys: sys, } } @@ -114,16 +116,151 @@ func TestScheduleNoBackend(t *testing.T) { } } +func TestScheduleRejectsReservedLinuxUIDBeforeMutation(t *testing.T) { + if strconv.IntSize < 64 { + t.Skip("int cannot represent the reserved uint32 uid sentinel") + } + sys := &fakeSystem{hasSystemctl: true, hasAt: true, atID: "42"} + s := newScheduler(t.TempDir(), sys) + reserved := int(uint64(^uint32(0))) + if _, err := s.Schedule("xxvcc-a1", reserved, testGeneration, 6); err == nil || !strings.Contains(err.Error(), "invalid Linux account UID") { + t.Fatalf("Schedule reserved UID error = %v, want range refusal", err) + } + if len(sys.calls) != 0 || sys.atCommand != "" { + t.Fatalf("Schedule mutated a backend before rejecting UID: systemctl=%v at=%q", sys.calls, sys.atCommand) + } +} + +func TestScheduleRejectsInvalidIdentityAndLifetimeBeforeMutation(t *testing.T) { + for _, tc := range []struct { + name string + user string + generation string + hours int + }{ + {name: "username", user: "bad user", generation: testGeneration, hours: 1}, + {name: "generation", user: "xxvcc-a1", generation: "bad", hours: 1}, + {name: "zero hours", user: "xxvcc-a1", generation: testGeneration, hours: 0}, + {name: "excessive hours", user: "xxvcc-a1", generation: testGeneration, hours: 24*366 + 1}, + } { + t.Run(tc.name, func(t *testing.T) { + sys := &fakeSystem{hasSystemctl: true, hasAt: true} + s := &Scheduler{SystemdDir: t.TempDir(), InstallPath: "/usr/local/sbin/linux-temp-admin", UnitPrefix: "lta-", Now: time.Now, Sys: sys} + if _, err := s.Schedule(tc.user, 1001, tc.generation, tc.hours); err == nil { + t.Fatal("Schedule accepted invalid input") + } + if len(sys.calls) != 0 || sys.atCommand != "" { + t.Fatalf("invalid input reached scheduler backend: calls=%v at=%q", sys.calls, sys.atCommand) + } + if entries, err := os.ReadDir(s.SystemdDir); err != nil || len(entries) != 0 { + t.Fatalf("invalid input changed systemd directory: entries=%v err=%v", entries, err) + } + }) + } +} + +func TestScheduleRollsBackPartiallyEnabledSystemdTimerBeforeAtFallback(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("systemd schedule rollback requires root-owned fixtures") + } + dir := t.TempDir() + sys := &fakeSystem{hasSystemctl: true, hasAt: true, atID: "42"} + sys.systemctlErr = func(args ...string) error { + if len(args) == 3 && args[0] == "enable" { + return errors.New("enable failed after starting timer") + } + return nil + } + s := newScheduler(dir, sys) + if err := os.Mkdir(s.SystemdTimerStateDir, 0o700); err != nil { + t.Fatal(err) + } + unit := s.UnitName("xxvcc-a1") + stamp := filepath.Join(s.SystemdTimerStateDir, "stamp-"+unit+".timer") + if err := os.WriteFile(stamp, nil, 0o644); err != nil { + t.Fatal(err) + } + + got, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", 6) + if err != nil { + t.Fatal(err) + } + if got != "at:42" { + t.Fatalf("Schedule = %q, want at fallback", got) + } + wantCalls := []string{ + "daemon-reload", + "enable --now " + unit + ".timer", + "disable --now " + unit + ".timer", + "daemon-reload", + } + if gotCalls := joinedSystemctlCalls(sys.calls); strings.Join(gotCalls, "|") != strings.Join(wantCalls, "|") { + t.Fatalf("systemctl calls = %v, want %v", gotCalls, wantCalls) + } + for _, suffix := range []string{".service", ".timer"} { + if _, statErr := os.Lstat(filepath.Join(dir, unit+suffix)); !os.IsNotExist(statErr) { + t.Errorf("%s survived rollback", suffix) + } + } + if _, statErr := os.Lstat(stamp); !os.IsNotExist(statErr) { + t.Errorf("persistent timer timestamp survived rollback: %v", statErr) + } +} + +func TestScheduleDoesNotFallbackWhenSystemdRollbackFails(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("systemd schedule rollback requires root-owned fixtures") + } + dir := t.TempDir() + sys := &fakeSystem{hasSystemctl: true, hasAt: true, atID: "42"} + sys.systemctlErr = func(args ...string) error { + if len(args) == 3 && args[0] == "enable" { + return errors.New("enable failed after starting timer") + } + if len(args) == 3 && args[0] == "disable" { + return errors.New("rollback disable failed") + } + return nil + } + s := newScheduler(dir, sys) + + _, err := s.Schedule("xxvcc-a1", 1001, "0123456789abcdef0123456789abcdef", 6) + if err == nil || !strings.Contains(err.Error(), "enable failed") || !strings.Contains(err.Error(), "rollback disable failed") { + t.Fatalf("Schedule error = %v, want original and rollback failures", err) + } + if sys.atCommand != "" { + t.Fatalf("unsafe at fallback was attempted after incomplete rollback: %q", sys.atCommand) + } + unit := s.UnitName("xxvcc-a1") + for _, suffix := range []string{".service", ".timer"} { + if _, statErr := os.Lstat(filepath.Join(dir, unit+suffix)); statErr != nil { + t.Errorf("%s was removed after rollback could not stop the timer: %v", suffix, statErr) + } + } +} + +func joinedSystemctlCalls(calls [][]string) []string { + joined := make([]string, 0, len(calls)) + for _, call := range calls { + joined = append(joined, strings.Join(call, " ")) + } + return joined +} + func TestCancelCleansBothAndRemovesUnits(t *testing.T) { dir := t.TempDir() sys := &fakeSystem{hasSystemctl: true} s := newScheduler(dir, sys) - s.UnderUnit = func(string) bool { return false } // not the firing service -> full cleanup unit := s.UnitName("xxvcc-a1") svc := filepath.Join(dir, unit+".service") tmr := filepath.Join(dir, unit+".timer") + if err := os.Mkdir(s.SystemdTimerStateDir, 0o700); err != nil { + t.Fatal(err) + } + stamp := filepath.Join(s.SystemdTimerStateDir, "stamp-"+unit+".timer") os.WriteFile(svc, []byte("x"), 0o644) os.WriteFile(tmr, []byte("x"), 0o644) + os.WriteFile(stamp, nil, 0o644) if err := s.Cancel("xxvcc-a1", ""); err != nil { t.Fatal(err) @@ -144,6 +281,9 @@ func TestCancelCleansBothAndRemovesUnits(t *testing.T) { if _, err := os.Lstat(tmr); !os.IsNotExist(err) { t.Error("timer file should be removed") } + if _, err := os.Lstat(stamp); !os.IsNotExist(err) { + t.Error("persistent timer timestamp should be removed") + } // systemctl disable + reset-failed + daemon-reload were invoked var seen []string for _, c := range sys.calls { @@ -157,11 +297,89 @@ func TestCancelCleansBothAndRemovesUnits(t *testing.T) { } } +func TestCancelUnlinksSystemdTimerStampSymlinkWithoutFollowingIt(t *testing.T) { + dir := t.TempDir() + sys := &fakeSystem{hasSystemctl: true} + s := newScheduler(dir, sys) + if err := os.Mkdir(s.SystemdTimerStateDir, 0o700); err != nil { + t.Fatal(err) + } + unit := s.UnitName("xxvcc-a1") + target := filepath.Join(dir, "must-survive") + if err := os.WriteFile(target, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + stamp := filepath.Join(s.SystemdTimerStateDir, "stamp-"+unit+".timer") + if err := os.Symlink(target, stamp); err != nil { + t.Fatal(err) + } + + if err := s.Cancel("xxvcc-a1", unit); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(stamp); !os.IsNotExist(err) { + t.Fatalf("timer timestamp symlink survived cancellation: %v", err) + } + if got, err := os.ReadFile(target); err != nil || string(got) != "keep" { + t.Fatalf("timer timestamp target changed: content=%q err=%v", got, err) + } +} + +func TestCleanupTimerStampsRemovesManagedNamespacesOnly(t *testing.T) { + dir := t.TempDir() + s := newScheduler(dir, &fakeSystem{}) + s.LegacyUnitPrefixes = []string{"linux-temp-admin-revoke-"} + if err := os.Mkdir(s.SystemdTimerStateDir, 0o700); err != nil { + t.Fatal(err) + } + managed := []string{ + "stamp-linux-temp-admin-v2-revoke-oldgone.timer", + "stamp-linux-temp-admin-revoke-oldergone.timer", + // Corrupt names in the owned namespace still belong to the old install and + // must not become permanent residue. + "stamp-linux-temp-admin-v2-revoke-bad$name.timer", + } + unrelated := []string{ + "stamp-apt-daily.timer", + "stamp-linux-temp-admin-v2-revoke-.timer", + "linux-temp-admin-v2-revoke-no-stamp-prefix.timer", + "stamp-linux-temp-admin-v2-revoke-wrong.service", + } + for _, name := range append(append([]string(nil), managed...), unrelated...) { + if err := os.WriteFile(filepath.Join(s.SystemdTimerStateDir, name), nil, 0o644); err != nil { + t.Fatal(err) + } + } + + if err := s.CleanupTimerStamps(); err != nil { + t.Fatal(err) + } + for _, name := range managed { + if _, err := os.Lstat(filepath.Join(s.SystemdTimerStateDir, name)); !os.IsNotExist(err) { + t.Errorf("managed timestamp survived cleanup: %s", name) + } + } + for _, name := range unrelated { + if _, err := os.Lstat(filepath.Join(s.SystemdTimerStateDir, name)); err != nil { + t.Errorf("unrelated timestamp was removed: %s: %v", name, err) + } + } +} + +func TestCleanupTimerStampsRejectsUnsafeStateDirectory(t *testing.T) { + s := newScheduler(t.TempDir(), &fakeSystem{}) + for _, path := range []string{"relative", "/"} { + s.SystemdTimerStateDir = path + if err := s.CleanupTimerStamps(); err == nil || !strings.Contains(err.Error(), "unsafe systemd timer state directory") { + t.Errorf("CleanupTimerStamps(%q) error = %v, want unsafe-path refusal", path, err) + } + } +} + func TestCancelTreatsMissingTimerAsSuccessWhenOnlyServiceRemains(t *testing.T) { dir := t.TempDir() sys := &fakeSystem{hasSystemctl: true} s := newScheduler(dir, sys) - s.UnderUnit = func(string) bool { return false } unit := s.UnitName("xxvcc-a1") servicePath := filepath.Join(dir, unit+".service") if err := os.WriteFile(servicePath, []byte("x"), 0o644); err != nil { @@ -193,7 +411,6 @@ func TestCancelStillReportsNonMissingTimerFailure(t *testing.T) { dir := t.TempDir() sys := &fakeSystem{hasSystemctl: true} s := newScheduler(dir, sys) - s.UnderUnit = func(string) bool { return false } unit := s.UnitName("xxvcc-a1") servicePath := filepath.Join(dir, unit+".service") if err := os.WriteFile(servicePath, []byte("x"), 0o644); err != nil { @@ -214,8 +431,55 @@ func TestCancelStillReportsNonMissingTimerFailure(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "Permission denied") { t.Fatalf("Cancel error = %v, want the real systemctl failure", err) } - if _, err := os.Lstat(servicePath); !os.IsNotExist(err) { - t.Error("cleanup should still remove the service after a systemctl failure") + if _, err := os.Lstat(servicePath); err != nil { + t.Error("cleanup must preserve the service as retry evidence after a systemctl failure") + } +} + +func TestCancelReportsStopFailureEvenWithoutUnitFiles(t *testing.T) { + sys := &fakeSystem{hasSystemctl: true} + sys.systemctlErr = func(args ...string) error { + if len(args) == 3 && args[0] == "disable" { + return &systemctlError{args: append([]string(nil), args...), err: errors.New("exit status 1"), output: "Failed to connect to bus: Permission denied"} + } + return nil + } + s := newScheduler(t.TempDir(), sys) + err := s.Cancel("xxvcc-a1", "") + if err == nil || !strings.Contains(err.Error(), "Permission denied") { + t.Fatalf("Cancel error = %v, want an in-memory timer stop failure", err) + } +} + +func TestCancelPreservesSystemdEvidenceWhenSystemctlIsUnavailable(t *testing.T) { + dir := t.TempDir() + sys := &fakeSystem{hasSystemctl: false} + s := newScheduler(dir, sys) + unit := s.UnitName("xxvcc-a1") + for _, suffix := range []string{".service", ".timer"} { + if err := os.WriteFile(filepath.Join(dir, unit+suffix), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + err := s.Cancel("xxvcc-a1", unit) + if err == nil || !strings.Contains(err.Error(), "systemctl is unavailable") { + t.Fatalf("Cancel error = %v, want inability to prove the timer stopped", err) + } + for _, suffix := range []string{".service", ".timer"} { + if _, statErr := os.Lstat(filepath.Join(dir, unit+suffix)); statErr != nil { + t.Errorf("%s was removed without stopping systemd: %v", suffix, statErr) + } + } +} + +func TestCancelKeepsRecordedSystemdTaskWithoutFilesWhenSystemctlIsUnavailable(t *testing.T) { + sys := &fakeSystem{hasSystemctl: false} + s := newScheduler(t.TempDir(), sys) + unit := s.UnitName("xxvcc-a1") + + if err := s.Cancel("xxvcc-a1", unit); err == nil || !strings.Contains(err.Error(), "systemctl is unavailable") { + t.Fatalf("Cancel error = %v, want recorded in-memory timer uncertainty", err) } } @@ -236,15 +500,14 @@ func TestCancelPropagatesAtRemovalFailure(t *testing.T) { } } -// TestCancelUnderFiringServiceLeavesServiceFile documents the INVOCATION_ID guard: -// when Cancel runs inside the firing systemd service, it still disables the timer -// and removes the .timer file, but leaves its own .service file and skips -// daemon-reload so the currently-executing unit is not disturbed. -func TestCancelUnderFiringServiceLeavesServiceFile(t *testing.T) { +// TestCancelUnderFiringServiceRemovesBothFiles pins the successful firing path: +// the unit is already loaded, so unlinking its configuration and reloading does +// not stop the oneshot process and avoids a permanent orphaned .service. +func TestCancelUnderFiringServiceRemovesBothFiles(t *testing.T) { dir := t.TempDir() sys := &fakeSystem{hasSystemctl: true} s := newScheduler(dir, sys) - s.UnderUnit = func(string) bool { return true } // simulate running as the firing service + t.Setenv("INVOCATION_ID", "test-firing-service") unit := s.UnitName("xxvcc-a1") svc := filepath.Join(dir, unit+".service") tmr := filepath.Join(dir, unit+".timer") @@ -258,8 +521,8 @@ func TestCancelUnderFiringServiceLeavesServiceFile(t *testing.T) { if _, err := os.Lstat(tmr); !os.IsNotExist(err) { t.Error("timer file should still be removed under the firing service") } - if _, err := os.Lstat(svc); err != nil { - t.Error("service file should be left in place under the firing service") + if _, err := os.Lstat(svc); !os.IsNotExist(err) { + t.Error("service file should be removed under the firing service") } var seen []string for _, c := range sys.calls { @@ -269,8 +532,8 @@ func TestCancelUnderFiringServiceLeavesServiceFile(t *testing.T) { if !strings.Contains(joined, "disable") || !strings.Contains(joined, "reset-failed") { t.Errorf("expected disable + reset-failed even under the firing service; calls=%v", sys.calls) } - if strings.Contains(joined, "daemon-reload") { - t.Errorf("daemon-reload must be skipped under the firing service; calls=%v", sys.calls) + if !strings.Contains(joined, "daemon-reload") { + t.Errorf("daemon-reload must run after firing-service cleanup; calls=%v", sys.calls) } } @@ -279,6 +542,7 @@ func TestParseAtJobID(t *testing.T) { "job 7 at Wed Jul 8 12:00:00 2026": "7", "warning: commands will be executed\njob 12 at ...": "12", "9\tWed Jul 8": "9", + "job -1 at ...": "", "nothing useful": "", } for in, want := range cases { diff --git a/internal/schedule/system.go b/internal/schedule/system.go index 08062b6..53e20fd 100644 --- a/internal/schedule/system.go +++ b/internal/schedule/system.go @@ -2,13 +2,39 @@ package schedule import ( "bufio" + "context" "errors" "fmt" "os/exec" "strconv" "strings" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/executil" + "github.com/xxvcc/linux-temp-admin/internal/validate" +) + +const ( + schedulerCommandTimeout = 15 * time.Second + schedulerOutputLimit = int64(64 << 10) + atQueueOutputLimit = int64(4 << 20) + atJobBodyLimit = int64(1 << 20) + maxAtJobs = 4096 ) +var ( + stableCommandLocale = []string{"LC_ALL=C", "LANG=C"} + atInventoryTimeout = 30 * time.Second + atInventoryMaxBodyBytes = int64(16 << 20) +) + +func schedulerCommandOptions(maxOutput int64) executil.Options { + return executil.Options{ + Timeout: schedulerCommandTimeout, MaxOutput: maxOutput, + ExtraEnv: stableCommandLocale, + } +} + // realSystem drives systemctl and at via os/exec. type realSystem struct{} @@ -30,14 +56,14 @@ func (e *systemctlError) Unwrap() error { return e.err } func has(name string) bool { _, err := exec.LookPath(name); return err == nil } func (realSystem) HasSystemctl() bool { return has("systemctl") } -func (realSystem) HasAt() bool { return has("at") } +func (realSystem) HasAt() bool { + return has("at") || has("atq") || has("atrm") || has("atd") +} func (realSystem) Systemctl(args ...string) error { - cmd := exec.Command("systemctl", args...) // Classification below relies on systemctl's diagnostics. Force the stable C // locale instead of trying to recognize every translated error message. - cmd.Env = append(cmd.Environ(), "LC_ALL=C") - out, err := cmd.CombinedOutput() + out, err := executil.CombinedOutput("systemctl", args, schedulerCommandOptions(schedulerOutputLimit)) if err != nil { return &systemctlError{ args: append([]string(nil), args...), @@ -64,12 +90,17 @@ func systemctlUnitFileMissing(err error, unit string) bool { } func (realSystem) ScheduleAt(command string, hours int) (string, error) { + for _, tool := range []string{"at", "atq", "atrm"} { + if !has(tool) { + return "", fmt.Errorf("%s is unavailable; refusing to create an at job that cannot be inventoried and cancelled", tool) + } + } if !ensureAtd() { return "", fmt.Errorf("atd is not running and could not be started; use systemd or start atd") } - cmd := exec.Command("at", "now", "+", strconv.Itoa(hours), "hours") - cmd.Stdin = strings.NewReader(command + "\n") - out, err := cmd.CombinedOutput() + opts := schedulerCommandOptions(schedulerOutputLimit) + opts.Stdin = strings.NewReader(command + "\n") + out, err := executil.CombinedOutput("at", []string{"now", "+", strconv.Itoa(hours), "hours"}, opts) if err != nil { return "", fmt.Errorf("at: %w: %s", err, strings.TrimSpace(string(out))) } @@ -83,20 +114,22 @@ func (realSystem) ScheduleAt(command string, hours int) (string, error) { // parseAtJobID extracts the numeric job id from at's output ("job 7 at ..."). func parseAtJobID(out string) string { sc := bufio.NewScanner(strings.NewReader(out)) + sc.Buffer(make([]byte, 1024), int(schedulerOutputLimit)) for sc.Scan() { fields := strings.Fields(sc.Text()) if len(fields) >= 2 && fields[0] == "job" { - if _, err := strconv.Atoi(fields[1]); err == nil { + if numericJobID(fields[1]) { return fields[1] } } } // Fallback: first line whose first field is numeric. sc = bufio.NewScanner(strings.NewReader(out)) + sc.Buffer(make([]byte, 1024), int(schedulerOutputLimit)) for sc.Scan() { fields := strings.Fields(sc.Text()) if len(fields) >= 1 { - if _, err := strconv.Atoi(fields[0]); err == nil { + if numericJobID(fields[0]) { return fields[0] } } @@ -104,17 +137,19 @@ func parseAtJobID(out string) string { return "" } -// ensureAtd makes a best effort to confirm/start the atd daemon so queued at -// jobs actually fire. Returns true if atd appears runnable. +// ensureAtd confirms or starts the atd daemon so queued jobs actually fire. It +// fails closed when no available service manager or process probe can confirm it. func ensureAtd() bool { - run := func(name string, args ...string) bool { return exec.Command(name, args...).Run() == nil } + run := func(name string, args ...string) bool { + return executil.Run(name, args, schedulerCommandOptions(schedulerOutputLimit)) == nil + } // Try each init system in turn (not first-match), returning as soon as atd is // confirmed runnable; do not claim success without confirmation. if has("systemctl") { if run("systemctl", "is-active", "--quiet", "atd") { return true } - _ = exec.Command("systemctl", "enable", "--now", "atd").Run() + _ = executil.Run("systemctl", []string{"enable", "--now", "atd"}, schedulerCommandOptions(schedulerOutputLimit)) if run("systemctl", "is-active", "--quiet", "atd") { return true } @@ -123,7 +158,7 @@ func ensureAtd() bool { if run("rc-service", "atd", "status") { return true } - _ = exec.Command("rc-service", "atd", "start").Run() + _ = executil.Run("rc-service", []string{"atd", "start"}, schedulerCommandOptions(schedulerOutputLimit)) if run("rc-service", "atd", "status") { return true } @@ -132,14 +167,15 @@ func ensureAtd() bool { if run("service", "atd", "status") { return true } - if run("service", "atd", "start") { // start exit 0 = running + _ = run("service", "atd", "start") + if run("service", "atd", "status") { return true } } if has("pgrep") { return run("pgrep", "-x", "atd") } - return true // no way to probe; proceed best-effort rather than disable at entirely + return false } func (realSystem) AtrmJob(id string) error { @@ -160,7 +196,7 @@ func (realSystem) AtrmJob(id string) error { if queued, err := atJobQueued(id); err == nil && !queued { return nil } - if out, err := exec.Command("atrm", id).CombinedOutput(); err != nil { + if out, err := executil.CombinedOutput("atrm", []string{id}, schedulerCommandOptions(schedulerOutputLimit)); err != nil { // The job may have fired between the queue check and atrm. Confirm absence // once more before reporting the command failure. if queued, qerr := atJobQueued(id); qerr == nil && !queued { @@ -172,14 +208,21 @@ func (realSystem) AtrmJob(id string) error { } func atJobQueued(id string) (bool, error) { + return atJobQueuedContext(context.Background(), id) +} + +func atJobQueuedContext(ctx context.Context, id string) (bool, error) { if !has("atq") { return false, fmt.Errorf("atq is unavailable") } - out, err := exec.Command("atq").Output() + opts := schedulerCommandOptions(atQueueOutputLimit) + opts.Context = ctx + out, err := executil.Output("atq", nil, opts) if err != nil { return false, fmt.Errorf("atq: %w", err) } sc := bufio.NewScanner(strings.NewReader(string(out))) + sc.Buffer(make([]byte, 1024), int(schedulerOutputLimit)) for sc.Scan() { fields := strings.Fields(sc.Text()) if len(fields) > 0 && fields[0] == id { @@ -193,13 +236,24 @@ func atJobQueued(id string) (bool, error) { } func (r realSystem) RemoveAtJobsFor(command string) error { + selector, ok := parseAtRevokeCommand(command, "") + if !ok || selector.kind != atRevokeLegacy { + return fmt.Errorf("invalid at revoke selector %q", command) + } + // `at` is an optional fallback. A systemd-only host with no trace of that + // backend has nothing to sweep and must still be able to revoke and uninstall. + // HasAt is deliberately true for a partial installation, so missing inventory + // commands in that case remain an error instead of hiding a possibly-live job. + if !r.HasAt() { + return nil + } jobs, err := r.AtJobs() if err != nil { return err } var errs []error for _, job := range jobs { - if strings.Contains(job.Body, command) { + if atBodyHasKnownRevoke(job.Body, selector.installPath, selector.user) { if err := r.AtrmJob(job.ID); err != nil { errs = append(errs, err) } @@ -209,28 +263,58 @@ func (r realSystem) RemoveAtJobsFor(command string) error { } func (realSystem) AtJobs() ([]AtJob, error) { - if !has("atq") || !has("at") || !has("atrm") { - return nil, nil + if !has("atq") { + return nil, fmt.Errorf("atq is unavailable") } - out, err := exec.Command("atq").Output() + if !has("at") { + return nil, fmt.Errorf("at is unavailable") + } + ctx, cancel := context.WithTimeout(context.Background(), atInventoryTimeout) + defer cancel() + queueOpts := schedulerCommandOptions(atQueueOutputLimit) + queueOpts.Context = ctx + out, err := executil.Output("atq", nil, queueOpts) if err != nil { return nil, fmt.Errorf("atq: %w", err) } var jobs []AtJob + inspected := 0 + totalBodyBytes := int64(0) sc := bufio.NewScanner(strings.NewReader(string(out))) + sc.Buffer(make([]byte, 1024), int(schedulerOutputLimit)) for sc.Scan() { fields := strings.Fields(sc.Text()) if len(fields) == 0 { continue } id := fields[0] - if _, err := strconv.Atoi(id); err != nil { + if !numericJobID(id) { continue } - body, err := exec.Command("at", "-c", id).Output() + inspected++ + if inspected > maxAtJobs { + return nil, fmt.Errorf("at queue contains more than %d inspectable jobs", maxAtJobs) + } + bodyOpts := schedulerCommandOptions(atJobBodyLimit) + bodyOpts.Context = ctx + body, err := executil.Output("at", []string{"-c", id}, bodyOpts) if err != nil { + queued, queueErr := atJobQueuedContext(ctx, id) + if queueErr != nil { + return nil, errors.Join( + fmt.Errorf("read at job %s: %w", id, err), + fmt.Errorf("recheck at job %s: %w", id, queueErr), + ) + } + if !queued { + continue + } return nil, fmt.Errorf("read at job %s: %w", id, err) } + totalBodyBytes += int64(len(body)) + if totalBodyBytes > atInventoryMaxBodyBytes { + return nil, fmt.Errorf("at job inventory exceeds %d bytes", atInventoryMaxBodyBytes) + } jobs = append(jobs, AtJob{ID: id, Body: string(body)}) } if err := sc.Err(); err != nil { @@ -238,3 +322,83 @@ func (realSystem) AtJobs() ([]AtJob, error) { } return jobs, nil } + +type atRevokeKind uint8 + +const ( + atRevokeLegacy atRevokeKind = iota + 1 + atRevokeForced + atRevokeCurrent +) + +type atRevokeCommand struct { + installPath string + user string + kind atRevokeKind +} + +// parseAtRevokeCommand accepts only command lines emitted by known releases. +// It deliberately does not treat an arbitrary suffix after the stable legacy +// prefix as one of our jobs. +func parseAtRevokeCommand(line, expectedInstallPath string) (atRevokeCommand, bool) { + line = strings.TrimSpace(line) + fields := strings.Fields(line) + if len(fields) != 5 && len(fields) != 8 && len(fields) != 12 { + return atRevokeCommand{}, false + } + if fields[1] != "revoke" || fields[2] != "--user" || fields[4] != "--yes" { + return atRevokeCommand{}, false + } + if expectedInstallPath != "" && fields[0] != expectedInstallPath { + return atRevokeCommand{}, false + } + user := fields[3] + if !validate.Username(user) { + return atRevokeCommand{}, false + } + parsed := atRevokeCommand{installPath: fields[0], user: user, kind: atRevokeLegacy} + want := fmt.Sprintf("%s revoke --user %s --yes", fields[0], user) + switch len(fields) { + case 5: + case 8: + if fields[5] != "--force" || fields[6] != "--confirm-force" || fields[7] != user { + return atRevokeCommand{}, false + } + parsed.kind = atRevokeForced + want += " --force --confirm-force " + user + case 12: + if fields[5] != "--force" || fields[6] != "--confirm-force" || fields[7] != user || + fields[8] != "--expected-uid" || fields[10] != "--generation" { + return atRevokeCommand{}, false + } + uid, err := strconv.Atoi(fields[9]) + if err != nil || !validate.AccountID(uid) || !validate.Generation(fields[11]) { + return atRevokeCommand{}, false + } + parsed.kind = atRevokeCurrent + want += fmt.Sprintf(" --force --confirm-force %s --expected-uid %d --generation %s", user, uid, fields[11]) + } + if line != want { + return atRevokeCommand{}, false + } + return parsed, true +} + +func atBodyHasKnownRevoke(body, installPath, user string) bool { + for _, line := range strings.Split(body, "\n") { + command, ok := parseAtRevokeCommand(line, installPath) + if ok && command.user == user { + return true + } + } + return false +} + +func atBodyHasExactCommand(body, command string) bool { + for _, line := range strings.Split(body, "\n") { + if strings.TrimSpace(line) == command { + return true + } + } + return false +} diff --git a/internal/schedule/system_test.go b/internal/schedule/system_test.go index e15fb39..7cad34d 100644 --- a/internal/schedule/system_test.go +++ b/internal/schedule/system_test.go @@ -1,10 +1,16 @@ package schedule import ( + "context" + "errors" "os" "path/filepath" + "strconv" "strings" "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/executil" ) func writeCommand(t *testing.T, dir, name, body string) { @@ -64,3 +70,249 @@ func TestSystemctlOtherFailureIsNotClassifiedAsMissingUnit(t *testing.T) { t.Fatalf("permission failure was misclassified as a missing unit: %v", err) } } + +func TestSystemctlBoundsOutputAndForcesCLocale(t *testing.T) { + t.Run("locale", func(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "systemctl", "[ \"$LC_ALL\" = C ] || { echo wrong-locale >&2; exit 9; }") + t.Setenv("PATH", dir) + t.Setenv("LC_ALL", "C.UTF-8") + if err := (realSystem{}).Systemctl("daemon-reload"); err != nil { + t.Fatalf("systemctl did not force LC_ALL=C: %v", err) + } + }) + t.Run("output", func(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "systemctl", "while :; do printf 0123456789abcdef; done") + t.Setenv("PATH", dir) + err := (realSystem{}).Systemctl("daemon-reload") + if !errors.Is(err, executil.ErrOutputLimit) { + t.Fatalf("systemctl output error=%v, want output limit", err) + } + }) +} + +func TestSystemctlTimerStateClassification(t *testing.T) { + const unit = "linux-temp-admin-v2-revoke-xxvcc-a1.timer" + tests := []struct { + name string + query string + body string + want bool + }{ + {name: "disabled", query: "is-enabled", body: "exit 1", want: true}, + {name: "inactive", query: "is-active", body: "exit 3", want: true}, + {name: "unknown", query: "is-active", body: "exit 4", want: true}, + {name: "query failure", query: "is-enabled", body: "echo 'Failed to connect to bus' >&2; exit 1"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "systemctl", tt.body) + t.Setenv("PATH", dir) + err := (realSystem{}).Systemctl(tt.query, "--quiet", unit) + if got := systemctlTimerStateNegative(err, tt.query, unit); got != tt.want { + t.Fatalf("systemctlTimerStateNegative(%v) = %v, want %v", err, got, tt.want) + } + }) + } +} + +func TestAtJobsFailsClosedWhenInventoryCommandsAreMissing(t *testing.T) { + t.Run("atq", func(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "at", "exit 0") + t.Setenv("PATH", dir) + + if _, err := (realSystem{}).AtJobs(); err == nil || !strings.Contains(err.Error(), "atq is unavailable") { + t.Fatalf("AtJobs error = %v, want missing atq", err) + } + }) + + t.Run("at", func(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "atq", "exit 0") + t.Setenv("PATH", dir) + + if _, err := (realSystem{}).AtJobs(); err == nil || !strings.Contains(err.Error(), "at is unavailable") { + t.Fatalf("AtJobs error = %v, want missing at", err) + } + }) +} + +func TestAtJobsInventoryDoesNotRequireAtrm(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "atq", "printf '42\\tFri Jul 24 00:00:00 2026 a root\\n'") + writeCommand(t, dir, "at", "printf '%s\\n' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes'") + t.Setenv("PATH", dir) + + jobs, err := (realSystem{}).AtJobs() + if err != nil { + t.Fatal(err) + } + if len(jobs) != 1 || jobs[0].ID != "42" || !strings.Contains(jobs[0].Body, "revoke --user xxvcc-a1") { + t.Fatalf("AtJobs = %#v, want job 42 without atrm installed", jobs) + } +} + +func TestAtJobsSkipsJobThatDisappearsAfterAtq(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "listed") + writeCommand(t, dir, "atq", "if [ -f '"+marker+"' ]; then exit 0; fi\n: > '"+marker+"'\nprintf '42\\tFri Jul 24 00:00:00 2026 a root\\n'") + writeCommand(t, dir, "at", "exit 1") + t.Setenv("PATH", dir) + + jobs, err := (realSystem{}).AtJobs() + if err != nil { + t.Fatalf("a job firing between atq and at -c should be ignored: %v", err) + } + if len(jobs) != 0 { + t.Fatalf("AtJobs = %#v, want vanished job omitted", jobs) + } +} + +func TestAtJobsBoundsWholeInventorySizeAndTime(t *testing.T) { + t.Run("aggregate body size", func(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "atq", "printf '1 x\\n2 x\\n'") + writeCommand(t, dir, "at", "printf '12345\\n'") + t.Setenv("PATH", dir) + oldLimit := atInventoryMaxBodyBytes + atInventoryMaxBodyBytes = 8 + t.Cleanup(func() { atInventoryMaxBodyBytes = oldLimit }) + + if _, err := (realSystem{}).AtJobs(); err == nil || !strings.Contains(err.Error(), "inventory exceeds 8 bytes") { + t.Fatalf("AtJobs aggregate-limit error = %v", err) + } + }) + + t.Run("whole inventory timeout", func(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "atq", "printf '1 x\\n'") + writeCommand(t, dir, "at", "/bin/sleep 30") + t.Setenv("PATH", dir) + oldTimeout := atInventoryTimeout + atInventoryTimeout = 50 * time.Millisecond + t.Cleanup(func() { atInventoryTimeout = oldTimeout }) + + start := time.Now() + _, err := (realSystem{}).AtJobs() + if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > 2*time.Second { + t.Fatalf("AtJobs timeout error=%v elapsed=%s", err, time.Since(start)) + } + }) +} + +func TestEnsureAtdRejectsWhenNoProbeCanConfirmIt(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "at", "exit 0") + t.Setenv("PATH", dir) + + if ensureAtd() { + t.Fatal("ensureAtd reported success without any way to confirm atd is running") + } +} + +func TestEnsureAtdDoesNotTrustServiceStartExitAlone(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "start-called") + writeCommand(t, dir, "service", "if [ \"$2\" = start ]; then : > '"+marker+"'; exit 0; fi; exit 1") + t.Setenv("PATH", dir) + + if ensureAtd() { + t.Fatal("ensureAtd trusted service start without a successful status or process probe") + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("service start was not attempted: %v", err) + } +} + +func TestScheduleAtRequiresCancellationToolsBeforeQueueing(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "queued") + writeCommand(t, dir, "at", "touch '"+marker+"'") + writeCommand(t, dir, "atq", "exit 0") + t.Setenv("PATH", dir) + + _, err := (realSystem{}).ScheduleAt("true", 1) + if err == nil || !strings.Contains(err.Error(), "atrm is unavailable") { + t.Fatalf("ScheduleAt error = %v, want missing atrm refusal", err) + } + if _, err := os.Lstat(marker); !os.IsNotExist(err) { + t.Fatal("at was invoked before cancellation tooling was proved available") + } +} + +func TestScheduleAtForcesCLocaleBeforeParsingJobID(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "atq", "exit 0") + writeCommand(t, dir, "atrm", "exit 0") + writeCommand(t, dir, "pgrep", "exit 0") + writeCommand(t, dir, "at", "[ \"$LC_ALL\" = C ] || { echo localized-output >&2; exit 9; }; while read line; do :; done; echo 'job 7 at Fri Jul 24 00:00:00 2026'") + t.Setenv("PATH", dir) + t.Setenv("LC_ALL", "C.UTF-8") + + id, err := (realSystem{}).ScheduleAt("true", 1) + if err != nil || id != "7" { + t.Fatalf("ScheduleAt id=%q err=%v, want C-locale job 7", id, err) + } +} + +func TestRemoveAtJobsForMatchesOnlyKnownStandaloneRevokeCommand(t *testing.T) { + dir := t.TempDir() + removed := filepath.Join(dir, "removed") + writeCommand(t, dir, "atq", "printf '1 x\\n2 x\\n3 x\\n4 x\\n5 x\\n'") + writeCommand(t, dir, "at", `case "$2" in +1) printf '%s\n' '# /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes' ;; +2) printf '%s\n' 'echo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes' ;; +3) printf '%s\n' '/usr/local/sbin/linux-temp-admin-helper revoke --user xxvcc-a1 --yes' ;; +4) printf '%s\n' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --unknown' ;; +5) printf '%s\n' '/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --force --confirm-force xxvcc-a1 --expected-uid 1001 --generation 0123456789abcdef0123456789abcdef' ;; +esac`) + writeCommand(t, dir, "atrm", "printf '%s\\n' \"$1\" >> '"+removed+"'") + t.Setenv("PATH", dir) + + s := newScheduler(dir, realSystem{}) + if err := (realSystem{}).RemoveAtJobsFor(s.revokeAtNeedle("xxvcc-a1")); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(removed) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(b)); got != "5" { + t.Fatalf("removed jobs = %q, want only the owned job 5", got) + } +} + +func TestParseAtRevokeCommandRejectsReservedLinuxUID(t *testing.T) { + if strconv.IntSize < 64 { + t.Skip("int cannot represent the reserved uint32 uid sentinel") + } + command := "/usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1 --yes --force --confirm-force xxvcc-a1 --expected-uid 4294967295 --generation 0123456789abcdef0123456789abcdef" + if parsed, ok := parseAtRevokeCommand(command, "/usr/local/sbin/linux-temp-admin"); ok { + t.Fatalf("parseAtRevokeCommand accepted the reserved Linux UID: %#v", parsed) + } +} + +func TestRemoveAtJobsForAllowsCompletelyAbsentAtBackend(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + + s := newScheduler(dir, realSystem{}) + if err := (realSystem{}).RemoveAtJobsFor(s.revokeAtNeedle("xxvcc-a1")); err != nil { + t.Fatalf("systemd-only cleanup failed because optional at is absent: %v", err) + } +} + +func TestRemoveAtJobsForFailsClosedOnPartialAtBackend(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "atd", "exit 0") + t.Setenv("PATH", dir) + + s := newScheduler(dir, realSystem{}) + err := (realSystem{}).RemoveAtJobsFor(s.revokeAtNeedle("xxvcc-a1")) + if err == nil || !strings.Contains(err.Error(), "atq is unavailable") { + t.Fatalf("partial at backend error = %v, want inventory failure", err) + } +} diff --git a/internal/schedule/valid.go b/internal/schedule/valid.go new file mode 100644 index 0000000..7107349 --- /dev/null +++ b/internal/schedule/valid.go @@ -0,0 +1,198 @@ +package schedule + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/validate" + "golang.org/x/sys/unix" +) + +const maxScheduleFileSize = 64 << 10 + +var ( + errSystemdUnitDisabled = errors.New("systemd unit is disabled") + errSystemdUnitInactive = errors.New("systemd unit is inactive") +) + +// ValidSchedule reports whether recordedUnit still names this exact account +// generation's queued revoke task. Invalid or stale artifacts return false; +// failures that prevent a reliable inventory return an error. +func (s *Scheduler) ValidSchedule(user string, uid int, generation, recordedUnit string) (bool, error) { + if !validate.Username(user) || !validate.AccountID(uid) || !validate.Generation(generation) { + return false, nil + } + + if strings.HasPrefix(recordedUnit, "at:") { + id := strings.TrimPrefix(recordedUnit, "at:") + if !numericJobID(id) { + return false, nil + } + if s.Sys == nil { + return false, fmt.Errorf("inventory at jobs: no system backend") + } + jobs, err := s.Sys.AtJobs() + if err != nil { + return false, fmt.Errorf("inventory at jobs: %w", err) + } + found := false + for _, job := range jobs { + if job.ID != id { + continue + } + if found || !atBodyHasExactCommand(job.Body, s.RevokeCommand(user, uid, generation)) { + return false, nil + } + found = true + } + return found, nil + } + + unit := s.UnitName(user) + if recordedUnit != unit || strings.ContainsAny(unit, "/ ") { + return false, nil + } + service, valid, err := readScheduleFile(filepath.Join(s.SystemdDir, unit+".service")) + if err != nil || !valid { + return false, err + } + if string(service) != s.serviceContent(user, uid, generation) { + return false, nil + } + timer, valid, err := readScheduleFile(filepath.Join(s.SystemdDir, unit+".timer")) + if err != nil || !valid { + return false, err + } + + calendar, ok := uniqueCalendar(timer) + if !ok || string(timer) != timerContent(unit, calendar) { + return false, nil + } + trigger, err := time.Parse("2006-01-02 15:04:05 UTC", calendar) + if err != nil { + return false, nil + } + now := time.Now + if s.Now != nil { + now = s.Now + } + if !trigger.After(now().UTC()) { + return false, nil + } + return s.systemdTimerExecutable(unit + ".timer") +} + +func (s *Scheduler) systemdTimerExecutable(timer string) (bool, error) { + if s.Sys == nil { + return false, fmt.Errorf("query systemd timer %s: no system backend", timer) + } + for _, query := range []string{"is-enabled", "is-active"} { + if err := s.Sys.Systemctl(query, "--quiet", timer); err != nil { + if systemctlTimerStateNegative(err, query, timer) { + return false, nil + } + return false, fmt.Errorf("systemctl %s %s: %w", query, timer, err) + } + } + return true, nil +} + +// systemctlTimerStateNegative recognizes only documented, quiet state-query +// exits. Diagnostics or unrelated failures remain errors so doctor cannot turn +// an unqueryable timer into a merely disabled one. +func systemctlTimerStateNegative(err error, query, timer string) bool { + switch query { + case "is-enabled": + if errors.Is(err, errSystemdUnitDisabled) { + return true + } + case "is-active": + if errors.Is(err, errSystemdUnitInactive) { + return true + } + default: + return false + } + + var commandErr *systemctlError + if !errors.As(err, &commandErr) || len(commandErr.args) != 3 || + commandErr.args[0] != query || commandErr.args[1] != "--quiet" || commandErr.args[2] != timer || + commandErr.output != "" { + return false + } + var exitErr *exec.ExitError + if !errors.As(commandErr, &exitErr) { + return false + } + switch query { + case "is-enabled": + return exitErr.ExitCode() == 1 + case "is-active": + return exitErr.ExitCode() == 3 || exitErr.ExitCode() == 4 + } + return false +} + +func numericJobID(id string) bool { + if id == "" { + return false + } + for _, r := range id { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func uniqueCalendar(content []byte) (string, bool) { + var value string + count := 0 + for _, line := range strings.Split(string(content), "\n") { + if strings.HasPrefix(line, "OnCalendar=") { + value = strings.TrimPrefix(line, "OnCalendar=") + count++ + } + } + return value, count == 1 +} + +// readScheduleFile opens the leaf with O_NOFOLLOW and validates metadata on the +// descriptor, closing the lstat/open race that a path-based read would leave. +func readScheduleFile(path string) ([]byte, bool, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ELOOP) { + return nil, false, nil + } + return nil, false, fmt.Errorf("open schedule file %s: %w", path, err) + } + f := os.NewFile(uintptr(fd), path) + defer f.Close() + + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return nil, false, fmt.Errorf("stat schedule file %s: %w", path, err) + } + if !validScheduleMetadata(&stat) { + return nil, false, nil + } + content, err := io.ReadAll(io.LimitReader(f, maxScheduleFileSize+1)) + if err != nil { + return nil, false, fmt.Errorf("read schedule file %s: %w", path, err) + } + if len(content) > maxScheduleFileSize { + return nil, false, nil + } + return content, true, nil +} + +func validScheduleMetadata(stat *unix.Stat_t) bool { + return stat.Mode&unix.S_IFMT == unix.S_IFREG && stat.Uid == 0 && stat.Gid == 0 && stat.Mode&0o7777 == 0o644 +} diff --git a/internal/schedule/valid_test.go b/internal/schedule/valid_test.go new file mode 100644 index 0000000..7887a7b --- /dev/null +++ b/internal/schedule/valid_test.go @@ -0,0 +1,371 @@ +package schedule + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +const testGeneration = "0123456789abcdef0123456789abcdef" + +func TestValidScheduleAcceptsExactCurrentAtJob(t *testing.T) { + sys := &fakeSystem{} + s := newScheduler(t.TempDir(), sys) + sys.atJobs = []AtJob{ + {ID: "41", Body: s.RevokeCommand("xxvcc-a1", 1001, testGeneration) + "\n"}, + {ID: "42", Body: "SHELL=/bin/sh\n" + s.RevokeCommand("xxvcc-a1", 1001, testGeneration) + "\n"}, + } + + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42") + if err != nil { + t.Fatal(err) + } + if !valid { + t.Fatal("exact queued at job was reported invalid") + } +} + +func TestReadScheduleFileRejectsFIFOWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "schedule.timer") + if err := unix.Mkfifo(path, 0o644); err != nil { + t.Fatal(err) + } + start := time.Now() + content, valid, err := readScheduleFile(path) + if err != nil || valid || content != nil { + t.Fatalf("FIFO schedule read = %q, valid=%v, err=%v; want rejected special file", content, valid, err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("FIFO schedule read blocked for %s", elapsed) + } +} + +func TestValidScheduleRejectsReservedLinuxUID(t *testing.T) { + if strconv.IntSize < 64 { + t.Skip("int cannot represent the reserved uint32 uid sentinel") + } + sys := &fakeSystem{atJobsErr: errors.New("must not inventory")} + s := newScheduler(t.TempDir(), sys) + valid, err := s.ValidSchedule("xxvcc-a1", int(uint64(^uint32(0))), testGeneration, "at:42") + if err != nil || valid { + t.Fatalf("ValidSchedule reserved UID = %v, %v; want false, nil", valid, err) + } +} + +func TestValidScheduleRejectsWrongAtIdentityAndPropagatesInventoryError(t *testing.T) { + sys := &fakeSystem{} + s := newScheduler(t.TempDir(), sys) + sys.atJobs = []AtJob{{ID: "42", Body: s.RevokeCommand("xxvcc-a1", 1002, testGeneration) + "\n"}} + + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42") + if err != nil { + t.Fatal(err) + } + if valid { + t.Fatal("at job for the wrong UID was accepted") + } + + sys.atJobsErr = errors.New("atq failed") + if _, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42"); err == nil || !strings.Contains(err.Error(), "atq failed") { + t.Fatalf("ValidSchedule error = %v, want inventory failure", err) + } +} + +func TestValidScheduleAtJobRequiresSystemBackend(t *testing.T) { + s := newScheduler(t.TempDir(), nil) + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42") + if err == nil || valid || !strings.Contains(err.Error(), "no system backend") { + t.Fatalf("ValidSchedule = %v, %v; want bounded backend error", valid, err) + } +} + +func TestValidScheduleAcceptsExactRootOwnedSystemdPair(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("valid systemd schedule files must be root-owned") + } + dir := t.TempDir() + s := newScheduler(dir, &fakeSystem{}) + unit := s.UnitName("xxvcc-a1") + writeSchedulePair(t, s, "xxvcc-a1", 1001, testGeneration, s.Now().Add(time.Hour)) + + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit) + if err != nil { + t.Fatal(err) + } + if !valid { + t.Fatal("exact root-owned systemd pair was reported invalid") + } +} + +func TestValidScheduleRequiresEnabledAndActiveSystemdTimer(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("systemd schedule files must be root-owned") + } + tests := []struct { + name string + stateErr func(args ...string) error + wantValid bool + wantErr string + wantCalls int + }{ + { + name: "enabled and active", + wantValid: true, + wantCalls: 2, + }, + { + name: "disabled", + stateErr: func(args ...string) error { + if args[0] == "is-enabled" { + return errSystemdUnitDisabled + } + return nil + }, + wantCalls: 1, + }, + { + name: "inactive", + stateErr: func(args ...string) error { + if args[0] == "is-active" { + return errSystemdUnitInactive + } + return nil + }, + wantCalls: 2, + }, + { + name: "enabled query error", + stateErr: func(args ...string) error { + if args[0] == "is-enabled" { + return errors.New("D-Bus unavailable") + } + return nil + }, + wantErr: "D-Bus unavailable", + wantCalls: 1, + }, + { + name: "active query error", + stateErr: func(args ...string) error { + if args[0] == "is-active" { + return errors.New("permission denied") + } + return nil + }, + wantErr: "permission denied", + wantCalls: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + sys := &fakeSystem{systemctlErr: tt.stateErr} + s := newScheduler(dir, sys) + unit := s.UnitName("xxvcc-a1") + writeSchedulePair(t, s, "xxvcc-a1", 1001, testGeneration, s.Now().Add(time.Hour)) + + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit) + if tt.wantErr == "" && err != nil { + t.Fatalf("ValidSchedule: %v", err) + } + if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) { + t.Fatalf("ValidSchedule error = %v, want %q", err, tt.wantErr) + } + if valid != tt.wantValid { + t.Fatalf("ValidSchedule valid = %v, want %v", valid, tt.wantValid) + } + if len(sys.calls) != tt.wantCalls { + t.Fatalf("systemctl calls = %v, want %d", sys.calls, tt.wantCalls) + } + if len(sys.calls) > 0 && strings.Join(sys.calls[0], " ") != "is-enabled --quiet "+unit+".timer" { + t.Fatalf("first systemctl call = %v", sys.calls[0]) + } + if len(sys.calls) > 1 && strings.Join(sys.calls[1], " ") != "is-active --quiet "+unit+".timer" { + t.Fatalf("second systemctl call = %v", sys.calls[1]) + } + }) + } +} + +func TestValidScheduleRejectsTamperedOrExpiredSystemdPair(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("systemd schedule validation requires root-owned fixtures") + } + tests := []struct { + name string + mutate func(*testing.T, *Scheduler, string) + }{ + { + name: "service command", + mutate: func(t *testing.T, s *Scheduler, unit string) { + t.Helper() + path := filepath.Join(s.SystemdDir, unit+".service") + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(b, []byte("ExecStart=/bin/true\n")...), 0o644); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "duplicate OnCalendar", + mutate: func(t *testing.T, s *Scheduler, unit string) { + t.Helper() + path := filepath.Join(s.SystemdDir, unit+".timer") + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString("OnCalendar=2026-07-07 14:00:00 UTC\n"); err != nil { + f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "wrong timer target", + mutate: func(t *testing.T, s *Scheduler, unit string) { + t.Helper() + path := filepath.Join(s.SystemdDir, unit+".timer") + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + b = []byte(strings.ReplaceAll(string(b), "Unit="+unit+".service", "Unit=other.service")) + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "unsafe mode", + mutate: func(t *testing.T, s *Scheduler, unit string) { + t.Helper() + if err := os.Chmod(filepath.Join(s.SystemdDir, unit+".timer"), 0o664); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "symlink", + mutate: func(t *testing.T, s *Scheduler, unit string) { + t.Helper() + path := filepath.Join(s.SystemdDir, unit+".timer") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(unit+".service", path); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "non-regular", + mutate: func(t *testing.T, s *Scheduler, unit string) { + t.Helper() + path := filepath.Join(s.SystemdDir, unit+".timer") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0o644); err != nil { + t.Fatal(err) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + s := newScheduler(dir, &fakeSystem{}) + unit := s.UnitName("xxvcc-a1") + writeSchedulePair(t, s, "xxvcc-a1", 1001, testGeneration, s.Now().Add(time.Hour)) + tt.mutate(t, s, unit) + + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit) + if err != nil { + t.Fatal(err) + } + if valid { + t.Fatal("tampered systemd pair was accepted") + } + }) + } + + t.Run("expired", func(t *testing.T) { + dir := t.TempDir() + s := newScheduler(dir, &fakeSystem{}) + unit := s.UnitName("xxvcc-a1") + writeSchedulePair(t, s, "xxvcc-a1", 1001, testGeneration, s.Now().Add(-time.Second)) + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit) + if err != nil { + t.Fatal(err) + } + if valid { + t.Fatal("expired systemd timer was accepted") + } + }) +} + +func TestValidScheduleMetadataRequiresRootOwnedRegular0644File(t *testing.T) { + tests := []struct { + name string + stat unix.Stat_t + want bool + }{ + {name: "valid", stat: unix.Stat_t{Mode: unix.S_IFREG | 0o644, Uid: 0}, want: true}, + {name: "non-root owner", stat: unix.Stat_t{Mode: unix.S_IFREG | 0o644, Uid: 1}}, + {name: "non-root group", stat: unix.Stat_t{Mode: unix.S_IFREG | 0o644, Uid: 0, Gid: 1}}, + {name: "group writable", stat: unix.Stat_t{Mode: unix.S_IFREG | 0o664, Uid: 0}}, + {name: "directory", stat: unix.Stat_t{Mode: unix.S_IFDIR | 0o644, Uid: 0}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := validScheduleMetadata(&tt.stat); got != tt.want { + t.Fatalf("validScheduleMetadata() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidScheduleRejectsUnexpectedRecordedUnit(t *testing.T) { + s := newScheduler(t.TempDir(), &fakeSystem{}) + for _, recorded := range []string{"", "other-unit", "at:not-a-number", "at:42.timer"} { + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, recorded) + if err != nil { + t.Fatalf("recorded %q: %v", recorded, err) + } + if valid { + t.Fatalf("recorded unit %q was accepted", recorded) + } + } +} + +func writeSchedulePair(t *testing.T, s *Scheduler, user string, uid int, generation string, trigger time.Time) { + t.Helper() + unit := s.UnitName(user) + files := map[string]string{ + unit + ".service": s.serviceContent(user, uid, generation), + unit + ".timer": timerContent(unit, trigger.UTC().Format("2006-01-02 15:04:05 UTC")), + } + for name, content := range files { + path := filepath.Join(s.SystemdDir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/selfmanage/pubkey.go b/internal/selfmanage/pubkey.go index d13e973..789d26d 100644 --- a/internal/selfmanage/pubkey.go +++ b/internal/selfmanage/pubkey.go @@ -10,23 +10,32 @@ import ( //go:embed release_pubkey.hex var releasePubkeyHex string -// embeddedPublicKey parses the embedded release signing key (hex; comment lines -// starting with '#' and whitespace ignored). Returns nil when unconfigured or -// malformed, which disables signed upgrades. -func embeddedPublicKey() ed25519.PublicKey { - var b strings.Builder +// embeddedPublicKeys parses the embedded release keyring. Each non-comment line +// is one complete hex-encoded ed25519 public key. Any malformed or duplicate key +// invalidates the whole keyring so a botched rotation fails closed. +func embeddedPublicKeys() []ed25519.PublicKey { + var keys []ed25519.PublicKey + seen := make(map[string]struct{}) for _, line := range strings.Split(releasePubkeyHex, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } - b.WriteString(line) + raw, err := hex.DecodeString(line) + if err != nil || len(raw) != ed25519.PublicKeySize { + return nil + } + id := string(raw) + if _, ok := seen[id]; ok { + return nil + } + seen[id] = struct{}{} + keys = append(keys, ed25519.PublicKey(raw)) } - raw, err := hex.DecodeString(b.String()) - if err != nil || len(raw) != ed25519.PublicKeySize { + if len(keys) == 0 { return nil } - return ed25519.PublicKey(raw) + return keys } func decodeHex(s string) ([]byte, error) { return hex.DecodeString(s) } diff --git a/internal/selfmanage/release_pipeline_test.go b/internal/selfmanage/release_pipeline_test.go new file mode 100644 index 0000000..29caf12 --- /dev/null +++ b/internal/selfmanage/release_pipeline_test.go @@ -0,0 +1,3455 @@ +package selfmanage + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/validate" +) + +func TestReleaseWriterIsSeparatedFromCandidateWorkflow(t *testing.T) { + release := readReleaseFile(t, "../../.github/workflows/release.yml") + stage := readReleaseFile(t, "../../.github/workflows/stage-release.yml") + if strings.Contains(release, "contents: write") { + t.Fatal("candidate-tag Release workflow must not receive contents:write") + } + for _, required := range []string{"workflow_run:", "contents: write", "refusing to refresh any remote asset"} { + if !strings.Contains(stage, required) { + t.Fatalf("trusted stage workflow is missing %q", required) + } + } + if !strings.Contains(stage, ".verification.verified") || !strings.Contains(stage, "valid GitHub-recognized OpenPGP signature") || + strings.Count(stage, ".verification.signature") != 2 || strings.Count(stage, "-----BEGIN PGP SIGNATURE-----") != 2 { + t.Fatal("trusted stage workflow does not reject unsigned annotated tags") + } + if !strings.Contains(stage, "[[ \"$lookup_status\" -eq 1 ]]") || + strings.Contains(stage, "[[ \"$lookup_status\" -ne 124") { + t.Fatal("trusted stage workflow accepts an abnormal release-lookup exit status as an HTTP 404") + } + if strings.Contains(stage, "--clobber") { + t.Fatal("trusted stage workflow must never refresh an existing draft") + } + for _, required := range []string{ + `find dist -mindepth 1 -printf '%P\t%y\n'`, + `wc -c < dist/SHA256SUMS`, + "1048576", + "Re-resolve the protected tag immediately before the first write", + } { + if !strings.Contains(stage, required) { + t.Fatalf("trusted stage workflow does not reject malformed artifacts or stale tags: missing %q", required) + } + } + for _, required := range []string{ + "group: stage-release-writer", + "LTA_RELEASE_ENVIRONMENT_CONFIGURED", + "github.event.workflow_run.path == '.github/workflows/release.yml'", + "needs: configuration-gate", + "name: release-staging", + } { + if !strings.Contains(stage, required) { + t.Fatalf("trusted stage workflow is missing protected-environment guard %q", required) + } + } + if !strings.Contains(release, "timeout-minutes: 60") || strings.Count(release, "timeout-minutes:") != 5 || + strings.Count(stage, "timeout-minutes:") != 2 { + t.Fatal("release workflows must bound every release-critical job") + } +} + +func TestMirrorReleaseWorkflowPublishesVerifiedImmutableContentFailClosed(t *testing.T) { + mirror := readReleaseFile(t, "../../.github/workflows/mirror-release.yml") + installer := readReleaseFile(t, "../../scripts/install.sh") + for _, required := range []string{ + "types: [published]", + "group: linux-temp-admin-release-mirror-stable", + "cancel-in-progress: false", + "environment: release-mirror", + "LTA_RELEASE_MIRROR_ENVIRONMENT_CONFIGURED", + `$GITHUB_REPOSITORY/.github/workflows/mirror-release.yml@refs/heads/$DEFAULT_BRANCH`, + "GH_HOST: github.com", + "GH_PROMPT_DISABLED: '1'", + "timeout -k 5 60 gh api", + "persist-credentials: false", + "MIRROR_BASE_URL: https://dl.ll.cd/linux-temp-admin", + "GITHUB_RELEASE_ROOT=https://github.com/xxvcc/linux-temp-admin/releases", + "cmp scripts/install.sh released-source/scripts/install.sh", + ".immutable == true", + "SHA256SUMS does not name the exact signed asset set", + "sha256sum -c --strict SHA256SUMS", + "openssl pkeyutl -verify", + "sudo --non-interactive --user=nobody", + "/usr/bin/env -i HOME=/nonexistent", + `[[ -d "$HOME/.ssh" && ! -L "$HOME/.ssh" ]]`, + "--ignore-existing", + "--delay-updates", + "-o BatchMode=yes", + "StrictHostKeyChecking=yes", + `cmp "deploy/$TAG/$asset" "public-check/$asset"`, + "INSTALLER_SHA256", + "LTA_RELEASE=latest", + "mirror canary used the GitHub transport fallback", + "upgrade --yes --force", + "self-upgrade canary used the GitHub transport fallback", + "linux-temp-admin-mirror-canary-owned-$GITHUB_RUN_ID", + } { + if !strings.Contains(mirror, required) { + t.Fatalf("mirror workflow is missing fail-closed publication guard %q", required) + } + } + for _, prohibited := range []string{ + "types: [published, edited]", + "contents: write", + "--clobber", + "--delete", + } { + if strings.Contains(mirror, prohibited) { + t.Fatalf("mirror workflow contains unsafe publication behavior %q", prohibited) + } + } + jobEnvStart := strings.Index(mirror, " env:\n") + stepsStart := strings.Index(mirror, " steps:\n") + if jobEnvStart < 0 || stepsStart <= jobEnvStart { + t.Fatal("could not isolate mirror job environment") + } + if strings.Contains(mirror[jobEnvStart:stepsStart], "GH_TOKEN") { + t.Fatal("mirror job exposes the GitHub token to release binaries instead of scoping it to API steps") + } + if strings.Count(mirror, "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1") != 2 { + t.Fatal("mirror workflow checkout actions are not exactly pinned to the reviewed commit") + } + if strings.Count(mirror, "--location --max-redirs 0") != 4 { + t.Fatal("public mirror verification must reject redirects for every stable and versioned fetch") + } + + ordered := []string{ + "Upload immutable version without overwriting existing files", + "Verify immutable version through public mirror", + "Confirm GitHub Latest immediately before stable update", + "Publish stable installer", + "Publish latest manifest last", + "Remove SSH identity", + } + previous := -1 + for _, marker := range ordered { + index := strings.Index(mirror, marker) + if index <= previous { + t.Fatalf("mirror publication step %q is absent or out of order", marker) + } + previous = index + } + + uploadStart := strings.Index(mirror, ordered[0]) + uploadEnd := strings.Index(mirror, ordered[1]) + upload := mirror[uploadStart:uploadEnd] + if trustCheck, write := strings.Index(upload, "git/ref/tags/$TAG"), strings.Index(upload, "rsync --archive"); trustCheck < 0 || write < 0 || trustCheck > write { + t.Fatal("mirror workflow does not re-resolve the protected tag immediately before its first write") + } + + manifestStart := strings.Index(mirror, ordered[4]) + manifestEnd := strings.Index(mirror, ordered[5]) + manifest := mirror[manifestStart:manifestEnd] + latestCheck := strings.Index(manifest, "releases/latest") + manifestWrite := strings.Index(manifest, "rsync --archive") + publicCompare := strings.Index(manifest, "cmp latest.json public-latest.json") + if latestCheck < 0 || manifestWrite < 0 || publicCompare < 0 || + latestCheck > manifestWrite || manifestWrite > publicCompare { + t.Fatal("latest.json is not atomically published after a final Latest check and before public comparison") + } + + canaryStart := strings.Index(mirror, "Install from the public mirror as a real root client") + if canaryStart < 0 { + t.Fatal("mirror workflow is missing the public installation canary") + } + canary := mirror[canaryStart:] + if !strings.Contains(canary, "downloaded the complete release set from the official mirror") || + !strings.Contains(canary, "falling back to GitHub") { + t.Fatal("public installation canary does not prove that the mirror served the complete release set") + } + for _, expectedOutput := range []string{ + "downloaded the complete release set from the official mirror", + "falling back to GitHub", + } { + if !strings.Contains(installer, expectedOutput) { + t.Fatalf("mirror canary depends on installer output that does not exist: %q", expectedOutput) + } + } +} + +func TestStageReleaseLookupAcceptsOnlyExactGHHTTPErrorStatus(t *testing.T) { + stage := readReleaseFile(t, "../../.github/workflows/stage-release.yml") + start := strings.Index(stage, " lookup=\"$(mktemp)\"") + endMarker := " rm -f -- \"$lookup\"" + if start < 0 { + t.Fatal("could not isolate staged-release absence check") + } + end := strings.Index(stage[start:], endMarker) + if end < 0 { + t.Fatal("could not isolate staged-release absence check") + } + end += start + len(endMarker) + body := stage[start:end] + body = strings.TrimPrefix(body, " ") + body = strings.ReplaceAll(body, "\n ", "\n") + + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + if err := os.Mkdir(binDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, "timeout"), []byte(`#!/bin/sh +shift 3 +exec "$@" +`), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, "gh"), []byte(`#!/bin/sh +printf 'HTTP/2 404 Not Found\n\n{}\n' +exit "${MOCK_GH_STATUS:?}" +`), 0o700); err != nil { + t.Fatal(err) + } + script := filepath.Join(dir, "lookup.sh") + if err := os.WriteFile(script, []byte("#!/bin/bash\nset -Eeuo pipefail\n"+body+"\n"), 0o700); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + status int + ok bool + }{ + {status: 1, ok: true}, + {status: 0, ok: false}, + {status: 2, ok: false}, + {status: 124, ok: false}, + {status: 137, ok: false}, + } { + t.Run(fmt.Sprintf("status-%d", tc.status), func(t *testing.T) { + cmd := exec.Command("/bin/bash", script) + cmd.Env = []string{ + "PATH=" + binDir + ":/usr/bin:/bin", + fmt.Sprintf("MOCK_GH_STATUS=%d", tc.status), + "GH_REPO=xxvcc/linux-temp-admin", + "TAG=v2.8.0", + } + out, err := cmd.CombinedOutput() + if tc.ok && err != nil { + t.Fatalf("exact HTTP error status was rejected: %v\n%s", err, out) + } + if !tc.ok && err == nil { + t.Fatalf("abnormal status was accepted: %s", out) + } + }) + } +} + +func TestManualLatestRecoveryUsesSanitizedBoundedGitHubClient(t *testing.T) { + releasing := readReleaseFile(t, "../../docs/releasing.md") + start := strings.Index(releasing, "For manual incident recovery only") + if start < 0 { + t.Fatal("could not isolate manual Latest recovery documentation") + } + end := strings.Index(releasing[start:], "Any other tag, success response") + if end < 0 { + t.Fatal("could not isolate manual Latest recovery documentation") + } + recovery := releasing[start : start+end] + for _, required := range []string{ + "/usr/bin/sudo /usr/bin/env -i", + "TAG=\"$TAG\" /bin/bash -p", + "read -r -s -p 'Short-lived github.com release token: ' GH_TOKEN &2 + exit 1 +fi +[ ! -d "$TEST_TIMEOUT_MARKER" ] +` + runShellFixture(t, shell.path, shell.args, source, + "TEST_BIN="+binDir, + "TEST_FETCH_OUT="+outPath, + "TEST_TIMEOUT_MARKER="+filepath.Join(dir, "timeout-ran"), + ) + }) + + t.Run(shell.name+"/wc", func(t *testing.T) { + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + if err := os.Mkdir(binDir, 0o700); err != nil { + t.Fatal(err) + } + fakeTimeout := "#!/bin/sh\nprintf payload > \"$TEST_FETCH_OUT\"\nmkdir \"$TEST_TIMEOUT_MARKER\"\nexit 0\n" + if err := os.WriteFile(filepath.Join(binDir, "timeout"), []byte(fakeTimeout), 0o700); err != nil { + t.Fatal(err) + } + outPath := filepath.Join(dir, "out") + source := `set -eu +PATH="$TEST_BIN:$PATH" +export PATH +FSIZE_BLOCK_BYTES=512 +FETCH_TIMEOUT_SECONDS=1 +CONNECT_TIMEOUT_SECONDS=1 +` + fetchFunction + ` +wc() { return 1; } +if fetch_once "https://example.invalid/bin" "$TEST_FETCH_OUT" 1024 "$FETCH_TIMEOUT_SECONDS"; then + echo "fetch unexpectedly succeeded" >&2 + exit 1 +fi +[ -d "$TEST_TIMEOUT_MARKER" ] +[ ! -e "$TEST_FETCH_OUT" ] +` + runShellFixture(t, shell.path, shell.args, source, + "TEST_BIN="+binDir, + "TEST_FETCH_OUT="+outPath, + "TEST_TIMEOUT_MARKER="+filepath.Join(dir, "timeout-ran"), + ) + }) + + for _, statCase := range []struct { + name string + mode string + }{ + {name: "stat-mode-failure", mode: "failure"}, + {name: "malformed-stat-mode", mode: "malformed"}, + } { + t.Run(shell.name+"/"+statCase.name, func(t *testing.T) { + dir := t.TempDir() + source := `set -eu +fail() { echo "error: $*" >&2; exit 1; } +` + checkFunction + ` +stat() { + case "$2" in + %u) printf '0\n' ;; + %A) + if [ "$TEST_STAT_MODE" = failure ]; then + return 1 + fi + printf 'not-a-directory-mode\n' + ;; + *) return 1 ;; + esac +} +check_safe_dir_chain "$TEST_DIR" +mkdir "$TEST_ACCEPTED_MARKER" +` + script := filepath.Join(dir, "fixture.sh") + if err := os.WriteFile(script, []byte(source), 0o700); err != nil { + t.Fatal(err) + } + args := append(append([]string(nil), shell.args...), script) + cmd := exec.Command(shell.path, args...) + marker := filepath.Join(dir, "unsafe-mode-accepted") + cmd.Env = append(os.Environ(), + "TEST_DIR="+dir, + "TEST_STAT_MODE="+statCase.mode, + "TEST_ACCEPTED_MARKER="+marker, + ) + if out, err := cmd.CombinedOutput(); err == nil { + t.Fatalf("unsafe stat result was accepted: %s", out) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("directory validation continued after unsafe stat result: %v", err) + } + }) + } + } +} + +func runShellFixture(t *testing.T, shell string, shellArgs []string, source string, env ...string) { + t.Helper() + dir := t.TempDir() + script := filepath.Join(dir, "fixture.sh") + if err := os.WriteFile(script, []byte(source), 0o700); err != nil { + t.Fatal(err) + } + args := append(append([]string(nil), shellArgs...), script) + cmd := exec.Command(shell, args...) + cmd.Env = append(os.Environ(), env...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("shell fixture failed: %v\n%s", err, out) + } +} + +func TestInstallerDetectsExactFileLimitBlockSize(t *testing.T) { + installer := readReleaseFile(t, "../../scripts/install.sh") + start := strings.Index(installer, "# Bash uses 1024-byte `ulimit -f` blocks") + end := strings.Index(installer, "\n\nif [ ! -d \"$TMP_ROOT\" ]") + if start < 0 || end < 0 || end <= start { + t.Fatal("could not isolate installer file-limit unit probe") + } + probe := installer[start:end] + + type shellCase struct { + name string + path string + args []string + want string + } + shells := []shellCase{ + {name: "bash", path: "/bin/bash", want: "1024"}, + {name: "bash-posix", path: "/bin/bash", args: []string{"--posix"}, want: "512"}, + {name: "dash", path: "/bin/dash", want: "512"}, + } + if busybox, err := exec.LookPath("busybox"); err == nil { + shells = append(shells, shellCase{name: "busybox-ash", path: busybox, args: []string{"ash"}, want: "512"}) + } + for _, shell := range shells { + t.Run(shell.name, func(t *testing.T) { + if _, err := os.Stat(shell.path); err != nil { + t.Skip(err) + } + script := filepath.Join(t.TempDir(), "probe.sh") + source := `set -eu +fail() { echo "error: $*" >&2; exit 1; } +` + probe + ` +printf '%s' "$FSIZE_BLOCK_BYTES" +` + if err := os.WriteFile(script, []byte(source), 0o700); err != nil { + t.Fatal(err) + } + args := append(append([]string(nil), shell.args...), script) + out, err := exec.Command(shell.path, args...).CombinedOutput() + if err != nil { + t.Fatalf("file-limit unit probe failed: %v\n%s", err, out) + } + if got := string(out); got != shell.want { + t.Fatalf("file-limit block size = %q, want %q", got, shell.want) + } + }) + } +} + +func TestInstallerPinnedReleaseEndToEnd(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("installer end-to-end test requires root") + } + for _, command := range []string{"curl", "openssl", "sha256sum", "timeout"} { + if _, err := exec.LookPath(command); err != nil { + t.Skipf("installer dependency unavailable: %s", command) + } + } + + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported installer test architecture: %s", runtime.GOARCH) + } + asset := "linux-temp-admin-linux-" + runtime.GOARCH + destDir, err := os.MkdirTemp("/usr/local/lib", ".lta-installer-test-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(destDir) }) + dest := filepath.Join(destDir, "linux-temp-admin") + coreEvidence := filepath.Join(destDir, "core-limits") + activationEvidence := filepath.Join(destDir, "activation-evidence") + activationBlock := filepath.Join(destDir, "activation-block") + tombstone := filepath.Join(destDir, "uninstalled-marker") + if err := os.WriteFile(tombstone, []byte("uninstalled-v1\n"), 0o600); err != nil { + t.Fatal(err) + } + candidate := []byte(fmt.Sprintf(`#!/bin/sh +case "$1" in + version) + awk '$1 == "Max" && $2 == "core" && $3 == "file" && $4 == "size" { print $5, $6 }' /proc/self/limits > "$LTA_CORE_EVIDENCE" + printf '2.8.0\n' + ;; + --lang) + [ "$#" -eq 4 ] && [ "$2" = en ] && [ "$3" = install ] && [ "$4" = --force ] || exit 76 + [ ! -e '%s' ] || exit 77 + /bin/cp -- "$0" '%s' + /bin/chown 0:0 '%s' + /bin/chmod 0755 '%s' + /bin/rm -f -- '%s' + printf 'delegated\n' > '%s' + ;; + *) exit 78 ;; +esac +`, activationBlock, dest, dest, dest, tombstone, activationEvidence)) + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signature := ed25519.Sign(priv, candidate) + keyDER, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + t.Fatal(err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: keyDER}) + sum := sha256.Sum256(candidate) + sigSum := sha256.Sum256(signature) + manifest := []byte(fmt.Sprintf("%x %s\n%x %s.sig\n", sum, asset, sigSum, asset)) + + var requests atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + switch filepath.Base(r.URL.Path) { + case asset: + _, _ = w.Write(candidate) + case asset + ".sig": + _, _ = w.Write(signature) + case "SHA256SUMS": + _, _ = w.Write(manifest) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + certPath := filepath.Join(t.TempDir(), "test-ca.pem") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + if err := os.WriteFile(certPath, certPEM, 0o600); err != nil { + t.Fatal(err) + } + installer := readReleaseFile(t, "../../scripts/install.sh") + installer = strings.ReplaceAll(installer, + `MIRROR_ROOT=https://dl.ll.cd/linux-temp-admin`, + `MIRROR_ROOT=`+server.URL) + installer = strings.ReplaceAll(installer, + `GITHUB_RELEASE_ROOT=https://github.com/xxvcc/linux-temp-admin/releases`, + `GITHUB_RELEASE_ROOT=`+server.URL) + installer = strings.ReplaceAll(installer, + "MANAGED_DEST=/usr/local/sbin/linux-temp-admin", + "MANAGED_DEST="+dest) + keyStart := strings.Index(installer, "RELEASE_PUBKEY_PEMS='") + if keyStart < 0 { + t.Fatal("installer keyring start not found") + } + keyBodyStart := keyStart + len("RELEASE_PUBKEY_PEMS='") + keyEndRel := strings.Index(installer[keyBodyStart:], "'\n# LTA_RELEASE_KEYS_END") + if keyEndRel < 0 { + t.Fatal("installer keyring end not found") + } + installer = installer[:keyBodyStart] + "\n" + strings.TrimSpace(string(keyPEM)) + "\n" + + installer[keyBodyStart+keyEndRel:] + installerPath := filepath.Join(t.TempDir(), "install.sh") + if err := os.WriteFile(installerPath, []byte(installer), 0o700); err != nil { + t.Fatal(err) + } + + doubleSlash := exec.Command("/bin/sh", installerPath) + doubleSlash.Env = []string{ + "PATH=/usr/sbin:/usr/bin:/sbin:/bin", + "DEST=//tmp/lta-must-not-install", + "LTA_RELEASE=v2.8.0", + } + requestsBeforeDoubleSlash := requests.Load() + doubleSlashOut, doubleSlashErr := doubleSlash.CombinedOutput() + if doubleSlashErr == nil || !strings.Contains(string(doubleSlashOut), "DEST must not begin with //") { + t.Fatalf("ambiguous double-slash destination was not rejected: err=%v\n%s", doubleSlashErr, doubleSlashOut) + } + if got := requests.Load(); got != requestsBeforeDoubleSlash { + t.Fatalf("ambiguous destination made %d network requests", got-requestsBeforeDoubleSlash) + } + + run := func(release string) ([]byte, error) { + env := make([]string, 0, len(os.Environ())+5) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "LTA_RELEASE=") || strings.HasPrefix(entry, "DEST=") || + strings.HasPrefix(entry, "CURL_CA_BUNDLE=") || strings.HasPrefix(entry, "LTA_CORE_EVIDENCE=") || + strings.HasPrefix(entry, "HTTPS_PROXY=") || strings.HasPrefix(entry, "https_proxy=") || + strings.HasPrefix(entry, "ALL_PROXY=") || strings.HasPrefix(entry, "all_proxy=") || + strings.HasPrefix(entry, "NO_PROXY=") || strings.HasPrefix(entry, "no_proxy=") { + continue + } + env = append(env, entry) + } + env = append(env, + "LTA_RELEASE="+release, + "CURL_CA_BUNDLE="+certPath, + "LTA_CORE_EVIDENCE="+coreEvidence, + "NO_PROXY=127.0.0.1,localhost", + ) + cmd := exec.Command("/bin/sh", installerPath) + cmd.Env = env + return cmd.CombinedOutput() + } + + for _, release := range []string{"", "v02.8.0", "v2.8.0?query", "../v2.8.0", "v2.8"} { + before := requests.Load() + out, err := run(release) + if err == nil { + t.Errorf("invalid LTA_RELEASE %q unexpectedly succeeded: %s", release, out) + } + if after := requests.Load(); after != before { + t.Errorf("invalid LTA_RELEASE %q made %d network requests", release, after-before) + } + if _, statErr := os.Lstat(dest); !os.IsNotExist(statErr) { + t.Fatalf("invalid LTA_RELEASE %q changed target: %v", release, statErr) + } + } + + out, err := run("v2.8.1") + if err == nil || !strings.Contains(string(out), "version does not match LTA_RELEASE") { + t.Fatalf("signed version mismatch was not rejected: err=%v\n%s", err, out) + } + if _, statErr := os.Lstat(dest); !os.IsNotExist(statErr) { + t.Fatalf("version mismatch changed target: %v", statErr) + } + + if err := os.WriteFile(activationBlock, []byte("block\n"), 0o600); err != nil { + t.Fatal(err) + } + out, err = run("v2.8.0") + if err == nil || !strings.Contains(string(out), "could not complete the managed install/reactivation") { + t.Fatalf("candidate install refusal was not fail-closed: err=%v\n%s", err, out) + } + if _, statErr := os.Lstat(dest); !os.IsNotExist(statErr) { + t.Fatalf("candidate install refusal changed target: %v", statErr) + } + if _, statErr := os.Stat(tombstone); statErr != nil { + t.Fatalf("candidate install refusal removed the uninstall marker: %v", statErr) + } + if err := os.Remove(activationBlock); err != nil { + t.Fatal(err) + } + + out, err = run("v2.8.0") + if err != nil { + t.Fatalf("pinned from-zero install failed: %v\n%s", err, out) + } + installed, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(installed, candidate) { + t.Fatal("installed bytes differ from the signed candidate") + } + if evidence, err := os.ReadFile(activationEvidence); err != nil || string(evidence) != "delegated\n" { + t.Fatalf("managed install was not delegated to the signed candidate: evidence=%q err=%v", evidence, err) + } + if _, err := os.Lstat(tombstone); !os.IsNotExist(err) { + t.Fatalf("successful delegated install did not clear the uninstall marker: %v", err) + } + fi, err := os.Stat(dest) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o755 { + t.Fatalf("installed mode = %04o, want 0755", fi.Mode().Perm()) + } + if stat, ok := fi.Sys().(*syscall.Stat_t); !ok || stat.Uid != 0 || stat.Gid != 0 { + t.Fatalf("installed ownership is not root:root: %#v", fi.Sys()) + } + limits, err := os.ReadFile(coreEvidence) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(limits)) != "0 0" { + t.Fatalf("candidate core soft/hard limits = %q, want 0 0", strings.TrimSpace(string(limits))) + } +} + +func TestInstallerOfficialMirrorFallbackBoundary(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("installer end-to-end test requires root") + } + for _, command := range []string{"curl", "openssl", "sha256sum", "timeout"} { + if _, err := exec.LookPath(command); err != nil { + t.Skipf("installer dependency unavailable: %s", command) + } + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported installer test architecture: %s", runtime.GOARCH) + } + + asset := "linux-temp-admin-linux-" + runtime.GOARCH + candidate := []byte("#!/bin/sh\n[ \"$1\" = version ] && printf '2.8.0\\n'\n") + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + goodSig := ed25519.Sign(priv, candidate) + _, wrongPriv, _ := ed25519.GenerateKey(rand.Reader) + wrongSig := ed25519.Sign(wrongPriv, candidate) + keyDER, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + t.Fatal(err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: keyDER}) + releaseSums := func(sig []byte) string { + return fmt.Sprintf("%x %s\n%x %s.sig\n", sha256.Sum256(candidate), asset, sha256.Sum256(sig), asset) + } + goodSum := releaseSums(goodSig) + + var mu sync.Mutex + mode := "" + requests := make(map[string]int) + mirrorCacheBypass := false + var server *httptest.Server + server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + currentMode := mode + requests[r.URL.Path]++ + if strings.HasPrefix(r.URL.Path, "/mirror/") && r.URL.Query().Has("download") { + mirrorCacheBypass = true + } + mu.Unlock() + if r.URL.Path == "/mirror/latest.json" { + switch currentMode { + case "manifest-transfer": + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + case "manifest-redirect": + http.Redirect(w, r, server.URL+"/mirror/redirected-latest.json", http.StatusFound) + return + case "manifest-invalid": + _, _ = w.Write([]byte(`{"version":"2.8.0","tag":"v2.8.1","base_url":"` + server.URL + `/mirror/v2.8.0","published_at":"2026-07-27T05:00:00Z"}` + "\n")) + return + case "manifest-time-invalid": + _, _ = w.Write([]byte(`{"version":"2.8.0","tag":"v2.8.0","base_url":"` + server.URL + `/mirror/v2.8.0","published_at":"2026-02-30T05:00:00Z"}` + "\n")) + return + case "manifest-format-invalid": + _, _ = w.Write([]byte(`{"tag":"v2.8.0","version":"2.8.0","base_url":"` + server.URL + `/mirror/v2.8.0","published_at":"2026-07-27T05:00:00Z"}` + "\n")) + return + default: + _, _ = w.Write([]byte(`{"version":"2.8.0","tag":"v2.8.0","base_url":"` + server.URL + `/mirror/v2.8.0","published_at":"2026-07-27T05:00:00Z"}` + "\n")) + return + } + } + name := filepath.Base(r.URL.Path) + isMirror := strings.HasPrefix(r.URL.Path, "/mirror/") + switch name { + case "SHA256SUMS": + if isMirror && currentMode == "sums-transfer" { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + } else if isMirror && currentMode == "sums-redirect" { + http.Redirect(w, r, server.URL+"/mirror/v2.8.0/redirected-sums", http.StatusFound) + } else if isMirror && currentMode == "sums-no-newline" { + _, _ = w.Write([]byte(strings.TrimSuffix(goodSum, "\n"))) + } else if isMirror && currentMode == "sums-uppercase" { + _, _ = w.Write([]byte(strings.ToUpper(goodSum[:64]) + goodSum[64:])) + } else if isMirror && currentMode == "checksum-invalid" { + _, _ = fmt.Fprintf(w, "%064d %s\n%x %s.sig\n", 0, asset, sha256.Sum256(goodSig), asset) + } else if isMirror && currentMode == "signature-checksum-invalid" { + _, _ = fmt.Fprintf(w, "%x %s\n%064d %s.sig\n", sha256.Sum256(candidate), asset, 0, asset) + } else if isMirror && currentMode == "signature-invalid" { + _, _ = w.Write([]byte(releaseSums(wrongSig))) + } else if isMirror && currentMode == "signature-hex" { + _, _ = w.Write([]byte(releaseSums([]byte(fmt.Sprintf("%x", goodSig))))) + } else if isMirror && currentMode == "signature-newline" { + _, _ = w.Write([]byte(releaseSums(append(append([]byte(nil), goodSig...), '\n')))) + } else { + _, _ = w.Write([]byte(goodSum)) + } + case asset: + if isMirror && currentMode == "binary-transfer" { + http.NotFound(w, r) + } else { + _, _ = w.Write(candidate) + } + case asset + ".sig": + if isMirror && currentMode == "asset-transfer" { + http.NotFound(w, r) + } else if isMirror && currentMode == "signature-invalid" { + _, _ = w.Write(wrongSig) + } else if isMirror && currentMode == "signature-hex" { + _, _ = fmt.Fprintf(w, "%x", goodSig) + } else if isMirror && currentMode == "signature-newline" { + _, _ = w.Write(append(append([]byte(nil), goodSig...), '\n')) + } else { + _, _ = w.Write(goodSig) + } + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + certPath := filepath.Join(t.TempDir(), "test-ca.pem") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + if err := os.WriteFile(certPath, certPEM, 0o600); err != nil { + t.Fatal(err) + } + installer := readReleaseFile(t, "../../scripts/install.sh") + installer = strings.ReplaceAll(installer, "MIRROR_ROOT=https://dl.ll.cd/linux-temp-admin", "MIRROR_ROOT="+server.URL+"/mirror") + installer = strings.ReplaceAll(installer, "GITHUB_RELEASE_ROOT=https://github.com/xxvcc/linux-temp-admin/releases", "GITHUB_RELEASE_ROOT="+server.URL+"/github") + installer = strings.ReplaceAll(installer, "MIRROR_FETCH_ATTEMPTS=2", "MIRROR_FETCH_ATTEMPTS=1") + installer = strings.ReplaceAll(installer, "GITHUB_FETCH_ATTEMPTS=4", "GITHUB_FETCH_ATTEMPTS=1") + keyStart := strings.Index(installer, "RELEASE_PUBKEY_PEMS='") + if keyStart < 0 { + t.Fatal("installer keyring start not found") + } + keyBodyStart := keyStart + len("RELEASE_PUBKEY_PEMS='") + keyEndRel := strings.Index(installer[keyBodyStart:], "'\n# LTA_RELEASE_KEYS_END") + if keyEndRel < 0 { + t.Fatal("installer keyring end not found") + } + installer = installer[:keyBodyStart] + "\n" + strings.TrimSpace(string(keyPEM)) + "\n" + installer[keyBodyStart+keyEndRel:] + installerPath := filepath.Join(t.TempDir(), "install.sh") + if err := os.WriteFile(installerPath, []byte(installer), 0o700); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + mode string + release string + wantOK bool + wantGitHub int + wantGitHubLatest bool + }{ + {name: "mirror exact success", mode: "success", release: "v2.8.0", wantOK: true}, + {name: "checksum transport falls back complete set", mode: "sums-transfer", release: "v2.8.0", wantOK: true, wantGitHub: 3}, + {name: "checksum redirect stops", mode: "sums-redirect", release: "v2.8.0"}, + {name: "binary transport falls back complete set", mode: "binary-transfer", release: "v2.8.0", wantOK: true, wantGitHub: 3}, + {name: "asset transport falls back complete set", mode: "asset-transfer", release: "v2.8.0", wantOK: true, wantGitHub: 3}, + {name: "checksum failure stops", mode: "checksum-invalid", release: "v2.8.0"}, + {name: "signature checksum failure stops", mode: "signature-checksum-invalid", release: "v2.8.0"}, + {name: "signature failure stops", mode: "signature-invalid", release: "v2.8.0"}, + {name: "noncanonical checksum newline stops", mode: "sums-no-newline", release: "v2.8.0"}, + {name: "noncanonical uppercase checksum stops", mode: "sums-uppercase", release: "v2.8.0"}, + {name: "hex signature format stops", mode: "signature-hex", release: "v2.8.0"}, + {name: "newline signature format stops", mode: "signature-newline", release: "v2.8.0"}, + {name: "candidate version failure stops", mode: "success", release: "v2.8.1"}, + {name: "latest pins fallback tag", mode: "asset-transfer", release: "latest", wantOK: true, wantGitHub: 3}, + {name: "manifest transport uses GitHub Latest", mode: "manifest-transfer", release: "latest", wantOK: true, wantGitHub: 3, wantGitHubLatest: true}, + {name: "manifest redirect stops", mode: "manifest-redirect", release: "latest"}, + {name: "manifest contradiction stops", mode: "manifest-invalid", release: "latest"}, + {name: "manifest timestamp stops", mode: "manifest-time-invalid", release: "latest"}, + {name: "manifest field order stops", mode: "manifest-format-invalid", release: "latest"}, + } { + t.Run(tc.name, func(t *testing.T) { + destDir, err := os.MkdirTemp("/usr/local/lib", ".lta-mirror-installer-test-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(destDir) }) + dest := filepath.Join(destDir, "linux-temp-admin") + mu.Lock() + mode = tc.mode + requests = make(map[string]int) + mirrorCacheBypass = false + mu.Unlock() + cmd := exec.Command("/bin/sh", installerPath) + cmd.Env = []string{ + "PATH=/usr/sbin:/usr/bin:/sbin:/bin", + "DEST=" + dest, + "LTA_RELEASE=" + tc.release, + "CURL_CA_BUNDLE=" + certPath, + "NO_PROXY=127.0.0.1,localhost", + } + out, runErr := cmd.CombinedOutput() + if (runErr == nil) != tc.wantOK { + t.Fatalf("installer err=%v wantOK=%v\n%s", runErr, tc.wantOK, out) + } + mu.Lock() + gotRequests := make(map[string]int, len(requests)) + for path, count := range requests { + gotRequests[path] = count + } + gotMirrorCacheBypass := mirrorCacheBypass + mu.Unlock() + if gotMirrorCacheBypass { + t.Fatal("official mirror request unexpectedly used the GitHub download=1 cache bypass") + } + if gotRequests["/mirror/redirected-latest.json"] != 0 || + gotRequests["/mirror/v2.8.0/redirected-sums"] != 0 { + t.Fatalf("official mirror redirect was followed: %v", gotRequests) + } + manifestRequests := gotRequests["/mirror/latest.json"] + if tc.release == "latest" && manifestRequests != 1 { + t.Fatalf("latest release made %d mirror manifest requests, want 1; all=%v", manifestRequests, gotRequests) + } + if tc.release != "latest" && manifestRequests != 0 { + t.Fatalf("exact release made %d mirror manifest requests, want 0; all=%v", manifestRequests, gotRequests) + } + githubRequests := 0 + latestRequests := 0 + for path, count := range gotRequests { + if strings.HasPrefix(path, "/github/") { + githubRequests += count + if strings.Contains(path, "/latest/download/") { + latestRequests += count + } + } + } + if githubRequests != tc.wantGitHub { + t.Fatalf("GitHub requests=%d, want %d; all=%v", githubRequests, tc.wantGitHub, gotRequests) + } + if (latestRequests > 0) != tc.wantGitHubLatest { + t.Fatalf("GitHub Latest requests=%d, wantLatest=%v; all=%v", latestRequests, tc.wantGitHubLatest, gotRequests) + } + }) + } +} + +func TestInstallerResolverClassifierMatchesGoPublicAddressPolicy(t *testing.T) { + installer := readReleaseFile(t, "../../scripts/install.sh") + start := strings.Index(installer, "validate_resolver_output() {") + if start < 0 { + t.Fatal("could not locate installer public-address classifier") + } + end := strings.Index(installer[start:], "\n\nresolve_public_address() {") + if end < 0 { + t.Fatal("could not isolate installer public-address classifier") + } + classifier := installer[start : start+end+2] + + addresses := []string{ + "8.8.8.8", "100.63.255.255", "100.128.0.1", "192.0.1.1", "198.20.0.1", + "0.0.0.1", "10.0.0.1", "100.64.0.1", "100.127.255.254", "127.0.0.1", + "169.254.1.1", "172.16.0.1", "172.31.255.254", "192.168.0.1", "192.0.0.1", + "192.0.2.1", "192.31.196.1", "192.52.193.1", "192.88.99.1", "192.175.48.1", + "198.18.0.1", "198.51.100.1", "203.0.113.1", "224.0.0.1", "240.0.0.1", + "2606:4700:4700::1111", "2000::1", "2001:200::1", "2001:db9::1", "3fef:ffff::1", "3ff0::1", "3fff:1000::1", + "::", "::1", "100:0:0:1::1", "4000::1", "fc00::1", "fdff::1", "fec0::1", "fe80::1", "ff02::1", + "64:ff9b::808:808", "64:ff9b:1::1", "100::1", "2001:1ff::1", + "2001:db8::1", "2002::1", "3fff::1", "3fff:0fff::1", "5f00::1", + "::ffff:8.8.8.8", "::ffff:10.0.0.1", + } + for _, address := range addresses { + t.Run(strings.ReplaceAll(address, ":", "_"), func(t *testing.T) { + script := filepath.Join(t.TempDir(), "classify.sh") + source := `set -eu +` + classifier + ` +if result=$(printf '%s STREAM resolved.example\n' "$TEST_ADDRESS" | validate_resolver_output getent); then + printf 'public:%s\n' "$result" +else + printf 'refused:%s\n' "$?" +fi +` + if err := os.WriteFile(script, []byte(source), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/sh", script) + cmd.Env = append(os.Environ(), "TEST_ADDRESS="+address) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("classifier fixture failed: %v\n%s", err, out) + } + wantPublic := validate.PublicIP(net.ParseIP(address)) + gotPublic := strings.HasPrefix(string(out), "public:") + if gotPublic != wantPublic { + t.Fatalf("installer public=%v, Go public=%v, output=%q", gotPublic, wantPublic, out) + } + if !wantPublic && string(out) != "refused:2\n" { + t.Fatalf("non-public address classification=%q, want policy status 2", out) + } + }) + } +} + +func TestInstallerGitHubRedirectsResolveBeforeRequestAcrossShells(t *testing.T) { + installer := readReleaseFile(t, "../../scripts/install.sh") + networkStart := strings.Index(installer, "validate_resolver_output() {") + fetchStart := strings.Index(installer, "fetch_once() {") + if networkStart < 0 || fetchStart < 0 { + t.Fatal("could not locate installer redirect guards") + } + networkEnd := strings.Index(installer[networkStart:], "\n# RLIMIT_FSIZE") + fetchEnd := strings.Index(installer[fetchStart:], "\n}\n\nfetch()") + if networkEnd < 0 || fetchEnd < 0 { + t.Fatal("could not isolate installer redirect guards") + } + networkFunctions := installer[networkStart : networkStart+networkEnd] + fetchFunction := installer[fetchStart : fetchStart+fetchEnd+2] + + type shellCase struct { + name string + path string + args []string + busyboxBin bool + } + shells := []shellCase{ + {name: "bash", path: "/bin/bash"}, + {name: "dash", path: "/bin/dash"}, + } + if busybox, err := exec.LookPath("busybox"); err == nil { + shells = append(shells, shellCase{name: "busybox-ash", path: busybox, args: []string{"ash"}, busyboxBin: true}) + } + + for _, shell := range shells { + if _, err := os.Stat(shell.path); err != nil { + continue + } + for _, tc := range []struct { + name string + resolver string + resolverOutput string + redirectURL string + secondPrivate bool + slowChain bool + slowResolver bool + curlDelay string + resolverDelay string + fetchTimeout string + nslookupOld bool + wantRC int + wantRequests int + }{ + {name: "getent public", resolver: "getent", resolverOutput: "93.184.216.34 STREAM redirect.example", wantRC: 0, wantRequests: 2}, + {name: "getent private", resolver: "getent", resolverOutput: "127.0.0.1 STREAM redirect.example", wantRC: 2, wantRequests: 1}, + {name: "getent mixed", resolver: "getent", resolverOutput: "93.184.216.34 STREAM redirect.example\n10.0.0.1 STREAM redirect.example", wantRC: 2, wantRequests: 1}, + {name: "second redirect private", resolver: "getent", resolverOutput: "93.184.216.34 STREAM redirect.example", secondPrivate: true, wantRC: 2, wantRequests: 2}, + {name: "multi-hop shares one wall clock budget", resolver: "getent", resolverOutput: "93.184.216.34 STREAM redirect.example", slowChain: true, curlDelay: "0.4", fetchTimeout: "1", wantRC: 1, wantRequests: -1}, + {name: "DNS shares the wall clock budget", resolver: "getent", resolverOutput: "93.184.216.34 STREAM redirect.example", slowResolver: true, curlDelay: "0.4", resolverDelay: "0.8", fetchTimeout: "1", wantRC: 1, wantRequests: 1}, + {name: "nslookup fallback", resolver: "nslookup", resolverOutput: "93.184.216.34", wantRC: 0, wantRequests: 2}, + {name: "old busybox nslookup fallback", resolver: "nslookup", resolverOutput: "93.184.216.34", nslookupOld: true, wantRC: 0, wantRequests: 2}, + {name: "public IPv4 literal", resolver: "none", redirectURL: "https://8.8.8.8/asset", wantRC: 0, wantRequests: 2}, + {name: "private IPv4 literal", resolver: "none", redirectURL: "https://127.0.0.1/asset", wantRC: 2, wantRequests: 1}, + {name: "public IPv6 literal", resolver: "none", redirectURL: "https://[2606:4700:4700::1111]/asset", wantRC: 0, wantRequests: 2}, + {name: "private IPv6 literal", resolver: "none", redirectURL: "https://[::1]/asset", wantRC: 2, wantRequests: 1}, + {name: "non-https redirect", resolver: "none", redirectURL: "http://127.0.0.1/asset", wantRC: 2, wantRequests: 1}, + {name: "missing resolver", resolver: "none", wantRC: 1, wantRequests: 1}, + } { + t.Run(shell.name+"/"+tc.name, func(t *testing.T) { + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + if err := os.Mkdir(binDir, 0o700); err != nil { + t.Fatal(err) + } + for _, command := range []string{"awk", "rm", "sleep", "timeout", "wc"} { + target := shell.path + if !shell.busyboxBin { + var err error + target, err = exec.LookPath(command) + if err != nil { + t.Skipf("installer dependency unavailable: %s", command) + } + } + if err := os.Symlink(target, filepath.Join(binDir, command)); err != nil { + t.Fatal(err) + } + } + curlFixture := `#!/bin/sh +curl_url= +curl_out= +curl_resolve= +curl_noproxy= +curl_location=0 +curl_max_redirs= +curl_max_time= +while [ "$#" -gt 0 ]; do + case "$1" in + --resolve) shift; curl_resolve=$1 ;; + --noproxy) shift; curl_noproxy=$1 ;; + --location) curl_location=1 ;; + --max-redirs) shift; curl_max_redirs=$1 ;; + --max-time) shift; curl_max_time=$1 ;; + -o | --output) shift; curl_out=$1 ;; + https://*) curl_url=$1 ;; + esac + shift +done +[ "$curl_noproxy" = '*' ] && [ "$curl_location" = 1 ] && [ "$curl_max_redirs" = 0 ] || exit 85 +printf '%s|%s|%s\n' "$curl_url" "$curl_resolve" "$curl_max_time" >> "$TEST_CURL_MARKER" +: > "$curl_out" +if [ -n "$TEST_CURL_DELAY" ]; then + sleep "$TEST_CURL_DELAY" +fi +case "$curl_url" in + https://github.example/asset) + printf '302\n%s\n' "${TEST_REDIRECT_URL:-https://redirect.example/asset}" + exit 47 + ;; + https://redirect.example/asset) + [ "$curl_resolve" = 'redirect.example:443:93.184.216.34' ] || exit 86 + if [ "$TEST_SECOND_PRIVATE" = 1 ]; then + printf '302\nhttps://second.example/asset\n' + exit 47 + elif [ "$TEST_SLOW_CHAIN" = 1 ]; then + printf '302\nhttps://second.example/asset\n' + exit 47 + else + printf payload > "$curl_out" + printf '200\n\n' + fi + ;; + https://8.8.8.8/asset | https://\[2606:4700:4700::1111\]/asset) + [ -z "$curl_resolve" ] || exit 88 + printf payload > "$curl_out" + printf '200\n\n' + ;; + https://second.example/asset) + [ "$curl_resolve" = 'second.example:443:93.184.216.34' ] || exit 89 + printf payload > "$curl_out" + printf '200\n\n' + ;; + *) exit 87 ;; +esac +` + if err := os.WriteFile(filepath.Join(binDir, "curl"), []byte(curlFixture), 0o700); err != nil { + t.Fatal(err) + } + switch tc.resolver { + case "getent": + fixture := `#!/bin/sh +if [ -n "$TEST_RESOLVER_DELAY" ]; then + sleep "$TEST_RESOLVER_DELAY" +fi +if [ "$2" = second.example ]; then + if [ "$TEST_SECOND_PRIVATE" = 1 ]; then + printf '127.0.0.1 STREAM second.example\n' + else + printf '93.184.216.34 STREAM second.example\n' + fi +else + printf '%s\n' "$TEST_RESOLVER_OUTPUT" +fi +` + if err := os.WriteFile(filepath.Join(binDir, "getent"), []byte(fixture), 0o700); err != nil { + t.Fatal(err) + } + case "nslookup": + fixture := `#!/bin/sh +if [ "$TEST_NSLOOKUP_OLD" = 1 ]; then + printf 'Server: resolver.invalid\nAddress 1: 10.0.0.53 resolver.invalid\n\n' + printf 'Name: redirect.example\nAddress 1: %s redirect.example\n' "$TEST_RESOLVER_OUTPUT" +else + printf 'Server:\tresolver.invalid\nAddress: 10.0.0.53#53\n\n' + printf 'Name:\tredirect.example\nAddress: %s\n' "$TEST_RESOLVER_OUTPUT" +fi +` + if err := os.WriteFile(filepath.Join(binDir, "nslookup"), []byte(fixture), 0o700); err != nil { + t.Fatal(err) + } + } + + script := filepath.Join(dir, "redirect.sh") + source := `set -eu +PATH="$TEST_BIN" +export PATH +FSIZE_BLOCK_BYTES=512 +CONNECT_TIMEOUT_SECONDS=2 +` + networkFunctions + "\n" + fetchFunction + ` +if fetch_once 'https://github.example/asset' "$TEST_OUT" 4096 "$TEST_FETCH_TIMEOUT" 1; then + fetch_rc=0 +else + fetch_rc=$? +fi +printf 'rc=%s\n' "$fetch_rc" +` + if err := os.WriteFile(script, []byte(source), 0o700); err != nil { + t.Fatal(err) + } + args := append(append([]string(nil), shell.args...), script) + cmd := exec.Command(shell.path, args...) + marker := filepath.Join(dir, "curl-requests") + nslookupOld := "0" + if tc.nslookupOld { + nslookupOld = "1" + } + secondPrivate := "0" + if tc.secondPrivate { + secondPrivate = "1" + } + slowChain := "0" + if tc.slowChain { + slowChain = "1" + } + fetchTimeout := tc.fetchTimeout + if fetchTimeout == "" { + fetchTimeout = "5" + } + cmd.Env = []string{ + "TEST_BIN=" + binDir, + "TEST_CURL_MARKER=" + marker, + "TEST_OUT=" + filepath.Join(dir, "out"), + "TEST_RESOLVER_OUTPUT=" + tc.resolverOutput, + "TEST_NSLOOKUP_OLD=" + nslookupOld, + "TEST_REDIRECT_URL=" + tc.redirectURL, + "TEST_SECOND_PRIVATE=" + secondPrivate, + "TEST_SLOW_CHAIN=" + slowChain, + "TEST_CURL_DELAY=" + tc.curlDelay, + "TEST_RESOLVER_DELAY=" + tc.resolverDelay, + "TEST_FETCH_TIMEOUT=" + fetchTimeout, + } + started := time.Now() + out, err := cmd.CombinedOutput() + elapsed := time.Since(started) + if err != nil { + t.Fatalf("redirect fixture failed: %v\n%s", err, out) + } + if string(out) != fmt.Sprintf("rc=%d\n", tc.wantRC) { + t.Fatalf("fetch result=%q, want rc=%d", out, tc.wantRC) + } + requests, err := os.ReadFile(marker) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(requests)), "\n") + if tc.slowChain && (len(lines) < 2 || len(lines) > 3) { + t.Fatalf("slow redirect chain made %d requests, want 2 or 3: %q", len(lines), requests) + } + if !tc.slowChain && len(lines) != tc.wantRequests { + t.Fatalf("curl requests=%d, want %d: %q", len(lines), tc.wantRequests, requests) + } + if (tc.slowChain || tc.slowResolver) && elapsed > 2*time.Second { + t.Fatalf("shared one-second fetch budget took %s", elapsed) + } + if tc.slowChain { + previous := 2.0 + for _, line := range lines { + parts := strings.SplitN(line, "|", 3) + if len(parts) != 3 { + t.Fatalf("malformed curl request evidence: %q", line) + } + remaining, err := strconv.ParseFloat(parts[2], 64) + if err != nil || remaining <= 0 || remaining >= previous { + t.Fatalf("per-hop remaining budgets did not decrease: %q", requests) + } + previous = remaining + } + } + if len(lines) >= 2 { + wantLine := "https://redirect.example/asset|redirect.example:443:93.184.216.34" + if tc.redirectURL != "" { + wantLine = tc.redirectURL + "|" + } + parts := strings.SplitN(lines[1], "|", 3) + if len(parts) != 3 { + t.Fatalf("malformed curl request evidence: %q", lines[1]) + } + gotLine := strings.Join(parts[:2], "|") + if gotLine != wantLine { + t.Fatalf("redirect request did not use the validated endpoint: got %q, want %q", gotLine, wantLine) + } + } + }) + } + } +} + +func TestInstallerFetchRejectsHTTPRedirectAndChunkedOverflow(t *testing.T) { + for _, command := range []string{"curl", "timeout"} { + if _, err := exec.LookPath(command); err != nil { + t.Skipf("installer dependency unavailable: %s", command) + } + } + + var plainRequests atomic.Int32 + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + plainRequests.Add(1) + _, _ = w.Write([]byte("redirect downgrade reached")) + })) + defer plain.Close() + tlsServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/redirect": + http.Redirect(w, r, plain.URL+"/hit", http.StatusFound) + case "/redirect-overflow": + w.Header().Set("Location", plain.URL+"/hit") + w.WriteHeader(http.StatusFound) + chunk := bytes.Repeat([]byte("x"), 1024) + for i := 0; i < 64; i++ { + if _, err := w.Write(chunk); err != nil { + return + } + } + case "/overflow": + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + chunk := bytes.Repeat([]byte("x"), 1024) + for i := 0; i < 64; i++ { + if _, err := w.Write(chunk); err != nil { + return + } + flusher.Flush() + } + default: + http.NotFound(w, r) + } + })) + defer tlsServer.Close() + + dir := t.TempDir() + certPath := filepath.Join(dir, "test-ca.pem") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: tlsServer.Certificate().Raw}) + if err := os.WriteFile(certPath, certPEM, 0o600); err != nil { + t.Fatal(err) + } + installer := readReleaseFile(t, "../../scripts/install.sh") + fetchStart := strings.Index(installer, "fetch_once() {") + unitStart := strings.Index(installer, "# Bash uses 1024-byte `ulimit -f` blocks") + unitEnd := strings.Index(installer, "\n\nif [ ! -d \"$TMP_ROOT\" ]") + if fetchStart < 0 || unitStart < 0 || unitEnd < 0 { + t.Fatal("could not locate installer fetch guards") + } + fetchEndRel := strings.Index(installer[fetchStart:], "\n}\n\nfetch()") + if fetchEndRel < 0 { + t.Fatal("could not isolate installer fetch function") + } + fetchFunction := installer[fetchStart : fetchStart+fetchEndRel+2] + unitProbe := installer[unitStart:unitEnd] + + run := func(t *testing.T, path string, wantRC int) { + t.Helper() + script := filepath.Join(t.TempDir(), "fetch-test.sh") + source := `set -eu +fail() { echo "error: $*" >&2; exit 1; } +` + unitProbe + ` +FETCH_TIMEOUT_SECONDS=5 +CONNECT_TIMEOUT_SECONDS=2 +` + fetchFunction + ` +out=$TEST_DIR/out +if fetch_once "$TEST_URL` + path + `" "$out" 4096 "$FETCH_TIMEOUT_SECONDS"; then + echo "unsafe fetch unexpectedly succeeded" >&2 + exit 1 +else + fetch_rc=$? +fi +[ "$fetch_rc" -eq ` + fmt.Sprint(wantRC) + ` ] +[ ! -e "$out" ] +` + if err := os.WriteFile(script, []byte(source), 0o700); err != nil { + t.Fatal(err) + } + env := make([]string, 0, len(os.Environ())+4) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "CURL_CA_BUNDLE=") || + strings.HasPrefix(entry, "HTTPS_PROXY=") || strings.HasPrefix(entry, "https_proxy=") || + strings.HasPrefix(entry, "ALL_PROXY=") || strings.HasPrefix(entry, "all_proxy=") || + strings.HasPrefix(entry, "NO_PROXY=") || strings.HasPrefix(entry, "no_proxy=") { + continue + } + env = append(env, entry) + } + env = append(env, + "CURL_CA_BUNDLE="+certPath, + "TEST_DIR="+dir, + "TEST_URL="+tlsServer.URL, + "NO_PROXY=127.0.0.1,localhost", + ) + cmd := exec.Command("/bin/sh", script) + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("installer fetch guard failed: %v\n%s", err, out) + } + } + + t.Run("https redirect to http", func(t *testing.T) { + run(t, "/redirect", 2) + if got := plainRequests.Load(); got != 0 { + t.Fatalf("HTTP redirect endpoint received %d requests, want 0", got) + } + }) + t.Run("oversized redirect remains policy failure", func(t *testing.T) { + run(t, "/redirect-overflow", 2) + if got := plainRequests.Load(); got != 0 { + t.Fatalf("HTTP redirect endpoint received %d requests, want 0", got) + } + }) + t.Run("chunked overflow", func(t *testing.T) { + run(t, "/overflow", 1) + }) +} + +func TestDocumentedBootstrapsRunInsideSanitizedRootShell(t *testing.T) { + documents := map[string]struct { + path string + wantCurlCount int + wantSudoDirect int + }{ + "README.md": {path: "../../README.md", wantCurlCount: 1, wantSudoDirect: 0}, + "README.en.md": {path: "../../README.en.md", wantCurlCount: 1, wantSudoDirect: 0}, + "releasing.md": {path: "../../docs/releasing.md", wantCurlCount: 2, wantSudoDirect: 0}, + } + for name, document := range documents { + content := readReleaseFile(t, document.path) + if strings.Contains(content, "sudo linux-temp-admin") || + strings.Contains(content, "sudo -E") { + t.Errorf("%s documents a PATH-dependent or broadly environment-preserving sudo invocation", name) + } + streamingRootShell := false + for _, line := range strings.Split(content, "\n") { + if strings.HasSuffix(strings.TrimSpace(line), "| sudo sh") { + streamingRootShell = true + } + } + if streamingRootShell || strings.Contains(content, "curl -fsSL") { + t.Errorf("%s still documents a streaming root-shell bootstrap", name) + } + for _, required := range []string{ + "if ! FSIZE_BLOCK_BYTES=$(\n", + "512 | 1024", + "INSTALLER_BLOCKS=$(( (INSTALLER_MAX_BYTES + FSIZE_BLOCK_BYTES - 1) / FSIZE_BLOCK_BYTES ))", + `ulimit -f "$INSTALLER_BLOCKS" || exit 1`, + "curl -q --fail --silent --show-error --location", + `--max-filesize "$INSTALLER_MAX_BYTES"`, + "--proto '=https' --proto-redir '=https'", + `--output "$installer"`, + } { + if got := strings.Count(content, required); got != document.wantCurlCount { + t.Errorf("%s count(%q)=%d, want %d", name, required, got, document.wantCurlCount) + } + } + if strings.Contains(content, `sudo sh "$installer"`) { + t.Errorf("%s resolves the root shell through PATH", name) + } + for _, forbidden := range []string{ + `sudo /bin/sh "$installer"`, + `/usr/bin/sudo /bin/sh "$installer"`, + `sudo install -o 0 -g 0 -m 0600`, + `/usr/bin/sudo install -o 0 -g 0 -m 0600`, + } { + if strings.Contains(content, forbidden) { + t.Errorf("%s crosses the privilege boundary through caller-owned installer bytes: %q", name, forbidden) + } + } + if got := strings.Count(content, `sudo /bin/sh "$installer"`); got != document.wantSudoDirect { + t.Errorf("%s direct sudo installer count=%d, want %d", name, got, document.wantSudoDirect) + } + } + releasing := readReleaseFile(t, "../../docs/releasing.md") + for _, required := range []string{ + "INSTALLER_COMMIT", "INSTALLER_SHA256", "LTA_RELEASE_TAG", + "stat -Lc '%u %a' -- /tmp", `[[ "$tmp_meta" =~ ^0\ 1[0-7]{3}$ ]]`, + "mktemp /tmp/.lta-bootstrap.", "sha256sum -c -", + "/usr/bin/sudo /usr/bin/env -i", "/usr/bin/env -i HOME=/root", + } { + if !strings.Contains(releasing, required) { + t.Errorf("high-assurance bootstrap is missing %q", required) + } + } + if strings.Contains(releasing, "sha256sum -c --strict") { + t.Error("high-assurance bootstrap uses the GNU-only sha256sum --strict option") + } + for _, required := range []string{ + "TRUSTED_SIGNER_SOURCE=/opt/lta-reviewed-source", + "TRUSTED_SIGNER_COMMIT='replace-with-the-independently-recorded-40-hex-audited-commit'", + "/bin/bash -p <<'LTA_TRUSTED_SIGNER'", `find "$TRUSTED_SIGNER_SOURCE" -print0`, + "check_safe_source_dir", "8#$source_dir_mode & 8#7022", + "8#$source_mode & 8#7022", "GIT_CONFIG_NOSYSTEM=1", "GIT_NO_REPLACE_OBJECTS=1", + "GIT_NO_LAZY_FETCH=1", "GIT_TERMINAL_PROMPT=0", "timeout -k 5 60 git", + `-c core.bare=false -c core.fsmonitor=false -c core.hooksPath=/dev/null`, + `$TRUSTED_SIGNER_SOURCE/.git/commondir`, + `$TRUSTED_SIGNER_SOURCE/.git/objects/info/alternates`, + `rev-parse --verify 'HEAD^{commit}'`, `[[ "$source_head" == "$TRUSTED_SIGNER_COMMIT" ]]`, + `ls-tree -r -z "$TRUSTED_SIGNER_COMMIT"`, "120000|160000", + `archive --format=tar --output="$source_archive" "$TRUSTED_SIGNER_COMMIT"`, + `cd -- "$source_snapshot"`, + `[[ "$tmp_meta" =~ ^0\ 1[0-7]{3}$ ]]`, + "signer_arch=$(env -i", "go env GOARCH", "amd64) signer_tune=GOAMD64=v1", + "arm64) signer_tune=GOARM64=v8.0", "for build_id in a b", + `GOCACHE="$build_root/$build_id/gocache"`, + `GOMODCACHE="$build_root/$build_id/gomodcache"`, + `GOPATH="$build_root/$build_id/gopath" GOTMPDIR="$build_root/$build_id/gotmp"`, + `GOARCH="$signer_arch" "$signer_tune"`, + `cmp "$build_root/a/lta-release" "$build_root/b/lta-release"`, + } { + if !strings.Contains(releasing, required) { + t.Errorf("trusted signer setup is missing %q", required) + } + } + if strings.Contains(releasing, "CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOAMD64=v1") { + t.Error("trusted signer setup is hard-coded to amd64") + } + bootstrapBlocks := func(content string) []string { + var blocks []string + for _, section := range strings.Split(content, "```bash\n")[1:] { + end := strings.Index(section, "\n```") + if end < 0 { + continue + } + block := section[:end] + if strings.Contains(block, "raw.githubusercontent.com/xxvcc/linux-temp-admin/") || + strings.Contains(block, "https://dl.ll.cd/linux-temp-admin/install.sh") { + blocks = append(blocks, block) + } + } + return blocks + } + heredocBody := func(block string) (string, error) { + const opener = "<<'LTA_BOOTSTRAP'" + openerAt := strings.Index(block, opener) + if openerAt < 0 { + return "", fmt.Errorf("missing quoted bootstrap heredoc") + } + afterOpener := openerAt + len(opener) + lineEnd := strings.Index(block[afterOpener:], "\n") + if lineEnd < 0 { + return "", fmt.Errorf("missing bootstrap heredoc body") + } + start := afterOpener + lineEnd + 1 + endRel := strings.Index(block[start:], "\nLTA_BOOTSTRAP") + if endRel < 0 { + return "", fmt.Errorf("missing bootstrap heredoc terminator") + } + return block[start : start+endRel], nil + } + highAssuranceBlocks := bootstrapBlocks(releasing) + if len(highAssuranceBlocks) != 2 { + t.Fatalf("releasing.md bootstrap block count=%d, want 2", len(highAssuranceBlocks)) + } + for name, document := range documents { + blocks := bootstrapBlocks(readReleaseFile(t, document.path)) + if len(blocks) != document.wantCurlCount { + t.Errorf("%s bootstrap block count=%d, want %d", name, len(blocks), document.wantCurlCount) + continue + } + for i, block := range blocks { + if mirrorAt := strings.Index(block, "https://dl.ll.cd/linux-temp-admin/install.sh"); mirrorAt >= 0 { + githubAt := strings.Index(block, "https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh") + if githubAt <= mirrorAt { + t.Errorf("%s bootstrap block %d does not try the official mirror before raw GitHub", name, i+1) + } + if !strings.Contains(block, "--location --max-redirs 0") || + !strings.Contains(block, "official mirror installer redirected; refusing source-policy fallback") { + t.Errorf("%s bootstrap block %d does not reject official-mirror redirects", name, i+1) + } + } + body, err := heredocBody(block) + if err != nil { + t.Errorf("%s bootstrap block %d: %v", name, i+1, err) + continue + } + prefix := block[:strings.Index(block, "<<'LTA_BOOTSTRAP'")] + if strings.Count(prefix, "/usr/bin/sudo /usr/bin/env -i") != 1 || + !strings.Contains(prefix, "HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C") { + t.Errorf("%s bootstrap block %d does not enter one sanitized root shell", name, i+1) + } + if strings.Contains(prefix, "mktemp") || strings.Contains(prefix, "curl ") { + t.Errorf("%s bootstrap block %d handles installer bytes before entering the root shell", name, i+1) + } + for _, required := range []string{ + "ulimit -c 0", "stat -Lc '%u %a' -- /tmp", + "mktemp /tmp/.lta-bootstrap.", `timeout -k 5 70 curl -q`, + `installer_size=$(wc -c < "$installer")`, + } { + if !strings.Contains(body, required) { + t.Errorf("%s bootstrap block %d is missing %q", name, i+1, required) + } + } + if strings.Contains(body, "sudo ") || strings.Contains(body, "/usr/bin/sudo") { + t.Errorf("%s bootstrap block %d re-crosses the privilege boundary", name, i+1) + } + + t.Run(fmt.Sprintf("%s download failure/block %d", name, i+1), func(t *testing.T) { + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + if err := os.Mkdir(binDir, 0o700); err != nil { + t.Fatal(err) + } + curlFixture := `#!/bin/sh +out= +while [ "$#" -gt 0 ]; do + case "$1" in + --output) shift; out=$1 ;; + esac + shift +done +[ -n "$out" ] || exit 88 +printf '%s\n' '#!/bin/sh' ': > "$TEST_EXEC_MARKER"' > "$out" +exit 55 +` + timeoutFixture := `#!/bin/sh +case "$1" in -k) shift 2 ;; esac +shift +exec "$@" +` + for fixtureName, fixture := range map[string]string{ + "curl": curlFixture, "timeout": timeoutFixture, + } { + if err := os.WriteFile(filepath.Join(binDir, fixtureName), []byte(fixture), 0o700); err != nil { + t.Fatal(err) + } + } + marker := filepath.Join(dir, "installer-ran") + testShell := "/bin/sh" + if strings.Contains(body, "set -Eeuo pipefail") { + testShell = "/bin/bash" + } + env := make([]string, 0, len(os.Environ())+5) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "PATH=") || strings.HasPrefix(entry, "TEST_") || + strings.HasPrefix(entry, "INSTALLER_") || strings.HasPrefix(entry, "LTA_RELEASE_TAG=") { + continue + } + env = append(env, entry) + } + env = append(env, + "PATH="+binDir+":/usr/sbin:/usr/bin:/sbin:/bin", + "TEST_EXEC_MARKER="+marker, + "INSTALLER_COMMIT="+strings.Repeat("a", 40), + "INSTALLER_SHA256="+strings.Repeat("0", 64), + "LTA_RELEASE_TAG=v9.9.9", + ) + cmd := exec.Command(testShell, "-c", body) + cmd.Env = env + out, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(out), "installer download failed or exceeded its limit") { + t.Fatalf("failed installer download was not fail-closed: err=%v\n%s", err, out) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("downloaded installer ran after curl failure: %v", err) + } + }) + } + } + for _, shell := range []struct { + name string + args []string + }{ + {name: "bash"}, + {name: "bash-posix", args: []string{"--posix"}}, + } { + t.Run("high-assurance malformed stat/"+shell.name, func(t *testing.T) { + body, err := heredocBody(highAssuranceBlocks[1]) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + if err := os.Mkdir(binDir, 0o700); err != nil { + t.Fatal(err) + } + fixtures := map[string]string{ + "stat": "#!/bin/sh\nprintf '0 1777\\nunexpected\\n'\n", + "curl": "#!/bin/sh\n: > \"$TEST_CURL_MARKER\"\nexit 99\n", + "sudo": "#!/bin/sh\n: > \"$TEST_SUDO_MARKER\"\nexit 99\n", + } + for name, fixture := range fixtures { + if err := os.WriteFile(filepath.Join(binDir, name), []byte(fixture), 0o700); err != nil { + t.Fatal(err) + } + } + curlMarker := filepath.Join(dir, "curl-ran") + sudoMarker := filepath.Join(dir, "sudo-ran") + env := make([]string, 0, len(os.Environ())+6) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "PATH=") || strings.HasPrefix(entry, "BASH_FUNC_") || + strings.HasPrefix(entry, "TEST_") || strings.HasPrefix(entry, "INSTALLER_") || + strings.HasPrefix(entry, "LTA_RELEASE_TAG=") { + continue + } + env = append(env, entry) + } + env = append(env, + "PATH="+binDir+":"+os.Getenv("PATH"), + "TEST_CURL_MARKER="+curlMarker, + "TEST_SUDO_MARKER="+sudoMarker, + "INSTALLER_COMMIT="+strings.Repeat("0", 40), + "INSTALLER_SHA256="+strings.Repeat("0", 64), + "LTA_RELEASE_TAG=v9.9.9", + ) + args := append(append([]string(nil), shell.args...), "-c", body) + cmd := exec.Command("/bin/bash", args...) + cmd.Env = env + out, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(out), "/tmp must be root-owned, sticky") { + t.Fatalf("malformed stat metadata was not rejected: err=%v\n%s", err, out) + } + for commandName, marker := range map[string]string{"curl": curlMarker, "sudo": sudoMarker} { + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("%s ran after malformed stat metadata: %v", commandName, err) + } + } + }) + } + for name, document := range documents { + content := readReleaseFile(t, document.path) + blocks := bootstrapBlocks(content) + if len(blocks) != document.wantCurlCount { + t.Errorf("%s bootstrap block count=%d, want %d", name, len(blocks), document.wantCurlCount) + continue + } + for i, block := range blocks { + t.Run(fmt.Sprintf("%s hard file limit/block %d", name, i+1), func(t *testing.T) { + body, err := heredocBody(block) + if err != nil { + t.Fatal(err) + } + dir, err := os.MkdirTemp("/tmp", "lta-bootstrap-limit-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + if os.Geteuid() == 0 { + if err := os.Chown(dir, 65534, 65534); err != nil { + t.Fatal(err) + } + } + binDir := filepath.Join(dir, "bin") + if err := os.Mkdir(binDir, 0o755); err != nil { + t.Fatal(err) + } + for commandName, markerVariable := range map[string]string{ + "curl": "TEST_CURL_MARKER", + "sudo": "TEST_SUDO_MARKER", + } { + fixture := "#!/bin/sh\nmkdir \"$" + markerVariable + "\"\nexit 0\n" + if err := os.WriteFile(filepath.Join(binDir, commandName), []byte(fixture), 0o755); err != nil { + t.Fatal(err) + } + } + testShell := "/bin/sh" + if strings.Contains(body, "set -Eeuo pipefail") { + testShell = "/bin/bash" + } + env := make([]string, 0, len(os.Environ())+9) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "PATH=") || strings.HasPrefix(entry, "TEST_") || + strings.HasPrefix(entry, "INSTALLER_") || strings.HasPrefix(entry, "LTA_RELEASE_TAG=") { + continue + } + env = append(env, entry) + } + curlMarker := filepath.Join(dir, "curl-ran") + sudoMarker := filepath.Join(dir, "sudo-ran") + env = append(env, + "PATH="+binDir+":"+os.Getenv("PATH"), + "TEST_SHELL="+testShell, + "TEST_BLOCK="+body, + "TEST_CURL_MARKER="+curlMarker, + "TEST_SUDO_MARKER="+sudoMarker, + "INSTALLER_COMMIT="+strings.Repeat("0", 40), + "INSTALLER_SHA256="+strings.Repeat("0", 64), + "LTA_RELEASE_TAG=v9.9.9", + ) + cmd := exec.Command("/bin/sh", "-c", "ulimit -S -f 0 || exit 90\nulimit -H -f 0 || exit 91\nexec \"$TEST_SHELL\" -c \"$TEST_BLOCK\"") + cmd.Env = env + if os.Geteuid() == 0 { + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: 65534, Gid: 65534}} + } + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("bootstrap unexpectedly succeeded with inherited hard RLIMIT_FSIZE=0: %s", out) + } + if !strings.Contains(string(out), "cannot determine the shell file-size limit unit") { + t.Fatalf("bootstrap did not report its file-limit failure: %v\n%s", err, out) + } + for markerName, marker := range map[string]string{"curl": curlMarker, "sudo": sudoMarker} { + if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) { + t.Fatalf("%s ran after file-limit setup failure: %v", markerName, statErr) + } + } + }) + } + } + + type signalShellCase struct { + name string + path string + args []string + } + signalShells := []signalShellCase{ + {name: "bash", path: "/bin/bash"}, + {name: "bash-posix", path: "/bin/bash", args: []string{"--posix"}}, + {name: "dash", path: "/bin/dash"}, + } + if busybox, err := exec.LookPath("busybox"); err == nil { + signalShells = append(signalShells, signalShellCase{name: "busybox-ash", path: busybox, args: []string{"ash"}}) + } + signals := []struct { + name string + signal syscall.Signal + }{ + {name: "hup", signal: syscall.SIGHUP}, + {name: "int", signal: syscall.SIGINT}, + {name: "term", signal: syscall.SIGTERM}, + } + for name, document := range documents { + blocks := bootstrapBlocks(readReleaseFile(t, document.path)) + for i, block := range blocks { + body, err := heredocBody(block) + if err != nil { + t.Fatalf("%s bootstrap block %d: %v", name, i+1, err) + } + const signalTrap = "trap 'exit 1' HUP INT TERM\n" + if strings.Count(body, signalTrap) != 1 { + t.Fatalf("%s bootstrap block %d must install one terminating signal trap", name, i+1) + } + body = strings.Replace(body, signalTrap, signalTrap+ + `: > "$TEST_SIGNAL_READY" +while :; do :; done +`, 1) + + bashOnly := strings.Contains(body, "set -Eeuo pipefail") + for _, shell := range signalShells { + if bashOnly && shell.name != "bash" && shell.name != "bash-posix" { + continue + } + if _, err := os.Stat(shell.path); err != nil { + continue + } + for _, sig := range signals { + t.Run(fmt.Sprintf("%s signal stop/block %d/%s/%s", name, i+1, shell.name, sig.name), func(t *testing.T) { + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + if err := os.Mkdir(binDir, 0o700); err != nil { + t.Fatal(err) + } + for commandName, markerVariable := range map[string]string{ + "curl": "TEST_CURL_MARKER", + "sudo": "TEST_SUDO_MARKER", + } { + fixture := "#!/bin/sh\n: > \"$" + markerVariable + "\"\nexit 99\n" + if err := os.WriteFile(filepath.Join(binDir, commandName), []byte(fixture), 0o700); err != nil { + t.Fatal(err) + } + } + + ready := filepath.Join(dir, "ready") + curlMarker := filepath.Join(dir, "curl-ran") + sudoMarker := filepath.Join(dir, "sudo-ran") + env := make([]string, 0, len(os.Environ())+7) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "PATH=") || strings.HasPrefix(entry, "TEST_") || + strings.HasPrefix(entry, "INSTALLER_") || strings.HasPrefix(entry, "LTA_RELEASE_TAG=") { + continue + } + env = append(env, entry) + } + env = append(env, + "PATH="+binDir+":"+os.Getenv("PATH"), + "TEST_SIGNAL_READY="+ready, + "TEST_CURL_MARKER="+curlMarker, + "TEST_SUDO_MARKER="+sudoMarker, + "INSTALLER_COMMIT="+strings.Repeat("0", 40), + "INSTALLER_SHA256="+strings.Repeat("0", 64), + "LTA_RELEASE_TAG=v9.9.9", + ) + + args := append(append([]string(nil), shell.args...), "-c", body) + cmd := exec.Command(shell.path, args...) + cmd.Env = env + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for { + if _, err := os.Stat(ready); err == nil { + break + } else if !os.IsNotExist(err) { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatal(err) + } + if time.Now().After(deadline) { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatalf("bootstrap did not reach its signal guard:\n%s", output.Bytes()) + } + time.Sleep(5 * time.Millisecond) + } + if err := cmd.Process.Signal(sig.signal); err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + if err == nil { + t.Fatalf("bootstrap succeeded after %s:\n%s", sig.name, output.Bytes()) + } + case <-time.After(2 * time.Second): + _ = cmd.Process.Kill() + <-done + t.Fatalf("bootstrap did not terminate after %s:\n%s", sig.name, output.Bytes()) + } + for commandName, marker := range map[string]string{"curl": curlMarker, "sudo": sudoMarker} { + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("%s ran after %s: %v\n%s", commandName, sig.name, err, output.Bytes()) + } + } + }) + } + } + } + } + + security := readReleaseFile(t, "../../SECURITY.md") + if strings.Contains(security, "LTA_ALLOW_UNVERIFIED") || + !strings.Contains(security, "no unsigned or checksum-only fallback") { + t.Fatal("SECURITY.md does not describe the current fail-closed bootstrap") + } +} + +func TestTrustedReleaseScriptsPinTemporaryDirectoryAndGitHubHost(t *testing.T) { + offline := readReleaseFile(t, "../../scripts/offline-sign-release.sh") + prepare := readReleaseFile(t, "../../scripts/prepare-release.sh") + publish := readReleaseFile(t, "../../scripts/publish-release.sh") + for name, content := range map[string]string{ + "offline": offline, + "prepare": prepare, + "publish": publish, + } { + for _, required := range []string{ + "require_trusted_tmp", + `stat -Lc '%u %a' -- /tmp`, + `"$tmp_uid" == 0`, + `8#$tmp_mode & 8#7000) == 8#1000`, + } { + if !strings.Contains(content, required) { + t.Fatalf("%s trusted script is missing temporary-directory guard %q", name, required) + } + } + if strings.Index(content, "require_trusted_tmp") > strings.Index(content, `mktemp -d /tmp/`) { + t.Fatalf("%s validates /tmp only after creating its private snapshot", name) + } + } + for name, content := range map[string]string{"prepare": prepare, "publish": publish} { + if !strings.Contains(content, "GH_HOST=github.com") || + !strings.Contains(content, "export PATH LC_ALL GIT_NO_REPLACE_OBJECTS GIT_NO_LAZY_FETCH GIT_TERMINAL_PROMPT") { + t.Fatalf("%s does not override and export the fixed GitHub host", name) + } + if strings.Index(content, "GH_HOST=github.com") > strings.Index(content, "gh_with_timeout api") { + t.Fatalf("%s sets GH_HOST only after its first GitHub call", name) + } + if !strings.Contains(content, `gh_with_timeout() {`) || + !strings.Contains(content, `timeout -k 5 300 gh "$@"`) { + t.Fatalf("%s does not bound GitHub CLI calls", name) + } + } + stage := readReleaseFile(t, "../../.github/workflows/stage-release.yml") + if strings.Count(stage, "GH_HOST: github.com") != 2 { + t.Fatal("trusted staging workflow does not pin github.com for every gh-bearing step") + } + if strings.Count(stage, "GH_PROMPT_DISABLED: '1'") != 2 || + !strings.Contains(stage, `timeout -k 5 600 gh release create`) || + !strings.Contains(stage, "could not prove release $TAG is absent") { + t.Fatal("trusted staging workflow does not bound GitHub calls and fail closed on ambiguous release lookup") + } + preflight := strings.Index(publish, "for command_name in") + firstMutation := strings.Index(publish, `gh_with_timeout release upload "$TAG"`) + if preflight < 0 || firstMutation < 0 || preflight > firstMutation { + t.Fatal("publisher command preflight does not precede its first remote mutation") + } + for _, command := range []string{"curl", "timeout", "sleep", "diff", "gh", "git"} { + if !strings.Contains(publish[preflight:firstMutation], command) { + t.Fatalf("publisher does not preflight post-mutation command %q", command) + } + } +} + +func TestTrustedReleaseScriptsBoundLocalCommands(t *testing.T) { + prepare := readReleaseFile(t, "../../scripts/prepare-release.sh") + offline := readReleaseFile(t, "../../scripts/offline-sign-release.sh") + publish := readReleaseFile(t, "../../scripts/publish-release.sh") + for name, content := range map[string]string{"prepare": prepare, "publish": publish} { + for _, required := range []string{ + "GIT_NO_LAZY_FETCH=1", + "GIT_TERMINAL_PROMPT=0", + "GH_PROMPT_DISABLED=1", + "git_with_timeout() {", + "--batch --no-auto-key-retrieve", + } { + if !strings.Contains(content, required) { + t.Fatalf("%s release script is missing bounded local-command guard %q", name, required) + } + } + } + if !strings.Contains(prepare, `timeout -k 30 "$GO_BUILD_TIMEOUT_SECONDS"`) || + !strings.Contains(prepare, "MAX_SOURCE_ARCHIVE_BYTES=134217728") || + !strings.Contains(prepare, `git_with_timeout -C "$SOURCE_DIR" archive`) { + t.Fatal("prepare phase does not bound its compiler or source export") + } + for name, content := range map[string]string{"prepare": prepare, "offline": offline} { + for _, required := range []string{ + `timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" mkdir -m 0700`, + `timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" chmod 0600`, + `timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" sha256sum`, + `exec sha256sum -c --strict`, + } { + if !strings.Contains(content, required) { + t.Fatalf("%s release script leaves final transfer-media operation unbounded: missing %q", name, required) + } + } + } + if !strings.Contains(prepare, `bounded_copy "$prepared_work/$name" "$OUT_DIR/$name" "$limit"`) || + !strings.Contains(offline, `bounded_copy "$signed_work/$name" "$SIGNED_DIR/$name" "$limit"`) { + t.Fatal("prepare/offline release output is not copied through the bounded transfer helper") + } + for name, content := range map[string]string{"offline": offline, "publish": publish} { + for _, required := range []string{ + "signer_with_timeout() {", + `timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS"`, + `cp --reflink=never --sparse=never`, + } { + if !strings.Contains(content, required) { + t.Fatalf("%s release script is missing bounded snapshot/signer guard %q", name, required) + } + } + } + if !strings.Contains(offline, "offline private key must be an absolute regular non-symlink file") { + t.Fatal("offline signer does not reject special-file private keys before the ceremony") + } + if strings.Contains(publish, `"$trusted_signer" verify`) || + strings.Count(publish, "signer_with_timeout verify") != 2 { + t.Fatal("publisher has an unbounded verifier invocation") + } +} + +func TestTrustedTemporaryDirectoryGuardWithMockStat(t *testing.T) { + offline := readReleaseFile(t, "../../scripts/offline-sign-release.sh") + start := strings.Index(offline, "require_trusted_tmp() {") + end := strings.Index(offline[start:], "\nrequire_trusted_tmp\n") + if start < 0 || end < 0 { + t.Fatal("could not isolate trusted temporary-directory guard") + } + guard := offline[start : start+end] + tests := []struct { + name string + uid string + mode string + wantOK bool + }{ + {name: "root owned sticky", uid: "0", mode: "1777", wantOK: true}, + {name: "setgid sticky", uid: "0", mode: "3777", wantOK: false}, + {name: "setuid sticky", uid: "0", mode: "5777", wantOK: false}, + {name: "all special bits", uid: "0", mode: "7777", wantOK: false}, + {name: "non-root owner", uid: "1000", mode: "1777", wantOK: false}, + {name: "missing sticky bit", uid: "0", mode: "0777", wantOK: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "tmp-guard.sh") + body := `#!/bin/bash + set -Eeuo pipefail + stat() { printf '%s %s\n' "$TEST_UID" "$TEST_MODE"; } + local_with_timeout() { "$@"; } + ` + guard + ` + require_trusted_tmp +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = append(os.Environ(), "TEST_UID="+tt.uid, "TEST_MODE="+tt.mode) + out, err := cmd.CombinedOutput() + if tt.wantOK && err != nil { + t.Fatalf("trusted /tmp guard failed: %v\n%s", err, out) + } + if !tt.wantOK && err == nil { + t.Fatalf("unsafe /tmp metadata unexpectedly passed: %s", out) + } + }) + } +} + +func TestTrustedOnlineReleaseEnvironmentIsSanitizedBeforeArchiveUse(t *testing.T) { + prepare := readReleaseFile(t, "../../scripts/prepare-release.sh") + publish := readReleaseFile(t, "../../scripts/publish-release.sh") + for name, content := range map[string]string{"prepare": prepare, "publish": publish} { + for _, required := range []string{ + "compgen -A variable", + "unset HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY", + "unset SSL_CERT_FILE SSL_CERT_DIR CURL_CA_BUNDLE", + "unset GH_CONFIG_DIR XDG_CONFIG_HOME GIT_SSL_CAINFO GIT_SSL_CAPATH", + "unset TAR_OPTIONS GZIP BZIP2 BZIP XZ_OPT", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + `GH_CONFIG_DIR="$work/gh-config"`, + "set GH_TOKEN to a short-lived github.com release token", + } { + if !strings.Contains(content, required) { + t.Fatalf("%s does not sanitize inherited release environment: missing %q", name, required) + } + } + } + if strings.Contains(prepare, "go version | awk") || !strings.Contains(prepare, `go_version_output="$(timeout`) { + t.Fatal("prepare release still allows a successful-looking Go version pipeline to hide a command failure") + } + releasing := readReleaseFile(t, "../../docs/releasing.md") + if strings.Contains(releasing, "go version | awk") || + !strings.Contains(releasing, "if ! trusted_go_version=") || + !strings.Contains(releasing, "hash-object --no-filters") || + strings.Contains(releasing, "diff --no-ext-diff --no-textconv") { + t.Fatal("trusted signer instructions do not isolate Git attributes and Go version failures") + } + + start := strings.Index(prepare, "PATH=/usr/local/go/bin:") + end := strings.Index(prepare[start:], "\nhash -r") + if start < 0 || end < 0 { + t.Fatal("could not isolate preparation environment sanitization") + } + sanitization := prepare[start : start+end+len("\nhash -r")] + + dir := t.TempDir() + sourceDir := filepath.Join(dir, "source") + extractDir := filepath.Join(dir, "extract") + if err := os.Mkdir(sourceDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(extractDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "payload"), bytes.Repeat([]byte("x"), 32768), 0o600); err != nil { + t.Fatal(err) + } + archive := filepath.Join(dir, "source.tar") + archiveCmd := exec.Command("tar", "-cf", archive, "-C", sourceDir, "payload") + for _, entry := range os.Environ() { + if !strings.HasPrefix(entry, "TAR_OPTIONS=") { + archiveCmd.Env = append(archiveCmd.Env, entry) + } + } + if out, err := archiveCmd.CombinedOutput(); err != nil { + t.Fatalf("create test archive: %v\n%s", err, out) + } + marker := filepath.Join(dir, "tar-options-executed") + attack := filepath.Join(dir, "attack.sh") + if err := os.WriteFile(attack, []byte("#!/bin/sh\n: > \"$TEST_MARKER\"\n"), 0o700); err != nil { + t.Fatal(err) + } + testScript := filepath.Join(dir, "sanitize.sh") + body := "#!/bin/bash\nset -Eeuo pipefail\n" + sanitization + ` +[[ -z ${TAR_OPTIONS+x} && -z ${HTTPS_PROXY+x} && -z ${SSL_CERT_FILE+x} && -z ${GIT_TRACE+x} ]] +[[ "$GIT_CONFIG_NOSYSTEM" == 1 && "$GIT_CONFIG_GLOBAL" == /dev/null ]] +timeout -k 5 30 tar --extract --file="$TEST_ARCHIVE" --directory="$TEST_EXTRACT" --no-same-owner --no-same-permissions +` + if err := os.WriteFile(testScript, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + env := make([]string, 0, len(os.Environ())+8) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "TAR_OPTIONS=") || strings.HasPrefix(entry, "HTTPS_PROXY=") || + strings.HasPrefix(entry, "SSL_CERT_FILE=") || strings.HasPrefix(entry, "GIT_TRACE=") || + strings.HasPrefix(entry, "TEST_") { + continue + } + env = append(env, entry) + } + env = append(env, + "TAR_OPTIONS=--checkpoint=1 --checkpoint-action=exec="+attack, + "HTTPS_PROXY=http://127.0.0.1:9", + "SSL_CERT_FILE="+filepath.Join(dir, "attacker-ca.pem"), + "GIT_TRACE="+filepath.Join(dir, "git-trace"), + "TEST_MARKER="+marker, + "TEST_ARCHIVE="+archive, + "TEST_EXTRACT="+extractDir, + ) + cmd := exec.Command("/bin/bash", "-p", testScript) + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("sanitized archive extraction failed: %v\n%s", err, out) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("inherited TAR_OPTIONS executed a command: %v", err) + } +} + +func TestTrustedSignerDocumentationBlockParsesAsBash(t *testing.T) { + releasing := readReleaseFile(t, "../../docs/releasing.md") + const opener = "/bin/bash -p <<'LTA_TRUSTED_SIGNER'\n" + start := strings.Index(releasing, opener) + if start < 0 { + t.Fatal("trusted signer heredoc opener is missing") + } + start += len(opener) + end := strings.Index(releasing[start:], "\nLTA_TRUSTED_SIGNER\n") + if end < 0 { + t.Fatal("trusted signer heredoc terminator is missing") + } + body := releasing[start : start+end] + cmd := exec.Command("/bin/bash", "-n") + cmd.Stdin = strings.NewReader(body) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("trusted signer documentation block has invalid Bash syntax: %v\n%s", err, out) + } +} + +func TestPrepareRejectsFailingGoVersionCommandWithValidLookingOutput(t *testing.T) { + prepare := readReleaseFile(t, "../../scripts/prepare-release.sh") + start := strings.Index(prepare, `go_version_output="$(timeout`) + end := strings.Index(prepare[start:], "\n\nwork=") + if start < 0 || end < 0 { + t.Fatal("could not isolate Go version gate") + } + versionGate := prepare[start : start+end] + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + if err := os.Mkdir(binDir, 0o700); err != nil { + t.Fatal(err) + } + mockGo := filepath.Join(binDir, "go") + if err := os.WriteFile(mockGo, []byte("#!/bin/sh\necho 'go version go1.26.5 linux/amd64'\nexit 23\n"), 0o700); err != nil { + t.Fatal(err) + } + marker := filepath.Join(dir, "gate-passed") + script := filepath.Join(dir, "version-gate.sh") + body := `#!/bin/bash +set -Eeuo pipefail +GO_VERSION=go1.26.5 +` + versionGate + ` +: > "$TEST_MARKER" +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = []string{ + "PATH=" + binDir + ":/usr/bin:/bin", + "TEST_MARKER=" + marker, + } + out, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(out), "could not execute the trusted Go toolchain") { + t.Fatalf("failing Go version command was not rejected: err=%v\n%s", err, out) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("Go version gate continued after command failure: %v", err) + } +} + +func TestPrepareArchiveVerificationRejectsLocalAttributeRewrite(t *testing.T) { + prepare := readReleaseFile(t, "../../scripts/prepare-release.sh") + start := strings.Index(prepare, `timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" tar --extract`) + endMarker := `done < "$work/tag-tree"` + end := strings.Index(prepare[start:], endMarker) + if start < 0 || end < 0 { + t.Fatal("could not isolate source archive verification") + } + verification := prepare[start : start+end+len(endMarker)] + + dir := t.TempDir() + repo := filepath.Join(dir, "repo") + if err := os.Mkdir(repo, 0o700); err != nil { + t.Fatal(err) + } + runGit := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + runGit("init", "-q") + payload := filepath.Join(repo, "payload.txt") + if err := os.WriteFile(payload, []byte("value=$Format:%H$\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit("add", "payload.txt") + runGit("-c", "user.name=test", "-c", "user.email=test@example.invalid", "commit", "-q", "-m", "payload") + marker := filepath.Join(dir, "filter-executed") + attack := filepath.Join(dir, "attack.sh") + if err := os.WriteFile(attack, []byte("#!/bin/sh\n: > \"$TEST_FILTER_MARKER\"\ncat\n"), 0o700); err != nil { + t.Fatal(err) + } + runGit("config", "filter.evil.clean", attack) + runGit("config", "filter.evil.smudge", attack) + if err := os.WriteFile(filepath.Join(repo, ".git", "info", "attributes"), []byte("payload.txt export-subst filter=evil\n"), 0o600); err != nil { + t.Fatal(err) + } + + work := filepath.Join(dir, "work") + if err := os.MkdirAll(filepath.Join(work, "source"), 0o700); err != nil { + t.Fatal(err) + } + archive := filepath.Join(work, "source.tar") + runGit("archive", "--format=tar", "--output="+archive, "HEAD") + treeCmd := exec.Command("git", "-C", repo, "ls-tree", "-r", "-z", "HEAD") + treeBytes, err := treeCmd.Output() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, "tag-tree"), treeBytes, 0o600); err != nil { + t.Fatal(err) + } + script := filepath.Join(dir, "verify-archive.sh") + body := `#!/bin/bash +set -Eeuo pipefail +LOCAL_COMMAND_TIMEOUT_SECONDS=30 +SOURCE_DIR="$TEST_REPO" +source_archive="$TEST_ARCHIVE" +work="$TEST_WORK" +git_with_timeout() { command git "$@"; } +` + verification + "\n" + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = append(os.Environ(), + "TEST_REPO="+repo, + "TEST_ARCHIVE="+archive, + "TEST_WORK="+work, + "TEST_FILTER_MARKER="+marker, + ) + out, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(out), "exported source differs from tagged Git object") { + t.Fatalf("local attribute rewrite was not detected: err=%v\n%s", err, out) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("local Git clean/smudge filter executed: %v", err) + } +} + +func TestReleaseOutputPathRejectsSymlinkedOrWritableParent(t *testing.T) { + offline := readReleaseFile(t, "../../scripts/offline-sign-release.sh") + start := strings.Index(offline, "require_safe_directory_path() {") + end := strings.Index(offline[start:], "\nPREPARED_DIR=") + if start < 0 || end < 0 { + t.Fatal("could not isolate trusted output-path guards") + } + guards := offline[start : start+end] + dir := t.TempDir() + realParent := filepath.Join(dir, "real") + if err := os.Mkdir(realParent, 0o700); err != nil { + t.Fatal(err) + } + symlinkParent := filepath.Join(dir, "linked") + if err := os.Symlink(realParent, symlinkParent); err != nil { + t.Fatal(err) + } + writableParent := filepath.Join(dir, "writable") + if err := os.Mkdir(writableParent, 0o777); err != nil { + t.Fatal(err) + } + if err := os.Chmod(writableParent, 0o777); err != nil { + t.Fatal(err) + } + + for name, output := range map[string]string{ + "symlinked ancestor": filepath.Join(symlinkParent, "out"), + "writable parent": filepath.Join(writableParent, "out"), + } { + t.Run(name, func(t *testing.T) { + script := filepath.Join(dir, strings.ReplaceAll(name, " ", "-")+".sh") + body := "#!/bin/bash\nset -Eeuo pipefail\nLOCAL_COMMAND_TIMEOUT_SECONDS=120\nlocal_with_timeout() { timeout -k 5 \"$LOCAL_COMMAND_TIMEOUT_SECONDS\" \"$@\"; }\n" + guards + ` +require_safe_new_output_path "$TEST_OUTPUT" "test output" +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = append(os.Environ(), "TEST_OUTPUT="+output) + if out, err := cmd.CombinedOutput(); err == nil { + t.Fatalf("unsafe output parent passed validation: %s", out) + } + }) + } +} + +func TestTrustedReleaseDirectoryLeavesRejectStickyMode(t *testing.T) { + for _, releaseScript := range []string{ + "../../scripts/prepare-release.sh", + "../../scripts/publish-release.sh", + } { + releaseScript := releaseScript + t.Run(filepath.Base(releaseScript), func(t *testing.T) { + content := readReleaseFile(t, releaseScript) + dirStart := strings.Index(content, "require_safe_directory_path() {") + sourceStart := strings.Index(content, "require_safe_source_repo() {") + if dirStart < 0 || sourceStart < 0 { + t.Fatal("could not locate trusted source-directory guards") + } + dirEnd := strings.Index(content[dirStart:], "\n}\n") + sourceEnd := strings.Index(content[sourceStart:], "\n}\n") + if dirEnd < 0 || sourceEnd < 0 { + t.Fatal("could not isolate trusted source-directory guards") + } + guards := content[dirStart:dirStart+dirEnd+2] + "\n" + + content[sourceStart:sourceStart+sourceEnd+2] + + for _, tt := range []struct { + name string + repoMode os.FileMode + gitMode os.FileMode + wantOK bool + }{ + {name: "private source and Git directory", repoMode: 0o700, gitMode: 0o700, wantOK: true}, + {name: "sticky source leaf", repoMode: 0o777 | os.ModeSticky, gitMode: 0o700, wantOK: false}, + {name: "sticky Git leaf", repoMode: 0o700, gitMode: 0o777 | os.ModeSticky, wantOK: false}, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + repo := filepath.Join(dir, "repo") + gitDir := filepath.Join(repo, ".git") + if err := os.MkdirAll(filepath.Join(gitDir, "objects", "info"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(repo, tt.repoMode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(gitDir, tt.gitMode); err != nil { + t.Fatal(err) + } + script := filepath.Join(dir, "source-guard.sh") + body := `#!/bin/bash +set -Eeuo pipefail +LOCAL_COMMAND_TIMEOUT_SECONDS=120 +local_with_timeout() { timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" "$@"; } +SOURCE_DIR="$TEST_REPO" +` + guards + ` +require_safe_source_repo +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = append(os.Environ(), "TEST_REPO="+repo) + out, err := cmd.CombinedOutput() + if tt.wantOK && err != nil { + t.Fatalf("private trusted source was rejected: %v\n%s", err, out) + } + if !tt.wantOK && err == nil { + t.Fatalf("sticky trusted-directory leaf unexpectedly passed: %s", out) + } + }) + } + }) + } +} + +func TestReleaseOutputPathAllowsStickyParentButRejectsNoncanonicalPath(t *testing.T) { + offline := readReleaseFile(t, "../../scripts/offline-sign-release.sh") + start := strings.Index(offline, "require_safe_directory_path() {") + end := strings.Index(offline[start:], "\nPREPARED_DIR=") + if start < 0 || end < 0 { + t.Fatal("could not isolate trusted output-path guards") + } + guards := offline[start : start+end] + dir := t.TempDir() + stickyParent := filepath.Join(dir, "sticky") + if err := os.Mkdir(stickyParent, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(stickyParent, 0o777|os.ModeSticky); err != nil { + t.Fatal(err) + } + + script := filepath.Join(dir, "output-path.sh") + body := `#!/bin/bash +set -Eeuo pipefail +LOCAL_COMMAND_TIMEOUT_SECONDS=120 +local_with_timeout() { timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" "$@"; } +` + guards + ` +require_safe_new_output_path "$TEST_STICKY_PARENT/out" "test output" +if require_safe_directory_path "$TEST_STICKY_PARENT" "test leaf"; then + echo "sticky leaf unexpectedly passed" >&2 + exit 90 +fi +if require_safe_new_output_path "$TEST_STICKY_PARENT//other" "test output"; then + echo "noncanonical output unexpectedly passed" >&2 + exit 91 +fi +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = append(os.Environ(), "TEST_STICKY_PARENT="+stickyParent) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("sticky-parent/noncanonical output guard failed: %v\n%s", err, out) + } +} + +func TestPublisherResumeAndRecoveryGuards(t *testing.T) { + publish := readReleaseFile(t, "../../scripts/publish-release.sh") + for _, required := range []string{ + "resume exactly matching published release (no asset mutation)", + "require_exact_signed_assets", + "require_remote_asset_digests", + "RESUMING_ALREADY_LATEST", + "LATEST_PROMOTION_ATTEMPTED=1", + "restore_latest_after_failed_promotion", + "highest_stable_release_excluding", + "require_latest_exact", + "HTTP/[0-9.]+ 404", + "CRITICAL: automatic Latest restoration failed", + } { + if !strings.Contains(publish, required) { + t.Fatalf("publisher resume/recovery path is missing %q", required) + } + } + resume := strings.Index(publish, "resume exactly matching published release") + upload := strings.Index(publish, `gh_with_timeout release upload "$TAG"`) + if resume < 0 || upload < 0 || resume < upload { + t.Fatal("published-release resume path is not separated from draft asset upload") + } +} + +func TestOnlineReleaseScriptsRejectTimedOutLatest404(t *testing.T) { + scripts := []string{ + "../../scripts/prepare-release.sh", + "../../scripts/publish-release.sh", + } + tests := []struct { + name string + status string + wantOK bool + }{ + {name: "ordinary REST 404", status: "1", wantOK: true}, + {name: "timeout after REST 404", status: "124", wantOK: false}, + {name: "forced kill after REST 404", status: "137", wantOK: false}, + } + + for _, releaseScript := range scripts { + releaseScript := releaseScript + t.Run(filepath.Base(releaseScript), func(t *testing.T) { + content := readReleaseFile(t, releaseScript) + start := strings.Index(content, "current_latest_tag() {") + if start < 0 { + t.Fatal("could not locate current_latest_tag") + } + end := strings.Index(content[start:], "\n}\n") + if end < 0 { + t.Fatal("could not isolate current_latest_tag") + } + latestFunction := content[start : start+end+2] + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "latest-test.sh") + body := `#!/bin/bash +set -Eeuo pipefail +REPO=mock/repo +work="$TEST_STATE/work" +mkdir -p "$work" +gh_with_timeout() { + if [[ "$1" == release && "$2" == view ]]; then + printf 'no latest release\n' >&2 + return 1 + fi + if [[ "$1" == api && "$2" == --include ]]; then + printf 'HTTP/2.0 404 Not Found\n' + return "$TEST_API_STATUS" + fi + return 99 +} +` + latestFunction + ` +current_latest_tag > "$TEST_STATE/result" +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = append(os.Environ(), "TEST_STATE="+dir, "TEST_API_STATUS="+tt.status) + out, err := cmd.CombinedOutput() + if tt.wantOK && err != nil { + t.Fatalf("ordinary REST 404 was rejected: %v\n%s", err, out) + } + if !tt.wantOK && err == nil { + t.Fatalf("REST 404 with exit status %s unexpectedly passed: %s", tt.status, out) + } + }) + } + }) + } +} + +func TestPublisherLatestRestorationWithMockGitHub(t *testing.T) { + publish := readReleaseFile(t, "../../scripts/publish-release.sh") + start := strings.Index(publish, "decimal_gt() {") + end := strings.Index(publish, "\nrelease_state() {") + if start < 0 || end <= start { + t.Fatal("could not isolate publisher Latest recovery functions") + } + recoveryFunctions := publish[start:end] + + tests := []struct { + name string + stableTags string + initial string + expected string + apiMode string + wantOK bool + }{ + { + name: "restore previous highest stable", + stableTags: "v2.7.3\nv2.8.0\n", + initial: "v2.8.0", + expected: "v2.7.3", + apiMode: "404", + wantOK: true, + }, + { + name: "restore concurrently published higher stable", + stableTags: "v2.7.3\nv2.8.0\nv2.9.0\n", + initial: "v2.8.0", + expected: "v2.9.0", + apiMode: "404", + wantOK: true, + }, + { + name: "clear Latest when no alternative exists", + stableTags: "v2.8.0\n", + initial: "v2.8.0", + expected: "", + apiMode: "404", + wantOK: true, + }, + { + name: "transport failure is not empty Latest", + stableTags: "v2.8.0\n", + initial: "v2.8.0", + expected: "", + apiMode: "transport", + wantOK: false, + }, + { + name: "mixed 404 and server error is not empty Latest", + stableTags: "v2.8.0\n", + initial: "v2.8.0", + expected: "", + apiMode: "mixed", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "recovery-test.sh") + body := `#!/bin/bash +set -Eeuo pipefail +TAG=v2.8.0 +REPO=mock/repo +LOCAL_COMMAND_TIMEOUT_SECONDS=120 +work="$TEST_STATE/work" +mkdir -p "$work" +printf '%s' "$TEST_STABLE_TAGS" > "$TEST_STATE/stable" +printf '%s' "$TEST_INITIAL_LATEST" > "$TEST_STATE/latest" +gh() { + if [[ "$1" == api && "$2" == --paginate ]]; then + cat "$TEST_STATE/stable" + return 0 + fi + if [[ "$1" == api && "$2" == --include ]]; then + if [[ "$TEST_API_MODE" == 404 ]]; then + printf 'HTTP/2.0 404 Not Found\n' + elif [[ "$TEST_API_MODE" == mixed ]]; then + printf 'HTTP/2.0 404 Not Found\nHTTP/2.0 500 Internal Server Error\n' + else + printf 'network unavailable\n' + fi + return 1 + fi + if [[ "$1" == release && "$2" == view ]]; then + if [[ -s "$TEST_STATE/latest" ]]; then + cat "$TEST_STATE/latest" + return 0 + fi + printf 'no latest release\n' >&2 + return 1 + fi + if [[ "$1" == release && "$2" == edit ]]; then + local tag=$3 arg + for arg in "$@"; do + case "$arg" in + --latest) printf '%s' "$tag" > "$TEST_STATE/latest"; return 0 ;; + --latest=false) : > "$TEST_STATE/latest"; return 0 ;; + esac + done + fi + printf 'unexpected gh invocation: %q ' "$@" >&2 + return 99 +} +gh_with_timeout() { gh "$@"; } +` + recoveryFunctions + ` +restore_latest_after_failed_promotion +actual="$(cat "$TEST_STATE/latest")" +[[ "$actual" == "$TEST_EXPECTED_LATEST" ]] +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = append(os.Environ(), + "TEST_STATE="+dir, + "TEST_STABLE_TAGS="+tt.stableTags, + "TEST_INITIAL_LATEST="+tt.initial, + "TEST_EXPECTED_LATEST="+tt.expected, + "TEST_API_MODE="+tt.apiMode, + ) + out, err := cmd.CombinedOutput() + if tt.wantOK && err != nil { + t.Fatalf("mock recovery failed: %v\n%s", err, out) + } + if !tt.wantOK && err == nil { + t.Fatalf("mock recovery unexpectedly succeeded: %s", out) + } + }) + } +} + +func TestPublisherPublishedAssetValidationWithMockGitHub(t *testing.T) { + publish := readReleaseFile(t, "../../scripts/publish-release.sh") + start := strings.Index(publish, "remote_asset_names() {") + end := strings.Index(publish, "\nif [[ \"$TAG\" == *-* ]]; then") + if start < 0 || end <= start { + t.Fatal("could not isolate publisher remote-asset validation functions") + } + assetFunctions := publish[start:end] + + assets := []string{ + "SHA256SUMS", + "linux-temp-admin-linux-amd64", + "linux-temp-admin-linux-amd64.sig", + "linux-temp-admin-linux-arm64", + "linux-temp-admin-linux-arm64.sig", + } + tests := []struct { + name string + extraAsset bool + badDigest bool + wantOK bool + }{ + {name: "exact published asset set", wantOK: true}, + {name: "extra published asset", extraAsset: true, wantOK: false}, + {name: "wrong published digest", badDigest: true, wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + bundle := filepath.Join(dir, "bundle") + if err := os.Mkdir(bundle, 0o700); err != nil { + t.Fatal(err) + } + var names, digests strings.Builder + for _, name := range assets { + body := []byte("mock-" + name + "\n") + if err := os.WriteFile(filepath.Join(bundle, name), body, 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(body) + fmt.Fprintf(&names, "%s\n", name) + fmt.Fprintf(&digests, "%s\tsha256:%x\t%d\n", name, sum, len(body)) + } + if tt.extraAsset { + names.WriteString("unexpected.txt\n") + } + digestOutput := digests.String() + if tt.badDigest { + digestOutput = strings.Replace(digestOutput, "sha256:", "sha256:00", 1) + } + + script := filepath.Join(dir, "asset-test.sh") + body := `#!/bin/bash +set -Eeuo pipefail +TAG=v2.8.0 +REPO=mock/repo +BUNDLE_DIR="$TEST_BUNDLE" +gh() { + [[ "$1" == release && "$2" == view ]] || return 99 + case "$*" in + *'.assets[].name'*) printf '%s' "$TEST_ASSET_NAMES" ;; + *'.digest // ""'*) printf '%s' "$TEST_ASSET_DIGESTS" ;; + *) return 98 ;; + esac +} +gh_with_timeout() { gh "$@"; } +` + assetFunctions + ` +require_exact_signed_assets +require_remote_asset_digests +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = append(os.Environ(), + "TEST_BUNDLE="+bundle, + "TEST_ASSET_NAMES="+names.String(), + "TEST_ASSET_DIGESTS="+digestOutput, + ) + out, err := cmd.CombinedOutput() + if tt.wantOK && err != nil { + t.Fatalf("exact published asset validation failed: %v\n%s", err, out) + } + if !tt.wantOK && err == nil { + t.Fatalf("mismatched published assets unexpectedly passed: %s", out) + } + }) + } +} + +func TestPublisherExitTrapRestoresLatestWithMockGitHub(t *testing.T) { + publish := readReleaseFile(t, "../../scripts/publish-release.sh") + cleanupStart := strings.Index(publish, "cleanup() {") + cleanupEnd := strings.Index(publish[cleanupStart:], "\ntrap cleanup EXIT") + recoveryStart := strings.Index(publish, "decimal_gt() {") + recoveryEnd := strings.Index(publish, "\nrelease_state() {") + if cleanupStart < 0 || cleanupEnd < 0 || recoveryStart < 0 || recoveryEnd <= recoveryStart { + t.Fatal("could not isolate publisher EXIT recovery logic") + } + cleanupFunction := publish[cleanupStart : cleanupStart+cleanupEnd] + recoveryFunctions := publish[recoveryStart:recoveryEnd] + + dir := t.TempDir() + script := filepath.Join(dir, "trap-test.sh") + body := `#!/bin/bash +set -Eeuo pipefail +TAG=v2.8.0 +REPO=mock/repo +LOCAL_COMMAND_TIMEOUT_SECONDS=120 +work="$TEST_STATE/work" +mkdir -p "$work" +printf 'v2.7.3\nv2.8.0\n' > "$TEST_STATE/stable" +printf 'v2.8.0' > "$TEST_STATE/latest" +gh() { + if [[ "$1" == api && "$2" == --paginate ]]; then cat "$TEST_STATE/stable"; return 0; fi + if [[ "$1" == release && "$2" == view ]]; then cat "$TEST_STATE/latest"; return 0; fi + if [[ "$1" == release && "$2" == edit && "$4" == --repo && "$6" == --latest ]]; then + printf '%s' "$3" > "$TEST_STATE/latest" + return 0 + fi + return 99 +} +gh_with_timeout() { gh "$@"; } +` + cleanupFunction + "\n" + recoveryFunctions + ` +LATEST_PROMOTION_ATTEMPTED=1 +PUBLISH_COMPLETE=0 +trap cleanup EXIT +exit 42 +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = append(os.Environ(), "TEST_STATE="+dir) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("failure injection unexpectedly succeeded: %s", out) + } + exitErr, ok := err.(*exec.ExitError) + if !ok || exitErr.ExitCode() != 42 { + t.Fatalf("EXIT trap did not preserve failure status 42: %v\n%s", err, out) + } + latest, readErr := os.ReadFile(filepath.Join(dir, "latest")) + if readErr != nil { + t.Fatal(readErr) + } + if string(latest) != "v2.7.3" { + t.Fatalf("EXIT trap restored Latest to %q, want v2.7.3\n%s", latest, out) + } +} + +func TestPublisherReadOnlyResumeFailureDoesNotDemoteLatest(t *testing.T) { + publish := readReleaseFile(t, "../../scripts/publish-release.sh") + cleanupStart := strings.Index(publish, "cleanup() {") + cleanupEnd := strings.Index(publish[cleanupStart:], "\ntrap cleanup EXIT") + if cleanupStart < 0 || cleanupEnd < 0 { + t.Fatal("could not isolate publisher cleanup logic") + } + cleanupFunction := publish[cleanupStart : cleanupStart+cleanupEnd] + + dir := t.TempDir() + script := filepath.Join(dir, "read-only-resume.sh") + body := `#!/bin/bash +set -Eeuo pipefail +TAG=v2.8.0 +LOCAL_COMMAND_TIMEOUT_SECONDS=120 +work="$TEST_STATE/work" +mkdir -p "$work" +restore_latest_after_failed_promotion() { : > "$TEST_STATE/demoted"; } +` + cleanupFunction + ` +LATEST_PROMOTION_ATTEMPTED=0 +PUBLISH_COMPLETE=0 +trap cleanup EXIT +exit 42 +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = append(os.Environ(), "TEST_STATE="+dir) + out, err := cmd.CombinedOutput() + exitErr, ok := err.(*exec.ExitError) + if !ok || exitErr.ExitCode() != 42 { + t.Fatalf("read-only resume did not preserve status 42: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(dir, "demoted")); !os.IsNotExist(err) { + t.Fatalf("read-only resume attempted to demote pre-existing Latest: %v", err) + } +} + +func TestPublisherCleanupFailurePreservesOriginalExitStatus(t *testing.T) { + publish := readReleaseFile(t, "../../scripts/publish-release.sh") + cleanupStart := strings.Index(publish, "cleanup() {") + cleanupEnd := strings.Index(publish[cleanupStart:], "\ntrap cleanup EXIT") + if cleanupStart < 0 || cleanupEnd < 0 { + t.Fatal("could not isolate publisher cleanup logic") + } + cleanupFunction := publish[cleanupStart : cleanupStart+cleanupEnd] + + dir := t.TempDir() + script := filepath.Join(dir, "cleanup-failure.sh") + body := `#!/bin/bash +set -Eeuo pipefail +TAG=v2.8.0 +LOCAL_COMMAND_TIMEOUT_SECONDS=120 +work="$TEST_STATE/work" +mkdir -p "$work" +timeout() { return 1; } +` + cleanupFunction + ` +LATEST_PROMOTION_ATTEMPTED=0 +PUBLISH_COMPLETE=0 +trap cleanup EXIT +exit 42 +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = append(os.Environ(), "TEST_STATE="+dir) + out, err := cmd.CombinedOutput() + exitErr, ok := err.(*exec.ExitError) + if !ok || exitErr.ExitCode() != 42 { + t.Fatalf("cleanup failure changed status 42: %v\n%s", err, out) + } +} + +func TestVulnerabilityScannerIsPinnedAndRunsInReleaseGate(t *testing.T) { + goWorkflow := readReleaseFile(t, "../../.github/workflows/go.yml") + releaseWorkflow := readReleaseFile(t, "../../.github/workflows/release.yml") + for name, content := range map[string]string{"Go": goWorkflow, "Release": releaseWorkflow} { + if !strings.Contains(content, "golang.org/x/vuln/cmd/govulncheck@v1.6.0") { + t.Fatalf("%s workflow does not pin govulncheck v1.6.0", name) + } + if strings.Contains(content, "govulncheck@latest") { + t.Fatalf("%s workflow still uses a floating govulncheck version", name) + } + } + if !strings.Contains(releaseWorkflow, "Release vulnerability gate") || !strings.Contains(releaseWorkflow, `"$GOBIN/govulncheck" ./...`) { + t.Fatal("tag Release workflow does not rerun the pinned vulnerability scan") + } +} + +func TestReleaseKeyringValidationIsPortableAcrossAwkImplementations(t *testing.T) { + const portableCheck = `length($0) != 64 || $0 !~ /^[0-9A-Fa-f]+$/` + const nonPortableCheck = `$0 !~ /^[0-9A-Fa-f]{64}$/` + for _, path := range []string{ + "../../scripts/prepare-release.sh", + "../../scripts/offline-sign-release.sh", + "../../scripts/publish-release.sh", + } { + content := readReleaseFile(t, path) + if !strings.Contains(content, portableCheck) { + t.Errorf("%s does not use the POSIX-awk key length check", path) + } + if strings.Contains(content, nonPortableCheck) { + t.Errorf("%s uses interval expressions that Debian mawk does not support", path) + } + } +} + +func TestOfflineSigningRejectsWrongKeyFromRotationKeyring(t *testing.T) { + dir := t.TempDir() + prepared := filepath.Join(dir, "prepared") + oldKey := strings.Repeat("11", 32) + newKey := strings.Repeat("22", 32) + manifestHash := writePreparedRelease(t, prepared, oldKey+"\n"+newKey+"\n") + + signer := filepath.Join(dir, "trusted-signer") + signerScript := "#!/bin/sh\ncase \"$1\" in\nversion) echo lta-release-offline-v1 ;;\npubkey) echo " + newKey + " ;;\n*) exit 99 ;;\nesac\n" + if err := os.WriteFile(signer, []byte(signerScript), 0o700); err != nil { + t.Fatal(err) + } + signerHash := sha256.Sum256([]byte(signerScript)) + privateKey := filepath.Join(dir, "test.key") + if err := os.WriteFile(privateKey, []byte("test-only\n"), 0o600); err != nil { + t.Fatal(err) + } + signed := filepath.Join(dir, "signed") + cmd := exec.Command("../../scripts/offline-sign-release.sh", prepared, signed) + cmd.Env = []string{ + "HOME=" + dir, + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "LTA_SIGN_KEY=" + privateKey, + "LTA_TRUSTED_SIGNER=" + signer, + fmt.Sprintf("LTA_TRUSTED_SIGNER_SHA256=%x", signerHash), + "LTA_EXPECTED_TAG=v2.8.0", + "LTA_EXPECTED_COMMIT=" + strings.Repeat("a", 40), + "LTA_EXPECTED_PREPARED_MANIFEST_SHA256=" + manifestHash, + "LTA_EXPECTED_RELEASE_SIGNER_PUBKEY=" + oldKey, + } + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("offline signer accepted NEW when OLD was independently selected: %s", out) + } + if !strings.Contains(string(out), "not the independently selected release-signing key") { + t.Fatalf("unexpected rejection: %s", out) + } + if _, err := os.Lstat(signed); !os.IsNotExist(err) { + t.Fatalf("failed signing ceremony left output behind: %v", err) + } +} + +func TestOfflineSigningProducesBundleBoundToSelectedKey(t *testing.T) { + dir := t.TempDir() + signer := filepath.Join(dir, "lta-release") + build := exec.Command("go", "build", "-buildvcs=false", "-o", signer, "../../cmd/lta-release") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build trusted signer: %v\n%s", err, out) + } + + privateKey := filepath.Join(dir, "release.key") + keygen := exec.Command(signer, "keygen", privateKey) + pubOut, err := keygen.Output() + if err != nil { + t.Fatal(err) + } + publicKey := strings.TrimSpace(string(pubOut)) + if len(publicKey) != 64 { + t.Fatalf("generated public key has length %d", len(publicKey)) + } + + prepared := filepath.Join(dir, "prepared") + manifestHash := writePreparedRelease(t, prepared, publicKey+"\n") + signerBytes, err := os.ReadFile(signer) + if err != nil { + t.Fatal(err) + } + signerHash := sha256.Sum256(signerBytes) + signed := filepath.Join(dir, "signed") + cmd := exec.Command("../../scripts/offline-sign-release.sh", prepared, signed) + cmd.Env = []string{ + "HOME=" + dir, + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "LTA_SIGN_KEY=" + privateKey, + "LTA_TRUSTED_SIGNER=" + signer, + fmt.Sprintf("LTA_TRUSTED_SIGNER_SHA256=%x", signerHash), + "LTA_EXPECTED_TAG=v2.8.0", + "LTA_EXPECTED_COMMIT=" + strings.Repeat("a", 40), + "LTA_EXPECTED_PREPARED_MANIFEST_SHA256=" + manifestHash, + "LTA_EXPECTED_RELEASE_SIGNER_PUBKEY=" + publicKey, + } + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("offline signing failed: %v\n%s", err, out) + } + gotKey, err := os.ReadFile(filepath.Join(signed, "RELEASE_SIGNER_PUBKEY")) + if err != nil { + t.Fatal(err) + } + if string(gotKey) != publicKey+"\n" { + t.Fatalf("bundle selected key=%q, want %q", gotKey, publicKey) + } + for _, arch := range []string{"amd64", "arm64"} { + asset := filepath.Join(signed, "linux-temp-admin-linux-"+arch) + verify := exec.Command(signer, "verify", filepath.Join(signed, "RELEASE_SIGNER_PUBKEY"), asset, asset+".sig") + if out, err := verify.CombinedOutput(); err != nil { + t.Fatalf("verify %s bundle signature: %v\n%s", arch, err, out) + } + } +} + +func TestOfflineSigningBoundsSnapshotBeforeManifestValidation(t *testing.T) { + dir := t.TempDir() + prepared := filepath.Join(dir, "prepared") + publicKey := strings.Repeat("11", 32) + manifestHash := writePreparedRelease(t, prepared, publicKey+"\n") + if err := os.WriteFile(filepath.Join(prepared, "TAG"), bytes.Repeat([]byte{'v'}, (1<<20)+1), 0o600); err != nil { + t.Fatal(err) + } + + signer := filepath.Join(dir, "trusted-signer") + signerScript := "#!/bin/sh\n[ \"$1\" = version ] && { echo lta-release-offline-v1; exit 0; }\nexit 99\n" + if err := os.WriteFile(signer, []byte(signerScript), 0o700); err != nil { + t.Fatal(err) + } + signerHash := sha256.Sum256([]byte(signerScript)) + privateKey := filepath.Join(dir, "test.key") + if err := os.WriteFile(privateKey, []byte("test-only\n"), 0o600); err != nil { + t.Fatal(err) + } + signed := filepath.Join(dir, "signed") + cmd := exec.Command("../../scripts/offline-sign-release.sh", prepared, signed) + cmd.Env = []string{ + "HOME=" + dir, + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "LTA_SIGN_KEY=" + privateKey, + "LTA_TRUSTED_SIGNER=" + signer, + fmt.Sprintf("LTA_TRUSTED_SIGNER_SHA256=%x", signerHash), + "LTA_EXPECTED_TAG=v2.8.0", + "LTA_EXPECTED_COMMIT=" + strings.Repeat("a", 40), + "LTA_EXPECTED_PREPARED_MANIFEST_SHA256=" + manifestHash, + "LTA_EXPECTED_RELEASE_SIGNER_PUBKEY=" + publicKey, + } + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("offline signer accepted oversized metadata: %s", out) + } + if !strings.Contains(string(out), "snapshot limit") { + t.Fatalf("unexpected oversized-input rejection: %s", out) + } + if _, err := os.Lstat(signed); !os.IsNotExist(err) { + t.Fatalf("oversized input left signed output behind: %v", err) + } +} + +func writePreparedRelease(t *testing.T, prepared, keyring string) string { + t.Helper() + if err := os.Mkdir(prepared, 0o700); err != nil { + t.Fatal(err) + } + files := map[string][]byte{ + "COMMIT": []byte(strings.Repeat("a", 40) + "\n"), + "TAG": []byte("v2.8.0\n"), + "VERSION": []byte("2.8.0\n"), + "release_pubkey.hex": []byte(keyring), + "linux-temp-admin-linux-amd64": []byte("amd64"), + "linux-temp-admin-linux-arm64": []byte("arm64"), + } + order := []string{"COMMIT", "TAG", "VERSION", "release_pubkey.hex", "linux-temp-admin-linux-amd64", "linux-temp-admin-linux-arm64"} + var manifest strings.Builder + for _, name := range order { + if err := os.WriteFile(filepath.Join(prepared, name), files[name], 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(files[name]) + fmt.Fprintf(&manifest, "%x %s\n", sum, name) + } + manifestBytes := []byte(manifest.String()) + if err := os.WriteFile(filepath.Join(prepared, "PREPARED_SHA256SUMS"), manifestBytes, 0o600); err != nil { + t.Fatal(err) + } + manifestHash := sha256.Sum256(manifestBytes) + return fmt.Sprintf("%x", manifestHash) +} + +func readReleaseFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/internal/selfmanage/release_pubkey.hex b/internal/selfmanage/release_pubkey.hex index 4453eb1..d25ca54 100644 --- a/internal/selfmanage/release_pubkey.hex +++ b/internal/selfmanage/release_pubkey.hex @@ -1,8 +1,13 @@ -# linux-temp-admin release signing public key (ed25519, hex-encoded, 64 hex chars). +# linux-temp-admin release signing keyring. +# +# Each non-comment line is one complete ed25519 public key (64 hex chars). During +# a planned rotation, commit an overlap release containing OLD then NEW and sign +# that transition with OLD. Keep both keys during the migration window; see +# docs/releasing.md before removing OLD. # # CONFIGURED: signed self-upgrade is ENABLED. `upgrade` verifies each downloaded -# release binary against this key before installing it, failing closed on any -# mismatch. The matching PRIVATE key is held OFFLINE by the maintainer (generated -# via `go run ./cmd/lta-release keygen`); only scripts/release.sh uses it. +# release binary against every key before installing it, failing closed on any +# mismatch. Private keys exist only on the air-gapped signing machine. Candidate +# source and online preparation/publication never receive a private-key path. # 982471fb0c9f82f76143c89d045f8a931180f8cca27dad5286bb2000614eaf1c diff --git a/internal/selfmanage/selfmanage.go b/internal/selfmanage/selfmanage.go index 1072088..f86e50b 100644 --- a/internal/selfmanage/selfmanage.go +++ b/internal/selfmanage/selfmanage.go @@ -5,16 +5,23 @@ package selfmanage import ( + "bytes" + "context" "crypto/ed25519" + "crypto/sha256" + "encoding/json" + "errors" "fmt" "io" "net" "net/http" - "net/netip" + neturl "net/url" "os" "os/exec" "path/filepath" "strings" + "sync" + "sync/atomic" "syscall" "time" @@ -26,9 +33,21 @@ import ( // Manager performs install/uninstall/upgrade. Fields are injectable for tests. type Manager struct { InstallPath string - PublicKey ed25519.PublicKey // release signing key; nil => signed upgrades disabled - Client *http.Client - MaxBytes int64 + // PublicKey is the legacy single-key injection point. PublicKeys is the + // rotation-capable keyring; Upgrade accepts a signature made by either. New + // populates both so existing callers that inspect PublicKey keep working. + PublicKey ed25519.PublicKey + PublicKeys []ed25519.PublicKey + Client *http.Client + MaxBytes int64 + RetryDelay time.Duration + ProbeTimeout time.Duration + ProbeMaxOutput int64 + // WriteRootFile is a filesystem fault-injection hook. Production leaves it nil + // and uses fsutil.WriteRootFile. + WriteRootFile func(string, []byte, os.FileMode) error + // Lstat is a target-inspection fault-injection hook. Production uses os.Lstat. + Lstat func(string) (os.FileInfo, error) // allowPrivateDial gates whether the dialer may connect to a private/reserved // IP. It is true only for the initial, operator-supplied URL of the current @@ -37,16 +56,69 @@ type Manager struct { // dialed — closing the DNS-rebinding gap where the redirect's name passed a // separate lookup but resolved to a private IP at connect time. Set per // download; a Manager runs its fetches sequentially. - allowPrivateDial bool + allowPrivateDial atomic.Bool + downloadMu sync.Mutex } +// ErrNotInstalled reports that InstallPath has no directory entry. Callers use +// it to distinguish a repairable missing command from an unsafe or unreadable +// installed command. +var ErrNotInstalled = errors.New("stable command is not installed") + +const ( + defaultRetryDelay = 500 * time.Millisecond + maxDownloadAttempts = 4 + defaultProbeTimeout = 10 * time.Second + defaultProbeMaxBytes = int64(256) + cacheBypassAttempt = 3 + maxReleaseMetadata = int64(1 << 20) + mirrorDownloadTries = 2 + mirrorManifestBudget = 40 * time.Second + mirrorReleaseBudget = 90 * time.Second + releaseSourceBudget = 5 * time.Minute +) + +type transportFailure struct{ err error } + +func (e *transportFailure) Error() string { return e.err.Error() } +func (e *transportFailure) Unwrap() error { return e.err } + +// IsTransportFailure reports whether err occurred before a complete response +// was accepted. Only this class may move an official download to its fallback; +// signature, checksum, version, URL-policy, and redirect-policy failures do not. +func IsTransportFailure(err error) bool { + var target *transportFailure + return errors.As(err, &target) +} + +func markTransportFailure(err error) error { + if err == nil || IsTransportFailure(err) { + return err + } + return &transportFailure{err: err} +} + +type downloadPolicy struct { + allowPrivateInitial bool + allowRedirects bool +} + +type downloadPolicyContextKey struct{} + // New returns a Manager with the embedded release public key and an HTTPS client // that refuses to follow a redirect to a non-https scheme. func New(installPath string, maxBytes int64) *Manager { + keys := embeddedPublicKeys() m := &Manager{ - InstallPath: installPath, - PublicKey: embeddedPublicKey(), - MaxBytes: maxBytes, + InstallPath: installPath, + PublicKeys: keys, + MaxBytes: maxBytes, + RetryDelay: defaultRetryDelay, + ProbeTimeout: defaultProbeTimeout, + ProbeMaxOutput: defaultProbeMaxBytes, + } + if len(keys) > 0 { + m.PublicKey = keys[0] } // The Control hook runs with the address ACTUALLY being dialed — the resolved // IP:port, after Go's own resolution — so it is the authoritative, rebinding- @@ -56,23 +128,35 @@ func New(installPath string, maxBytes int64) *Manager { // deliberate internal mirror still works; the first redirect clears it. dialer := &net.Dialer{ Control: func(_, address string, _ syscall.RawConn) error { - return checkDialAddr(address, m.allowPrivateDial) + return checkDialAddr(address, m.allowPrivateDial.Load()) }, } m.Client = &http.Client{ - Timeout: 60 * time.Second, // bound the whole fetch; a stalled server can't hang upgrade - Transport: &http.Transport{DialContext: dialer.DialContext, ForceAttemptHTTP2: true}, - CheckRedirect: func(req *http.Request, _ []*http.Request) error { + Timeout: 60 * time.Second, // bound the whole fetch; a stalled server can't hang upgrade + // Disable reuse so every redirect target reaches the dial-time IP policy; + // otherwise an already-idle private connection could bypass Control. + Transport: &http.Transport{DialContext: dialer.DialContext, ForceAttemptHTTP2: true, DisableKeepAlives: true}, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // net/http synthesizes Referer from the previous complete URL before it + // calls CheckRedirect. Custom mirror URLs may carry signed query values or + // fragments, so never forward that URL to a redirect-selected endpoint. + req.Header.Del("Referer") // A redirect target is chosen by the (possibly hostile) release server, so // it must stay https, and from here on a private address is refused: the // operator only vouched for the initial URL, not for wherever it bounces. - m.allowPrivateDial = false + m.allowPrivateDial.Store(false) + if policy, ok := req.Context().Value(downloadPolicyContextKey{}).(downloadPolicy); ok && !policy.allowRedirects { + return safeDiagnostic("official mirror endpoints must not redirect") + } + if len(via) >= 10 { + return safeDiagnostic("too many redirects") + } if req.URL.Scheme != "https" { - return fmt.Errorf("refusing redirect to non-https: %s", req.URL) + return safeDiagnostic("refusing redirect to a non-https endpoint") } // The name-based check stays as a friendly, early rejection; the Control // hook above is what actually holds under DNS rebinding. - return refusePrivateRedirect(req.URL.Hostname()) + return refusePrivateRedirect(req.Context(), req.URL.Hostname()) }, } return m @@ -83,14 +167,27 @@ func New(installPath string, maxBytes int64) *Manager { // returns (false, nil), mirroring Upgrade's ("", nil) for "nothing to do". If the // target differs and force is false, it refuses. func (m *Manager) Install(srcBytes []byte, force bool) (installed bool, err error) { - if fi, err := os.Lstat(m.InstallPath); err == nil { + if err := ensureInstallDir(filepath.Dir(m.InstallPath)); err != nil { + return false, err + } + fi, statErr := m.lstat(m.InstallPath) + if statErr == nil { if fi.Mode()&os.ModeSymlink != 0 { return false, fmt.Errorf("%s is a symlink; refusing", m.InstallPath) } if fi.Mode().IsRegular() { - cur, rerr := os.ReadFile(m.InstallPath) - if rerr == nil && string(cur) == string(srcBytes) { - return false, nil // already installed and byte-identical + same, rerr := sameInstalledBytes(m.InstallPath, srcBytes) + if rerr == nil && same { + if installedFileMetadataSafe(fi) { + return false, nil // byte-identical and already root:root 0755 + } + // Identical bytes do not make an attacker-writable or set-id binary + // safe. Rewrite atomically to normalize all metadata, even without + // --force: no content replacement has been requested. + if err := m.writeRootFile(srcBytes); err != nil { + return mutationResult(err) + } + return true, nil } if !force { // Fail closed: never replace an existing binary without --force, even @@ -100,14 +197,86 @@ func (m *Manager) Install(srcBytes []byte, force bool) (installed bool, err erro } return false, fmt.Errorf("%s already exists and differs; use --force to replace", m.InstallPath) } + } else if !force { + return false, fmt.Errorf("%s exists and is not a regular file; use --force to replace", m.InstallPath) } + } else if !errors.Is(statErr, os.ErrNotExist) { + return false, fmt.Errorf("inspect existing install target: %w", statErr) } - if err := fsutil.WriteRootFile(m.InstallPath, srcBytes, 0o755); err != nil { - return false, err + if err := m.writeRootFile(srcBytes); err != nil { + return mutationResult(err) } return true, nil } +func (m *Manager) lstat(path string) (os.FileInfo, error) { + if m.Lstat != nil { + return m.Lstat(path) + } + return os.Lstat(path) +} + +func ensureInstallDir(dir string) error { + if err := fsutil.RootSafeDir(dir); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("unsafe target directory: %w", err) + } + if err := fsutil.EnsureDir(dir, 0o755, 0, 0); err != nil { + return fmt.Errorf("create target directory: %w", err) + } + if err := fsutil.RootSafeDir(dir); err != nil { + return fmt.Errorf("unsafe target directory after creation: %w", err) + } + return nil +} + +func sameInstalledBytes(path string, expected []byte) (bool, error) { + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) + if err != nil { + return false, err + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + return false, err + } + if !fi.Mode().IsRegular() { + return false, fmt.Errorf("%s is not a regular file", path) + } + if fi.Size() != int64(len(expected)) { + return false, nil + } + actual, err := io.ReadAll(io.LimitReader(f, int64(len(expected))+1)) + if err != nil { + return false, err + } + return bytes.Equal(actual, expected), nil +} + +func (m *Manager) writeRootFile(content []byte) error { + if m.WriteRootFile != nil { + return m.WriteRootFile(m.InstallPath, content, 0o755) + } + return fsutil.WriteRootFile(m.InstallPath, content, 0o755) +} + +// mutationResult preserves the crucial distinction exposed by DurabilityError: +// the new inode is already visible even though its parent-directory fsync failed. +func mutationResult(err error) (bool, error) { + var durability *fsutil.DurabilityError + return errors.As(err, &durability), err +} + +func installedFileMetadataSafe(fi os.FileInfo) bool { + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok || st.Uid != 0 || st.Gid != 0 { + return false + } + special := os.ModeSetuid | os.ModeSetgid | os.ModeSticky + return fi.Mode().IsRegular() && fi.Mode().Perm() == 0o755 && fi.Mode()&special == 0 +} + // Uninstall removes the stable command. Unless force is set, the target must be a // safe root-owned regular file. func (m *Manager) Uninstall(force bool) error { @@ -119,73 +288,593 @@ func (m *Manager) Uninstall(force bool) error { return fmt.Errorf("refusing to remove an unsafe path: %w", err) } } - return os.Remove(m.InstallPath) + return fsutil.RemoveFile(m.InstallPath) +} + +// UpgradeCandidate is an authenticated, version-probed binary ready for a short +// locked commit. Its bytes are intentionally private so callers cannot alter the +// payload between verification and installation. +type UpgradeCandidate struct { + bin []byte + version string +} + +// ReleaseManifest is untrusted routing metadata from the official mirror. Its +// base URL is accepted only when it exactly matches the compiled-in mirror root +// plus Tag; release signatures remain the content trust root. +type ReleaseManifest struct { + Version string + Tag string + BaseURL string + PublishedAt string +} + +// Version reports the authenticated candidate's probed version. +func (c *UpgradeCandidate) Version() string { + if c == nil { + return "" + } + return c.version +} + +// FetchReleaseManifest downloads and strictly decodes one mirror manifest. +// Duplicate or unknown fields are rejected, as are noncanonical versions and a +// base URL that attempts to move downloads away from expectedRoot. +func (m *Manager) FetchReleaseManifest(manifestURL, expectedRoot string) (ReleaseManifest, error) { + ctx, cancel := context.WithTimeout(context.Background(), mirrorManifestBudget) + defer cancel() + b, err := m.downloadContextWithPolicy(ctx, manifestURL, maxReleaseMetadata, mirrorDownloadTries, downloadPolicy{}) + if err != nil { + return ReleaseManifest{}, fmt.Errorf("download release manifest: %w", err) + } + manifest, err := decodeReleaseManifest(b) + if err != nil { + return ReleaseManifest{}, fmt.Errorf("invalid release manifest: %w", err) + } + root := strings.TrimSuffix(expectedRoot, "/") + if !validate.UpgradeURL(root) { + return ReleaseManifest{}, fmt.Errorf("invalid compiled-in mirror root") + } + if !validate.ReleaseVersion(manifest.Version) || manifest.Tag != "v"+manifest.Version { + return ReleaseManifest{}, fmt.Errorf("version and tag are inconsistent") + } + if manifest.BaseURL != root+"/"+manifest.Tag { + return ReleaseManifest{}, fmt.Errorf("base URL does not match the official mirror") + } + if !canonicalPublishedAt(manifest.PublishedAt) { + return ReleaseManifest{}, fmt.Errorf("published_at is not canonical UTC RFC3339") + } + canonical, err := json.Marshal(struct { + Version string `json:"version"` + Tag string `json:"tag"` + BaseURL string `json:"base_url"` + PublishedAt string `json:"published_at"` + }{manifest.Version, manifest.Tag, manifest.BaseURL, manifest.PublishedAt}) + if err != nil { + return ReleaseManifest{}, fmt.Errorf("encode canonical release manifest: %w", err) + } + canonical = append(canonical, '\n') + if !bytes.Equal(b, canonical) { + return ReleaseManifest{}, fmt.Errorf("release manifest is not canonical single-line JSON") + } + return manifest, nil +} + +func canonicalPublishedAt(value string) bool { + if len(value) < 20 || len(value) > 30 || value[4] != '-' || value[7] != '-' || + value[10] != 'T' || value[13] != ':' || value[16] != ':' || value[len(value)-1] != 'Z' { + return false + } + for _, index := range []int{0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18} { + if value[index] < '0' || value[index] > '9' { + return false + } + } + if value[:4] == "0000" { + return false + } + if len(value) == 20 { + if value[19] != 'Z' { + return false + } + } else { + if value[19] != '.' || len(value) < 22 { + return false + } + for i := 20; i < len(value)-1; i++ { + if value[i] < '0' || value[i] > '9' { + return false + } + } + } + _, err := time.Parse(time.RFC3339Nano, value) + return err == nil } -// Upgrade downloads the binary and its detached signature, verifies the signature -// with the embedded public key, confirms the downloaded version is newer than -// currentVersion (unless force), and atomically installs it. It returns the new -// version, or ("", nil) if already up to date. -func (m *Manager) Upgrade(binaryURL, sigURL, currentVersion string, force bool) (string, error) { - if len(m.PublicKey) != ed25519.PublicKeySize { - return "", fmt.Errorf("no release signing key configured; signed upgrade is disabled") +func decodeReleaseManifest(b []byte) (ReleaseManifest, error) { + dec := json.NewDecoder(bytes.NewReader(b)) + open, err := dec.Token() + if err != nil || open != json.Delim('{') { + return ReleaseManifest{}, errors.New("expected one JSON object") + } + var manifest ReleaseManifest + seen := make(map[string]bool, 4) + for dec.More() { + token, err := dec.Token() + if err != nil { + return ReleaseManifest{}, errors.New("invalid object key") + } + key, ok := token.(string) + if !ok || seen[key] { + return ReleaseManifest{}, errors.New("duplicate or invalid object key") + } + seen[key] = true + var value string + if err := dec.Decode(&value); err != nil { + return ReleaseManifest{}, errors.New("manifest values must be strings") + } + switch key { + case "version": + manifest.Version = value + case "tag": + manifest.Tag = value + case "base_url": + manifest.BaseURL = value + case "published_at": + manifest.PublishedAt = value + default: + return ReleaseManifest{}, errors.New("unknown object key") + } + } + closeToken, err := dec.Token() + if err != nil || closeToken != json.Delim('}') { + return ReleaseManifest{}, errors.New("unterminated JSON object") + } + if token, err := dec.Token(); !errors.Is(err, io.EOF) || token != nil { + return ReleaseManifest{}, errors.New("trailing JSON data") + } + if len(seen) != 4 || manifest.Version == "" || manifest.Tag == "" || + manifest.BaseURL == "" || manifest.PublishedAt == "" { + return ReleaseManifest{}, errors.New("missing required field") + } + return manifest, nil +} + +// PrepareUpgrade performs every slow, read-only upgrade step: download, detached +// signature verification, and the bounded candidate version probe. Callers can +// do this before taking their lifecycle mutation lock. +func (m *Manager) PrepareUpgrade(binaryURL, sigURL string) (*UpgradeCandidate, error) { + keys := m.verificationKeys() + if len(keys) == 0 { + return nil, fmt.Errorf("no release signing key configured; signed upgrade is disabled") } bin, err := m.download(binaryURL, m.MaxBytes) if err != nil { - return "", fmt.Errorf("download binary: %w", err) + return nil, fmt.Errorf("download binary: %w", err) } sig, err := m.download(sigURL, ed25519.SignatureSize*4) if err != nil { - return "", fmt.Errorf("download signature: %w", err) + return nil, fmt.Errorf("download signature: %w", err) + } + return m.prepareVerifiedCandidate(bin, sig, "") +} + +// PrepareReleaseUpgrade downloads the complete public set needed for one +// architecture from a single immutable base URL. Transport failures remain +// identifiable to the caller; once all bytes arrive, any checksum, signature, +// or version failure is fail-closed and must not select another source. +func (m *Manager) PrepareReleaseUpgrade(baseURL, asset, expectedVersion string) (*UpgradeCandidate, error) { + ctx, cancel := context.WithTimeout(context.Background(), releaseSourceBudget) + defer cancel() + return m.prepareReleaseUpgrade(ctx, baseURL, asset, expectedVersion, maxDownloadAttempts, downloadPolicy{allowRedirects: true}) +} + +// PrepareMirrorReleaseUpgrade gives the preferred mirror a short total budget +// before the caller selects GitHub. The budget spans all three files, so a black +// hole cannot consume one full retry window per asset. +func (m *Manager) PrepareMirrorReleaseUpgrade(baseURL, asset, expectedVersion string) (*UpgradeCandidate, error) { + ctx, cancel := context.WithTimeout(context.Background(), mirrorReleaseBudget) + defer cancel() + return m.prepareReleaseUpgrade(ctx, baseURL, asset, expectedVersion, mirrorDownloadTries, downloadPolicy{}) +} + +func (m *Manager) prepareReleaseUpgrade(ctx context.Context, baseURL, asset, expectedVersion string, attempts int, policy downloadPolicy) (*UpgradeCandidate, error) { + if asset != "linux-temp-admin-linux-amd64" && asset != "linux-temp-admin-linux-arm64" { + return nil, fmt.Errorf("unsupported release asset") + } + if expectedVersion != "" && !validate.ReleaseVersion(expectedVersion) { + return nil, fmt.Errorf("invalid expected release version") + } + sumsURL, err := releaseFileURL(baseURL, "SHA256SUMS") + if err != nil { + return nil, err + } + binaryURL, err := releaseFileURL(baseURL, asset) + if err != nil { + return nil, err + } + sigURL, err := releaseFileURL(baseURL, asset+".sig") + if err != nil { + return nil, err + } + sums, err := m.downloadContextWithPolicy(ctx, sumsURL, maxReleaseMetadata, attempts, policy) + if err != nil { + return nil, fmt.Errorf("download SHA256SUMS: %w", err) + } + bin, err := m.downloadContextWithPolicy(ctx, binaryURL, m.MaxBytes, attempts, policy) + if err != nil { + return nil, fmt.Errorf("download binary: %w", err) + } + sig, err := m.downloadContextWithPolicy(ctx, sigURL, ed25519.SignatureSize*4, attempts, policy) + if err != nil { + return nil, fmt.Errorf("download signature: %w", err) + } + if err := verifyReleaseChecksums(sums, map[string][]byte{asset: bin, asset + ".sig": sig}); err != nil { + return nil, fmt.Errorf("checksum verification failed: %w", err) + } + if len(sig) != ed25519.SignatureSize { + return nil, fmt.Errorf("official release signature must be exactly %d raw bytes", ed25519.SignatureSize) + } + return m.prepareVerifiedCandidate(bin, sig, expectedVersion) +} + +func releaseFileURL(baseURL, name string) (string, error) { + u, err := neturl.Parse(baseURL) + if err != nil || !validate.UpgradeURL(baseURL) || u.Scheme != "https" || u.Host == "" || + u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawPath != "" { + return "", fmt.Errorf("invalid release base URL") + } + u.Path = strings.TrimSuffix(u.Path, "/") + "/" + name + return u.String(), nil +} + +func verifyReleaseChecksums(sums []byte, files map[string][]byte) error { + if len(sums) == 0 || sums[len(sums)-1] != '\n' || bytes.IndexByte(sums, 0) >= 0 { + return errors.New("SHA256SUMS is not a canonical newline-terminated manifest") + } + wanted := make(map[string]string, len(files)) + for _, line := range strings.Split(strings.TrimSuffix(string(sums), "\n"), "\n") { + parts := strings.Split(line, " ") + if len(parts) != 2 || len(parts[0]) != 64 || parts[1] == "" { + return errors.New("SHA256SUMS contains an invalid record") + } + if parts[0] != strings.ToLower(parts[0]) { + return errors.New("SHA256SUMS digest is not canonical lowercase hexadecimal") + } + if _, err := decodeHex(parts[0]); err != nil { + return errors.New("SHA256SUMS contains an invalid digest") + } + if _, needed := files[parts[1]]; !needed { + continue + } + if _, duplicate := wanted[parts[1]]; duplicate { + return errors.New("SHA256SUMS contains a duplicate selected asset") + } + wanted[parts[1]] = parts[0] + } + for name, data := range files { + want, ok := wanted[name] + if !ok { + return errors.New("SHA256SUMS is missing a selected asset") + } + got := fmt.Sprintf("%x", sha256.Sum256(data)) + if got != want { + return errors.New("selected asset digest mismatch") + } + } + return nil +} + +func (m *Manager) prepareVerifiedCandidate(bin, sig []byte, expectedVersion string) (*UpgradeCandidate, error) { + keys := m.verificationKeys() + if len(keys) == 0 { + return nil, fmt.Errorf("no release signing key configured; signed upgrade is disabled") } sig = normalizeSig(sig) - if !ed25519.Verify(m.PublicKey, bin, sig) { - return "", fmt.Errorf("signature verification failed; refusing to install") + verified := false + for _, key := range keys { + if ed25519.Verify(key, bin, sig) { + verified = true + break + } + } + if !verified { + return nil, fmt.Errorf("signature verification failed; refusing to install") } // The bytes are authenticated; safe to execute for its version. newVer, err := m.probeVersion(bin) if err != nil { - return "", fmt.Errorf("read downloaded version: %w", err) + return nil, fmt.Errorf("read downloaded version: %w", err) + } + if expectedVersion != "" && newVer != expectedVersion { + return nil, fmt.Errorf("signed candidate version %q does not match selected release %q", newVer, expectedVersion) + } + return &UpgradeCandidate{bin: append([]byte(nil), bin...), version: newVer}, nil +} + +// ApplyUpgrade re-reads the installed command at commit time, applies the +// downgrade policy to that current state, and atomically installs candidate. +// It returns ("", nil) if the installed command is already the same version or +// newer. If replacement is visible but not known durable, the version is returned +// alongside the durability error so the CLI can report the partial outcome. +func (m *Manager) ApplyUpgrade(candidate *UpgradeCandidate, force bool) (string, error) { + if candidate == nil || len(candidate.bin) == 0 || !validate.InstalledVersion(candidate.version) { + return "", fmt.Errorf("invalid prepared upgrade candidate") + } + installedVersion := "" + if current, err := m.InstalledVersion(); err == nil { + installedVersion = current + } else if !errors.Is(err, ErrNotInstalled) && !force { + return "", fmt.Errorf("read installed version: %w", err) } - if !force && !version.Greater(newVer, currentVersion) { + if !force && installedVersion != "" && !version.Greater(candidate.version, installedVersion) { return "", nil // already up to date or newer } - if _, err := m.Install(bin, true); err != nil { + installed, err := m.Install(candidate.bin, true) + if err != nil { + if installed { + return candidate.version, fmt.Errorf("installed command was replaced but durability is unknown: %w", err) + } return "", err } - return newVer, nil + if !installed { + return "", nil + } + return candidate.version, nil +} + +// Upgrade is the one-shot API retained for callers that already provide their +// own serialization. CLI code uses PrepareUpgrade and ApplyUpgrade separately so +// network retries never hold the global lifecycle lock. +func (m *Manager) Upgrade(binaryURL, sigURL string, force bool) (string, error) { + candidate, err := m.PrepareUpgrade(binaryURL, sigURL) + if err != nil { + return "", err + } + return m.ApplyUpgrade(candidate, force) +} + +func (m *Manager) verificationKeys() []ed25519.PublicKey { + keys := make([]ed25519.PublicKey, 0, len(m.PublicKeys)+1) + seen := make(map[string]struct{}) + add := func(key ed25519.PublicKey) { + if len(key) != ed25519.PublicKeySize { + return + } + id := string(key) + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + keys = append(keys, key) + } + for _, key := range m.PublicKeys { + add(key) + } + add(m.PublicKey) + return keys } func (m *Manager) download(url string, max int64) ([]byte, error) { + return m.downloadContextWithPolicy(context.Background(), url, max, maxDownloadAttempts, downloadPolicy{ + allowPrivateInitial: true, + allowRedirects: true, + }) +} + +func (m *Manager) downloadContextWithPolicy(ctx context.Context, url string, max int64, attempts int, policy downloadPolicy) ([]byte, error) { + m.downloadMu.Lock() + defer m.downloadMu.Unlock() + + if attempts < 1 { + return nil, fmt.Errorf("download attempts must be positive") + } + var lastErr error + for attempt := 1; attempt <= attempts; attempt++ { + if err := ctx.Err(); err != nil { + return nil, markTransportFailure(fmt.Errorf("download source deadline exceeded")) + } + attemptURL := url + if attempt >= cacheBypassAttempt { + var err error + attemptURL, err = withDownloadCacheBypass(url) + if err != nil { + return nil, err + } + } + body, retry, err := m.downloadOnce(ctx, attemptURL, max, policy) + if err == nil { + return body, nil + } + lastErr = err + if !retry || attempt == attempts { + break + } + if m.RetryDelay > 0 { + timer := time.NewTimer(time.Duration(attempt) * m.RetryDelay) + select { + case <-timer.C: + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return nil, markTransportFailure(fmt.Errorf("download source deadline exceeded")) + } + } + } + return nil, lastErr +} + +func withDownloadCacheBypass(rawURL string) (string, error) { + u, err := neturl.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("cannot prepare cache-bypass URL: %s", RedactedURL(rawURL)) + } + // download=1 is an observed GitHub Releases edge-cache recovery. Applying it + // to arbitrary mirrors can invalidate signed queries such as AWS SigV4, so + // custom URLs are retried byte-for-byte unchanged. + if u.Scheme != "https" || u.Host != "github.com" || u.User != nil || u.RawPath != "" || + u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || + (!strings.HasPrefix(u.Path, "/xxvcc/linux-temp-admin/releases/download/") && + !strings.HasPrefix(u.Path, "/xxvcc/linux-temp-admin/releases/latest/download/")) { + return rawURL, nil + } + query := u.Query() + query.Set("download", "1") + u.RawQuery = query.Encode() + return u.String(), nil +} + +func (m *Manager) downloadOnce(ctx context.Context, url string, max int64, policy downloadPolicy) ([]byte, bool, error) { if !validate.UpgradeURL(url) { - return nil, fmt.Errorf("unsafe or invalid URL: %s", url) + return nil, false, fmt.Errorf("unsafe or invalid URL: %s", RedactedURL(url)) } - req, err := http.NewRequest(http.MethodGet, url, nil) + ctx = context.WithValue(ctx, downloadPolicyContextKey{}, policy) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, err + return nil, false, fmt.Errorf("cannot construct request for %s", RedactedURL(url)) } - // The operator vouched for this initial URL, so a private/reserved address is - // allowed for it (a deliberate internal mirror); the first redirect clears this. - m.allowPrivateDial = true + // Only an explicit operator-selected URL may opt its initial address into the + // private/reserved exception. Compiled-in mirror and GitHub requests do not; + // every redirect clears the exception regardless of source. + m.allowPrivateDial.Store(policy.allowPrivateInitial) resp, err := m.Client.Do(req) + // Do has completed every dial (including redirects). Do not leave this + // exception enabled while a response body is processed or between retries. + m.allowPrivateDial.Store(false) if err != nil { - return nil, err + safeErr := safeRequestError(url, err) + var policy *safeDiagnosticError + if errors.As(err, &policy) { + return nil, false, safeErr + } + return nil, true, markTransportFailure(safeErr) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("status %d", resp.StatusCode) + err := fmt.Errorf("request to %s returned status %d", redactedURLOrigin(url), resp.StatusCode) + return nil, retryableHTTPStatus(resp.StatusCode), markTransportFailure(err) } b, err := io.ReadAll(io.LimitReader(resp.Body, max+1)) if err != nil { - return nil, err + return nil, true, markTransportFailure(safeResponseReadError(url, err)) } if int64(len(b)) > max { - return nil, fmt.Errorf("response exceeds %d bytes", max) + return nil, false, markTransportFailure(fmt.Errorf("response exceeds %d bytes", max)) } if len(b) == 0 { - return nil, fmt.Errorf("empty response") + return nil, true, markTransportFailure(fmt.Errorf("empty response")) + } + return b, false, nil +} + +// RedactedURL renders enough of an upgrade URL to identify its endpoint while +// never disclosing HTTP userinfo or resource-specific path, query, or fragment +// data. It is also safe for malformed input: when no clean https origin can be +// recovered, no part of the supplied value is returned. +func RedactedURL(rawURL string) string { + origin := redactedURLOrigin(rawURL) + if origin == "[redacted URL]" { + return origin + } + return origin + "/[details hidden]" +} + +func redactedURLOrigin(rawURL string) string { + u, err := neturl.Parse(rawURL) + if err != nil || u.Scheme != "https" || u.Host == "" || !safeDiagnosticHost(u.Host) { + return "[redacted URL]" } - return b, nil + // u.Host deliberately excludes u.User. Constructing this string directly also + // avoids URL.String, which would restore every sensitive URL component. + return "https://" + u.Host +} + +func safeDiagnosticHost(host string) bool { + for _, r := range host { + if r < 0x21 || r > 0x7e || strings.ContainsRune("/\\@?#<>'\"`|", r) { + return false + } + } + return true +} + +// safeRequestError intentionally does not wrap or quote err. net/http's +// *url.Error and arbitrary RoundTrippers commonly embed the complete request +// URL in their text, including credentials and signed query parameters. +func safeRequestError(rawURL string, err error) error { + endpoint := redactedURLOrigin(rawURL) + var diagnostic *safeDiagnosticError + var transportDiagnostic *safeTransportDiagnosticError + switch { + case errors.As(err, &diagnostic): + return fmt.Errorf("request to %s failed: %s", endpoint, diagnostic.Error()) + case errors.As(err, &transportDiagnostic): + return fmt.Errorf("request to %s failed: %s", endpoint, transportDiagnostic.Error()) + case errors.Is(err, context.DeadlineExceeded): + return fmt.Errorf("request to %s timed out", endpoint) + case errors.Is(err, context.Canceled): + return fmt.Errorf("request to %s was cancelled", endpoint) + default: + return fmt.Errorf("request to %s failed", endpoint) + } +} + +func safeResponseReadError(rawURL string, err error) error { + endpoint := redactedURLOrigin(rawURL) + var networkError net.Error + if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &networkError) && networkError.Timeout()) { + return fmt.Errorf("response from %s timed out", endpoint) + } + return fmt.Errorf("cannot read response from %s", endpoint) +} + +type safeDiagnosticError struct{ message string } + +func (e *safeDiagnosticError) Error() string { return e.message } + +func safeDiagnostic(message string) error { return &safeDiagnosticError{message: message} } + +// safeTransportDiagnosticError preserves a useful non-secret transport reason +// without putting it in the policy-error class that forbids source fallback. +type safeTransportDiagnosticError struct{ message string } + +func (e *safeTransportDiagnosticError) Error() string { return e.message } + +func safeTransportDiagnostic(message string) error { + return &safeTransportDiagnosticError{message: message} +} + +func retryableHTTPStatus(status int) bool { + if status >= 500 && status <= 599 { + return true + } + switch status { + case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests: + return true + default: + return false + } +} + +// InstalledVersion safely executes ` version` under the same +// timeout, output cap, process-group cancellation, and WaitDelay used for a +// downloaded candidate. +func (m *Manager) InstalledVersion() (string, error) { + if _, err := os.Lstat(m.InstallPath); err != nil { + if os.IsNotExist(err) { + return "", ErrNotInstalled + } + return "", err + } + if err := fsutil.RootSafeFile(m.InstallPath); err != nil { + return "", fmt.Errorf("installed command is unsafe: %w", err) + } + v, err := m.runVersionProbe(m.InstallPath) + if err != nil { + return "", fmt.Errorf("probe installed command: %w", err) + } + return v, nil } // probeVersion writes the (already verified) bytes to a temp file beside the @@ -204,28 +893,91 @@ func (m *Manager) probeVersion(bin []byte) (string, error) { } tmp := f.Name() defer os.Remove(tmp) - if _, err := f.Write(bin); err != nil { - f.Close() + if _, err := io.Copy(f, bytes.NewReader(bin)); err != nil { + _ = f.Close() return "", err } if err := f.Chmod(0o700); err != nil { - f.Close() + _ = f.Close() return "", err } if err := f.Close(); err != nil { return "", err } - out, err := exec.Command(tmp, "version").Output() + v, err := m.runVersionProbe(tmp) + if err != nil { + return "", err + } + return v, nil +} + +func (m *Manager) runVersionProbe(path string) (string, error) { + timeout := m.ProbeTimeout + if timeout <= 0 { + timeout = defaultProbeTimeout + } + maxOutput := m.ProbeMaxOutput + if maxOutput <= 0 { + maxOutput = defaultProbeMaxBytes + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, path, "version") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return nil + } + return err + } + cmd.WaitDelay = time.Second + out := &boundedBuffer{max: maxOutput} + cmd.Stdout = out + err := cmd.Run() + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("version probe timed out after %s", timeout) + } + if errors.Is(err, errProbeOutputLimit) || errors.Is(out.err, errProbeOutputLimit) { + return "", fmt.Errorf("version probe output exceeds %d bytes", maxOutput) + } if err != nil { return "", err } - v := strings.TrimSpace(string(out)) + v := strings.TrimSpace(out.String()) if !validate.InstalledVersion(v) { - return "", fmt.Errorf("downloaded binary reported an invalid version: %q", v) + return "", fmt.Errorf("binary reported an invalid version: %q", v) } return v, nil } +var errProbeOutputLimit = errors.New("version probe output limit exceeded") + +type boundedBuffer struct { + buf bytes.Buffer + max int64 + err error +} + +func (b *boundedBuffer) Write(p []byte) (int, error) { + remaining := b.max - int64(b.buf.Len()) + if remaining <= 0 { + b.err = errProbeOutputLimit + return 0, b.err + } + if int64(len(p)) > remaining { + n, _ := b.buf.Write(p[:remaining]) + b.err = errProbeOutputLimit + return n, b.err + } + return b.buf.Write(p) +} + +func (b *boundedBuffer) String() string { return b.buf.String() } + // normalizeSig accepts a raw 64-byte signature or a hex-encoded one. It handles // a lone trailing newline without TrimSpace (which could strip a whitespace- // valued edge byte from a genuine raw signature). @@ -247,14 +999,24 @@ func normalizeSig(b []byte) []byte { // refusePrivateRedirect errors unless host resolves entirely to routable public // addresses. A redirect that points at a private/reserved endpoint is rejected so // a hostile release host cannot use the upgrade fetch as an SSRF pivot. -func refusePrivateRedirect(host string) error { - ips, err := net.LookupIP(host) +var ( + redirectLookupTimeout = 10 * time.Second + lookupRedirectIPs = net.DefaultResolver.LookupIP +) + +func refusePrivateRedirect(parent context.Context, host string) error { + ctx, cancel := context.WithTimeout(parent, redirectLookupTimeout) + defer cancel() + ips, err := lookupRedirectIPs(ctx, "ip", host) if err != nil { - return fmt.Errorf("cannot resolve redirect host %q: %w", host, err) + return safeTransportDiagnostic("cannot resolve redirect host") + } + if len(ips) == 0 { + return safeTransportDiagnostic("redirect host resolved to no addresses") } for _, ip := range ips { if !isPublicIP(ip) { - return fmt.Errorf("refusing redirect to non-public address (%s -> %s)", host, ip) + return safeDiagnostic("refusing redirect to a non-public address") } } return nil @@ -274,49 +1036,20 @@ func checkDialAddr(address string, allowPrivate bool) error { if err != nil { return err } - ip := net.ParseIP(host) - if ip == nil || isPublicIP(ip) || allowPrivate { - return nil - } - return fmt.Errorf("refusing to dial non-public address after redirect: %s", address) -} - -func isPublicIP(ip net.IP) bool { - addr, ok := netip.AddrFromSlice(ip) - if !ok { - return false + ipHost := host + if zone := strings.LastIndexByte(ipHost, '%'); zone > 0 && strings.Contains(ipHost[:zone], ":") { + ipHost = ipHost[:zone] } - addr = addr.Unmap() - if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() { - return false + ip := net.ParseIP(ipHost) + if ip == nil { + return safeDiagnostic("refusing to dial an unresolved address") } - for _, prefix := range nonPublicPrefixes { - if prefix.Contains(addr) { - return false - } + if isPublicIP(ip) || allowPrivate { + return nil } - return true + return safeDiagnostic("refusing to dial a non-public address after redirect") } -var nonPublicPrefixes = []netip.Prefix{ - netip.MustParsePrefix("0.0.0.0/8"), - netip.MustParsePrefix("100.64.0.0/10"), - netip.MustParsePrefix("192.0.0.0/24"), - netip.MustParsePrefix("192.0.2.0/24"), - netip.MustParsePrefix("192.31.196.0/24"), - netip.MustParsePrefix("192.52.193.0/24"), - netip.MustParsePrefix("192.88.99.0/24"), - netip.MustParsePrefix("192.175.48.0/24"), - netip.MustParsePrefix("198.18.0.0/15"), - netip.MustParsePrefix("198.51.100.0/24"), - netip.MustParsePrefix("203.0.113.0/24"), - netip.MustParsePrefix("240.0.0.0/4"), - netip.MustParsePrefix("64:ff9b::/96"), - netip.MustParsePrefix("64:ff9b:1::/48"), - netip.MustParsePrefix("100::/64"), - netip.MustParsePrefix("2001::/23"), - netip.MustParsePrefix("2001:db8::/32"), - netip.MustParsePrefix("2002::/16"), - netip.MustParsePrefix("3fff::/20"), - netip.MustParsePrefix("5f00::/16"), +func isPublicIP(ip net.IP) bool { + return validate.PublicIP(ip) } diff --git a/internal/selfmanage/selfmanage_root_test.go b/internal/selfmanage/selfmanage_root_test.go index 5ecbe00..9da61f1 100644 --- a/internal/selfmanage/selfmanage_root_test.go +++ b/internal/selfmanage/selfmanage_root_test.go @@ -5,12 +5,20 @@ package selfmanage import ( "crypto/ed25519" "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io/fs" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "syscall" "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/fsutil" ) func rootDir(t *testing.T) string { @@ -80,6 +88,130 @@ func TestInstallIdempotentAndForce(t *testing.T) { } } +func TestInstallFailsClosedOnTargetInspectionError(t *testing.T) { + dir := rootDir(t) + wrote := false + m := &Manager{ + InstallPath: filepath.Join(dir, "linux-temp-admin"), + Lstat: func(string) (os.FileInfo, error) { return nil, fs.ErrPermission }, + WriteRootFile: func(string, []byte, os.FileMode) error { + wrote = true + return nil + }, + } + if _, err := m.Install([]byte("candidate"), true); !errors.Is(err, fs.ErrPermission) { + t.Fatalf("Install error = %v, want target inspection failure", err) + } + if wrote { + t.Fatal("Install wrote after target inspection failed") + } +} + +func TestInstallRequiresForceForExistingSpecialFile(t *testing.T) { + dir := rootDir(t) + path := filepath.Join(dir, "linux-temp-admin") + if err := syscall.Mkfifo(path, 0o600); err != nil { + t.Fatal(err) + } + m := &Manager{InstallPath: path} + if _, err := m.Install([]byte("candidate"), false); err == nil || !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("Install error = %v, want special-file refusal", err) + } + fi, err := os.Lstat(path) + if err != nil { + t.Fatalf("Install removed existing FIFO without force: %v", err) + } + if fi.Mode()&os.ModeNamedPipe == 0 { + t.Fatalf("Install replaced existing FIFO without force: mode=%v", fi.Mode()) + } +} + +func TestInstallCreatesMissingRootSafeParent(t *testing.T) { + base := rootDir(t) + localDir := filepath.Join(base, "usr", "local") + if err := os.MkdirAll(localDir, 0o755); err != nil { + t.Fatal(err) + } + installDir := filepath.Join(localDir, "sbin") + m := &Manager{InstallPath: filepath.Join(installDir, "linux-temp-admin")} + + installed, err := m.Install([]byte("candidate"), false) + if err != nil || !installed { + t.Fatalf("Install with missing parent: installed=%v err=%v", installed, err) + } + if err := fsutil.RootSafeDir(installDir); err != nil { + t.Fatalf("created install directory is not root-safe: %v", err) + } + fi, err := os.Lstat(installDir) + if err != nil { + t.Fatal(err) + } + st := fi.Sys().(*syscall.Stat_t) + if fi.Mode().Perm() != 0o755 || st.Uid != 0 || st.Gid != 0 { + t.Fatalf("created install directory mode=%o owner=%d:%d, want 755 0:0", fi.Mode().Perm(), st.Uid, st.Gid) + } + if got, err := os.ReadFile(m.InstallPath); err != nil || string(got) != "candidate" { + t.Fatalf("installed command content=%q err=%v", got, err) + } +} + +func TestInstallRepairsUnsafeMetadataOnIdenticalFile(t *testing.T) { + dir := rootDir(t) + path := filepath.Join(dir, "linux-temp-admin") + m := &Manager{InstallPath: path} + if _, err := m.Install([]byte("same"), false); err != nil { + t.Fatal(err) + } + if err := os.Chown(path, 1, 1); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o4777); err != nil { + t.Fatal(err) + } + before := statT(t, path) + installed, err := m.Install([]byte("same"), false) + if err != nil { + t.Fatal(err) + } + if !installed { + t.Fatal("unsafe identical target must be atomically repaired") + } + after := statT(t, path) + if after.Uid != 0 || after.Gid != 0 { + t.Fatalf("owner=%d:%d, want 0:0", after.Uid, after.Gid) + } + fi, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o755 || fi.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) != 0 { + t.Fatalf("mode=%v, want regular 0755 without special bits", fi.Mode()) + } + if after.Ino == before.Ino { + t.Fatal("metadata repair should use an atomic replacement") + } +} + +func TestInstallReportsVisibleReplacementOnDurabilityFailure(t *testing.T) { + dir := rootDir(t) + m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin")} + m.WriteRootFile = func(path string, content []byte, mode os.FileMode) error { + if err := fsutil.WriteRootFile(path, content, mode); err != nil { + return err + } + return &fsutil.DurabilityError{Operation: "rename", Err: syscall.EIO} + } + + installed, err := m.Install([]byte("new command"), false) + var durability *fsutil.DurabilityError + if !installed || !errors.As(err, &durability) { + t.Fatalf("Install = installed=%v err=%v, want visible replacement plus DurabilityError", installed, err) + } + if b, readErr := os.ReadFile(m.InstallPath); readErr != nil || string(b) != "new command" { + t.Fatalf("visible replacement missing: content=%q err=%v", b, readErr) + } +} + func newBinary(version string) []byte { return []byte("#!/bin/sh\n[ \"$1\" = version ] && echo " + version + "\nexit 0\n") } @@ -100,6 +232,84 @@ func signedServer(t *testing.T, bin, sig []byte) *httptest.Server { return srv } +func releaseSetServer(t *testing.T, asset string, bin, sig []byte, missing string) *httptest.Server { + t.Helper() + sums := fmt.Sprintf("%x %s\n%x %s.sig\n", sha256.Sum256(bin), asset, sha256.Sum256(sig), asset) + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := filepath.Base(r.URL.Path) + if name == missing { + w.WriteHeader(http.StatusNotFound) + return + } + switch name { + case "SHA256SUMS": + _, _ = w.Write([]byte(sums)) + case asset: + _, _ = w.Write(bin) + case asset + ".sig": + _, _ = w.Write(sig) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func TestPrepareReleaseUpgradeVerifiesCompleteSet(t *testing.T) { + dir := rootDir(t) + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + asset := "linux-temp-admin-linux-amd64" + bin := newBinary("2.8.0") + sig := ed25519.Sign(priv, bin) + srv := releaseSetServer(t, asset, bin, sig, "") + m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20} + candidate, err := m.PrepareReleaseUpgrade(srv.URL+"/v2.8.0", asset, "2.8.0") + if err != nil || candidate.Version() != "2.8.0" { + t.Fatalf("PrepareReleaseUpgrade: version=%q err=%v", candidate.Version(), err) + } +} + +func TestPrepareReleaseUpgradeClassifiesFallbackBoundary(t *testing.T) { + asset := "linux-temp-admin-linux-amd64" + for _, tc := range []struct { + name string + candidate string + missing string + wrongSigner bool + signatureForm string + wantTransport bool + }{ + {name: "missing signature is transport", candidate: "2.8.0", missing: asset + ".sig", wantTransport: true}, + {name: "wrong signature is verification", candidate: "2.8.0", wrongSigner: true}, + {name: "signed version mismatch is verification", candidate: "2.8.1"}, + {name: "hex signature is not an official release signature", candidate: "2.8.0", signatureForm: "hex"}, + {name: "newline signature is not an official release signature", candidate: "2.8.0", signatureForm: "newline"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := rootDir(t) + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + bin := newBinary(tc.candidate) + if tc.wrongSigner { + _, priv, _ = ed25519.GenerateKey(rand.Reader) + } + sig := ed25519.Sign(priv, bin) + switch tc.signatureForm { + case "hex": + sig = []byte(fmt.Sprintf("%x", sig)) + case "newline": + sig = append(sig, '\n') + } + srv := releaseSetServer(t, asset, bin, sig, tc.missing) + m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20, RetryDelay: 0} + _, err := m.PrepareReleaseUpgrade(srv.URL+"/v2.8.0", asset, "2.8.0") + if err == nil || IsTransportFailure(err) != tc.wantTransport { + t.Fatalf("err=%v transport=%v, want transport=%v", err, IsTransportFailure(err), tc.wantTransport) + } + }) + } +} + func TestUpgradeVerifiesSignatureAndInstalls(t *testing.T) { dir := rootDir(t) pub, priv, _ := ed25519.GenerateKey(rand.Reader) @@ -108,7 +318,7 @@ func TestUpgradeVerifiesSignatureAndInstalls(t *testing.T) { srv := signedServer(t, bin, sig) m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20} - got, err := m.Upgrade(srv.URL+"/bin", srv.URL+"/sig", "2.0.0", false) + got, err := m.Upgrade(srv.URL+"/bin", srv.URL+"/sig", false) if err != nil { t.Fatalf("Upgrade: %v", err) } @@ -120,6 +330,34 @@ func TestUpgradeVerifiesSignatureAndInstalls(t *testing.T) { } } +func TestPreparedUpgradeRechecksInstalledVersionAtCommit(t *testing.T) { + dir := rootDir(t) + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + candidateBytes := newBinary("2.0.0") + srv := signedServer(t, candidateBytes, ed25519.Sign(priv, candidateBytes)) + m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20} + if wrote, err := m.Install(newBinary("1.0.0"), false); err != nil || !wrote { + t.Fatalf("seed old install: wrote=%v err=%v", wrote, err) + } + + candidate, err := m.PrepareUpgrade(srv.URL+"/bin", srv.URL+"/sig") + if err != nil || candidate.Version() != "2.0.0" { + t.Fatalf("PrepareUpgrade: version=%q err=%v", candidate.Version(), err) + } + if current, err := m.InstalledVersion(); err != nil || current != "1.0.0" { + t.Fatalf("preparation mutated the install: version=%q err=%v", current, err) + } + if wrote, err := m.Install(newBinary("3.0.0"), true); err != nil || !wrote { + t.Fatalf("concurrent newer install: wrote=%v err=%v", wrote, err) + } + if got, err := m.ApplyUpgrade(candidate, false); err != nil || got != "" { + t.Fatalf("ApplyUpgrade over newer install: version=%q err=%v", got, err) + } + if current, err := m.InstalledVersion(); err != nil || current != "3.0.0" { + t.Fatalf("prepared candidate downgraded newer install: version=%q err=%v", current, err) + } +} + func TestUpgradeRejectsBadSignature(t *testing.T) { dir := rootDir(t) pub, _, _ := ed25519.GenerateKey(rand.Reader) @@ -129,7 +367,7 @@ func TestUpgradeRejectsBadSignature(t *testing.T) { srv := signedServer(t, bin, badSig) m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20} - if _, err := m.Upgrade(srv.URL+"/bin", srv.URL+"/sig", "2.0.0", false); err == nil { + if _, err := m.Upgrade(srv.URL+"/bin", srv.URL+"/sig", false); err == nil { t.Fatal("Upgrade must reject a bad signature") } if _, err := os.Lstat(m.InstallPath); !os.IsNotExist(err) { @@ -145,15 +383,109 @@ func TestUpgradeSkipsWhenNotNewer(t *testing.T) { srv := signedServer(t, bin, sig) m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20} - got, err := m.Upgrade(srv.URL+"/bin", srv.URL+"/sig", "2.0.0", false) + if installed, err := m.Install(bin, false); err != nil || !installed { + t.Fatalf("seed installed version: installed=%v err=%v", installed, err) + } + got, err := m.Upgrade(srv.URL+"/bin", srv.URL+"/sig", false) if err != nil { t.Fatal(err) } if got != "" { t.Errorf("expected no upgrade (same version), got %q", got) } - if _, err := os.Lstat(m.InstallPath); !os.IsNotExist(err) { - t.Error("nothing should be installed when not newer") + if b, err := os.ReadFile(m.InstallPath); err != nil || string(b) != string(bin) { + t.Errorf("same installed version changed: content=%q err=%v", b, err) + } +} + +func TestUpgradeUsesInstalledCommandAsVersionBaseline(t *testing.T) { + for _, tc := range []struct { + name string + installed string + candidate string + want string + }{ + {name: "installed older", installed: "2.0.0", candidate: "2.0.1", want: "2.0.1"}, + {name: "installed newer", installed: "3.0.0", candidate: "2.0.1", want: "3.0.0"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := rootDir(t) + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + candidate := newBinary(tc.candidate) + srv := signedServer(t, candidate, ed25519.Sign(priv, candidate)) + m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20} + if wrote, err := m.Install(newBinary(tc.installed), false); err != nil || !wrote { + t.Fatalf("seed installed command: wrote=%v err=%v", wrote, err) + } + + got, err := m.Upgrade(srv.URL+"/bin", srv.URL+"/sig", false) + if err != nil { + t.Fatal(err) + } + if tc.want == tc.candidate && got != tc.candidate { + t.Fatalf("Upgrade version=%q, want %q", got, tc.candidate) + } + if tc.want == tc.installed && got != "" { + t.Fatalf("newer installed command was downgraded without --force: Upgrade=%q", got) + } + current, err := m.InstalledVersion() + if err != nil || current != tc.want { + t.Fatalf("installed version=%q err=%v, want %q", current, err, tc.want) + } + }) + } +} + +func TestUpgradeReturnsCandidateWithDurabilityFailure(t *testing.T) { + dir := rootDir(t) + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + bin := newBinary("2.0.1") + srv := signedServer(t, bin, ed25519.Sign(priv, bin)) + m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20} + m.WriteRootFile = func(path string, content []byte, mode os.FileMode) error { + if err := fsutil.WriteRootFile(path, content, mode); err != nil { + return err + } + return &fsutil.DurabilityError{Operation: "rename", Err: syscall.EIO} + } + + got, err := m.Upgrade(srv.URL+"/bin", srv.URL+"/sig", false) + var durability *fsutil.DurabilityError + if got != "2.0.1" || !errors.As(err, &durability) { + t.Fatalf("Upgrade = version=%q err=%v, want candidate plus DurabilityError", got, err) + } +} + +func TestUpgradeAcceptsAnyKeyInKeyring(t *testing.T) { + dir := rootDir(t) + first, _, _ := ed25519.GenerateKey(rand.Reader) + second, secondPriv, _ := ed25519.GenerateKey(rand.Reader) + bin := newBinary("2.0.1") + srv := signedServer(t, bin, ed25519.Sign(secondPriv, bin)) + m := &Manager{ + InstallPath: filepath.Join(dir, "linux-temp-admin"), + PublicKeys: []ed25519.PublicKey{first, second}, + Client: srv.Client(), + MaxBytes: 1 << 20, + } + if got, err := m.Upgrade(srv.URL+"/bin", srv.URL+"/sig", false); err != nil || got != "2.0.1" { + t.Fatalf("Upgrade with secondary key: version=%q err=%v", got, err) + } +} + +func TestProbeVersionIsBounded(t *testing.T) { + dir := rootDir(t) + m := &Manager{ + InstallPath: filepath.Join(dir, "linux-temp-admin"), + ProbeTimeout: 100 * time.Millisecond, + ProbeMaxOutput: 32, + } + if _, err := m.probeVersion([]byte("#!/bin/sh\nsleep 30\n")); err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("hanging probe error=%v, want timeout", err) + } + noisy := []byte("#!/bin/sh\nwhile :; do printf '0123456789abcdef'; done\n") + if _, err := m.probeVersion(noisy); err == nil || !strings.Contains(err.Error(), "output exceeds") { + t.Fatalf("noisy probe error=%v, want output limit", err) } } diff --git a/internal/selfmanage/selfmanage_test.go b/internal/selfmanage/selfmanage_test.go index b321649..6e074d8 100644 --- a/internal/selfmanage/selfmanage_test.go +++ b/internal/selfmanage/selfmanage_test.go @@ -1,24 +1,279 @@ package selfmanage import ( + "bytes" + "context" "crypto/ed25519" "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io" "net" "net/http" "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" "testing" + "time" + + "golang.org/x/sys/unix" ) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +type failingResponseBody struct{ err error } + +func (b failingResponseBody) Read([]byte) (int, error) { return 0, b.err } +func (failingResponseBody) Close() error { return nil } + func TestEmbeddedPublicKeyConfigured(t *testing.T) { - // A release signing key is embedded, so signed upgrades are enabled. - if len(embeddedPublicKey()) != ed25519.PublicKeySize { - t.Errorf("embedded release key must be a %d-byte ed25519 key, got %d", ed25519.PublicKeySize, len(embeddedPublicKey())) + keys := embeddedPublicKeys() + if len(keys) == 0 { + t.Fatal("at least one release signing key must be embedded") + } + for i, key := range keys { + if len(key) != ed25519.PublicKeySize { + t.Errorf("embedded release key %d must be %d bytes, got %d", i, ed25519.PublicKeySize, len(key)) + } + } +} + +func TestSameInstalledBytesIsBoundedAndRefusesSymlinks(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "command") + if err := os.WriteFile(path, []byte("same"), 0o600); err != nil { + t.Fatal(err) + } + if same, err := sameInstalledBytes(path, []byte("same")); err != nil || !same { + t.Fatalf("equal comparison: same=%v err=%v", same, err) + } + // A sparse one-gigabyte target must be rejected by size without reading it into + // the root process's heap. + if err := os.Truncate(path, 1<<30); err != nil { + t.Fatal(err) + } + if same, err := sameInstalledBytes(path, []byte("small")); err != nil || same { + t.Fatalf("large comparison: same=%v err=%v", same, err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(path, link); err != nil { + t.Fatal(err) + } + if _, err := sameInstalledBytes(link, nil); err == nil { + t.Fatal("sameInstalledBytes followed a symlink") + } + fifo := filepath.Join(dir, "fifo") + if err := unix.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + start := time.Now() + if _, err := sameInstalledBytes(fifo, nil); err == nil || !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("FIFO comparison error = %v, want special-file refusal", err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("FIFO comparison blocked for %s", elapsed) + } +} + +func TestInstallRejectsExistingUnsafeParent(t *testing.T) { + t.Run("world writable", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "unsafe") + if err := os.Mkdir(dir, 0o777); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o777); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "linux-temp-admin") + if _, err := (&Manager{InstallPath: path}).Install([]byte("candidate"), false); err == nil { + t.Fatal("Install accepted an existing world-writable parent") + } + fi, err := os.Lstat(dir) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o777 { + t.Fatalf("unsafe parent mode=%o, want unchanged 777", fi.Mode().Perm()) + } + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("command was created in unsafe parent: %v", err) + } + }) + + t.Run("symlink", func(t *testing.T) { + base := t.TempDir() + realDir := filepath.Join(base, "real") + if err := os.Mkdir(realDir, 0o755); err != nil { + t.Fatal(err) + } + linkDir := filepath.Join(base, "sbin") + if err := os.Symlink(realDir, linkDir); err != nil { + t.Fatal(err) + } + if _, err := (&Manager{InstallPath: filepath.Join(linkDir, "linux-temp-admin")}).Install([]byte("candidate"), false); err == nil { + t.Fatal("Install accepted a symlink parent") + } + if _, err := os.Lstat(filepath.Join(realDir, "linux-temp-admin")); !os.IsNotExist(err) { + t.Fatalf("Install followed the parent symlink: %v", err) + } + }) +} + +func TestInstallerPublicKeysMatchEmbeddedKeyring(t *testing.T) { + script, err := os.ReadFile("../../scripts/install.sh") + if err != nil { + t.Fatal(err) + } + var installerKeys []ed25519.PublicKey + rest := script + for { + block, next := pem.Decode(rest) + if block == nil { + break + } + rest = next + if block.Type != "PUBLIC KEY" { + continue + } + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + t.Fatalf("parse installer public key: %v", err) + } + key, ok := parsed.(ed25519.PublicKey) + if !ok { + t.Fatalf("installer key is %T, want ed25519.PublicKey", parsed) + } + installerKeys = append(installerKeys, key) + } + embedded := embeddedPublicKeys() + if len(installerKeys) != len(embedded) { + t.Fatalf("installer has %d release keys; Go keyring has %d", len(installerKeys), len(embedded)) + } + for i := range embedded { + if !bytes.Equal(installerKeys[i], embedded[i]) { + t.Errorf("installer release key %d differs from Go keyring", i) + } + } +} + +func TestInstallerPinsAndValidatesTemporaryRoot(t *testing.T) { + script, err := os.ReadFile("../../scripts/install.sh") + if err != nil { + t.Fatal(err) + } + s := string(script) + for _, required := range []string{ + "TMP_ROOT=/tmp", + `[ ! -d "$TMP_ROOT" ] || [ -L "$TMP_ROOT" ]`, + `if ! tmp_root_uid=$(stat -c %u -- "$TMP_ROOT"); then`, + `'' | *[!0-9]*) fail "invalid temporary root owner: $TMP_ROOT"`, + `if ! tmp_root_mode=$(stat -c %A -- "$TMP_ROOT"); then`, + `d?????????)`, + `?????????t|?????????T)`, + `mktemp -d "$TMP_ROOT/linux-temp-admin.XXXXXXXXXX"`, + } { + if !strings.Contains(s, required) { + t.Errorf("installer is missing temporary-root control %q", required) + } + } + for _, forbidden := range []string{`tmp="$(mktemp -d)"`, "TMPDIR"} { + if strings.Contains(s, forbidden) { + t.Errorf("installer must not use caller-controlled temporary placement: found %q", forbidden) + } + } +} + +func TestInstallerDropsImportedShellFunctions(t *testing.T) { + wantDiagnostic := "run this installer as root" + if os.Geteuid() == 0 { + wantDiagnostic = "DEST must be an absolute path" } + for _, shell := range []struct { + name string + args []string + }{ + {name: "bash"}, + {name: "bash-posix", args: []string{"--posix"}}, + } { + t.Run(shell.name, func(t *testing.T) { + marker := filepath.Join(t.TempDir(), "imported-function-ran") + env := append(os.Environ(), + "DEST=relative", + "TEST_IMPORTED_MARKER="+marker, + `BASH_FUNC_id%%=() { printf imported > "$TEST_IMPORTED_MARKER"; /usr/bin/id "$@"; }`, + ) + + // Prove this exact Bash mode imports the fixture; otherwise the installer + // assertion below could pass without exercising its startup hardening. + probeArgs := append(append([]string(nil), shell.args...), "-c", `[ "$(type -t id)" = function ]`) + probe := exec.Command("/bin/bash", probeArgs...) + probe.Env = env + if out, err := probe.CombinedOutput(); err != nil { + t.Fatalf("Bash function-import fixture is inactive: %v\n%s", err, out) + } + + args := append(append([]string(nil), shell.args...), "../../scripts/install.sh") + cmd := exec.Command("/bin/bash", args...) + cmd.Env = env + if out, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(out), wantDiagnostic) { + t.Fatalf("installer did not reach the expected validation boundary: err=%v\n%s", err, out) + } + if _, err := os.Lstat(marker); !os.IsNotExist(err) { + t.Fatalf("installer executed an imported shell function: %v", err) + } + }) + } + + t.Run("bash-imported-special-builtins", func(t *testing.T) { + dir := t.TempDir() + unsetMarker := filepath.Join(dir, "imported-unset-ran") + setMarker := filepath.Join(dir, "imported-set-ran") + colonMarker := filepath.Join(dir, "imported-colon-ran") + idMarker := filepath.Join(dir, "imported-id-ran") + env := append(os.Environ(), + "DEST=relative", + "TEST_IMPORTED_UNSET_MARKER="+unsetMarker, + "TEST_IMPORTED_SET_MARKER="+setMarker, + "TEST_IMPORTED_COLON_MARKER="+colonMarker, + "TEST_IMPORTED_ID_MARKER="+idMarker, + `BASH_FUNC_unset%%=() { printf imported > "$TEST_IMPORTED_UNSET_MARKER"; return 0; }`, + `BASH_FUNC_set%%=() { printf imported > "$TEST_IMPORTED_SET_MARKER"; return 0; }`, + `BASH_FUNC_:%%=() { printf imported > "$TEST_IMPORTED_COLON_MARKER"; return 0; }`, + `BASH_FUNC_id%%=() { printf imported > "$TEST_IMPORTED_ID_MARKER"; /usr/bin/id "$@"; }`, + ) + + probe := exec.Command("/bin/bash", "-c", `[ "$(type -t unset)" = function ] && [ "$(type -t set)" = function ] && [ "$(type -t ':')" = function ] && [ "$(type -t id)" = function ]`) + probe.Env = env + if out, err := probe.CombinedOutput(); err != nil { + t.Fatalf("Bash special-builtin import fixture is inactive: %v\n%s", err, out) + } + + cmd := exec.Command("/bin/bash", "../../scripts/install.sh") + cmd.Env = env + if out, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(out), wantDiagnostic) { + t.Fatalf("installer did not reach the expected validation boundary with imported special builtins: err=%v\n%s", err, out) + } + for name, marker := range map[string]string{"unset": unsetMarker, "set": setMarker, ":": colonMarker, "id": idMarker} { + if _, err := os.Lstat(marker); !os.IsNotExist(err) { + t.Fatalf("installer executed imported %s function: %v", name, err) + } + } + }) } func TestUpgradeRefusedWithoutKey(t *testing.T) { m := &Manager{PublicKey: nil} - if _, err := m.Upgrade("https://x/bin", "https://x/sig", "2.0.0", false); err == nil { + if _, err := m.Upgrade("https://x/bin", "https://x/sig", false); err == nil { t.Error("Upgrade must refuse when no signing key is configured") } } @@ -42,6 +297,126 @@ func TestNormalizeSig(t *testing.T) { } } +func TestVerifyReleaseChecksumsRequiresCanonicalManifest(t *testing.T) { + files := map[string][]byte{ + "linux-temp-admin-linux-amd64": []byte("binary"), + "linux-temp-admin-linux-amd64.sig": []byte("signature"), + } + canonical := fmt.Sprintf("%x linux-temp-admin-linux-amd64\n%x linux-temp-admin-linux-amd64.sig\n", + sha256.Sum256(files["linux-temp-admin-linux-amd64"]), + sha256.Sum256(files["linux-temp-admin-linux-amd64.sig"])) + if err := verifyReleaseChecksums([]byte(canonical), files); err != nil { + t.Fatalf("canonical checksum manifest failed: %v", err) + } + for name, manifest := range map[string][]byte{ + "missing newline": []byte(strings.TrimSuffix(canonical, "\n")), + "embedded NUL": []byte(strings.Replace(canonical, " linux", "\x00 linux", 1)), + "uppercase digest": []byte(strings.ToUpper(canonical[:64]) + canonical[64:]), + "unselected uppercase digest": []byte(canonical + strings.Repeat("A", 64) + " linux-temp-admin-linux-arm64\n"), + "unselected non-hex digest": []byte(canonical + strings.Repeat("g", 64) + " linux-temp-admin-linux-arm64\n"), + "extra separator": []byte(canonical + strings.Repeat("0", 64) + " ignored name\n"), + } { + t.Run(name, func(t *testing.T) { + if err := verifyReleaseChecksums(manifest, files); err == nil { + t.Fatal("noncanonical checksum manifest was accepted") + } + }) + } +} + +func TestDecodeReleaseManifestStrictly(t *testing.T) { + valid := []byte(`{"version":"2.8.0","tag":"v2.8.0","base_url":"https://dl.ll.cd/linux-temp-admin/v2.8.0","published_at":"2026-07-27T05:00:00Z"}` + "\n") + manifest, err := decodeReleaseManifest(valid) + if err != nil { + t.Fatal(err) + } + if manifest.Version != "2.8.0" || manifest.Tag != "v2.8.0" { + t.Fatalf("manifest=%+v", manifest) + } + for name, body := range map[string]string{ + "duplicate": `{"version":"2.8.0","version":"2.8.1","tag":"v2.8.0","base_url":"https://dl.ll.cd/linux-temp-admin/v2.8.0","published_at":"2026-07-27T05:00:00Z"}`, + "unknown": `{"version":"2.8.0","tag":"v2.8.0","base_url":"https://dl.ll.cd/linux-temp-admin/v2.8.0","published_at":"2026-07-27T05:00:00Z","extra":"x"}`, + "non-string": `{"version":280,"tag":"v2.8.0","base_url":"https://dl.ll.cd/linux-temp-admin/v2.8.0","published_at":"2026-07-27T05:00:00Z"}`, + "trailing": string(valid) + `{}`, + } { + t.Run(name, func(t *testing.T) { + if _, err := decodeReleaseManifest([]byte(body)); err == nil { + t.Fatal("invalid manifest was accepted") + } + }) + } +} + +func TestFetchReleaseManifestValidatesPinnedRouting(t *testing.T) { + root := "https://dl.ll.cd/linux-temp-admin" + valid := `{"version":"2.8.0","tag":"v2.8.0","base_url":"` + root + `/v2.8.0","published_at":"2026-07-27T05:00:00Z"}` + "\n" + m := &Manager{Client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(valid)), Request: req}, nil + })}, RetryDelay: 0} + manifest, err := m.FetchReleaseManifest(root+"/latest.json", root) + if err != nil || manifest.BaseURL != root+"/v2.8.0" { + t.Fatalf("FetchReleaseManifest: manifest=%+v err=%v", manifest, err) + } + + for _, body := range []string{ + strings.Replace(valid, `"tag":"v2.8.0"`, `"tag":"v2.8.1"`, 1), + strings.Replace(valid, root+`/v2.8.0`, `https://example.invalid/v2.8.0`, 1), + strings.Replace(valid, `2026-07-27T05:00:00Z`, `not-a-time`, 1), + strings.TrimSuffix(valid, "\n"), + strings.Replace(valid, `{"version"`, `{ "version"`, 1), + `{"tag":"v2.8.0","version":"2.8.0","base_url":"` + root + `/v2.8.0","published_at":"2026-07-27T05:00:00Z"}` + "\n", + } { + m.Client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil + }) + if _, err := m.FetchReleaseManifest(root+"/latest.json", root); err == nil || IsTransportFailure(err) { + t.Fatalf("semantic manifest failure err=%v, want non-transport failure", err) + } + } +} + +func TestOfficialMirrorRedirectIsPolicyFailure(t *testing.T) { + m := New("/tmp/none", 16) + requests := 0 + m.RetryDelay = 0 + m.Client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + requests++ + header := make(http.Header) + header.Set("Location", "https://cdn.example.invalid/linux-temp-admin/latest.json") + return &http.Response{ + StatusCode: http.StatusFound, + Header: header, + Body: io.NopCloser(strings.NewReader("redirect")), + Request: req, + }, nil + }) + _, err := m.FetchReleaseManifest( + "https://dl.ll.cd/linux-temp-admin/latest.json", + "https://dl.ll.cd/linux-temp-admin", + ) + if err == nil || IsTransportFailure(err) { + t.Fatalf("official mirror redirect err=%v transport=%v, want policy failure", err, IsTransportFailure(err)) + } + if requests != 1 { + t.Fatalf("official mirror redirect made %d requests, want 1", requests) + } +} + +func TestOfficialSourceDoesNotAllowPrivateInitialAddress(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"version":"2.8.0"}`)) + })) + defer srv.Close() + + m := New("/tmp/none", 16) + m.RetryDelay = 0 + m.Client.Transport.(*http.Transport).TLSClientConfig = srv.Client().Transport.(*http.Transport).TLSClientConfig + _, err := m.FetchReleaseManifest(srv.URL+"/latest.json", srv.URL) + if err == nil || IsTransportFailure(err) { + t.Fatalf("private official source err=%v transport=%v, want policy failure", err, IsTransportFailure(err)) + } +} + func TestIsPublicIP(t *testing.T) { cases := []struct { ip string @@ -50,6 +425,9 @@ func TestIsPublicIP(t *testing.T) { {"8.8.8.8", true}, {"1.1.1.1", true}, {"2606:4700:4700::1111", true}, // public IPv6 + {"2000::1", true}, // lower 2000::/3 boundary + {"3ff0::1", true}, // outside 3fff::/20 documentation space + {"3fff:1000::1", true}, // immediately above 3fff::/20 {"169.254.169.254", false}, // link-local (cloud metadata) {"127.0.0.1", false}, // loopback {"10.0.0.1", false}, // RFC1918 @@ -64,6 +442,9 @@ func TestIsPublicIP(t *testing.T) { {"240.0.0.1", false}, // reserved for future use {"0.0.0.0", false}, // unspecified {"::1", false}, // IPv6 loopback + {"100:0:0:1::1", false}, // IANA Dummy IPv6 Prefix + {"4000::1", false}, // outside current global-unicast allocation + {"fec0::1", false}, // deprecated site-local {"fd00::1", false}, // IPv6 ULA {"fe80::1", false}, // IPv6 link-local {"64:ff9b::a00:1", false}, // NAT64 well-known prefix @@ -71,6 +452,7 @@ func TestIsPublicIP(t *testing.T) { {"2001:db8::1", false}, // IPv6 documentation {"2002:a00:1::1", false}, // deprecated 6to4, embeds private IPv4 {"3fff::1", false}, // IPv6 documentation + {"3fff:0fff::1", false}, // upper 3fff::/20 documentation boundary {"::ffff:10.0.0.1", false}, // IPv4-mapped private address } for _, c := range cases { @@ -87,15 +469,64 @@ func TestIsPublicIP(t *testing.T) { func TestRefusePrivateRedirect(t *testing.T) { // IP literals resolve without DNS, so this is hermetic. for _, bad := range []string{"127.0.0.1", "169.254.169.254", "10.1.2.3", "::1"} { - if err := refusePrivateRedirect(bad); err == nil { + if err := refusePrivateRedirect(context.Background(), bad); err == nil { t.Errorf("redirect to %s must be refused", bad) } } - if err := refusePrivateRedirect("8.8.8.8"); err != nil { + if err := refusePrivateRedirect(context.Background(), "8.8.8.8"); err != nil { t.Errorf("redirect to public 8.8.8.8 must be allowed: %v", err) } } +func TestRefusePrivateRedirectBoundsDNSLookup(t *testing.T) { + oldLookup, oldTimeout := lookupRedirectIPs, redirectLookupTimeout + redirectLookupTimeout = 25 * time.Millisecond + lookupRedirectIPs = func(ctx context.Context, _, _ string) ([]net.IP, error) { + <-ctx.Done() + return nil, ctx.Err() + } + t.Cleanup(func() { + lookupRedirectIPs, redirectLookupTimeout = oldLookup, oldTimeout + }) + + start := time.Now() + if err := refusePrivateRedirect(context.Background(), "lookup.invalid"); err == nil { + t.Fatal("timed-out redirect lookup was accepted") + } else { + var transportDiagnostic *safeTransportDiagnosticError + if !errors.As(err, &transportDiagnostic) { + t.Fatalf("redirect DNS failure type = %T, want a transport diagnostic", err) + } + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("redirect lookup exceeded its bound: %s", elapsed) + } +} + +func TestRedirectDNSFailurePermitsSourceFallback(t *testing.T) { + source := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://redirect-lookup.invalid/asset", http.StatusFound) + })) + defer source.Close() + + oldLookup := lookupRedirectIPs + lookupRedirectIPs = func(context.Context, string, string) ([]net.IP, error) { + return nil, errors.New("synthetic DNS failure") + } + t.Cleanup(func() { lookupRedirectIPs = oldLookup }) + + m := New("/tmp/none", 16) + m.RetryDelay = 0 + m.Client.Transport.(*http.Transport).TLSClientConfig = source.Client().Transport.(*http.Transport).TLSClientConfig + _, err := m.download(source.URL+"/asset", 16) + if err == nil || !IsTransportFailure(err) { + t.Fatalf("redirect DNS failure err=%v transport=%v, want transport failure", err, IsTransportFailure(err)) + } + if !strings.Contains(err.Error(), "cannot resolve redirect host") { + t.Fatalf("redirect DNS diagnostic lost its safe reason: %v", err) + } +} + func toHex(b []byte) string { const hexdigits = "0123456789abcdef" out := make([]byte, len(b)*2) @@ -140,6 +571,69 @@ func TestNewClientRedirectToPrivateIsRefused(t *testing.T) { } } +func TestNewClientDoesNotForwardSensitiveRefererOnRedirect(t *testing.T) { + referer := make(chan string, 1) + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + referer <- r.Referer() + _, _ = w.Write([]byte("redirected body")) + })) + defer target.Close() + + targetURL, err := url.Parse(target.URL) + if err != nil { + t.Fatal(err) + } + _, targetPort, err := net.SplitHostPort(targetURL.Host) + if err != nil { + t.Fatal(err) + } + targetURL.Host = net.JoinHostPort("redirect-target.example", targetPort) + targetURL.Path = "/asset" + + source := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, targetURL.String(), http.StatusFound) + })) + defer source.Close() + + oldLookup := lookupRedirectIPs + lookupRedirectIPs = func(context.Context, string, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("93.184.216.34")}, nil + } + t.Cleanup(func() { lookupRedirectIPs = oldLookup }) + + m := New("/tmp/none", 1<<20) + m.RetryDelay = 0 + dialer := &net.Dialer{} + transport := m.Client.Transport.(*http.Transport) + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + host, _, splitErr := net.SplitHostPort(address) + if splitErr != nil { + return nil, splitErr + } + if host == "redirect-target.example" { + address = target.Listener.Addr().String() + } + return dialer.DialContext(ctx, network, address) + } + // Both endpoints are local test servers and the redirected hostname is + // synthetic. Certificate verification is outside this header-focused test. + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec + + const queryMarker = "query-secret-2c591f" + const fragmentMarker = "fragment-secret-90ad47" + if _, err := m.download(source.URL+"/private/path?token="+queryMarker+"#"+fragmentMarker, 1<<20); err != nil { + t.Fatal(err) + } + select { + case got := <-referer: + if got != "" { + t.Fatalf("redirect target received sensitive Referer %q", got) + } + case <-time.After(time.Second): + t.Fatal("redirect target did not receive the request") + } +} + // TestCheckDialAddr exercises the dial-time policy the Control hook enforces — // including the DENY branch, which the redirect integration test cannot reach // (its loopback target is refused earlier by the name check). This is the @@ -150,13 +644,16 @@ func TestCheckDialAddr(t *testing.T) { allowPrivate bool wantErr bool }{ - {"93.184.216.34:443", false, false}, // public, redirect phase -> allowed - {"93.184.216.34:443", true, false}, // public, initial -> allowed - {"127.0.0.1:443", true, false}, // private but initial mirror -> allowed - {"127.0.0.1:443", false, true}, // private AFTER redirect -> DENIED (the fix) - {"10.0.0.5:443", false, true}, // RFC1918 after redirect -> denied - {"169.254.169.254:80", false, true}, // link-local metadata after redirect -> denied - {"[::1]:443", false, true}, // ipv6 loopback after redirect -> denied + {"93.184.216.34:443", false, false}, // public, redirect phase -> allowed + {"93.184.216.34:443", true, false}, // public, initial -> allowed + {"127.0.0.1:443", true, false}, // private but initial mirror -> allowed + {"127.0.0.1:443", false, true}, // private AFTER redirect -> DENIED (the fix) + {"10.0.0.5:443", false, true}, // RFC1918 after redirect -> denied + {"169.254.169.254:80", false, true}, // link-local metadata after redirect -> denied + {"[::1]:443", false, true}, // ipv6 loopback after redirect -> denied + {"redirect.example:443", false, true}, // Control must receive an already-resolved IP + {"[fe80::1%lo]:443", false, true}, // zone-qualified link-local redirect is still private + {"[fe80::1%lo]:443", true, false}, // explicit internal mirror may need an interface zone } for _, c := range cases { err := checkDialAddr(c.addr, c.allowPrivate) @@ -165,3 +662,172 @@ func TestCheckDialAddr(t *testing.T) { } } } + +func TestDownloadRetriesTransientStatus(t *testing.T) { + requests := 0 + var queries []string + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + queries = append(queries, r.URL.RawQuery) + if requests < 3 { + w.WriteHeader(http.StatusGatewayTimeout) + return + } + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + m := &Manager{Client: srv.Client(), RetryDelay: 0} + got, err := m.download(srv.URL, 16) + if err != nil { + t.Fatal(err) + } + if string(got) != "ok" || requests != 3 { + t.Fatalf("body=%q requests=%d, want ok and 3", got, requests) + } + if want := []string{"", "", ""}; !slices.Equal(queries, want) { + t.Fatalf("queries=%q, want %q", queries, want) + } + if m.allowPrivateDial.Load() { + t.Fatal("private-address dial exception remained enabled after download") + } +} + +func TestDownloadCacheBypassIsRestrictedToOfficialReleaseURLs(t *testing.T) { + cases := map[string]string{ + "https://github.com/xxvcc/linux-temp-admin/releases/download/v2.8.0/linux-temp-admin-linux-amd64": "https://github.com/xxvcc/linux-temp-admin/releases/download/v2.8.0/linux-temp-admin-linux-amd64?download=1", + "https://github.com/xxvcc/linux-temp-admin/releases/latest/download/linux-temp-admin-linux-amd64.sig": "https://github.com/xxvcc/linux-temp-admin/releases/latest/download/linux-temp-admin-linux-amd64.sig?download=1", + "https://example.com/bin?token=secret&download=old#fragment": "https://example.com/bin?token=secret&download=old#fragment", + "https://github.com/xxvcc/linux-temp-admin/releases/download/v2.8.0/bin?X-Amz-Signature=secret": "https://github.com/xxvcc/linux-temp-admin/releases/download/v2.8.0/bin?X-Amz-Signature=secret", + "https://github.com/another/repo/releases/download/v1.0.0/bin": "https://github.com/another/repo/releases/download/v1.0.0/bin", + } + for input, want := range cases { + got, err := withDownloadCacheBypass(input) + if err != nil { + t.Errorf("withDownloadCacheBypass(%q): %v", input, err) + continue + } + if got != want { + t.Errorf("withDownloadCacheBypass(%q)=%q, want %q", input, got, want) + } + } +} + +func TestUpgradeURLDiagnosticsHideSensitiveComponents(t *testing.T) { + const ( + userinfoMarker = "userinfo-marker-8d31" + pathMarker = "path-marker-4b72" + queryMarker = "query-marker-6c93" + fragmentMarker = "fragment-marker-1a54" + ) + rawURL := "https://" + userinfoMarker + ":password@example.invalid/releases/" + pathMarker + + "?token=" + queryMarker + "#" + fragmentMarker + markers := []string{userinfoMarker, pathMarker, queryMarker, fragmentMarker} + + if got, want := RedactedURL(rawURL), "https://example.invalid/[details hidden]"; got != want { + t.Fatalf("RedactedURL() = %q, want %q", got, want) + } + + tests := map[string]roundTripFunc{ + "transport error": func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("transport echoed complete URL %s", req.URL.String()) + }, + "response read error": func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: failingResponseBody{err: fmt.Errorf("body echoed complete URL %s", req.URL.String())}, + Request: req, + }, nil + }, + } + for name, transport := range tests { + t.Run(name, func(t *testing.T) { + m := &Manager{Client: &http.Client{Transport: transport}, RetryDelay: 0} + _, err := m.download(rawURL, 16) + if err == nil { + t.Fatal("download unexpectedly succeeded") + } + diagnostic := err.Error() + if !strings.Contains(diagnostic, "https://example.invalid") { + t.Errorf("diagnostic lost the safe endpoint: %q", diagnostic) + } + for _, marker := range markers { + if strings.Contains(diagnostic, marker) { + t.Errorf("diagnostic leaked %q: %q", marker, diagnostic) + } + } + }) + } + + malformed := "https://" + userinfoMarker + "@example.invalid/" + pathMarker + + "/%zz?token=" + queryMarker + "#" + fragmentMarker + if _, err := withDownloadCacheBypass(malformed); err == nil { + t.Fatal("malformed cache-bypass URL unexpectedly succeeded") + } else { + for _, marker := range markers { + if strings.Contains(err.Error(), marker) { + t.Errorf("malformed-URL diagnostic leaked %q: %q", marker, err) + } + } + } +} + +func TestRedirectErrorDoesNotLeakTargetURLDetails(t *testing.T) { + markers := []string{ + "redirect-userinfo-marker-2f85", + "redirect-path-marker-7a46", + "redirect-query-marker-9c17", + "redirect-fragment-marker-3d68", + } + target := "http://" + markers[0] + ":password@example.invalid/" + markers[1] + + "?token=" + markers[2] + "#" + markers[3] + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target, http.StatusFound) + })) + defer srv.Close() + + m := New("/tmp/none", 16) + m.RetryDelay = 0 + m.Client.Transport.(*http.Transport).TLSClientConfig = srv.Client().Transport.(*http.Transport).TLSClientConfig + _, err := m.download(srv.URL+"/binary", 16) + if err == nil { + t.Fatal("non-https redirect unexpectedly succeeded") + } + if IsTransportFailure(err) { + t.Fatalf("redirect-policy failure was classified as transport: %v", err) + } + if !strings.Contains(err.Error(), "non-https endpoint") { + t.Fatalf("redirect diagnostic lost the useful refusal reason: %q", err) + } + for _, marker := range markers { + if strings.Contains(err.Error(), marker) { + t.Errorf("redirect diagnostic leaked %q: %q", marker, err) + } + } +} + +func TestRetryableHTTPStatusIncludesEvery5xx(t *testing.T) { + for status := 500; status <= 599; status++ { + if !retryableHTTPStatus(status) { + t.Fatalf("status %d should be retryable", status) + } + } +} + +func TestDownloadDoesNotRetryPermanentStatus(t *testing.T) { + requests := 0 + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + m := &Manager{Client: srv.Client(), RetryDelay: 0} + if _, err := m.download(srv.URL, 16); err == nil { + t.Fatal("404 download should fail") + } else if !IsTransportFailure(err) { + t.Fatalf("404 should permit source fallback: %v", err) + } + if requests != 1 { + t.Fatalf("404 made %d requests, want 1", requests) + } +} diff --git a/internal/sshdconf/sshdconf.go b/internal/sshdconf/sshdconf.go index 6fdeba6..f0a442d 100644 --- a/internal/sshdconf/sshdconf.go +++ b/internal/sshdconf/sshdconf.go @@ -1,11 +1,14 @@ // Package sshdconf grants and removes a per-account sshd exception, so an invite // can work on a server that does not accept public-key logins by default. // -// The exception is a drop-in file of its own, containing nothing but a -// `Match User ` block. That shape is the whole design: +// The exception is a drop-in file of its own, containing a +// `Match User ` block followed by an empty `Match all` scope reset. +// That shape is the whole design: // // - The global policy is never edited. Every other account on the host keeps -// the operator's baseline, byte for byte. +// the operator's baseline, byte for byte. The final scope reset is required +// because Match state persists between files expanded by one Include glob; +// without it, this early-sorting drop-in would capture later global entries. // - "Restoring" is deleting our own file. There is no backup to keep, so the // tool can never clobber a change the operator (or their config management) // made in the days between the invite and its expiry, and it can never @@ -30,6 +33,7 @@ package sshdconf import ( "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -37,11 +41,14 @@ import ( "strconv" "strings" "syscall" + "time" "github.com/xxvcc/linux-temp-admin/internal/config" + "github.com/xxvcc/linux-temp-admin/internal/executil" "github.com/xxvcc/linux-temp-admin/internal/fsutil" "github.com/xxvcc/linux-temp-admin/internal/sysinfo" "github.com/xxvcc/linux-temp-admin/internal/validate" + "golang.org/x/sys/unix" ) // filePrefix namespaces the drop-in files this tool manages. The "10-" sorts the @@ -50,6 +57,21 @@ import ( // sshd keeps the first value it obtained. const filePrefix = "10-" + config.ManagedTag + "-" +const removePendingSuffix = ".remove-pending" + +var ( + sshdCheckOptions = executil.Options{ + Timeout: 10 * time.Second, + MaxOutput: 256 << 10, + ExtraEnv: []string{"LC_ALL=C", "LANG=C"}, + } + sshdReloadOptions = executil.Options{ + Timeout: 30 * time.Second, + MaxOutput: 256 << 10, + ExtraEnv: []string{"LC_ALL=C", "LANG=C"}, + } +) + // DefaultDir is where sshd's per-file configuration drop-ins live. const DefaultDir = "/etc/ssh/sshd_config.d" @@ -60,10 +82,11 @@ const DefaultLock = "/run/" + config.ManagedTag + "-sshd.lock" // ErrNoReloadMechanism means there was no running sshd to notify: no init system // took the reload, and no live sshd process could be found to signal. // -// It is not a failure. A socket-activated sshd (and one that simply is not -// running) starts a fresh process per connection and reads the new configuration -// then. But it is not a success either: we cannot say the running daemon adopted -// the change, so a caller must not go on to claim the login is verified. +// A socket-activated sshd (and one that simply is not running) starts a fresh +// process per connection and reads the new configuration then, so a grant may +// remain on disk as explicitly unverified. A removal is different: this error +// cannot prove that an undiscovered long-running daemon stopped using the old +// name-scoped exception, so the pending removal must be retained for retry. var ErrNoReloadMechanism = errors.New("no running sshd could be asked to re-read its configuration") // GrantResult describes what a grant actually achieved. @@ -85,7 +108,7 @@ type Manager struct { Validate func() error // syntax check (default: sshd -t) Effective func(user string) (*sysinfo.SSHDConfig, error) // effective config (default: sshd -T -C user=) Reload func() error // ask sshd to re-read its config - RemoveFile func(path string) error // defaults to os.Remove; injectable for rollback tests + RemoveFile func(path string) error // defaults to durable unlink; injectable for rollback tests } // New returns a Manager for the real /etc/ssh/sshd_config.d. @@ -96,7 +119,7 @@ func New() *Manager { Validate: sshdSyntaxCheck, Effective: sysinfo.SSHDEffective, Reload: reload, - RemoveFile: os.Remove, + RemoveFile: fsutil.RemoveFile, } } @@ -142,23 +165,38 @@ func (m *Manager) Grant(user string, groups []string, report sysinfo.LoginReport } } path := m.FilePath(user) + rollback := func(cause error, restoreDaemon bool) error { + pendingExisted, staged, err := m.stageRemovalLocked(path) + if err != nil { + return errors.Join(cause, fmt.Errorf("stage failed sshd grant removal: %w", err)) + } + if !staged { + return cause + } + // Before the first reload attempt, a newly-created drop-in cannot be in + // daemon memory. Its unlink still goes through the durable marker protocol, + // but no reload is needed unless this call inherited older pending state. + if !restoreDaemon && !pendingExisted { + if err := clearPending(path + removePendingSuffix); err != nil { + return errors.Join(cause, fmt.Errorf("complete failed sshd grant removal: %w", err)) + } + return cause + } + if err := m.finishRemovalLocked(path); err != nil { + return errors.Join(cause, fmt.Errorf("restore sshd after failed grant: %w", err)) + } + return cause + } if err := fsutil.WriteRootFile(path, content, 0o644); err != nil { + var committed *fsutil.DurabilityError + if errors.As(err, &committed) { + return rollback(err, false) + } return err } // Everything below reads the config from disk, so the grant is proved correct // before the running sshd is asked to adopt it. Until the reload, the running // daemon has not seen this file at all, so removing it fully undoes the grant. - rollback := func(cause error, restoreDaemon bool) error { - var rollbackErrs []error - if err := m.removeFile(path); err != nil { - rollbackErrs = append(rollbackErrs, fmt.Errorf("remove failed sshd drop-in %s: %w", path, err)) - } else if restoreDaemon && m.Reload != nil { - if err := m.Reload(); err != nil { - rollbackErrs = append(rollbackErrs, fmt.Errorf("restore sshd after failed reload: %w", err)) - } - } - return errors.Join(append([]error{cause}, rollbackErrs...)...) - } if m.Validate != nil { if err := m.Validate(); err != nil { return rollback(fmt.Errorf("sshd rejected the configuration this grant produced: %w", err), false) @@ -206,7 +244,8 @@ func (m *Manager) Grant(user string, groups []string, report sysinfo.LoginReport // blindly — like the sudoers drop-in next to it, it only ever removes the // managed file for this one account — so revoke need not know whether a grant // was ever made. Removing a file that is not there is not an error and does not -// disturb sshd. +// disturb sshd unless a pending marker says an earlier removal still needs to +// be adopted by the running daemon. func (m *Manager) Remove(user string) error { if !validate.Username(user) { return fmt.Errorf("refusing to remove an sshd drop-in for invalid username %q", user) @@ -216,90 +255,213 @@ func (m *Manager) Remove(user string) error { return fmt.Errorf("refusing to remove an unmanaged file: %s", path) } return m.withLock(func() error { - if _, err := os.Lstat(path); err != nil { - if os.IsNotExist(err) { - return nil // nothing was granted; do not disturb sshd - } + _, staged, err := m.stageRemovalLocked(path) + if err != nil { return err } - if err := m.removeFile(path); err != nil { - return err + if !staged { + return nil // nothing was granted; do not disturb sshd } - // The exception is gone from disk — the removal itself has succeeded, and the - // caller must not be told otherwise. What remains is whether the running sshd - // can safely be asked to notice. - // - // It must NOT be asked if the host's config is invalid, and the invalid part - // is very unlikely to be ours (we just deleted our only file). A reload - // re-execs sshd against what is on disk: if an operator left a typo in - // sshd_config this afternoon and never reloaded, the running daemon is still - // happily serving its old in-memory config — and this reload, fired at 3am by - // an unattended auto-revoke timer, would be the thing that finally takes SSH - // off the machine. A missed reload is recoverable. A dead sshd on a remote - // box is not. - if m.Validate != nil { - if err := m.Validate(); err != nil { - return fmt.Errorf("the sshd exception was removed, but sshd was NOT reloaded: the host's sshd configuration is invalid and a reload would take sshd down: %w", err) - } + return m.finishRemovalLocked(path) + }) +} + +// stageRemovalLocked records durable retry state, then durably removes path. +// It returns whether a marker predated this call and whether there was any state +// to remove. The caller must hold m's lock through the eventual finish/clear. +func (m *Manager) stageRemovalLocked(path string) (pendingExisted, staged bool, err error) { + pending := path + removePendingSuffix + dropInExists, err := pathExists(path) + if err != nil { + return false, false, err + } + pendingExists, err := pathExists(pending) + if err != nil { + return false, false, err + } + if !dropInExists && !pendingExists { + return false, false, nil + } + if pendingExists { + if err := validatePending(pending); err != nil { + return true, true, fmt.Errorf("unsafe pending sshd removal: %w", err) } - if m.Reload != nil { - if err := m.Reload(); err != nil && !errors.Is(err, ErrNoReloadMechanism) { - return fmt.Errorf("the sshd exception was removed, but the reload failed: %w", err) - } + } + if !dropInExists { + return pendingExists, true, nil + } + // The marker is empty, so even an unusually broad Include cannot turn it into + // an sshd directive; its non-.conf suffix also keeps it out of the normal glob. + // WriteRootFile syncs the directory before the policy file is unlinked. + if !pendingExists { + if err := fsutil.WriteRootFile(pending, nil, 0o600); err != nil { + return false, true, fmt.Errorf("record pending sshd reload: %w", err) } - return nil - }) + } + if err := m.removeFile(path); err != nil { + return pendingExists, true, fmt.Errorf("remove failed sshd drop-in %s: %w", path, err) + } + if stillExists, err := pathExists(path); err != nil { + return pendingExists, true, err + } else if stillExists { + return pendingExists, true, fmt.Errorf("remove reported success but sshd exception still exists: %s", path) + } + if err := syncParent(path); err != nil { + return pendingExists, true, fmt.Errorf("sync removed sshd exception: %w", err) + } + return pendingExists, true, nil +} + +// finishRemovalLocked validates the post-removal host config, requires a +// confirmed reload, and only then clears the retry marker. +func (m *Manager) finishRemovalLocked(path string) error { + if m.Validate == nil { + return fmt.Errorf("the sshd exception was removed, but no configuration validator is available") + } + if err := m.Validate(); err != nil { + return fmt.Errorf("the sshd exception was removed, but sshd was NOT reloaded: the host's sshd configuration is invalid and a reload would take sshd down: %w", err) + } + if m.Reload == nil { + return fmt.Errorf("the sshd exception was removed, but its removal could not be confirmed: %w", ErrNoReloadMechanism) + } + if err := m.Reload(); err != nil { + if errors.Is(err, ErrNoReloadMechanism) { + return fmt.Errorf("the sshd exception was removed, but its removal could not be confirmed: %w", err) + } + return fmt.Errorf("the sshd exception was removed, but the reload failed: %w", err) + } + return clearPending(path + removePendingSuffix) +} + +func clearPending(pending string) error { + if err := fsutil.RemoveFile(pending); err != nil { + return fmt.Errorf("clear pending sshd reload: %w", err) + } + return nil +} + +func pathExists(path string) (bool, error) { + _, err := os.Lstat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func syncParent(path string) error { + dir, err := os.OpenFile(filepath.Dir(path), os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} + +func validatePending(path string) error { + fi, err := os.Lstat(path) + if err != nil { + return err + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", path) + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("cannot stat %s", path) + } + if st.Uid != 0 || st.Gid != 0 { + return fmt.Errorf("%s is not owned by root:root (owner %d:%d)", path, st.Uid, st.Gid) + } + if fi.Mode().Perm() != 0o600 { + return fmt.Errorf("%s has mode %o, want 600", path, fi.Mode().Perm()) + } + if fi.Size() != 0 { + return fmt.Errorf("%s is not empty", path) + } + return nil } func (m *Manager) removeFile(path string) error { if m.RemoveFile != nil { return m.RemoveFile(path) } - return os.Remove(path) + return fsutil.RemoveFile(path) } -// Orphans returns the accounts whose managed drop-in is still on disk although -// the account itself is gone. A grant -// outlives its account only if something went wrong (a revoke run by an older -// binary that did not know about these files, or an account deleted out of -// band), and an orphan is a standing loosening of sshd policy that re-arms the -// moment the username is reused — so something has to be able to find them. // All returns every account this tool has an sshd exception for, whether or not -// the account still exists. Orphans answers "which exceptions outlived their -// account", which is the wrong question for a teardown: an exception whose -// account is alive is precisely what has to go. +// the account still exists. A pending removal is included too: its drop-in is +// already gone, but the running daemon may still hold the exception until a +// retry validates and reloads sshd. func (m *Manager) All() ([]string, error) { - matches, err := filepath.Glob(filepath.Join(m.Dir, filePrefix+"*.conf")) + entries, err := readDir(m.Dir) + if os.IsNotExist(err) { + return nil, nil + } if err != nil { - return nil, err + return nil, fmt.Errorf("scan sshd drop-in directory %s: %w", m.Dir, err) } - var users []string - for _, path := range matches { - user := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(path), filePrefix), ".conf") - if user != "" && validate.Username(user) { - users = append(users, user) + users := make(map[string]struct{}) + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, filePrefix) { + continue } + var suffix string + switch { + case strings.HasSuffix(name, ".conf"+removePendingSuffix): + suffix = ".conf" + removePendingSuffix + case strings.HasSuffix(name, ".conf"): + suffix = ".conf" + default: + continue + } + user := strings.TrimSuffix(strings.TrimPrefix(name, filePrefix), suffix) + if user == "" || !validate.Username(user) { + return nil, fmt.Errorf("managed sshd artifact has an invalid account name: %s", filepath.Join(m.Dir, name)) + } + users[user] = struct{}{} + } + out := make([]string, 0, len(users)) + for user := range users { + out = append(out, user) + } + sort.Strings(out) + return out, nil +} + +var readDir = readDirectory + +func readDirectory(path string) ([]os.DirEntry, error) { + dir, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0) + if err != nil { + return nil, err } - sort.Strings(users) - return users, nil + entries, readErr := dir.ReadDir(-1) + closeErr := dir.Close() + return entries, errors.Join(readErr, closeErr) } +// Orphans returns the accounts whose managed drop-in or pending daemon reload +// outlived the account itself. A grant outlives its account only if something +// went wrong (a revoke run by an older binary that did not know about these +// files, or an account deleted out of band), and the exception can re-arm the +// moment the username is reused — so cleanup must be able to find it. func (m *Manager) Orphans(exists func(string) (bool, error)) ([]string, error) { - matches, err := filepath.Glob(filepath.Join(m.Dir, filePrefix+"*.conf")) + users, err := m.All() if err != nil { return nil, err } var orphans []string - for _, path := range matches { - user := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(path), filePrefix), ".conf") - if user != "" && validate.Username(user) { - live, err := exists(user) - if err != nil { - return nil, err - } - if !live { - orphans = append(orphans, user) - } + for _, user := range users { + live, err := exists(user) + if err != nil { + return nil, err + } + if !live { + orphans = append(orphans, user) } } return orphans, nil @@ -310,19 +472,32 @@ func (m *Manager) Orphans(exists func(string) (bool, error)) ([]string, error) { // concurrent invites are not independent: without this, one grant's reload could // push the other's not-yet-validated file live. // -// A host with no usable lock path still gets the feature, just unserialized — -// losing the ability to invite would be the worse failure. +// Lock acquisition fails closed. Continuing without serialization would let one +// caller reload another caller's not-yet-validated file and defeat the transaction +// this lock exists to protect. func (m *Manager) withLock(fn func() error) error { if m.Lock == "" { return fn() } - f, err := os.OpenFile(m.Lock, os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0o600) + f, err := os.OpenFile(m.Lock, os.O_CREATE|os.O_RDWR|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o600) if err != nil { - return fn() + return fmt.Errorf("open sshd transaction lock %s: %w", m.Lock, err) } defer f.Close() + fi, err := f.Stat() + if err != nil { + return fmt.Errorf("stat sshd transaction lock %s: %w", m.Lock, err) + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok || !fi.Mode().IsRegular() { + return fmt.Errorf("sshd transaction lock %s is not a regular file", m.Lock) + } + if int(st.Uid) != os.Geteuid() || int(st.Gid) != os.Getegid() || fi.Mode().Perm() != 0o600 { + return fmt.Errorf("sshd transaction lock %s has unsafe metadata: owner %d:%d mode %o, want %d:%d mode 600", + m.Lock, st.Uid, st.Gid, fi.Mode().Perm(), os.Geteuid(), os.Getegid()) + } if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { - return fn() + return fmt.Errorf("lock sshd transaction %s: %w", m.Lock, err) } defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }() return fn() @@ -404,6 +579,11 @@ func dropIn(user string, groups []string, report sysinfo.LoginReport) ([]byte, e } fmt.Fprintf(&b, " AllowGroups %s\n", g) } + // OpenSSH restores the caller's Match state after an Include directive, but + // files expanded by the same Include glob share state with each other. Since + // this file deliberately sorts first, leave the include stream in global scope + // so later drop-ins keep their intended host-wide meaning. + b.WriteString("Match all\n") return []byte(b.String()), nil } @@ -428,9 +608,9 @@ func unfixable(report sysinfo.LoginReport) []string { // sshdSyntaxCheck runs `sshd -t`, surfacing sshd's own complaint on failure. func sshdSyntaxCheck() error { - out, err := exec.Command("sshd", "-t").CombinedOutput() + out, err := executil.CombinedOutput("sshd", []string{"-t"}, sshdCheckOptions) if err != nil { - return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out))) + return fmt.Errorf("sshd -t: %w: %s", err, strings.TrimSpace(string(out))) } return nil } @@ -447,61 +627,202 @@ func reload() error { // The unit is "ssh" on Debian/Ubuntu and "sshd" on RHEL/Arch; one is usually // an alias of the other, so trying both is how we stay distro-neutral. for _, unit := range []string{"sshd", "ssh"} { - if exec.Command("systemctl", "reload", unit).Run() == nil { + if executil.Run("systemctl", []string{"reload", unit}, sshdReloadOptions) == nil { return nil } } } if _, err := exec.LookPath("rc-service"); err == nil { - if exec.Command("rc-service", "sshd", "reload").Run() == nil { + if executil.Run("rc-service", []string{"sshd", "reload"}, sshdReloadOptions) == nil { return nil } } if _, err := exec.LookPath("service"); err == nil { for _, unit := range []string{"sshd", "ssh"} { - if exec.Command("service", unit, "reload").Run() == nil { + if executil.Run("service", []string{unit, "reload"}, sshdReloadOptions) == nil { return nil } } } - if pid, ok := sshdPID(); ok { - return syscall.Kill(pid, syscall.SIGHUP) - } - return ErrNoReloadMechanism + return signalSSHDMaster() } -// sshdPID returns the master sshd's pid, but only after confirming the process -// it names really is sshd. -// -// SIGHUP's default action is to TERMINATE. A pid file outlives its process (that -// is precisely what "stale" means), and pids are recycled — so signalling the -// number on faith is not a no-op that might miss, it is a root-privileged kill -// aimed at whatever inherited the number. That risk lands exactly where this -// fallback is reached: hosts with no working init integration, which are the -// stripped-down images most likely to be carrying a stale pid file. -func sshdPID() (int, bool) { - for _, p := range []string{"/run/sshd.pid", "/var/run/sshd.pid"} { - b, err := os.ReadFile(p) +var ( + sshdPIDFiles = []string{"/run/sshd.pid", "/var/run/sshd.pid"} + sshdPIDOwnerUID = uint32(0) + sshdProcessUID = uint32(0) + sshdProcRoot = "/proc" + pidfdOpen = unix.PidfdOpen + pidfdSendSignal = unix.PidfdSendSignal + closeFD = unix.Close +) + +const maxSSHDMasterPIDBytes = int64(64) + +// signalSSHDMaster opens a pidfd, proves the referenced process is a current sshd +// listener from the pid file's generation, then sends SIGHUP through that +// descriptor. The descriptor keeps that identity stable during validation and +// signalling; stale pid files fail closed. +func signalSSHDMaster() error { + for _, p := range sshdPIDFiles { + pid, pidFileTime, err := readSSHDMasterPID(p) if err != nil { continue } - pid, err := strconv.Atoi(strings.TrimSpace(string(b))) - if err != nil || pid <= 0 { + fd, err := pidfdOpen(pid, 0) + if err == unix.ESRCH || err == unix.ENOENT { continue } - if !isSSHD(pid) { + if err != nil { + return fmt.Errorf("open pidfd for sshd pid %d: %w", pid, err) + } + if !isSSHDMaster(pid, pidFileTime) { + _ = closeFD(fd) continue } - return pid, true + signalErr := pidfdSendSignal(fd, unix.SIGHUP, nil, 0) + closeErr := closeFD(fd) + if signalErr == unix.ESRCH { + if closeErr != nil { + return fmt.Errorf("close pidfd for exited sshd pid %d: %w", pid, closeErr) + } + continue + } + if signalErr != nil { + return errors.Join(fmt.Errorf("signal sshd pid %d: %w", pid, signalErr), closeErr) + } + if closeErr != nil { + return fmt.Errorf("close pidfd for sshd pid %d: %w", pid, closeErr) + } + return nil } - return 0, false + return ErrNoReloadMechanism } -// isSSHD reports whether pid is a live process whose executable is sshd. -func isSSHD(pid int) bool { - comm, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) +// readSSHDMasterPID treats the runtime pid file as untrusted filesystem input. +// O_NONBLOCK prevents a planted FIFO from hanging a privileged reload forever; +// the descriptor checks and hard read limit reject every non-regular, writable, +// symlinked, or oversized substitute before its content is parsed. +func readSSHDMasterPID(path string) (int, time.Time, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) if err != nil { - return false // no such process, or no /proc: signalling it is not safe + return 0, time.Time{}, err + } + f := os.NewFile(uintptr(fd), path) + if f == nil { + _ = unix.Close(fd) + return 0, time.Time{}, fmt.Errorf("open sshd pid file %s", path) + } + defer f.Close() + + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return 0, time.Time{}, fmt.Errorf("stat sshd pid file %s: %w", path, err) + } + if stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Uid != sshdPIDOwnerUID || stat.Mode&0o022 != 0 { + return 0, time.Time{}, fmt.Errorf("sshd pid file %s has unsafe metadata", path) + } + if stat.Size > maxSSHDMasterPIDBytes { + return 0, time.Time{}, fmt.Errorf("sshd pid file %s exceeds %d-byte limit", path, maxSSHDMasterPIDBytes) + } + b, err := io.ReadAll(io.LimitReader(f, maxSSHDMasterPIDBytes+1)) + if err != nil { + return 0, time.Time{}, fmt.Errorf("read sshd pid file %s: %w", path, err) + } + if int64(len(b)) > maxSSHDMasterPIDBytes { + return 0, time.Time{}, fmt.Errorf("sshd pid file %s exceeds %d-byte limit", path, maxSSHDMasterPIDBytes) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(b))) + if err != nil || pid <= 0 { + return 0, time.Time{}, fmt.Errorf("sshd pid file %s has invalid pid", path) + } + return pid, time.Unix(stat.Mtim.Sec, stat.Mtim.Nsec), nil +} + +// isSSHDMaster proves that pid is a root sshd listener from the same process +// generation that wrote the pid file. A pidfd prevents reuse after it is opened; +// this timestamp check closes the remaining stale-pidfile window before open. +func isSSHDMaster(pid int, pidFileTime time.Time) bool { + procDir := filepath.Join(sshdProcRoot, strconv.Itoa(pid)) + var stat unix.Stat_t + if err := unix.Stat(procDir, &stat); err != nil || stat.Mode&unix.S_IFMT != unix.S_IFDIR || stat.Uid != sshdProcessUID { + return false + } + comm, err := readBoundedSSHDProcFile(filepath.Join(procDir, "comm"), 64) + if err != nil || strings.TrimSpace(string(comm)) != "sshd" { + return false + } + cmdline, err := readBoundedSSHDProcFile(filepath.Join(procDir, "cmdline"), 4<<10) + if err != nil || !strings.Contains(string(cmdline), "[listener]") { + return false + } + started, err := sshdProcessStartTime(pid) + if err != nil { + return false + } + // btime has one-second precision. Comparing whole seconds accepts a genuine + // pid file written in the same second as process start while rejecting a stale + // file from every earlier process generation. + return pidFileTime.Unix() >= started.Unix() +} + +func readBoundedSSHDProcFile(path string, maxBytes int64) ([]byte, error) { + f, err := os.OpenFile(path, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > maxBytes { + return nil, fmt.Errorf("%s exceeds %d-byte limit", path, maxBytes) + } + return b, nil +} + +func sshdProcessStartTime(pid int) (time.Time, error) { + data, err := readBoundedSSHDProcFile(filepath.Join(sshdProcRoot, strconv.Itoa(pid), "stat"), 4<<10) + if err != nil { + return time.Time{}, err + } + // comm is parenthesized and may itself contain spaces or ')', so split after + // the final closing parenthesis. starttime is field 22, index 19 from state. + closeParen := strings.LastIndexByte(string(data), ')') + if closeParen < 0 { + return time.Time{}, fmt.Errorf("malformed process stat") + } + fields := strings.Fields(string(data[closeParen+1:])) + if len(fields) <= 19 { + return time.Time{}, fmt.Errorf("process stat has too few fields") + } + startTicks, err := strconv.ParseUint(fields[19], 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("malformed process start time: %w", err) + } + procStat, err := readBoundedSSHDProcFile(filepath.Join(sshdProcRoot, "stat"), 1<<20) + if err != nil { + return time.Time{}, err + } + var bootSeconds int64 = -1 + for _, line := range strings.Split(string(procStat), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[0] == "btime" { + bootSeconds, err = strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("malformed kernel boot time: %w", err) + } + break + } + } + if bootSeconds < 0 { + return time.Time{}, fmt.Errorf("kernel stat has no boot time") } - return strings.TrimSpace(string(comm)) == "sshd" + // Linux exposes process starttime in USER_HZ ticks. The supported Linux + // amd64/arm64 ABIs both define USER_HZ as 100 regardless of CONFIG_HZ. + const linuxUserHZ = uint64(100) + seconds := startTicks / linuxUserHZ + nanos := (startTicks % linuxUserHZ) * uint64(time.Second) / linuxUserHZ + return time.Unix(bootSeconds, 0).Add(time.Duration(seconds)*time.Second + time.Duration(nanos)), nil } diff --git a/internal/sshdconf/sshdconf_root_test.go b/internal/sshdconf/sshdconf_root_test.go index cab6999..07421c8 100644 --- a/internal/sshdconf/sshdconf_root_test.go +++ b/internal/sshdconf/sshdconf_root_test.go @@ -3,8 +3,10 @@ package sshdconf import ( + "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -75,6 +77,38 @@ func TestGrantWritesProvesAndReloads(t *testing.T) { } } +func TestDropInRestoresScopeForLaterIncludedFiles(t *testing.T) { + sshd, err := exec.LookPath("sshd") + if err != nil { + t.Skip("sshd is unavailable") + } + dir := rootDir(t) + body, err := dropIn(acct, []string{acct}, report(blocked)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "10-managed.conf"), body, 0o644); err != nil { + t.Fatal(err) + } + // This is a normal global hardening directive in the next file expanded by + // the same Include glob. Without the managed file's final Match all, OpenSSH + // treats it as part of Match User above and other accounts keep the default. + if err := os.WriteFile(filepath.Join(dir, "20-hardening.conf"), []byte("PasswordAuthentication no\n"), 0o644); err != nil { + t.Fatal(err) + } + main := filepath.Join(dir, "sshd_config") + if err := os.WriteFile(main, []byte("Include "+dir+"/*.conf\n"), 0o600); err != nil { + t.Fatal(err) + } + out, err := exec.Command(sshd, "-T", "-f", main, "-C", "user=xxvcc-other,host=localhost,addr=127.0.0.1").CombinedOutput() + if err != nil { + t.Fatalf("sshd -T: %v: %s", err, strings.TrimSpace(string(out))) + } + if got := sysinfo.ParseSSHD(string(out)).First("passwordauthentication"); got != "no" { + t.Fatalf("later global drop-in was captured by the managed Match block: PasswordAuthentication=%q, want no", got) + } +} + func TestRemoveNeverReloadsOntoABrokenConfig(t *testing.T) { // THE 3am SCENARIO. An operator edits /etc/ssh/sshd_config at 14:00, leaves a // typo, and never reloads: the running sshd is still serving its old, good @@ -106,6 +140,61 @@ func TestRemoveNeverReloadsOntoABrokenConfig(t *testing.T) { if _, err := os.Lstat(res.Path); !os.IsNotExist(err) { t.Error("Remove left the sshd exception on disk") } + pending := res.Path + removePendingSuffix + if _, err := os.Lstat(pending); err != nil { + t.Fatalf("Remove did not retain pending reload state: %v", err) + } + + // Once the host config is repaired, a fresh process must discover the pending + // state even though the original drop-in is already gone, then finish the + // validate/reload transaction. + m.Validate = func() error { return nil } + if err := m.Remove(acct); err != nil { + t.Fatalf("Remove retry: %v", err) + } + if reloads != 1 { + t.Fatalf("Remove retry reloads = %d, want 1", reloads) + } + if _, err := os.Lstat(pending); !os.IsNotExist(err) { + t.Fatalf("successful Remove retry left pending state: %v", err) + } +} + +func TestRemoveRetriesAfterReloadFailure(t *testing.T) { + reloads := 0 + m := okManager(t, &reloads) + res, err := m.Grant(acct, []string{acct}, report(blocked)) + if err != nil { + t.Fatalf("Grant: %v", err) + } + reloads = 0 + m.Reload = func() error { + reloads++ + if reloads == 1 { + return fmt.Errorf("systemctl reload failed") + } + return nil + } + + if err := m.Remove(acct); err == nil { + t.Fatal("first Remove succeeded despite reload failure") + } + if _, err := os.Lstat(res.Path); !os.IsNotExist(err) { + t.Fatalf("first Remove left the drop-in active: %v", err) + } + pending := res.Path + removePendingSuffix + if _, err := os.Lstat(pending); err != nil { + t.Fatalf("first Remove did not retain pending reload state: %v", err) + } + if err := m.Remove(acct); err != nil { + t.Fatalf("Remove retry: %v", err) + } + if reloads != 2 { + t.Fatalf("reload calls = %d, want retry to reload again", reloads) + } + if _, err := os.Lstat(pending); !os.IsNotExist(err) { + t.Fatalf("successful Remove retry left pending state: %v", err) + } } func TestGrantKeepsTheFileButDoesNotClaimVerifiedWhenNothingCouldBeReloaded(t *testing.T) { @@ -127,10 +216,21 @@ func TestGrantKeepsTheFileButDoesNotClaimVerifiedWhenNothingCouldBeReloaded(t *t if _, err := os.Lstat(res.Path); err != nil { t.Errorf("the proved drop-in should be kept: %v", err) } - // Remove tolerates the same sentinel: there is nothing to reload on the way out - // either, and that is not a failure. + // Removal cannot make the same assumption: failure to find a reload mechanism + // does not prove that no manually-started daemon still holds the Match block. + if err := m.Remove(acct); err == nil || !errors.Is(err, ErrNoReloadMechanism) { + t.Fatalf("Remove error = %v, want an unconfirmed-removal failure", err) + } + pending := res.Path + removePendingSuffix + if _, err := os.Lstat(pending); err != nil { + t.Fatalf("unconfirmed Remove did not retain pending state: %v", err) + } + m.Reload = func() error { return nil } if err := m.Remove(acct); err != nil { - t.Errorf("Remove: %v", err) + t.Fatalf("confirmed retry Remove: %v", err) + } + if _, err := os.Lstat(pending); !os.IsNotExist(err) { + t.Fatalf("confirmed retry left pending state: %v", err) } } @@ -195,16 +295,36 @@ func TestGrantRollsBackWhenSSHDRejectsTheConfigItProduced(t *testing.T) { } } -func TestGrantRollsBackWhenTheReloadFails(t *testing.T) { +func TestGrantKeepsPendingWhenBothReloadAttemptsFail(t *testing.T) { reloads := 0 m := okManager(t, &reloads) - m.Reload = func() error { return fmt.Errorf("systemctl: Job for ssh.service failed") } + m.Reload = func() error { + reloads++ + return fmt.Errorf("systemctl: Job for ssh.service failed") + } if _, err := m.Grant(acct, []string{acct}, report(blocked)); err == nil { t.Fatal("Grant must fail when sshd cannot be reloaded: the invite would not work yet") } - if ents, _ := os.ReadDir(m.Dir); len(ents) != 0 { - t.Errorf("a grant whose reload failed left the drop-in behind: %v", ents) + if reloads != 2 { + t.Fatalf("reload attempts = %d, want initial reload plus rollback reload", reloads) + } + if _, err := os.Lstat(m.FilePath(acct)); !os.IsNotExist(err) { + t.Fatalf("failed Grant left its active drop-in: %v", err) + } + pending := m.FilePath(acct) + removePendingSuffix + if _, err := os.Lstat(pending); err != nil { + t.Fatalf("two failed reloads lost retry state: %v", err) + } + m.Reload = func() error { reloads++; return nil } + if err := m.Remove(acct); err != nil { + t.Fatalf("Remove retry: %v", err) + } + if reloads != 3 { + t.Fatalf("reload attempts after retry = %d, want 3", reloads) + } + if _, err := os.Lstat(pending); !os.IsNotExist(err) { + t.Fatalf("successful retry left pending state: %v", err) } } @@ -221,6 +341,42 @@ func TestGrantReportsRollbackRemovalFailure(t *testing.T) { if _, statErr := os.Lstat(m.FilePath(acct)); statErr != nil { t.Fatalf("fixture did not leave the unremovable drop-in behind: %v", statErr) } + if _, statErr := os.Lstat(m.FilePath(acct) + removePendingSuffix); statErr != nil { + t.Fatalf("rollback removal failure did not retain pending state: %v", statErr) + } +} + +func TestRemoveRejectsUnsafePendingMarker(t *testing.T) { + tests := []struct { + name string + plant func(string) error + }{ + {"directory", func(path string) error { return os.Mkdir(path, 0o700) }}, + {"wrong mode", func(path string) error { return os.WriteFile(path, nil, 0o644) }}, + {"nonempty", func(path string) error { return os.WriteFile(path, []byte("Match all\n"), 0o600) }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := rootDir(t) + m := &Manager{ + Dir: dir, + Validate: func() error { t.Fatal("unsafe marker reached validation"); return nil }, + Reload: func() error { t.Fatal("unsafe marker reached reload"); return nil }, + } + pending := m.FilePath(acct) + removePendingSuffix + if err := tc.plant(pending); err != nil { + t.Fatal(err) + } + if tc.name == "wrong mode" { + if err := os.Chmod(pending, 0o644); err != nil { + t.Fatal(err) + } + } + if err := m.Remove(acct); err == nil { + t.Fatal("Remove accepted an unsafe pending marker") + } + }) + } } func TestRemoveOnlyEverTouchesItsOwnFile(t *testing.T) { diff --git a/internal/sshdconf/sshdconf_test.go b/internal/sshdconf/sshdconf_test.go index 9009536..2c48a85 100644 --- a/internal/sshdconf/sshdconf_test.go +++ b/internal/sshdconf/sshdconf_test.go @@ -1,16 +1,322 @@ package sshdconf import ( + "context" + "errors" "os" "path/filepath" + "strconv" "strings" + "syscall" "testing" + "time" + "github.com/xxvcc/linux-temp-admin/internal/executil" "github.com/xxvcc/linux-temp-admin/internal/sysinfo" + "golang.org/x/sys/unix" ) const acct = "xxvcc-a1b2c3" +func writeSSHDCommand(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } +} + +func writeFakeSSHDProcess(t *testing.T, procRoot string, pid int, listener bool, bootSeconds int64, startTicks uint64) { + t.Helper() + procDir := filepath.Join(procRoot, strconv.Itoa(pid)) + if err := os.MkdirAll(procDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(procDir, "comm"), []byte("sshd\n"), 0o600); err != nil { + t.Fatal(err) + } + cmdline := "sshd: test [priv]\x00" + if listener { + cmdline = "sshd: /usr/sbin/sshd -D [listener]\x00" + } + if err := os.WriteFile(filepath.Join(procDir, "cmdline"), []byte(cmdline), 0o600); err != nil { + t.Fatal(err) + } + fields := make([]string, 20) + for i := range fields { + fields[i] = "0" + } + fields[0] = "S" + fields[1] = "1" + fields[2] = strconv.Itoa(pid) + fields[3] = strconv.Itoa(pid) + fields[19] = strconv.FormatUint(startTicks, 10) + processStat := strconv.Itoa(pid) + " (sshd) " + strings.Join(fields, " ") + "\n" + if err := os.WriteFile(filepath.Join(procDir, "stat"), []byte(processStat), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(procRoot, "stat"), []byte("cpu 1 2 3 4\nbtime "+strconv.FormatInt(bootSeconds, 10)+"\n"), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestSSHDHelpersAreBoundedAndUseCLocale(t *testing.T) { + oldCheck, oldReload := sshdCheckOptions, sshdReloadOptions + oldPIDFiles := sshdPIDFiles + t.Cleanup(func() { + sshdCheckOptions, sshdReloadOptions = oldCheck, oldReload + sshdPIDFiles = oldPIDFiles + }) + + t.Run("syntax locale", func(t *testing.T) { + sshdCheckOptions = oldCheck + dir := t.TempDir() + writeSSHDCommand(t, dir, "sshd", `[ "$LC_ALL:$LANG:$1" = "C:C:-t" ]`) + t.Setenv("PATH", dir) + if err := sshdSyntaxCheck(); err != nil { + t.Fatalf("sshdSyntaxCheck did not force the C locale/expected argv: %v", err) + } + }) + + t.Run("syntax timeout", func(t *testing.T) { + dir := t.TempDir() + writeSSHDCommand(t, dir, "sshd", `/bin/sleep 30 & wait`) + t.Setenv("PATH", dir) + opts := oldCheck + opts.Timeout = 50 * time.Millisecond + sshdCheckOptions = opts + if err := sshdSyntaxCheck(); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("sshdSyntaxCheck error = %v, want timeout", err) + } + }) + + t.Run("syntax output limit", func(t *testing.T) { + dir := t.TempDir() + writeSSHDCommand(t, dir, "sshd", `while :; do printf 0123456789abcdef; done`) + t.Setenv("PATH", dir) + opts := oldCheck + opts.Timeout = time.Second + opts.MaxOutput = 64 + sshdCheckOptions = opts + if err := sshdSyntaxCheck(); !errors.Is(err, executil.ErrOutputLimit) { + t.Fatalf("sshdSyntaxCheck error = %v, want output limit", err) + } + }) + + t.Run("reload locale", func(t *testing.T) { + sshdReloadOptions = oldReload + dir := t.TempDir() + writeSSHDCommand(t, dir, "systemctl", `[ "$LC_ALL:$LANG:$1:$2" = "C:C:reload:sshd" ]`) + t.Setenv("PATH", dir) + if err := reload(); err != nil { + t.Fatalf("reload did not force the C locale/expected argv: %v", err) + } + }) + + t.Run("reload timeout is bounded", func(t *testing.T) { + dir := t.TempDir() + writeSSHDCommand(t, dir, "systemctl", `/bin/sleep 30 & wait`) + t.Setenv("PATH", dir) + opts := oldReload + opts.Timeout = 50 * time.Millisecond + sshdReloadOptions = opts + sshdPIDFiles = nil + start := time.Now() + err := reload() + if !errors.Is(err, ErrNoReloadMechanism) || time.Since(start) > 2*time.Second { + t.Fatalf("reload error = %v after %s, want bounded fallback", err, time.Since(start)) + } + }) +} + +func TestSignalSSHDUsesPidfd(t *testing.T) { + root := t.TempDir() + pidFile := filepath.Join(root, "sshd.pid") + if err := os.WriteFile(pidFile, []byte("42\n"), 0o600); err != nil { + t.Fatal(err) + } + procRoot := filepath.Join(root, "proc") + writeFakeSSHDProcess(t, procRoot, 42, true, 1, 100) + oldFiles, oldOwner, oldProcessUID, oldProc := sshdPIDFiles, sshdPIDOwnerUID, sshdProcessUID, sshdProcRoot + oldOpen, oldSend, oldClose := pidfdOpen, pidfdSendSignal, closeFD + sshdPIDFiles = []string{pidFile} + sshdPIDOwnerUID = uint32(os.Geteuid()) + sshdProcessUID = uint32(os.Geteuid()) + sshdProcRoot = procRoot + opened := 0 + pidfdOpen = func(pid, flags int) (int, error) { + opened++ + if pid != 42 || flags != 0 { + t.Fatalf("PidfdOpen(%d, %d)", pid, flags) + } + return 99, nil + } + pidfdSendSignal = func(fd int, sig unix.Signal, _ *unix.Siginfo, flags int) error { + if fd != 99 || sig != unix.SIGHUP || flags != 0 { + t.Fatalf("PidfdSendSignal(%d, %v, flags=%d)", fd, sig, flags) + } + return nil + } + closed := 0 + closeFD = func(fd int) error { closed++; return nil } + t.Cleanup(func() { + sshdPIDFiles, sshdPIDOwnerUID, sshdProcessUID, sshdProcRoot = oldFiles, oldOwner, oldProcessUID, oldProc + pidfdOpen, pidfdSendSignal, closeFD = oldOpen, oldSend, oldClose + }) + if err := signalSSHDMaster(); err != nil { + t.Fatal(err) + } + if opened != 1 || closed != 1 { + t.Fatalf("pidfd opened=%d closed=%d, want 1/1", opened, closed) + } +} + +func TestSignalSSHDPropagatesPidfdFailure(t *testing.T) { + root := t.TempDir() + pidFile := filepath.Join(root, "sshd.pid") + if err := os.WriteFile(pidFile, []byte("42\n"), 0o600); err != nil { + t.Fatal(err) + } + procRoot := filepath.Join(root, "proc") + writeFakeSSHDProcess(t, procRoot, 42, true, 1, 100) + oldFiles, oldOwner, oldProcessUID, oldProc, oldOpen := sshdPIDFiles, sshdPIDOwnerUID, sshdProcessUID, sshdProcRoot, pidfdOpen + sshdPIDFiles = []string{pidFile} + sshdPIDOwnerUID = uint32(os.Geteuid()) + sshdProcessUID = uint32(os.Geteuid()) + sshdProcRoot = procRoot + pidfdOpen = func(int, int) (int, error) { return -1, syscall.EPERM } + t.Cleanup(func() { + sshdPIDFiles, sshdPIDOwnerUID, sshdProcessUID, sshdProcRoot, pidfdOpen = oldFiles, oldOwner, oldProcessUID, oldProc, oldOpen + }) + if err := signalSSHDMaster(); err == nil || !errors.Is(err, syscall.EPERM) { + t.Fatalf("signalSSHDMaster error = %v, want pidfd EPERM", err) + } +} + +func TestReadSSHDMasterPIDRejectsSpecialAndUnsafeFilesWithoutBlocking(t *testing.T) { + dir := t.TempDir() + oldOwner := sshdPIDOwnerUID + sshdPIDOwnerUID = uint32(os.Geteuid()) + t.Cleanup(func() { sshdPIDOwnerUID = oldOwner }) + + valid := filepath.Join(dir, "valid.pid") + if err := os.WriteFile(valid, []byte("42\n"), 0o600); err != nil { + t.Fatal(err) + } + if pid, _, err := readSSHDMasterPID(valid); err != nil || pid != 42 { + t.Fatalf("valid pid = %d, %v; want 42", pid, err) + } + + link := filepath.Join(dir, "link.pid") + if err := os.Symlink(valid, link); err != nil { + t.Fatal(err) + } + if _, _, err := readSSHDMasterPID(link); err == nil { + t.Fatal("symlinked pid file was accepted") + } + + fifo := filepath.Join(dir, "fifo.pid") + if err := unix.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + start := time.Now() + if _, _, err := readSSHDMasterPID(fifo); err == nil { + t.Fatal("FIFO pid file was accepted") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("FIFO pid file blocked for %s", elapsed) + } + + oversized := filepath.Join(dir, "oversized.pid") + if err := os.WriteFile(oversized, []byte(strings.Repeat("1", int(maxSSHDMasterPIDBytes)+1)), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := readSSHDMasterPID(oversized); err == nil || !strings.Contains(err.Error(), "byte limit") { + t.Fatalf("oversized pid error = %v, want bounded-read refusal", err) + } + + unsafeMode := filepath.Join(dir, "writable.pid") + if err := os.WriteFile(unsafeMode, []byte("42\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(unsafeMode, 0o666); err != nil { + t.Fatal(err) + } + if _, _, err := readSSHDMasterPID(unsafeMode); err == nil || !strings.Contains(err.Error(), "unsafe metadata") { + t.Fatalf("writable pid error = %v, want metadata refusal", err) + } + + sshdPIDOwnerUID++ + if _, _, err := readSSHDMasterPID(valid); err == nil || !strings.Contains(err.Error(), "unsafe metadata") { + t.Fatalf("foreign-owned pid error = %v, want metadata refusal", err) + } +} + +func TestSSHDMasterIdentityRejectsStalePIDAndSessionChild(t *testing.T) { + procRoot := filepath.Join(t.TempDir(), "proc") + const ( + pid = 42 + bootSeconds = int64(1_000) + startTicks = uint64(250) // process start = 1002.5, exposed as Unix second 1002 + ) + oldRoot, oldUID := sshdProcRoot, sshdProcessUID + sshdProcRoot = procRoot + sshdProcessUID = uint32(os.Geteuid()) + t.Cleanup(func() { sshdProcRoot, sshdProcessUID = oldRoot, oldUID }) + + writeFakeSSHDProcess(t, procRoot, pid, true, bootSeconds, startTicks) + if isSSHDMaster(pid, time.Unix(1_001, 999_999_999)) { + t.Fatal("listener was accepted through a pid file from an older process generation") + } + if !isSSHDMaster(pid, time.Unix(1_002, 500_000_000)) { + t.Fatal("current-generation root sshd listener was rejected") + } + + writeFakeSSHDProcess(t, procRoot, pid, false, bootSeconds, startTicks) + if isSSHDMaster(pid, time.Unix(1_003, 0)) { + t.Fatal("sshd session/priv child was accepted as the reload master") + } +} + +func TestSignalSSHDDoesNotHUPStalePIDReuse(t *testing.T) { + root := t.TempDir() + pidFile := filepath.Join(root, "sshd.pid") + if err := os.WriteFile(pidFile, []byte("42\n"), 0o600); err != nil { + t.Fatal(err) + } + // The pid file predates the process that currently occupies PID 42. + if err := os.Chtimes(pidFile, time.Unix(1_001, 0), time.Unix(1_001, 0)); err != nil { + t.Fatal(err) + } + procRoot := filepath.Join(root, "proc") + writeFakeSSHDProcess(t, procRoot, 42, true, 1_000, 250) + + oldFiles, oldPIDOwner, oldProcessUID, oldProc := sshdPIDFiles, sshdPIDOwnerUID, sshdProcessUID, sshdProcRoot + oldOpen, oldSend, oldClose := pidfdOpen, pidfdSendSignal, closeFD + sshdPIDFiles = []string{pidFile} + sshdPIDOwnerUID = uint32(os.Geteuid()) + sshdProcessUID = uint32(os.Geteuid()) + sshdProcRoot = procRoot + pidfdOpen = func(int, int) (int, error) { return 99, nil } + sent := 0 + pidfdSendSignal = func(int, unix.Signal, *unix.Siginfo, int) error { + sent++ + return nil + } + closed := 0 + closeFD = func(int) error { closed++; return nil } + t.Cleanup(func() { + sshdPIDFiles, sshdPIDOwnerUID, sshdProcessUID, sshdProcRoot = oldFiles, oldPIDOwner, oldProcessUID, oldProc + pidfdOpen, pidfdSendSignal, closeFD = oldOpen, oldSend, oldClose + }) + + if err := signalSSHDMaster(); !errors.Is(err, ErrNoReloadMechanism) { + t.Fatalf("stale pid fallback error = %v, want no reload mechanism", err) + } + if sent != 0 || closed != 1 { + t.Fatalf("stale pid sent=%d closed=%d, want no signal and one close", sent, closed) + } +} + // report builds a LoginReport carrying exactly the given blockers, the way // CheckKeyLogin would. func report(config string) sysinfo.LoginReport { @@ -26,6 +332,9 @@ func TestDropInIsScopedToTheAccount(t *testing.T) { if !strings.Contains(got, "Match User "+acct+"\n") { t.Errorf("no Match block for the account:\n%s", got) } + if !strings.HasSuffix(got, "Match all\n") { + t.Errorf("drop-in does not restore global scope for later Include files:\n%s", got) + } // Every directive must sit inside the Match block. A single line above it // would silently become global policy for every account on the host -- the one // outcome this whole design exists to prevent. @@ -48,6 +357,35 @@ func TestDropInIsScopedToTheAccount(t *testing.T) { } } +func TestWithLockFailsClosed(t *testing.T) { + called := false + m := &Manager{Lock: filepath.Join(t.TempDir(), "missing", "sshd.lock")} + if err := m.withLock(func() error { called = true; return nil }); err == nil { + t.Fatal("withLock ignored a lock-open failure") + } + if called { + t.Fatal("withLock ran the sshd transaction without acquiring its lock") + } +} + +func TestWithLockRejectsUnsafeMetadata(t *testing.T) { + lock := filepath.Join(t.TempDir(), "sshd.lock") + if err := os.WriteFile(lock, nil, 0o666); err != nil { + t.Fatal(err) + } + if err := os.Chmod(lock, 0o666); err != nil { + t.Fatal(err) + } + called := false + m := &Manager{Lock: lock} + if err := m.withLock(func() error { called = true; return nil }); err == nil { + t.Fatal("withLock accepted a group/world-writable lock") + } + if called { + t.Fatal("withLock ran the sshd transaction with an unsafe lock") + } +} + func TestDropInLiftsOnlyWhatBlocks(t *testing.T) { tests := []struct { name string @@ -172,12 +510,21 @@ func TestGrantAndRemoveRefuseInvalidNames(t *testing.T) { func TestRemoveWithNoDropIn(t *testing.T) { // revoke calls this for every account, including the ones that never needed an - // sshd exception. It must be a silent no-op -- and must not reload sshd. + // sshd exception. It must be a silent no-op -- and must neither validate nor + // reload sshd. + validations := 0 reloads := 0 - m := &Manager{Dir: t.TempDir(), Reload: func() error { reloads++; return nil }} + m := &Manager{ + Dir: t.TempDir(), + Validate: func() error { validations++; return nil }, + Reload: func() error { reloads++; return nil }, + } if err := m.Remove(acct); err != nil { t.Fatalf("Remove on an account with no drop-in: %v", err) } + if validations != 0 { + t.Error("Remove validated sshd although it had no removal to finish") + } if reloads != 0 { t.Error("Remove reloaded sshd although it had nothing to remove") } @@ -186,7 +533,9 @@ func TestRemoveWithNoDropIn(t *testing.T) { func TestOrphansFindsExceptionsWhoseAccountIsGone(t *testing.T) { dir := t.TempDir() for _, name := range []string{ - filePrefix + "xxvcc-gone.conf", // ours, account deleted -> orphan + filePrefix + "xxvcc-gone.conf", // ours, account deleted -> orphan + filePrefix + "xxvcc-gone.conf" + removePendingSuffix, // duplicate pending state -> one result + filePrefix + "xxvcc-pending.conf" + removePendingSuffix, filePrefix + "xxvcc-alive.conf", // ours, account exists -> not an orphan "99-somebody-else.conf", // not ours: never touch it } { @@ -199,7 +548,62 @@ func TestOrphansFindsExceptionsWhoseAccountIsGone(t *testing.T) { if err != nil { t.Fatal(err) } - if len(orphans) != 1 || orphans[0] != "xxvcc-gone" { - t.Fatalf("orphans = %v, want [xxvcc-gone]", orphans) + if len(orphans) != 2 || orphans[0] != "xxvcc-gone" || orphans[1] != "xxvcc-pending" { + t.Fatalf("orphans = %v, want [xxvcc-gone xxvcc-pending]", orphans) + } +} + +func TestAllPropagatesDirectoryReadFailure(t *testing.T) { + want := syscall.EACCES + oldReadDir := readDir + readDir = func(string) ([]os.DirEntry, error) { return nil, want } + t.Cleanup(func() { readDir = oldReadDir }) + + if _, err := (&Manager{Dir: "/unreadable"}).All(); !errors.Is(err, want) { + t.Fatalf("All error = %v, want EACCES", err) + } +} + +func TestAllRejectsNonDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + if _, err := (&Manager{Dir: path}).All(); err == nil || !errors.Is(err, syscall.ENOTDIR) { + t.Fatalf("All error = %v, want ENOTDIR", err) + } +} + +func TestAllRejectsSymlinkedDirectory(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := (&Manager{Dir: link}).All(); err == nil { + t.Fatal("All followed a symlinked sshd directory") + } +} + +func TestAllAllowsAbsentDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "absent") + users, err := (&Manager{Dir: dir}).All() + if err != nil || len(users) != 0 { + t.Fatalf("All on absent directory = %v, %v; want empty success", users, err) + } +} + +func TestAllRejectsMalformedManagedArtifact(t *testing.T) { + dir := t.TempDir() + name := filePrefix + "not.valid.conf" + if err := os.WriteFile(filepath.Join(dir, name), nil, 0o600); err != nil { + t.Fatal(err) + } + if _, err := (&Manager{Dir: dir}).All(); err == nil || !strings.Contains(err.Error(), name) { + t.Fatalf("All error = %v, want malformed managed artifact", err) } } diff --git a/internal/sshkey/sshkey.go b/internal/sshkey/sshkey.go index 3815bb5..7f5ae3f 100644 --- a/internal/sshkey/sshkey.go +++ b/internal/sshkey/sshkey.go @@ -13,10 +13,11 @@ import ( "os" "path/filepath" "strings" - "syscall" "github.com/xxvcc/linux-temp-admin/internal/fsutil" + "github.com/xxvcc/linux-temp-admin/internal/validate" "golang.org/x/crypto/ssh" + "golang.org/x/sys/unix" ) // KeyPair is a generated one-time ed25519 keypair. @@ -33,10 +34,12 @@ func GenerateEd25519(comment string) (*KeyPair, error) { if err != nil { return nil, fmt.Errorf("generate ed25519 key: %w", err) } + defer clear(priv) block, err := ssh.MarshalPrivateKey(priv, comment) if err != nil { return nil, fmt.Errorf("marshal private key: %w", err) } + defer clear(block.Bytes) sshPub, err := ssh.NewPublicKey(pub) if err != nil { return nil, fmt.Errorf("wrap public key: %w", err) @@ -45,8 +48,12 @@ func GenerateEd25519(comment string) (*KeyPair, error) { if comment != "" { authLine += " " + comment } + privatePEM := pem.EncodeToMemory(block) + if privatePEM == nil { + return nil, fmt.Errorf("encode OpenSSH private key") + } return &KeyPair{ - PrivatePEM: pem.EncodeToMemory(block), + PrivatePEM: privatePEM, AuthorizedKey: []byte(authLine + "\n"), Fingerprint: ssh.FingerprintSHA256(sshPub), }, nil @@ -56,47 +63,121 @@ func GenerateEd25519(comment string) (*KeyPair, error) { // authorizedKey to .ssh/authorized_keys (0600, owned by uid:gid), refusing any // symlinked component and never following one. func WriteAuthorizedKeys(homeDir string, uid, gid int, authorizedKey []byte) error { - if uid < 1 || gid < 1 { + if !validate.AccountID(uid) || !validate.AccountID(gid) { return fmt.Errorf("refusing non-user uid/gid %d:%d", uid, gid) } + uid64 := int64(uid) + gid64 := int64(gid) if !filepath.IsAbs(homeDir) || filepath.Clean(homeDir) == "/" { return fmt.Errorf("home directory %s is not a safe absolute user home", homeDir) } - fi, err := os.Lstat(homeDir) + homeFD, err := unix.Open(homeDir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if err != nil { - return fmt.Errorf("home directory: %w", err) + return fmt.Errorf("open home directory: %w", err) } - if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { - return fmt.Errorf("home directory %s is not a safe directory", homeDir) + home := os.NewFile(uintptr(homeFD), homeDir) + defer home.Close() + var homeStat unix.Stat_t + if err := unix.Fstat(homeFD, &homeStat); err != nil { + return fmt.Errorf("stat home directory: %w", err) } - // The home must belong exactly to the account; refuse to write into a dir - // owned by anyone else, so a hijacked home can't redirect the key write. Fail - // closed if ownership can't be determined (mirrors fsutil's stat handling). - st, ok := fi.Sys().(*syscall.Stat_t) - if !ok { - return fmt.Errorf("cannot determine owner of home directory %s", homeDir) + if homeStat.Mode&unix.S_IFMT != unix.S_IFDIR || int64(homeStat.Uid) != uid64 { + return fmt.Errorf("home directory %s is not a safe account-owned directory", homeDir) } - if st.Uid != uint32(uid) { - return fmt.Errorf("home directory %s is not owned by the account", homeDir) + + createdSSHDir := false + if err := unix.Mkdirat(homeFD, ".ssh", 0o700); err == nil { + createdSSHDir = true + } else if err != unix.EEXIST { + return fmt.Errorf("create .ssh: %w", err) } - sshDir := filepath.Join(homeDir, ".ssh") - if fi, err := os.Lstat(sshDir); err == nil { - if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { - return fmt.Errorf("%s is not a safe directory", sshDir) - } - } else if !os.IsNotExist(err) { - return err + sshFD, err := unix.Openat(homeFD, ".ssh", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return fmt.Errorf("open .ssh without following links: %w", err) } - if err := fsutil.EnsureDir(sshDir, 0o700, uid, gid); err != nil { - return fmt.Errorf("create .ssh: %w", err) + sshDir := os.NewFile(uintptr(sshFD), filepath.Join(homeDir, ".ssh")) + defer sshDir.Close() + var sshStat unix.Stat_t + if err := unix.Fstat(sshFD, &sshStat); err != nil { + return fmt.Errorf("stat .ssh: %w", err) + } + if !safeInitialSSHDirectory(sshStat, createdSSHDir, uid64) { + return fmt.Errorf("%s is not a safe account-owned directory", sshDir.Name()) } - authFile := filepath.Join(sshDir, "authorized_keys") - if fi, err := os.Lstat(authFile); err == nil { - if fi.Mode()&os.ModeSymlink != 0 || !fi.Mode().IsRegular() { - return fmt.Errorf("%s is not a safe regular file", authFile) + if err := sshDir.Chown(uid, gid); err != nil { + return fmt.Errorf("set .ssh owner: %w", err) + } + if err := sshDir.Chmod(0o700); err != nil { + return fmt.Errorf("set .ssh mode: %w", err) + } + if err := unix.Fstat(sshFD, &sshStat); err != nil { + return fmt.Errorf("verify .ssh metadata: %w", err) + } + if int64(sshStat.Uid) != uid64 || int64(sshStat.Gid) != gid64 || sshStat.Mode&0o7777 != 0o700 { + return fmt.Errorf(".ssh metadata remains unsafe after repair") + } + if err := syncSSHDirectory(sshDir); err != nil { + return &fsutil.DurabilityError{Operation: ".ssh metadata update", Err: err} + } + if createdSSHDir { + if err := syncSSHHomeDirectory(home); err != nil { + return &fsutil.DurabilityError{Operation: ".ssh directory creation", Err: err} } - } else if !os.IsNotExist(err) { + } + if beforeAuthorizedKeysWrite != nil { + beforeAuthorizedKeysWrite() + } + if err := verifyPinnedDirectories(homeDir, homeFD, homeStat, sshFD, sshStat); err != nil { return err } - return fsutil.AtomicWriteFileAs(authFile, authorizedKey, 0o600, uid, gid) + if err := fsutil.AtomicWriteFileAt(sshDir, "authorized_keys", authorizedKey, 0o600, uid, gid); err != nil { + return fmt.Errorf("write authorized_keys: %w", err) + } + return verifyPinnedDirectories(homeDir, homeFD, homeStat, sshFD, sshStat) +} + +// beforeAuthorizedKeysWrite is a deterministic race hook for the integration +// test. Production leaves it nil. +var beforeAuthorizedKeysWrite func() + +var ( + syncSSHDirectory = func(dir *os.File) error { return dir.Sync() } + syncSSHHomeDirectory = func(home *os.File) error { return home.Sync() } +) + +func verifyPinnedDirectories(homeDir string, homeFD int, homeStat unix.Stat_t, sshFD int, sshStat unix.Stat_t) error { + var currentHome unix.Stat_t + if err := unix.Lstat(homeDir, ¤tHome); err != nil { + return fmt.Errorf("recheck home directory: %w", err) + } + if !sameInode(homeStat, currentHome) || currentHome.Mode&unix.S_IFMT != unix.S_IFDIR { + return fmt.Errorf("home directory %s was replaced while installing the key", homeDir) + } + var currentSSH unix.Stat_t + if err := unix.Fstatat(homeFD, ".ssh", ¤tSSH, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return fmt.Errorf("recheck .ssh: %w", err) + } + if !sameInode(sshStat, currentSSH) || currentSSH.Mode&unix.S_IFMT != unix.S_IFDIR { + return fmt.Errorf(".ssh was replaced while installing the key") + } + var openSSH unix.Stat_t + if err := unix.Fstat(sshFD, &openSSH); err != nil { + return fmt.Errorf("recheck open .ssh: %w", err) + } + if !sameInode(sshStat, openSSH) { + return fmt.Errorf("open .ssh inode changed unexpectedly") + } + return nil +} + +func sameInode(a, b unix.Stat_t) bool { return a.Dev == b.Dev && a.Ino == b.Ino } + +func safeInitialSSHDirectory(stat unix.Stat_t, created bool, uid int64) bool { + if stat.Mode&unix.S_IFMT != unix.S_IFDIR { + return false + } + if created { + return stat.Uid == 0 + } + return int64(stat.Uid) == uid } diff --git a/internal/sshkey/sshkey_root_test.go b/internal/sshkey/sshkey_root_test.go index 94a69c4..16ca057 100644 --- a/internal/sshkey/sshkey_root_test.go +++ b/internal/sshkey/sshkey_root_test.go @@ -3,22 +3,38 @@ package sshkey import ( + "errors" "os" "path/filepath" "syscall" "testing" + + "github.com/xxvcc/linux-temp-admin/internal/fsutil" ) const testUID, testGID = 12345, 12345 +func chownTestUserOrSkip(t *testing.T, path string) { + t.Helper() + if err := os.Chown(path, testUID, testGID); err != nil { + if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.EPERM) { + t.Skipf("test filesystem cannot represent uid %d: %v", testUID, err) + } + t.Fatal(err) + } +} + func TestWriteAuthorizedKeys(t *testing.T) { if os.Getuid() != 0 { t.Skip("requires root") } home := t.TempDir() - if err := os.Chown(home, testUID, testGID); err != nil { - t.Fatal(err) - } + chownTestUserOrSkip(t, home) + oldSSHSync, oldHomeSync := syncSSHDirectory, syncSSHHomeDirectory + sshSyncs, homeSyncs := 0, 0 + syncSSHDirectory = func(*os.File) error { sshSyncs++; return nil } + syncSSHHomeDirectory = func(*os.File) error { homeSyncs++; return nil } + t.Cleanup(func() { syncSSHDirectory, syncSSHHomeDirectory = oldSSHSync, oldHomeSync }) line := []byte("ssh-ed25519 AAAAExample comment\n") if err := WriteAuthorizedKeys(home, testUID, testGID, line); err != nil { t.Fatal(err) @@ -42,16 +58,38 @@ func TestWriteAuthorizedKeys(t *testing.T) { if b, _ := os.ReadFile(authFile); string(b) != string(line) { t.Errorf("content = %q, want %q", b, line) } + if sshSyncs != 1 || homeSyncs != 1 { + t.Fatalf("directory syncs: .ssh=%d home=%d, want 1 each", sshSyncs, homeSyncs) + } } -func TestWriteAuthorizedKeysRefusesSymlink(t *testing.T) { +func TestWriteAuthorizedKeysReportsSSHDirectorySyncFailure(t *testing.T) { if os.Getuid() != 0 { t.Skip("requires root") } home := t.TempDir() - if err := os.Chown(home, testUID, testGID); err != nil { - t.Fatal(err) + chownTestUserOrSkip(t, home) + old := syncSSHDirectory + wantErr := errors.New("forced .ssh sync failure") + syncSSHDirectory = func(*os.File) error { return wantErr } + t.Cleanup(func() { syncSSHDirectory = old }) + + err := WriteAuthorizedKeys(home, testUID, testGID, []byte("ssh-ed25519 AAAAExample\n")) + var durability *fsutil.DurabilityError + if !errors.As(err, &durability) || !errors.Is(err, wantErr) { + t.Fatalf("WriteAuthorizedKeys error=%v, want DurabilityError", err) + } + if _, statErr := os.Lstat(filepath.Join(home, ".ssh", "authorized_keys")); !os.IsNotExist(statErr) { + t.Fatalf("authorized_keys was written after the directory sync failed: %v", statErr) + } +} + +func TestWriteAuthorizedKeysRefusesSymlink(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("requires root") } + home := t.TempDir() + chownTestUserOrSkip(t, home) sshDir := filepath.Join(home, ".ssh") if err := os.Mkdir(sshDir, 0o700); err != nil { t.Fatal(err) @@ -80,3 +118,35 @@ func TestWriteAuthorizedKeysRefusesRootOwnedHome(t *testing.T) { t.Fatal("expected a root-owned home to be refused") } } + +func TestWriteAuthorizedKeysDetectsSSHDirectoryReplacement(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("requires root") + } + home := t.TempDir() + chownTestUserOrSkip(t, home) + sshDir := filepath.Join(home, ".ssh") + if err := os.Mkdir(sshDir, 0o700); err != nil { + t.Fatal(err) + } + chownTestUserOrSkip(t, sshDir) + victim := t.TempDir() + hook := beforeAuthorizedKeysWrite + beforeAuthorizedKeysWrite = func() { + if err := os.Rename(sshDir, filepath.Join(home, ".ssh-moved")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(victim, sshDir); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { beforeAuthorizedKeysWrite = hook }) + + err := WriteAuthorizedKeys(home, testUID, testGID, []byte("ssh-ed25519 AAAAExample\n")) + if err == nil { + t.Fatal("directory replacement must be detected") + } + if _, err := os.Lstat(filepath.Join(victim, "authorized_keys")); !os.IsNotExist(err) { + t.Fatalf("authorized_keys escaped into replacement directory: %v", err) + } +} diff --git a/internal/sshkey/sshkey_test.go b/internal/sshkey/sshkey_test.go index 4b82c74..5709064 100644 --- a/internal/sshkey/sshkey_test.go +++ b/internal/sshkey/sshkey_test.go @@ -1,10 +1,12 @@ package sshkey import ( + "strconv" "strings" "testing" "golang.org/x/crypto/ssh" + "golang.org/x/sys/unix" ) func TestGenerateEd25519(t *testing.T) { @@ -48,3 +50,39 @@ func TestGenerateEd25519(t *testing.T) { t.Error("two generated keys share a fingerprint") } } + +func TestWriteAuthorizedKeysRejectsIDsOutsideKernelRange(t *testing.T) { + if strconv.IntSize < 64 { + t.Skip("int cannot represent a uid above uint32") + } + reserved := int(uint64(^uint32(0))) + tooLarge := reserved + 1 + for _, ids := range [][2]int{{reserved, 1}, {1, reserved}, {tooLarge, 1}, {1, tooLarge}} { + if err := WriteAuthorizedKeys("/unused", ids[0], ids[1], nil); err == nil || !strings.Contains(err.Error(), "refusing non-user uid/gid") { + t.Fatalf("WriteAuthorizedKeys(%d, %d) error=%v, want range refusal", ids[0], ids[1], err) + } + } +} + +func TestSafeInitialSSHDirectoryDistinguishesNewFromExisting(t *testing.T) { + const uid = 12345 + tests := []struct { + name string + created bool + stat unix.Stat_t + want bool + }{ + {"new root-owned", true, unix.Stat_t{Mode: unix.S_IFDIR | 0o700, Uid: 0}, true}, + {"new swapped to user directory", true, unix.Stat_t{Mode: unix.S_IFDIR | 0o700, Uid: uid}, false}, + {"existing account-owned", false, unix.Stat_t{Mode: unix.S_IFDIR | 0o700, Uid: uid}, true}, + {"existing foreign-owned", false, unix.Stat_t{Mode: unix.S_IFDIR | 0o700, Uid: 0}, false}, + {"not a directory", false, unix.Stat_t{Mode: unix.S_IFREG | 0o600, Uid: uid}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := safeInitialSSHDirectory(tt.stat, tt.created, uid); got != tt.want { + t.Fatalf("safeInitialSSHDirectory() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/sudoers/sudoers.go b/internal/sudoers/sudoers.go index 498c320..0312386 100644 --- a/internal/sudoers/sudoers.go +++ b/internal/sudoers/sudoers.go @@ -5,14 +5,17 @@ package sudoers import ( "bytes" + "errors" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" + "time" "github.com/xxvcc/linux-temp-admin/internal/config" + "github.com/xxvcc/linux-temp-admin/internal/executil" "github.com/xxvcc/linux-temp-admin/internal/fsutil" "github.com/xxvcc/linux-temp-admin/internal/validate" ) @@ -20,17 +23,32 @@ import ( // filePrefix namespaces the drop-in files this tool manages. const filePrefix = config.ManagedTag + "-" -// Manager writes sudoers drop-ins. Dir and the two external checks are fields so -// tests can point at a temporary directory and inject validators. +var sudoProbeOptions = executil.Options{ + Timeout: 10 * time.Second, + MaxOutput: 256 << 10, + ExtraEnv: []string{"LC_ALL=C", "LANG=C"}, +} + +// Manager writes sudoers drop-ins. Its paths and external operations are fields +// so tests can point at a temporary directory and inject failures. type Manager struct { Dir string - Validate func(path string) error // syntax check (default: visudo -cf) - Verify func(user string) error // effective-policy check (default: sudo -n -l -U) + Validate func(content []byte) error // syntax check (default: visudo -cf -) + Verify func(user string) error // effective-policy check (default: sudo -n -l -U) + // RemoveFile defaults to a durable, directory-fsynced unlink. Tests inject + // failures here to verify that + // callers retain the account while a name-scoped root grant may still exist. + RemoveFile func(path string) error } // New returns a Manager for the real /etc/sudoers.d using visudo and sudo. func New() *Manager { - return &Manager{Dir: "/etc/sudoers.d", Validate: visudoValidate, Verify: verifyNopasswd} + return &Manager{ + Dir: "/etc/sudoers.d", + Validate: visudoValidate, + Verify: verifyNopasswd, + RemoveFile: fsutil.RemoveFile, + } } // FilePath is the drop-in path for user. Exported so a diagnostic can name the @@ -56,26 +74,22 @@ func (m *Manager) Grant(user string) error { return fmt.Errorf("%s is not a safe directory", m.Dir) } content := []byte(fmt.Sprintf("%s ALL=(ALL) NOPASSWD:ALL\n", user)) - // Validate a throwaway copy BEFORE the drop-in goes live in sudoers.d, so a - // syntactically broken file never briefly breaks sudo system-wide. + // Validate the exact bytes through stdin BEFORE the drop-in goes live in + // sudoers.d, so a syntactically broken file never briefly breaks sudo + // system-wide and no attacker-controlled temporary pathname is involved. if m.Validate != nil { - tmp, err := os.CreateTemp("", "lta-sudoers-*") - if err != nil { - return err - } - tmpName := tmp.Name() - _, werr := tmp.Write(content) - _ = tmp.Close() - if werr == nil { - werr = m.Validate(tmpName) - } - _ = os.Remove(tmpName) - if werr != nil { - return fmt.Errorf("sudoers validation failed: %w", werr) + if err := m.Validate(content); err != nil { + return fmt.Errorf("sudoers validation failed: %w", err) } } path := m.FilePath(user) if err := fsutil.WriteRootFile(path, content, 0o440); err != nil { + var committed *fsutil.DurabilityError + if errors.As(err, &committed) { + if rmErr := m.removeFile(path); rmErr != nil && !os.IsNotExist(rmErr) { + return errors.Join(err, fmt.Errorf("remove committed sudoers file after durability failure: %w", rmErr)) + } + } return err } if m.Verify != nil { @@ -84,7 +98,7 @@ func (m *Manager) Grant(user string) error { // real — back it out. If removal also fails, surface that loudly rather // than swallowing it, because the caller must know a NOPASSWD grant may // still be on disk and needs manual cleanup. - if rmErr := os.Remove(path); rmErr != nil { + if rmErr := m.removeFile(path); rmErr != nil { return fmt.Errorf("sudo policy did not take effect (%w) and rollback failed: %v; NOPASSWD drop-in may persist at %s", err, rmErr, path) } return fmt.Errorf("sudo policy did not take effect: %w", err) @@ -103,18 +117,26 @@ func (m *Manager) Grant(user string) error { // file that could not be deleted is exactly that. Silently discarding the error // let the removal fail and the teardown call it done. func (m *Manager) Remove(user string) error { - path := m.FilePath(user) - if !strings.HasPrefix(filepath.Base(path), filePrefix) { - // Not ours to touch. Nothing was granted under this name by this tool, so - // there is nothing to remove and no failure to report. - return nil + // FilePath joins user onto a privileged directory. Reject path separators and + // every other invalid account name before constructing that path; checking the + // final basename is insufficient because filepath.Join cleans ".." segments. + if !validate.Username(user) { + return fmt.Errorf("refusing sudoers removal for invalid username %q", user) } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + path := m.FilePath(user) + if err := m.removeFile(path); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("remove sudo grant %s: %w", path, err) } return nil } +func (m *Manager) removeFile(path string) error { + if m.RemoveFile != nil { + return m.RemoveFile(path) + } + return fsutil.RemoveFile(path) +} + // All returns every account this tool has a sudo drop-in for, whether or not the // account still exists. // @@ -126,13 +148,16 @@ func (m *Manager) Remove(user string) error { // IS the passwordless root, so hiding an account means keeping the file that // names it. func (m *Manager) All() ([]string, error) { - matches, err := filepath.Glob(filepath.Join(m.Dir, filePrefix+"*")) + entries, err := readManagedDir(m.Dir) + if os.IsNotExist(err) { + return nil, nil + } if err != nil { - return nil, err + return nil, fmt.Errorf("read sudoers directory %s: %w", m.Dir, err) } var users []string - for _, path := range matches { - user := strings.TrimPrefix(filepath.Base(path), filePrefix) + for _, entry := range entries { + user := strings.TrimPrefix(entry.Name(), filePrefix) if user != "" && validate.Username(user) { users = append(users, user) } @@ -141,6 +166,8 @@ func (m *Manager) All() ([]string, error) { return users, nil } +var readManagedDir = os.ReadDir + // Orphans returns the accounts whose managed drop-in is still on disk although // the account itself is gone. exists reports whether an account is still present. // @@ -151,23 +178,18 @@ func (m *Manager) All() ([]string, error) { // not finish — so nothing else will notice them. This is what lets `doctor` // report them and `cleanup-expired --compact` remove them. func (m *Manager) Orphans(exists func(string) (bool, error)) ([]string, error) { - matches, err := filepath.Glob(filepath.Join(m.Dir, filePrefix+"*")) + users, err := m.All() if err != nil { return nil, err } var orphans []string - for _, path := range matches { - user := strings.TrimPrefix(filepath.Base(path), filePrefix) - // validate.Username keeps a hand-made file with a strange name from being - // reported (and later removed) as if this tool had written it. - if user != "" && validate.Username(user) { - live, err := exists(user) - if err != nil { - return nil, err - } - if !live { - orphans = append(orphans, user) - } + for _, user := range users { + live, err := exists(user) + if err != nil { + return nil, err + } + if !live { + orphans = append(orphans, user) } } return orphans, nil @@ -175,21 +197,81 @@ func (m *Manager) Orphans(exists func(string) (bool, error)) ([]string, error) { // visudoValidate syntax-checks a sudoers file. If visudo is unavailable the // check is skipped (best-effort). -func visudoValidate(path string) error { +func visudoValidate(content []byte) error { if _, err := exec.LookPath("visudo"); err != nil { return nil } - return exec.Command("visudo", "-cf", path).Run() + opts := sudoProbeOptions + opts.Stdin = bytes.NewReader(content) + out, err := executil.CombinedOutput("visudo", []string{"-cf", "-"}, opts) + if err != nil { + return fmt.Errorf("visudo -cf: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil } // verifyNopasswd confirms the effective policy grants user NOPASSWD sudo. func verifyNopasswd(user string) error { - out, err := exec.Command("sudo", "-n", "-l", "-U", user).Output() + out, err := executil.Output("sudo", []string{"-n", "-l", "-U", user}, sudoProbeOptions) if err != nil { - return err + return fmt.Errorf("sudo -n -l -U %s: %w", user, err) } - if !bytes.Contains(out, []byte("NOPASSWD:")) { - return fmt.Errorf("effective policy has no NOPASSWD grant") + return verifyNopasswdOutput(out) +} + +func verifyNopasswdOutput(out []byte) error { + for _, raw := range strings.Split(string(out), "\n") { + line := strings.TrimSpace(raw) + if !strings.HasPrefix(line, "(") { + continue + } + endRunas := strings.IndexByte(line, ')') + if endRunas < 2 || !runasIncludesRoot(line[1:endRunas]) { + continue + } + if nopasswdAll(strings.TrimSpace(line[endRunas+1:])) { + return nil + } + } + return fmt.Errorf("effective policy has no root NOPASSWD: ALL grant") +} + +func runasIncludesRoot(runas string) bool { + users := runas + if colon := strings.IndexByte(users, ':'); colon >= 0 { + users = users[:colon] + } + for _, user := range strings.Split(users, ",") { + switch strings.TrimSpace(user) { + case "root", "ALL": + return true + } + } + return false +} + +func nopasswdAll(spec string) bool { + nopasswd := false + for { + colon := strings.IndexByte(spec, ':') + if colon < 0 { + return nopasswd && strings.TrimSpace(spec) == "ALL" + } + tag := strings.TrimSpace(spec[:colon]) + switch tag { + case "NOPASSWD": + nopasswd = true + case "PASSWD": + nopasswd = false + case "EXEC", "NOEXEC", "FOLLOW", "NOFOLLOW", "SETENV", "NOSETENV", + "LOG_INPUT", "NOLOG_INPUT", "LOG_OUTPUT", "NOLOG_OUTPUT", "MAIL", "NOMAIL", + "INTERCEPT", "NOINTERCEPT": + // Other sudo tags do not change password authentication. + default: + // The colon belongs to the command or a later comma-separated rule, + // not to a leading tag sequence. It cannot be our exact ALL grant. + return false + } + spec = strings.TrimSpace(spec[colon+1:]) } - return nil } diff --git a/internal/sudoers/sudoers_root_test.go b/internal/sudoers/sudoers_root_test.go index 572ac66..d3cd8ea 100644 --- a/internal/sudoers/sudoers_root_test.go +++ b/internal/sudoers/sudoers_root_test.go @@ -27,7 +27,11 @@ func rootDir(t *testing.T) string { func TestGrantWritesValidatedDropin(t *testing.T) { dir := rootDir(t) - m := &Manager{Dir: dir, Validate: func(string) error { return nil }, Verify: func(string) error { return nil }} + var validated []byte + m := &Manager{Dir: dir, Validate: func(content []byte) error { + validated = append([]byte(nil), content...) + return nil + }, Verify: func(string) error { return nil }} const user = "xxvcc-a1" if err := m.Grant(user); err != nil { t.Fatal(err) @@ -43,12 +47,14 @@ func TestGrantWritesValidatedDropin(t *testing.T) { b, _ := os.ReadFile(path) if want := user + " ALL=(ALL) NOPASSWD:ALL\n"; string(b) != want { t.Errorf("content = %q, want %q", b, want) + } else if string(validated) != want { + t.Errorf("validated content = %q, want %q", validated, want) } } func TestGrantRemovesFileOnValidationFailure(t *testing.T) { dir := rootDir(t) - m := &Manager{Dir: dir, Validate: func(string) error { return fmt.Errorf("bad syntax") }} + m := &Manager{Dir: dir, Validate: func([]byte) error { return fmt.Errorf("bad syntax") }} if err := m.Grant("xxvcc-a1"); err == nil { t.Fatal("expected Grant to fail on validation error") } @@ -59,7 +65,7 @@ func TestGrantRemovesFileOnValidationFailure(t *testing.T) { func TestGrantRemovesFileOnVerifyFailure(t *testing.T) { dir := rootDir(t) - m := &Manager{Dir: dir, Validate: func(string) error { return nil }, Verify: func(string) error { return fmt.Errorf("not effective") }} + m := &Manager{Dir: dir, Validate: func([]byte) error { return nil }, Verify: func(string) error { return fmt.Errorf("not effective") }} if err := m.Grant("xxvcc-a1"); err == nil { t.Fatal("expected Grant to fail on verify error") } @@ -70,7 +76,7 @@ func TestGrantRemovesFileOnVerifyFailure(t *testing.T) { func TestRemove(t *testing.T) { dir := rootDir(t) - m := &Manager{Dir: dir, Validate: func(string) error { return nil }, Verify: func(string) error { return nil }} + m := &Manager{Dir: dir, Validate: func([]byte) error { return nil }, Verify: func(string) error { return nil }} if err := m.Grant("xxvcc-a1"); err != nil { t.Fatal(err) } diff --git a/internal/sudoers/sudoers_test.go b/internal/sudoers/sudoers_test.go index 421a315..1df0caa 100644 --- a/internal/sudoers/sudoers_test.go +++ b/internal/sudoers/sudoers_test.go @@ -1,11 +1,173 @@ package sudoers import ( + "context" + "errors" "os" "path/filepath" + "strings" "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/executil" ) +func writeSudoersCommand(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } +} + +func TestAllPropagatesDirectoryReadFailure(t *testing.T) { + old := readManagedDir + readManagedDir = func(string) ([]os.DirEntry, error) { + return nil, errors.New("injected directory I/O failure") + } + t.Cleanup(func() { readManagedDir = old }) + + if _, err := (&Manager{Dir: t.TempDir()}).All(); err == nil || !strings.Contains(err.Error(), "injected directory I/O failure") { + t.Fatalf("All error = %v, want directory failure", err) + } +} + +func TestSudoersProbesAreBoundedAndUseCLocale(t *testing.T) { + old := sudoProbeOptions + t.Cleanup(func() { sudoProbeOptions = old }) + + t.Run("visudo locale", func(t *testing.T) { + sudoProbeOptions = old + dir := t.TempDir() + writeSudoersCommand(t, dir, "visudo", `[ "$LC_ALL:$LANG:$1:$2" = "C:C:-cf:-" ]`) + t.Setenv("PATH", dir) + if err := visudoValidate([]byte("alice ALL=(ALL) NOPASSWD:ALL\n")); err != nil { + t.Fatalf("visudoValidate did not force the C locale: %v", err) + } + }) + + t.Run("sudo locale and argv", func(t *testing.T) { + sudoProbeOptions = old + dir := t.TempDir() + writeSudoersCommand(t, dir, "sudo", `[ "$LC_ALL:$LANG:$1:$2:$3:$4" = "C:C:-n:-l:-U:alice" ] || exit 9 +printf ' (root) NOPASSWD: ALL\n'`) + t.Setenv("PATH", dir) + if err := verifyNopasswd("alice"); err != nil { + t.Fatalf("verifyNopasswd did not force the C locale/expected argv: %v", err) + } + }) + + t.Run("timeout", func(t *testing.T) { + dir := t.TempDir() + writeSudoersCommand(t, dir, "sudo", `/bin/sleep 30 & wait`) + t.Setenv("PATH", dir) + opts := old + opts.Timeout = 50 * time.Millisecond + sudoProbeOptions = opts + if err := verifyNopasswd("alice"); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("verifyNopasswd error = %v, want timeout", err) + } + }) + + t.Run("output limit", func(t *testing.T) { + dir := t.TempDir() + writeSudoersCommand(t, dir, "visudo", `while :; do printf 0123456789abcdef; done`) + t.Setenv("PATH", dir) + opts := old + opts.Timeout = time.Second + opts.MaxOutput = 64 + sudoProbeOptions = opts + if err := visudoValidate([]byte("alice ALL=(ALL) NOPASSWD:ALL\n")); !errors.Is(err, executil.ErrOutputLimit) { + t.Fatalf("visudoValidate error = %v, want output limit", err) + } + }) +} + +func TestRemoveUsesInjectedRemoveFile(t *testing.T) { + wantErr := errors.New("injected remove failure") + var removed string + m := &Manager{ + Dir: t.TempDir(), + RemoveFile: func(path string) error { + removed = path + return wantErr + }, + } + + err := m.Remove("xxvcc-a1") + if !errors.Is(err, wantErr) { + t.Fatalf("Remove error = %v, want injected failure", err) + } + if removed != m.FilePath("xxvcc-a1") { + t.Fatalf("removed path = %q, want %q", removed, m.FilePath("xxvcc-a1")) + } +} + +func TestRemoveRejectsInvalidUsernameBeforePathResolution(t *testing.T) { + const malicious = "x/../../linux-temp-admin-target" + + t.Run("injected remover is not called", func(t *testing.T) { + called := false + m := &Manager{ + Dir: t.TempDir(), + RemoveFile: func(string) error { + called = true + return nil + }, + } + if err := m.Remove(malicious); err == nil || !strings.Contains(err.Error(), "invalid username") { + t.Fatalf("Remove error = %v, want invalid username refusal", err) + } + if called { + t.Fatal("RemoveFile was called for an invalid username") + } + }) + + t.Run("file outside sudoers directory survives", func(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "sudoers") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(root, "linux-temp-admin-target") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := (&Manager{Dir: dir}).Remove(malicious); err == nil { + t.Fatal("Remove accepted a path-traversal username") + } + if got, err := os.ReadFile(sentinel); err != nil || string(got) != "keep\n" { + t.Fatalf("outside sentinel changed: content=%q err=%v", got, err) + } + }) +} + +func TestVerifyNopasswdOutputRequiresRootNopasswdAll(t *testing.T) { + tests := []struct { + name string + out string + ok bool + }{ + {"root", "User alice may run the following commands:\n (root) NOPASSWD: ALL\n", true}, + {"all runas", " (ALL : ALL) NOPASSWD: ALL\n", true}, + {"non-root runas", " (daemon) NOPASSWD: ALL\n", false}, + {"restricted command", " (root) NOPASSWD: /usr/bin/id\n", false}, + {"password required", " (root) PASSWD: ALL\n", false}, + {"unrelated nopasswd", " (daemon) NOPASSWD: /bin/true\n (root) PASSWD: ALL\n", false}, + {"tag changes before all", " (root) NOPASSWD: /bin/true, PASSWD: ALL\n", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := verifyNopasswdOutput([]byte(tt.out)) + if tt.ok && err != nil { + t.Fatalf("verifyNopasswdOutput rejected full grant: %v", err) + } + if !tt.ok && (err == nil || !strings.Contains(err.Error(), "root NOPASSWD: ALL")) { + t.Fatalf("verifyNopasswdOutput error = %v, want precise refusal", err) + } + }) + } +} + // TestOrphansFindsGrantsWhoseAccountIsGone pins the M1 fix. An orphaned // NOPASSWD:ALL drop-in is the most dangerous leftover this tool can produce — it // re-arms full root the instant its username is reused — so it must be findable. diff --git a/internal/sysinfo/sshd.go b/internal/sysinfo/sshd.go index 1779719..3db2b7a 100644 --- a/internal/sysinfo/sshd.go +++ b/internal/sysinfo/sshd.go @@ -1,3 +1,5 @@ +package sysinfo + // This file reads sshd's *effective* configuration and answers the one question // the tool used to assume: will this server actually let the account we are about // to create log in with the key we are about to write? @@ -7,20 +9,31 @@ // distro crypto policy, and it is the same evaluation the running sshd performs. // Guessing at the file's text would be worse than not looking at all, because a // wrong guess turns into a confidently false invite. -package sysinfo import ( "bufio" + "errors" "fmt" + "io" "os" - "os/exec" "path/filepath" "strings" + "syscall" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/executil" + "golang.org/x/sys/unix" ) // sshdCommand is the sshd binary; overridable in tests. var sshdCommand = "sshd" +var sshdProbeOptions = executil.Options{ + Timeout: 10 * time.Second, + MaxOutput: 1 << 20, + ExtraEnv: []string{"LC_ALL=C", "LANG=C"}, +} + // SSHDConfig is sshd's effective configuration, as reported by `sshd -T`. Keys // are the lowercase directive names sshd prints; a directive that sshd repeats // across lines (AllowUsers, AllowGroups, ...) accumulates all of its values. @@ -61,7 +74,7 @@ func SSHDEffective(user string) (*SSHDConfig, error) { if user != "" { args = append(args, "-C", "user="+user) } - out, err := exec.Command(sshdCommand, args...).Output() + out, err := executil.Output(sshdCommand, args, sshdProbeOptions) if err != nil { // A failed per-user probe must NOT fall back to the global view. The global // view cannot see `Match User` blocks, so a host whose Match block blocks @@ -80,9 +93,12 @@ func SSHDEffective(user string) (*SSHDConfig, error) { // the machine running the tests. func ParseSSHD(out string) *SSHDConfig { c := &SSHDConfig{vals: map[string][]string{}} - sc := bufio.NewScanner(strings.NewReader(out)) - for sc.Scan() { - fields := strings.Fields(sc.Text()) + // SSHDEffective already captured the complete output in memory. Splitting that + // string avoids bufio.Scanner's 64 KiB token limit: a very long directive must + // not silently hide a later DenyUsers/DenyGroups rule and turn an incomplete + // parse into a false "login accepted" verdict. + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) if len(fields) < 2 { continue } @@ -95,6 +111,26 @@ func ParseSSHD(out string) *SSHDConfig { // sshdConfigDropInDir is the standard drop-in directory; overridable in tests. var sshdConfigDropInDir = "/etc/ssh/sshd_config.d" +const ( + maxSSHDIncludeDepth = 64 + maxSSHDIncludeFiles = 256 + maxSSHDIncludeGlobs = 1024 + maxSSHDIncludeBytes = int64(64 << 20) +) + +type sshdConfigIdentity struct { + dev uint64 + ino uint64 +} + +type sshdIncludeScan struct { + paths map[string]bool + identities map[sshdConfigIdentity]bool + files int + globs int + bytes int64 +} + // HasConnectionScopedMatch reports whether sshd's configuration contains a // `Match` criterion that `sshd -T -C user=X` cannot evaluate without more // connection attributes. @@ -114,15 +150,18 @@ var sshdConfigDropInDir = "/etc/ssh/sshd_config.d" // never produce a false verified claim. func HasConnectionScopedMatch() bool { files := []string{sshdConfigPath} - if entries, err := filepath.Glob(filepath.Join(sshdConfigDropInDir, "*.conf")); err == nil { + if entries, err := strictGlob(filepath.Join(sshdConfigDropInDir, "*.conf")); err == nil { files = append(files, entries...) } else { return true } - seen := map[string]bool{} + scan := &sshdIncludeScan{ + paths: map[string]bool{}, + identities: map[sshdConfigIdentity]bool{}, + } baseDir := filepath.Dir(sshdConfigPath) for _, f := range files { - found, complete := fileHasConnectionScopedMatch(f, baseDir, seen) + found, complete := fileHasConnectionScopedMatch(f, baseDir, scan, 0) if found || !complete { return true } @@ -130,18 +169,45 @@ func HasConnectionScopedMatch() bool { return false } -func fileHasConnectionScopedMatch(path, baseDir string, seen map[string]bool) (found, complete bool) { +func fileHasConnectionScopedMatch(path, baseDir string, scan *sshdIncludeScan, depth int) (found, complete bool) { + if depth >= maxSSHDIncludeDepth { + return false, false + } path = filepath.Clean(path) - if seen[path] { + if scan.paths[path] { return false, true } - seen[path] = true - f, err := os.Open(path) + scan.paths[path] = true + // sshd follows symlinked configuration files, so this scanner does too. Open + // nonblocking, then require the resolved descriptor to be regular and bounded: + // a damaged Include that names a FIFO or device must downgrade the verdict + // instead of hanging or streaming forever. + f, err := os.OpenFile(path, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) if err != nil { return false, false } defer f.Close() - sc := bufio.NewScanner(f) + fi, err := f.Stat() + if err != nil || !fi.Mode().IsRegular() || fi.Size() > maxSSHDConfigBytes { + return false, false + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return false, false + } + identity := sshdConfigIdentity{dev: uint64(st.Dev), ino: st.Ino} + if scan.identities[identity] { + return false, true + } + if scan.files >= maxSSHDIncludeFiles || fi.Size() > maxSSHDIncludeBytes-scan.bytes { + return false, false + } + scan.identities[identity] = true + scan.files++ + scan.bytes += fi.Size() + limited := &io.LimitedReader{R: f, N: maxSSHDConfigBytes + 1} + sc := bufio.NewScanner(limited) + sc.Buffer(make([]byte, 64<<10), maxSSHDConfigLine) for sc.Scan() { line, _, _ := strings.Cut(sc.Text(), "#") fields := strings.Fields(line) @@ -150,10 +216,14 @@ func fileHasConnectionScopedMatch(path, baseDir string, seen map[string]bool) (f } if strings.EqualFold(fields[0], "Include") { for _, pattern := range fields[1:] { + if scan.globs >= maxSSHDIncludeGlobs { + return false, false + } + scan.globs++ if !filepath.IsAbs(pattern) { pattern = filepath.Join(baseDir, pattern) } - matches, err := filepath.Glob(pattern) + matches, err := strictGlob(pattern) if err != nil { return false, false } @@ -161,7 +231,7 @@ func fileHasConnectionScopedMatch(path, baseDir string, seen map[string]bool) (f return false, false } for _, include := range matches { - found, complete := fileHasConnectionScopedMatch(include, baseDir, seen) + found, complete := fileHasConnectionScopedMatch(include, baseDir, scan, depth+1) if found || !complete { return found, complete } @@ -194,9 +264,115 @@ func fileHasConnectionScopedMatch(path, baseDir string, seen map[string]bool) (f } } } - return false, sc.Err() == nil + return false, sc.Err() == nil && limited.N > 0 } +var errSSHDGlobLimit = errors.New("sshd Include directory exceeds traversal limit") + +var strictGlobReadDir = readSSHDGlobDir + +func readSSHDGlobDir(path string) ([]os.DirEntry, error) { + f, err := os.OpenFile(path, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NONBLOCK|unix.O_DIRECTORY, 0) + if err != nil { + return nil, err + } + defer f.Close() + entries, err := f.ReadDir(maxSSHDIncludeFiles + 1) + if err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if len(entries) > maxSSHDIncludeFiles { + return nil, errSSHDGlobLimit + } + return entries, nil +} + +// strictGlob is filepath.Glob with one security-relevant difference: directory +// I/O errors are returned instead of silently treated as no matches. An +// incomplete sshd Include scan must downgrade the login verdict, not hide a +// connection-scoped Match rule. +func strictGlob(pattern string) ([]string, error) { + if _, err := filepath.Match(pattern, ""); err != nil { + return nil, err + } + return strictGlobDepth(pattern, 0) +} + +func strictGlobDepth(pattern string, depth int) ([]string, error) { + const maxDepth = 256 + if depth == maxDepth { + return nil, filepath.ErrBadPattern + } + if !globHasMeta(pattern) { + if _, err := os.Lstat(pattern); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return []string{pattern}, nil + } + + dir, file := filepath.Split(pattern) + switch dir { + case "": + dir = "." + case string(filepath.Separator): + default: + dir = dir[:len(dir)-1] + } + if !globHasMeta(dir) { + return strictGlobDir(dir, file, nil) + } + if dir == pattern { + return nil, filepath.ErrBadPattern + } + dirs, err := strictGlobDepth(dir, depth+1) + if err != nil { + return nil, err + } + var matches []string + for _, matchedDir := range dirs { + matches, err = strictGlobDir(matchedDir, file, matches) + if err != nil { + return nil, err + } + } + return matches, nil +} + +func strictGlobDir(dir, pattern string, matches []string) ([]string, error) { + fi, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + return matches, nil + } + return nil, err + } + if !fi.IsDir() { + return matches, nil + } + entries, err := strictGlobReadDir(dir) + if err != nil { + return nil, err + } + for _, entry := range entries { + matched, err := filepath.Match(pattern, entry.Name()) + if err != nil { + return nil, err + } + if matched { + if len(matches) >= maxSSHDIncludeFiles { + return nil, errSSHDGlobLimit + } + matches = append(matches, filepath.Join(dir, entry.Name())) + } + } + return matches, nil +} + +func globHasMeta(path string) bool { return strings.ContainsAny(path, `*?[\`) } + // Blocker is one reason a login would fail. The values are stable identifiers, // not messages: sysinfo stays free of i18n, and the cli layer renders them. type Blocker int diff --git a/internal/sysinfo/sshd_test.go b/internal/sysinfo/sshd_test.go index 115a29f..3d500f3 100644 --- a/internal/sysinfo/sshd_test.go +++ b/internal/sysinfo/sshd_test.go @@ -1,8 +1,17 @@ package sysinfo import ( + "context" + "errors" + "fmt" "os" + "path/filepath" + "strings" "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/executil" + "golang.org/x/sys/unix" ) // The account an invite creates: a fresh name, in no group but its own. @@ -10,6 +19,42 @@ const acct = "xxvcc-a1b2c3" var acctGroups = []string{acct} +func TestSSHDEffectiveIsBoundedAndUsesCLocale(t *testing.T) { + oldCommand, oldOptions := sshdCommand, sshdProbeOptions + t.Cleanup(func() { sshdCommand, sshdProbeOptions = oldCommand, oldOptions }) + + t.Run("locale and argv", func(t *testing.T) { + sshdProbeOptions = oldOptions + sshdCommand = writeSysinfoCommand(t, t.TempDir(), "sshd", `[ "$LC_ALL:$LANG:$*" = "C:C:-T -C user=alice" ] || exit 9 +printf 'port 2222\npubkeyauthentication yes\n'`) + cfg, err := SSHDEffective("alice") + if err != nil || cfg.First("port") != "2222" { + t.Fatalf("SSHDEffective config=%v err=%v; helper did not receive C locale/expected argv", cfg, err) + } + }) + + t.Run("timeout", func(t *testing.T) { + sshdCommand = writeSysinfoCommand(t, t.TempDir(), "sshd", `/bin/sleep 30 & wait`) + opts := oldOptions + opts.Timeout = 50 * time.Millisecond + sshdProbeOptions = opts + if _, err := SSHDEffective("alice"); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("SSHDEffective error = %v, want timeout", err) + } + }) + + t.Run("output limit", func(t *testing.T) { + sshdCommand = writeSysinfoCommand(t, t.TempDir(), "sshd", `while :; do printf 0123456789abcdef; done`) + opts := oldOptions + opts.Timeout = time.Second + opts.MaxOutput = 64 + sshdProbeOptions = opts + if _, err := SSHDEffective("alice"); !errors.Is(err, executil.ErrOutputLimit) { + t.Fatalf("SSHDEffective error = %v, want output limit", err) + } + }) +} + func TestParseSSHDAccumulatesRepeatedDirectives(t *testing.T) { c := ParseSSHD("port 22\nallowusers alice\nallowusers bob\nauthorizedkeysfile .ssh/authorized_keys .ssh/authorized_keys2\n") if got := c.First("port"); got != "22" { @@ -168,6 +213,48 @@ func TestCheckKeyLogin(t *testing.T) { } } +func TestParseSSHDDoesNotTruncateAfterLongLine(t *testing.T) { + const acct = "xxvcc-a1" + longValue := strings.Repeat("x", 70<<10) + cfg := ParseSSHD("pubkeyauthentication yes\n" + + "authorizedkeysfile .ssh/authorized_keys\n" + + "banner " + longValue + "\n" + + "denyusers " + acct + "\n") + rep := CheckKeyLogin(cfg, acct, []string{acct}) + if !rep.Has(BlockDenyUsers) { + t.Fatalf("long directive hid a later DenyUsers rule: blockers=%v", rep.Blockers) + } +} + +func TestConnectionScopedMatchScannerHandlesLongLinesAndFailsClosed(t *testing.T) { + dir := t.TempDir() + main := dir + "/sshd_config" + dropins := dir + "/sshd_config.d" + if err := os.MkdirAll(dropins, 0o755); err != nil { + t.Fatal(err) + } + oldConfig, oldDropins := sshdConfigPath, sshdConfigDropInDir + sshdConfigPath, sshdConfigDropInDir = main, dropins + t.Cleanup(func() { sshdConfigPath, sshdConfigDropInDir = oldConfig, oldDropins }) + + content := "Banner " + strings.Repeat("x", 70<<10) + "\nMatch Address 203.0.113.0/24\n" + if err := os.WriteFile(main, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if !HasConnectionScopedMatch() { + t.Fatal("a >64 KiB line hid a later connection-scoped Match") + } + + // Lines beyond the explicit cap are malformed for this bounded parser. The + // scan must become unverifiable rather than silently returning a clean bill. + if err := os.WriteFile(main, []byte(strings.Repeat("x", maxSSHDConfigLine+1)), 0o644); err != nil { + t.Fatal(err) + } + if !HasConnectionScopedMatch() { + t.Fatal("scanner overflow was treated as a complete configuration scan") + } +} + func TestCheckKeyLoginWarnsOnAuthorizedKeysCommand(t *testing.T) { // Not a blocker: the command is an *additional* source of keys, so the file we // wrote is still read. The operator should still hear about it. @@ -371,6 +458,155 @@ func TestHasConnectionScopedMatch(t *testing.T) { } } +func TestHasConnectionScopedMatchFailsClosedOnGlobIOError(t *testing.T) { + dir := t.TempDir() + main := dir + "/sshd_config" + dropins := dir + "/sshd_config.d" + if err := os.MkdirAll(dropins, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(main, []byte("PubkeyAuthentication yes\n"), 0o644); err != nil { + t.Fatal(err) + } + oldConfig, oldDropins, oldReadDir := sshdConfigPath, sshdConfigDropInDir, strictGlobReadDir + sshdConfigPath, sshdConfigDropInDir = main, dropins + strictGlobReadDir = func(path string) ([]os.DirEntry, error) { + if path == dropins { + return nil, errors.New("injected directory I/O failure") + } + return os.ReadDir(path) + } + t.Cleanup(func() { + sshdConfigPath, sshdConfigDropInDir, strictGlobReadDir = oldConfig, oldDropins, oldReadDir + }) + + if !HasConnectionScopedMatch() { + t.Fatal("an unreadable drop-in directory was treated as a complete sshd policy scan") + } +} + +func TestHasConnectionScopedMatchBoundsAndRejectsSpecialFiles(t *testing.T) { + dir := t.TempDir() + main := dir + "/sshd_config" + dropins := dir + "/sshd_config.d" + if err := os.MkdirAll(dropins, 0o755); err != nil { + t.Fatal(err) + } + oldConfig, oldDropins := sshdConfigPath, sshdConfigDropInDir + sshdConfigPath, sshdConfigDropInDir = main, dropins + t.Cleanup(func() { sshdConfigPath, sshdConfigDropInDir = oldConfig, oldDropins }) + + if err := unix.Mkfifo(main, 0o600); err != nil { + t.Fatal(err) + } + start := time.Now() + if !HasConnectionScopedMatch() { + t.Fatal("FIFO sshd config was treated as a complete policy scan") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("FIFO sshd config blocked for %s", elapsed) + } + if err := os.Remove(main); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(main, nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Truncate(main, maxSSHDConfigBytes+1); err != nil { + t.Fatal(err) + } + if !HasConnectionScopedMatch() { + t.Fatal("oversized sshd config was treated as a complete policy scan") + } + + if err := os.Remove(main); err != nil { + t.Fatal(err) + } + real := dir + "/real.conf" + if err := os.WriteFile(real, []byte("Match Address 203.0.113.0/24\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(real, main); err != nil { + t.Fatal(err) + } + if !HasConnectionScopedMatch() { + t.Fatal("regular symlinked sshd config was not scanned as sshd would scan it") + } +} + +func TestHasConnectionScopedMatchBoundsIncludeTraversal(t *testing.T) { + dir := t.TempDir() + main := filepath.Join(dir, "sshd_config") + dropins := filepath.Join(dir, "sshd_config.d") + if err := os.MkdirAll(dropins, 0o755); err != nil { + t.Fatal(err) + } + oldConfig, oldDropins := sshdConfigPath, sshdConfigDropInDir + sshdConfigPath, sshdConfigDropInDir = main, dropins + t.Cleanup(func() { sshdConfigPath, sshdConfigDropInDir = oldConfig, oldDropins }) + + for i := 0; i <= maxSSHDIncludeDepth; i++ { + path := filepath.Join(dir, fmt.Sprintf("depth-%03d.conf", i)) + content := "PubkeyAuthentication yes\n" + if i < maxSSHDIncludeDepth { + content = fmt.Sprintf("Include depth-%03d.conf\n", i+1) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(main, []byte("Include depth-000.conf\n"), 0o644); err != nil { + t.Fatal(err) + } + if !HasConnectionScopedMatch() { + t.Fatal("over-deep Include traversal was treated as a complete policy scan") + } + + includeDir := filepath.Join(dir, "many") + if err := os.Mkdir(includeDir, 0o755); err != nil { + t.Fatal(err) + } + for i := 0; i <= maxSSHDIncludeFiles; i++ { + path := filepath.Join(includeDir, fmt.Sprintf("%03d.conf", i)) + if err := os.WriteFile(path, []byte("PubkeyAuthentication yes\n"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(main, []byte("Include many/*.conf\n"), 0o644); err != nil { + t.Fatal(err) + } + if !HasConnectionScopedMatch() { + t.Fatal("over-wide Include traversal was treated as a complete policy scan") + } + + var patterns strings.Builder + for i := 0; i <= maxSSHDIncludeGlobs; i++ { + fmt.Fprintf(&patterns, "Include missing-%04d-*.conf\n", i) + } + if err := os.WriteFile(main, []byte(patterns.String()), 0o644); err != nil { + t.Fatal(err) + } + if !HasConnectionScopedMatch() { + t.Fatal("too many Include patterns were treated as a complete policy scan") + } + + for i := 0; i < 17; i++ { + wideDir := filepath.Join(dir, fmt.Sprintf("wide-%02d", i)) + if err := os.Mkdir(wideDir, 0o755); err != nil { + t.Fatal(err) + } + for j := 0; j < 16; j++ { + path := filepath.Join(wideDir, fmt.Sprintf("%02d.conf", j)) + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + } + } + if _, err := strictGlob(filepath.Join(dir, "wide-*", "*.conf")); !errors.Is(err, errSSHDGlobLimit) { + t.Fatalf("wide glob error = %v, want traversal-limit refusal", err) + } +} + // TestMatchSSHDPattern pins the matcher against OpenSSH's match.c semantics: // only '*' and '?' are special, everything else is literal. func TestMatchSSHDPattern(t *testing.T) { diff --git a/internal/sysinfo/sysinfo.go b/internal/sysinfo/sysinfo.go index 680fc57..9eba33b 100644 --- a/internal/sysinfo/sysinfo.go +++ b/internal/sysinfo/sysinfo.go @@ -7,15 +7,35 @@ package sysinfo import ( "bufio" "fmt" + "io" "os" "os/exec" "strconv" "strings" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/executil" + "golang.org/x/sys/unix" ) // sshdConfigPath is overridable in tests. var sshdConfigPath = "/etc/ssh/sshd_config" +const ( + maxSSHDConfigLine = 1 << 20 + maxSSHDConfigBytes = int64(16 << 20) +) + +var packageCommandOptions = executil.Options{ + Timeout: 30 * time.Minute, + MaxOutput: 8 << 20, + ExtraEnv: []string{ + "DEBIAN_FRONTEND=noninteractive", + "LC_ALL=C", + "LANG=C", + }, +} + func has(name string) bool { _, err := exec.LookPath(name); return err == nil } // PackageManager returns the detected package manager, or "" if none is found. @@ -115,25 +135,29 @@ func PackageCandidate(label, pm string) string { // InstallPackages installs pkgs using the given package manager. func InstallPackages(pm string, pkgs []string) error { - var cmd *exec.Cmd + var name string + var args []string switch pm { case "apt": - _ = exec.Command("apt-get", "update").Run() - cmd = exec.Command("apt-get", append([]string{"install", "-y"}, pkgs...)...) + _ = executil.Run("apt-get", []string{"update"}, packageCommandOptions) + name, args = "apt-get", append([]string{"install", "-y"}, pkgs...) case "dnf": - cmd = exec.Command("dnf", append([]string{"install", "-y"}, pkgs...)...) + name, args = "dnf", append([]string{"install", "-y"}, pkgs...) case "yum": - cmd = exec.Command("yum", append([]string{"install", "-y"}, pkgs...)...) + name, args = "yum", append([]string{"install", "-y"}, pkgs...) case "apk": - cmd = exec.Command("apk", append([]string{"add", "--no-cache"}, pkgs...)...) + name, args = "apk", append([]string{"add", "--no-cache"}, pkgs...) case "pacman": - cmd = exec.Command("pacman", append([]string{"-Syu", "--noconfirm", "--needed"}, pkgs...)...) + // Arch supports only full system upgrades. `pacman -S` can create a partial + // upgrade when the sync database is newer than installed packages, while + // `pacman -Syu` would let an account-invite command upgrade the whole host + // unattended. Neither is an acceptable implicit dependency action. + return fmt.Errorf("automatic pacman installation is disabled because Arch does not support partial upgrades; run a deliberate full system upgrade and install the required packages first") default: return fmt.Errorf("unsupported package manager: %q", pm) } - cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive") - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out))) + if out, err := executil.CombinedOutput(name, args, packageCommandOptions); err != nil { + return fmt.Errorf("%s: %w: %s", name, err, strings.TrimSpace(string(out))) } return nil } @@ -144,7 +168,7 @@ func SSHPort() int { if p, ok := sshPortFromSshdT(); ok { return p } - if p, ok := sshPortFromConfig(sshdConfigPath); ok { + if p, ok, _ := sshPortFromConfig(sshdConfigPath); ok { return p } return 22 @@ -161,27 +185,47 @@ func sshPortFromSshdT() (int, bool) { return 0, false } -func sshPortFromConfig(path string) (int, bool) { - f, err := os.Open(path) +func sshPortFromConfig(path string) (int, bool, error) { + f, err := os.OpenFile(path, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) if err != nil { - return 0, false + return 0, false, err } defer f.Close() - sc := bufio.NewScanner(f) + fi, err := f.Stat() + if err != nil { + return 0, false, err + } + if !fi.Mode().IsRegular() { + return 0, false, fmt.Errorf("sshd config %s is not a regular non-symlink file", path) + } + if fi.Size() > maxSSHDConfigBytes { + return 0, false, fmt.Errorf("sshd config %s exceeds %d-byte limit", path, maxSSHDConfigBytes) + } + limited := &io.LimitedReader{R: f, N: maxSSHDConfigBytes + 1} + sc := bufio.NewScanner(limited) + sc.Buffer(make([]byte, 64<<10), maxSSHDConfigLine) + var port int + found := false for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } fields := strings.Fields(line) - if len(fields) >= 2 && strings.EqualFold(fields[0], "port") { + if !found && len(fields) >= 2 && strings.EqualFold(fields[0], "port") { if p, err := strconv.Atoi(fields[1]); err == nil && p >= 1 && p <= 65535 { // First Port wins: sshd listens on every Port directive, and // sshPortFromSshdT returns the first, so the config fallback matches it // for a consistent hint (rather than the bash awk's last-wins). - return p, true + port, found = p, true } } } - return 0, false + if err := sc.Err(); err != nil { + return 0, false, fmt.Errorf("scan sshd config %s: %w", path, err) + } + if limited.N == 0 { + return 0, false, fmt.Errorf("sshd config %s exceeds %d-byte limit", path, maxSSHDConfigBytes) + } + return port, found, nil } diff --git a/internal/sysinfo/sysinfo_test.go b/internal/sysinfo/sysinfo_test.go index fbeb58d..aec400b 100644 --- a/internal/sysinfo/sysinfo_test.go +++ b/internal/sysinfo/sysinfo_test.go @@ -1,11 +1,27 @@ package sysinfo import ( + "context" + "errors" "os" "path/filepath" + "strings" "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/executil" + "golang.org/x/sys/unix" ) +func writeSysinfoCommand(t *testing.T, dir, name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + return path +} + func TestSSHPortFromConfig(t *testing.T) { cases := []struct { name string @@ -17,6 +33,7 @@ func TestSSHPortFromConfig(t *testing.T) { {"commented", "#Port 2222\n", 0, false}, {"first wins", "Port 1000\nPort 2020\n", 1000, true}, {"indented", " Port 2200\n", 2200, true}, + {"long preceding line", strings.Repeat("x", 70<<10) + "\nPort 2201\n", 2201, true}, {"none", "PermitRootLogin no\n", 0, false}, {"out of range", "Port 99999\n", 0, false}, } @@ -26,7 +43,10 @@ func TestSSHPortFromConfig(t *testing.T) { if err := os.WriteFile(p, []byte(c.content), 0o644); err != nil { t.Fatal(err) } - got, ok := sshPortFromConfig(p) + got, ok, err := sshPortFromConfig(p) + if err != nil { + t.Fatal(err) + } if got != c.want || ok != c.ok { t.Errorf("= (%d,%v), want (%d,%v)", got, ok, c.want, c.ok) } @@ -34,17 +54,121 @@ func TestSSHPortFromConfig(t *testing.T) { } } +func TestSSHPortFromConfigReportsScanFailure(t *testing.T) { + for _, content := range []string{ + strings.Repeat("x", maxSSHDConfigLine+1) + "\nPort 2202\n", + "Port 2202\n" + strings.Repeat("x", maxSSHDConfigLine+1), + } { + p := filepath.Join(t.TempDir(), "sshd_config") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if _, ok, err := sshPortFromConfig(p); err == nil || ok { + t.Fatalf("sshPortFromConfig = ok %v, err %v; want scanner failure", ok, err) + } + } +} + +func TestSSHPortFromConfigRejectsSpecialFilesWithoutBlocking(t *testing.T) { + dir := t.TempDir() + regular := filepath.Join(dir, "regular") + if err := os.WriteFile(regular, []byte("Port 2202\n"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(regular, link); err != nil { + t.Fatal(err) + } + if _, _, err := sshPortFromConfig(link); err == nil { + t.Fatal("symlinked sshd config was accepted") + } + fifo := filepath.Join(dir, "fifo") + if err := unix.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + start := time.Now() + if _, _, err := sshPortFromConfig(fifo); err == nil || !strings.Contains(err.Error(), "not a regular") { + t.Fatalf("FIFO config error = %v, want special-file refusal", err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("FIFO config blocked for %s", elapsed) + } +} + +func TestSSHPortFromConfigRejectsOversizedFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "sshd_config") + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Truncate(path, maxSSHDConfigBytes+1); err != nil { + t.Fatal(err) + } + if _, _, err := sshPortFromConfig(path); err == nil || !strings.Contains(err.Error(), "byte limit") { + t.Fatalf("oversized config error = %v, want bounded-read refusal", err) + } +} + func TestSSHPortDefault(t *testing.T) { old := sshdConfigPath sshdConfigPath = filepath.Join(t.TempDir(), "does-not-exist") t.Cleanup(func() { sshdConfigPath = old }) // No sshd and no config => default 22 (sshd may exist on this host; only assert // the config path fallback via sshPortFromConfig here). - if _, ok := sshPortFromConfig(sshdConfigPath); ok { + if _, ok, err := sshPortFromConfig(sshdConfigPath); ok || err == nil { t.Error("missing config should not yield a port") } } +func TestInstallPackagesUsesLongBoundedExecution(t *testing.T) { + old := packageCommandOptions + t.Cleanup(func() { packageCommandOptions = old }) + + t.Run("locale and argv", func(t *testing.T) { + packageCommandOptions = old + dir := t.TempDir() + writeSysinfoCommand(t, dir, "dnf", `[ "$DEBIAN_FRONTEND:$LC_ALL:$LANG:$*" = "noninteractive:C:C:install -y sudo passwd" ]`) + t.Setenv("PATH", dir) + if err := InstallPackages("dnf", []string{"sudo", "passwd"}); err != nil { + t.Fatalf("InstallPackages did not preserve env/argv: %v", err) + } + }) + + t.Run("pacman refuses an implicit partial or full upgrade", func(t *testing.T) { + packageCommandOptions = old + dir := t.TempDir() + writeSysinfoCommand(t, dir, "pacman", `exit 99`) + t.Setenv("PATH", dir) + if err := InstallPackages("pacman", []string{"sudo", "shadow"}); err == nil || !strings.Contains(err.Error(), "partial upgrades") { + t.Fatalf("InstallPackages pacman error = %v, want an explicit partial-upgrade refusal", err) + } + }) + + t.Run("timeout", func(t *testing.T) { + dir := t.TempDir() + writeSysinfoCommand(t, dir, "dnf", `/bin/sleep 30 & wait`) + t.Setenv("PATH", dir) + opts := old + opts.Timeout = 50 * time.Millisecond + packageCommandOptions = opts + if err := InstallPackages("dnf", []string{"sudo"}); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("InstallPackages error = %v, want timeout", err) + } + }) + + t.Run("output limit", func(t *testing.T) { + dir := t.TempDir() + writeSysinfoCommand(t, dir, "dnf", `while :; do printf 0123456789abcdef; done`) + t.Setenv("PATH", dir) + opts := old + opts.Timeout = time.Second + opts.MaxOutput = 64 + packageCommandOptions = opts + if err := InstallPackages("dnf", []string{"sudo"}); !errors.Is(err, executil.ErrOutputLimit) { + t.Fatalf("InstallPackages error = %v, want output limit", err) + } + }) +} + func TestPackageCandidate(t *testing.T) { if got := PackageCandidate("chage", "apt"); got != "passwd" { t.Errorf("chage/apt = %q, want passwd", got) diff --git a/internal/table/table.go b/internal/table/table.go index 634dc4a..c7adcb8 100644 --- a/internal/table/table.go +++ b/internal/table/table.go @@ -46,9 +46,9 @@ func (t *Table) Empty() bool { return len(t.rows) == 0 } // Render writes the table. Every column is as wide as its widest cell (header // included), measured in terminal columns. -func (t *Table) Render(w io.Writer) { +func (t *Table) Render(w io.Writer) error { if len(t.headers) == 0 { - return + return nil } widths := make([]int, len(t.headers)) for i, h := range t.headers { @@ -62,19 +62,22 @@ func (t *Table) Render(w io.Writer) { } } - io.WriteString(w, rule(widths, "┌", "┬", "┐")) - io.WriteString(w, line(t.headers, widths)) - io.WriteString(w, rule(widths, "├", "┼", "┤")) + var rendered strings.Builder + rendered.WriteString(rule(widths, "┌", "┬", "┐")) + rendered.WriteString(line(t.headers, widths)) + rendered.WriteString(rule(widths, "├", "┼", "┤")) for _, row := range t.rows { - io.WriteString(w, line(row, widths)) + rendered.WriteString(line(row, widths)) } - io.WriteString(w, rule(widths, "└", "┴", "┘")) + rendered.WriteString(rule(widths, "└", "┴", "┘")) + _, err := io.WriteString(w, rendered.String()) + return err } // String renders the table into a string. func (t *Table) String() string { var b strings.Builder - t.Render(&b) + _ = t.Render(&b) // strings.Builder.Write never returns an error return b.String() } diff --git a/internal/table/table_test.go b/internal/table/table_test.go index 4bd3285..0f7f33d 100644 --- a/internal/table/table_test.go +++ b/internal/table/table_test.go @@ -1,10 +1,16 @@ package table import ( + "errors" + "io" "strings" "testing" ) +type failingWriter struct{ err error } + +func (w failingWriter) Write([]byte) (int, error) { return 0, w.err } + func TestWidth(t *testing.T) { cases := []struct { in string @@ -62,6 +68,15 @@ func TestRenderShape(t *testing.T) { } } +func TestRenderPropagatesWriterError(t *testing.T) { + tb := New("A") + tb.Row("1") + err := tb.Render(failingWriter{err: io.ErrClosedPipe}) + if !errors.Is(err, io.ErrClosedPipe) { + t.Fatalf("Render error = %v, want writer failure", err) + } +} + // TestRowLengthMismatchCannotBreakBorders: a caller that miscounts cells must // still get a well-formed table rather than ragged rules. func TestRowLengthMismatchCannotBreakBorders(t *testing.T) { diff --git a/internal/user/user.go b/internal/user/user.go index 2aec987..d7535c6 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -9,19 +9,39 @@ package user import ( "errors" "fmt" + "io" "os" "os/exec" + "path/filepath" + "sort" "strconv" "strings" - "syscall" "time" "github.com/xxvcc/linux-temp-admin/internal/config" + "github.com/xxvcc/linux-temp-admin/internal/executil" + "github.com/xxvcc/linux-temp-admin/internal/validate" + "golang.org/x/sys/unix" ) // passwdPath is the account database; overridable in tests. var passwdPath = "/etc/passwd" +const maxLocalPasswdBytes = 64 << 20 + +var ( + nssCommandOptions = executil.Options{ + Timeout: 10 * time.Second, + MaxOutput: 256 << 10, + ExtraEnv: []string{"LC_ALL=C", "LANG=C"}, + } + accountCommandOptions = executil.Options{ + Timeout: 2 * time.Minute, + MaxOutput: 1 << 20, + ExtraEnv: []string{"LC_ALL=C", "LANG=C"}, + } +) + // Passwd is one /etc/passwd entry. type Passwd struct { Name string @@ -36,12 +56,11 @@ type Passwd struct { // A caller must distinguish a confirmed absence from an unreadable or malformed // account database; destructive lifecycle operations fail closed on err. func Lookup(name string) (Passwd, bool, error) { - // os.ReadFile, not a bufio.Scanner: a scanner ignores a mid-file read error and - // stops early, which would make an account later in the file look absent — and - // Lookup backs user.Exists, which the teardown trusts as ground truth. ReadFile - // either returns the whole file or an error, so a partial read can never - // masquerade as EOF or as a missing account. - data, err := os.ReadFile(passwdPath) + // Read the complete bounded file, not a bufio.Scanner: a scanner ignores a + // mid-file read error and stops early, which would make an account later in the + // file look absent. Lookup backs destructive existence checks, so partial or + // oversized input must fail closed rather than masquerade as EOF. + data, err := readPasswdDatabase(passwdPath, maxLocalPasswdBytes) if err != nil { return Passwd{}, false, fmt.Errorf("read passwd database: %w", err) } @@ -52,7 +71,7 @@ func Lookup(name string) (Passwd, bool, error) { } uid, err1 := strconv.Atoi(parts[2]) gid, err2 := strconv.Atoi(parts[3]) - if err1 != nil || err2 != nil { + if err1 != nil || err2 != nil || !validate.KernelID(uid) || !validate.KernelID(gid) { return Passwd{}, false, fmt.Errorf("malformed passwd entry for %s", name) } return Passwd{Name: parts[0], UID: uid, GID: gid, GECOS: parts[4], Home: parts[5], Shell: parts[6]}, true, nil @@ -60,6 +79,32 @@ func Lookup(name string) (Passwd, bool, error) { return Passwd{}, false, nil } +func readPasswdDatabase(path string, maxBytes int64) ([]byte, error) { + f, err := os.OpenFile(path, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, fmt.Errorf("%s is not a regular file", path) + } + if fi.Size() > maxBytes { + return nil, fmt.Errorf("%s exceeds %d-byte limit", path, maxBytes) + } + b, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > maxBytes { + return nil, fmt.Errorf("%s exceeds %d-byte limit", path, maxBytes) + } + return b, nil +} + // Exists reports whether name is a local account. func Exists(name string) (bool, error) { _, ok, err := Lookup(name) @@ -70,19 +115,38 @@ func Exists(name string) (bool, error) { // resolver knows name. Account ownership still comes only from /etc/passwd, but // invite must not create a local account that shadows an LDAP/SSSD identity. func NameInUse(name string) (bool, error) { + if !validate.Username(name) { + return false, fmt.Errorf("refusing NSS query for invalid username %q", name) + } local, err := Exists(name) if err != nil || local { return local, err } - err = exec.Command("id", "-u", name).Run() + out, err := executil.CombinedOutput("id", []string{"-u", "--", name}, nssCommandOptions) if err == nil { return true, nil } var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if errors.As(err, &exitErr) && idReportsUnknownUser(name, out) { return false, nil } - return false, fmt.Errorf("query NSS identity %s: %w", name, err) + diagnostic := strings.TrimSpace(string(out)) + if diagnostic == "" { + return false, fmt.Errorf("query NSS identity %s: %w", name, err) + } + return false, fmt.Errorf("query NSS identity %s: %w: %s", name, err, diagnostic) +} + +// idReportsUnknownUser recognizes only the C-locale diagnostics emitted for a +// confirmed miss by the implementations on supported systems: GNU coreutils on +// glibc, GNU coreutils on Alpine/musl (which appends EINVAL), and BusyBox on +// Alpine. Other nonzero exits may be an NSS/LDAP/SSSD failure and must not +// authorize creation of a shadowing account. +func idReportsUnknownUser(name string, out []byte) bool { + diagnostic := strings.TrimSpace(string(out)) + return diagnostic == fmt.Sprintf("id: '%s': no such user", name) || + diagnostic == fmt.Sprintf("id: '%s': no such user: Invalid argument", name) || + diagnostic == "id: unknown user "+name } // Groups returns pw's group names: its primary group, plus every group that @@ -92,7 +156,7 @@ func NameInUse(name string) (bool, error) { func Groups(pw Passwd) ([]string, error) { // Use the system identity resolver rather than parsing /etc/group: sshd also // consults NSS, so LDAP/SSSD memberships must participate in DenyGroups. - out, err := exec.Command("id", "-Gn", pw.Name).Output() + out, err := executil.Output("id", []string{"-Gn", pw.Name}, nssCommandOptions) if err != nil { return nil, fmt.Errorf("resolve groups for %s: %w", pw.Name, err) } @@ -103,25 +167,63 @@ func Groups(pw Passwd) ([]string, error) { return groups, nil } -// IsManaged reports whether name's GECOS carries the exact managed tag this tool -// sets — an exact match on the GECOS full-name subfield, not a bare substring, so -// a self-set partial GECOS cannot pose as managed. +// IsManaged reports whether name's GECOS carries a syntactically exact legacy or +// generation-bound marker written by this tool. Identity-sensitive callers must +// additionally match the generation stored in the registry. func IsManaged(name string) (bool, error) { pw, ok, err := Lookup(name) - return ok && hasManagedGECOS(pw.GECOS), err + return ok && IsManagedEntry(pw), err +} + +// IsManagedEntry recognizes both deployed fixed markers and well-formed dynamic +// markers. It is suitable for display and explicitly confirmed recovery only; +// registry-backed identity decisions must use MatchesManagedGeneration. +func IsManagedEntry(pw Passwd) bool { + name := gecosFullName(pw.GECOS) + if name == config.ManagedGECOS { + return true + } + generation, found := strings.CutPrefix(name, config.ManagedGenerationGECOSPrefix) + return found && validate.Generation(generation) +} + +// IsLegacyManagedEntry reports whether pw has the fixed marker used by released +// versions that could not bind the passwd entry to a registry generation. +func IsLegacyManagedEntry(pw Passwd) bool { + return gecosFullName(pw.GECOS) == config.ManagedGECOS +} + +// MatchesManagedGeneration requires the exact dynamic marker for generation. +// Matching only the username, UID, and legacy marker is unsafe because all three +// can be reproduced after an out-of-band account deletion and recreation. +func MatchesManagedGeneration(pw Passwd, generation string) bool { + return validate.Generation(generation) && + gecosFullName(pw.GECOS) == config.ManagedGenerationGECOSPrefix+generation } -// hasManagedGECOS reports whether a GECOS value is exactly the managed tag. It -// compares the first comma-separated subfield (the "full name"), because some -// account tools (and chfn) pad GECOS with trailing commas for the empty -// office/phone subfields; a plain substring match would let any GECOS that merely -// contains the tag pose as managed. -func hasManagedGECOS(gecos string) bool { +// ManagedGECOSForGeneration returns the exact completed marker for generation. +func ManagedGECOSForGeneration(generation string) (string, error) { + if !validate.Generation(generation) { + return "", fmt.Errorf("invalid account generation %q", generation) + } + return config.ManagedGenerationGECOSPrefix + generation, nil +} + +func pendingGECOSForGeneration(generation string) (string, error) { + if !validate.Generation(generation) { + return "", fmt.Errorf("invalid account generation %q", generation) + } + return config.PendingGenerationGECOSPrefix + generation, nil +} + +// gecosFullName returns the first comma-separated GECOS subfield. Account tools +// may pad the remaining office/phone fields with commas. +func gecosFullName(gecos string) string { name := gecos if i := strings.IndexByte(gecos, ','); i >= 0 { name = gecos[:i] } - return name == config.ManagedGECOS + return name } // protectedNames are never deletable regardless of registration. @@ -163,33 +265,43 @@ func IsReservedName(name string) bool { // An account that escalates itself to UID 0 stays protected: never auto-delete a // root account. The caller is expected to report that tamper rather than retry — // see UIDTampered. -func IsProtectedRevokeTarget(name string, registered bool, recordedUID int) (bool, error) { - if IsReservedName(name) { - return true, nil - } +func IsProtectedRevokeTarget(name string, registered bool, recordedUID int, recordedGeneration string, allowLegacy bool) (bool, error) { pw, ok, err := Lookup(name) if err != nil { return true, err } - if !ok { - return !registered, nil + return IsProtectedRevokeEntry(name, pw, ok, registered, recordedUID, recordedGeneration, allowLegacy), nil +} + +// IsProtectedRevokeEntry applies the revoke policy to one already-read passwd +// snapshot. Destructive callers must not splice the UID from one lookup together +// with the marker or name from another lookup while an account is being replaced. +func IsProtectedRevokeEntry(name string, pw Passwd, exists, registered bool, recordedUID int, recordedGeneration string, allowLegacy bool) bool { + if IsReservedName(name) { + return true + } + if !exists { + return !registered } if pw.UID == 0 { - return true, nil + return true } - if registered && recordedUID > 0 { - if pw.UID != recordedUID { - return true, nil + if registered { + if recordedUID < 1 || pw.UID != recordedUID { + return true } } - managed := hasManagedGECOS(pw.GECOS) + managed := IsManagedEntry(pw) + if registered { + managed = MatchesManagedGeneration(pw, recordedGeneration) || (allowLegacy && IsLegacyManagedEntry(pw)) + } if pw.UID < 1000 { - return !(registered && managed), nil + return !(registered && managed) } // UIDs are reusable. Even a matching recorded UID cannot prove that this is the // same account generation after an out-of-band deletion and recreation. Require // the per-account marker as well; ambiguity is safer to leave for an operator. - return !managed, nil + return !managed } // UIDTampered reports whether name's current UID differs from the one the @@ -225,7 +337,7 @@ type Runner interface { type execRunner struct{} func (execRunner) Run(name string, args ...string) error { - out, err := exec.Command(name, args...).CombinedOutput() + out, err := executil.CombinedOutput(name, args, accountCommandOptions) if err != nil { return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) } @@ -233,12 +345,13 @@ func (execRunner) Run(name string, args ...string) error { } func (execRunner) RunInput(stdin string, name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Stdin = strings.NewReader(stdin) - out, err := cmd.CombinedOutput() + opts := accountCommandOptions + opts.Stdin = strings.NewReader(stdin) + err := executil.Run(name, args, opts) if err != nil { - // The error text must never carry stdin back out: it holds the password. - return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) + // A malicious or broken helper can echo stdin to either output stream. Do + // not include any child output in this error: stdin holds the password. + return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) } return nil } @@ -251,26 +364,126 @@ type Manager struct{ Runner Runner } // New returns a Manager using real command execution. func New() *Manager { return &Manager{Runner: execRunner{}} } -// Create makes a new account with a home directory, the given login shell, and -// the managed GECOS tag, using useradd or (BusyBox) adduser. -func (m *Manager) Create(name, shell string) error { +// Create makes a new account with a generation-bound managed GECOS tag. Invite uses +// CreatePending instead so an older binary cannot mistake a pre-UID-registration +// account for a completed managed identity. +func (m *Manager) Create(name, shell, generation string) error { + gecos, err := ManagedGECOSForGeneration(generation) + if err != nil { + return err + } + return m.create(name, shell, gecos) +} + +// CreatePending makes an account whose GECOS is intentionally not the managed +// marker. The caller must persist the selected UID and then call MarkManaged +// before granting credentials or policy. +func (m *Manager) CreatePending(name, shell, generation string) error { + gecos, err := pendingGECOSForGeneration(generation) + if err != nil { + return err + } + return m.create(name, shell, gecos) +} + +func (m *Manager) create(name, shell, gecos string) error { + if !validate.Username(name) { + return fmt.Errorf("invalid username %q", name) + } + var err error switch { case m.Runner.Look("useradd"): - return m.Runner.Run("useradd", "-m", "-s", shell, "-c", config.ManagedGECOS, name) + err = m.Runner.Run("useradd", "-m", "-s", shell, "-c", gecos, name) case m.Runner.Look("adduser"): - return m.Runner.Run("adduser", "-D", "-s", shell, "-g", config.ManagedGECOS, name) + err = m.Runner.Run("adduser", "-D", "-s", shell, "-g", gecos, name) default: return fmt.Errorf("no useradd/adduser available") } + if err != nil { + return err + } + + // useradd/adduser choose a numeric UID automatically. A process left behind + // after an out-of-band deletion may still carry that number; giving it to the + // new account would immediately give the process ownership of the new home and + // any later sudo/key material. Check all four Linux credential UIDs before the + // caller is allowed to use the account, and roll the just-created account back + // whenever the check cannot prove the UID is idle. + pw, ok, lookupErr := Lookup(name) + if lookupErr != nil { + return m.rollbackCreate(name, fmt.Errorf("look up newly created account: %w", lookupErr)) + } + if !ok || pw.UID < 1 { + return m.rollbackCreate(name, fmt.Errorf("newly created account %s has no safe local UID", name)) + } + pids, scanErr := processesForUID(pw.UID) + if scanErr != nil { + return m.rollbackCreate(name, fmt.Errorf("scan processes before using UID %d: %w", pw.UID, scanErr)) + } + if len(pids) != 0 { + return m.rollbackCreate(name, fmt.Errorf("refusing reused UID %d: residual processes %v already carry it", pw.UID, pids)) + } + return nil +} + +// MarkManaged changes a pending account to the exact managed marker only after +// its numeric UID has been durably recorded. usermod is a required dependency +// for both invite and fail-closed revoke, so there is no weaker fallback here. +func (m *Manager) MarkManaged(name, generation string) error { + if !validate.Username(name) { + return fmt.Errorf("invalid username %q", name) + } + gecos, err := ManagedGECOSForGeneration(generation) + if err != nil { + return err + } + if !m.Runner.Look("usermod") { + return fmt.Errorf("usermod not available") + } + return m.Runner.Run("usermod", "-c", gecos, name) } -// LockPassword disables password login for name. -func (m *Manager) LockPassword(name string) error { return m.Runner.Run("usermod", "-L", name) } +func (m *Manager) rollbackCreate(name string, cause error) error { + if err := m.Delete(name); err != nil { + return errors.Join(cause, fmt.Errorf("rollback newly created account %s: %w", name, err)) + } + return cause +} + +// keyOnlyPasswordHash is deliberately not a valid crypt(3) result. Traditional +// DES crypt output is exactly 13 bytes, while modern Linux schemes start with +// '$' (or '_' for BSD extended DES). No password can reproduce this longer, +// unmarked value. Unlike a shadow value beginning with '!' or '*', it also does +// not make OpenSSH on Alpine reject the entire account before public-key auth. +const keyOnlyPasswordHash = "linux-temp-admin-key-only-password-disabled" + +// DisablePasswordForKeyLogin makes password authentication impossible without +// marking the whole account locked. This distinction is required on OpenSSH +// builds that reject a shadow-locked account even when its authorized key is +// valid (notably Alpine's default configuration). +func (m *Manager) DisablePasswordForKeyLogin(name string) error { + if !validate.Username(name) { + return fmt.Errorf("invalid username %q", name) + } + return m.Runner.Run("usermod", "-p", keyOnlyPasswordHash, name) +} + +// LockPassword locks name during revocation. Revoke also expires the account, +// so rejecting every authentication method is intentional here. +func (m *Manager) LockPassword(name string) error { + if !validate.Username(name) { + return fmt.Errorf("invalid username %q", name) + } + return m.Runner.Run("usermod", "-L", name) +} // SetPassword sets name's login password, for the --password-login invite on a // host whose sshd will not take a key. The password goes to chpasswd on stdin, // never in argv, so it cannot be read out of the process table. func (m *Manager) SetPassword(name, password string) error { + if !validate.Username(name) { + return fmt.Errorf("invalid username %q", name) + } if !m.Runner.Look("chpasswd") { return fmt.Errorf("chpasswd not available") } @@ -283,7 +496,12 @@ func (m *Manager) SetPassword(name, password string) error { } // SetExpiry sets the account expiry date (YYYY-MM-DD) via chage. -func (m *Manager) SetExpiry(name, date string) error { return m.Runner.Run("chage", "-E", date, name) } +func (m *Manager) SetExpiry(name, date string) error { + if !validate.Username(name) { + return fmt.Errorf("invalid username %q", name) + } + return m.Runner.Run("chage", "-E", date, name) +} // expiredDate is a date safely in the past; chage -E it to make an account // expired as of now. A literal date is used rather than "0" because chage's @@ -301,9 +519,8 @@ const expiredDate = "1970-01-01" // account alive. Expiry is the effective gate for a key-based account: locking // the password alone would not stop a public-key login. // -// Both steps are best-effort in the sense that the caller continues to the -// delete either way, but their errors are returned so the caller can say the -// door could not be shut. +// Both steps are attempted and their errors are returned. Destructive callers +// must stop before process termination or deletion unless both doors were shut. // // Both steps are ATTEMPTED even if the first fails. They guard different auth // vectors — expiry stops a key login, the lock stops a password login — so @@ -324,14 +541,34 @@ func (m *Manager) DisableLogin(name string) error { // makes the delete succeed against a stale utmp entry too. Deleting an account // out from under a live session is exactly what a revoke is asking for. func (m *Manager) Delete(name string) error { + if !validate.Username(name) { + return fmt.Errorf("invalid username %q", name) + } var delErr error if m.Runner.Look("deluser") { if delErr = m.Runner.Run("deluser", "--remove-home", name); delErr == nil { - return nil + absent, err := accountConfirmedAbsent(name) + if err != nil { + return fmt.Errorf("verify deluser removed %s: %w", name, err) + } + if absent { + return nil + } + delErr = fmt.Errorf("deluser reported success but account %s still exists", name) } } if m.Runner.Look("userdel") { - return m.Runner.Run("userdel", "-r", "-f", "--", name) + if err := m.Runner.Run("userdel", "-r", "-f", "--", name); err != nil { + return errors.Join(delErr, fmt.Errorf("userdel: %w", err)) + } + absent, err := accountConfirmedAbsent(name) + if err != nil { + return fmt.Errorf("verify userdel removed %s: %w", name, err) + } + if !absent { + return errors.Join(delErr, fmt.Errorf("userdel reported success but account %s still exists", name)) + } + return nil } // deluser ran and failed but there is no userdel to fall back to: return the // REAL deluser error, not a generic "no tool available". On BusyBox (deluser, @@ -343,16 +580,47 @@ func (m *Manager) Delete(name string) error { return fmt.Errorf("no userdel/deluser available") } -// kill is syscall.Kill, indirected so a test can observe which pids would be -// signalled without signalling anything. A test that called the real syscall to -// prove the uid guard holds would kill every root process if the guard broke. -var kill = syscall.Kill +func accountConfirmedAbsent(name string) (bool, error) { + exists, err := Exists(name) + if err != nil { + return false, err + } + return !exists, nil +} + +var ( + procRoot = "/proc" + pidfdOpen = unix.PidfdOpen + pidfdSendSignal = unix.PidfdSendSignal + closeFD = unix.Close + terminateSleep = time.Sleep +) // terminateSweeps bounds the SIGKILL retry loop. A handful of passes clears any // realistic fork loop; the bound keeps a process that cannot be killed at all (an // uninterruptible-sleep task) from spinning here forever while holding up revoke. const terminateSweeps = 5 +// CheckPidfd verifies that this kernel and sandbox allow pidfd operations. The +// revoke path relies on pidfds so a PID reused between inspection and signalling +// can never redirect a root-issued signal at an unrelated process. +func CheckPidfd() error { + fd, err := pidfdOpen(os.Getpid(), 0) + if err != nil { + return fmt.Errorf("pidfd is unavailable (Linux 5.3+ and permission from the process sandbox are required): %w", err) + } + signalErr := pidfdSendSignal(fd, 0, nil, 0) + closeErr := closeFD(fd) + var errs []error + if signalErr != nil { + errs = append(errs, fmt.Errorf("pidfd signalling is unavailable (permission from the process sandbox is required): %w", signalErr)) + } + if closeErr != nil { + errs = append(errs, fmt.Errorf("close pidfd capability probe: %w", closeErr)) + } + return errors.Join(errs...) +} + // TerminateProcesses signals SIGTERM then, after a grace period, SIGKILL to every // process owned by uid. It no-ops for a non-positive uid (never root/all). Done // natively via /proc (no pkill dependency). @@ -363,67 +631,181 @@ const terminateSweeps = 5 // the revoke as an orphan owned by a uid that is about to be recycled. Re-scanning // after each kill closes that window — each pass strictly shrinks the survivors, // since a killed parent cannot fork again. -func TerminateProcesses(uid int) { +func TerminateProcesses(uid int) error { if uid < 1 { - return + return nil + } + if !validate.AccountID(uid) { + return fmt.Errorf("refusing invalid Linux UID %d", uid) + } + var errs []error + pids, err := signalUID(unix.SIGTERM, uid) + if err != nil { + errs = append(errs, fmt.Errorf("signal UID %d processes with SIGTERM: %w", uid, err)) + } + if len(pids) != 0 { + terminateSleep(2 * time.Second) } - signalUID(syscall.SIGTERM, uid) - time.Sleep(2 * time.Second) for i := 0; i < terminateSweeps; i++ { - if n := signalUID(syscall.SIGKILL, uid); n == 0 { - return + pids, err = signalUID(unix.SIGKILL, uid) + if err != nil { + errs = append(errs, fmt.Errorf("signal UID %d processes with SIGKILL: %w", uid, err)) + } + if len(pids) == 0 { + return errors.Join(errs...) } + terminateSleep(100 * time.Millisecond) + } + survivors, err := processesForUID(uid) + if err != nil { + errs = append(errs, fmt.Errorf("final scan for UID %d: %w", uid, err)) + } else if len(survivors) != 0 { + errs = append(errs, fmt.Errorf("UID %d still has surviving processes %v after SIGKILL", uid, survivors)) } + return errors.Join(errs...) } -// signalUID sends sig to every process owned by uid and returns how many it -// signalled, so a caller can tell an empty sweep from a productive one. -func signalUID(sig syscall.Signal, uid int) int { - entries, err := os.ReadDir("/proc") +// signalUID first filters by credentials, then opens a pidfd and rechecks those +// credentials before signalling through the descriptor. The first filter avoids +// requiring pidfd access to every unrelated host process; the second check plus +// the pidfd means PID reuse can never redirect a signal at an unrelated process. +func signalUID(sig unix.Signal, uid int) ([]int, error) { + entries, err := os.ReadDir(procRoot) if err != nil { - return 0 + return nil, fmt.Errorf("scan %s: %w", procRoot, err) } - signalled := 0 + var signalled []int + var errs []error for _, e := range entries { pid, err := strconv.Atoi(e.Name()) if err != nil { continue } - ruid, euid, ok := procUIDs(pid) - if !ok { + status, uidErr := readProcStatus(pid) + if uidErr != nil { + if !errors.Is(uidErr, os.ErrNotExist) && !errors.Is(uidErr, unix.ESRCH) { + errs = append(errs, fmt.Errorf("read credentials for pid %d: %w", pid, uidErr)) + } + continue + } + if status.inactive || !containsUID(status.uids, uid) { continue } - if ruid == uid || euid == uid { - if kill(pid, sig) == nil { - signalled++ + fd, err := pidfdOpen(pid, 0) + if err == unix.ESRCH || err == unix.ENOENT { + continue + } + if err != nil { + errs = append(errs, fmt.Errorf("open pidfd for pid %d: %w", pid, err)) + continue + } + status, uidErr = readProcStatus(pid) + if uidErr != nil { + _ = closeFD(fd) + if !errors.Is(uidErr, os.ErrNotExist) && !errors.Is(uidErr, unix.ESRCH) { + errs = append(errs, fmt.Errorf("read credentials for pid %d: %w", pid, uidErr)) } + continue + } + // Zombies and already-dead tasks cannot execute, fork, or retain a usable + // credential. They are reaped only by their parent (or init), so repeatedly + // SIGKILLing them would make every revoke fail forever without improving + // isolation. + if status.inactive || !containsUID(status.uids, uid) { + _ = closeFD(fd) + continue + } + signalErr := pidfdSendSignal(fd, sig, nil, 0) + closeErr := closeFD(fd) + if signalErr == nil { + signalled = append(signalled, pid) + } else if signalErr != unix.ESRCH { + errs = append(errs, fmt.Errorf("signal pid %d: %w", pid, signalErr)) + } + if closeErr != nil { + errs = append(errs, fmt.Errorf("close pidfd for pid %d: %w", pid, closeErr)) } } - return signalled + sort.Ints(signalled) + return signalled, errors.Join(errs...) } -// procUIDs returns the real and effective UID from /proc//status. -func procUIDs(pid int) (ruid, euid int, ok bool) { - // Whole-file read: a scanner that errored before the Uid: line would drop this - // pid from the SIGKILL sweep silently. /proc//status is tiny. - data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) +func processesForUID(uid int) ([]int, error) { + entries, err := os.ReadDir(procRoot) if err != nil { - return 0, 0, false + return nil, fmt.Errorf("scan %s: %w", procRoot, err) } - for _, line := range strings.Split(string(data), "\n") { - if !strings.HasPrefix(line, "Uid:") { + var pids []int + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil { continue } - fields := strings.Fields(line) - if len(fields) < 3 { - return 0, 0, false + status, err := readProcStatus(pid) + if errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ESRCH) { + continue } - r, err1 := strconv.Atoi(fields[1]) - e, err2 := strconv.Atoi(fields[2]) - if err1 != nil || err2 != nil { - return 0, 0, false + if err != nil { + return nil, fmt.Errorf("read credentials for pid %d: %w", pid, err) + } + if !status.inactive && containsUID(status.uids, uid) { + pids = append(pids, pid) } - return r, e, true } - return 0, 0, false + sort.Ints(pids) + return pids, nil +} + +func containsUID(uids [4]int, uid int) bool { + for _, candidate := range uids { + if candidate == uid { + return true + } + } + return false +} + +type processStatus struct { + uids [4]int + inactive bool +} + +// readProcStatus returns Linux's real, effective, saved-set, and filesystem +// UIDs and whether the task is already a zombie/dead process awaiting reaping. +func readProcStatus(pid int) (processStatus, error) { + // Whole-file read: a scanner that errored before the Uid: line would drop this + // pid from the SIGKILL sweep silently. /proc//status is tiny. + data, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "status")) + if err != nil { + return processStatus{}, err + } + var status processStatus + foundUID := false + for _, line := range strings.Split(string(data), "\n") { + switch { + case strings.HasPrefix(line, "State:"): + fields := strings.Fields(line) + if len(fields) < 2 { + return processStatus{}, fmt.Errorf("malformed State line") + } + status.inactive = fields[1] == "Z" || fields[1] == "X" + case strings.HasPrefix(line, "Uid:"): + fields := strings.Fields(line) + if len(fields) != 5 { + return processStatus{}, fmt.Errorf("malformed Uid line") + } + for i := range status.uids { + parsed, err := strconv.Atoi(fields[i+1]) + if err != nil || !validate.KernelID(parsed) { + return processStatus{}, fmt.Errorf("malformed Uid value %q", fields[i+1]) + } + status.uids[i] = parsed + } + foundUID = true + } + } + if !foundUID { + return processStatus{}, fmt.Errorf("status has no Uid line") + } + return status, nil } diff --git a/internal/user/user_root_test.go b/internal/user/user_root_test.go index 64ec4ed..2b3cc51 100644 --- a/internal/user/user_root_test.go +++ b/internal/user/user_root_test.go @@ -20,8 +20,9 @@ func TestUserLifecycle(t *testing.T) { forceDelete() t.Cleanup(forceDelete) + const generation = "0123456789abcdef0123456789abcdef" m := New() - if err := m.Create(name, "/bin/sh"); err != nil { + if err := m.Create(name, "/bin/sh", generation); err != nil { t.Fatalf("Create: %v", err) } exists, err := Exists(name) @@ -35,12 +36,15 @@ func TestUserLifecycle(t *testing.T) { if !ok || pw.UID < 1 { t.Fatalf("Lookup after create: %+v ok=%v", pw, ok) } + if !MatchesManagedGeneration(pw, generation) { + t.Error("created account marker does not match its generation") + } managed, err := IsManaged(name) if err != nil || !managed { t.Error("created account should carry the managed GECOS tag") } - if err := m.LockPassword(name); err != nil { - t.Errorf("LockPassword: %v", err) + if err := m.DisablePasswordForKeyLogin(name); err != nil { + t.Errorf("DisablePasswordForKeyLogin: %v", err) } if err := m.SetExpiry(name, "2999-01-01"); err != nil { t.Errorf("SetExpiry: %v", err) diff --git a/internal/user/user_test.go b/internal/user/user_test.go index 01be119..4cc3012 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -1,13 +1,21 @@ package user import ( + "context" + "errors" + "fmt" "os" "path/filepath" "reflect" + "strconv" + "strings" "syscall" "testing" + "time" "github.com/xxvcc/linux-temp-admin/internal/config" + "github.com/xxvcc/linux-temp-admin/internal/executil" + "golang.org/x/sys/unix" ) // setPasswd points Lookup at a temporary passwd file for the test. @@ -22,13 +30,25 @@ func setPasswd(t *testing.T, content string) { t.Cleanup(func() { passwdPath = old }) } +func writeUserCommand(t *testing.T, dir, name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +const testGeneration = "0123456789abcdef0123456789abcdef" + const samplePasswd = `root:x:0:0:root:/root:/bin/bash svc:x:200:200::/var/lib/svc:/usr/sbin/nologin human:x:1000:1000:A Human:/home/human:/bin/bash -tmp1000:x:1001:1001:` + config.ManagedGECOS + `,,,:/home/tmp1000:/bin/bash -tmp500:x:500:500:` + config.ManagedGECOS + `:/home/tmp500:/bin/bash +tmp1000:x:1001:1001:` + config.ManagedGenerationGECOSPrefix + testGeneration + `,,,:/home/tmp1000:/bin/bash +tmp500:x:500:500:` + config.ManagedGenerationGECOSPrefix + testGeneration + `:/home/tmp500:/bin/bash +legacy:x:1004:1004:` + config.ManagedGECOS + `:/home/legacy:/bin/bash wiped:x:1002:1002:not the marker any more:/home/wiped:/bin/bash -escalated:x:0:0:` + config.ManagedGECOS + `:/home/escalated:/bin/bash +escalated:x:0:0:` + config.ManagedGenerationGECOSPrefix + testGeneration + `:/home/escalated:/bin/bash ` func TestLookupAndManaged(t *testing.T) { @@ -51,6 +71,76 @@ func TestLookupAndManaged(t *testing.T) { } } +func TestGenerationBoundManagedMarkers(t *testing.T) { + const otherGeneration = "fedcba9876543210fedcba9876543210" + tests := []struct { + name string + gecos string + managed bool + legacy bool + matchesGen bool + }{ + {name: "legacy", gecos: config.ManagedGECOS + ",,,", managed: true, legacy: true}, + {name: "bound", gecos: config.ManagedGenerationGECOSPrefix + testGeneration + ",,,", managed: true, matchesGen: true}, + {name: "other generation", gecos: config.ManagedGenerationGECOSPrefix + otherGeneration, managed: true}, + {name: "malformed generation", gecos: config.ManagedGenerationGECOSPrefix + "short"}, + {name: "substring", gecos: "prefix " + config.ManagedGECOS}, + {name: "pending", gecos: config.PendingGenerationGECOSPrefix + testGeneration}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pw := Passwd{GECOS: tc.gecos} + if got := IsManagedEntry(pw); got != tc.managed { + t.Errorf("IsManagedEntry = %v, want %v", got, tc.managed) + } + if got := IsLegacyManagedEntry(pw); got != tc.legacy { + t.Errorf("IsLegacyManagedEntry = %v, want %v", got, tc.legacy) + } + if got := MatchesManagedGeneration(pw, testGeneration); got != tc.matchesGen { + t.Errorf("MatchesManagedGeneration = %v, want %v", got, tc.matchesGen) + } + }) + } + if _, err := ManagedGECOSForGeneration("short"); err == nil { + t.Fatal("ManagedGECOSForGeneration accepted an invalid generation") + } +} + +func TestLookupRejectsReservedKernelIDs(t *testing.T) { + if strconv.IntSize < 64 { + t.Skip("int cannot represent the reserved uint32 uid/gid sentinel") + } + reserved := uint64(^uint32(0)) + for _, entry := range []string{ + fmt.Sprintf("baduid:x:%d:1000::/home/baduid:/bin/sh\n", reserved), + fmt.Sprintf("badgid:x:1000:%d::/home/badgid:/bin/sh\n", reserved), + } { + setPasswd(t, entry) + name := strings.SplitN(entry, ":", 2)[0] + if _, _, err := Lookup(name); err == nil || !strings.Contains(err.Error(), "malformed passwd entry") { + t.Fatalf("Lookup(%s) error = %v, want invalid uid/gid refusal", name, err) + } + } +} + +func TestReadPasswdDatabaseIsBoundedAndRejectsFIFO(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "passwd") + if err := os.WriteFile(path, []byte("12345"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readPasswdDatabase(path, 4); err == nil || !strings.Contains(err.Error(), "4-byte limit") { + t.Fatalf("oversized passwd error = %v, want bounded-read refusal", err) + } + fifo := filepath.Join(dir, "passwd.fifo") + if err := unix.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + if _, err := readPasswdDatabase(fifo, 4); err == nil || !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("FIFO passwd error = %v, want regular-file refusal", err) + } +} + func TestNameInUseConsultsNSSAfterLocalMiss(t *testing.T) { // Hide every local row from this package while leaving the real resolver // available. root must still be found through `id`, exercising the same path @@ -73,6 +163,94 @@ func TestNameInUseFailsClosedWithoutResolver(t *testing.T) { } } +func TestNameInUseDistinguishesConfirmedMissFromResolverFailure(t *testing.T) { + tests := []struct { + name string + diagnostic string + wantErr bool + }{ + {"gnu missing user", `printf "id: '%s': no such user\n" "$3" >&2; exit 1`, false}, + {"gnu musl missing user", `printf "id: '%s': no such user: Invalid argument\n" "$3" >&2; exit 1`, false}, + {"busybox missing user", `printf "id: unknown user %s\n" "$3" >&2; exit 1`, false}, + {"different GNU errno", `printf "id: '%s': no such user: Resource temporarily unavailable\n" "$3" >&2; exit 1`, true}, + {"NSS backend failure", `printf "id: NSS backend unavailable\n" >&2; exit 1`, true}, + {"unclassified empty failure", `exit 2`, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setPasswd(t, "") + dir := t.TempDir() + writeUserCommand(t, dir, "id", `[ "$1:$2" = "-u:--" ] || exit 9 +`+tt.diagnostic) + t.Setenv("PATH", dir) + + inUse, err := NameInUse("ldap-user") + if inUse { + t.Fatal("failed identity query reported the username as in use") + } + if (err != nil) != tt.wantErr { + t.Fatalf("NameInUse error = %v, wantErr=%v", err, tt.wantErr) + } + }) + } +} + +func TestNSSCommandsAreBoundedAndUseCLocale(t *testing.T) { + old := nssCommandOptions + t.Cleanup(func() { nssCommandOptions = old }) + + t.Run("name probe locale", func(t *testing.T) { + setPasswd(t, "") + dir := t.TempDir() + writeUserCommand(t, dir, "id", `[ "$LC_ALL:$LANG:$1:$2:$3" = "C:C:-u:--:ldap-user" ]`) + t.Setenv("PATH", dir) + inUse, err := NameInUse("ldap-user") + if err != nil || !inUse { + t.Fatalf("NameInUse = %v, %v; helper did not receive C locale/expected argv", inUse, err) + } + }) + + t.Run("groups locale", func(t *testing.T) { + dir := t.TempDir() + writeUserCommand(t, dir, "id", `[ "$LC_ALL:$LANG:$1:$2" = "C:C:-Gn:alice" ] || exit 9 +printf 'primary extra\n'`) + t.Setenv("PATH", dir) + groups, err := Groups(Passwd{Name: "alice"}) + if err != nil || !reflect.DeepEqual(groups, []string{"primary", "extra"}) { + t.Fatalf("Groups = %v, %v; helper did not receive C locale/expected argv", groups, err) + } + }) + + t.Run("timeout", func(t *testing.T) { + setPasswd(t, "") + dir := t.TempDir() + writeUserCommand(t, dir, "id", `/bin/sleep 30 & wait`) + t.Setenv("PATH", dir) + opts := old + opts.Timeout = 50 * time.Millisecond + nssCommandOptions = opts + _, err := NameInUse("slow-user") + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("NameInUse error = %v, want bounded timeout", err) + } + }) + + t.Run("output limit", func(t *testing.T) { + setPasswd(t, "") + dir := t.TempDir() + writeUserCommand(t, dir, "id", `while :; do printf 0123456789abcdef; done`) + t.Setenv("PATH", dir) + opts := old + opts.Timeout = time.Second + opts.MaxOutput = 64 + nssCommandOptions = opts + _, err := NameInUse("noisy-user") + if !errors.Is(err, executil.ErrOutputLimit) { + t.Fatalf("NameInUse error = %v, want output limit", err) + } + }) +} + func TestLookupErrorsAreNotAbsence(t *testing.T) { old := passwdPath passwdPath = t.TempDir() // ReadFile on a directory fails. @@ -83,7 +261,7 @@ func TestLookupErrorsAreNotAbsence(t *testing.T) { if _, err := Exists("someone"); err == nil { t.Fatal("Exists must preserve the passwd read error") } - if _, err := IsProtectedRevokeTarget("someone", true, 1001); err == nil { + if _, err := IsProtectedRevokeTarget("someone", true, 1001, testGeneration, false); err == nil { t.Fatal("revoke protection must fail closed on a passwd read error") } } @@ -107,7 +285,7 @@ func TestIsReservedName(t *testing.T) { // Every reserved name must also be refused by the revoke path (defense in // depth: the two sides share this predicate and must never diverge). for _, n := range reserved { - if protected, err := IsProtectedRevokeTarget(n, true, 0); err != nil || !protected { + if protected, err := IsProtectedRevokeTarget(n, true, 0, testGeneration, false); err != nil || !protected { t.Errorf("reserved %q is not a protected revoke target", n) } } @@ -119,30 +297,34 @@ func TestIsProtectedRevokeTarget(t *testing.T) { name string registered bool recordedUID int // 0 = an older registry row that recorded no UID + generation string + allowLegacy bool want bool }{ - {"root", false, 0, true}, // uid 0 / blocklist - {"daemon", false, 0, true}, // blocklist (not in passwd) - {"systemd-network", false, 0, true}, // systemd- prefix - {"svc", false, 0, true}, // system uid, unregistered - {"svc", true, 0, true}, // system uid, registered but not managed - {"tmp500", false, 0, true}, // managed system uid but unregistered - {"tmp500", true, 0, false}, // managed + registered system uid -> deletable - {"human", false, 0, true}, // real uid, unregistered human - {"human", true, 0, true}, // real uid, registered but NOT managed -> protected (stale entry / name reuse) - {"tmp1000", false, 0, false}, // managed real uid -> deletable even unregistered + {"root", false, 0, "", false, true}, // uid 0 / blocklist + {"daemon", false, 0, "", false, true}, // blocklist (not in passwd) + {"systemd-network", false, 0, "", false, true}, // systemd- prefix + {"svc", false, 0, "", false, true}, // system uid, unregistered + {"svc", true, 0, testGeneration, false, true}, // system uid, registered but not managed + {"tmp500", false, 0, "", false, true}, // managed system uid but unregistered + {"tmp500", true, 500, testGeneration, false, false}, // generation-bound + registered system uid -> deletable + {"human", false, 0, "", false, true}, // real uid, unregistered human + {"human", true, 0, testGeneration, false, true}, // real uid, registered but NOT managed -> protected + {"tmp1000", false, 0, "", false, false}, // managed real uid -> explicit unregistered recovery may delete + {"legacy", true, 1004, "", false, true}, // fixed legacy marker is not identity proof + {"legacy", true, 1004, "", true, false}, // direct force recovery may accept it // A recorded UID detects contradictions but cannot prove identity on its own: // Linux can reuse the same UID after an account is deleted and recreated. - {"wiped", true, 1002, true}, // marker erased: UID alone is reusable and cannot prove identity - {"wiped", true, 0, true}, // same account, legacy row with no recorded uid -> old GECOS rule -> protected - {"wiped", false, 1002, true}, // unregistered: a recorded uid we never wrote proves nothing - {"wiped", true, 9999, true}, // recorded uid does NOT match -> not the account we made -> protected + {"wiped", true, 1002, testGeneration, false, true}, // marker erased + {"wiped", true, 0, testGeneration, false, true}, // no recorded uid + {"wiped", false, 1002, "", false, true}, // unregistered + {"wiped", true, 9999, testGeneration, false, true}, // recorded uid mismatch // A recorded UID must never make a real account deletable, even when it // matches exactly: the username and UID can both be reused. - {"human", true, 1000, true}, // matching UID can belong to a recreated real account - {"human", true, 1234, true}, // recorded uid disagrees with passwd -> real account stays protected + {"human", true, 1000, testGeneration, false, true}, // matching UID can belong to a recreated real account + {"human", true, 1234, testGeneration, false, true}, // recorded uid disagrees // A recorded UID that disagrees is not a MISSING witness but a CONTRADICTING // one, and the marker must not overrule it. The two rows above only ever @@ -150,25 +332,34 @@ func TestIsProtectedRevokeTarget(t *testing.T) { // that decides it — marker intact, recorded UID contradicting — went untested // and returned "deletable". revoke then aimed its SIGKILL sweep at the UID in // passwd, i.e. at whatever UID the account had been given. - {"tmp1000", true, 9999, true}, // marker intact BUT recorded uid contradicts -> protected + {"tmp1000", true, 9999, testGeneration, false, true}, // marker intact BUT recorded uid contradicts + {"tmp1000", true, 1001, "fedcba9876543210fedcba9876543210", false, true}, // wrong generation // Escalating to uid 0 stays protected — never auto-delete a root account — // even though it is registered, managed, and its name is ours. - {"escalated", true, 1003, true}, - {"escalated", true, 0, true}, + {"escalated", true, 1003, testGeneration, false, true}, + {"escalated", true, 0, testGeneration, false, true}, } for _, c := range cases { - got, err := IsProtectedRevokeTarget(c.name, c.registered, c.recordedUID) + got, err := IsProtectedRevokeTarget(c.name, c.registered, c.recordedUID, c.generation, c.allowLegacy) if err != nil { t.Fatalf("IsProtectedRevokeTarget(%q): %v", c.name, err) } if got != c.want { - t.Errorf("IsProtectedRevokeTarget(%q, registered=%v, recordedUID=%d) = %v, want %v", - c.name, c.registered, c.recordedUID, got, c.want) + t.Errorf("IsProtectedRevokeTarget(%q, registered=%v, recordedUID=%d, generation=%q, allowLegacy=%v) = %v, want %v", + c.name, c.registered, c.recordedUID, c.generation, c.allowLegacy, got, c.want) } } } +func TestIsProtectedRevokeEntryUsesSuppliedSnapshot(t *testing.T) { + setPasswd(t, "same:x:4321:4321:"+config.ManagedGECOS+":/home/same:/bin/bash\n") + snapshot := Passwd{Name: "same", UID: 1234, GID: 1234, GECOS: "Real Person", Home: "/srv/same", Shell: "/bin/sh"} + if !IsProtectedRevokeEntry("same", snapshot, true, true, 1234, testGeneration, false) { + t.Fatal("supplied untrusted snapshot was ignored in favor of a second passwd lookup") + } +} + type fakeRunner struct { available map[string]bool failOn map[string]bool @@ -190,47 +381,378 @@ func (f *fakeRunner) RunInput(stdin string, name string, args ...string) error { func (f *fakeRunner) Look(name string) bool { return f.available[name] } +func TestAccountMutationsRejectInvalidUsernameBeforeRunningHelpers(t *testing.T) { + tests := []struct { + name string + run func(*Manager) error + }{ + {name: "create", run: func(m *Manager) error { return m.Create("bad:user", "/bin/sh", testGeneration) }}, + {name: "create pending", run: func(m *Manager) error { return m.CreatePending("bad:user", "/bin/sh", testGeneration) }}, + {name: "mark managed", run: func(m *Manager) error { return m.MarkManaged("bad:user", testGeneration) }}, + {name: "disable key password", run: func(m *Manager) error { return m.DisablePasswordForKeyLogin("bad:user") }}, + {name: "lock password", run: func(m *Manager) error { return m.LockPassword("bad:user") }}, + {name: "set password", run: func(m *Manager) error { return m.SetPassword("bad:user", "secret") }}, + {name: "set expiry", run: func(m *Manager) error { return m.SetExpiry("bad:user", "2026-07-09") }}, + {name: "delete", run: func(m *Manager) error { return m.Delete("bad:user") }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f := &fakeRunner{available: map[string]bool{ + "useradd": true, "adduser": true, "usermod": true, + "chpasswd": true, "chage": true, "deluser": true, "userdel": true, + }} + if err := tc.run(&Manager{Runner: f}); err == nil || !strings.Contains(err.Error(), "invalid username") { + t.Fatalf("mutation error = %v, want username refusal", err) + } + if len(f.calls) != 0 || len(f.stdin) != 0 { + t.Fatalf("invalid username reached helper: calls=%v stdin=%v", f.calls, f.stdin) + } + }) + } +} + var errForced = &forcedErr{} type forcedErr struct{} func (*forcedErr) Error() string { return "forced failure" } +func TestExecRunnerBoundsCommandsAndNeverEchoesSecretInput(t *testing.T) { + old := accountCommandOptions + t.Cleanup(func() { accountCommandOptions = old }) + runner := execRunner{} + + t.Run("locale", func(t *testing.T) { + accountCommandOptions = old + cmd := writeUserCommand(t, t.TempDir(), "account-helper", `[ "$LC_ALL:$LANG" = "C:C" ]`) + if err := runner.Run(cmd); err != nil { + t.Fatalf("Run did not force the C locale: %v", err) + } + }) + + t.Run("output limit", func(t *testing.T) { + opts := old + opts.Timeout = time.Second + opts.MaxOutput = 64 + accountCommandOptions = opts + cmd := writeUserCommand(t, t.TempDir(), "account-helper", `while :; do printf 0123456789abcdef; done`) + if err := runner.Run(cmd); !errors.Is(err, executil.ErrOutputLimit) { + t.Fatalf("Run error = %v, want output limit", err) + } + }) + + t.Run("secret output discarded", func(t *testing.T) { + accountCommandOptions = old + cmd := writeUserCommand(t, t.TempDir(), "account-helper", `IFS= read -r value +printf 'child echoed %s\n' "$value" >&2 +exit 1`) + const secret = "alice:correct-horse-battery-staple" + err := runner.RunInput(secret+"\n", cmd) + if err == nil { + t.Fatal("RunInput accepted a failing helper") + } + if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), "child echoed") { + t.Fatalf("RunInput leaked child output containing stdin: %v", err) + } + }) +} + func TestCreateArgvUseradd(t *testing.T) { + marker := config.ManagedGenerationGECOSPrefix + testGeneration + setPasswd(t, "xxvcc-a1:x:2345:2345:"+marker+":/home/xxvcc-a1:/bin/bash\n") + setProcRoot(t, map[int]string{}) f := &fakeRunner{available: map[string]bool{"useradd": true, "adduser": true}} m := &Manager{Runner: f} - if err := m.Create("xxvcc-a1", "/bin/bash"); err != nil { + if err := m.Create("xxvcc-a1", "/bin/bash", testGeneration); err != nil { t.Fatal(err) } - want := []string{"useradd", "-m", "-s", "/bin/bash", "-c", config.ManagedGECOS, "xxvcc-a1"} + want := []string{"useradd", "-m", "-s", "/bin/bash", "-c", marker, "xxvcc-a1"} if len(f.calls) != 1 || !reflect.DeepEqual(f.calls[0], want) { t.Errorf("useradd argv = %v, want %v", f.calls, want) } } +func TestCreatePendingAndMarkManagedArgv(t *testing.T) { + pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration + managedMarker := config.ManagedGenerationGECOSPrefix + testGeneration + setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/bash\n") + setProcRoot(t, map[int]string{}) + f := &fakeRunner{available: map[string]bool{"useradd": true, "usermod": true}} + m := &Manager{Runner: f} + if err := m.CreatePending("xxvcc-a1", "/bin/bash", testGeneration); err != nil { + t.Fatal(err) + } + if err := m.MarkManaged("xxvcc-a1", testGeneration); err != nil { + t.Fatal(err) + } + want := [][]string{ + {"useradd", "-m", "-s", "/bin/bash", "-c", pendingMarker, "xxvcc-a1"}, + {"usermod", "-c", managedMarker, "xxvcc-a1"}, + } + if !reflect.DeepEqual(f.calls, want) { + t.Fatalf("pending identity argv = %v, want %v", f.calls, want) + } +} + +func TestMarkManagedRequiresUsermod(t *testing.T) { + if err := (&Manager{Runner: &fakeRunner{}}).MarkManaged("xxvcc-a1", testGeneration); err == nil { + t.Fatal("MarkManaged accepted a host without usermod") + } +} + func TestCreateArgvAdduserBusybox(t *testing.T) { + marker := config.ManagedGenerationGECOSPrefix + testGeneration + setPasswd(t, "xxvcc-a1:x:2345:2345:"+marker+":/home/xxvcc-a1:/bin/sh\n") + setProcRoot(t, map[int]string{}) f := &fakeRunner{available: map[string]bool{"adduser": true}} // no useradd m := &Manager{Runner: f} - if err := m.Create("xxvcc-a1", "/bin/sh"); err != nil { + if err := m.Create("xxvcc-a1", "/bin/sh", testGeneration); err != nil { t.Fatal(err) } - want := []string{"adduser", "-D", "-s", "/bin/sh", "-g", config.ManagedGECOS, "xxvcc-a1"} + want := []string{"adduser", "-D", "-s", "/bin/sh", "-g", marker, "xxvcc-a1"} if !reflect.DeepEqual(f.calls[0], want) { t.Errorf("adduser argv = %v, want %v", f.calls[0], want) } } +func setProcRoot(t *testing.T, statuses map[int]string) { + t.Helper() + dir := t.TempDir() + for pid, status := range statuses { + pidDir := filepath.Join(dir, fmt.Sprint(pid)) + if err := os.Mkdir(pidDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pidDir, "status"), []byte(status), 0o600); err != nil { + t.Fatal(err) + } + } + old := procRoot + procRoot = dir + t.Cleanup(func() { procRoot = old }) +} + +func TestCreateRejectsUIDWithResidualProcess(t *testing.T) { + marker := config.ManagedGenerationGECOSPrefix + testGeneration + setPasswd(t, "xxvcc-a1:x:2345:2345:"+marker+":/home/xxvcc-a1:/bin/sh\n") + // The target UID appears only in the saved-set UID column. Checking only real + // and effective UIDs would miss this process, which can switch back to 2345. + setProcRoot(t, map[int]string{77: "Name:\tleftover\nUid:\t1000\t1000\t2345\t1000\n"}) + f := &fakeRunner{available: map[string]bool{"useradd": true, "userdel": true}} + err := (&Manager{Runner: f}).Create("xxvcc-a1", "/bin/sh", testGeneration) + if err == nil || !strings.Contains(err.Error(), "UID 2345") || !strings.Contains(err.Error(), "77") { + t.Fatalf("Create error = %v, want residual-UID process refusal", err) + } + want := [][]string{ + {"useradd", "-m", "-s", "/bin/sh", "-c", marker, "xxvcc-a1"}, + {"userdel", "-r", "-f", "--", "xxvcc-a1"}, + } + if !reflect.DeepEqual(f.calls, want) { + t.Fatalf("Create calls = %v, want create followed by rollback %v", f.calls, want) + } +} + +func TestCreateFailsClosedWhenProcCannotBeScanned(t *testing.T) { + setPasswd(t, "xxvcc-a1:x:2345:2345:"+config.ManagedGenerationGECOSPrefix+testGeneration+":/home/xxvcc-a1:/bin/sh\n") + old := procRoot + procRoot = filepath.Join(t.TempDir(), "missing") + t.Cleanup(func() { procRoot = old }) + f := &fakeRunner{available: map[string]bool{"useradd": true, "userdel": true}} + if err := (&Manager{Runner: f}).Create("xxvcc-a1", "/bin/sh", testGeneration); err == nil || !strings.Contains(err.Error(), "scan") { + t.Fatalf("Create error = %v, want proc scan failure", err) + } + if len(f.calls) != 2 || f.calls[1][0] != "userdel" { + t.Fatalf("failed safety check did not roll back account: calls=%v", f.calls) + } +} + +func TestProcessesForUIDChecksAllFourUIDColumns(t *testing.T) { + setProcRoot(t, map[int]string{ + 11: "Uid:\t1111\t2000\t2000\t2000\n", + 12: "Uid:\t2000\t1111\t2000\t2000\n", + 13: "Uid:\t2000\t2000\t1111\t2000\n", + 14: "Uid:\t2000\t2000\t2000\t1111\n", + }) + pids, err := processesForUID(1111) + if err != nil { + t.Fatal(err) + } + if want := []int{11, 12, 13, 14}; !reflect.DeepEqual(pids, want) { + t.Fatalf("processesForUID = %v, want %v", pids, want) + } +} + +func TestProcessesForUIDIgnoresZombieAndDeadTasks(t *testing.T) { + setProcRoot(t, map[int]string{ + 11: "State:\tZ (zombie)\nUid:\t1111\t1111\t1111\t1111\n", + 12: "State:\tX (dead)\nUid:\t1111\t1111\t1111\t1111\n", + 13: "State:\tS (sleeping)\nUid:\t1111\t1111\t1111\t1111\n", + }) + pids, err := processesForUID(1111) + if err != nil { + t.Fatal(err) + } + if want := []int{13}; !reflect.DeepEqual(pids, want) { + t.Fatalf("processesForUID = %v, want only live tasks %v", pids, want) + } +} + +func withFakePidfds(t *testing.T, send func(int, unix.Signal, *unix.Siginfo, int) error) { + t.Helper() + oldOpen, oldSend, oldClose, oldSleep := pidfdOpen, pidfdSendSignal, closeFD, terminateSleep + pidfdOpen = func(pid, flags int) (int, error) { return pid + 10000, nil } + pidfdSendSignal = send + closeFD = func(int) error { return nil } + terminateSleep = func(time.Duration) {} + t.Cleanup(func() { + pidfdOpen, pidfdSendSignal, closeFD, terminateSleep = oldOpen, oldSend, oldClose, oldSleep + }) +} + +func TestCheckPidfdReportsKernelOrSandboxFailure(t *testing.T) { + oldOpen, oldSend, oldClose := pidfdOpen, pidfdSendSignal, closeFD + pidfdOpen = func(pid, flags int) (int, error) { + if pid != os.Getpid() || flags != 0 { + t.Fatalf("PidfdOpen(%d, %d), want self", pid, flags) + } + return -1, syscall.ENOSYS + } + pidfdSendSignal = func(int, unix.Signal, *unix.Siginfo, int) error { + t.Fatal("signal called after failed pidfd open") + return nil + } + closeFD = func(int) error { t.Fatal("close called after failed pidfd probe"); return nil } + t.Cleanup(func() { pidfdOpen, pidfdSendSignal, closeFD = oldOpen, oldSend, oldClose }) + if err := CheckPidfd(); err == nil || !errors.Is(err, syscall.ENOSYS) { + t.Fatalf("CheckPidfd error=%v, want ENOSYS", err) + } +} + +func TestCheckPidfdReportsSignalFailureAndClosesDescriptor(t *testing.T) { + oldOpen, oldSend, oldClose := pidfdOpen, pidfdSendSignal, closeFD + pidfdOpen = func(int, int) (int, error) { return 42, nil } + pidfdSendSignal = func(fd int, sig unix.Signal, info *unix.Siginfo, flags int) error { + if fd != 42 || sig != 0 || info != nil || flags != 0 { + t.Fatalf("PidfdSendSignal(%d, %d, %v, %d), want harmless self probe", fd, sig, info, flags) + } + return syscall.EPERM + } + closed := false + closeFD = func(fd int) error { + if fd != 42 { + t.Fatalf("close(%d), want 42", fd) + } + closed = true + return nil + } + t.Cleanup(func() { pidfdOpen, pidfdSendSignal, closeFD = oldOpen, oldSend, oldClose }) + if err := CheckPidfd(); err == nil || !errors.Is(err, syscall.EPERM) { + t.Fatalf("CheckPidfd signal error=%v, want EPERM", err) + } + if !closed { + t.Fatal("pidfd was not closed after the signalling probe failed") + } +} + +func TestTerminateProcessesDoesNotOpenPidfdsForUnrelatedUIDs(t *testing.T) { + setProcRoot(t, map[int]string{77: "State:\tS (sleeping)\nUid:\t9999\t9999\t9999\t9999\n"}) + oldOpen := pidfdOpen + opened := 0 + pidfdOpen = func(int, int) (int, error) { + opened++ + return -1, syscall.ENOSYS + } + t.Cleanup(func() { pidfdOpen = oldOpen }) + if err := TerminateProcesses(2345); err != nil { + t.Fatalf("no target processes should need no pidfd: %v", err) + } + if opened != 0 { + t.Fatalf("opened %d pidfds for unrelated processes, want 0", opened) + } +} + +func TestTerminateProcessesFailsClosedWhenTargetNeedsUnavailablePidfd(t *testing.T) { + setProcRoot(t, map[int]string{77: "State:\tS (sleeping)\nUid:\t2345\t2345\t2345\t2345\n"}) + oldOpen := pidfdOpen + pidfdOpen = func(int, int) (int, error) { return -1, syscall.ENOSYS } + t.Cleanup(func() { pidfdOpen = oldOpen }) + if err := TerminateProcesses(2345); err == nil || !errors.Is(err, syscall.ENOSYS) { + t.Fatalf("target process without pidfd support error=%v, want ENOSYS", err) + } +} + +func TestTerminateProcessesReportsScanAndSignalFailures(t *testing.T) { + t.Run("scan", func(t *testing.T) { + old := procRoot + procRoot = filepath.Join(t.TempDir(), "missing") + t.Cleanup(func() { procRoot = old }) + if err := TerminateProcesses(2345); err == nil || !strings.Contains(err.Error(), "scan") { + t.Fatalf("TerminateProcesses error = %v, want scan error", err) + } + }) + + t.Run("signal", func(t *testing.T) { + setProcRoot(t, map[int]string{77: "Uid:\t2345\t2345\t2345\t2345\n"}) + withFakePidfds(t, func(int, unix.Signal, *unix.Siginfo, int) error { return syscall.EPERM }) + if err := TerminateProcesses(2345); err == nil || !errors.Is(err, syscall.EPERM) { + t.Fatalf("TerminateProcesses error = %v, want EPERM", err) + } + }) +} + +func TestTerminateProcessesUsesPidfdAndReportsSurvivors(t *testing.T) { + setProcRoot(t, map[int]string{77: "Uid:\t2345\t2345\t2345\t2345\n"}) + var signals []unix.Signal + withFakePidfds(t, func(fd int, sig unix.Signal, _ *unix.Siginfo, flags int) error { + if fd != 10077 || flags != 0 { + t.Fatalf("pidfd signal args fd=%d flags=%d", fd, flags) + } + signals = append(signals, sig) + return nil + }) + err := TerminateProcesses(2345) + if err == nil || !strings.Contains(err.Error(), "surviving processes") || !strings.Contains(err.Error(), "77") { + t.Fatalf("TerminateProcesses error = %v, want survivor list", err) + } + if len(signals) != 1+terminateSweeps || signals[0] != unix.SIGTERM { + t.Fatalf("signals = %v, want TERM then %d KILL sweeps", signals, terminateSweeps) + } + for _, sig := range signals[1:] { + if sig != unix.SIGKILL { + t.Fatalf("signals = %v, want only SIGKILL after SIGTERM", signals) + } + } +} + func TestLockExpiryArgv(t *testing.T) { f := &fakeRunner{available: map[string]bool{}} m := &Manager{Runner: f} - _ = m.LockPassword("u") - _ = m.SetExpiry("u", "2026-07-09") - want := [][]string{{"usermod", "-L", "u"}, {"chage", "-E", "2026-07-09", "u"}} + _ = m.LockPassword("xxvcc-u") + _ = m.SetExpiry("xxvcc-u", "2026-07-09") + want := [][]string{{"usermod", "-L", "xxvcc-u"}, {"chage", "-E", "2026-07-09", "xxvcc-u"}} if !reflect.DeepEqual(f.calls, want) { t.Errorf("calls = %v, want %v", f.calls, want) } } +func TestDisablePasswordForKeyLoginUsesUnmatchableUnlockedShadowValue(t *testing.T) { + f := &fakeRunner{available: map[string]bool{}} + m := &Manager{Runner: f} + if err := m.DisablePasswordForKeyLogin("xxvcc-u"); err != nil { + t.Fatal(err) + } + want := [][]string{{"usermod", "-p", keyOnlyPasswordHash, "xxvcc-u"}} + if !reflect.DeepEqual(f.calls, want) { + t.Fatalf("calls = %v, want %v", f.calls, want) + } + if strings.HasPrefix(keyOnlyPasswordHash, "!") || strings.HasPrefix(keyOnlyPasswordHash, "*") { + t.Fatal("key-only shadow value would make OpenSSH classify the account as locked") + } + if len(keyOnlyPasswordHash) == 13 || strings.HasPrefix(keyOnlyPasswordHash, "$") || strings.HasPrefix(keyOnlyPasswordHash, "_") { + t.Fatal("key-only shadow value resembles a supported crypt(3) result") + } +} + func TestDeleteFallsBackToUserdel(t *testing.T) { // deluser present but fails -> userdel is tried. // @@ -239,30 +761,52 @@ func TestDeleteFallsBackToUserdel(t *testing.T) { // every revoke fail and keep the account alive. f := &fakeRunner{available: map[string]bool{"deluser": true, "userdel": true}, failOn: map[string]bool{"deluser": true}} m := &Manager{Runner: f} - if err := m.Delete("u"); err != nil { + if err := m.Delete("xxvcc-u"); err != nil { t.Fatal(err) } - if len(f.calls) != 2 || f.calls[0][0] != "deluser" || !reflect.DeepEqual(f.calls[1], []string{"userdel", "-r", "-f", "--", "u"}) { + if len(f.calls) != 2 || f.calls[0][0] != "deluser" || !reflect.DeepEqual(f.calls[1], []string{"userdel", "-r", "-f", "--", "xxvcc-u"}) { t.Errorf("delete calls = %v", f.calls) } } +func TestDeleteRequiresConfirmedAccountRemoval(t *testing.T) { + setPasswd(t, "xxvcc-u:x:1001:1001::/home/xxvcc-u:/bin/sh\n") + f := &fakeRunner{available: map[string]bool{"deluser": true, "userdel": true}} + m := &Manager{Runner: f} + if err := m.Delete("xxvcc-u"); err == nil || !strings.Contains(err.Error(), "still exists") { + t.Fatalf("Delete error = %v, want post-delete existence failure", err) + } + if len(f.calls) != 2 || f.calls[0][0] != "deluser" || f.calls[1][0] != "userdel" { + t.Fatalf("Delete calls = %v, want both helpers after the first false success", f.calls) + } +} + +func TestDeleteFailsClosedWhenRemovalCannotBeVerified(t *testing.T) { + old := passwdPath + passwdPath = t.TempDir() + t.Cleanup(func() { passwdPath = old }) + f := &fakeRunner{available: map[string]bool{"deluser": true}} + if err := (&Manager{Runner: f}).Delete("xxvcc-u"); err == nil || !strings.Contains(err.Error(), "verify deluser") { + t.Fatalf("Delete error = %v, want passwd verification failure", err) + } +} + // TestDisableLoginExpiresBeforeLocking pins the H2 fix: revoke must shut the // account's door before it starts taking it apart. Expiry is what actually stops // a KEY login (locking the password alone would not), so it must be issued. func TestDisableLoginExpiresBeforeLocking(t *testing.T) { f := &fakeRunner{available: map[string]bool{"chage": true, "usermod": true}} m := &Manager{Runner: f} - if err := m.DisableLogin("u"); err != nil { + if err := m.DisableLogin("xxvcc-u"); err != nil { t.Fatal(err) } if len(f.calls) != 2 { t.Fatalf("DisableLogin calls = %v, want chage then usermod", f.calls) } - if !reflect.DeepEqual(f.calls[0], []string{"chage", "-E", "1970-01-01", "u"}) { + if !reflect.DeepEqual(f.calls[0], []string{"chage", "-E", "1970-01-01", "xxvcc-u"}) { t.Errorf("first call = %v, want the account expired to a past date", f.calls[0]) } - if !reflect.DeepEqual(f.calls[1], []string{"usermod", "-L", "u"}) { + if !reflect.DeepEqual(f.calls[1], []string{"usermod", "-L", "xxvcc-u"}) { t.Errorf("second call = %v, want the password locked", f.calls[1]) } } @@ -271,18 +815,29 @@ func TestDisableLoginExpiresBeforeLocking(t *testing.T) { // mis-parsed or zero uid from signalling every root-owned process on the host. // kill is stubbed, so a regression fails the test instead of killing the runner. func TestTerminateProcessesNeverSignalsRootOrAll(t *testing.T) { - var signalled [][2]int - orig := kill - kill = func(pid int, sig syscall.Signal) error { - signalled = append(signalled, [2]int{pid, int(sig)}) - return nil + var opened []int + orig := pidfdOpen + pidfdOpen = func(pid, _ int) (int, error) { + opened = append(opened, pid) + return -1, errors.New("must not be called") } - t.Cleanup(func() { kill = orig }) + t.Cleanup(func() { pidfdOpen = orig }) for _, uid := range []int{0, -1, -1000} { - TerminateProcesses(uid) - if len(signalled) != 0 { - t.Fatalf("uid %d must signal nothing, signalled %v", uid, signalled) + if err := TerminateProcesses(uid); err != nil { + t.Fatalf("TerminateProcesses(%d): %v", uid, err) + } + if len(opened) != 0 { + t.Fatalf("uid %d must open no pidfds, opened %v", uid, opened) + } + } + if strconv.IntSize >= 64 { + reserved := int(uint64(^uint32(0))) + if err := TerminateProcesses(reserved); err == nil || !strings.Contains(err.Error(), "invalid Linux UID") { + t.Fatalf("TerminateProcesses(%d) error = %v, want range refusal", reserved, err) + } + if len(opened) != 0 { + t.Fatalf("reserved uid must open no pidfds, opened %v", opened) } } } diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 1afe6de..3bceae5 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -6,6 +6,7 @@ package validate import ( "net" + "net/netip" "regexp" "strconv" "strings" @@ -24,7 +25,9 @@ var ( dnsLabelRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$`) // exactly three numeric components + optional [._+~-]-led suffix installedVersionRe = regexp.MustCompile(`^[0-9]+([.][0-9]+){2}([-_+~][A-Za-z0-9._+~-]+)?$`) - generationRe = regexp.MustCompile(`^[a-f0-9]{32}$`) + // Exact release version used by v-tags and the public mirror manifest. + releaseVersionRe = regexp.MustCompile(`^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$`) + generationRe = regexp.MustCompile(`^[a-f0-9]{32}$`) ) // Username reports whether s is a valid temporary username. @@ -33,6 +36,17 @@ func Username(s string) bool { return usernameRe.MatchString(s) } // Generation reports whether s is a 128-bit lowercase hex account-generation token. func Generation(s string) bool { return generationRe.MatchString(s) } +// KernelID reports whether id can be represented as a concrete Linux uid_t or +// gid_t. The all-ones value is deliberately excluded: chown(2) reserves it as +// the "leave this owner unchanged" sentinel rather than an account identity. +func KernelID(id int) bool { + return id >= 0 && uint64(id) < uint64(^uint32(0)) +} + +// AccountID is KernelID restricted to non-root identities. Temporary accounts +// and every unattended action tied to one require this stronger form. +func AccountID(id int) bool { return id > 0 && KernelID(id) } + // Prefix reports whether s is a valid username prefix. func Prefix(s string) bool { return prefixRe.MatchString(s) && !strings.HasSuffix(s, "-") && !strings.HasSuffix(s, "_") @@ -101,52 +115,17 @@ func PublicIPv4(ip string) bool { if len(parts) != 4 { return false } - o := make([]int, 4) - for i, p := range parts { + for _, p := range parts { if !validOctet(p) { return false } - o[i], _ = strconv.Atoi(p) - } - switch o[0] { - case 0, 10, 127: - return false - } - if o[0] >= 224 { // multicast + reserved (224.0.0.0/3) - return false - } - switch { - case o[0] == 100 && o[1] >= 64 && o[1] <= 127: // CGNAT 100.64/10 - return false - case o[0] == 169 && o[1] == 254: // link-local - return false - case o[0] == 172 && o[1] >= 16 && o[1] <= 31: // 172.16/12 - return false - case o[0] == 192 && o[1] == 0 && o[2] == 0: // 192.0.0.0/24 - return false - case o[0] == 192 && o[1] == 0 && o[2] == 2: // TEST-NET-1 - return false - case o[0] == 192 && o[1] == 88 && o[2] == 99: // 6to4 relay anycast - return false - case o[0] == 192 && o[1] == 168: // 192.168/16 - return false - case o[0] == 198 && (o[1] == 18 || o[1] == 19): // benchmarking 198.18/15 - return false - case o[0] == 198 && o[1] == 51 && o[2] == 100: // TEST-NET-2 - return false - case o[0] == 203 && o[1] == 0 && o[2] == 113: // TEST-NET-3 - return false } - return true + return PublicIP(net.ParseIP(ip)) } -// PublicIPv6 reports whether ip is a routable global-unicast IPv6 address — the -// IPv6 counterpart of PublicIPv4, used only to filter auto-detection candidates. -// It leans on net's classifiers (which already exclude loopback ::1, the -// unspecified ::, link-local fe80::/10, and every multicast form) and adds the -// two they do not cover for our purpose: unique-local fc00::/7 (IsPrivate) and -// the documentation range 2001:db8::/32, which is global-unicast-shaped but not -// routable. An IPv4 or IPv4-mapped address is rejected here; PublicIPv4 owns it. +// PublicIPv6 reports whether ip is a routable global-unicast IPv6 address, using +// the same special-purpose exclusions as the redirect SSRF boundary. An IPv4 or +// IPv4-mapped address is rejected here; PublicIPv4 owns it. func PublicIPv6(ip string) bool { if !strings.Contains(ip, ":") { return false @@ -155,16 +134,61 @@ func PublicIPv6(ip string) bool { if parsed == nil || parsed.To4() != nil { return false } - if !parsed.IsGlobalUnicast() || parsed.IsPrivate() { + return PublicIP(parsed) +} + +// PublicIP reports whether ip is suitable for either an automatically advertised +// SSH endpoint or a redirect-time network destination. Keep this one classifier +// shared so public-IP detection cannot accept a special-use range that the +// upgrade SSRF boundary rejects (or vice versa). +func PublicIP(ip net.IP) bool { + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + addr = addr.Unmap() + if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() { return false } - // 2001:db8::/32 — RFC 3849 documentation prefix. - if parsed[0] == 0x20 && parsed[1] == 0x01 && parsed[2] == 0x0d && parsed[3] == 0xb8 { + // netip's protocol-level IsGlobalUnicast also accepts deprecated site-local + // space and address ranges that IANA has not allocated for global IPv6 + // unicast. Redirect targets must stay inside the current 2000::/3 allocation. + if addr.Is6() && !ipv6GlobalUnicastPrefix.Contains(addr) { return false } + for _, prefix := range nonPublicPrefixes { + if prefix.Contains(addr) { + return false + } + } return true } +var ipv6GlobalUnicastPrefix = netip.MustParsePrefix("2000::/3") + +var nonPublicPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.31.196.0/24"), + netip.MustParsePrefix("192.52.193.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), + netip.MustParsePrefix("192.175.48.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("64:ff9b::/96"), + netip.MustParsePrefix("64:ff9b:1::/48"), + netip.MustParsePrefix("100::/64"), + netip.MustParsePrefix("2001::/23"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("2002::/16"), + netip.MustParsePrefix("3fff::/20"), + netip.MustParsePrefix("5f00::/16"), +} + // Port reports whether p is a usable TCP port (1..65535). func Port(p int) bool { return p >= 1 && p <= 65535 } @@ -196,3 +220,7 @@ func UpgradeURL(u string) bool { // (a suffix reads as a prerelease), so the upgrade gate would silently decline a // genuinely newer release. So a 2- or 4-part string cannot slip through. func InstalledVersion(v string) bool { return installedVersionRe.MatchString(v) } + +// ReleaseVersion reports whether v is the canonical version portion of a +// supported vX.Y.Z release tag. +func ReleaseVersion(v string) bool { return releaseVersionRe.MatchString(v) } diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index 4ef892e..d8a382f 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -1,6 +1,9 @@ package validate -import "testing" +import ( + "strconv" + "testing" +) func TestUsername(t *testing.T) { cases := []struct { @@ -41,6 +44,33 @@ func TestPrefix(t *testing.T) { } } +func TestKernelAndAccountID(t *testing.T) { + for _, id := range []int{0, 1, 65534} { + if !KernelID(id) { + t.Errorf("KernelID(%d) = false, want true", id) + } + } + if AccountID(0) || !AccountID(1) { + t.Fatalf("AccountID root/non-root boundary is wrong") + } + for _, id := range []int{-1, -1000} { + if KernelID(id) || AccountID(id) { + t.Errorf("negative id %d was accepted", id) + } + } + if strconv.IntSize >= 64 { + reserved := int(uint64(^uint32(0))) + for _, id := range []int{reserved, reserved + 1} { + if KernelID(id) || AccountID(id) { + t.Errorf("out-of-range/reserved id %d was accepted", id) + } + } + if !KernelID(reserved-1) || !AccountID(reserved-1) { + t.Errorf("highest concrete Linux id %d was rejected", reserved-1) + } + } +} + func TestHost(t *testing.T) { cases := []struct { in string @@ -82,6 +112,10 @@ func TestPublicIPv4(t *testing.T) { {"169.254.1.1", false}, // link-local {"198.18.0.1", false}, // benchmark {"192.0.2.1", false}, // TEST-NET-1 + {"192.31.196.1", false}, // AS112-v4 + {"192.52.193.1", false}, // AMT + {"192.88.99.1", false}, // deprecated 6to4 relay anycast + {"192.175.48.1", false}, // AS112 direct delegation {"198.51.100.10", false}, // TEST-NET-2 {"203.0.113.10", false}, // TEST-NET-3 {"224.0.0.1", false}, // multicast @@ -101,13 +135,28 @@ func TestPublicIPv6(t *testing.T) { }{ {"2400:cb00:2049:1::a29f:1804", true}, // routable global unicast {"2606:4700:4700::1111", true}, // Cloudflare resolver, global unicast + {"2000::1", true}, // lower 2000::/3 boundary + {"3fef:ffff::1", true}, // representative high 2000::/3 address + {"3ff0::1", true}, // outside the narrower 3fff::/20 documentation prefix + {"3fff:1000::1", true}, // immediately above 3fff::/20 {"::1", false}, // loopback {"::", false}, // unspecified + {"100:0:0:1::1", false}, // IANA Dummy IPv6 Prefix + {"1fff:ffff::1", false}, // below current global-unicast allocation + {"4000::1", false}, // above current global-unicast allocation {"fe80::1", false}, // link-local + {"fec0::1", false}, // deprecated site-local {"fc00::1", false}, // unique-local fc00::/7 {"fd12:3456::1", false}, // unique-local {"ff02::1", false}, // multicast {"2001:db8::1", false}, // documentation 2001:db8::/32 + {"64:ff9b::8.8.8.8", false}, // NAT64 well-known prefix + {"64:ff9b:1::1", false}, // NAT64 local-use prefix + {"100::1", false}, // discard-only prefix + {"2002:808:808::1", false}, // deprecated 6to4 + {"3fff::1", false}, // documentation prefix + {"3fff:0fff::1", false}, // upper 3fff::/20 documentation boundary + {"5f00::1", false}, // IPv6 segment routing SIDs {"8.8.8.8", false}, // an IPv4 is PublicIPv4's job, not this one {"::ffff:8.8.8.8", false}, // IPv4-mapped resolves to v4, rejected here {"not-an-ip", false}, @@ -146,6 +195,19 @@ func TestInstalledVersion(t *testing.T) { } } +func TestReleaseVersion(t *testing.T) { + for _, value := range []string{"0.0.0", "2.8.0", "12.34.56-rc.10"} { + if !ReleaseVersion(value) { + t.Errorf("ReleaseVersion(%q) = false, want true", value) + } + } + for _, value := range []string{"", "v2.8.0", "02.8.0", "2.08.0", "2.8", "2.8.0+build", "2.8.0-rc_1"} { + if ReleaseVersion(value) { + t.Errorf("ReleaseVersion(%q) = true, want false", value) + } + } +} + func TestUpgradeURL(t *testing.T) { cases := []struct { in string diff --git a/internal/version/version.go b/internal/version/version.go index e130ad8..48c36e4 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -5,14 +5,13 @@ package version import ( "regexp" - "strconv" "strings" ) var re = regexp.MustCompile(`^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$`) type parsed struct { - major, minor, patch int + major, minor, patch string suffix string ok bool } @@ -22,13 +21,7 @@ func parse(v string) parsed { if m == nil { return parsed{} } - maj, err1 := strconv.Atoi(m[1]) - min, err2 := strconv.Atoi(m[2]) - pat, err3 := strconv.Atoi(m[3]) - if err1 != nil || err2 != nil || err3 != nil { - return parsed{} - } - return parsed{major: maj, minor: min, patch: pat, suffix: m[4], ok: true} + return parsed{major: m[1], minor: m[2], patch: m[3], suffix: m[4], ok: true} } // Greater reports whether newer is strictly greater than older. Either operand @@ -40,14 +33,10 @@ func Greater(newer, older string) bool { if !n.ok || !o.ok { return false } - if n.major != o.major { - return n.major > o.major - } - if n.minor != o.minor { - return n.minor > o.minor - } - if n.patch != o.patch { - return n.patch > o.patch + for _, pair := range [][2]string{{n.major, o.major}, {n.minor, o.minor}, {n.patch, o.patch}} { + if cmp := compareDecimal(pair[0], pair[1]); cmp != 0 { + return cmp > 0 + } } // Equal core version: a final release outranks a prerelease suffix. if n.suffix == "" && o.suffix != "" { @@ -62,6 +51,33 @@ func Greater(newer, older string) bool { return naturalCompare(n.suffix, o.suffix) > 0 } +// compareDecimal compares arbitrarily long non-negative decimal integers. Tag +// validation does not impose the host int width, so version ordering must not +// silently become "unparseable" merely because a component exceeds strconv.Int. +func compareDecimal(a, b string) int { + a = strings.TrimLeft(a, "0") + b = strings.TrimLeft(b, "0") + if a == "" { + a = "0" + } + if b == "" { + b = "0" + } + if len(a) < len(b) { + return -1 + } + if len(a) > len(b) { + return 1 + } + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 +} + // naturalCompare orders two strings so that runs of digits compare by numeric value // (with leading zeros ignored) and everything else compares byte-wise. It returns // -1, 0, or 1. This gives "-rc2" < "-rc10" while keeping a stable total order. diff --git a/internal/version/version_test.go b/internal/version/version_test.go index a8e6a37..004d919 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -17,9 +17,11 @@ func TestGreater(t *testing.T) { {"1.2.0", "nope", false}, // unparseable older => false {"1.2.3-rc10", "1.2.3-rc9", true}, // numeric-aware suffix: rc10 > rc9 {"1.2.3-rc9", "1.2.3-rc10", false}, - {"1.2.3-rc2", "1.2.3-rc2", false}, // identical prerelease is not greater - {"1.2.10", "1.2.9", true}, // numeric core, not lexical - {"1.0.0-beta", "1.0.0-alpha", true}, // non-numeric suffix still compares + {"1.2.3-rc2", "1.2.3-rc2", false}, // identical prerelease is not greater + {"1.2.10", "1.2.9", true}, // numeric core, not lexical + {"1.0.0-beta", "1.0.0-alpha", true}, // non-numeric suffix still compares + {"184467440737095516160.0.0", "184467440737095516159.999.999", true}, // wider than uint64 + {"000000000000000000002.0.0", "1.999.999", true}, // leading zeroes stay numeric } for _, c := range cases { if got := Greater(c.newer, c.older); got != c.want { diff --git a/scripts/install.sh b/scripts/install.sh index 38d694d..58f0e7f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,114 +1,927 @@ -#!/usr/bin/env sh -# One-line installer for linux-temp-admin (v2). -# -# Downloads the latest signed release binary for this architecture over HTTPS, -# verifies its SHA-256 against the published SHA256SUMS, AND verifies a detached -# ed25519 signature against the release public key embedded below (the same key -# `upgrade` uses) before installing — failing closed on any mismatch. So the very -# first install carries the same signature-based trust as later `upgrade`s: a -# tampered or maliciously re-published binary is rejected even if the release host -# is compromised, because only the offline private key can produce a valid sig. -# -# Run as root: curl -fsSL https://.../scripts/install.sh | sudo sh -# -# Signature verification needs openssl >= 3.0 (for `pkeyutl -rawin`). If it is -# unavailable the install fails closed, unless you explicitly accept checksum-only -# trust by setting LTA_ALLOW_UNVERIFIED=1. +#!/bin/sh +# Signed bootstrap installer for linux-temp-admin. +# Deployed /bin/sh implementations (Bash, dash, and BusyBox ash) support the +# non-POSIX ulimit -c/-f switches used for kernel-enforced output limits. +# shellcheck disable=SC3045 +# Explicit non-POSIX Bash lets imported functions override even special builtins +# such as `set` and `unset`. POSIXLY_CORRECT is a Bash dynamic variable: this +# assignment changes command lookup before the first shadowable command runs. +case ${BASH_VERSION-} in + ?*) POSIXLY_CORRECT=y ;; +esac set -eu +# Some systems link /bin/sh to Bash, which imports exported functions before +# executing this file. Clear every command name we call before trusting PATH. +for imported_name in id uname mktemp stat sha256sum openssl timeout awk grep od wc cp chmod chown mv \ + dirname mkdir rm sleep curl getent nslookup command printf ulimit echo umask export unset set trap exit return break ':' '['; do + unset -f "$imported_name" 2>/dev/null || : +done +PATH=/usr/sbin:/usr/bin:/sbin:/bin +LC_ALL=C +OPENSSL_CONF=/dev/null +export PATH LC_ALL OPENSSL_CONF +unset OPENSSL_CONF_INCLUDE OPENSSL_MODULES OPENSSL_ENGINES +umask 077 +if ! ulimit -c 0 2>/dev/null; then + echo "error: could not disable core dumps" >&2 + exit 1 +fi -BASE="https://github.com/xxvcc/linux-temp-admin/releases/latest/download" -DEST="${DEST:-/usr/local/sbin/linux-temp-admin}" -MAX_DOWNLOAD_BYTES=67108864 +release="${LTA_RELEASE-latest}" +expected_version="" +release_tag="" +MIRROR_ROOT=https://dl.ll.cd/linux-temp-admin +GITHUB_RELEASE_ROOT=https://github.com/xxvcc/linux-temp-admin/releases +MANAGED_DEST=/usr/local/sbin/linux-temp-admin +DEST="${DEST:-$MANAGED_DEST}" +TMP_ROOT=/tmp +MAX_BINARY_BYTES=67108864 +MAX_SUMS_BYTES=1048576 +MAX_SIGNATURE_BYTES=256 +MAX_MANIFEST_BYTES=1048576 +MAX_PROBE_BYTES=256 +MIRROR_FETCH_ATTEMPTS=2 +MIRROR_FETCH_TIMEOUT_SECONDS=20 +GITHUB_FETCH_ATTEMPTS=4 +GITHUB_FETCH_TIMEOUT_SECONDS=90 +CONNECT_TIMEOUT_SECONDS=10 -if [ "$(id -u)" -ne 0 ]; then - echo "run this installer as root" >&2 - exit 1 +fail() { echo "error: $*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || fail "run this installer as root" +case "$DEST" in + //*) fail "DEST must not begin with //" ;; + /*) ;; + *) fail "DEST must be an absolute path" ;; +esac + +for command_name in id uname mktemp stat sha256sum openssl timeout awk grep od wc cp chmod chown mv \ + dirname mkdir rm sleep curl; do + command -v "$command_name" >/dev/null 2>&1 || fail "required command not found: $command_name" +done + +case "$release" in + latest) ;; + *) + case "$release" in + '' | *[!v0-9A-Za-z.+-]*) fail "LTA_RELEASE must be latest or an exact vX.Y.Z release tag" ;; + esac + printf '%s\n' "$release" \ + | grep -Eq '^v(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$' \ + || fail "LTA_RELEASE must be latest or an exact vX.Y.Z release tag" + release_tag=$release + expected_version=${release#v} + ;; +esac + +# Bash uses 1024-byte `ulimit -f` blocks normally but 512-byte blocks in POSIX +# or sh mode; dash and BusyBox ash also use 512. Probe in a command-substitution +# child so the temporary soft limit cannot affect the installer, then read the +# inherited kernel value from Linux procfs instead of guessing from shell names. +if ! FSIZE_BLOCK_BYTES=$( + ulimit -f 1 || exit 1 + awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } + END { if (!found) exit 1 }' /proc/self/limits +); then + fail "could not determine the shell file-size limit unit" +fi +case "$FSIZE_BLOCK_BYTES" in + 512 | 1024) ;; + *) fail "unsupported shell file-size limit unit" ;; +esac + +if [ ! -d "$TMP_ROOT" ] || [ -L "$TMP_ROOT" ]; then + fail "temporary root is not a real directory: $TMP_ROOT" +fi +if ! tmp_root_uid=$(stat -c %u -- "$TMP_ROOT"); then + fail "cannot inspect temporary root owner: $TMP_ROOT" +fi +case "$tmp_root_uid" in + '' | *[!0-9]*) fail "invalid temporary root owner: $TMP_ROOT" ;; +esac +[ "$tmp_root_uid" -eq 0 ] || fail "temporary root is not root-owned: $TMP_ROOT" +if ! tmp_root_mode=$(stat -c %A -- "$TMP_ROOT"); then + fail "cannot inspect temporary root mode: $TMP_ROOT" fi +case "$tmp_root_mode" in + d?????????) ;; + *) fail "invalid temporary root mode: $TMP_ROOT" ;; +esac +case "$tmp_root_mode" in + ?????w????|????????w?) + case "$tmp_root_mode" in + ?????????t|?????????T) ;; + *) fail "writable temporary root lacks the sticky bit: $TMP_ROOT" ;; + esac + ;; +esac -# Release signing public key (ed25519) as a SubjectPublicKeyInfo PEM — the same key -# as internal/selfmanage/release_pubkey.hex, in the form openssl reads. Keep the two -# in sync. To regenerate this block after a key rotation: -# hex=$(grep -v '^#' internal/selfmanage/release_pubkey.hex | tr -d '[:space:]') -# python3 -c 'import base64,sys;k=bytes.fromhex("302a300506032b6570032100"+sys.argv[1]);print("-----BEGIN PUBLIC KEY-----");print(base64.encodebytes(k).decode().strip());print("-----END PUBLIC KEY-----")' "$hex" -RELEASE_PUBKEY_PEM='-----BEGIN PUBLIC KEY----- +# LTA_RELEASE_KEYS_BEGIN -- every PEM block must match, in order, one complete +# non-comment line in internal/selfmanage/release_pubkey.hex. A Go test enforces +# this invariant, including during a multi-key rotation overlap. +RELEASE_PUBKEY_PEMS=' +-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAmCRx+wyfgvdhQ8idBF+KkxGA+Myifa1ShrsgAGFOrxw= ------END PUBLIC KEY-----' +-----END PUBLIC KEY----- +' +# LTA_RELEASE_KEYS_END case "$(uname -m)" in x86_64) arch=amd64 ;; aarch64 | arm64) arch=arm64 ;; - *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; + *) fail "unsupported architecture: $(uname -m)" ;; esac asset="linux-temp-admin-linux-${arch}" -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT +tmp="$(mktemp -d "$TMP_ROOT/linux-temp-admin.XXXXXXXXXX")" +stage="" +cleanup() { + if [ -n "$stage" ]; then + rm -f -- "$stage" + fi + rm -rf -- "$tmp" +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM -# Choose a downloader that can enforce HTTPS redirects and a response-size cap. -if command -v curl >/dev/null 2>&1; then - DL=curl -elif command -v wget >/dev/null 2>&1 \ - && wget --help 2>&1 | grep -q -- '--https-only' \ - && wget --help 2>&1 | grep -q -- '--max-filesize'; then - DL=wget -else - echo "need curl or wget with HTTPS-only redirects and download-size limits" >&2 - exit 1 -fi +# Keep this classifier aligned with validate.PublicIP. A GitHub redirect target +# is resolved before curl can request it, every answer must be public, and the +# selected answer is pinned with --resolve so DNS rebinding cannot change it. +validate_resolver_output() { + resolver_mode=$1 + awk -v mode="$resolver_mode" ' + function public_v4_parts(a, b, c, d) { + if (a == 0 || a == 10 || a == 127 || a >= 224) return 0 + if (a == 100 && b >= 64 && b <= 127) return 0 + if (a == 169 && b == 254) return 0 + if (a == 172 && b >= 16 && b <= 31) return 0 + if (a == 192 && b == 168) return 0 + if (a == 192 && b == 0 && (c == 0 || c == 2)) return 0 + if (a == 192 && b == 31 && c == 196) return 0 + if (a == 192 && b == 52 && c == 193) return 0 + if (a == 192 && b == 88 && c == 99) return 0 + if (a == 192 && b == 175 && c == 48) return 0 + if (a == 198 && (b == 18 || b == 19)) return 0 + if (a == 198 && b == 51 && c == 100) return 0 + if (a == 203 && b == 0 && c == 113) return 0 + return 1 + } + function parse_v4(text, parts, count, i) { + count=split(text, parts, ".") + if (count != 4) return 0 + for (i=1; i<=4; i++) { + if (parts[i] !~ /^[0-9]+$/ || parts[i] + 0 > 255 || + (length(parts[i]) > 1 && substr(parts[i], 1, 1) == "0")) return 0 + } + return 1 + } + function public_v4(text, parts) { + if (!parse_v4(text, parts)) return 0 + return public_v4_parts(parts[1] + 0, parts[2] + 0, parts[3] + 0, parts[4] + 0) + } + function hex_value(text, chars, value, i, digit) { + chars="0123456789abcdef" + value=0 + if (text == "" || length(text) > 4) return -1 + for (i=1; i<=length(text); i++) { + digit=index(chars, substr(text, i, 1)) - 1 + if (digit < 0) return -1 + value=value * 16 + digit + } + return value + } + function public_v6(text, left, right, left_count, right_count, count, + colon, tail, i, value, nonzero, v4, left_parts, + right_parts, parts, words) { + text=tolower(text) + if (text !~ /^[0-9a-f:.]+$/) return 0 + if (text ~ /[.]/) { + colon=0 + for (i=1; i<=length(text); i++) if (substr(text, i, 1) == ":") colon=i + if (!colon) return 0 + tail=substr(text, colon + 1) + if (!parse_v4(tail, v4)) return 0 + text=substr(text, 1, colon) sprintf("%x:%x", + (v4[1] + 0) * 256 + (v4[2] + 0), + (v4[3] + 0) * 256 + (v4[4] + 0)) + } + colon=index(text, "::") + if (colon) { + if (index(substr(text, colon + 2), "::")) return 0 + left=substr(text, 1, colon - 1) + right=substr(text, colon + 2) + left_count=(left == "" ? 0 : split(left, left_parts, ":")) + right_count=(right == "" ? 0 : split(right, right_parts, ":")) + if (left_count + right_count >= 8) return 0 + for (i=1; i<=left_count; i++) { + value=hex_value(left_parts[i]) + if (value < 0) return 0 + words[i]=value + } + for (i=left_count + 1; i<=8 - right_count; i++) words[i]=0 + for (i=1; i<=right_count; i++) { + value=hex_value(right_parts[i]) + if (value < 0) return 0 + words[8 - right_count + i]=value + } + } else { + count=split(text, parts, ":") + if (count != 8) return 0 + for (i=1; i<=8; i++) { + value=hex_value(parts[i]) + if (value < 0) return 0 + words[i]=value + } + } + if (words[1] == 0 && words[2] == 0 && words[3] == 0 && + words[4] == 0 && words[5] == 0 && words[6] == 65535) { + return public_v4_parts(int(words[7] / 256), words[7] % 256, + int(words[8] / 256), words[8] % 256) + } + if (words[1] < hex_value("2000") || words[1] > hex_value("3fff")) return 0 + nonzero=0 + for (i=1; i<=8; i++) if (words[i] != 0) nonzero=1 + if (!nonzero) return 0 + if (words[1] == 0 && words[2] == 0 && words[3] == 0 && + words[4] == 0 && words[5] == 0 && words[6] == 0 && + words[7] == 0 && words[8] == 1) return 0 + if (words[1] >= hex_value("fc00") && words[1] <= hex_value("fdff")) return 0 + if (words[1] >= hex_value("fe80") && words[1] <= hex_value("febf")) return 0 + if (words[1] >= hex_value("ff00")) return 0 + if (words[1] == hex_value("0064") && words[2] == hex_value("ff9b") && words[3] == 0 && + words[4] == 0 && words[5] == 0 && words[6] == 0) return 0 + if (words[1] == hex_value("0064") && words[2] == hex_value("ff9b") && words[3] == 1) return 0 + if (words[1] == hex_value("0100") && words[2] == 0 && words[3] == 0 && words[4] == 0) return 0 + if (words[1] == hex_value("2001") && words[2] < hex_value("0200")) return 0 + if (words[1] == hex_value("2001") && words[2] == hex_value("0db8")) return 0 + if (words[1] == hex_value("2002")) return 0 + if (words[1] == hex_value("3fff") && words[2] < hex_value("1000")) return 0 + if (words[1] == hex_value("5f00")) return 0 + return 1 + } + function public_ip(address) { + if (address ~ /:/) return public_v6(address) + return public_v4(address) + } + function add_address(address) { + if (address in seen) return + seen[address]=1 + address_count++ + if (!public_ip(address)) invalid=1 + else if (first_public == "") first_public=address + } + mode == "getent" && $1 ~ /^[0-9A-Fa-f:.]+$/ && $1 ~ /[.:]/ { + add_address($1) + next + } + mode == "nslookup" && ($1 == "Server:" || $1 == "Server") { + in_answer=0 + next + } + mode == "nslookup" && ($1 == "Name:" || $1 == "Name") { + in_answer=1 + next + } + mode == "nslookup" && in_answer && $1 == "Address:" { + add_address($2) + } + mode == "nslookup" && in_answer && $1 == "Address" && $2 ~ /^[0-9]+:$/ { + add_address($3) + } + END { + if (invalid) exit 2 + if (!address_count) exit 1 + print first_public + } + ' +} -fetch() { - case "$DL" in - curl) curl -fsSL --proto '=https' --proto-redir '=https' \ - --max-filesize "$MAX_DOWNLOAD_BYTES" "$1" -o "$2" ;; - wget) wget --https-only --max-filesize="$MAX_DOWNLOAD_BYTES" -qO "$2" "$1" ;; +resolve_public_address() { + resolve_host=$1 + resolver_output="" + if command -v getent >/dev/null 2>&1; then + if ! resolver_timeout=$(fetch_remaining_timeout "$CONNECT_TIMEOUT_SECONDS"); then + return 1 + fi + if ! resolver_output=$(timeout -s KILL "$resolver_timeout" getent ahosts "$resolve_host") 2>/dev/null; then + resolver_output="" + fi + if [ -z "$resolver_output" ]; then + if ! resolver_timeout=$(fetch_remaining_timeout "$CONNECT_TIMEOUT_SECONDS"); then + return 1 + fi + if ! resolver_output=$(timeout -s KILL "$resolver_timeout" getent hosts "$resolve_host") 2>/dev/null; then + resolver_output="" + fi + fi + if [ -n "$resolver_output" ]; then + if resolved_address=$(printf '%s\n' "$resolver_output" | validate_resolver_output getent); then + printf '%s\n' "$resolved_address" + return 0 + else + resolve_rc=$? + fi + [ "$resolve_rc" -ne 2 ] || return 2 + fi + fi + if command -v nslookup >/dev/null 2>&1; then + resolver_output="" + if ! resolver_timeout=$(fetch_remaining_timeout "$CONNECT_TIMEOUT_SECONDS"); then + return 1 + fi + if ! resolver_part=$(timeout -s KILL "$resolver_timeout" nslookup -type=A "$resolve_host") 2>/dev/null; then + resolver_part="" + fi + resolver_output=$resolver_part + if ! resolver_timeout=$(fetch_remaining_timeout "$CONNECT_TIMEOUT_SECONDS"); then + return 1 + fi + if ! resolver_part=$(timeout -s KILL "$resolver_timeout" nslookup -type=AAAA "$resolve_host") 2>/dev/null; then + resolver_part="" + fi + if [ -n "$resolver_part" ]; then + if [ -n "$resolver_output" ]; then + resolver_output="${resolver_output} +${resolver_part}" + else + resolver_output=$resolver_part + fi + fi + if resolved_address=$(printf '%s\n' "$resolver_output" | validate_resolver_output nslookup); then + printf '%s\n' "$resolved_address" + return 0 + else + return $? + fi + fi + return 1 +} + +valid_redirect_dns_host() { + case "$1" in + '' | .* | *. | *..* | *[!A-Za-z0-9.-]*) return 1 ;; + esac + printf '%s\n' "$1" | awk -F . ' + length($0) > 253 { exit 1 } + { + for (i=1; i<=NF; i++) { + if (length($i) < 1 || length($i) > 63 || + substr($i, 1, 1) == "-" || substr($i, length($i), 1) == "-") exit 1 + } + } + ' +} + +redirect_resolve_entry() { + redirect_url=$1 + case "$redirect_url" in + https://*) ;; + *) return 2 ;; + esac + case "$redirect_url" in + *\\*) return 2 ;; + esac + redirect_remainder=${redirect_url#https://} + redirect_authority=${redirect_remainder%%[/?#]*} + case "$redirect_authority" in + '' | *@*) return 2 ;; + esac + redirect_port=443 + redirect_literal=0 + case "$redirect_authority" in + \[*\]*) + redirect_host=${redirect_authority#\[} + redirect_host=${redirect_host%%\]*} + redirect_suffix=${redirect_authority#*\]} + [ "$redirect_authority" = "[${redirect_host}]${redirect_suffix}" ] || return 2 + case "$redirect_host" in + '' | *[!0-9A-Fa-f:.]*) return 2 ;; + esac + case "$redirect_suffix" in + '') ;; + :*) redirect_port=${redirect_suffix#:} ;; + *) return 2 ;; + esac + redirect_literal=1 + ;; + *) + case "$redirect_authority" in + *:*) + redirect_host=${redirect_authority%%:*} + redirect_port=${redirect_authority#*:} + case "$redirect_port" in *:*) return 2 ;; esac + ;; + *) redirect_host=$redirect_authority ;; + esac + valid_redirect_dns_host "$redirect_host" || return 2 + case "$redirect_host" in + *[!0-9.]*) ;; + *) redirect_literal=1 ;; + esac + ;; + esac + case "$redirect_port" in + '' | *[!0-9]*) return 2 ;; esac + [ "$redirect_port" -ge 1 ] 2>/dev/null && [ "$redirect_port" -le 65535 ] 2>/dev/null || return 2 + if [ "$redirect_literal" -eq 1 ]; then + printf '%s STREAM literal\n' "$redirect_host" | validate_resolver_output getent >/dev/null + return $? + fi + if redirect_address=$(resolve_public_address "$redirect_host"); then + : + else + return $? + fi + case "$redirect_address" in + *:*) redirect_address="[${redirect_address}]" ;; + esac + printf '%s:%s:%s\n' "$redirect_host" "$redirect_port" "$redirect_address" } -# can_verify_sig succeeds only if openssl can do ed25519 detached verification, -# which needs the one-shot `-rawin` mode (OpenSSL 3.0+). -can_verify_sig() { - command -v openssl >/dev/null 2>&1 || return 1 - openssl pkeyutl -help 2>&1 | grep -q -- '-rawin' || return 1 +# RLIMIT_FSIZE is the authoritative cap. It remains effective for chunked +# responses and curl versions whose --max-filesize only checks Content-Length. +# -L with --max-redirs 0 makes curl stop from the response headers, before a +# redirect body can hit RLIMIT_FSIZE. Official mirror calls return status 2 for +# every 3xx. GitHub redirects are followed manually only after public-IP checks. +fetch_once() { + fetch_url=$1 + fetch_out=$2 + fetch_max=$3 + fetch_timeout=$4 + fetch_follow_redirects=${5-0} + # One monotonic deadline covers the initial request, redirect DNS lookups, + # and every subsequent hop. No DNS fallback or redirect can reset it. + if ! fetch_deadline_ticks=$(awk -v budget="$fetch_timeout" ' + BEGIN { + if (budget !~ /^[0-9]+([.][0-9]+)?$/ || budget + 0 <= 0) exit 1 + } + NR == 1 && $1 ~ /^[0-9]+([.][0-9]+)?$/ { + printf "%.0f\n", ($1 + budget) * 100 + found=1 + } + END { if (!found) exit 1 } + ' /proc/uptime); then + return 1 + fi + fetch_remaining_timeout() { + fetch_timeout_cap=${1-} + awk -v deadline="$fetch_deadline_ticks" -v cap="$fetch_timeout_cap" ' + BEGIN { + if (deadline !~ /^[0-9]+$/ || + (cap != "" && (cap !~ /^[0-9]+([.][0-9]+)?$/ || cap + 0 <= 0))) exit 1 + } + NR == 1 && $1 ~ /^[0-9]+([.][0-9]+)?$/ { + remaining=(deadline - ($1 * 100)) / 100 + if (remaining <= 0) exit 1 + if (cap != "" && remaining > cap + 0) remaining=cap + 0 + printf "%.2f\n", remaining + found=1 + } + END { if (!found) exit 1 } + ' /proc/uptime + } + fetch_blocks=$(( (fetch_max + FSIZE_BLOCK_BYTES - 1) / FSIZE_BLOCK_BYTES )) + rm -f -- "$fetch_out" || return 1 + fetch_resolve="" + fetch_redirect_count=0 + while :; do + if ! fetch_call_timeout=$(fetch_remaining_timeout); then + rm -f -- "$fetch_out" || : + return 1 + fi + if fetch_meta=$( + umask 077 || exit 1 + ulimit -f "$fetch_blocks" || exit 1 + if [ -n "$fetch_resolve" ]; then + exec timeout -s KILL "$fetch_call_timeout" curl -q -fs --location --max-redirs 0 \ + --connect-timeout "$CONNECT_TIMEOUT_SECONDS" --max-time "$fetch_call_timeout" \ + --max-filesize "$fetch_max" --proto '=https' --proto-redir '=https' --noproxy '*' \ + --resolve "$fetch_resolve" --write-out '%{http_code}\n%{redirect_url}\n' \ + "$fetch_url" -o "$fetch_out" 2>/dev/null + else + exec timeout -s KILL "$fetch_call_timeout" curl -q -fs --location --max-redirs 0 \ + --connect-timeout "$CONNECT_TIMEOUT_SECONDS" --max-time "$fetch_call_timeout" \ + --max-filesize "$fetch_max" --proto '=https' --proto-redir '=https' --noproxy '*' \ + --write-out '%{http_code}\n%{redirect_url}\n' "$fetch_url" -o "$fetch_out" 2>/dev/null + fi + ) 2>/dev/null; then + fetch_curl_rc=0 + else + fetch_curl_rc=$? + fi + fetch_http_status=$(printf '%s\n' "$fetch_meta" | awk 'NR == 1 { print; exit }') + fetch_redirect_url=$(printf '%s\n' "$fetch_meta" | awk 'NR == 2 { print; exit }') + case "$fetch_http_status" in + 200) + if [ "$fetch_curl_rc" -ne 0 ]; then + rm -f -- "$fetch_out" || : + return 1 + fi + if ! fetch_remaining_timeout >/dev/null; then + rm -f -- "$fetch_out" || : + return 1 + fi + break + ;; + 3??) + rm -f -- "$fetch_out" || : + [ "$fetch_follow_redirects" -eq 1 ] || return 2 + [ -n "$fetch_redirect_url" ] || return 1 + [ "$fetch_redirect_count" -lt 10 ] || return 2 + if fetch_resolve=$(redirect_resolve_entry "$fetch_redirect_url"); then + : + else + return $? + fi + fetch_url=$fetch_redirect_url + fetch_redirect_count=$((fetch_redirect_count + 1)) + ;; + *) + rm -f -- "$fetch_out" || : + return 1 + ;; + esac + done + if ! fetch_size=$(wc -c < "$fetch_out"); then + rm -f -- "$fetch_out" || : + return 1 + fi + case "$fetch_size" in + '' | *[!0-9]*) + rm -f -- "$fetch_out" || : + return 1 + ;; + esac + if [ "$fetch_size" -le 0 ] || [ "$fetch_size" -gt "$fetch_max" ]; then + rm -f -- "$fetch_out" || : + return 1 + fi + if ! fetch_remaining_timeout >/dev/null; then + rm -f -- "$fetch_out" || : + return 1 + fi } -fetch "${BASE}/${asset}" "$tmp/bin" -fetch "${BASE}/SHA256SUMS" "$tmp/sums" +fetch() { + fetch_original_url=$1 + fetch_out=$2 + fetch_max=$3 + fetch_github_cache_bypass=$4 + fetch_attempts=$5 + fetch_timeout=$6 + fetch_attempt=1 + while [ "$fetch_attempt" -le "$fetch_attempts" ]; do + fetch_url=$fetch_original_url + if [ "$fetch_github_cache_bypass" -eq 1 ] && [ "$fetch_attempt" -ge 3 ]; then + case "$fetch_url" in + *\?*) fetch_url="${fetch_url}&download=1" ;; + *) fetch_url="${fetch_url}?download=1" ;; + esac + fi + if fetch_once "$fetch_url" "$fetch_out" "$fetch_max" "$fetch_timeout" \ + "$fetch_github_cache_bypass"; then + return 0 + else + fetch_rc=$? + fi + if [ "$fetch_rc" -eq 2 ]; then + return 2 + fi + if [ "$fetch_attempt" -eq "$fetch_attempts" ]; then + return 1 + fi + echo "download attempt ${fetch_attempt}/${fetch_attempts} failed; retrying" >&2 + sleep "$fetch_attempt" + fetch_attempt=$((fetch_attempt + 1)) + done + return 1 +} -# 1) Integrity: SHA-256 against the published SHA256SUMS. -want="$(grep " ${asset}\$" "$tmp/sums" | awk '{print $1}')" -got="$(sha256sum "$tmp/bin" | awk '{print $1}')" -if [ -z "$want" ] || [ "$want" != "$got" ]; then - echo "checksum verification failed for ${asset}" >&2 - exit 1 +parse_mirror_manifest() { + awk -F '"' -v root="$MIRROR_ROOT" ' + function digits(value) { + return value != "" && value !~ /[^0-9]/ + } + function valid_published_at(value, year, month, day, hour, minute, second, maxday, fraction) { + if (length(value) < 20 || length(value) > 30 || + substr(value, 5, 1) != "-" || substr(value, 8, 1) != "-" || + substr(value, 11, 1) != "T" || substr(value, 14, 1) != ":" || + substr(value, 17, 1) != ":" || substr(value, length(value), 1) != "Z") return 0 + year=substr(value, 1, 4); month=substr(value, 6, 2); day=substr(value, 9, 2) + hour=substr(value, 12, 2); minute=substr(value, 15, 2); second=substr(value, 18, 2) + if (!digits(year) || year == "0000" || !digits(month) || !digits(day) || + !digits(hour) || !digits(minute) || !digits(second)) return 0 + if (length(value) == 20) { + if (substr(value, 20, 1) != "Z") return 0 + } else { + if (substr(value, 20, 1) != ".") return 0 + fraction=substr(value, 21, length(value) - 21) + if (!digits(fraction)) return 0 + } + month += 0; day += 0; hour += 0; minute += 0; second += 0 + if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return 0 + maxday=31 + if (month == 4 || month == 6 || month == 9 || month == 11) maxday=30 + if (month == 2) { + maxday=28 + if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) maxday=29 + } + return day >= 1 && day <= maxday + } + NR == 1 && NF == 17 && + $1 == "{" && $2 == "version" && $3 == ":" && + $5 == "," && $6 == "tag" && $7 == ":" && + $9 == "," && $10 == "base_url" && $11 == ":" && + $13 == "," && $14 == "published_at" && $15 == ":" && $17 == "}" && + $4 != "" && $8 == "v" $4 && $12 == root "/v" $4 && valid_published_at($16) { + version=$4 + valid=1 + } + END { + if (NR != 1 || !valid) exit 1 + print version + } + ' "$1" +} + +canonical_text_file() { + od -An -v -t u1 "$1" | awk ' + { + for (i=1; i<=NF; i++) { + if ($i == 0) invalid=1 + last=$i + count++ + } + } + END { if (!count || invalid || last != 10) exit 1 } + ' +} + +fetch_release_set() { + source_base=$1 + source_dir=$2 + source_github_cache_bypass=$3 + if [ "$source_github_cache_bypass" -eq 1 ]; then + source_attempts=$GITHUB_FETCH_ATTEMPTS + source_timeout=$GITHUB_FETCH_TIMEOUT_SECONDS + else + source_attempts=$MIRROR_FETCH_ATTEMPTS + source_timeout=$MIRROR_FETCH_TIMEOUT_SECONDS + fi + mkdir -m 0700 "$source_dir" || return 1 + fetch "${source_base}/SHA256SUMS" "$source_dir/SHA256SUMS" \ + "$MAX_SUMS_BYTES" "$source_github_cache_bypass" "$source_attempts" "$source_timeout" \ + || { source_rc=$?; rm -f -- "$source_dir/SHA256SUMS"; return "$source_rc"; } + fetch "${source_base}/${asset}" "$source_dir/${asset}" \ + "$MAX_BINARY_BYTES" "$source_github_cache_bypass" "$source_attempts" "$source_timeout" \ + || { source_rc=$?; rm -f -- "$source_dir/SHA256SUMS" "$source_dir/${asset}"; return "$source_rc"; } + fetch "${source_base}/${asset}.sig" "$source_dir/${asset}.sig" \ + "$MAX_SIGNATURE_BYTES" "$source_github_cache_bypass" "$source_attempts" "$source_timeout" \ + || { source_rc=$?; rm -f -- "$source_dir/SHA256SUMS" "$source_dir/${asset}" "$source_dir/${asset}.sig"; return "$source_rc"; } + return 0 +} + +mirror_base="" +github_base="" +if [ "$release" = latest ]; then + if fetch "${MIRROR_ROOT}/latest.json" "$tmp/latest.json" "$MAX_MANIFEST_BYTES" 0 \ + "$MIRROR_FETCH_ATTEMPTS" "$MIRROR_FETCH_TIMEOUT_SECONDS"; then + canonical_text_file "$tmp/latest.json" \ + || fail "mirror latest manifest must be NUL-free and newline-terminated" + expected_version=$(parse_mirror_manifest "$tmp/latest.json") \ + || fail "mirror latest manifest is invalid; refusing to hide a possible integrity incident" + printf '%s\n' "$expected_version" \ + | grep -Eq '^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$' \ + || fail "mirror latest manifest contains an invalid release version" + release_tag="v${expected_version}" + mirror_base="${MIRROR_ROOT}/${release_tag}" + github_base="${GITHUB_RELEASE_ROOT}/download/${release_tag}" + else + manifest_rc=$? + [ "$manifest_rc" -ne 2 ] \ + || fail "official mirror index redirected; refusing source-policy fallback" + echo "official mirror index is unavailable; falling back to GitHub" >&2 + github_base="${GITHUB_RELEASE_ROOT}/latest/download" + fi +else + mirror_base="${MIRROR_ROOT}/${release_tag}" + github_base="${GITHUB_RELEASE_ROOT}/download/${release_tag}" fi -# 2) Authenticity: detached ed25519 signature against the embedded release key. -if can_verify_sig; then - if ! fetch "${BASE}/${asset}.sig" "$tmp/sig"; then - echo "could not download ${asset}.sig; refusing to install unverified" >&2 - exit 1 +selected_dir="" +if [ -n "$mirror_base" ]; then + if fetch_release_set "$mirror_base" "$tmp/mirror" 0; then + selected_dir=$tmp/mirror + echo "downloaded the complete release set from the official mirror" >&2 + else + release_set_rc=$? + [ "$release_set_rc" -ne 2 ] \ + || fail "official mirror release redirected; refusing source-policy fallback" + echo "official mirror release download is incomplete; falling back to GitHub" >&2 fi - printf '%s\n' "$RELEASE_PUBKEY_PEM" > "$tmp/release.pem" - if openssl pkeyutl -verify -pubin -inkey "$tmp/release.pem" -rawin \ +fi +if [ -z "$selected_dir" ]; then + fetch_release_set "$github_base" "$tmp/github" 1 \ + || fail "could not download a complete release set from the official mirror or GitHub" + selected_dir=$tmp/github +fi + +mv -- "$selected_dir/${asset}" "$tmp/bin" +mv -- "$selected_dir/SHA256SUMS" "$tmp/sums" +mv -- "$selected_dir/${asset}.sig" "$tmp/sig" + +if ! canonical_text_file "$tmp/sums"; then + fail "SHA256SUMS must be NUL-free and newline-terminated" +fi + +if ! selected_sums=$(awk -v binary="$asset" -v signature="${asset}.sig" ' + function valid_digest(value) { + return length(value) == 64 && value !~ /[^0-9a-f]/ + } + { + digest=substr($0, 1, 64) + separator=substr($0, 65, 2) + name=substr($0, 67) + if (!valid_digest(digest) || separator != " " || name == "" || index(name, " ") != 0) exit 3 + if (name == binary) { + if (binary_found) exit 2 + binary_digest=digest + binary_found=1 + } + if (name == signature) { + if (signature_found) exit 2 + signature_digest=digest + signature_found=1 + } + } + END { + if (!binary_found || !signature_found) exit 1 + print binary_digest + print signature_digest + } +' "$tmp/sums"); then + fail "SHA256SUMS must be canonical and contain exactly one entry for ${asset} and ${asset}.sig" +fi +want=$(printf '%s\n' "$selected_sums" | awk 'NR == 1 { print; found=1 } END { if (!found) exit 1 }') \ + || fail "cannot read checksum for ${asset}" +sig_want=$(printf '%s\n' "$selected_sums" | awk 'NR == 2 { print; found=1 } END { if (!found) exit 1 }') \ + || fail "cannot read checksum for ${asset}.sig" +got=$(sha256sum "$tmp/bin" | awk '{print $1}') +[ "$want" = "$got" ] || fail "checksum verification failed for ${asset}" +sig_got=$(sha256sum "$tmp/sig" | awk '{print $1}') +[ "$sig_want" = "$sig_got" ] || fail "checksum verification failed for ${asset}.sig" + +openssl pkeyutl -help 2>&1 | grep -q -- '-rawin' \ + || fail "openssl >= 3.0 with pkeyutl -rawin is required; unsigned fallback is not allowed" +[ "$(wc -c < "$tmp/sig")" -eq 64 ] || fail "invalid signature size for ${asset}" + +if ! key_count=$(printf '%s' "$RELEASE_PUBKEY_PEMS" | awk -v dir="$tmp" ' + /^-----BEGIN PUBLIC KEY-----$/ { + n++; inkey=1; file=sprintf("%s/release-key.%d.pem", dir, n) + } + inkey { print > file } + /^-----END PUBLIC KEY-----$/ { close(file); inkey=0 } + END { if (inkey || n == 0) exit 1; print n } +'); then + fail "embedded release keyring is malformed" +fi + +verified=0 +key_index=1 +while [ "$key_index" -le "$key_count" ]; do + if openssl pkeyutl -verify -pubin -inkey "$tmp/release-key.${key_index}.pem" -rawin \ -in "$tmp/bin" -sigfile "$tmp/sig" >/dev/null 2>&1; then - echo "signature verified for ${asset}" - else - echo "SIGNATURE VERIFICATION FAILED for ${asset}; refusing to install" >&2 - exit 1 + verified=1 + break fi -elif [ "${LTA_ALLOW_UNVERIFIED:-}" = "1" ]; then - echo "WARNING: openssl >= 3.0 not available; skipping the ed25519 signature check" >&2 - echo "WARNING: proceeding with checksum-only trust (LTA_ALLOW_UNVERIFIED=1)" >&2 + key_index=$((key_index + 1)) +done +[ "$verified" -eq 1 ] || fail "SIGNATURE VERIFICATION FAILED for ${asset}" + +dest_dir=$(dirname -- "$DEST") +existing_ancestor=$dest_dir +while [ ! -e "$existing_ancestor" ] && [ ! -L "$existing_ancestor" ]; do + next_ancestor=$(dirname -- "$existing_ancestor") + [ "$next_ancestor" != "$existing_ancestor" ] || fail "cannot resolve destination parent" + existing_ancestor=$next_ancestor +done + +check_safe_dir_chain() { + check_dir=$1 + while :; do + if [ ! -d "$check_dir" ] || [ -L "$check_dir" ]; then + fail "unsafe destination directory: $check_dir" + fi + if ! check_uid=$(stat -c %u -- "$check_dir"); then + fail "cannot inspect destination directory owner: $check_dir" + fi + case "$check_uid" in + '' | *[!0-9]*) fail "invalid destination directory owner: $check_dir" ;; + esac + [ "$check_uid" -eq 0 ] || fail "destination directory is not root-owned: $check_dir" + if ! check_mode=$(stat -c %A -- "$check_dir"); then + fail "cannot inspect destination directory mode: $check_dir" + fi + case "$check_mode" in + d?????????) ;; + *) fail "invalid destination directory mode: $check_dir" ;; + esac + case "$check_mode" in + ?????w????|????????w?) fail "destination directory is group/world writable: $check_dir" ;; + esac + case "$check_dir" in + / | //) break ;; + esac + check_dir=$(dirname -- "$check_dir") + done +} + +check_safe_dir_chain "$existing_ancestor" +mkdir -p -- "$dest_dir" +check_safe_dir_chain "$dest_dir" + +if [ -L "$DEST" ]; then + fail "destination is a symlink: $DEST" +fi +if [ -e "$DEST" ] && [ ! -f "$DEST" ]; then + fail "destination is not a regular file: $DEST" +fi + +stage=$(mktemp "${dest_dir}/.linux-temp-admin.XXXXXX") +if [ ! -f "$stage" ] || [ -L "$stage" ]; then + fail "could not create a safe staging file" +fi +cp -- "$tmp/bin" "$stage" +chown 0:0 -- "$stage" +chmod 0755 -- "$stage" +[ "$(sha256sum "$stage" | awk '{print $1}')" = "$got" ] || fail "staging copy changed unexpectedly" + +# Probe before committing. RLIMIT_FSIZE bounds stdout even if a signed but buggy +# candidate prints forever; timeout kills a hanging candidate and its children. +if ! ( + ulimit -f 1 || exit 1 + exec timeout -k 1 10 "$stage" version > "$tmp/version" 2> "$tmp/version.err" +); then + fail "downloaded binary failed its pre-install version probe" +fi +[ "$(wc -c < "$tmp/version")" -le "$MAX_PROBE_BYTES" ] || fail "version output is too large" +if ! candidate_version=$(awk ' + NR == 1 && $0 ~ /^[0-9]+[.][0-9]+[.][0-9]+([-_+~][A-Za-z0-9._+~-]+)?$/ { version=$0; next } + { invalid=1 } + END { if (NR != 1 || invalid) exit 1; printf "%s", version } +' "$tmp/version"); then + fail "downloaded binary reported an invalid or multi-line version" +fi +[ -z "$expected_version" ] || [ "$candidate_version" = "$expected_version" ] \ + || fail "downloaded binary version does not match LTA_RELEASE" + +[ "$(sha256sum "$stage" | awk '{print $1}')" = "$got" ] \ + || fail "staging copy changed during the version probe" +check_safe_dir_chain "$dest_dir" +if [ -L "$DEST" ] || { [ -e "$DEST" ] && [ ! -f "$DEST" ]; }; then + fail "destination changed before commit: $DEST" +fi +if [ "$DEST" = "$MANAGED_DEST" ]; then + # The signed candidate owns the lifecycle lock and uninstall marker protocol. + # Delegating the managed-path commit to it serializes reinstall with every + # other mutation and reactivates a deliberately uninstalled host. An unsafe + # marker is rejected before the candidate changes the stable command. + if ! timeout -k 1 30 "$stage" --lang en install --force >/dev/null 2>&1; then + fail "signed candidate could not complete the managed install/reactivation" + fi + rm -f -- "$stage" || fail "could not remove the verified staging file" + stage="" else - echo "cannot verify the release signature: openssl >= 3.0 (pkeyutl -rawin) not found." >&2 - echo "install openssl, or re-run with LTA_ALLOW_UNVERIFIED=1 to accept checksum-only trust." >&2 - exit 1 + if mv --help 2>&1 | grep -q -- '--no-target-directory'; then + mv -fT -- "$stage" "$DEST" + else + mv -f -- "$stage" "$DEST" + fi + stage="" fi -mkdir -p "$(dirname "$DEST")" -cp "$tmp/bin" "${DEST}.new" -chmod 0755 "${DEST}.new" -chown root:root "${DEST}.new" -mv "${DEST}.new" "$DEST" -echo "installed ${DEST}" -"$DEST" version +if [ -L "$DEST" ] || [ ! -f "$DEST" ]; then + fail "installed destination is not a regular non-symlink file" +fi +[ "$(sha256sum "$DEST" | awk '{print $1}')" = "$got" ] \ + || fail "installed destination differs from the verified candidate" +if ! final_uid=$(stat -c %u -- "$DEST"); then + fail "cannot inspect installed destination owner" +fi +case "$final_uid" in + '' | *[!0-9]*) fail "invalid installed destination owner" ;; +esac +[ "$final_uid" -eq 0 ] || fail "installed destination is not root-owned" +if ! final_gid=$(stat -c %g -- "$DEST"); then + fail "cannot inspect installed destination group" +fi +case "$final_gid" in + '' | *[!0-9]*) fail "invalid installed destination group" ;; +esac +[ "$final_gid" -eq 0 ] || fail "installed destination group is not root" +if ! final_mode=$(stat -c %a -- "$DEST"); then + fail "cannot inspect installed destination mode" +fi +[ "$final_mode" = 755 ] || fail "installed destination mode is not 0755" +echo "installed ${DEST} (version ${candidate_version})" diff --git a/scripts/mirror-receiver.py b/scripts/mirror-receiver.py new file mode 100644 index 0000000..57c8ce8 --- /dev/null +++ b/scripts/mirror-receiver.py @@ -0,0 +1,545 @@ +#!/usr/bin/python3 +"""Restricted rsync receiver for the official linux-temp-admin mirror.""" + +from __future__ import annotations + +import datetime as dt +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import resource +import secrets +import shlex +import shutil +import signal +import stat +import subprocess +import sys +import tempfile + + +PROJECT_ROOT = Path("/www/wwwroot/dl.ll.cd/linux-temp-admin") +INCOMING_ROOT = Path("/var/lib/linux-temp-admin-mirror") +LOCK_PATH = INCOMING_ROOT / ".deploy.lock" +RRSYNC = Path("/usr/bin/rrsync") +MIRROR_BASE_URL = "https://dl.ll.cd/linux-temp-admin" +TRANSFER_TIMEOUT_SECONDS = 300 +MAX_BINARY_BYTES = 64 * 1024 * 1024 +MAX_METADATA_BYTES = 1024 * 1024 + +VERSION_PATTERN = re.compile( + r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + r"(?:-([0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*))?" +) +EXPECTED_VERSION_FILES = ( + "SHA256SUMS", + "linux-temp-admin-linux-amd64", + "linux-temp-admin-linux-amd64.sig", + "linux-temp-admin-linux-arm64", + "linux-temp-admin-linux-arm64.sig", + "install.sh", +) +CHECKSUM_FILES = ( + "linux-temp-admin-linux-amd64", + "linux-temp-admin-linux-amd64.sig", + "linux-temp-admin-linux-arm64", + "linux-temp-admin-linux-arm64.sig", +) +STABLE_FILES = ("install.sh", "latest.json") + + +class ReceiverError(RuntimeError): + pass + + +def fail(message: str) -> None: + raise ReceiverError(message) + + +def lstat(path: Path) -> os.stat_result: + try: + return path.lstat() + except OSError as exc: + fail(f"cannot inspect {path}: {exc}") + + +def require_directory(path: Path, *, owner: int, mode: int | None = None) -> None: + info = lstat(path) + if not stat.S_ISDIR(info.st_mode) or path.is_symlink(): + fail(f"not a real directory: {path}") + if info.st_uid != owner: + fail(f"unexpected directory owner: {path}") + if info.st_mode & 0o7022: + fail(f"directory is writable by another account or has special bits: {path}") + if mode is not None and stat.S_IMODE(info.st_mode) != mode: + fail(f"unexpected directory mode: {path}") + + +def require_safe_ancestry(path: Path, *, leaf_owner: int, leaf_mode: int) -> None: + if not path.is_absolute() or path.resolve(strict=True) != path: + fail(f"path is not canonical: {path}") + current = Path("/") + parts = path.parts[1:] + for index, part in enumerate(parts): + current /= part + info = lstat(current) + if not stat.S_ISDIR(info.st_mode) or current.is_symlink(): + fail(f"unsafe directory ancestor: {current}") + is_leaf = index == len(parts) - 1 + expected_owner = leaf_owner if is_leaf else 0 + if info.st_uid != expected_owner: + fail(f"unexpected directory owner: {current}") + if info.st_mode & 0o7022: + fail(f"unsafe directory permissions: {current}") + if is_leaf and stat.S_IMODE(info.st_mode) != leaf_mode: + fail(f"unexpected directory mode: {current}") + + +def require_regular(path: Path, *, owner: int, maximum: int, exact: int | None = None) -> int: + info = lstat(path) + if not stat.S_ISREG(info.st_mode) or path.is_symlink(): + fail(f"not a regular file: {path}") + if info.st_uid != owner or info.st_nlink != 1: + fail(f"unsafe file ownership or link count: {path}") + if info.st_mode & 0o7022: + fail(f"file is writable by another account or has special bits: {path}") + if info.st_size <= 0 or info.st_size > maximum: + fail(f"file has an invalid size: {path}") + if exact is not None and info.st_size != exact: + fail(f"file has an unexpected exact size: {path}") + return info.st_size + + +def require_trusted_executable(path: Path, *, owner: int) -> None: + try: + canonical = path.resolve(strict=True) + except OSError as exc: + fail(f"cannot resolve trusted executable {path}: {exc}") + if not path.is_absolute() or canonical != path: + fail(f"trusted executable path is not canonical: {path}") + info = lstat(path) + if not stat.S_ISREG(info.st_mode) or path.is_symlink(): + fail(f"trusted executable is not a regular file: {path}") + if info.st_uid != owner or info.st_mode & 0o7022: + fail(f"trusted executable has unsafe ownership or permissions: {path}") + if info.st_size <= 0 or info.st_size > MAX_METADATA_BYTES: + fail(f"trusted executable has an invalid size: {path}") + if stat.S_IMODE(info.st_mode) & 0o111 == 0 or not os.access(path, os.X_OK): + fail(f"trusted executable is not executable: {path}") + + +def parse_request(command: str) -> tuple[str, str]: + if not command or "\x00" in command or "\n" in command or "\r" in command: + fail("missing or malformed SSH_ORIGINAL_COMMAND") + if any(character in "'\"\\" or ord(character) < 0x20 or ord(character) == 0x7F + for character in command): + fail("SSH_ORIGINAL_COMMAND contains ambiguous quoting or control characters") + try: + argv = shlex.split(command, posix=True) + except ValueError as exc: + fail(f"invalid rsync command quoting: {exc}") + if len(argv) < 5 or argv[:2] != ["rsync", "--server"] or "--sender" in argv: + fail("only an rsync receiver command is allowed") + dot_indexes = [index for index, value in enumerate(argv) if value == "."] + if len(dot_indexes) != 1: + fail("rsync command must contain one destination marker") + dot_index = dot_indexes[0] + if dot_index < 3 or len(argv[dot_index + 1 :]) != 1: + fail("rsync command must contain one destination") + destination = argv[dot_index + 1] + if destination.startswith("./"): + destination = destination[2:] + options = argv[2:dot_index] + if destination in STABLE_FILES: + request_type = "stable" + normalized_destination = destination + expected_long_options = ["--delay-updates"] + else: + version = destination[:-1] if destination.endswith("/") else destination + version_match = VERSION_PATTERN.fullmatch(version) + if version_match is None: + fail("destination is not an allowed stable file or canonical version directory") + major = version_match.group(1) + if len(major) == 1 and int(major) < 2: + fail("release versions below v2 are not accepted") + if destination != f"{version}/": + fail("version uploads must be directory-scoped") + request_type = "version" + normalized_destination = version + expected_long_options = ["--delay-updates", "--ignore-existing"] + + short_options = [option for option in options if not option.startswith("--")] + long_options = [option for option in options if option.startswith("--")] + if ( + short_options != ["-logDtprce.iLsfxCIvu"] + or sorted(long_options) != sorted(expected_long_options) + ): + fail("rsync command does not use the exact deployment option profile") + return request_type, normalized_destination + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb", buffering=0) as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def files_equal(left: Path, right: Path) -> bool: + left_info = lstat(left) + right_info = lstat(right) + if not stat.S_ISREG(left_info.st_mode) or not stat.S_ISREG(right_info.st_mode): + return False + if left_info.st_size != right_info.st_size: + return False + with left.open("rb", buffering=0) as left_handle, right.open("rb", buffering=0) as right_handle: + while True: + left_chunk = left_handle.read(1024 * 1024) + right_chunk = right_handle.read(1024 * 1024) + if left_chunk != right_chunk: + return False + if not left_chunk: + return True + + +def canonical_checksum_bytes(directory: Path) -> bytes: + return b"".join( + f"{sha256_file(directory / name)} {name}\n".encode("ascii") + for name in CHECKSUM_FILES + ) + + +def validate_version(directory: Path, *, owner: int, published: bool = False) -> None: + require_directory(directory, owner=owner, mode=0o755 if published else None) + entries = sorted(entry.name for entry in os.scandir(directory)) + if entries != sorted(EXPECTED_VERSION_FILES): + fail(f"version directory does not contain the exact release set: {directory}") + for name in EXPECTED_VERSION_FILES: + maximum = MAX_BINARY_BYTES if name in ( + "linux-temp-admin-linux-amd64", + "linux-temp-admin-linux-arm64", + ) else MAX_METADATA_BYTES + exact = 64 if name.endswith(".sig") else None + require_regular(directory / name, owner=owner, maximum=maximum, exact=exact) + checksum = (directory / "SHA256SUMS").read_bytes() + if checksum != canonical_checksum_bytes(directory): + fail(f"SHA256SUMS is not canonical or does not match the release files: {directory}") + if published: + for name in EXPECTED_VERSION_FILES: + if stat.S_IMODE(lstat(directory / name).st_mode) != 0o644: + fail(f"published release file mode is not 0644: {directory / name}") + + +def stable_version_tuple(tag: str) -> tuple[int, int, int]: + match = VERSION_PATTERN.fullmatch(tag) + if match is None or match.group(4) is not None: + fail("stable metadata must name a canonical non-prerelease tag") + return tuple(int(match.group(index)) for index in range(1, 4)) + + +def parse_latest(path: Path, *, owner: int) -> tuple[dict[str, str], bytes]: + require_regular(path, owner=owner, maximum=MAX_METADATA_BYTES) + raw = path.read_bytes() + try: + value = json.loads(raw.decode("ascii")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + fail(f"latest.json is not canonical ASCII JSON: {exc}") + if not isinstance(value, dict) or list(value) != ["version", "tag", "base_url", "published_at"]: + fail("latest.json has an unexpected field set or order") + if not all(isinstance(item, str) for item in value.values()): + fail("latest.json fields must all be strings") + tag = value["tag"] + stable_version_tuple(tag) + if value["version"] != tag[1:]: + fail("latest.json has an invalid tag/version pair") + if value["base_url"] != f"{MIRROR_BASE_URL}/{tag}": + fail("latest.json has an invalid base_url") + published_at = value["published_at"] + if re.fullmatch( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?Z", + published_at, + ) is None: + fail("latest.json has an invalid published_at") + try: + dt.datetime.fromisoformat(published_at[:-1] + "+00:00") + except ValueError as exc: + fail(f"latest.json has an invalid timestamp: {exc}") + canonical = (json.dumps(value, ensure_ascii=True, separators=(",", ":")) + "\n").encode("ascii") + if raw != canonical: + fail("latest.json is not canonical single-line JSON") + return value, raw + + +def validate_latest(path: Path, *, owner: int, project_root: Path) -> dict[str, str]: + value, _ = parse_latest(path, owner=owner) + tag = value["tag"] + version_dir = project_root / tag + validate_version(version_dir, owner=owner, published=True) + stable_installer = project_root / "install.sh" + require_regular(stable_installer, owner=owner, maximum=MAX_METADATA_BYTES) + if not files_equal(stable_installer, version_dir / "install.sh"): + fail("stable installer does not match the manifest version") + return value + + +def current_latest_state( + project_root: Path, *, owner: int +) -> tuple[tuple[int, int, int], bytes] | None: + latest = project_root / "latest.json" + if not latest.exists() and not latest.is_symlink(): + return None + value, raw = parse_latest(latest, owner=owner) + validate_version(project_root / value["tag"], owner=owner, published=True) + return stable_version_tuple(value["tag"]), raw + + +def fsync_file(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def copy_to_private_temp(source: Path, directory: Path, label: str) -> Path: + temporary = directory / f".mirror-{label}-{secrets.token_hex(16)}" + descriptor = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + try: + with source.open("rb", buffering=0) as source_handle, os.fdopen( + descriptor, "wb", buffering=0, closefd=False + ) as destination_handle: + shutil.copyfileobj(source_handle, destination_handle, 1024 * 1024) + os.fsync(destination_handle.fileno()) + os.fchmod(descriptor, 0o644) + os.fsync(descriptor) + except BaseException: + try: + temporary.unlink() + except FileNotFoundError: + pass + raise + finally: + os.close(descriptor) + return temporary + + +def publish_version(staged: Path, destination: Path, *, owner: int) -> None: + validate_version(staged, owner=owner) + for name in EXPECTED_VERSION_FILES: + os.chmod(staged / name, 0o644, follow_symlinks=False) + fsync_file(staged / name) + if destination.exists() or destination.is_symlink(): + require_directory(destination, owner=owner, mode=0o755) + existing = sorted(entry.name for entry in os.scandir(destination)) + if not set(existing).issubset(EXPECTED_VERSION_FILES): + fail(f"version directory contains a non-release path: {destination}") + for name in existing: + maximum = MAX_BINARY_BYTES if name in ( + "linux-temp-admin-linux-amd64", + "linux-temp-admin-linux-arm64", + ) else MAX_METADATA_BYTES + exact = 64 if name.endswith(".sig") else None + require_regular(destination / name, owner=owner, maximum=maximum, exact=exact) + if stat.S_IMODE(lstat(destination / name).st_mode) != 0o644: + fail(f"published release file mode is not 0644: {destination / name}") + if not files_equal(staged / name, destination / name): + fail( + "immutable release file already exists with different bytes: " + f"{destination / name}" + ) + else: + try: + os.mkdir(destination, 0o755) + except FileExistsError: + fail(f"version destination appeared concurrently: {destination}") + os.chmod(destination, 0o755, follow_symlinks=False) + require_directory(destination, owner=owner, mode=0o755) + fsync_directory(destination.parent) + try: + for name in EXPECTED_VERSION_FILES: + if (destination / name).exists() or (destination / name).is_symlink(): + continue + temporary = copy_to_private_temp(staged / name, destination, name) + try: + os.link(temporary, destination / name, follow_symlinks=False) + temporary.unlink() + fsync_directory(destination) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + validate_version(destination, owner=owner, published=True) + except BaseException: + # Valid files already linked into an incomplete version are deliberately + # retained. A retry may fill the missing files but can never replace one. + raise + fsync_directory(destination) + + +def matching_stable_installer_versions( + project_root: Path, installer: Path, *, owner: int +) -> list[tuple[int, int, int]]: + matches: list[tuple[int, int, int]] = [] + with os.scandir(project_root) as entries: + for entry in entries: + version_match = VERSION_PATTERN.fullmatch(entry.name) + if (not entry.is_dir(follow_symlinks=False) or version_match is None + or version_match.group(4) is not None): + continue + version_dir = project_root / entry.name + try: + validate_version(version_dir, owner=owner, published=True) + except ReceiverError: + continue + if files_equal(installer, version_dir / "install.sh"): + matches.append(tuple(int(version_match.group(index)) for index in range(1, 4))) + return matches + + +def atomic_replace(staged: Path, destination: Path, *, project_root: Path, owner: int) -> None: + require_regular(staged, owner=owner, maximum=MAX_METADATA_BYTES) + os.chmod(staged, 0o644, follow_symlinks=False) + fsync_file(staged) + if destination.exists() or destination.is_symlink(): + require_regular(destination, owner=owner, maximum=MAX_METADATA_BYTES) + temporary = copy_to_private_temp(staged, project_root, destination.name) + try: + os.replace(temporary, destination) + fsync_directory(project_root) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def publish_stable(staged: Path, name: str, *, project_root: Path, owner: int) -> None: + if name == "install.sh": + require_regular(staged, owner=owner, maximum=MAX_METADATA_BYTES) + matching_versions = matching_stable_installer_versions( + project_root, staged, owner=owner + ) + if not matching_versions: + fail("stable installer is not byte-identical to a complete stable version") + current = current_latest_state(project_root, owner=owner) + if current is not None and not any(version >= current[0] for version in matching_versions): + fail("stable installer would roll back the published stable version") + elif name == "latest.json": + candidate = validate_latest(staged, owner=owner, project_root=project_root) + candidate_version = stable_version_tuple(candidate["tag"]) + candidate_raw = staged.read_bytes() + current = current_latest_state(project_root, owner=owner) + if current is not None: + current_version, current_raw = current + if candidate_version < current_version: + fail("latest.json would roll back the published stable version") + if candidate_version == current_version and candidate_raw != current_raw: + fail("latest.json cannot mutate metadata for the current stable version") + else: + fail("unexpected stable file") + atomic_replace(staged, project_root / name, project_root=project_root, owner=owner) + + +def limit_receiver() -> None: + resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + resource.setrlimit(resource.RLIMIT_FSIZE, (MAX_BINARY_BYTES, MAX_BINARY_BYTES)) + resource.setrlimit(resource.RLIMIT_NOFILE, (128, 128)) + + +def run_rrsync(stage: Path, original_command: str) -> None: + environment = { + "HOME": str(INCOMING_ROOT), + "LC_ALL": "C", + "LOGNAME": str(os.getuid()), + "PATH": "/usr/bin:/bin", + "SSH_CONNECTION": os.environ.get("SSH_CONNECTION", "unknown 0 unknown 0"), + "SSH_ORIGINAL_COMMAND": original_command, + "USER": str(os.getuid()), + } + process = subprocess.Popen( + [str(RRSYNC), "-wo", "-no-del", str(stage)], + env=environment, + preexec_fn=limit_receiver, + start_new_session=True, + ) + try: + status = process.wait(timeout=TRANSFER_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + fail("rsync transfer exceeded its time limit") + if status != 0: + fail(f"rrsync rejected or failed the transfer with status {status}") + + +def open_lock(path: Path, *, owner: int) -> int: + descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_CLOEXEC | os.O_NOFOLLOW, 0o600) + info = os.fstat(descriptor) + if (not stat.S_ISREG(info.st_mode) or info.st_uid != owner + or stat.S_IMODE(info.st_mode) != 0o600): + os.close(descriptor) + fail("deployment lock has unsafe metadata") + fcntl.flock(descriptor, fcntl.LOCK_EX) + return descriptor + + +def main() -> int: + owner = os.getuid() + if owner == 0: + fail("mirror receiver must not run as root") + require_safe_ancestry(PROJECT_ROOT, leaf_owner=owner, leaf_mode=0o755) + require_safe_ancestry(INCOMING_ROOT, leaf_owner=owner, leaf_mode=0o700) + require_trusted_executable(RRSYNC, owner=0) + + original_command = os.environ.get("SSH_ORIGINAL_COMMAND", "") + request_type, destination = parse_request(original_command) + stage = Path(tempfile.mkdtemp(prefix="transfer-", dir=INCOMING_ROOT)) + os.chmod(stage, 0o700) + try: + run_rrsync(stage, original_command) + root_entries = sorted(entry.name for entry in os.scandir(stage)) + expected_root = [destination] + if root_entries != expected_root: + fail("transfer created an unexpected staging tree") + lock_descriptor = open_lock(LOCK_PATH, owner=owner) + try: + require_safe_ancestry(PROJECT_ROOT, leaf_owner=owner, leaf_mode=0o755) + if request_type == "version": + publish_version(stage / destination, PROJECT_ROOT / destination, owner=owner) + else: + publish_stable( + stage / destination, + destination, + project_root=PROJECT_ROOT, + owner=owner, + ) + finally: + os.close(lock_descriptor) + finally: + shutil.rmtree(stage, ignore_errors=True) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ReceiverError as exc: + print(f"mirror receiver: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/mirror_receiver_test.py b/scripts/mirror_receiver_test.py new file mode 100644 index 0000000..d2ea923 --- /dev/null +++ b/scripts/mirror_receiver_test.py @@ -0,0 +1,281 @@ +#!/usr/bin/python3 + +import importlib.util +import json +import os +from pathlib import Path +import shutil +import tempfile +import unittest + + +MODULE_PATH = Path(__file__).with_name("mirror-receiver.py") +SPEC = importlib.util.spec_from_file_location("mirror_receiver", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +mirror_receiver = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(mirror_receiver) + + +def make_version( + parent: Path, + tag: str = "v2.8.0", + installer: bytes = b"#!/bin/sh\nexit 0\n", +) -> Path: + parent.mkdir(mode=0o755) + directory = parent / tag + directory.mkdir(mode=0o755) + payloads = { + "linux-temp-admin-linux-amd64": b"amd64-binary", + "linux-temp-admin-linux-amd64.sig": b"a" * 64, + "linux-temp-admin-linux-arm64": b"arm64-binary", + "linux-temp-admin-linux-arm64.sig": b"b" * 64, + "install.sh": installer, + } + for name, content in payloads.items(): + (directory / name).write_bytes(content) + (directory / name).chmod(0o644) + (directory / "SHA256SUMS").write_bytes(mirror_receiver.canonical_checksum_bytes(directory)) + (directory / "SHA256SUMS").chmod(0o644) + return directory + + +def make_latest(path: Path, tag: str, published_at: str = "2026-07-27T00:00:00Z") -> Path: + path.write_text( + json.dumps( + { + "version": tag[1:], + "tag": tag, + "base_url": f"https://dl.ll.cd/linux-temp-admin/{tag}", + "published_at": published_at, + }, + separators=(",", ":"), + ) + + "\n", + encoding="ascii", + ) + path.chmod(0o644) + return path + + +class ParseRequestTests(unittest.TestCase): + def test_accepts_only_expected_destinations(self) -> None: + version = "rsync --server --ignore-existing -logDtprce.iLsfxCIvu --delay-updates . v2.8.0/" + stable = "rsync --server -logDtprce.iLsfxCIvu --delay-updates . install.sh" + self.assertEqual(mirror_receiver.parse_request(version), ("version", "v2.8.0")) + self.assertEqual(mirror_receiver.parse_request(stable), ("stable", "install.sh")) + + def test_rejects_unsafe_or_mutable_version_commands(self) -> None: + commands = ( + "bash -c id", + "rsync --server -logDtpre.iLsfxCIvu . v2.8.0/", + "rsync --server --ignore-existing --inplace . v2.8.0/", + "rsync --server --ignore-existing --only-write-batch=/tmp/out . v2.8.0/", + "rsync --server --ignore-existing --files-from=/tmp/list . v2.8.0/", + "rsync --server --ignore-existing -D --delay-updates . v2.8.0/", + "rsync --server --ignore-existing -Rb --delay-updates . v2.8.0/", + "rsync --server --ignore-existing . ../v2.8.0/", + "rsync --server --ignore-existing . v1.9.9/", + "rsync --server --sender --ignore-existing . v2.8.0/", + "rsync --server --ignore-existing . latest.json extra", + "rsync --server --ignore-existing . 'v2.8.0/'", + "rsync --server --ignore-existing . v2.8.0\\/", + "rsync\t--server --ignore-existing . v2.8.0/", + "rsync --server --ignore-existing --delay-updates . install.sh", + ) + for command in commands: + with self.subTest(command=command), self.assertRaises(mirror_receiver.ReceiverError): + mirror_receiver.parse_request(command) + + +class TrustedExecutableTests(unittest.TestCase): + def test_requires_canonical_owned_nonwritable_executable(self) -> None: + with tempfile.TemporaryDirectory(prefix="mirror-executable-test-") as temporary: + directory = Path(temporary) + executable = directory / "rrsync" + executable.write_text("#!/bin/sh\nexit 0\n", encoding="ascii") + executable.chmod(0o755) + mirror_receiver.require_trusted_executable(executable, owner=os.getuid()) + + executable.chmod(0o775) + with self.assertRaises(mirror_receiver.ReceiverError): + mirror_receiver.require_trusted_executable(executable, owner=os.getuid()) + + executable.chmod(0o644) + with self.assertRaises(mirror_receiver.ReceiverError): + mirror_receiver.require_trusted_executable(executable, owner=os.getuid()) + + executable.chmod(0o755) + symlink = directory / "rrsync-link" + symlink.symlink_to(executable) + with self.assertRaises(mirror_receiver.ReceiverError): + mirror_receiver.require_trusted_executable(symlink, owner=os.getuid()) + + +class ReceiverPolicyTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = Path(tempfile.mkdtemp(prefix="mirror-receiver-test-")) + self.owner = os.getuid() + self.project = self.temporary / "project" + self.project.mkdir(mode=0o755) + + def tearDown(self) -> None: + shutil.rmtree(self.temporary) + + def test_version_is_create_only_idempotent_and_repairable(self) -> None: + first = make_version(self.temporary / "first") + destination = self.project / "v2.8.0" + mirror_receiver.publish_version(first, destination, owner=self.owner) + mirror_receiver.validate_version(destination, owner=self.owner, published=True) + + (destination / "linux-temp-admin-linux-arm64.sig").unlink() + second = make_version(self.temporary / "second") + mirror_receiver.publish_version(second, destination, owner=self.owner) + mirror_receiver.validate_version(destination, owner=self.owner, published=True) + + third = make_version(self.temporary / "third") + (third / "linux-temp-admin-linux-amd64").write_bytes(b"different") + (third / "SHA256SUMS").write_bytes(mirror_receiver.canonical_checksum_bytes(third)) + with self.assertRaises(mirror_receiver.ReceiverError): + mirror_receiver.publish_version(third, destination, owner=self.owner) + self.assertEqual( + (destination / "linux-temp-admin-linux-amd64").read_bytes(), b"amd64-binary" + ) + + def test_new_version_directory_ignores_restrictive_process_umask(self) -> None: + staged = make_version(self.temporary / "restrictive-umask") + destination = self.project / "v2.8.0" + previous_umask = os.umask(0o077) + try: + mirror_receiver.publish_version(staged, destination, owner=self.owner) + finally: + os.umask(previous_umask) + + self.assertEqual(destination.stat().st_mode & 0o7777, 0o755) + mirror_receiver.validate_version(destination, owner=self.owner, published=True) + + def test_version_rejects_extra_paths_and_bad_checksums(self) -> None: + extra = make_version(self.temporary / "extra") + (extra / "unexpected").write_text("no", encoding="ascii") + with self.assertRaises(mirror_receiver.ReceiverError): + mirror_receiver.validate_version(extra, owner=self.owner) + + bad = make_version(self.temporary / "bad") + (bad / "SHA256SUMS").write_text( + "0" * 64 + " linux-temp-admin-linux-amd64\n", encoding="ascii" + ) + with self.assertRaises(mirror_receiver.ReceiverError): + mirror_receiver.validate_version(bad, owner=self.owner) + + def test_stable_files_must_bind_to_a_complete_version(self) -> None: + staged_version = make_version(self.temporary / "version") + mirror_receiver.publish_version(staged_version, self.project / "v2.8.0", owner=self.owner) + + staged_installer = self.temporary / "install.sh" + staged_installer.write_bytes((self.project / "v2.8.0/install.sh").read_bytes()) + staged_installer.chmod(0o644) + mirror_receiver.publish_stable( + staged_installer, "install.sh", project_root=self.project, owner=self.owner + ) + + latest = make_latest(self.temporary / "latest.json", "v2.8.0") + mirror_receiver.publish_stable( + latest, "latest.json", project_root=self.project, owner=self.owner + ) + self.assertEqual((self.project / "latest.json").read_bytes(), latest.read_bytes()) + + wrong = self.temporary / "wrong-install.sh" + wrong.write_bytes(b"#!/bin/sh\nexit 1\n") + wrong.chmod(0o644) + with self.assertRaises(mirror_receiver.ReceiverError): + mirror_receiver.publish_stable( + wrong, "install.sh", project_root=self.project, owner=self.owner + ) + + def test_latest_rejects_noncanonical_or_inconsistent_content(self) -> None: + staged_version = make_version(self.temporary / "version") + mirror_receiver.publish_version(staged_version, self.project / "v2.8.0", owner=self.owner) + shutil.copyfile(self.project / "v2.8.0/install.sh", self.project / "install.sh") + (self.project / "install.sh").chmod(0o644) + + latest = self.temporary / "latest.json" + latest.write_text( + '{"tag":"v2.8.0","version":"2.8.0",' + '"base_url":"https://dl.ll.cd/linux-temp-admin/v2.8.0",' + '"published_at":"2026-07-27T00:00:00Z"}\n', + encoding="ascii", + ) + latest.chmod(0o644) + with self.assertRaises(mirror_receiver.ReceiverError): + mirror_receiver.validate_latest(latest, owner=self.owner, project_root=self.project) + + def test_stable_metadata_rejects_prereleases(self) -> None: + staged_version = make_version(self.temporary / "prerelease", tag="v2.9.0-rc.1") + mirror_receiver.publish_version( + staged_version, self.project / "v2.9.0-rc.1", owner=self.owner + ) + shutil.copyfile(staged_version / "install.sh", self.project / "install.sh") + (self.project / "install.sh").chmod(0o644) + latest = make_latest(self.temporary / "prerelease-latest.json", "v2.9.0-rc.1") + with self.assertRaises(mirror_receiver.ReceiverError): + mirror_receiver.validate_latest(latest, owner=self.owner, project_root=self.project) + + def test_stable_files_cannot_roll_back_or_mutate_current_metadata(self) -> None: + old = make_version( + self.temporary / "old-version", tag="v2.8.0", installer=b"#!/bin/sh\n# old\n" + ) + new = make_version( + self.temporary / "new-version", tag="v2.9.0", installer=b"#!/bin/sh\n# new\n" + ) + mirror_receiver.publish_version(old, self.project / "v2.8.0", owner=self.owner) + mirror_receiver.publish_version(new, self.project / "v2.9.0", owner=self.owner) + + old_installer = self.temporary / "old-install.sh" + shutil.copyfile(old / "install.sh", old_installer) + old_installer.chmod(0o644) + mirror_receiver.publish_stable( + old_installer, "install.sh", project_root=self.project, owner=self.owner + ) + old_latest = make_latest(self.temporary / "old-latest.json", "v2.8.0") + mirror_receiver.publish_stable( + old_latest, "latest.json", project_root=self.project, owner=self.owner + ) + + new_installer = self.temporary / "new-install.sh" + shutil.copyfile(new / "install.sh", new_installer) + new_installer.chmod(0o644) + mirror_receiver.publish_stable( + new_installer, "install.sh", project_root=self.project, owner=self.owner + ) + new_latest = make_latest(self.temporary / "new-latest.json", "v2.9.0") + mirror_receiver.publish_stable( + new_latest, "latest.json", project_root=self.project, owner=self.owner + ) + + with self.assertRaisesRegex(mirror_receiver.ReceiverError, "roll back"): + mirror_receiver.publish_stable( + old_installer, "install.sh", project_root=self.project, owner=self.owner + ) + + shutil.copyfile(old / "install.sh", self.project / "install.sh") + (self.project / "install.sh").chmod(0o644) + with self.assertRaisesRegex(mirror_receiver.ReceiverError, "roll back"): + mirror_receiver.publish_stable( + old_latest, "latest.json", project_root=self.project, owner=self.owner + ) + self.assertEqual((self.project / "latest.json").read_bytes(), new_latest.read_bytes()) + + shutil.copyfile(new / "install.sh", self.project / "install.sh") + (self.project / "install.sh").chmod(0o644) + changed_latest = make_latest( + self.temporary / "changed-latest.json", + "v2.9.0", + published_at="2026-07-27T00:00:01Z", + ) + with self.assertRaisesRegex(mirror_receiver.ReceiverError, "cannot mutate"): + mirror_receiver.publish_stable( + changed_latest, "latest.json", project_root=self.project, owner=self.owner + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/offline-sign-release.sh b/scripts/offline-sign-release.sh new file mode 100755 index 0000000..a209226 --- /dev/null +++ b/scripts/offline-sign-release.sh @@ -0,0 +1,296 @@ +#!/bin/bash -p +# Offline phase. Run a separately installed, audited copy of this script on an +# air-gapped machine. Candidate files are data only; none are executed. +[[ $- == *p* ]] || { echo "execute offline-sign-release.sh directly; privileged Bash mode is required" >&2; exit 2; } +set -Eeuo pipefail +umask 077 +ulimit -c 0 || { echo "cannot disable core dumps for the trusted signing phase" >&2; exit 1; } +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +LC_ALL=C +export PATH LC_ALL +unset TAR_OPTIONS GZIP BZIP2 BZIP XZ_OPT +hash -r + +MAX_BINARY_BYTES=67108864 +MAX_METADATA_BYTES=1048576 +LOCAL_COMMAND_TIMEOUT_SECONDS=120 +SIGNER_TIMEOUT_SECONDS=300 + +for command_name in awk chmod cp dirname grep mkdir mktemp readlink rm sha256sum stat timeout wc; do + command -v "$command_name" >/dev/null 2>&1 \ + || { echo "required command not found: $command_name" >&2; exit 1; } +done +timeout -k 1 1 /bin/true \ + || { echo "timeout does not support the required kill-after option" >&2; exit 1; } + +local_with_timeout() { + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" "$@" +} + +require_trusted_tmp() { + local tmp_meta tmp_uid tmp_mode + [[ -d /tmp && ! -L /tmp ]] || { echo "/tmp must be a real directory" >&2; return 1; } + tmp_meta="$(local_with_timeout stat -Lc '%u %a' -- /tmp)" \ + || { echo "cannot inspect /tmp" >&2; return 1; } + read -r tmp_uid tmp_mode <<<"$tmp_meta" + [[ "$tmp_uid" == 0 && "$tmp_mode" =~ ^[0-7]{4}$ ]] \ + || { echo "/tmp must be owned by root and have a valid sticky mode" >&2; return 1; } + (( (8#$tmp_mode & 8#7000) == 8#1000 )) \ + || { echo "/tmp must have exactly the sticky special bit" >&2; return 1; } +} +require_trusted_tmp + +require_safe_directory_path() { + local path=$1 label=$2 allow_sticky_leaf=${3:-0} canonical meta type uid mode extra parent leaf=1 + canonical="$(local_with_timeout readlink -f -- "$path")" \ + || { echo "cannot resolve $label: $path" >&2; return 1; } + [[ "$canonical" == "$path" ]] \ + || { echo "$label must be canonical and contain no symlinked ancestor: $path" >&2; return 1; } + while :; do + meta="$(local_with_timeout stat -c '%F|%u|%a' -- "$path")" \ + || { echo "cannot inspect $label ancestor: $path" >&2; return 1; } + IFS='|' read -r type uid mode extra <<<"$meta" + [[ "$meta" == "$type|$uid|$mode" && "$type" == directory && "$uid" =~ ^[0-9]+$ \ + && "$mode" =~ ^[0-7]{3,4}$ && -z "$extra" ]] \ + || { echo "$label ancestor has invalid metadata: $path" >&2; return 1; } + if (( uid == 0 && (8#$mode & 8#7000) == 8#1000 )); then + (( leaf == 0 || allow_sticky_leaf == 1 )) \ + || { echo "$label leaf must not be a shared sticky directory: $path" >&2; return 1; } + elif (( (uid == 0 || uid == EUID) && (8#$mode & 8#7022) == 0 )); then + : + else + echo "$label ancestor is owned or writable by an untrusted account: $path" >&2 + return 1 + fi + [[ "$path" == / ]] && break + parent="$(dirname -- "$path")" + [[ "$parent" != "$path" ]] || { echo "cannot resolve $label ancestry" >&2; return 1; } + path=$parent + leaf=0 + done +} + +require_regular_file_path() { + local path=$1 label=$2 canonical type + canonical="$(local_with_timeout readlink -f -- "$path")" \ + || { echo "cannot resolve $label: $path" >&2; return 1; } + [[ "$canonical" == "$path" ]] \ + || { echo "$label must be canonical and contain no symlinked ancestor" >&2; return 1; } + type="$(local_with_timeout stat -c '%F' -- "$path")" \ + || { echo "cannot inspect $label: $path" >&2; return 1; } + [[ "$type" == "regular file" ]] \ + || { echo "$label must be a regular non-symlink file" >&2; return 1; } +} + +require_safe_file_path() { + local path=$1 label=$2 parent + require_regular_file_path "$path" "$label" + parent="$(dirname -- "$path")" + require_safe_directory_path "$parent" "$label parent" +} + +require_real_directory_path() { + local path=$1 label=$2 canonical type + canonical="$(local_with_timeout readlink -f -- "$path")" \ + || { echo "cannot resolve $label: $path" >&2; return 1; } + [[ "$canonical" == "$path" ]] \ + || { echo "$label must be canonical and contain no symlinked ancestor" >&2; return 1; } + type="$(local_with_timeout stat -c '%F' -- "$path")" \ + || { echo "cannot inspect $label: $path" >&2; return 1; } + [[ "$type" == directory ]] || { echo "$label must be a real directory" >&2; return 1; } +} + +require_safe_new_output_path() { + local path=$1 label=$2 canonical parent path_status + [[ "$path" == /* && "$path" != / && "$path" != */ ]] \ + || { echo "$label must be a new canonical absolute path other than /" >&2; return 1; } + canonical="$(local_with_timeout readlink -m -- "$path")" \ + || { echo "cannot resolve $label: $path" >&2; return 1; } + [[ "$canonical" == "$path" ]] \ + || { echo "$label must be a new canonical absolute path other than /" >&2; return 1; } + if local_with_timeout stat -c '%F' -- "$path" >/dev/null 2>&1; then + path_status=0 + else + path_status=$? + fi + [[ "$path_status" -eq 1 ]] \ + || { echo "$label already exists or could not be inspected: $path" >&2; return 1; } + parent="$(dirname -- "$path")" + require_safe_directory_path "$parent" "$label parent" 1 +} + +PREPARED_DIR="${1:?usage: offline-sign-release.sh /absolute/prepared-dir /absolute/signed-dir}" +SIGNED_DIR="${2:?usage: offline-sign-release.sh /absolute/prepared-dir /absolute/signed-dir}" +: "${LTA_SIGN_KEY:?set LTA_SIGN_KEY to the offline ed25519 private key}" +: "${LTA_TRUSTED_SIGNER:?set LTA_TRUSTED_SIGNER to the fixed audited lta-release binary}" +: "${LTA_TRUSTED_SIGNER_SHA256:?set the offline-recorded SHA-256 of LTA_TRUSTED_SIGNER}" +: "${LTA_EXPECTED_TAG:?set the independently recorded release tag}" +: "${LTA_EXPECTED_COMMIT:?set the independently recorded 40-hex release commit}" +: "${LTA_EXPECTED_PREPARED_MANIFEST_SHA256:?set the manifest hash printed by trusted preparation}" +: "${LTA_EXPECTED_RELEASE_SIGNER_PUBKEY:?set the independently recorded ed25519 public key for this release}" + +for online_var in GH_TOKEN GITHUB_TOKEN GH_ENTERPRISE_TOKEN GITHUB_ENTERPRISE_TOKEN \ + HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy \ + SSL_CERT_FILE SSL_CERT_DIR CURL_CA_BUNDLE REQUESTS_CA_BUNDLE NODE_EXTRA_CA_CERTS GH_CONFIG_DIR; do + [[ -z "${!online_var:-}" ]] || { echo "$online_var must not be present during offline signing" >&2; exit 1; } +done +[[ "$PREPARED_DIR" == /* ]] \ + || { echo "prepared input must be a real absolute directory" >&2; exit 1; } +[[ "$SIGNED_DIR" == /* && "$SIGNED_DIR" != / && "$SIGNED_DIR" != */ ]] \ + || { echo "signed output must be a new absolute directory other than /" >&2; exit 1; } +[[ "$LTA_TRUSTED_SIGNER" == /* ]] \ + || { echo "trusted signer must be an absolute regular non-symlink file" >&2; exit 1; } +[[ "$LTA_SIGN_KEY" == /* ]] \ + || { echo "offline private key must be an absolute regular non-symlink file" >&2; exit 1; } +[[ "$LTA_TRUSTED_SIGNER_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "invalid trusted signer SHA-256" >&2; exit 1; } +[[ "$LTA_EXPECTED_RELEASE_SIGNER_PUBKEY" =~ ^[0-9A-Fa-f]{64}$ ]] \ + || { echo "invalid expected release-signer public key" >&2; exit 1; } +LTA_EXPECTED_RELEASE_SIGNER_PUBKEY="${LTA_EXPECTED_RELEASE_SIGNER_PUBKEY,,}" +require_real_directory_path "$PREPARED_DIR" "prepared input" +require_safe_file_path "$LTA_TRUSTED_SIGNER" "trusted signer" +require_regular_file_path "$LTA_SIGN_KEY" "offline private key" +require_safe_new_output_path "$SIGNED_DIR" "signed output" + +prepared_files=(COMMIT TAG VERSION release_pubkey.hex linux-temp-admin-linux-amd64 linux-temp-admin-linux-arm64) +for name in "${prepared_files[@]}" PREPARED_SHA256SUMS; do + require_regular_file_path "$PREPARED_DIR/$name" "prepared file $name" \ + || { echo "missing regular prepared file: $name" >&2; exit 1; } +done + +# The removable input stays untrusted and mutable. Copy its allow-listed files +# once, then validate and sign only the private snapshot. +work="$(mktemp -d /tmp/lta-offline-sign.XXXXXX)" +snapshot="$work/prepared" +signed_work="$work/signed" +mkdir -m 0700 "$snapshot" "$signed_work" +out_created=0 +complete=0 +cleanup() { + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" rm -rf -- "$work" \ + || echo "warning: could not remove private signing workspace within timeout: $work" >&2 + if (( out_created == 1 && complete == 0 )); then + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" rm -rf -- "$SIGNED_DIR" \ + || echo "warning: could not remove incomplete signed output within timeout: $SIGNED_DIR" >&2 + fi +} +trap cleanup EXIT + +bounded_copy() { + local source=$1 destination=$2 max=$3 blocks size + blocks=$(( (max + 1023) / 1024 )) + if ! ( ulimit -f "$blocks"; timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" \ + cp --reflink=never --sparse=never -- "$source" "$destination" ); then + echo "input exceeds its snapshot limit or could not be copied: $source" >&2 + return 1 + fi + size="$(timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" stat -Lc '%s' -- "$destination")" \ + || { echo "copied file could not be measured: $destination" >&2; return 1; } + (( size <= max )) || { echo "input exceeds its snapshot limit: $source" >&2; return 1; } +} + +# Pin the audited executable by an open descriptor. Hashing and every later +# execution address this same inode even if its pathname is replaced. +exec {trusted_signer_fd}<"$LTA_TRUSTED_SIGNER" +trusted_signer="/proc/$$/fd/${trusted_signer_fd}" +[[ -f "$trusted_signer" && -x "$trusted_signer" ]] \ + || { echo "trusted signer descriptor is not an executable regular file" >&2; exit 1; } +read -r trusted_signer_uid trusted_signer_mode < <(local_with_timeout stat -Lc '%u %a' -- "$trusted_signer") +[[ "$trusted_signer_uid" == 0 || "$trusted_signer_uid" == "$EUID" ]] \ + || { echo "trusted signer is owned by an unexpected uid" >&2; exit 1; } +if [[ ! "$trusted_signer_mode" =~ ^[0-7]{3}$ ]] \ + || (( (8#$trusted_signer_mode & 8#022) != 0 )); then + echo "trusted signer has unsafe group/world-write or special mode bits" >&2 + exit 1 +fi +[[ "$(timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" sha256sum "$trusted_signer" | awk '{print $1}')" == "$LTA_TRUSTED_SIGNER_SHA256" ]] \ + || { echo "trusted signer hash mismatch" >&2; exit 1; } +signer_with_timeout() { + timeout -k 5 "$SIGNER_TIMEOUT_SECONDS" "$trusted_signer" "$@" +} +[[ "$(signer_with_timeout version)" == "lta-release-offline-v1" ]] \ + || { echo "unsupported trusted signer protocol" >&2; exit 1; } + +for name in "${prepared_files[@]}" PREPARED_SHA256SUMS; do + limit=$MAX_METADATA_BYTES + [[ "$name" != linux-temp-admin-linux-amd64 && "$name" != linux-temp-admin-linux-arm64 ]] \ + || limit=$MAX_BINARY_BYTES + bounded_copy "$PREPARED_DIR/$name" "$snapshot/$name" "$limit" +done + +[[ "$(awk 'NF {print $2}' "$snapshot/PREPARED_SHA256SUMS")" == $'COMMIT\nTAG\nVERSION\nrelease_pubkey.hex\nlinux-temp-admin-linux-amd64\nlinux-temp-admin-linux-arm64' ]] \ + || { echo "prepared manifest has unexpected entries" >&2; exit 1; } +( cd "$snapshot" && sha256sum -c --strict PREPARED_SHA256SUMS ) +for arch in amd64 arm64; do + asset="$snapshot/linux-temp-admin-linux-${arch}" + [[ -s "$asset" && "$(wc -c < "$asset")" -le "$MAX_BINARY_BYTES" ]] \ + || { echo "prepared ${arch} binary is empty or exceeds the 64 MiB client limit" >&2; exit 1; } +done +TAG="$(<"$snapshot/TAG")" +VERSION="$(<"$snapshot/VERSION")" +[[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z]+([.-][0-9A-Za-z]+)*))?$ \ + && "$VERSION" == "${TAG#v}" ]] \ + || { echo "invalid or inconsistent prepared tag/version" >&2; exit 1; } +major="${BASH_REMATCH[1]}" +(( ${#major} > 1 || 10#$major >= 2 )) || { echo "release tags below v2 are not supported" >&2; exit 1; } +[[ "$TAG" == "$LTA_EXPECTED_TAG" ]] || { echo "prepared tag differs from the independently recorded tag" >&2; exit 1; } +[[ "$(<"$snapshot/COMMIT")" == "$LTA_EXPECTED_COMMIT" && "$LTA_EXPECTED_COMMIT" =~ ^[0-9a-f]{40}$ ]] \ + || { echo "prepared commit differs from the independently recorded commit" >&2; exit 1; } +[[ "$LTA_EXPECTED_PREPARED_MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ \ + && "$(sha256sum "$snapshot/PREPARED_SHA256SUMS" | awk '{print $1}')" == "$LTA_EXPECTED_PREPARED_MANIFEST_SHA256" ]] \ + || { echo "prepared manifest hash differs from the independently recorded value" >&2; exit 1; } + +signing_pub="$(signer_with_timeout pubkey "$LTA_SIGN_KEY")" +[[ "$signing_pub" == "$LTA_EXPECTED_RELEASE_SIGNER_PUBKEY" ]] \ + || { echo "private key is not the independently selected release-signing key" >&2; exit 1; } +awk ' + /^[[:space:]]*(#|$)/ { next } + { gsub(/^[[:space:]]+|[[:space:]]+$/, ""); if (length($0) != 64 || $0 !~ /^[0-9A-Fa-f]+$/ || seen[tolower($0)]++) exit 1; count++ } + END { if (!count) exit 1 } +' "$snapshot/release_pubkey.hex" || { echo "prepared release keyring is malformed or duplicated" >&2; exit 1; } +awk '/^[[:space:]]*(#|$)/ {next} {gsub(/[[:space:]]/, ""); print tolower($0)}' "$snapshot/release_pubkey.hex" \ + | grep -Fqx "$signing_pub" \ + || { echo "private key public half is not present in the candidate keyring" >&2; exit 1; } + +for name in "${prepared_files[@]}" PREPARED_SHA256SUMS; do + cp -- "$snapshot/$name" "$signed_work/$name" +done +printf '%s\n' "$LTA_TRUSTED_SIGNER_SHA256" > "$signed_work/SIGNER_SHA256" +printf '%s\n' "$signing_pub" > "$signed_work/RELEASE_SIGNER_PUBKEY" + +for arch in amd64 arm64; do + signer_with_timeout sign "$LTA_SIGN_KEY" "$signed_work/linux-temp-admin-linux-${arch}" + signer_with_timeout verify "$signed_work/RELEASE_SIGNER_PUBKEY" \ + "$signed_work/linux-temp-admin-linux-${arch}" "$signed_work/linux-temp-admin-linux-${arch}.sig" +done +( cd "$signed_work" && sha256sum linux-temp-admin-linux-amd64 linux-temp-admin-linux-amd64.sig \ + linux-temp-admin-linux-arm64 linux-temp-admin-linux-arm64.sig > SHA256SUMS ) +( cd "$signed_work" && sha256sum COMMIT PREPARED_SHA256SUMS RELEASE_SIGNER_PUBKEY SHA256SUMS SIGNER_SHA256 TAG VERSION \ + release_pubkey.hex linux-temp-admin-linux-amd64 linux-temp-admin-linux-amd64.sig \ + linux-temp-admin-linux-arm64 linux-temp-admin-linux-arm64.sig > SIGNED_BUNDLE_SHA256SUMS ) +signed_manifest_sha256="$(sha256sum "$signed_work/SIGNED_BUNDLE_SHA256SUMS" | awk '{print $1}')" + +require_safe_new_output_path "$SIGNED_DIR" "signed output" +timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" mkdir -m 0700 -- "$SIGNED_DIR" +out_created=1 +require_safe_directory_path "$SIGNED_DIR" "signed output" +for name in COMMIT PREPARED_SHA256SUMS RELEASE_SIGNER_PUBKEY SHA256SUMS SIGNER_SHA256 TAG VERSION release_pubkey.hex \ + linux-temp-admin-linux-amd64 linux-temp-admin-linux-amd64.sig \ + linux-temp-admin-linux-arm64 linux-temp-admin-linux-arm64.sig SIGNED_BUNDLE_SHA256SUMS; do + limit=$MAX_METADATA_BYTES + [[ "$name" != linux-temp-admin-linux-amd64 && "$name" != linux-temp-admin-linux-arm64 ]] \ + || limit=$MAX_BINARY_BYTES + bounded_copy "$signed_work/$name" "$SIGNED_DIR/$name" "$limit" +done +for name in COMMIT PREPARED_SHA256SUMS RELEASE_SIGNER_PUBKEY SHA256SUMS SIGNER_SHA256 TAG VERSION release_pubkey.hex \ + linux-temp-admin-linux-amd64 linux-temp-admin-linux-amd64.sig \ + linux-temp-admin-linux-arm64 linux-temp-admin-linux-arm64.sig SIGNED_BUNDLE_SHA256SUMS; do + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" chmod 0600 "$SIGNED_DIR/$name" +done +[[ "$(timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" sha256sum "$SIGNED_DIR/SIGNED_BUNDLE_SHA256SUMS" | awk '{print $1}')" == "$signed_manifest_sha256" ]] \ + || { echo "signed output manifest changed during transfer" >&2; exit 1; } +timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" /bin/sh -c \ + 'cd -- "$1" && exec sha256sum -c --strict SIGNED_BUNDLE_SHA256SUMS' sh "$SIGNED_DIR" +complete=1 +echo "signed release data: $SIGNED_DIR" +echo "signed bundle manifest SHA-256: $signed_manifest_sha256" +echo "remove the private key/media, then transfer this directory to the online publisher" diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh new file mode 100755 index 0000000..7ee655a --- /dev/null +++ b/scripts/prepare-release.sh @@ -0,0 +1,435 @@ +#!/bin/bash -p +# Online, keyless phase: independently rebuild an immutable tag and byte-compare +# it with the CI draft before preparing data for the offline signer. +[[ $- == *p* ]] || { echo "execute prepare-release.sh directly; privileged Bash mode is required" >&2; exit 2; } +set -Eeuo pipefail +umask 077 +ulimit -c 0 || { echo "cannot disable core dumps for the trusted preparation phase" >&2; exit 1; } +PATH=/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +LC_ALL=C +while IFS= read -r inherited_name; do + [[ "$inherited_name" == GIT_* ]] && unset "$inherited_name" +done < <(compgen -A variable) +unset HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy +unset SSL_CERT_FILE SSL_CERT_DIR CURL_CA_BUNDLE REQUESTS_CA_BUNDLE NODE_EXTRA_CA_CERTS +unset GH_CONFIG_DIR XDG_CONFIG_HOME GIT_SSL_CAINFO GIT_SSL_CAPATH +unset TAR_OPTIONS GZIP BZIP2 BZIP XZ_OPT +GIT_NO_REPLACE_OBJECTS=1 +GIT_NO_LAZY_FETCH=1 +GIT_TERMINAL_PROMPT=0 +GIT_CONFIG_NOSYSTEM=1 +GIT_CONFIG_GLOBAL=/dev/null +GIT_CONFIG_SYSTEM=/dev/null +GIT_ASKPASS=/bin/false +SSH_ASKPASS=/bin/false +GIT_PAGER='cat' +GIT_OPTIONAL_LOCKS=0 +OPENSSL_CONF=/dev/null +GH_HOST=github.com +GH_PROMPT_DISABLED=1 +GH_PAGER='cat' +export PATH LC_ALL GIT_NO_REPLACE_OBJECTS GIT_NO_LAZY_FETCH GIT_TERMINAL_PROMPT \ + GIT_CONFIG_NOSYSTEM GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM GIT_ASKPASS SSH_ASKPASS \ + GIT_PAGER GIT_OPTIONAL_LOCKS OPENSSL_CONF GH_HOST GH_PROMPT_DISABLED GH_PAGER +unset OPENSSL_CONF_INCLUDE OPENSSL_MODULES OPENSSL_ENGINES +unset GPG_TTY +unset GOROOT GOEXPERIMENT GOFIPS140 GO111MODULE GOCACHE GOMODCACHE GOPATH GOTMPDIR +unset GOPROXY GOSUMDB GONOSUMDB GOPRIVATE GONOPROXY GOINSECURE GOVCS GOAUTH GOTELEMETRY +hash -r + +TAG="${1:?usage: prepare-release.sh vX.Y.Z /absolute/source/repo /absolute/prepared-dir}" +SOURCE_DIR="${2:?usage: prepare-release.sh vX.Y.Z /absolute/source/repo /absolute/prepared-dir}" +OUT_DIR="${3:?usage: prepare-release.sh vX.Y.Z /absolute/source/repo /absolute/prepared-dir}" +REPO="xxvcc/linux-temp-admin" +GO_VERSION="go1.26.5" +MAX_BINARY_BYTES=67108864 +MAX_METADATA_BYTES=1048576 +MAX_SOURCE_ARCHIVE_BYTES=134217728 +LOCAL_COMMAND_TIMEOUT_SECONDS=120 +GO_BUILD_TIMEOUT_SECONDS=900 +: "${LTA_EXPECTED_TAG_SIGNER_FINGERPRINT:?set the independently recorded OpenPGP tag-signer fingerprint}" + +local_with_timeout() { + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" "$@" +} + +require_trusted_tmp() { + local tmp_meta tmp_uid tmp_mode + [[ -d /tmp && ! -L /tmp ]] || { echo "/tmp must be a real directory" >&2; return 1; } + tmp_meta="$(local_with_timeout stat -Lc '%u %a' -- /tmp)" \ + || { echo "cannot inspect /tmp" >&2; return 1; } + read -r tmp_uid tmp_mode <<<"$tmp_meta" + [[ "$tmp_uid" == 0 && "$tmp_mode" =~ ^[0-7]{4}$ ]] \ + || { echo "/tmp must be owned by root and have a valid sticky mode" >&2; return 1; } + (( (8#$tmp_mode & 8#7000) == 8#1000 )) \ + || { echo "/tmp must have exactly the sticky special bit" >&2; return 1; } +} + +require_safe_directory_path() { + local path=$1 label=$2 allow_sticky_leaf=${3:-0} canonical meta type uid mode extra parent leaf=1 + canonical="$(local_with_timeout readlink -f -- "$path")" \ + || { echo "cannot resolve $label: $path" >&2; return 1; } + [[ "$canonical" == "$path" ]] \ + || { echo "$label must be canonical and contain no symlinked ancestor: $path" >&2; return 1; } + while :; do + meta="$(local_with_timeout stat -c '%F|%u|%a' -- "$path")" \ + || { echo "cannot inspect $label ancestor: $path" >&2; return 1; } + IFS='|' read -r type uid mode extra <<<"$meta" + [[ "$meta" == "$type|$uid|$mode" && "$type" == directory && "$uid" =~ ^[0-9]+$ \ + && "$mode" =~ ^[0-7]{3,4}$ && -z "$extra" ]] \ + || { echo "$label ancestor has invalid metadata: $path" >&2; return 1; } + if (( uid == 0 && (8#$mode & 8#7000) == 8#1000 )); then + (( leaf == 0 || allow_sticky_leaf == 1 )) \ + || { echo "$label leaf must not be a shared sticky directory: $path" >&2; return 1; } + elif (( (uid == 0 || uid == EUID) && (8#$mode & 8#7022) == 0 )); then + : + else + echo "$label ancestor is owned or writable by an untrusted account: $path" >&2 + return 1 + fi + [[ "$path" == / ]] && break + parent="$(dirname -- "$path")" + [[ "$parent" != "$path" ]] || { echo "cannot resolve $label ancestry" >&2; return 1; } + path=$parent + leaf=0 + done +} + +require_safe_source_repo() { + require_safe_directory_path "$SOURCE_DIR" "source repo" + require_safe_directory_path "$SOURCE_DIR/.git" "source Git directory" + local external_git_store + for external_git_store in "$SOURCE_DIR/.git/commondir" \ + "$SOURCE_DIR/.git/objects/info/alternates" "$SOURCE_DIR/.git/objects/info/http-alternates"; do + [[ ! -e "$external_git_store" && ! -L "$external_git_store" ]] \ + || { echo "source repo uses an external Git object or metadata store: $external_git_store" >&2; return 1; } + done +} + +require_safe_new_output_path() { + local path=$1 label=$2 canonical parent path_status + [[ "$path" == /* && "$path" != / && "$path" != */ ]] \ + || { echo "$label must be a new canonical absolute path other than /" >&2; return 1; } + canonical="$(local_with_timeout readlink -m -- "$path")" \ + || { echo "cannot resolve $label: $path" >&2; return 1; } + [[ "$canonical" == "$path" ]] \ + || { echo "$label must be a new canonical absolute path other than /" >&2; return 1; } + if local_with_timeout stat -c '%F' -- "$path" >/dev/null 2>&1; then + path_status=0 + else + path_status=$? + fi + [[ "$path_status" -eq 1 ]] \ + || { echo "$label already exists or could not be inspected: $path" >&2; return 1; } + parent="$(dirname -- "$path")" + require_safe_directory_path "$parent" "$label parent" 1 +} + +[[ -z "${LTA_SIGN_KEY:-}" ]] || { echo "LTA_SIGN_KEY must not be present on the online preparation machine" >&2; exit 1; } +[[ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]] \ + || { echo "set GH_TOKEN to a short-lived github.com release token" >&2; exit 1; } +GH_TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" +export GH_TOKEN +[[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z]+([.-][0-9A-Za-z]+)*))?$ ]] \ + || { echo "tag must be vX.Y.Z or vX.Y.Z-prerelease" >&2; exit 1; } +major="${BASH_REMATCH[1]}" +(( ${#major} > 1 || 10#$major >= 2 )) || { echo "release tags below v2 are not supported" >&2; exit 1; } +VERSION="${TAG#v}" +[[ "$SOURCE_DIR" == /* ]] || { echo "source repo must be an absolute directory" >&2; exit 1; } +[[ "$OUT_DIR" == /* && "$OUT_DIR" != / && "$OUT_DIR" != */ ]] \ + || { echo "prepared output must be a new absolute directory other than /" >&2; exit 1; } +[[ "$LTA_EXPECTED_TAG_SIGNER_FINGERPRINT" =~ ^([0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$ ]] \ + || { echo "invalid expected OpenPGP tag-signer fingerprint" >&2; exit 1; } +LTA_EXPECTED_TAG_SIGNER_FINGERPRINT="${LTA_EXPECTED_TAG_SIGNER_FINGERPRINT,,}" + +decimal_gt() { + local left=$1 right=$2 + (( ${#left} > ${#right} )) && return 0 + (( ${#left} < ${#right} )) && return 1 + [[ "$left" > "$right" ]] +} + +stable_tag_gt() { + local newer=$1 older=$2 nmajor nminor npatch omajor ominor opatch + [[ "$newer" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || return 1 + nmajor=${BASH_REMATCH[1]}; nminor=${BASH_REMATCH[2]}; npatch=${BASH_REMATCH[3]} + [[ "$older" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || return 1 + omajor=${BASH_REMATCH[1]}; ominor=${BASH_REMATCH[2]}; opatch=${BASH_REMATCH[3]} + for pair in "$nmajor:$omajor" "$nminor:$ominor" "$npatch:$opatch"; do + local left=${pair%%:*} right=${pair#*:} + decimal_gt "$left" "$right" && return 0 + decimal_gt "$right" "$left" && return 1 + done + return 1 +} + +for command_name in awk chmod cmp cp dirname gh git go grep mkdir mktemp readlink rm sha256sum sort stat tar timeout wc; do + command -v "$command_name" >/dev/null 2>&1 || { echo "required command not found: $command_name" >&2; exit 1; } +done +timeout -k 1 1 /bin/true \ + || { echo "timeout does not support the required kill-after option" >&2; exit 1; } +[[ -x /usr/bin/gpg ]] || { echo "required trusted command not found: /usr/bin/gpg" >&2; exit 1; } +require_trusted_tmp +require_safe_source_repo +require_safe_new_output_path "$OUT_DIR" "prepared output" + +gh_with_timeout() { + timeout -k 5 300 gh "$@" +} +git_with_timeout() { + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" git \ + -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.attributesFile=/dev/null \ + -c core.pager=cat -c pager.branch=false -c pager.tag=false "$@" +} +go_version_output="$(timeout -k 5 30 env GOENV=off GOTOOLCHAIN=local GOFLAGS= GOWORK=off go version)" \ + || { echo "could not execute the trusted Go toolchain" >&2; exit 1; } +[[ "$(awk '{print $3}' <<<"$go_version_output")" == "$GO_VERSION" ]] \ + || { echo "release rebuild requires exactly $GO_VERSION" >&2; exit 1; } + +work="$(mktemp -d /tmp/lta-prepare-release.XXXXXX)" +mkdir -m 0700 "$work/go-cache" "$work/lta-module-cache" "$work/go-path" "$work/go-tmp" "$work/gh-config" +GH_CONFIG_DIR="$work/gh-config" +export GH_CONFIG_DIR +gpg_wrapper="$work/gpg-batch" +printf '%s\n' '#!/bin/sh' 'exec /usr/bin/gpg --batch --no-auto-key-retrieve "$@"' > "$gpg_wrapper" +chmod 0700 "$gpg_wrapper" +out_created=0 +complete=0 +cleanup() { + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" rm -rf -- "$work" \ + || echo "warning: could not remove private preparation workspace within timeout: $work" >&2 + if (( out_created == 1 && complete == 0 )); then + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" rm -rf -- "$OUT_DIR" \ + || echo "warning: could not remove incomplete prepared output within timeout: $OUT_DIR" >&2 + fi +} +trap cleanup EXIT + +current_latest_tag() { + local latest response_file api_status status_count not_found_count + response_file="$work/latest-api-response" + if latest="$(gh_with_timeout release view --repo "$REPO" --json tagName --jq '.tagName' 2>"$work/latest-view-error")"; then + [[ "$latest" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] \ + || { echo "Latest has a non-canonical stable tag: $latest" >&2; return 1; } + printf '%s\n' "$latest" + return 0 + fi + if gh_with_timeout api --include "repos/${REPO}/releases/latest" >"$response_file" 2>&1; then + api_status=0 + else + api_status=$? + fi + if [[ "$api_status" -eq 0 ]]; then + echo "Latest lookup was inconsistent: release view failed but the REST route succeeded" >&2 + return 1 + fi + if [[ "$api_status" -ne 1 ]]; then + cat "$work/latest-view-error" "$response_file" >&2 + echo "Latest REST lookup failed with unexpected status $api_status" >&2 + return 1 + fi + status_count="$(grep -Ec '^HTTP/[0-9.]+ [0-9]{3}([[:space:]]|$)' "$response_file" || true)" + not_found_count="$(grep -Ec '^HTTP/[0-9.]+ 404([[:space:]]|$)' "$response_file" || true)" + if [[ "$status_count" -eq 1 && "$not_found_count" -eq 1 ]]; then + printf '\n' + return 0 + fi + cat "$work/latest-view-error" "$response_file" >&2 + echo "could not determine the exact Latest release" >&2 + return 1 +} + +bounded_copy() { + local source=$1 destination=$2 max=$3 blocks size + blocks=$(( (max + 1023) / 1024 )) + if ! ( ulimit -f "$blocks"; timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" \ + cp --reflink=never --sparse=never -- "$source" "$destination" ); then + echo "file exceeds its transfer limit or could not be copied: $source" >&2 + return 1 + fi + size="$(timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" stat -Lc '%s' -- "$destination")" \ + || { echo "copied file could not be measured: $destination" >&2; return 1; } + (( size <= max )) || { echo "file exceeds its transfer limit: $source" >&2; return 1; } +} + +echo ">> [prepare 1/6] authenticate tag, main ancestry, workflow, and draft" +if ! tag_object="$(git_with_timeout -C "$SOURCE_DIR" rev-parse --verify "refs/tags/${TAG}^{tag}")"; then + echo "$TAG must resolve to an annotated tag object" >&2 + exit 1 +fi +tag_commit="$(git_with_timeout -C "$SOURCE_DIR" rev-parse --verify "${tag_object}^{commit}")" +embedded_tag="$(git_with_timeout -C "$SOURCE_DIR" cat-file tag "$tag_object" | awk ' + /^$/ { headers=0 } + headers != 0 && /^tag / { if (found++) exit 2; sub(/^tag /, ""); value=$0 } + NR == 1 { headers=1 } + END { if (found != 1) exit 1; print value } +')" +[[ "$embedded_tag" == "$TAG" ]] \ + || { echo "annotated tag object names $embedded_tag, not $TAG" >&2; exit 1; } +git_with_timeout -C "$SOURCE_DIR" ls-tree -r -z "$tag_commit" > "$work/tag-tree" +while IFS= read -r -d '' tree_entry; do + tree_mode=${tree_entry%% *} + [[ "$tree_mode" != 120000 && "$tree_mode" != 160000 ]] \ + || { echo "$TAG contains a symlink or submodule; release source must be self-contained" >&2; exit 1; } +done < "$work/tag-tree" +if ! tag_status="$(git_with_timeout -c gpg.format=openpgp -c gpg.program="$gpg_wrapper" \ + -c gpg.openpgp.program="$gpg_wrapper" \ + -C "$SOURCE_DIR" verify-tag --raw "$tag_object" 2>&1)"; then + printf '%s\n' "$tag_status" >&2 + echo "$TAG does not have a valid OpenPGP signature" >&2 + exit 1 +fi +printf '%s\n' "$tag_status" | awk -v expected="$LTA_EXPECTED_TAG_SIGNER_FINGERPRINT" ' + $1 == "[GNUPG:]" && $2 == "VALIDSIG" && (tolower($3) == expected || tolower($NF) == expected) { matched++ } + END { exit(matched == 1 ? 0 : 1) } +' || { echo "$TAG was not signed by the independently pinned OpenPGP key" >&2; exit 1; } +remote_tag="$(gh_with_timeout api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.sha')" +[[ "$tag_object" == "$remote_tag" ]] \ + || { echo "local and GitHub tag objects differ" >&2; exit 1; } +ancestry="$(gh_with_timeout api "repos/${REPO}/compare/${tag_commit}...main" --jq '.status')" +[[ "$ancestry" == identical || "$ancestry" == ahead ]] \ + || { echo "$TAG is not contained in GitHub main" >&2; exit 1; } +[[ "$(gh_with_timeout release view "$TAG" --repo "$REPO" --json isDraft,tagName --jq '. | (.isDraft|tostring) + " " + .tagName')" == "true $TAG" ]] \ + || { echo "GitHub release is missing, published, or points at another tag" >&2; exit 1; } +draft_assets="$(gh_with_timeout release view "$TAG" --repo "$REPO" --json assets \ + --jq '.assets[] | [.name, (.size|tostring), .apiUrl] | @tsv' | LC_ALL=C sort)" +[[ "$(awk -F $'\t' '{print $1}' <<<"$draft_assets")" == $'SHA256SUMS\nlinux-temp-admin-linux-amd64\nlinux-temp-admin-linux-arm64' ]] \ + || { echo "CI draft contains missing, signed, or unexpected assets" >&2; exit 1; } +if [[ "$TAG" != *-* ]]; then + latest_tag="$(current_latest_tag)" + if [[ -n "$latest_tag" ]]; then + stable_tag_gt "$TAG" "$latest_tag" \ + || { echo "stable release $TAG must be newer than current Latest $latest_tag" >&2; exit 1; } + else + published_stable_tags="$(gh_with_timeout api --paginate "repos/${REPO}/releases?per_page=100" \ + --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name')" + [[ -z "$published_stable_tags" ]] \ + || { echo "published stable releases exist but GitHub has no exact Latest release" >&2; exit 1; } + fi +fi +successful_sha="$(gh_with_timeout run list --repo "$REPO" --workflow release.yml --branch "$TAG" --event push --limit 100 \ + --json conclusion,headSha,headBranch \ + --jq "first(.[] | select(.conclusion == \"success\" and .headSha == \"$tag_commit\" and .headBranch == \"$TAG\")) | .headSha // \"\"")" +[[ "$successful_sha" == "$tag_commit" ]] || { echo "no successful Release workflow for $TAG at $tag_commit" >&2; exit 1; } + +echo ">> [prepare 2/6] download and strictly validate CI draft assets" +mkdir "$work/ci" +download_draft_asset() { + local name=$1 max=$2 record advertised_size api_url blocks actual_size + record="$(awk -F $'\t' -v wanted="$name" '$1 == wanted { print $2 "\t" $3 }' <<<"$draft_assets")" + IFS=$'\t' read -r advertised_size api_url <<<"$record" + [[ "$advertised_size" =~ ^[0-9]+$ && "$advertised_size" -gt 0 && "$advertised_size" -le "$max" ]] \ + || { echo "invalid or oversized advertised draft asset: $name" >&2; return 1; } + [[ "$api_url" == "https://api.github.com/repos/${REPO}/releases/assets/"* ]] \ + || { echo "unexpected GitHub asset API URL for $name" >&2; return 1; } + blocks=$(( (max + 1023) / 1024 )) + ( ulimit -f "$blocks"; gh_with_timeout api -H 'Accept: application/octet-stream' "$api_url" > "$work/ci/$name" ) \ + || { echo "bounded draft download failed: $name" >&2; return 1; } + actual_size="$(wc -c < "$work/ci/$name")" + [[ "$actual_size" -eq "$advertised_size" && "$actual_size" -le "$max" ]] \ + || { echo "draft asset size changed during download: $name" >&2; return 1; } +} +download_draft_asset linux-temp-admin-linux-amd64 "$MAX_BINARY_BYTES" +download_draft_asset linux-temp-admin-linux-arm64 "$MAX_BINARY_BYTES" +download_draft_asset SHA256SUMS "$MAX_METADATA_BYTES" +for name in linux-temp-admin-linux-amd64 linux-temp-admin-linux-arm64 SHA256SUMS; do + [[ -f "$work/ci/$name" && ! -L "$work/ci/$name" ]] || { echo "missing regular CI asset: $name" >&2; exit 1; } +done +[[ "$(awk 'NF {print $2}' "$work/ci/SHA256SUMS")" == $'linux-temp-admin-linux-amd64\nlinux-temp-admin-linux-arm64' ]] \ + || { echo "CI SHA256SUMS must name exactly amd64 and arm64, in order" >&2; exit 1; } +( cd "$work/ci" && sha256sum -c --strict SHA256SUMS ) +for arch in amd64 arm64; do + asset="$work/ci/linux-temp-admin-linux-${arch}" + [[ -s "$asset" && "$(wc -c < "$asset")" -le "$MAX_BINARY_BYTES" ]] \ + || { echo "CI ${arch} binary is empty or exceeds the 64 MiB client limit" >&2; exit 1; } +done + +echo ">> [prepare 3/6] export committed source only (no candidate scripts executed)" +mkdir "$work/source" +source_archive="$work/source.tar" +source_blocks=$(( (MAX_SOURCE_ARCHIVE_BYTES + 1023) / 1024 )) +( ulimit -f "$source_blocks"; git_with_timeout -C "$SOURCE_DIR" archive --format=tar "$tag_commit" > "$source_archive" ) \ + || { echo "tag source archive is oversized or could not be exported" >&2; exit 1; } +[[ -s "$source_archive" && "$(wc -c < "$source_archive")" -le "$MAX_SOURCE_ARCHIVE_BYTES" ]] \ + || { echo "tag source archive is empty or oversized" >&2; exit 1; } +timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" tar --extract --file="$source_archive" \ + --directory="$work/source" --no-same-owner --no-same-permissions +while IFS= read -r -d '' tree_entry; do + tree_header=${tree_entry%%$'\t'*} + tree_path=${tree_entry#*$'\t'} + read -r tree_mode tree_type tree_object <<<"$tree_header" + [[ "$tree_type" == blob && -f "$work/source/$tree_path" && ! -L "$work/source/$tree_path" ]] \ + || { echo "exported source is missing a regular tagged file: $tree_path" >&2; exit 1; } + extracted_object="$(git_with_timeout -C "$SOURCE_DIR" hash-object --no-filters -- "$work/source/$tree_path")" + [[ "$extracted_object" == "$tree_object" ]] \ + || { echo "exported source differs from tagged Git object: $tree_path" >&2; exit 1; } +done < "$work/tag-tree" + +echo ">> [prepare 4/6] reproducibly rebuild with fixed toolchain and flags" +mkdir "$work/local" +for arch in amd64 arm64; do + case "$arch" in + amd64) arch_tune="GOAMD64=v1" ;; + arm64) arch_tune="GOARM64=v8.0" ;; + esac + # shellcheck disable=SC2086 # arch_tune is one deliberate NAME=value assignment. + ( cd "$work/source" && timeout -k 30 "$GO_BUILD_TIMEOUT_SECONDS" \ + env GOENV=off GOTOOLCHAIN=local GOFLAGS= GOWORK=off GO111MODULE=on \ + GOEXPERIMENT= GOFIPS140=off GOTELEMETRY=off GOAUTH=off GOVCS='*:off' \ + GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org GONOSUMDB= GOPRIVATE= GONOPROXY= GOINSECURE= \ + GOCACHE="$work/go-cache" GOMODCACHE="$work/lta-module-cache" GOPATH="$work/go-path" GOTMPDIR="$work/go-tmp" \ + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" $arch_tune \ + go build -mod=readonly -buildvcs=false -trimpath -tags osusergo,netgo \ + -ldflags "-s -w -X github.com/xxvcc/linux-temp-admin/internal/buildinfo.Version=${VERSION}" \ + -o "$work/local/linux-temp-admin-linux-${arch}" ./cmd/linux-temp-admin ) +done + +echo ">> [prepare 5/6] compare every CI byte with the independent rebuild" +for arch in amd64 arm64; do + cmp "$work/ci/linux-temp-admin-linux-${arch}" "$work/local/linux-temp-admin-linux-${arch}" \ + || { echo "CI ${arch} binary is not reproducible from $TAG" >&2; exit 1; } +done + +keyring="$work/source/internal/selfmanage/release_pubkey.hex" +awk ' + /^[[:space:]]*(#|$)/ { next } + { gsub(/^[[:space:]]+|[[:space:]]+$/, ""); if (length($0) != 64 || $0 !~ /^[0-9A-Fa-f]+$/ || seen[tolower($0)]++) exit 1; count++ } + END { if (!count) exit 1 } +' "$keyring" || { echo "candidate release keyring is malformed or duplicated" >&2; exit 1; } + +echo ">> [prepare 6/6] create non-executable transfer directory" +prepared_work="$work/prepared-output" +mkdir -m 0700 "$prepared_work" +bounded_copy "$work/ci/linux-temp-admin-linux-amd64" "$prepared_work/linux-temp-admin-linux-amd64" "$MAX_BINARY_BYTES" +bounded_copy "$work/ci/linux-temp-admin-linux-arm64" "$prepared_work/linux-temp-admin-linux-arm64" "$MAX_BINARY_BYTES" +bounded_copy "$keyring" "$prepared_work/release_pubkey.hex" "$MAX_METADATA_BYTES" +printf '%s\n' "$TAG" > "$prepared_work/TAG" +printf '%s\n' "$VERSION" > "$prepared_work/VERSION" +printf '%s\n' "$tag_commit" > "$prepared_work/COMMIT" +( cd "$prepared_work" && sha256sum COMMIT TAG VERSION release_pubkey.hex \ + linux-temp-admin-linux-amd64 linux-temp-admin-linux-arm64 > PREPARED_SHA256SUMS ) +chmod 0600 "$prepared_work"/* +prepared_manifest_sha256="$(sha256sum "$prepared_work/PREPARED_SHA256SUMS" | awk '{print $1}')" + +require_safe_new_output_path "$OUT_DIR" "prepared output" +timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" mkdir -m 0700 -- "$OUT_DIR" +out_created=1 +require_safe_directory_path "$OUT_DIR" "prepared output" +prepared_output_files=(COMMIT TAG VERSION release_pubkey.hex linux-temp-admin-linux-amd64 linux-temp-admin-linux-arm64 PREPARED_SHA256SUMS) +for name in "${prepared_output_files[@]}"; do + limit=$MAX_METADATA_BYTES + [[ "$name" != linux-temp-admin-linux-amd64 && "$name" != linux-temp-admin-linux-arm64 ]] \ + || limit=$MAX_BINARY_BYTES + bounded_copy "$prepared_work/$name" "$OUT_DIR/$name" "$limit" + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" chmod 0600 "$OUT_DIR/$name" +done +[[ "$(timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" sha256sum "$OUT_DIR/PREPARED_SHA256SUMS" | awk '{print $1}')" == "$prepared_manifest_sha256" ]] \ + || { echo "prepared output manifest changed during transfer" >&2; exit 1; } +timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" /bin/sh -c \ + 'cd -- "$1" && exec sha256sum -c --strict PREPARED_SHA256SUMS' sh "$OUT_DIR" +complete=1 +echo "prepared release data: $OUT_DIR" +echo "release tag: $TAG" +echo "release commit: $tag_commit" +echo "prepared manifest SHA-256: $prepared_manifest_sha256" +echo "transfer this directory to the offline signing machine as data only" diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh new file mode 100755 index 0000000..a9f419d --- /dev/null +++ b/scripts/publish-release.sh @@ -0,0 +1,688 @@ +#!/bin/bash -p +# Online, keyless phase: verify a signed bundle, replace the still-draft assets, +# publish, then independently download and verify public versioned/Latest bytes. +[[ $- == *p* ]] || { echo "execute publish-release.sh directly; privileged Bash mode is required" >&2; exit 2; } +set -Eeuo pipefail +umask 077 +ulimit -c 0 || { echo "cannot disable core dumps for the trusted publication phase" >&2; exit 1; } +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +LC_ALL=C +while IFS= read -r inherited_name; do + [[ "$inherited_name" == GIT_* ]] && unset "$inherited_name" +done < <(compgen -A variable) +unset HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy +unset SSL_CERT_FILE SSL_CERT_DIR CURL_CA_BUNDLE REQUESTS_CA_BUNDLE NODE_EXTRA_CA_CERTS +unset GH_CONFIG_DIR XDG_CONFIG_HOME GIT_SSL_CAINFO GIT_SSL_CAPATH +unset TAR_OPTIONS GZIP BZIP2 BZIP XZ_OPT +GIT_NO_REPLACE_OBJECTS=1 +GIT_NO_LAZY_FETCH=1 +GIT_TERMINAL_PROMPT=0 +GIT_CONFIG_NOSYSTEM=1 +GIT_CONFIG_GLOBAL=/dev/null +GIT_CONFIG_SYSTEM=/dev/null +GIT_ASKPASS=/bin/false +SSH_ASKPASS=/bin/false +GIT_PAGER='cat' +GIT_OPTIONAL_LOCKS=0 +OPENSSL_CONF=/dev/null +GH_HOST=github.com +GH_PROMPT_DISABLED=1 +GH_PAGER='cat' +export PATH LC_ALL GIT_NO_REPLACE_OBJECTS GIT_NO_LAZY_FETCH GIT_TERMINAL_PROMPT \ + GIT_CONFIG_NOSYSTEM GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM GIT_ASKPASS SSH_ASKPASS \ + GIT_PAGER GIT_OPTIONAL_LOCKS OPENSSL_CONF GH_HOST GH_PROMPT_DISABLED GH_PAGER +unset OPENSSL_CONF_INCLUDE OPENSSL_MODULES OPENSSL_ENGINES +unset GPG_TTY +hash -r + +MAX_BINARY_BYTES=67108864 +MAX_METADATA_BYTES=1048576 +LOCAL_COMMAND_TIMEOUT_SECONDS=120 +SIGNER_TIMEOUT_SECONDS=300 + +SIGNED_DIR="${1:?usage: publish-release.sh /absolute/signed-dir /absolute/source/repo}" +SOURCE_DIR="${2:?usage: publish-release.sh /absolute/signed-dir /absolute/source/repo}" +REPO="xxvcc/linux-temp-admin" +: "${LTA_TRUSTED_SIGNER:?set LTA_TRUSTED_SIGNER to the fixed audited lta-release verifier}" +: "${LTA_TRUSTED_SIGNER_SHA256:?set its independently recorded SHA-256}" +: "${LTA_EXPECTED_SIGNED_BUNDLE_MANIFEST_SHA256:?set the independently recorded signed-bundle manifest SHA-256}" +: "${LTA_EXPECTED_TAG_SIGNER_FINGERPRINT:?set the independently recorded OpenPGP tag-signer fingerprint}" +: "${LTA_EXPECTED_RELEASE_SIGNER_PUBKEY:?set the independently recorded ed25519 public key used for this release}" + +[[ -z "${LTA_SIGN_KEY:-}" ]] || { echo "LTA_SIGN_KEY must not be present on the online publishing machine" >&2; exit 1; } +[[ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]] \ + || { echo "set GH_TOKEN to a short-lived github.com release token" >&2; exit 1; } +GH_TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" +export GH_TOKEN +[[ "$SIGNED_DIR" == /* ]] || { echo "signed bundle must be a real absolute directory" >&2; exit 1; } +[[ "$SOURCE_DIR" == /* ]] || { echo "source repo must be an absolute directory" >&2; exit 1; } +[[ "$LTA_TRUSTED_SIGNER" == /* ]] \ + || { echo "trusted verifier must be an absolute regular non-symlink file" >&2; exit 1; } +[[ "$LTA_TRUSTED_SIGNER_SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "invalid trusted verifier SHA-256" >&2; exit 1; } +[[ "$LTA_EXPECTED_SIGNED_BUNDLE_MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ ]] \ + || { echo "invalid expected signed-bundle manifest SHA-256" >&2; exit 1; } +[[ "$LTA_EXPECTED_TAG_SIGNER_FINGERPRINT" =~ ^([0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})$ ]] \ + || { echo "invalid expected OpenPGP tag-signer fingerprint" >&2; exit 1; } +[[ "$LTA_EXPECTED_RELEASE_SIGNER_PUBKEY" =~ ^[0-9A-Fa-f]{64}$ ]] \ + || { echo "invalid expected release-signer public key" >&2; exit 1; } +LTA_EXPECTED_TAG_SIGNER_FINGERPRINT="${LTA_EXPECTED_TAG_SIGNER_FINGERPRINT,,}" +LTA_EXPECTED_RELEASE_SIGNER_PUBKEY="${LTA_EXPECTED_RELEASE_SIGNER_PUBKEY,,}" + +for command_name in awk cat cmp cp curl diff dirname gh git grep mkdir mktemp readlink rm sha256sum sleep sort stat timeout wc; do + command -v "$command_name" >/dev/null 2>&1 \ + || { echo "required command not found: $command_name" >&2; exit 1; } +done +curl -q --proto '=https' --proto-redir '=https' --connect-timeout 1 --max-time 1 --version >/dev/null 2>&1 \ + || { echo "curl does not support the required HTTPS and timeout options" >&2; exit 1; } +timeout -k 1 1 /bin/true \ + || { echo "timeout does not support the required kill-after option" >&2; exit 1; } +sleep 0 || { echo "sleep command is not usable" >&2; exit 1; } +[[ -x /usr/bin/gpg ]] || { echo "required trusted command not found: /usr/bin/gpg" >&2; exit 1; } + +local_with_timeout() { + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" "$@" +} + +require_trusted_tmp() { + local tmp_meta tmp_uid tmp_mode + [[ -d /tmp && ! -L /tmp ]] || { echo "/tmp must be a real directory" >&2; return 1; } + tmp_meta="$(local_with_timeout stat -Lc '%u %a' -- /tmp)" \ + || { echo "cannot inspect /tmp" >&2; return 1; } + read -r tmp_uid tmp_mode <<<"$tmp_meta" + [[ "$tmp_uid" == 0 && "$tmp_mode" =~ ^[0-7]{4}$ ]] \ + || { echo "/tmp must be owned by root and have a valid sticky mode" >&2; return 1; } + (( (8#$tmp_mode & 8#7000) == 8#1000 )) \ + || { echo "/tmp must have exactly the sticky special bit" >&2; return 1; } +} +require_trusted_tmp + +require_safe_directory_path() { + local path=$1 label=$2 allow_sticky_leaf=${3:-0} canonical meta type uid mode extra parent leaf=1 + canonical="$(local_with_timeout readlink -f -- "$path")" \ + || { echo "cannot resolve $label: $path" >&2; return 1; } + [[ "$canonical" == "$path" ]] \ + || { echo "$label must be canonical and contain no symlinked ancestor: $path" >&2; return 1; } + while :; do + meta="$(local_with_timeout stat -c '%F|%u|%a' -- "$path")" \ + || { echo "cannot inspect $label ancestor: $path" >&2; return 1; } + IFS='|' read -r type uid mode extra <<<"$meta" + [[ "$meta" == "$type|$uid|$mode" && "$type" == directory && "$uid" =~ ^[0-9]+$ \ + && "$mode" =~ ^[0-7]{3,4}$ && -z "$extra" ]] \ + || { echo "$label ancestor has invalid metadata: $path" >&2; return 1; } + if (( uid == 0 && (8#$mode & 8#7000) == 8#1000 )); then + (( leaf == 0 || allow_sticky_leaf == 1 )) \ + || { echo "$label leaf must not be a shared sticky directory: $path" >&2; return 1; } + elif (( (uid == 0 || uid == EUID) && (8#$mode & 8#7022) == 0 )); then + : + else + echo "$label ancestor is owned or writable by an untrusted account: $path" >&2 + return 1 + fi + [[ "$path" == / ]] && break + parent="$(dirname -- "$path")" + [[ "$parent" != "$path" ]] || { echo "cannot resolve $label ancestry" >&2; return 1; } + path=$parent + leaf=0 + done +} + +require_regular_file_path() { + local path=$1 label=$2 canonical type + canonical="$(local_with_timeout readlink -f -- "$path")" \ + || { echo "cannot resolve $label: $path" >&2; return 1; } + [[ "$canonical" == "$path" ]] \ + || { echo "$label must be canonical and contain no symlinked ancestor" >&2; return 1; } + type="$(local_with_timeout stat -c '%F' -- "$path")" \ + || { echo "cannot inspect $label: $path" >&2; return 1; } + [[ "$type" == "regular file" ]] \ + || { echo "$label must be a regular non-symlink file" >&2; return 1; } +} + +require_safe_file_path() { + local path=$1 label=$2 parent + require_regular_file_path "$path" "$label" + parent="$(dirname -- "$path")" + require_safe_directory_path "$parent" "$label parent" +} + +require_real_directory_path() { + local path=$1 label=$2 canonical type + canonical="$(local_with_timeout readlink -f -- "$path")" \ + || { echo "cannot resolve $label: $path" >&2; return 1; } + [[ "$canonical" == "$path" ]] \ + || { echo "$label must be canonical and contain no symlinked ancestor" >&2; return 1; } + type="$(local_with_timeout stat -c '%F' -- "$path")" \ + || { echo "cannot inspect $label: $path" >&2; return 1; } + [[ "$type" == directory ]] || { echo "$label must be a real directory" >&2; return 1; } +} + +require_safe_source_repo() { + require_safe_directory_path "$SOURCE_DIR" "source repo" + require_safe_directory_path "$SOURCE_DIR/.git" "source Git directory" + local external_git_store + for external_git_store in "$SOURCE_DIR/.git/commondir" \ + "$SOURCE_DIR/.git/objects/info/alternates" "$SOURCE_DIR/.git/objects/info/http-alternates"; do + [[ ! -e "$external_git_store" && ! -L "$external_git_store" ]] \ + || { echo "source repo uses an external Git object or metadata store: $external_git_store" >&2; return 1; } + done +} +require_real_directory_path "$SIGNED_DIR" "signed bundle" +require_safe_file_path "$LTA_TRUSTED_SIGNER" "trusted verifier" +require_safe_source_repo + +gh_with_timeout() { + timeout -k 5 300 gh "$@" +} +git_with_timeout() { + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" git \ + -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.attributesFile=/dev/null \ + -c core.pager=cat -c pager.branch=false -c pager.tag=false "$@" +} + +bundle_files=(COMMIT PREPARED_SHA256SUMS RELEASE_SIGNER_PUBKEY SHA256SUMS SIGNER_SHA256 TAG VERSION release_pubkey.hex \ + linux-temp-admin-linux-amd64 linux-temp-admin-linux-amd64.sig \ + linux-temp-admin-linux-arm64 linux-temp-admin-linux-arm64.sig) +for name in "${bundle_files[@]}" SIGNED_BUNDLE_SHA256SUMS; do + require_regular_file_path "$SIGNED_DIR/$name" "signed-bundle file $name" \ + || { echo "missing regular signed-bundle file: $name" >&2; exit 1; } +done + +# Snapshot the removable transfer once. Every subsequent check, upload, and +# comparison uses only this private directory, so a writer cannot swap a valid +# old bundle into the publication path after validation. +work="$(mktemp -d /tmp/lta-publish-release.XXXXXX)" +mkdir -m 0700 "$work/gh-config" +GH_CONFIG_DIR="$work/gh-config" +export GH_CONFIG_DIR +gpg_wrapper="$work/gpg-batch" +printf '%s\n' '#!/bin/sh' 'exec /usr/bin/gpg --batch --no-auto-key-retrieve "$@"' > "$gpg_wrapper" +chmod 0700 "$gpg_wrapper" +LATEST_PROMOTION_ATTEMPTED=0 +PUBLISH_COMPLETE=0 +cleanup() { + local status=$? + trap - EXIT + if (( status != 0 && LATEST_PROMOTION_ATTEMPTED == 1 && PUBLISH_COMPLETE == 0 )); then + echo "stable publication failed after a mutation that could affect Latest; restoring the exact highest stable release other than $TAG" >&2 + if ! restore_latest_after_failed_promotion; then + echo "CRITICAL: automatic Latest restoration failed; keep the release unannounced and follow docs/releasing.md recovery" >&2 + fi + fi + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" rm -rf -- "$work" \ + || echo "warning: could not remove private publication workspace within timeout: $work" >&2 + exit "$status" +} +trap cleanup EXIT + +bounded_copy() { + local source=$1 destination=$2 max=$3 blocks size + blocks=$(( (max + 1023) / 1024 )) + if ! ( ulimit -f "$blocks"; timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" \ + cp --reflink=never --sparse=never -- "$source" "$destination" ); then + echo "input exceeds its snapshot limit or could not be copied: $source" >&2 + return 1 + fi + size="$(local_with_timeout stat -Lc '%s' -- "$destination")" \ + || { echo "copied file could not be measured: $destination" >&2; return 1; } + (( size <= max )) || { echo "input exceeds its snapshot limit: $source" >&2; return 1; } +} + +# Pin the verifier by descriptor so the inode hashed here is the one every +# subsequent verify command executes. +exec {trusted_signer_fd}<"$LTA_TRUSTED_SIGNER" +trusted_signer="/proc/$$/fd/${trusted_signer_fd}" +[[ -f "$trusted_signer" && -x "$trusted_signer" ]] \ + || { echo "trusted verifier descriptor is not an executable regular file" >&2; exit 1; } +read -r trusted_signer_uid trusted_signer_mode < <(local_with_timeout stat -Lc '%u %a' -- "$trusted_signer") +[[ "$trusted_signer_uid" == 0 || "$trusted_signer_uid" == "$EUID" ]] \ + || { echo "trusted verifier is owned by an unexpected uid" >&2; exit 1; } +if [[ ! "$trusted_signer_mode" =~ ^[0-7]{3}$ ]] \ + || (( (8#$trusted_signer_mode & 8#022) != 0 )); then + echo "trusted verifier has unsafe group/world-write or special mode bits" >&2 + exit 1 +fi +[[ "$(timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" sha256sum "$trusted_signer" | awk '{print $1}')" == "$LTA_TRUSTED_SIGNER_SHA256" ]] \ + || { echo "trusted verifier hash mismatch" >&2; exit 1; } +signer_with_timeout() { + timeout -k 5 "$SIGNER_TIMEOUT_SECONDS" "$trusted_signer" "$@" +} +[[ "$(signer_with_timeout version)" == "lta-release-offline-v1" ]] \ + || { echo "unsupported trusted verifier protocol" >&2; exit 1; } + +BUNDLE_DIR="$work/bundle" +mkdir -m 0700 "$BUNDLE_DIR" +for name in "${bundle_files[@]}" SIGNED_BUNDLE_SHA256SUMS; do + limit=$MAX_METADATA_BYTES + [[ "$name" != linux-temp-admin-linux-amd64 && "$name" != linux-temp-admin-linux-arm64 ]] \ + || limit=$MAX_BINARY_BYTES + bounded_copy "$SIGNED_DIR/$name" "$BUNDLE_DIR/$name" "$limit" +done + +[[ "$(sha256sum "$BUNDLE_DIR/SIGNED_BUNDLE_SHA256SUMS" | awk '{print $1}')" == "$LTA_EXPECTED_SIGNED_BUNDLE_MANIFEST_SHA256" ]] \ + || { echo "signed-bundle manifest differs from the independently recorded value" >&2; exit 1; } +[[ "$(awk 'NF {print $2}' "$BUNDLE_DIR/SIGNED_BUNDLE_SHA256SUMS")" == $'COMMIT\nPREPARED_SHA256SUMS\nRELEASE_SIGNER_PUBKEY\nSHA256SUMS\nSIGNER_SHA256\nTAG\nVERSION\nrelease_pubkey.hex\nlinux-temp-admin-linux-amd64\nlinux-temp-admin-linux-amd64.sig\nlinux-temp-admin-linux-arm64\nlinux-temp-admin-linux-arm64.sig' ]] \ + || { echo "signed bundle manifest has unexpected entries" >&2; exit 1; } +( cd "$BUNDLE_DIR" && sha256sum -c --strict SIGNED_BUNDLE_SHA256SUMS ) +[[ "$(<"$BUNDLE_DIR/SIGNER_SHA256")" == "$LTA_TRUSTED_SIGNER_SHA256" ]] || { echo "bundle used another signer" >&2; exit 1; } +[[ "$(<"$BUNDLE_DIR/RELEASE_SIGNER_PUBKEY")" == "$LTA_EXPECTED_RELEASE_SIGNER_PUBKEY" ]] \ + || { echo "bundle was signed by a different release key" >&2; exit 1; } + +TAG="$(<"$BUNDLE_DIR/TAG")" +VERSION="$(<"$BUNDLE_DIR/VERSION")" +COMMIT="$(<"$BUNDLE_DIR/COMMIT")" +[[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z]+([.-][0-9A-Za-z]+)*))?$ && "$VERSION" == "${TAG#v}" ]] \ + || { echo "invalid or inconsistent bundle tag/version" >&2; exit 1; } +major="${BASH_REMATCH[1]}" +(( ${#major} > 1 || 10#$major >= 2 )) || { echo "release tags below v2 are not supported" >&2; exit 1; } +[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo "invalid bundle commit" >&2; exit 1; } +[[ "$(awk 'NF {print $2}' "$BUNDLE_DIR/SHA256SUMS")" == $'linux-temp-admin-linux-amd64\nlinux-temp-admin-linux-amd64.sig\nlinux-temp-admin-linux-arm64\nlinux-temp-admin-linux-arm64.sig' ]] \ + || { echo "release SHA256SUMS has unexpected entries" >&2; exit 1; } +( cd "$BUNDLE_DIR" && sha256sum -c --strict SHA256SUMS ) +[[ "$(wc -l < "$BUNDLE_DIR/RELEASE_SIGNER_PUBKEY")" -eq 1 ]] \ + || { echo "release-signer key file must contain exactly one line" >&2; exit 1; } +grep -Fqx "$LTA_EXPECTED_RELEASE_SIGNER_PUBKEY" "$BUNDLE_DIR/RELEASE_SIGNER_PUBKEY" \ + || { echo "release-signer key file is malformed" >&2; exit 1; } +awk '/^[[:space:]]*(#|$)/ {next} {gsub(/[[:space:]]/, ""); print tolower($0)}' "$BUNDLE_DIR/release_pubkey.hex" \ + | grep -Fqx "$LTA_EXPECTED_RELEASE_SIGNER_PUBKEY" \ + || { echo "selected release signer is absent from the tagged keyring" >&2; exit 1; } +awk ' + /^[[:space:]]*(#|$)/ { next } + { gsub(/^[[:space:]]+|[[:space:]]+$/, ""); if (length($0) != 64 || $0 !~ /^[0-9A-Fa-f]+$/ || seen[tolower($0)]++) exit 1; count++ } + END { if (!count) exit 1 } +' "$BUNDLE_DIR/release_pubkey.hex" || { echo "signed-bundle release keyring is malformed or duplicated" >&2; exit 1; } +for arch in amd64 arm64; do + asset="$BUNDLE_DIR/linux-temp-admin-linux-${arch}" + [[ -s "$asset" && "$(wc -c < "$asset")" -le "$MAX_BINARY_BYTES" ]] \ + || { echo "signed ${arch} binary is empty or exceeds the 64 MiB client limit" >&2; exit 1; } + [[ "$(wc -c < "$BUNDLE_DIR/linux-temp-admin-linux-${arch}.sig")" -eq 64 ]] || { echo "invalid ${arch} signature size" >&2; exit 1; } + signer_with_timeout verify "$BUNDLE_DIR/RELEASE_SIGNER_PUBKEY" \ + "$BUNDLE_DIR/linux-temp-admin-linux-${arch}" "$BUNDLE_DIR/linux-temp-admin-linux-${arch}.sig" +done + +if ! tag_object="$(git_with_timeout -C "$SOURCE_DIR" rev-parse --verify "refs/tags/${TAG}^{tag}")"; then + echo "$TAG must resolve to an annotated tag object" >&2 + exit 1 +fi +tag_commit="$(git_with_timeout -C "$SOURCE_DIR" rev-parse --verify "${tag_object}^{commit}")" +[[ "$tag_commit" == "$COMMIT" ]] || { echo "bundle commit differs from local tag" >&2; exit 1; } +git_with_timeout -C "$SOURCE_DIR" ls-tree -r -z "$tag_commit" > "$work/tag-tree" +while IFS= read -r -d '' tree_entry; do + tree_mode=${tree_entry%% *} + [[ "$tree_mode" != 120000 && "$tree_mode" != 160000 ]] \ + || { echo "$TAG contains a symlink or submodule; release source must be self-contained" >&2; exit 1; } +done < "$work/tag-tree" +embedded_tag="$(git_with_timeout -C "$SOURCE_DIR" cat-file tag "$tag_object" | awk ' + /^$/ { headers=0 } + headers != 0 && /^tag / { if (found++) exit 2; sub(/^tag /, ""); value=$0 } + NR == 1 { headers=1 } + END { if (found != 1) exit 1; print value } +')" +[[ "$embedded_tag" == "$TAG" ]] \ + || { echo "annotated tag object names $embedded_tag, not $TAG" >&2; exit 1; } +if ! tag_status="$(git_with_timeout -c gpg.format=openpgp -c gpg.program="$gpg_wrapper" \ + -c gpg.openpgp.program="$gpg_wrapper" \ + -C "$SOURCE_DIR" verify-tag --raw "$tag_object" 2>&1)"; then + printf '%s\n' "$tag_status" >&2 + echo "$TAG does not have a valid OpenPGP signature" >&2 + exit 1 +fi +printf '%s\n' "$tag_status" | awk -v expected="$LTA_EXPECTED_TAG_SIGNER_FINGERPRINT" ' + $1 == "[GNUPG:]" && $2 == "VALIDSIG" && (tolower($3) == expected || tolower($NF) == expected) { matched++ } + END { exit(matched == 1 ? 0 : 1) } +' || { echo "$TAG was not signed by the independently pinned OpenPGP key" >&2; exit 1; } +remote_tag="$(gh_with_timeout api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.sha')" +[[ "$tag_object" == "$remote_tag" ]] || { echo "local and GitHub tag objects differ" >&2; exit 1; } +ancestry="$(gh_with_timeout api "repos/${REPO}/compare/${tag_commit}...main" --jq '.status')" +[[ "$ancestry" == identical || "$ancestry" == ahead ]] \ + || { echo "$TAG is not contained in GitHub main" >&2; exit 1; } +successful_sha="$(gh_with_timeout run list --repo "$REPO" --workflow release.yml --branch "$TAG" --event push --limit 100 \ + --json conclusion,headSha,headBranch \ + --jq "first(.[] | select(.conclusion == \"success\" and .headSha == \"$tag_commit\" and .headBranch == \"$TAG\")) | .headSha // \"\"")" +[[ "$successful_sha" == "$tag_commit" ]] || { echo "no successful Release workflow for $TAG at $tag_commit" >&2; exit 1; } +git_with_timeout -C "$SOURCE_DIR" show "$tag_commit:internal/selfmanage/release_pubkey.hex" > "$work/tag-keyring.hex" +cmp "$work/tag-keyring.hex" "$BUNDLE_DIR/release_pubkey.hex" || { echo "bundle keyring differs from tagged source" >&2; exit 1; } + +decimal_gt() { + local left=$1 right=$2 + (( ${#left} > ${#right} )) && return 0 + (( ${#left} < ${#right} )) && return 1 + [[ "$left" > "$right" ]] +} + +stable_tag_gt() { + local newer=$1 older=$2 nmajor nminor npatch omajor ominor opatch pair left right + [[ "$newer" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || return 1 + nmajor=${BASH_REMATCH[1]}; nminor=${BASH_REMATCH[2]}; npatch=${BASH_REMATCH[3]} + [[ "$older" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || return 1 + omajor=${BASH_REMATCH[1]}; ominor=${BASH_REMATCH[2]}; opatch=${BASH_REMATCH[3]} + for pair in "$nmajor:$omajor" "$nminor:$ominor" "$npatch:$opatch"; do + left=${pair%%:*}; right=${pair#*:} + decimal_gt "$left" "$right" && return 0 + decimal_gt "$right" "$left" && return 1 + done + return 1 +} + +highest_stable_release_excluding() { + local excluded=${1:-} release_tags tag highest="" + release_tags="$(gh_with_timeout api --paginate "repos/${REPO}/releases?per_page=100" \ + --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name')" || return 1 + while IFS= read -r tag; do + [[ -n "$tag" ]] || continue + [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] \ + || { echo "published stable release has a non-canonical tag: $tag" >&2; return 1; } + [[ -z "$excluded" || "$tag" != "$excluded" ]] || continue + if [[ -z "$highest" ]] || stable_tag_gt "$tag" "$highest"; then + highest=$tag + fi + done <<<"$release_tags" + printf '%s\n' "$highest" +} + +current_latest_tag() { + local latest response_file api_status status_count not_found_count + response_file="$work/latest-api-response" + if latest="$(gh_with_timeout release view --repo "$REPO" --json tagName --jq '.tagName' 2>"$work/latest-view-error")"; then + [[ "$latest" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] \ + || { echo "Latest has a non-canonical stable tag: $latest" >&2; return 1; } + printf '%s\n' "$latest" + return 0 + fi + + # gh release view exits nonzero when the repository has no Latest release. + # Confirm the exact REST 404 so authentication, transport, and API failures + # can never be mistaken for the valid empty-Latest state. + if gh_with_timeout api --include "repos/${REPO}/releases/latest" >"$response_file" 2>&1; then + api_status=0 + else + api_status=$? + fi + if [[ "$api_status" -eq 0 ]]; then + echo "Latest lookup was inconsistent: release view failed but the REST route succeeded" >&2 + return 1 + fi + if [[ "$api_status" -ne 1 ]]; then + cat "$work/latest-view-error" "$response_file" >&2 + echo "Latest REST lookup failed with unexpected status $api_status" >&2 + return 1 + fi + status_count="$(grep -Ec '^HTTP/[0-9.]+ [0-9]{3}([[:space:]]|$)' "$response_file" || true)" + not_found_count="$(grep -Ec '^HTTP/[0-9.]+ 404([[:space:]]|$)' "$response_file" || true)" + if [[ "$status_count" -eq 1 && "$not_found_count" -eq 1 ]]; then + printf '\n' + return 0 + fi + cat "$work/latest-view-error" "$response_file" >&2 + echo "could not determine the exact Latest release" >&2 + return 1 +} + +require_latest_exact() { + local expected=$1 context=$2 actual expected_display actual_display + actual="$(current_latest_tag)" || return 1 + expected_display=${expected:-} + actual_display=${actual:-} + [[ "$actual" == "$expected" ]] \ + || { echo "$context: Latest is $actual_display, expected exactly $expected_display" >&2; return 1; } +} + +restore_latest_after_failed_promotion() { + local expected + expected="$(highest_stable_release_excluding "$TAG")" \ + || { echo "could not enumerate the stable release to restore" >&2; return 1; } + if [[ -n "$expected" ]]; then + gh_with_timeout release edit "$expected" --repo "$REPO" --latest \ + || { echo "could not restore $expected as Latest" >&2; return 1; } + else + gh_with_timeout release edit "$TAG" --repo "$REPO" --latest=false \ + || { echo "could not clear Latest when no other stable release exists" >&2; return 1; } + fi + require_latest_exact "$expected" "Latest restoration failed" || return 1 + echo "restored Latest to ${expected:-}" >&2 +} + +release_state() { + gh_with_timeout release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,tagName \ + --jq '. | (.isDraft|tostring) + " " + (.isPrerelease|tostring) + " " + .tagName' +} + +require_draft() { + [[ "$(gh_with_timeout release view "$TAG" --repo "$REPO" --json isDraft,tagName --jq '. | (.isDraft|tostring) + " " + .tagName')" == "true $TAG" ]] \ + || { echo "release is no longer the expected draft" >&2; exit 1; } +} +require_remote_tag_object() { + [[ "$(gh_with_timeout api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.sha')" == "$tag_object" ]] \ + || { echo "GitHub tag object changed during publication" >&2; exit 1; } +} +remote_asset_names() { + gh_with_timeout release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name' | LC_ALL=C sort +} +require_initial_remote_assets() { + local got + got="$(remote_asset_names)" + printf '%s\n' "$got" | awk ' + $0 == "SHA256SUMS" || $0 == "linux-temp-admin-linux-amd64" || + $0 == "linux-temp-admin-linux-amd64.sig" || $0 == "linux-temp-admin-linux-arm64" || + $0 == "linux-temp-admin-linux-arm64.sig" { seen[$0]=1; next } + { invalid=1 } + END { + if (invalid || !seen["SHA256SUMS"] || !seen["linux-temp-admin-linux-amd64"] || + !seen["linux-temp-admin-linux-arm64"]) exit 1 + } + ' || { echo "release is missing a core unsigned asset or contains an unexpected asset" >&2; printf '%s\n' "$got" >&2; exit 1; } +} +require_exact_signed_assets() { + local got expected + got="$(remote_asset_names)" + expected=$'SHA256SUMS\nlinux-temp-admin-linux-amd64\nlinux-temp-admin-linux-amd64.sig\nlinux-temp-admin-linux-arm64\nlinux-temp-admin-linux-arm64.sig' + [[ "$got" == "$expected" ]] \ + || { echo "release does not contain exactly the signed release assets" >&2; printf '%s\n' "$got" >&2; exit 1; } +} +require_remote_asset_digests() { + local got expected name digest size + got="$(gh_with_timeout release view "$TAG" --repo "$REPO" --json assets \ + --jq '.assets[] | [.name, (.digest // ""), (.size|tostring)] | @tsv' | LC_ALL=C sort)" + expected="$({ + for name in SHA256SUMS linux-temp-admin-linux-amd64 linux-temp-admin-linux-amd64.sig \ + linux-temp-admin-linux-arm64 linux-temp-admin-linux-arm64.sig; do + digest="$(sha256sum "$BUNDLE_DIR/$name" | awk '{print $1}')" + size="$(wc -c < "$BUNDLE_DIR/$name")" + printf '%s\tsha256:%s\t%s\n' "$name" "$digest" "$size" + done + } | LC_ALL=C sort)" + [[ "$got" == "$expected" ]] \ + || { echo "remote release asset digests differ from the signed bundle" >&2; diff -u <(printf '%s\n' "$expected") <(printf '%s\n' "$got") >&2 || true; exit 1; } +} +download_draft_asset() { + local name=$1 max=$2 out=$3 record advertised_size api_url blocks actual_size + record="$(gh_with_timeout release view "$TAG" --repo "$REPO" --json assets \ + --jq ".assets[] | select(.name == \"$name\") | [(.size|tostring), .apiUrl] | @tsv")" + IFS=$'\t' read -r advertised_size api_url <<<"$record" + [[ "$advertised_size" =~ ^[0-9]+$ && "$advertised_size" -gt 0 && "$advertised_size" -le "$max" ]] \ + || { echo "invalid or oversized advertised draft asset: $name" >&2; return 1; } + [[ "$api_url" == "https://api.github.com/repos/${REPO}/releases/assets/"* ]] \ + || { echo "unexpected GitHub asset API URL for $name" >&2; return 1; } + blocks=$(( (max + 1023) / 1024 )) + ( ulimit -f "$blocks"; gh_with_timeout api -H 'Accept: application/octet-stream' "$api_url" > "$out" ) \ + || { echo "bounded draft download failed: $name" >&2; return 1; } + actual_size="$(wc -c < "$out")" + [[ "$actual_size" -eq "$advertised_size" && "$actual_size" -le "$max" ]] \ + || { echo "draft asset size changed during download: $name" >&2; return 1; } +} +if [[ "$TAG" == *-* ]]; then + expected_prerelease=true +else + expected_prerelease=false +fi + +REMOTE_RELEASE_STATE="$(release_state)" +case "$REMOTE_RELEASE_STATE" in + "true false $TAG"|"true true $TAG") RELEASE_WAS_DRAFT=1 ;; + "false $expected_prerelease $TAG") RELEASE_WAS_DRAFT=0 ;; + *) + echo "release is neither the expected draft nor an exactly matching published release: $REMOTE_RELEASE_STATE" >&2 + exit 1 + ;; +esac + +BASELINE_HIGHEST_TAG="$(highest_stable_release_excluding "$TAG")" +BASELINE_LATEST_TAG="$(current_latest_tag)" +RESUMING_ALREADY_LATEST=0 +if [[ "$TAG" != *-* && "$RELEASE_WAS_DRAFT" -eq 0 && "$BASELINE_LATEST_TAG" == "$TAG" ]]; then + # A previous run completed the promotion. Verification failures during this + # read-only resume must not demote a release that was already Latest at entry. + RESUMING_ALREADY_LATEST=1 +else + require_latest_exact "$BASELINE_HIGHEST_TAG" "invalid publication baseline" +fi +if [[ "$TAG" != *-* && -n "$BASELINE_HIGHEST_TAG" ]]; then + stable_tag_gt "$TAG" "$BASELINE_HIGHEST_TAG" \ + || { echo "stable release $TAG must be newer than highest other release $BASELINE_HIGHEST_TAG" >&2; exit 1; } +fi + +if (( RELEASE_WAS_DRAFT == 1 )); then + require_draft + require_initial_remote_assets + echo ">> [publish 1/4] replace draft with the exact signed bytes" + gh_with_timeout release upload "$TAG" --repo "$REPO" --clobber \ + "$BUNDLE_DIR/linux-temp-admin-linux-amd64" "$BUNDLE_DIR/linux-temp-admin-linux-amd64.sig" \ + "$BUNDLE_DIR/linux-temp-admin-linux-arm64" "$BUNDLE_DIR/linux-temp-admin-linux-arm64.sig" \ + "$BUNDLE_DIR/SHA256SUMS" + require_draft + require_exact_signed_assets + mkdir "$work/draft" + for name in SHA256SUMS linux-temp-admin-linux-amd64 linux-temp-admin-linux-amd64.sig linux-temp-admin-linux-arm64 linux-temp-admin-linux-arm64.sig; do + limit=$MAX_METADATA_BYTES + [[ "$name" != linux-temp-admin-linux-amd64 && "$name" != linux-temp-admin-linux-arm64 ]] \ + || limit=$MAX_BINARY_BYTES + download_draft_asset "$name" "$limit" "$work/draft/$name" + cmp "$work/draft/$name" "$BUNDLE_DIR/$name" || { echo "draft asset changed during upload: $name" >&2; exit 1; } + done + require_remote_asset_digests + + echo ">> [publish 2/4] publish only after authenticated draft verification" + require_draft + require_exact_signed_assets + require_remote_asset_digests + require_remote_tag_object + require_latest_exact "$BASELINE_HIGHEST_TAG" "Latest changed during publication preparation" + if [[ "$TAG" == *-* ]]; then + gh_with_timeout release edit "$TAG" --repo "$REPO" --draft=false --prerelease --latest=false + else + # Keep the new stable version off Latest until its public versioned route + # has passed byte-for-byte and signature verification. Set the restoration + # guard before the call because a failed response can still follow an + # applied server-side mutation. + LATEST_PROMOTION_ATTEMPTED=1 + gh_with_timeout release edit "$TAG" --repo "$REPO" --draft=false --prerelease=false --latest=false + fi +else + echo ">> [publish 1/4] resume exactly matching published release (no asset mutation)" + require_exact_signed_assets + require_remote_asset_digests + echo ">> [publish 2/4] published state already present; continue independent verification" +fi + +[[ "$(release_state)" == "false $expected_prerelease $TAG" ]] \ + || { echo "release did not publish as expected" >&2; exit 1; } +require_remote_tag_object +require_exact_signed_assets +require_remote_asset_digests + +public_fetch() { + local url=$1 out=$2 max=$3 attempt blocks fetch_url + # Bash expresses RLIMIT_FSIZE in 1024-byte units. Exact byte checks below + # handle the final partial block and remain authoritative. + blocks=$(( (max + 1023) / 1024 )) + for attempt in 1 2 3 4 5 6; do + rm -f -- "$out" + fetch_url="$url" + if (( attempt >= 4 )); then + fetch_url="${url}?download=1" + fi + if ( ulimit -f "$blocks"; timeout -k 5 120 curl -q -fsSL --connect-timeout 10 --max-time 120 \ + --proto '=https' --proto-redir '=https' "$fetch_url" -o "$out" ) \ + && [[ -s "$out" && "$(wc -c < "$out")" -le "$max" ]]; then + return 0 + fi + sleep "$attempt" + done + return 1 +} + +verify_public_set() { + local base=$1 dir=$2 name arch + mkdir "$dir" + public_fetch "$base/SHA256SUMS" "$dir/SHA256SUMS" 1048576 + for arch in amd64 arm64; do + name="linux-temp-admin-linux-${arch}" + public_fetch "$base/$name" "$dir/$name" 67108864 + public_fetch "$base/$name.sig" "$dir/$name.sig" 256 + done + for name in SHA256SUMS linux-temp-admin-linux-amd64 linux-temp-admin-linux-amd64.sig linux-temp-admin-linux-arm64 linux-temp-admin-linux-arm64.sig; do + cmp "$dir/$name" "$BUNDLE_DIR/$name" || { echo "public asset differs: $name" >&2; return 1; } + done + ( cd "$dir" && sha256sum -c --strict SHA256SUMS ) + for arch in amd64 arm64; do + signer_with_timeout verify "$BUNDLE_DIR/RELEASE_SIGNER_PUBKEY" \ + "$dir/linux-temp-admin-linux-${arch}" "$dir/linux-temp-admin-linux-${arch}.sig" + done +} + +echo ">> [publish 3/4] independently verify public versioned assets" +verify_public_set "https://github.com/${REPO}/releases/download/${TAG}" "$work/public-versioned" +if [[ "$TAG" != *-* ]]; then + echo ">> [publish 4/4] promote and independently verify the stable Latest route" + if (( RESUMING_ALREADY_LATEST == 0 )); then + require_latest_exact "$BASELINE_HIGHEST_TAG" "Latest changed before final promotion; refusing to overwrite it" + [[ "$(highest_stable_release_excluding "$TAG")" == "$BASELINE_HIGHEST_TAG" ]] \ + || { echo "the stable release baseline changed before final promotion" >&2; exit 1; } + [[ "$(highest_stable_release_excluding "")" == "$TAG" ]] \ + || { echo "a higher stable release appeared; refusing to promote $TAG" >&2; exit 1; } + # Set this before the mutating call: gh can fail after the server applied + # the update. The EXIT trap must restore even in that ambiguous outcome. + LATEST_PROMOTION_ATTEMPTED=1 + gh_with_timeout release edit "$TAG" --repo "$REPO" --latest + else + echo "resuming a previously promoted $TAG after exact asset verification" + fi + + final_highest="$(highest_stable_release_excluding "")" + if [[ "$final_highest" != "$TAG" ]]; then + LATEST_PROMOTION_ATTEMPTED=1 + if restore_latest_after_failed_promotion; then + LATEST_PROMOTION_ATTEMPTED=0 + echo "a higher stable release appeared during Latest promotion; restored the exact highest alternative" >&2 + else + echo "a higher stable release appeared and immediate Latest restoration failed; the EXIT trap will retry" >&2 + fi + exit 1 + fi + require_latest_exact "$TAG" "published stable release did not become Latest" + verify_public_set "https://github.com/${REPO}/releases/latest/download" "$work/public-latest" + require_remote_tag_object + require_exact_signed_assets + require_remote_asset_digests + final_highest="$(highest_stable_release_excluding "")" + if [[ "$final_highest" != "$TAG" ]]; then + LATEST_PROMOTION_ATTEMPTED=1 + if restore_latest_after_failed_promotion; then + LATEST_PROMOTION_ATTEMPTED=0 + echo "a higher stable release appeared during final verification; restored the exact highest alternative" >&2 + else + echo "a higher stable release appeared and immediate Latest restoration failed; the EXIT trap will retry" >&2 + fi + exit 1 + fi + require_latest_exact "$TAG" "Latest changed during final verification" +else + require_remote_tag_object + require_exact_signed_assets + require_remote_asset_digests + [[ "$(highest_stable_release_excluding "$TAG")" == "$BASELINE_HIGHEST_TAG" ]] \ + || { echo "the stable release set changed while publishing a prerelease" >&2; exit 1; } + require_latest_exact "$BASELINE_HIGHEST_TAG" "publishing a prerelease unexpectedly changed Latest" + echo ">> [publish 4/4] prerelease correctly excluded from Latest verification" +fi +PUBLISH_COMPLETE=1 +echo "published and independently verified: $TAG" diff --git a/scripts/release.sh b/scripts/release.sh index e732ec1..a9b74cf 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,38 +1,6 @@ -#!/usr/bin/env bash -# Build, sign, and stage v2 release binaries (linux amd64 + arm64). -# -# Prereqs: -# - Go toolchain -# - LTA_SIGN_KEY = path to the ed25519 private key file produced by -# `go run ./cmd/lta-release keygen ` (keep it OFFLINE) -# - the matching public key already pasted into -# internal/selfmanage/release_pubkey.hex and committed -# -# Usage: LTA_SIGN_KEY=~/.lta/signing.key scripts/release.sh 2.0.0 +#!/bin/bash -p +[[ $- == *p* ]] || { echo "execute release.sh directly; privileged Bash mode is required" >&2; exit 2; } set -Eeuo pipefail - -VERSION="${1:?usage: release.sh X.Y.Z}" -: "${LTA_SIGN_KEY:?set LTA_SIGN_KEY to the ed25519 private key file}" -[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] \ - || { echo "version must be X.Y.Z or X.Y.Z-prerelease" >&2; exit 1; } - -cd "$(dirname "$0")/.." -rm -rf dist && mkdir -p dist -go build -o dist/lta-release ./cmd/lta-release - -for arch in amd64 arm64; do - out="dist/linux-temp-admin-linux-${arch}" - CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build \ - -trimpath -tags osusergo,netgo \ - -ldflags "-s -w -X github.com/xxvcc/linux-temp-admin/internal/buildinfo.Version=${VERSION}" \ - -o "$out" ./cmd/linux-temp-admin - ./dist/lta-release sign "$LTA_SIGN_KEY" "$out" # writes ${out}.sig - echo "built + signed $out" -done - -( cd dist && sha256sum linux-temp-admin-linux-* > SHA256SUMS ) -rm -f dist/lta-release - -echo -echo "Artifacts staged in dist/. Publish with:" -echo " gh release create v${VERSION} dist/linux-temp-admin-linux-* dist/SHA256SUMS --title v${VERSION}" +echo "release.sh is intentionally disabled: the old local fallback bypassed provenance and prerelease gates." >&2 +echo "Use prepare-release.sh, offline-sign-release.sh, and publish-release.sh; see docs/releasing.md." >&2 +exit 2 diff --git a/scripts/sign-release.sh b/scripts/sign-release.sh index 7fca629..26cd3e7 100755 --- a/scripts/sign-release.sh +++ b/scripts/sign-release.sh @@ -1,104 +1,6 @@ -#!/usr/bin/env bash -# Second half of a v2 release — run on the maintainer's OFFLINE machine. -# -# The release workflow (.github/workflows/release.yml) builds the static -# binaries + SHA256SUMS on tag push and stages them in a DRAFT GitHub Release. -# This script signs those exact CI-built binaries with the offline ed25519 key -# (which never leaves this machine) and publishes the release: -# -# 1. prove local HEAD/tag, the remote tag, and the draft all agree, -# 2. download the draft's binaries + SHA256SUMS, -# 3. verify the checksums, -# 4. sign each binary -> .sig (raw 64-byte ed25519), -# 5. verify each .sig against the embedded public key (fail closed), -# 6. refresh SHA256SUMS to also cover the .sig files, -# 7. upload the signatures + refreshed SHA256SUMS, -# 8. flip the release from draft to published (stable tags become latest). -# -# Because it signs the bytes CI actually published, the signature is valid for -# the exact assets users download — no reproducible-build assumption needed. -# -# Prereqs: gh (authenticated, write access), Go toolchain, -# LTA_SIGN_KEY = path to the ed25519 private key file (keep OFFLINE). -# Usage: LTA_SIGN_KEY=~/.lta/signing.key scripts/sign-release.sh v2.0.1 +#!/bin/bash -p +[[ $- == *p* ]] || { echo "execute sign-release.sh directly; privileged Bash mode is required" >&2; exit 2; } set -Eeuo pipefail - -TAG="${1:?usage: sign-release.sh vX.Y.Z}" -: "${LTA_SIGN_KEY:?set LTA_SIGN_KEY to the ed25519 private key file}" -[[ "$TAG" =~ ^v([0-9]+)\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] \ - || { echo "tag must be vX.Y.Z or vX.Y.Z-prerelease" >&2; exit 1; } -major="${BASH_REMATCH[1]}" -(( 10#$major >= 2 )) || { echo "release tags below v2 are not supported" >&2; exit 1; } - -cd "$(dirname "$0")/.." -PUBHEX="internal/selfmanage/release_pubkey.hex" -REPO="xxvcc/linux-temp-admin" - -require_draft() { - [[ "$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq '.isDraft')" == "true" ]] \ - || { echo "release $TAG is not a draft; refusing to sign or replace published assets" >&2; exit 1; } -} - -echo ">> [1/8] verifying source, tag, and draft state" -git rev-parse --verify "refs/tags/${TAG}^{commit}" >/dev/null -if [[ -n "$(git status --porcelain)" ]]; then - echo "worktree must be clean before signing a release" >&2 - exit 1 -fi -[[ "$(git rev-parse HEAD)" == "$(git rev-parse "refs/tags/${TAG}^{commit}")" ]] \ - || { echo "HEAD must be checked out at $TAG before signing" >&2; exit 1; } -remote_tag="$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.sha')" -[[ "$(git rev-parse "refs/tags/${TAG}")" == "$remote_tag" ]] \ - || { echo "local tag $TAG does not match the tag on GitHub" >&2; exit 1; } -[[ "$(gh release view "$TAG" --repo "$REPO" --json tagName --jq '.tagName')" == "$TAG" ]] \ - || { echo "GitHub Release tag does not match $TAG" >&2; exit 1; } -require_draft - -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT - -echo ">> [2/8] downloading draft assets for $TAG" -gh release download "$TAG" \ - --repo "$REPO" \ - --pattern 'linux-temp-admin-linux-amd64' \ - --pattern 'linux-temp-admin-linux-arm64' \ - --pattern 'SHA256SUMS' \ - --dir "$work" - -echo ">> [3/8] verifying checksums" -( cd "$work" && sha256sum -c SHA256SUMS ) - -echo ">> [4/8] building the signer (lta-release)" -go build -o "$work/lta-release" ./cmd/lta-release - -echo ">> [5/8] signing binaries offline" -for arch in amd64 arm64; do - bin="$work/linux-temp-admin-linux-${arch}" - "$work/lta-release" sign "$LTA_SIGN_KEY" "$bin" # writes ${bin}.sig -done - -echo ">> [6/8] verifying signatures against the embedded public key (fail closed)" -for arch in amd64 arm64; do - bin="$work/linux-temp-admin-linux-${arch}" - "$work/lta-release" verify "$PUBHEX" "$bin" "${bin}.sig" -done - -echo ">> refreshing SHA256SUMS to cover the .sig files" -( cd "$work" && sha256sum linux-temp-admin-linux-* > SHA256SUMS ) - -echo ">> [7/8] uploading signatures + refreshed SHA256SUMS" -require_draft -gh release upload "$TAG" \ - --repo "$REPO" \ - "$work/linux-temp-admin-linux-amd64.sig" \ - "$work/linux-temp-admin-linux-arm64.sig" \ - "$work/SHA256SUMS" --clobber - -echo ">> [8/8] publishing release $TAG" -if [[ "$TAG" == *-* ]]; then - gh release edit "$TAG" --repo "$REPO" --draft=false --prerelease -else - gh release edit "$TAG" --repo "$REPO" --draft=false --latest -fi - -echo "done: $TAG published with verified signatures." +echo "sign-release.sh is intentionally disabled: an online one-step signer exposes the long-term key." >&2 +echo "Use trusted copies of prepare-release.sh, offline-sign-release.sh, and publish-release.sh; see docs/releasing.md." >&2 +exit 2 From 8b860d0913fdd1964ccb4eace14d756b9717c50b Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Mon, 27 Jul 2026 18:33:07 +0800 Subject: [PATCH 2/4] ci: avoid duplicate branch and pull request runs --- .github/workflows/go.yml | 1 + .github/workflows/shellcheck.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index fa5d0ce..f9098b8 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -2,6 +2,7 @@ name: Go on: push: + branches: [main] pull_request: permissions: diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index e926ed6..c1aeaa5 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -2,6 +2,7 @@ name: ShellCheck on: push: + branches: [main] pull_request: permissions: From d028b2f5df99cd2dfb9a4ef470b4bd236286c214 Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Mon, 27 Jul 2026 19:03:18 +0800 Subject: [PATCH 3/4] test: make CI gates independent of runner state --- internal/cli/cli.go | 49 +++--- internal/cli/cli_e2e_test.go | 10 +- internal/cli/cli_test.go | 34 +++- internal/cli/invite.go | 12 +- internal/cli/invite_reuse_root_test.go | 19 ++- internal/selfmanage/release_pipeline_test.go | 100 +++++++++-- internal/sshdconf/sshdconf_root_test.go | 171 +++++++++++++++++++ 7 files changed, 332 insertions(+), 63 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 096f4ee..1888daa 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -55,6 +55,10 @@ type App struct { // SSHDConfig reads sshd's effective configuration for a user; injectable so a // test's verdict comes from a fixture, not from the test host's own sshd. SSHDConfig func(user string) (*sysinfo.SSHDConfig, error) + // SSHDHasConnectionScopedMatch covers the part of sshd policy that a user-only + // effective-config probe cannot evaluate. Keep it beside SSHDConfig so tests + // can source the complete policy verdict from fixtures instead of the host. + SSHDHasConnectionScopedMatch func() bool InstallPath string // StateDir and AuditLogDir are the paths an uninstall removes RECURSIVELY, so @@ -91,28 +95,29 @@ type App struct { // NewApp builds an App with real collaborators and the resolved language. func NewApp(lang i18n.Lang) *App { return &App{ - Out: os.Stdout, - Err: os.Stderr, - In: os.Stdin, - P: i18n.Printer{Lang: lang}, - Users: user.New(), - Sudoers: sudoers.New(), - SSHD: sshdconf.New(), - Scheduler: schedule.New(), - Registry: registry.Default(), - Detector: netdetect.New(), - Selfmanage: selfmanage.New(config.InstallPath, config.MaxUpgradeBytes), - Audit: audit.Default(), - Lifecycle: lifecycle.New(config.LifecycleLockFile), - SSHDConfig: sysinfo.SSHDEffective, - InstallPath: config.InstallPath, - StateDir: config.StateDir, - AuditLogDir: config.AuditLogDir, - Now: time.Now, - RandHex: randHex, - RandPassword: randPassword, - StdoutIsTTY: func() bool { return term.IsTerminal(int(os.Stdout.Fd())) }, - StdinIsTTY: func() bool { return term.IsTerminal(int(os.Stdin.Fd())) }, + Out: os.Stdout, + Err: os.Stderr, + In: os.Stdin, + P: i18n.Printer{Lang: lang}, + Users: user.New(), + Sudoers: sudoers.New(), + SSHD: sshdconf.New(), + Scheduler: schedule.New(), + Registry: registry.Default(), + Detector: netdetect.New(), + Selfmanage: selfmanage.New(config.InstallPath, config.MaxUpgradeBytes), + Audit: audit.Default(), + Lifecycle: lifecycle.New(config.LifecycleLockFile), + SSHDConfig: sysinfo.SSHDEffective, + SSHDHasConnectionScopedMatch: sysinfo.HasConnectionScopedMatch, + InstallPath: config.InstallPath, + StateDir: config.StateDir, + AuditLogDir: config.AuditLogDir, + Now: time.Now, + RandHex: randHex, + RandPassword: randPassword, + StdoutIsTTY: func() bool { return term.IsTerminal(int(os.Stdout.Fd())) }, + StdinIsTTY: func() bool { return term.IsTerminal(int(os.Stdin.Fd())) }, TerminalWidth: func() int { width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil { diff --git a/internal/cli/cli_e2e_test.go b/internal/cli/cli_e2e_test.go index 0f1d28f..f3f0fa2 100644 --- a/internal/cli/cli_e2e_test.go +++ b/internal/cli/cli_e2e_test.go @@ -184,9 +184,10 @@ func TestInviteThenRevokeEndToEnd(t *testing.T) { Dir: sshdDir, Validate: func() error { return nil }, Reload: func() error { return nil }, Effective: func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdOK), nil }, }, - SSHDConfig: func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdOK), nil }, - Detector: netdetect.New(), - Selfmanage: &selfmanage.Manager{InstallPath: installPath}, + SSHDConfig: func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdOK), nil }, + SSHDHasConnectionScopedMatch: func() bool { return false }, + Detector: netdetect.New(), + Selfmanage: &selfmanage.Manager{InstallPath: installPath}, Audit: &audit.Logger{ Dir: filepath.Dir(auditFile), File: auditFile, Now: now, Actor: func() (string, int) { return "e2e", 0 }, @@ -490,7 +491,8 @@ func TestInviteFixSSHDThenRevokeEndToEnd(t *testing.T) { Dir: sshdDir, Validate: func() error { return nil }, Effective: effective, Reload: func() error { reloads++; return nil }, }, - SSHDConfig: effective, + SSHDConfig: effective, + SSHDHasConnectionScopedMatch: func() bool { return false }, Scheduler: &schedule.Scheduler{ SystemdDir: rootDir(t, 0o755), InstallPath: installPath, UnitPrefix: config.AutoRevokeUnitPrefix, Now: now, Sys: fakeSched{}, diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index daf17cd..938ca0d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -75,14 +75,15 @@ func newTestApp(t *testing.T, in string) (*App, *bytes.Buffer, *bytes.Buffer) { var out, errb bytes.Buffer a := &App{ Out: &out, Err: &errb, In: strings.NewReader(in), - P: i18n.Printer{Lang: i18n.EN}, - Registry: ®istry.Store{Dir: dir, File: filepath.Join(dir, "r.tsv"), Lock: filepath.Join(dir, "r.lock")}, - InstallPath: filepath.Join(dir, "lta"), - Now: func() time.Time { return time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) }, - RandHex: func(int) (string, error) { return "abcdef0123", nil }, - StdoutIsTTY: func() bool { return true }, - StdinIsTTY: func() bool { return false }, - Geteuid: func() int { return 0 }, + P: i18n.Printer{Lang: i18n.EN}, + Registry: ®istry.Store{Dir: dir, File: filepath.Join(dir, "r.tsv"), Lock: filepath.Join(dir, "r.lock")}, + InstallPath: filepath.Join(dir, "lta"), + Now: func() time.Time { return time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) }, + RandHex: func(int) (string, error) { return "abcdef0123", nil }, + StdoutIsTTY: func() bool { return true }, + StdinIsTTY: func() bool { return false }, + Geteuid: func() int { return 0 }, + SSHDHasConnectionScopedMatch: func() bool { return false }, } return a, &out, &errb } @@ -1192,6 +1193,23 @@ func TestPasswordFallbackIsNotOfferedForUnverifiablePolicy(t *testing.T) { } } +func TestLoginChecksUseInjectedConnectionScopedMatchProbe(t *testing.T) { + a, _, _ := newTestApp(t, "") + probes := 0 + a.SSHDHasConnectionScopedMatch = func() bool { + probes++ + return true + } + cfg := sysinfo.ParseSSHD("pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\n") + rep := a.checkKeyLogin(cfg, "xxvcc-a1", []string{"xxvcc-a1"}) + if rep.Certain() || len(rep.Unverifiable) != 1 { + t.Fatalf("connection-scoped Match probe did not downgrade the report: %+v", rep) + } + if probes != 1 { + t.Fatalf("connection-scoped Match probes = %d, want 1", probes) + } +} + func TestDetachedSignatureURLPreservesQueryAndFragment(t *testing.T) { cases := map[string]string{ "https://example.com/releases/lta": "https://example.com/releases/lta.sig", diff --git a/internal/cli/invite.go b/internal/cli/invite.go index 800b2ac..cd1f583 100644 --- a/internal/cli/invite.go +++ b/internal/cli/invite.go @@ -663,15 +663,19 @@ func (a *App) reportBlockers(rep sysinfo.LoginReport) { // blocker, so it neither refuses the invite nor triggers a fix, only downgrades a // "verified" claim to an honest UNVERIFIED. func (a *App) checkKeyLogin(cfg *sysinfo.SSHDConfig, user string, groups []string) sysinfo.LoginReport { - return withConnectionScopedMatch(sysinfo.CheckKeyLogin(cfg, user, groups)) + return a.withConnectionScopedMatch(sysinfo.CheckKeyLogin(cfg, user, groups)) } func (a *App) checkPasswordLogin(cfg *sysinfo.SSHDConfig, user string, groups []string) sysinfo.LoginReport { - return withConnectionScopedMatch(sysinfo.CheckPasswordLogin(cfg, user, groups)) + return a.withConnectionScopedMatch(sysinfo.CheckPasswordLogin(cfg, user, groups)) } -func withConnectionScopedMatch(rep sysinfo.LoginReport) sysinfo.LoginReport { - if sysinfo.HasConnectionScopedMatch() { +func (a *App) withConnectionScopedMatch(rep sysinfo.LoginReport) sysinfo.LoginReport { + hasConnectionScopedMatch := sysinfo.HasConnectionScopedMatch + if a.SSHDHasConnectionScopedMatch != nil { + hasConnectionScopedMatch = a.SSHDHasConnectionScopedMatch + } + if hasConnectionScopedMatch() { rep.Unverifiable = append(rep.Unverifiable, "sshd has a connection-scoped Match rule; whether this account is admitted depends on address, port, or routing attributes that cannot be checked here") } diff --git a/internal/cli/invite_reuse_root_test.go b/internal/cli/invite_reuse_root_test.go index e892991..30c6d07 100644 --- a/internal/cli/invite_reuse_root_test.go +++ b/internal/cli/invite_reuse_root_test.go @@ -61,15 +61,16 @@ func inviteApp(t *testing.T) (*cli.App, *sudoers.Manager, *sshdconf.Manager, str UnitPrefix: config.AutoRevokeUnitPrefix, LegacyUnitPrefixes: []string{config.V1AutoRevokeUnitPrefix}, Now: now, Sys: fakeSched{}, }, - Registry: ®istry.Store{Dir: regDir, File: filepath.Join(regDir, "registry.tsv"), Lock: filepath.Join(regDir, "registry.lock")}, - SSHD: sshdMgr, - SSHDConfig: func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdOK), nil }, - Detector: netdetect.New(), - Selfmanage: &selfmanage.Manager{InstallPath: installPath}, - Audit: &audit.Logger{Dir: filepath.Dir(auditFile), File: auditFile, Now: now, Actor: func() (string, int) { return "test", 0 }}, - InstallPath: installPath, - Executable: func() (string, error) { return installPath, nil }, - Now: now, + Registry: ®istry.Store{Dir: regDir, File: filepath.Join(regDir, "registry.tsv"), Lock: filepath.Join(regDir, "registry.lock")}, + SSHD: sshdMgr, + SSHDConfig: func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD(sshdOK), nil }, + SSHDHasConnectionScopedMatch: func() bool { return false }, + Detector: netdetect.New(), + Selfmanage: &selfmanage.Manager{InstallPath: installPath}, + Audit: &audit.Logger{Dir: filepath.Dir(auditFile), File: auditFile, Now: now, Actor: func() (string, int) { return "test", 0 }}, + InstallPath: installPath, + Executable: func() (string, error) { return installPath, nil }, + Now: now, RandHex: func(n int) (string, error) { if n == 16 { return "0123456789abcdef0123456789abcdef", nil diff --git a/internal/selfmanage/release_pipeline_test.go b/internal/selfmanage/release_pipeline_test.go index 29caf12..32aead4 100644 --- a/internal/selfmanage/release_pipeline_test.go +++ b/internal/selfmanage/release_pipeline_test.go @@ -623,8 +623,21 @@ fi if err := os.Mkdir(binDir, 0o700); err != nil { t.Fatal(err) } - fakeTimeout := "#!/bin/sh\nprintf payload > \"$TEST_FETCH_OUT\"\nmkdir \"$TEST_TIMEOUT_MARKER\"\nexit 0\n" - if err := os.WriteFile(filepath.Join(binDir, "timeout"), []byte(fakeTimeout), 0o700); err != nil { + fakeCurl := `#!/bin/sh +set -eu +curl_out= +while [ "$#" -gt 0 ]; do + case "$1" in + -o | --output) shift; curl_out=$1 ;; + esac + shift +done +[ -n "$curl_out" ] +printf payload > "$curl_out" +mkdir "$TEST_CURL_MARKER" +printf '200\n\n' +` + if err := os.WriteFile(filepath.Join(binDir, "curl"), []byte(fakeCurl), 0o700); err != nil { t.Fatal(err) } outPath := filepath.Join(dir, "out") @@ -635,18 +648,32 @@ FSIZE_BLOCK_BYTES=512 FETCH_TIMEOUT_SECONDS=1 CONNECT_TIMEOUT_SECONDS=1 ` + fetchFunction + ` -wc() { return 1; } +wc() { + mkdir "$TEST_WC_MARKER" + return 1 +} if fetch_once "https://example.invalid/bin" "$TEST_FETCH_OUT" 1024 "$FETCH_TIMEOUT_SECONDS"; then echo "fetch unexpectedly succeeded" >&2 exit 1 fi -[ -d "$TEST_TIMEOUT_MARKER" ] -[ ! -e "$TEST_FETCH_OUT" ] +if [ ! -d "$TEST_CURL_MARKER" ]; then + echo "curl fixture was not invoked" >&2 + exit 92 +fi +if [ ! -d "$TEST_WC_MARKER" ]; then + echo "wc failure fixture was not invoked" >&2 + exit 93 +fi +if [ -e "$TEST_FETCH_OUT" ]; then + echo "download output survived failed size inspection" >&2 + exit 94 +fi ` runShellFixture(t, shell.path, shell.args, source, "TEST_BIN="+binDir, "TEST_FETCH_OUT="+outPath, - "TEST_TIMEOUT_MARKER="+filepath.Join(dir, "timeout-ran"), + "TEST_CURL_MARKER="+filepath.Join(dir, "curl-ran"), + "TEST_WC_MARKER="+filepath.Join(dir, "wc-ran"), ) }) @@ -1287,6 +1314,26 @@ func TestInstallerGitHubRedirectsResolveBeforeRequestAcrossShells(t *testing.T) } networkFunctions := installer[networkStart : networkStart+networkEnd] fetchFunction := installer[fetchStart : fetchStart+fetchEnd+2] + if got := strings.Count(networkFunctions, "command -v nslookup"); got != 1 { + t.Fatalf("installer nslookup availability probe count=%d, want 1", got) + } + if got := strings.Count(networkFunctions, " nslookup -type="); got != 2 { + t.Fatalf("installer nslookup invocation count=%d, want 2", got) + } + // BusyBox builds with FEATURE_PREFER_APPLETS ignore PATH fixtures when an + // applet invokes another applet. Point only the external resolver dependency + // at an absolute fixture while retaining the installer's parser and policy. + fixtureNetworkFunctions := strings.Replace( + networkFunctions, + "command -v nslookup", + `command -v "$TEST_NSLOOKUP_COMMAND"`, + 1, + ) + fixtureNetworkFunctions = strings.ReplaceAll( + fixtureNetworkFunctions, + " nslookup -type=", + ` "$TEST_NSLOOKUP_COMMAND" -type=`, + ) type shellCase struct { name string @@ -1436,6 +1483,7 @@ fi } case "nslookup": fixture := `#!/bin/sh +printf '%s\n' "$*" >> "$TEST_NSLOOKUP_MARKER" if [ "$TEST_NSLOOKUP_OLD" = 1 ]; then printf 'Server: resolver.invalid\nAddress 1: 10.0.0.53 resolver.invalid\n\n' printf 'Name: redirect.example\nAddress 1: %s redirect.example\n' "$TEST_RESOLVER_OUTPUT" @@ -1455,7 +1503,7 @@ PATH="$TEST_BIN" export PATH FSIZE_BLOCK_BYTES=512 CONNECT_TIMEOUT_SECONDS=2 -` + networkFunctions + "\n" + fetchFunction + ` +` + fixtureNetworkFunctions + "\n" + fetchFunction + ` if fetch_once 'https://github.example/asset' "$TEST_OUT" 4096 "$TEST_FETCH_TIMEOUT" 1; then fetch_rc=0 else @@ -1469,6 +1517,7 @@ printf 'rc=%s\n' "$fetch_rc" args := append(append([]string(nil), shell.args...), script) cmd := exec.Command(shell.path, args...) marker := filepath.Join(dir, "curl-requests") + nslookupMarker := filepath.Join(dir, "nslookup-requests") nslookupOld := "0" if tc.nslookupOld { nslookupOld = "1" @@ -1488,6 +1537,8 @@ printf 'rc=%s\n' "$fetch_rc" cmd.Env = []string{ "TEST_BIN=" + binDir, "TEST_CURL_MARKER=" + marker, + "TEST_NSLOOKUP_COMMAND=" + filepath.Join(binDir, "nslookup"), + "TEST_NSLOOKUP_MARKER=" + nslookupMarker, "TEST_OUT=" + filepath.Join(dir, "out"), "TEST_RESOLVER_OUTPUT=" + tc.resolverOutput, "TEST_NSLOOKUP_OLD=" + nslookupOld, @@ -1504,6 +1555,16 @@ printf 'rc=%s\n' "$fetch_rc" if err != nil { t.Fatalf("redirect fixture failed: %v\n%s", err, out) } + if tc.resolver == "nslookup" { + calls, err := os.ReadFile(nslookupMarker) + if err != nil { + t.Fatalf("nslookup fixture was not invoked: %v", err) + } + const wantCalls = "-type=A redirect.example\n-type=AAAA redirect.example\n" + if string(calls) != wantCalls { + t.Fatalf("nslookup calls=%q, want %q", calls, wantCalls) + } + } if string(out) != fmt.Sprintf("rc=%d\n", tc.wantRC) { t.Fatalf("fetch result=%q, want rc=%d", out, tc.wantRC) } @@ -2745,13 +2806,13 @@ func TestReleaseOutputPathAllowsStickyParentButRejectsNoncanonicalPath(t *testin } guards := offline[start : start+end] dir := t.TempDir() - stickyParent := filepath.Join(dir, "sticky") - if err := os.Mkdir(stickyParent, 0o700); err != nil { - t.Fatal(err) - } - if err := os.Chmod(stickyParent, 0o777|os.ModeSticky); err != nil { - t.Fatal(err) + const stickyParent = "/tmp" + outputLeaf := filepath.Base(filepath.Dir(dir)) + "-output" + outputPath := filepath.Join(stickyParent, outputLeaf) + if _, err := os.Lstat(outputPath); !os.IsNotExist(err) { + t.Fatalf("test output path must begin nonexistent: %v", err) } + noncanonicalOutputPath := stickyParent + "//" + outputLeaf + "-other" script := filepath.Join(dir, "output-path.sh") body := `#!/bin/bash @@ -2759,12 +2820,12 @@ set -Eeuo pipefail LOCAL_COMMAND_TIMEOUT_SECONDS=120 local_with_timeout() { timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" "$@"; } ` + guards + ` -require_safe_new_output_path "$TEST_STICKY_PARENT/out" "test output" +require_safe_new_output_path "$TEST_OUTPUT_PATH" "test output" if require_safe_directory_path "$TEST_STICKY_PARENT" "test leaf"; then echo "sticky leaf unexpectedly passed" >&2 exit 90 fi -if require_safe_new_output_path "$TEST_STICKY_PARENT//other" "test output"; then +if require_safe_new_output_path "$TEST_NONCANONICAL_OUTPUT_PATH" "test output"; then echo "noncanonical output unexpectedly passed" >&2 exit 91 fi @@ -2773,10 +2834,17 @@ fi t.Fatal(err) } cmd := exec.Command("/bin/bash", script) - cmd.Env = append(os.Environ(), "TEST_STICKY_PARENT="+stickyParent) + cmd.Env = append(os.Environ(), + "TEST_STICKY_PARENT="+stickyParent, + "TEST_OUTPUT_PATH="+outputPath, + "TEST_NONCANONICAL_OUTPUT_PATH="+noncanonicalOutputPath, + ) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("sticky-parent/noncanonical output guard failed: %v\n%s", err, out) } + if _, err := os.Lstat(outputPath); !os.IsNotExist(err) { + t.Fatalf("output path guard created its target: %v", err) + } } func TestPublisherResumeAndRecoveryGuards(t *testing.T) { diff --git a/internal/sshdconf/sshdconf_root_test.go b/internal/sshdconf/sshdconf_root_test.go index 07421c8..16a7f9d 100644 --- a/internal/sshdconf/sshdconf_root_test.go +++ b/internal/sshdconf/sshdconf_root_test.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "strings" + "syscall" "testing" "github.com/xxvcc/linux-temp-admin/internal/sysinfo" @@ -29,6 +30,112 @@ func rootDir(t *testing.T) string { return dir } +func verifySSHDPrivilegeSeparationDir(path string, info os.FileInfo) error { + if !info.IsDir() { + return fmt.Errorf("%s is %s, want a directory", path, info.Mode().Type()) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("cannot determine owner of %s", path) + } + if stat.Uid != 0 { + return fmt.Errorf("%s is owned by uid %d, want root", path, stat.Uid) + } + if info.Mode().Perm()&0o022 != 0 { + return fmt.Errorf("%s has unsafe mode %04o: group/other write bits must be clear", path, info.Mode().Perm()) + } + return nil +} + +// ensureSSHDPrivilegeSeparationDir supplies the runtime prerequisite that +// distro packages normally create before starting sshd. Minimal CI images can +// contain the real sshd binary without that directory. +func ensureSSHDPrivilegeSeparationDir(t *testing.T, path string) { + t.Helper() + + info, err := os.Lstat(path) + if err == nil { + if err := verifySSHDPrivilegeSeparationDir(path, info); err != nil { + t.Fatal(err) + } + return + } + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("inspect sshd privilege separation directory: %v", err) + } + + if err := os.Mkdir(path, 0o755); err != nil { + // Another process may have created the directory after Lstat. In that + // case it owns the lifecycle, so validate it without registering cleanup. + if errors.Is(err, os.ErrExist) { + info, statErr := os.Lstat(path) + if statErr != nil { + t.Fatalf("inspect concurrently created sshd privilege separation directory: %v", statErr) + } + if err := verifySSHDPrivilegeSeparationDir(path, info); err != nil { + t.Fatal(err) + } + return + } + t.Fatalf("create sshd privilege separation directory: %v", err) + } + + created, err := os.Lstat(path) + if err != nil { + t.Fatalf("inspect created sshd privilege separation directory: %v", err) + } + t.Cleanup(func() { + current, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return + } + if err != nil { + t.Errorf("inspect sshd privilege separation directory during cleanup: %v", err) + return + } + if !os.SameFile(created, current) { + return + } + if err := os.Remove(path); err != nil { + t.Errorf("remove test-created sshd privilege separation directory: %v", err) + } + }) + + dir, err := os.Open(path) + if err != nil { + t.Fatalf("open created sshd privilege separation directory: %v", err) + } + opened, err := dir.Stat() + if err != nil { + dir.Close() + t.Fatalf("inspect opened sshd privilege separation directory: %v", err) + } + if !os.SameFile(created, opened) { + dir.Close() + t.Fatal("sshd privilege separation directory was replaced while being opened") + } + if err := dir.Chmod(0o755); err != nil { + dir.Close() + t.Fatalf("set sshd privilege separation directory mode: %v", err) + } + if err := dir.Close(); err != nil { + t.Fatalf("close sshd privilege separation directory: %v", err) + } + info, err = os.Lstat(path) + if err != nil { + t.Fatalf("verify created sshd privilege separation directory: %v", err) + } + if !os.SameFile(created, info) { + t.Fatal("sshd privilege separation directory was replaced while being configured") + } + if err := verifySSHDPrivilegeSeparationDir(path, info); err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o755 { + t.Fatalf("created sshd privilege separation directory mode = %04o, want 0755", got) + } +} + // blocked is a host that refuses public-key logins; fixed is the same host once // the drop-in is in place. const ( @@ -100,6 +207,7 @@ func TestDropInRestoresScopeForLaterIncludedFiles(t *testing.T) { if err := os.WriteFile(main, []byte("Include "+dir+"/*.conf\n"), 0o600); err != nil { t.Fatal(err) } + ensureSSHDPrivilegeSeparationDir(t, "/run/sshd") out, err := exec.Command(sshd, "-T", "-f", main, "-C", "user=xxvcc-other,host=localhost,addr=127.0.0.1").CombinedOutput() if err != nil { t.Fatalf("sshd -T: %v: %s", err, strings.TrimSpace(string(out))) @@ -109,6 +217,69 @@ func TestDropInRestoresScopeForLaterIncludedFiles(t *testing.T) { } } +func TestEnsureSSHDPrivilegeSeparationDirLifecycle(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("requires root") + } + + t.Run("creates and cleans", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "sshd") + t.Run("create", func(t *testing.T) { + ensureSSHDPrivilegeSeparationDir(t, path) + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("created directory: %v", err) + } + if got := info.Mode().Perm(); got != 0o755 { + t.Fatalf("created directory mode = %04o, want 0755", got) + } + }) + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("test-created directory was not cleaned up: %v", err) + } + }) + + t.Run("leaves existing directory unchanged", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "sshd") + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o700); err != nil { + t.Fatal(err) + } + ensureSSHDPrivilegeSeparationDir(t, path) + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("existing directory was removed: %v", err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Fatalf("existing directory mode = %04o, want unchanged 0700", got) + } + }) + + t.Run("preserves replacement inode", func(t *testing.T) { + parent := t.TempDir() + path := filepath.Join(parent, "sshd") + displaced := filepath.Join(parent, "sshd-created-by-test") + t.Run("replace", func(t *testing.T) { + ensureSSHDPrivilegeSeparationDir(t, path) + if err := os.Rename(path, displaced); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + }) + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("replacement directory was removed: %v", err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Fatalf("replacement directory mode = %04o, want 0700", got) + } + }) +} + func TestRemoveNeverReloadsOntoABrokenConfig(t *testing.T) { // THE 3am SCENARIO. An operator edits /etc/ssh/sshd_config at 14:00, leaves a // typo, and never reloads: the running sshd is still serving its old, good From 08e914d90ba250f28251fab9c0325c810e38a88f Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Mon, 27 Jul 2026 19:22:35 +0800 Subject: [PATCH 4/4] docs: adopt single-maintainer release governance --- .github/CODEOWNERS | 2 + .github/workflows/stage-release.yml | 4 +- docs/releasing.md | 91 ++++++++++++++++++----------- 3 files changed, 62 insertions(+), 35 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index efe15ec..372ebbc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,5 @@ +# Ownership metadata only; the single-maintainer ruleset does not require a +# CODEOWNERS approval. # Default owner for everything in the repo. * @xxvcc diff --git a/.github/workflows/stage-release.yml b/.github/workflows/stage-release.yml index b07ed06..38ad16f 100644 --- a/.github/workflows/stage-release.yml +++ b/.github/workflows/stage-release.yml @@ -30,7 +30,7 @@ jobs: permissions: contents: read steps: - - name: Require reviewed release-environment configuration + - name: Require release-environment configuration env: RELEASE_ENVIRONMENT_CONFIGURED: ${{ vars.LTA_RELEASE_ENVIRONMENT_CONFIGURED }} shell: bash @@ -143,7 +143,7 @@ jobs: rm -f -- "$lookup" # Re-resolve the protected tag immediately before the first write so - # an approval delay cannot make the earlier validation stale. + # a queueing or environment delay cannot stale the earlier validation. ref_json="$(timeout -k 5 60 gh api "repos/${GH_REPO}/git/ref/tags/${TAG}")" tag_object="$(jq -r '.object.sha' <<<"$ref_json")" [[ "$(jq -r '.object.type' <<<"$ref_json")" == tag ]] diff --git a/docs/releasing.md b/docs/releasing.md index 7703c2b..ba80ecc 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -5,10 +5,30 @@ private key is never present on a networked machine, candidate source is never executed on the signing machine, and CI output is never signed merely because its own checksum file matches. +## Maintainer model + +This repository currently uses a single-maintainer release process. Pull +requests preserve an auditable diff and must pass every required status check +and resolve every discussion, but GitHub does not require approval from another +account, CODEOWNERS review, or approval of the last push. The +`release-staging` and `release-mirror` environments likewise have no required +reviewers; their branch/tag restrictions and disabled administrator bypass +remain enforced. + +References below to independent caches, channels, machines, checks, or recorded +values describe technical separation, not a second human reviewer. This model +explicitly gives up human separation of duties: compromise of the sole +maintainer's GitHub authority can change protected source and workflows without +another person's approval. The required CI, OpenPGP-signed tag, offline ed25519 +release signature, reproducible rebuild, immutable Release, restricted mirror +receiver, and public post-deployment verification remain mandatory. Keep the +GitHub credential, OpenPGP private key, and offline ed25519 key separately +protected; external review is useful but is not a release gate. + ## One-time trusted tooling setup -On an audited source commit, before preparing any candidate release, obtain its -full 40-hex commit ID through the independent review record, place a standalone +On an audited source commit, before preparing any candidate release, record its +full 40-hex commit ID in the maintainer's release audit record, place a standalone root-owned checkout at the path below, and build the small network-incapable signer with the exact supported Go toolchain: @@ -135,7 +155,7 @@ unset TAR_OPTIONS GZIP BZIP2 BZIP XZ_OPT fail "cannot resolve trusted signer source HEAD" fi [[ "$source_head" == "$TRUSTED_SIGNER_COMMIT" ]] \ - || fail "trusted signer source HEAD is not the independently audited commit" + || fail "trusted signer source HEAD is not the recorded audited commit" source_tree="$build_root/source-tree" "${git_env[@]}" timeout -k 5 60 git \ @@ -241,7 +261,7 @@ private build/module caches, require the public Go module proxy and checksum database, disable direct VCS fetching, and clear caller-controlled `GOROOT`, experiment, FIPS, telemetry, and authentication settings. The signer is built twice with independent caches and installed only after the outputs compare -byte-for-byte. Run the setup from a separately reviewed, canonical source tree +byte-for-byte. Run the setup from a separately audited, canonical source tree whose complete contents and ancestry are root-owned and not writable by another account; the block verifies that boundary before Git or Go sees the tree. It also enters a clean privileged Bash, disables caller Git configuration and replacement @@ -305,20 +325,19 @@ Before enabling release staging, configure these repository controls. They are part of the release trust boundary and cannot be enforced by files inside the repository: -1. Protect `main` with a ruleset that requires pull requests, independent - approval including CODEOWNERS for `.github/workflows/**`, `scripts/**`, - `cmd/lta-release/**`, and this document, required CI status checks, resolved - conversations, and blocks force pushes and deletion. Do not permit bypass by - ordinary release operators. +1. Protect `main` with a ruleset that requires pull requests, required CI status + checks, resolved conversations, and blocks force pushes and deletion. Set the + required approval count to zero, and do not require CODEOWNERS review or + approval of the last push. CODEOWNERS remains ownership metadata only. Do not + permit ruleset bypass by ordinary release operations. 2. Protect `v*` tags with a ruleset that restricts creation, update, and deletion to the designated release maintainers. The pipeline additionally requires an annotated tag with a valid OpenPGP signature and independently pins its exact signing fingerprint. -3. Create an environment named `release-staging`, require approval by a reviewer - other than the triggering operator, prevent self-review, disable administrator - bypass, and restrict deployments to the protected `main` branch. A - `workflow_run` receiver executes from the default branch even though it - validates and stages the triggering `v*` tag. +3. Create an environment named `release-staging` with no required reviewers, + disable administrator bypass, and restrict deployments to the protected + `main` branch. A `workflow_run` receiver executes from the default branch even + though it validates and stages the triggering `v*` tag. 4. Only after verifying those controls, set the repository Actions variable `LTA_RELEASE_ENVIRONMENT_CONFIGURED` to the exact value `true`. Missing or different values fail closed before the write-capable job can run. Remove the @@ -374,7 +393,7 @@ Paste the complete output between `LTA_RELEASE_KEYS_BEGIN` and ## Release sequence -### 1. Create a reviewed signed tag +### 1. Create an audited signed tag The candidate commit must already be in `origin/main`. Use an OpenPGP-signed tag; `prepare-release.sh` and `publish-release.sh` both verify its signature against @@ -446,10 +465,10 @@ exec /opt/lta-release-tools/prepare-release.sh \ LTA_PREPARE_RELEASE ``` -Record the printed tag, commit, and prepared-manifest SHA-256 through an -independent operator channel. Transfer the prepared directory to removable +Record the printed tag, commit, and prepared-manifest SHA-256 in a separate +authenticated release record. Transfer the prepared directory to removable media as data. A compromised CI can choose its binary and checksum together, -but it cannot make those bytes equal the trusted rebuild unless the reviewed +but it cannot make those bytes equal the trusted rebuild unless the audited source/toolchain or preparation workstation is also compromised. ### 3. Air-gapped signing @@ -710,12 +729,11 @@ same-version forced self-upgrade from the public mirror. That canary first verifies the stable installer hash and fails if either client reports that it used the GitHub fallback. -Create a protected GitHub Environment named `release-mirror`. Require an -independent reviewer, prevent self-review and administrator bypass, and allow -only protected `v*` tags plus the protected default branch used for an explicit -recovery dispatch. Enable immutable Releases for the repository; synchronization -fails closed when the selected GitHub Release is mutable. Configure exactly -these environment values: +Create a protected GitHub Environment named `release-mirror` with no required +reviewers. Disable administrator bypass, and allow only protected `v*` tags plus +the protected default branch used for an explicit recovery dispatch. Enable +immutable Releases for the repository; synchronization fails closed when the +selected GitHub Release is mutable. Configure exactly these environment values: - Actions variables: `MIRROR_HOST`, `MIRROR_PORT`, `MIRROR_USER`, and `LTA_RELEASE_MIRROR_ENVIRONMENT_CONFIGURED` with the exact value `true`; @@ -732,7 +750,7 @@ from the repository's default branch and refuses any other repository identity. The production mirror uses the following exact layout. Treat a difference as configuration drift and clear the Environment configuration gate until it has -been reviewed: +been revalidated: - `ltamirror` is a password-locked, non-sudo account. Its login shell exists only so sshd can execute the forced command; its sole authorized key cannot @@ -795,7 +813,7 @@ cmp scripts/mirror-receiver.py /usr/local/libexec/linux-temp-admin-mirror-receiv ``` The final `cmp` is a post-install drift check and must succeed. For an Nginx -change, preserve the prior root-owned include, install the reviewed repository +change, preserve the prior root-owned include, install the audited repository copy, run `nginx -t`, reload the `nginx` service only after the syntax check, and repeat the public header, method, unknown-path, and byte-comparison probes. If syntax, reload, or a public probe fails, restore the preserved include, retest, @@ -817,15 +835,15 @@ Recovery is deliberately narrow: 4. A leftover `transfer-*` directory may be quarantined only after confirming that no `ltamirror` receiver or rsync process is active. `.deploy.lock` is persistent state and must not be treated as a stale transfer. -5. After total mirror loss, rebuild this empty layout, restore the reviewed +5. After total mirror loss, rebuild this empty layout, restore the audited receiver and Nginx include, rotate the deployment key, and dispatch each required immutable tag. Dispatch the current GitHub Latest tag after its version bytes are present so stable files are reconstructed last. Repeat all independent public checks before reopening the announcement gate. An intentional emergency downgrade is not normal deployment-key recovery: the -receiver blocks it. It requires a separately approved root-host incident -procedure, explicit client downgrade handling, and a new independent audit. +receiver blocks it. It requires an explicitly authorized and recorded root-host +incident procedure, explicit client downgrade handling, and a fresh audit. Do not announce a release until both mirror workflow jobs are green and an independent network check has fetched `latest.json`, the selected version's @@ -945,7 +963,7 @@ trusts the TLS and mutable script route of the source ultimately used. The mirrored installer is copied from the released signed tag, but is not itself an offline-ed25519-signed GitHub Release asset. For a high-assurance first install, obtain all three values below through -the review/signing record and an independent authenticated channel, then run: +the release audit/signing record and a separate authenticated channel, then run: ```bash INSTALLER_COMMIT='replace-with-the-audited-40-hex-commit' @@ -1097,8 +1115,8 @@ metadata are already safe; otherwise it is atomically repaired. - The private key is protected from candidate code, CI, GitHub, and networked preparation/publication. The air-gapped OS, fixed signer binary, trusted offline script, and physical transfer procedure remain trusted. -- Reproducible comparison binds CI bytes to the reviewed tag under the fixed Go - toolchain. The reviewed source, signed-tag identity, trusted preparation copy, +- Reproducible comparison binds CI bytes to the audited tag under the fixed Go + toolchain. The audited source, signed-tag identity, trusted preparation copy, Go distribution, and preparation workstation remain trusted. - The convenience bootstrap obtains its script and embedded trust anchors over TLS from the official mirror. The mirror takes that installer from the signed @@ -1108,7 +1126,7 @@ metadata are already safe; otherwise it is atomically repaired. channel before running it. - A fresh bootstrap has no previously installed version state, so a release-host compromise can replay an older release that still has a valid offline - signature. Pin a reviewed installer commit and pass `LTA_RELEASE=vX.Y.Z` when + signature. Pin an audited installer commit and pass `LTA_RELEASE=vX.Y.Z` when rollback resistance is required for first installation; the installer rejects a candidate whose reported version does not exactly match that tag. - Publication is not transactionally coupled to public CDN verification. If the @@ -1124,3 +1142,10 @@ metadata are already safe; otherwise it is atomically repaired. observed conflicts, but cannot eliminate an operation that starts immediately after the last check. The protected environment and organization-wide single-publisher lock are mandatory operational controls. +- The single-maintainer model has no independent human approval boundary. A + compromise of that maintainer's GitHub authority can merge source or workflow + changes and request deployments. Offline release signing and client + verification still prevent an account-only attacker from forging an accepted + binary update, but the stable bootstrap script and release availability remain + operational trust surfaces. Monitor them from a separate system and rotate + affected credentials immediately after a suspected compromise.