diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f1f5a..7c0c247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,150 @@ All notable changes to this project are documented here. +## v2.10.7 - 2026-09-26 + +- Close the remaining audit findings: clean up helper process groups before + reaping their leader on errors or inherited-pipe timeouts; retain orphan + auto-revoke retries until sshd removal has a confirmed reload. +- Require persistent atd enablement as well as a running daemon before accepting + an at fallback. Distinguish a positively absent systemd manager from a broken + manager so non-systemd hosts can use their native init backend. +- Create delivered private-key files with umask 077 and no-clobber semantics; + refuse existing files and links before writing any credential. +- Keep the mirror deployment lock until signal-triggered transfer cleanup has + killed and reaped its writer. Apply the correct shell file-limit units to every + release download and source archive, including POSIX Bash environments. +- Require complete process credentials before accepting an empty UID scan; + a /proc directory owner cannot exclude mixed-UID processes. Accept ASCII blank + lines and indented comments in account databases without relaxing real records. +- Make doctor verify the queued revoke deadline against the registry, including + minute rounding. Legacy ambiguous timezone abbreviations now report an + unverifiable deadline instead of silently passing that check. +- Preserve the full unsigned kernel login UID in audit records on every Go + architecture, avoiding an int conversion that could wrap on 32-bit builds. +- Upgrade golang.org/x/crypto to v0.56.0 and update the security-model + descriptions to the current expiry date, v5 registry and reserved UID/GID flow. + +- Skip the lines the system's own readers skip when parsing group, gshadow and + the subordinate-ID databases. glibc ignores blank and `#` lines and both glibc + and shadow-utils accept the `+`/`-` NIS compatibility entries, but any of them + was treated as a malformed record and hard-failed every sequential invite on a + host that carries one. The same rule was missing from the integration + suite's own group reader. +- Show revoke's account picker through the narrow-terminal view every other + picker already uses, and label a row the uninstall plan is going to excuse as + left alone rather than listing it under what will be removed. +- Reject a mirror manifest whose `published_at` is in the future, bound the + published-installer scan that hashes one file per release while holding the + deployment lock, and pass `-munge` to rrsync so a transferred symlink cannot + name a path outside the staging directory. +- Carry the invite password as bytes end to end so it can be zeroed, the way the + private key already is. A Go string cannot be cleared, and the secret was + copied into several of them. +- Bind the install directory's safety verdict to a single inode in the version + probe, closing the window between the check and the write in the one + privileged write-and-exec that works by name rather than through a pinned + directory descriptor. +- Refuse to delete an account outside this suite's own namespace from the + integration cleanup helper, which runs `userdel -r -f` as root from every + fixture in the tree and previously took whatever name it was handed. +- Sweep the quarantine unit namespace in the uninstall test fixture, as + production does, so the one prefix naming a disabled-but-still-present account + is no longer absent from every uninstall case. +- Anchor the expiry model to what `chage` actually stores rather than to its own + assumption, and pin the sshd drop-in's trailing `Match all` directly. Both + tests previously agreed with the code by construction and could not fail. +- Record the kernel's audit login uid alongside the actor. The actor name comes + from `SUDO_USER`, which the invoking environment supplies; loginuid is set once + per login session by PAM and is the value to reconcile against when an actor + name is in question. The unset sentinel is recorded as absent, not as a uid. +- Bound the GID that may authorize removing a private group to the same floor the + account protection and the identity allocator use. `validate.AccountID` only + asks for a positive value, so a system-range GID could have authorized + `groupdel` for a group this tool never created. +- Refuse a symlinked systemd unit directory when scanning for orphans, the + discipline the sudoers and sshd managers already apply to every directory they + open. +- Give the downloaded upgrade candidate `/dev/null` for stdin. On the documented + `curl … | sh` install path, stdin is the pipe still carrying the rest of the + installer, and the candidate is untrusted at that point. +- Probe the shell's `ulimit -f` block unit in `bounded_copy` instead of assuming + 1024 bytes. Under POSIX or sh mode the unit is 512, which halved the effective + cap and rejected files that were within the limit; `install.sh` already probed + it the same way. +- Add `chmod` to publish-release.sh's required-command preflight, sanitize + `GNUPGHOME` alongside the other trust-affecting variables, and stop closing an + already-closed descriptor number in `atomic_write`'s handler, where a later + open can inherit it. +- Refuse a corrupt quarantine deadline instead of letting it choose a teardown. + The discarded parse error left the zero time, which is before every real clock + reading, so an unparseable value silently selected the path that skips the + synchronous drain. +- Stop reporting an account as disabled when disabling it is what failed. The + quarantine handoff calls `DisableLogin` first, so that error reached the branch + claiming "the account is disabled and retained" — the one thing that message + must never say while the door may still be open. +- Recognise every form of `--yes` when deciding a run is unattended. Go's flag + package accepts `--yes=true` and friends, and an exact token match let those + runs be stopped by the first-run language prompt. +- Say so when the official mirror index offers a version older than the installed + one, instead of reporting "already up to date" and exiting silently. A stale, + rolled-back or tampered index is exactly what an operator would want to see. +- Print usage instead of panicking when the process is exec'd with `argc == 0`. +- Fail closed on an sshd config line whose keyword is empty. sshd treats a + leading `=` as the keyword/value separator and honours the directive after it, + but the Match/Include scan skipped such a line as if it were blank — hiding a + connection-scoped `Match` and letting an invite report a verified login that + sshd denies. +- Arm `atd` for later boots on OpenRC and sysvinit hosts, as the systemd branch + already did. Those are exactly the hosts where the `at` fallback is the + auto-revoke mechanism, so an atd that ran only until the next reboot meant + every queued revocation silently stopped firing. +- Stop reporting a legacy registry beside an existing identity sequence as an + unrepairable integrity failure. The sequence becomes mandatory only once a v5 + header is visible — `Init` reseeds it on the migration path — and neither + recovery command would touch the state the check was flagging. +- Adopt a committed-but-unsynced registry write in invite instead of abandoning + it. `AtomicWriteFileAt` renames the new registry into place before the + directory fsync, so a durability failure leaves the row on disk while `Record` + reports an error; the creation-intent row then had no release cleanup, and the + pending-to-completed row left the transaction comparing against a superseded + shape, which aborted invite's own rollback and left a live account behind. +- Read the SSH port from the listeners rather than the reported default. + OpenSSH's `ListenAddress addr:port` creates a listener without adding to + `options->ports`, so `sshd -T` prints `port 22` on a host that listens only on + 2222 — verified against OpenSSH 9.2 — and the invite handed the collaborator a + port nothing was listening on, confidently and without a warning. Every + listener sshd reports is now read; several distinct ports fail closed and ask + for an explicit `--port` rather than guessing. +- Parse `mountinfo` on the separator the kernel writes. The mount-boundary check + that stops a recursive removal split on any Unicode space, while the kernel + escapes only space, tab, newline and backslash inside path fields, so an + unescaped vertical tab or form feed added a field and shifted the positional + mountpoint — reporting no mount under a removal root that had one. +- Match the numeric-owner form when checking that `userdel` left no + subordinate-ID assignments. `subuid(5)`/`subgid(5)` define the first field as + "login name or UID" and recommend the numeric form on hosts with many + entries, but only the login name was compared, so real residue was reported + clean and the caller then dropped the registry row that was its last recovery + pointer. +- Reload sshd when a failed grant rolls back over a drop-in that was already + live. The rollback skipped the reload on the invariant that the daemon cannot + have seen a file this call just created, but the write replaces an existing + drop-in in place, and one left by an earlier granted-and-reloaded call may + already be in daemon memory — unlinking it silently left sshd enforcing a + grant this tool believed it had removed. +- Stop writing the one account-expiry value shadow documents as unusable. + `DisableLogin` expired an account with `chage -E 1970-01-01`, and chage stores + that field as days since the epoch, so the literal date encoded to `0` — + the value `shadow(5)` describes as "interpreted as either an account with no + expiration, or as an expiration on Jan 1, 1970", and which shadow's own + `isexpired()` reads the first way because it requires `sp_expire > 0`. That + expiry is the gate which stops a public-key login on a disabled account; the + password lock does not. The date moves one day past the epoch, which is + equally in the past and encodes as `1`. The old comment reached for exactly + this ambiguity and avoided it only in the argument, not in the stored field. + ## v2.10.6 - 2026-09-06 - Attribute `/proc` snapshot instability to the process directory's owner so an diff --git a/cmd/linux-temp-admin/main.go b/cmd/linux-temp-admin/main.go index c73901b..76d2cc3 100644 --- a/cmd/linux-temp-admin/main.go +++ b/cmd/linux-temp-admin/main.go @@ -15,7 +15,14 @@ func main() { fmt.Fprintln(os.Stderr, "cannot disable core dumps:", err) os.Exit(1) } - os.Exit(cli.Run(os.Args[1:])) + // os.Args is built as make([]string, argc), so an exec with argc == 0 leaves it + // zero-length and os.Args[1:] panics with a goroutine dump instead of reaching + // the usage text. cmd/lta-release guards the same way before it indexes. + args := os.Args + if len(args) > 0 { + args = args[1:] + } + os.Exit(cli.Run(args)) } func disableCoreDumps() error { diff --git a/cmd/lta-release/main_test.go b/cmd/lta-release/main_test.go index b64e108..3401f6f 100644 --- a/cmd/lta-release/main_test.go +++ b/cmd/lta-release/main_test.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -233,3 +234,99 @@ func TestAtomicWriteSignatureSyncsDirectoryAndReportsCommittedFailure(t *testing t.Fatalf("signature write left temporary files: %v", entries) } } + +// `lta-release verify` is the only tool a signer has to check a release artifact +// before it is published, and its reject path ends in os.Exit — so it can only be +// covered by running the built command. Nothing did: mutating the loop to accept +// every signature left the whole suite green. +func TestVerifyCommandAcceptsOnlyAGenuineSignature(t *testing.T) { + dir := t.TempDir() + tool := filepath.Join(dir, "lta-release") + // -buildvcs=false: stamping reads the git metadata, which fails whenever the + // checkout is not readable by the user running the tests. The binary under + // test does not depend on the stamp. + build := exec.Command("go", "build", "-buildvcs=false", "-o", tool, ".") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build lta-release: %v\n%s", err, out) + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + _, otherPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + write := func(name string, content []byte) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + return path + } + keyring := write("keyring", []byte(hex.EncodeToString(pub)+"\n")) + artifact := []byte("release bytes\n") + binPath := write("artifact.bin", artifact) + + run := func(t *testing.T, keyring, file, sig string) (int, string) { + t.Helper() + cmd := exec.Command(tool, "verify", keyring, file, sig) + out, err := cmd.CombinedOutput() + code := 0 + var exit *exec.ExitError + if errors.As(err, &exit) { + code = exit.ExitCode() + } else if err != nil { + t.Fatalf("run verify: %v", err) + } + return code, string(out) + } + + t.Run("a genuine signature verifies", func(t *testing.T) { + sig := write("good.sig", ed25519.Sign(priv, artifact)) + code, out := run(t, keyring, binPath, sig) + if code != 0 || !strings.Contains(out, "ok:") { + t.Fatalf("verify of a genuine signature: exit=%d out=%q", code, out) + } + }) + + t.Run("a signature from another key is rejected", func(t *testing.T) { + sig := write("wrong-key.sig", ed25519.Sign(otherPriv, artifact)) + code, out := run(t, keyring, binPath, sig) + if code == 0 { + t.Fatalf("verify accepted a signature made by a key outside the keyring: %q", out) + } + if !strings.Contains(out, "SIGNATURE INVALID") { + t.Fatalf("output = %q, want the rejection named", out) + } + }) + + t.Run("a tampered artifact is rejected", func(t *testing.T) { + sig := write("good2.sig", ed25519.Sign(priv, artifact)) + tampered := write("tampered.bin", []byte("release bytez\n")) + code, out := run(t, keyring, tampered, sig) + if code == 0 || !strings.Contains(out, "SIGNATURE INVALID") { + t.Fatalf("verify accepted a tampered artifact: exit=%d out=%q", code, out) + } + }) + + t.Run("a truncated signature is rejected before verification", func(t *testing.T) { + full := ed25519.Sign(priv, artifact) + sig := write("short.sig", full[:len(full)-1]) + code, out := run(t, keyring, binPath, sig) + if code == 0 || !strings.Contains(out, "invalid signature length") { + t.Fatalf("verify accepted a truncated signature: exit=%d out=%q", code, out) + } + }) + + t.Run("an empty signature is rejected", func(t *testing.T) { + sig := write("empty.sig", nil) + code, out := run(t, keyring, binPath, sig) + if code == 0 { + t.Fatalf("verify accepted an empty signature: %q", out) + } + }) +} diff --git a/docs/releasing.md b/docs/releasing.md index d179641..d8b28f1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -917,10 +917,13 @@ been revalidated: directory before creating a stage, throughout transfer, after transfer, and after cleanup. It refuses new work when residual `transfer-*` state exhausts that budget and reports cleanup failure instead of silently accumulating it. - On every catchable receiver failure it terminates and reaps the complete - transfer process group before releasing the deployment lock, and reports a - failure in that cleanup without hiding the primary error. An uncatchable - receiver death can still leave a detached writer, and directory scans cannot + Receiver failures, including SIGTERM, SIGHUP, and SIGINT, terminate the + complete transfer process group and reap its direct child before releasing the + deployment lock. Signals are deferred while child ownership is established and + during kill/reap; repeated termination signals cannot interrupt shutdown. Even + a successful transfer leader exit retires surviving descendants before + publication. Cleanup failures are reported without hiding the primary error. An + uncatchable receiver death can still leave a detached writer, and directory scans cannot account for a file that has already been unlinked but remains open; use a dedicated quota or filesystem when the host requires a kernel-enforced hard bound against either residual case. diff --git a/docs/security-model.en.md b/docs/security-model.en.md index 32edabc..95dbfa8 100644 --- a/docs/security-model.en.md +++ b/docs/security-model.en.md @@ -55,7 +55,7 @@ Automatic revoke and ordinary `revoke` require these identity values to agree. W A root-only high-water file beside the registry advances atomically before each `useradd`. Even if later creation or rollback fails, this tool never allocates that UID/GID again. A candidate is above every UID and GID in the ordinary local passwd/group range and is limited to the overlap of the `login.defs` UID and GID ranges. A confirmed-absent `/etc/login.defs` falls back to the same built-in 1000..60000 range shadow uses, because `useradd` itself relies on its compiled-in defaults on such a host; a file that exists but cannot be read, is oversized, or is not a regular file still fails closed, since it may be hiding a narrower configured range. `useradd -U -u ID -K GID_MIN=ID -K GID_MAX=ID` pins the account and private group to the same number, and the complete passwd identity is checked afterward. Once a v5 registry exists, a missing or corrupt high-water file fails closed instead of being reconstructed from only the surviving accounts and risking reuse of a retired identity. This guarantee covers this tool, an intact state directory, and the local account databases. Non-enumerable external NSS, another local root explicitly assigning IDs, or manual state-directory deletion remain trust boundaries, so this is not a claim of absolute system-wide non-reuse. -The account-database entry is created with `useradd -M -e 1970-01-01 -p '!'`, so it is expired to a past date and password-locked from the moment it appears, and it does not copy `/etc/skel`, which may contain host-local authentication material. A default generated username has a long random suffix and is paired with a UID/GID this tool has never reused. While the account is still credential-less, Home-less, and expired, the tool performs two process-termination and cron/at cleanup passes without a 65-second wait. An explicit `--user` may reuse a historical name and a daemon-cached name-keyed job, so that path still clears, waits one complete polling cycle, and clears again. Only after the repeated checks and complete identity verification pass does the tool create an empty mode-`0700` Home through a pinned `/home` directory descriptor and assign its owner. The account stays past-date expired throughout preparation. A final `chage` writes the requested expiry or never-expire value and activates login only after the password or key, sshd/sudo policy, registry state, and automatic revoke task are complete. +The account-database entry is created with `useradd -M -e 1970-01-02 -p '!'`, so it is expired to a past date and password-locked from the moment it appears, and it does not copy `/etc/skel`, which may contain host-local authentication material. A default generated username has a long random suffix and is paired with a UID/GID this tool has never reused. While the account is still credential-less, Home-less, and expired, the tool performs two process-termination and cron/at cleanup passes without a 65-second wait. An explicit `--user` may reuse a historical name and a daemon-cached name-keyed job, so that path still clears, waits one complete polling cycle, and clears again. Only after the repeated checks and complete identity verification pass does the tool create an empty mode-`0700` Home through a pinned `/home` directory descriptor and assign its owner. The account stays past-date expired throughout preparation. A final `chage` writes the requested expiry or never-expire value and activates login only after the password or key, sshd/sudo policy, registry state, and automatic revoke task are complete. The tool repeatedly rechecks passwd identity at critical transaction stages. The forward invite transaction still requires the complete passwd snapshot captured after `useradd` to remain byte-for-byte unchanged while old mail is cleared, Home is created, pending is promoted to managed, real groups are checked, and credentials are prepared; a failure before the final login-activation helper is invoked also rolls back against that complete snapshot. Once the activation helper starts, the tool treats it as potentially effective even if it reports an error. Rollback after that boundary, ordinary revoke, and deletion require a current fifth-field-witness account's name, UID, GID, Home, and exact `lta-m=`/`lta-p=` witness to remain unchanged while tolerating concurrent regular-user changes to the first four GECOS subfields and a nonempty shell, so a possibly logged-in invitee cannot hold access removal or final deletion open with a `chfn`/`chsh` loop. An older first-field-only account has no independent witness and still requires a byte-for-byte passwd snapshot match on those paths. The GECOS identity-finalization and account-deletion paths also verify identity after their name-scoped helpers return, detecting observable same-name replacement. These checks do not turn a helper into an atomic compare-and-swap; local root that can concurrently rewrite the account database remains inside the trust boundary. Account deletion invokes only `userdel --` without `-r/-f`, never a distro `deluser` that can read `/etc/deluser.conf` and re-enable recursive cleanup or an arbitrary BusyBox applet whose compile-time account-database semantics are unknown. Shadow-utils `-f` is also refused because it can delete a same-name group that another account still uses as its primary group. @@ -115,11 +115,13 @@ Missing-sequence recovery is a narrow boundary, not automatic reconstruction of A default random-name invite computes its requested lifetime after immediate deferred-job cleanup and no longer has a 65-second foreground wait. An explicit `--user` computes the lifetime after its synchronous name-reuse defense, so the safety wait does not shorten nominal access. The target is converted once into an absolute deadline rounded upward to a whole minute, adding less than one minute. Invite display, the `chage -E` backstop date, the systemd timer, and `at` are all derived from that target; `at` receives an absolute UTC minute so a daylight-saving transition cannot revoke access early. `chage -E` remains only a later, day-granularity lock fallback. `at` is attempted only when systemd is unavailable or its failed scheduling attempt was safely rolled back. Invite creation rolls back when neither backend can schedule successfully or the deadline has already arrived before scheduling. Scheduler load, host downtime, and revoke retries can delay actual removal, so access that is no longer needed should be revoked manually. +An at fallback is accepted only after the native init backend confirms both current daemon availability and persistent boot enablement. A standalone atd process without verifiable boot restoration is insufficient. Merely having systemctl installed does not prove that systemd manages this host; an uncertain or broken manager remains an error. Doctor compares the actual task deadline with the registry and permits only exact or upward-minute-rounded times. Historical timezone abbreviations that do not identify an absolute instant are reported as unverifiable. + The revoke task rechecks name, UID/GID, Home, the exact fifth-field generation witness, and the registry row. User-changeable GECOS/shell fields are excluded from the stable comparison for current-format accounts, while an old first-field-only account conservatively requires the complete passwd snapshot. An identity mismatch, missing registry, or recreated account is skipped safely for operator inspection. Failed systemd revokes use bounded retries; one-shot backend failures require `doctor` and manual action. ## Installation and upgrade trust boundary -The official installer and default official upgrades use the embedded ed25519 keyring to verify canonical `SHA256SUMS`, the current-architecture binary, its detached signature, architecture, and version as one release set. The official mirror is the preferred complete source. After a valid mirror index, only a transport failure discards the complete set and redownloads it from the same GitHub tag; a transport failure obtaining the index queries GitHub Latest. Manifest-semantic, checksum, signature, and candidate-version failures stop immediately. +The official installer and default official upgrades use the embedded ed25519 keyring to verify canonical `SHA256SUMS`, the current-architecture binary, its detached signature, architecture, and version as one release set. The official mirror is the preferred complete source. After a valid mirror index, only a transport failure discards the complete set and redownloads it from the same GitHub tag; a transport failure obtaining the index queries GitHub Latest. Manifest-semantic, checksum, signature, and candidate-version failures stop immediately. The scope of that binding is worth stating: the detached signature covers raw bytes only — no asset name and no architecture — and the amd64 and arm64 builds of one release carry a byte-identical version witness, while `SHA256SUMS`, the only artifact that binds a name to a digest, is unsigned. Both the installer and the upgrade path therefore read EI_CLASS/EI_DATA/e_machine straight out of the candidate's ELF header before installing it and require a match with this host's architecture, refusing another architecture's equally valid signed binary. A custom `upgrade --url` or `--url-file` verifies the operator-selected binary against its detached ed25519 signature and the embedded keyring, but does not fetch `SHA256SUMS`. The local `install` command copies the already-running inode without network access or an additional signature check; the operator must independently trust that binary first. Neither upgrade path has an unsigned fallback. diff --git a/docs/security-model.md b/docs/security-model.md index e1c8d06..a644025 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -55,7 +55,7 @@ 登记表旁的 root-only 高水位文件在每次 `useradd` 前先原子前进;即使后续创建或回滚失败,这个 UID/GID 也不会再由本工具分配。候选值高于当前本地 passwd/group 中普通账号范围内的全部 UID/GID,并限制在 `login.defs` 的 UID/GID 重叠范围内(`/etc/login.defs` 确认不存在时使用与 shadow 相同的内置 1000..60000 范围,因为 `useradd` 自身在这种主机上也退回编译期默认值;文件存在却无法读取、超限或不是普通文件仍然失败关闭,因为它可能正藏着一个更窄的配置范围);`useradd -U -u ID -K GID_MIN=ID -K GID_MAX=ID` 把账号和私有组固定为同一号码,返回后再核对完整 passwd 身份。v5 登记已经存在但高水位文件丢失或损坏时会失败关闭,不会根据现存账号重建并冒险复用已退休身份。这一保证只覆盖本工具、完整保留的状态目录和本地账号数据库;不可枚举的外部 NSS、另一个本机 root 手工指定号码或人工删除状态目录仍在信任边界内,不能表述为全系统绝对不复用。 -账号数据库项使用 `useradd -M -e 1970-01-01 -p '!'` 创建,因此从出现起就处于过去日期过期且密码锁定的状态,并且不会复制可能含有本机认证材料的 `/etc/skel`。默认自动生成的用户名具有长随机后缀,并与从未由本工具复用的 UID/GID 配对;程序在账号仍无凭据、无 Home 且保持过期时执行两轮进程终止和 cron/at 清理,不再等待 65 秒。显式 `--user` 可能重用历史名称及 daemon 已缓存的同名任务,因此仍会清场、等待完整轮询周期并再次清场。只有复查和完整身份核对通过后,程序才通过固定 `/home` 目录描述符创建权限为 `0700` 的空 Home 并设置其属主。准备期间账号一直保持过去日期过期;密码或公钥、sshd/sudo 策略、登记和自动撤销任务均完成后,最后一次 `chage` 才写入请求的到期日或永久值并激活登录。 +账号数据库项使用 `useradd -M -e 1970-01-02 -p '!'` 创建,因此从出现起就处于过去日期过期且密码锁定的状态,并且不会复制可能含有本机认证材料的 `/etc/skel`。默认自动生成的用户名具有长随机后缀,并与从未由本工具复用的 UID/GID 配对;程序在账号仍无凭据、无 Home 且保持过期时执行两轮进程终止和 cron/at 清理,不再等待 65 秒。显式 `--user` 可能重用历史名称及 daemon 已缓存的同名任务,因此仍会清场、等待完整轮询周期并再次清场。只有复查和完整身份核对通过后,程序才通过固定 `/home` 目录描述符创建权限为 `0700` 的空 Home 并设置其属主。准备期间账号一直保持过去日期过期;密码或公钥、sshd/sudo 策略、登记和自动撤销任务均完成后,最后一次 `chage` 才写入请求的到期日或永久值并激活登录。 程序会在关键事务阶段重复核对 passwd 身份。邀请的正向创建阶段仍要求 `useradd` 后捕获的完整 passwd 快照逐字节不变,包括清理旧邮件、创建 Home、pending 转 managed、核对真实用户组和安装凭据前的检查;最终登录激活 helper 调用前发生的失败也按该完整快照回滚。激活 helper 一旦开始,程序就按“可能已经生效后才报错”处理;此后的失败回滚与普通撤销、删除一样,对当前第五字段见证账号要求用户名、UID、GID、Home 和精确的 `lta-m=`/`lta-p=` 世代见证不变,同时允许普通用户可并发修改的首四个 GECOS 子字段及非空 shell 变化,避免可能已登录的邀请对象用 `chfn`/`chsh` 循环阻断清权或终删。旧单首字段账号没有独立见证,在这些路径中仍要求完整 passwd 快照不变。完成 GECOS 身份标记和账号删除的路径还会在按用户名操作的 helper 返回后复核,以发现可观察到的同名替换。这些复核不能把 helper 变成原子的比较并交换。能够并发改写账号数据库的本机 root 仍属于信任边界。账号删除只调用不带 `-r/-f` 的 `userdel --`,不执行会读取 `/etc/deluser.conf` 并可能重新递归清理的发行版 `deluser`,也不接受编译期账号数据库语义不明的任意 BusyBox applet;shadow-utils 的 `-f` 还可能删除仍被其他账号用作主组的同名组,因此也被拒绝。 @@ -115,11 +115,13 @@ invite、revoke、cleanup、install、upgrade 和 uninstall 的受管状态提 默认随机用户名的邀请在即时任务清场后计算有效期,不再经历 65 秒前台等待;显式 `--user` 的同步名称复用防护完成后才计算有效期,所以安全等待不会缩短名义访问时长。目标只转换一次为向上取整到整分钟的绝对截止时间,取整最多多不到一分钟。邀请显示、`chage -E` 兜底日期、systemd timer 和 `at` 都由这一目标生成;`at` 使用绝对 UTC 分钟,不会因夏令时跳变而提前撤销。`chage -E` 仍只是更晚、按天粒度的锁定兜底。systemd 不可用,或其排程失败且任务已安全回滚时,才尝试 `at`;任一后端都无法成功排程,或者排程前截止时间已到时,邀请创建会回滚。调度器忙碌、主机停机和撤销重试都可能让实际删除延后,因此不再需要的访问应手动立即撤销。 +只有原生 init 后端同时确认 atd 当前可用及已持久启用开机启动,才接受 at 回退;单独存在的 atd 进程不足以证明重启恢复能力。安装了 systemctl 不等于主机由 systemd 管理,但状态不明或 manager 故障仍按错误处理。doctor 会比较任务的真实期限与登记,仅允许精确匹配或向上整分钟取整;不能确定绝对时刻的历史时区缩写会报告为不可验证。 + 撤销任务再次验证用户名、UID/GID、Home、精确的第五字段世代见证和登记行;当前格式账号的用户可写 GECOS/shell 字段不参与稳定身份比较,旧单首字段账号则保守核对完整 passwd 快照。身份不匹配、登记丢失或账号已重建时会安全跳过,交由管理员检查。systemd 撤销失败会限速重试;一次性后端失败需要 `doctor` 和人工处理。 ## 安装与升级信任边界 -官方安装器和默认官方升级使用内置 ed25519 公钥 keyring,把规范 `SHA256SUMS`、当前架构二进制、detached 签名、架构和版本作为同一套发行文件验证。官方镜像是首选完整来源;取得有效镜像索引后,只有传输故障才丢弃整套文件并从同一 GitHub tag 重新下载。索引本身传输失败时查询 GitHub Latest;manifest 语义、校验和、签名或候选版本失败立即停止。 +官方安装器和默认官方升级使用内置 ed25519 公钥 keyring,把规范 `SHA256SUMS`、当前架构二进制、detached 签名、架构和版本作为同一套发行文件验证。需要说明这套绑定的边界:detached 签名只覆盖裸字节,不含资产名,也不含架构,而同一发行版的 amd64 与 arm64 构建携带逐字节相同的版本见证;唯一把文件名绑定到摘要的 `SHA256SUMS` 本身没有签名。因此安装器与升级路径都会在安装前直接读取候选二进制 ELF 头的 EI_CLASS/EI_DATA/e_machine,要求它与本机架构一致,拒绝被换成另一架构那份签名同样有效的二进制。官方镜像是首选完整来源;取得有效镜像索引后,只有传输故障才丢弃整套文件并从同一 GitHub tag 重新下载。索引本身传输失败时查询 GitHub Latest;manifest 语义、校验和、签名或候选版本失败立即停止。 自定义 `upgrade --url` 或 `--url-file` 会使用内置 keyring,根据 detached ed25519 签名验证管理员选定的二进制,但不下载 `SHA256SUMS`。本地 `install` 命令不联网,也不额外验签,只复制已经运行的 inode;管理员必须先独立信任该二进制。两种升级路径都不存在未验签回退。 diff --git a/go.mod b/go.mod index ddfe1a1..750a6a4 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/xxvcc/linux-temp-admin go 1.26.6 require ( - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.56.0 golang.org/x/term v0.45.0 ) diff --git a/go.sum b/go.sum index d29e079..9952466 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= diff --git a/internal/atqueue/atqueue.go b/internal/atqueue/atqueue.go index 8ca7b9b..d98acda 100644 --- a/internal/atqueue/atqueue.go +++ b/internal/atqueue/atqueue.go @@ -7,6 +7,7 @@ import ( "fmt" "strconv" "strings" + "time" ) // MaxJobs bounds a complete queue inventory before callers inspect each job. @@ -39,11 +40,38 @@ func ValidJobID(id string) bool { return true } +// Entry is one atq inventory line. ScheduledAt is the queued run time when atq +// printed one in the C-locale shape this tool pins; it stays zero for the +// implementations that print something else, because an unparsed column must not +// be mistaken for "no deadline". +type Entry struct { + ID string + ScheduledAt time.Time +} + // ParseInventory treats every non-empty atq line as inventory evidence. It // fails closed rather than returning a partial inventory when any line is // malformed, an ID is duplicated, or the queue exceeds MaxJobs. func ParseInventory(out []byte, maxTokenBytes int) ([]string, error) { - var ids []string + entries, err := ParseInventoryEntries(out, maxTokenBytes) + if err != nil { + return nil, err + } + if entries == nil { + return nil, nil + } + ids := make([]string, 0, len(entries)) + for _, e := range entries { + ids = append(ids, e.ID) + } + return ids, nil +} + +// ParseInventoryEntries is ParseInventory plus each job's queued run time, which +// callers need to tell a pending job from one whose deadline has already passed +// while atd was not running. +func ParseInventoryEntries(out []byte, maxTokenBytes int) ([]Entry, error) { + var ids []Entry seen := make(map[string]bool) scanner := bufio.NewScanner(bytes.NewReader(out)) scanner.Buffer(make([]byte, 1024), maxTokenBytes) @@ -63,7 +91,7 @@ func ParseInventory(out []byte, maxTokenBytes int) ([]string, error) { return nil, fmt.Errorf("parse atq line %d: duplicate job id %s", lineNo, id) } seen[id] = true - ids = append(ids, id) + ids = append(ids, Entry{ID: id, ScheduledAt: parseQueuedTime(fields)}) if len(ids) > MaxJobs { // Name what the operator can act on: the count spans every account's // jobs, so the blocker is usually not the account being cleaned up. @@ -76,6 +104,25 @@ func ParseInventory(out []byte, maxTokenBytes int) ([]string, error) { return ids, nil } +// parseQueuedTime reads the five-field date atq prints under the pinned C locale +// ("Fri Jul 24 00:00:00 2026"). It returns the zero time for any other shape: +// guessing a deadline is worse than admitting the queue did not state one. +// +// The instant is read as UTC, which is correct ONLY because the caller runs atq +// with TZ=UTC. Parsing in time.Local would read the child's output in this +// process's own zone — two different zones on any host whose $TZ differs from +// /etc/localtime, since helpers inherit no TZ. +func parseQueuedTime(fields []string) time.Time { + if len(fields) < 6 { + return time.Time{} + } + when, err := time.ParseInLocation("Mon Jan 2 15:04:05 2006", strings.Join(fields[1:6], " "), time.UTC) + if err != nil { + return time.Time{} + } + return when +} + // ParseOwner returns the UID from the first atrun-shaped owner header. The GID // is validated even though callers currently need only the UID. A malformed // first header is authoritative because later lines may be user-controlled. diff --git a/internal/atqueue/atqueue_test.go b/internal/atqueue/atqueue_test.go index 1702fdd..c977743 100644 --- a/internal/atqueue/atqueue_test.go +++ b/internal/atqueue/atqueue_test.go @@ -5,6 +5,7 @@ import ( "reflect" "strings" "testing" + "time" ) func TestValidJobID(t *testing.T) { @@ -122,3 +123,68 @@ func TestParseKernelID(t *testing.T) { } } } + +// ScheduledAt is produced in exactly one place, and it decides whether a queued +// job's deadline has passed. A silent regression here restores the pre-fix +// behaviour where a long-passed deadline reads as a healthy schedule, so the +// parse is pinned against real atq output shapes. +func TestParseInventoryEntriesReadsTheQueuedDeadline(t *testing.T) { + // atq under the pinned C locale, with TZ=UTC forced by the caller. %e + // space-pads a single-digit day, which strings.Fields collapses. + out := []byte("" + + "41\tFri Jul 3 00:00:00 2026 a root\n" + + "42\tTue Sep 15 12:34:56 2026 a root\n") + entries, err := ParseInventoryEntries(out, 64<<10) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("entries = %d, want 2", len(entries)) + } + want := []time.Time{ + time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC), + time.Date(2026, 9, 15, 12, 34, 56, 0, time.UTC), + } + for i, e := range entries { + if !e.ScheduledAt.Equal(want[i]) { + t.Fatalf("entry %d ScheduledAt = %s, want %s", i, e.ScheduledAt, want[i]) + } + if e.ScheduledAt.Location() != time.UTC { + t.Fatalf("entry %d parsed in %s, want UTC (the caller runs atq with TZ=UTC)", i, e.ScheduledAt.Location()) + } + } + + // ParseInventory keeps returning ids only, for the callers that want them. + ids, err := ParseInventory(out, 64<<10) + if err != nil || len(ids) != 2 || ids[0] != "41" || ids[1] != "42" { + t.Fatalf("ParseInventory = %v, %v; want the two ids", ids, err) + } +} + +// A shape this parser cannot read means "the queue did not state a deadline", +// never "the deadline passed" — the caller keys a stranded-schedule report on +// exactly that distinction. +func TestParseQueuedTimeReportsUnknownRatherThanGuessing(t *testing.T) { + for _, tc := range []struct { + name string + line string + }{ + {"no date column at all", "7 queued"}, + {"too few fields", "7 Fri Jul 3 2026"}, + {"non-C-locale month", "7 ven. juil. 3 00:00:00 2026 a root"}, + {"unparseable day", "7 Fri Jul XX 00:00:00 2026 a root"}, + } { + t.Run(tc.name, func(t *testing.T) { + entries, err := ParseInventoryEntries([]byte(tc.line+"\n"), 64<<10) + if err != nil { + t.Fatalf("ParseInventoryEntries = %v; an unreadable date must not fail the inventory", err) + } + if len(entries) != 1 { + t.Fatalf("entries = %d, want the job still inventoried", len(entries)) + } + if !entries[0].ScheduledAt.IsZero() { + t.Fatalf("ScheduledAt = %s, want the zero time (unknown), not a guess", entries[0].ScheduledAt) + } + }) + } +} diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 90e96f0..5b6b62b 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -18,6 +18,8 @@ import ( "io" "os" "path/filepath" + "strconv" + "strings" "syscall" "time" @@ -42,15 +44,21 @@ type Event struct { // record is the on-disk JSON shape. type record struct { - Time string `json:"time"` - PID int `json:"pid"` - Actor string `json:"actor"` - UID int `json:"uid"` - Action string `json:"action"` - Target string `json:"target,omitempty"` - Result string `json:"result"` - Detail string `json:"detail,omitempty"` - Fields map[string]string `json:"fields,omitempty"` + Time string `json:"time"` + PID int `json:"pid"` + Actor string `json:"actor"` + UID int `json:"uid"` + // LoginUID is the kernel's audit login uid for this process, omitted when the + // kernel does not provide one. Actor comes from SUDO_USER, which the invoking + // environment supplies and a root-equivalent caller can therefore choose; + // loginuid is set once per login session by PAM and is the value to reconcile + // against when an actor name is in question. + LoginUID *uint64 `json:"loginuid,omitempty"` + Action string `json:"action"` + Target string `json:"target,omitempty"` + Result string `json:"result"` + Detail string `json:"detail,omitempty"` + Fields map[string]string `json:"fields,omitempty"` } // Logger appends events to File, which must be a direct child of the absolute @@ -83,6 +91,26 @@ func realActor() (string, int) { return "root", euid } +// loginUID reads the kernel's audit login uid, the one identity in this record +// an invoking environment cannot simply assert. It is nil when the kernel does +// not expose it, or when it holds the unset sentinel (4294967295) — a value that +// says "no login session", not "uid 4294967295". +// loginUIDPath is a var so tests can supply the kernel's shapes, matching how +// this codebase makes its other /proc and /etc readers testable. +var loginUIDPath = "/proc/self/loginuid" + +func loginUID() *uint64 { + raw, err := os.ReadFile(loginUIDPath) + if err != nil { + return nil + } + value, err := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 32) + if err != nil || value == uint64(^uint32(0)) { + return nil + } + return &value +} + // Log appends one event. It is best-effort from the caller's perspective (it // 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 @@ -117,15 +145,16 @@ func (l *Logger) Log(ev Event) error { result = "ok" } line, err := json.Marshal(record{ - Time: now().UTC().Format(time.RFC3339), - PID: os.Getpid(), - Actor: actor, - UID: uid, - Action: ev.Action, - Target: ev.Target, - Result: result, - Detail: ev.Detail, - Fields: ev.Fields, + Time: now().UTC().Format(time.RFC3339), + PID: os.Getpid(), + Actor: actor, + UID: uid, + LoginUID: loginUID(), + Action: ev.Action, + Target: ev.Target, + Result: result, + Detail: ev.Detail, + Fields: ev.Fields, }) if err != nil { return err diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 0602874..0419dfc 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -603,3 +603,49 @@ func TestRealActor(t *testing.T) { t.Errorf("actor without SUDO_USER = %q, want root", a) } } + +// TestLoginUIDReadsTheKernelShapes pins the one identity in an audit record that +// the invoking environment cannot simply assert. Actor comes from SUDO_USER, +// which a root-equivalent caller chooses; loginuid is set once per login session +// by PAM. The unset sentinel means "no login session" and must not be recorded +// as a uid. +func TestLoginUIDReadsTheKernelShapes(t *testing.T) { + dir := t.TempDir() + old := loginUIDPath + t.Cleanup(func() { loginUIDPath = old }) + for _, tc := range []struct { + name string + content string + want *uint64 + }{ + {name: "a real login session", content: "1000\n", want: func() *uint64 { v := uint64(1000); return &v }()}, + {name: "root login", content: "0\n", want: func() *uint64 { v := uint64(0); return &v }()}, + {name: "uid above signed 32-bit range", content: "2147483648\n", want: func() *uint64 { v := uint64(2147483648); return &v }()}, + {name: "largest actual uid", content: "4294967294\n", want: func() *uint64 { v := uint64(4294967294); return &v }()}, + {name: "out of kernel uid range", content: "4294967296\n", want: nil}, + {name: "unset sentinel is not a uid", content: "4294967295\n", want: nil}, + {name: "unreadable value", content: "not-a-number\n", want: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(dir, "loginuid") + if err := os.WriteFile(path, []byte(tc.content), 0o600); err != nil { + t.Fatal(err) + } + loginUIDPath = path + got := loginUID() + switch { + case tc.want == nil && got != nil: + t.Fatalf("loginUID = %d, want nil for %q", *got, tc.content) + case tc.want != nil && got == nil: + t.Fatalf("loginUID = nil, want %d for %q", *tc.want, tc.content) + case tc.want != nil && *got != *tc.want: + t.Fatalf("loginUID = %d, want %d", *got, *tc.want) + } + }) + } + // An absent file is the ordinary case on a kernel without audit support. + loginUIDPath = filepath.Join(dir, "absent") + if got := loginUID(); got != nil { + t.Fatalf("loginUID = %d for an absent file, want nil", *got) + } +} diff --git a/internal/cli/accountdb_cleanup_test.go b/internal/cli/accountdb_cleanup_test.go index c31321f..5b9678b 100644 --- a/internal/cli/accountdb_cleanup_test.go +++ b/internal/cli/accountdb_cleanup_test.go @@ -29,7 +29,7 @@ func TestAbsentAccountDatabaseFailureStillCleansNameScopedArtifacts(t *testing.T InspectSameNameGroupState: func(string) (bool, error) { return false, wantDBErr }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, } scheduleCalls := 0 diff --git a/internal/cli/accountdb_wiring_root_test.go b/internal/cli/accountdb_wiring_root_test.go index dcd4ac1..49e7e34 100644 --- a/internal/cli/accountdb_wiring_root_test.go +++ b/internal/cli/accountdb_wiring_root_test.go @@ -90,7 +90,7 @@ func TestInviteReconcilesPartialUseraddAccountDatabaseState(t *testing.T) { NameInUse: func(string) (bool, error) { return false, nil }, InspectPrivateGroupState: func(_ string, gid int, _ bool) (bool, error) { return runner.groupPresent && gid == reservedID, nil }, InspectSameNameGroupState: func(string) (bool, error) { return runner.groupPresent, nil }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, ValidateManagedMailRoots: func() error { return nil }, PrepareManagedHome: func(string) error { return nil }, RemoveManagedMail: func(user.Passwd) error { return nil }, @@ -113,7 +113,7 @@ func TestInviteReconcilesPartialUseraddAccountDatabaseState(t *testing.T) { t.Fatalf("runInvite rc = %d, want original useradd failure", rc) } wantCalls := [][]string{ - {"useradd", "-M", "-d", "/home/" + username, "-s", resolveShell(), "-c", ",,,," + config.PendingGenerationGECOSWitnessPrefix + "0123456789abcdef0123456789abcdef", "-e", "1970-01-01", "-p", "!", "-U", "-u", "4000000", "-K", "GID_MIN=4000000", "-K", "GID_MAX=4000000", username}, + {"useradd", "-M", "-d", "/home/" + username, "-s", resolveShell(), "-c", ",,,," + config.PendingGenerationGECOSWitnessPrefix + "0123456789abcdef0123456789abcdef", "-e", "1970-01-02", "-p", "!", "-U", "-u", "4000000", "-K", "GID_MIN=4000000", "-K", "GID_MAX=4000000", username}, {"groupdel", "--", username}, } if !reflect.DeepEqual(runner.calls, wantCalls) { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index f0587b4..0516ff3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -98,9 +98,12 @@ type HostOperations struct { // RuntimeHooks contains process-local facilities that tests make deterministic. type RuntimeHooks struct { - Now func() time.Time - RandHex func(nBytes int) (string, error) - RandPassword func(nChars int) (string, error) + Now func() time.Time + RandHex func(nBytes int) (string, error) + // RandPassword yields the secret as bytes so the caller can clear it. A Go + // string cannot be zeroed, and this is the one credential the key path already + // takes that care with. + RandPassword func(nChars int) ([]byte, error) StdoutIsTTY func() bool StdinIsTTY func() bool TerminalWidth func() int @@ -134,6 +137,11 @@ type App struct { RuntimeHooks InstallPath string + // stableCommandReplaced records that this process overwrote the shared stable + // command at InstallPath. Nothing can restore the previous bytes, so a failed + // invite cannot roll it back — but the operator must be told it happened + // rather than be left believing the run left no trace. + stableCommandReplaced bool // StateDir and AuditLogDir are the paths an uninstall removes RECURSIVELY, so // they are fields for the same reason InstallPath is: a test that ran the // teardown against the constants would delete the real ones. CI runs the @@ -283,20 +291,23 @@ const passwordLen = 24 // randPassword returns a uniformly random password. Rejection sampling keeps the // distribution flat: taking a raw byte modulo 62 would quietly favour the first // few letters of the alphabet. -func randPassword(nChars int) (string, error) { +func randPassword(nChars int) ([]byte, error) { out := make([]byte, 0, nChars) buf := make([]byte, 1) const limit = 256 - (256 % len(passwordAlphabet)) // 248: the unbiased range for len(out) < nChars { if _, err := rand.Read(buf); err != nil { - return "", err + clear(out) + return nil, err } if int(buf[0]) >= limit { continue } out = append(out, passwordAlphabet[int(buf[0])%len(passwordAlphabet)]) } - return string(out), nil + // Returned as bytes on purpose: string(out) would mint an immutable copy that + // nothing can ever zero, and every later hop would copy it again. + return out, nil } // EnvLang overrides the language for one run without changing what is @@ -396,12 +407,34 @@ func askLang(rest []string) (lang i18n.Lang, ok, prompted bool) { return lang, ok, true } +// isUnattendedYesFlag reports whether arg is any spelling of the --yes boolean +// that Go's flag package accepts. The package treats one and two leading dashes +// identically and allows any unambiguous prefix of nothing — it matches the +// registered name exactly — but both "-yes" and "--y" reach the same flag here +// because the mutating subcommands register "yes" and "y" as separate booleans. +// Listing only some spellings let an unattended run stop on the first-run +// language prompt. +func isUnattendedYesFlag(arg string) bool { + name := strings.TrimPrefix(strings.TrimPrefix(arg, "-"), "-") + if name == arg { + return false // not a flag at all + } + if i := strings.IndexByte(name, '='); i >= 0 { + name = name[:i] + } + return name == "yes" || name == "y" +} + 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 + // An unattended run must not be stopped by a question. Go's flag package + // accepts --yes=true, -y=1 and so on for the boolean flags every mutating + // subcommand registers, so an exact token match let those forms reach the + // first-run language prompt and abort the run. + if isUnattendedYesFlag(arg) { return false } } diff --git a/internal/cli/cli_e2e_test.go b/internal/cli/cli_e2e_test.go index fc98b97..cc46f4f 100644 --- a/internal/cli/cli_e2e_test.go +++ b/internal/cli/cli_e2e_test.go @@ -46,6 +46,7 @@ func (fakeSched) ScheduleAt(string, time.Time) (string, error) { return "1", nil func (fakeSched) RemoveAtJobsFor(string) error { return nil } func (fakeSched) AtrmJob(string) error { return nil } func (fakeSched) AtJobs() ([]schedule.AtJob, error) { return nil, nil } +func (fakeSched) AtDaemonRunning() (bool, error) { return true, nil } type unavailableSched struct{} @@ -58,6 +59,7 @@ func (unavailableSched) ScheduleAt(string, time.Time) (string, error) { func (unavailableSched) RemoveAtJobsFor(string) error { return nil } func (unavailableSched) AtrmJob(string) error { return nil } func (unavailableSched) AtJobs() ([]schedule.AtJob, error) { return nil, nil } +func (unavailableSched) AtDaemonRunning() (bool, error) { return false, nil } type trackingSched struct { jobs map[string]string @@ -97,6 +99,8 @@ func (s *trackingSched) AtrmJob(id string) error { delete(s.jobs, id) return nil } +func (s *trackingSched) AtDaemonRunning() (bool, error) { return true, nil } + func (s *trackingSched) AtJobs() ([]schedule.AtJob, error) { jobs := make([]schedule.AtJob, 0, len(s.jobs)) for id, body := range s.jobs { diff --git a/internal/cli/cli_root_test.go b/internal/cli/cli_root_test.go index 72cdc31..e0ad626 100644 --- a/internal/cli/cli_root_test.go +++ b/internal/cli/cli_root_test.go @@ -225,10 +225,11 @@ func TestUpgradeDownloadDoesNotHoldLifecycleLock(t *testing.T) { a.InstallPath = installPath a.Lifecycle = lifecycle.New(lockPath) a.Selfmanage = &selfmanage.Manager{ - InstallPath: installPath, - PublicKey: pub, - Client: srv.Client(), - MaxBytes: config.MaxUpgradeBytes, + InstallPath: installPath, + RequireHostMachine: cliAllowAnyMachine, + PublicKey: pub, + Client: srv.Client(), + MaxBytes: config.MaxUpgradeBytes, } done := make(chan commandResult, 1) go func() { done <- a.upgradeResult([]string{"--url", srv.URL + "/bin", "--yes"}) }() @@ -316,10 +317,11 @@ func TestOfficialUpgradeMirrorFallbackBoundary(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, + InstallPath: filepath.Join(dir, "linux-temp-admin"), + RequireHostMachine: cliAllowAnyMachine, + PublicKey: pub, + MaxBytes: config.MaxUpgradeBytes, + RetryDelay: 0, } m.Client = &http.Client{Transport: cliRoundTripFunc(func(req *http.Request) (*http.Response, error) { mirrorBin := tc.mirrorBin @@ -356,7 +358,7 @@ func TestOfficialUpgradeMirrorFallbackBoundary(t *testing.T) { })} a, _, _ := newTestApp(t, "") a.Selfmanage = m - candidate, gotErr := a.prepareOfficialUpgrade() + candidate, gotErr := a.prepareOfficialUpgrade(false) if (gotErr != nil) != tc.wantErr { t.Fatalf("candidate=%v err=%v, wantErr=%v", candidate, gotErr, tc.wantErr) } @@ -465,3 +467,7 @@ func TestLanguagePreferencesCannotRecreateStateAfterUninstall(t *testing.T) { t.Fatalf("menu language switch recreated uninstalled state: %v", err) } } + +// cliAllowAnyMachine lets the shell-script upgrade fixtures past the ELF +// architecture gate. The gate itself is covered in internal/selfmanage. +func cliAllowAnyMachine([]byte) error { return nil } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index f455ef8..a5fbfc3 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -47,6 +47,8 @@ func (failingScheduleSystem) HasAt() bool { ret func (failingScheduleSystem) ScheduleAt(string, time.Time) (string, error) { return "", nil } func (failingScheduleSystem) RemoveAtJobsFor(string) error { return nil } func (failingScheduleSystem) AtrmJob(string) error { return nil } +func (failingScheduleSystem) AtDaemonRunning() (bool, error) { return true, nil } + func (failingScheduleSystem) AtJobs() ([]schedule.AtJob, error) { return nil, errors.New("at queue unreadable") } @@ -2893,3 +2895,48 @@ func TestAskLangInputDefaultsAndEOF(t *testing.T) { }) } } + +// TestShouldAskLangRecognisesEveryYesForm pins the unattended detection to the +// forms Go's flag package actually accepts for the boolean --yes/-y that every +// mutating subcommand registers. An exact token match let --yes=true reach the +// first-run language prompt, which stops the run. +func TestShouldAskLangRecognisesEveryYesForm(t *testing.T) { + for _, arg := range []string{"--yes", "-y", "--yes=true", "-y=true", "--yes=1", "-y=1", "--yes=false"} { + if shouldAskLang([]string{"invite", arg}, true, true, true) { + t.Errorf("shouldAskLang stopped an unattended run carrying %q", arg) + } + } + // A run with no such flag still gets the question. + if !shouldAskLang([]string{"invite"}, true, true, true) { + t.Error("shouldAskLang skipped the language question for an interactive run") + } + // And a flag that merely starts with the same letters must not disarm it. + if !shouldAskLang([]string{"invite", "--yesterday"}, true, true, true) { + t.Error("shouldAskLang treated an unrelated flag as an unattended marker") + } +} + +// TestRandPasswordYieldsClearableBytes pins the password to a form that can be +// zeroed. A Go string cannot be, and this is the only credential the invite +// issues besides the key, which the code already clears. +func TestRandPasswordYieldsClearableBytes(t *testing.T) { + pw, err := randPassword(24) + if err != nil { + t.Fatal(err) + } + if len(pw) != 24 { + t.Fatalf("randPassword returned %d bytes, want 24", len(pw)) + } + for _, b := range pw { + if !strings.ContainsRune(passwordAlphabet, rune(b)) { + t.Fatalf("password byte %q is outside the alphabet", b) + } + } + // The caller can destroy it, which is the whole point of the byte slice. + clear(pw) + for i, b := range pw { + if b != 0 { + t.Fatalf("byte %d survived clear()", i) + } + } +} diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 5da78fd..8a1cd9a 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -4,6 +4,7 @@ import ( "errors" "flag" "fmt" + "os" "sort" "strconv" "strings" @@ -12,6 +13,7 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/buildinfo" "github.com/xxvcc/linux-temp-admin/internal/config" + "github.com/xxvcc/linux-temp-admin/internal/expiry" "github.com/xxvcc/linux-temp-admin/internal/fsutil" "github.com/xxvcc/linux-temp-admin/internal/i18n" "github.com/xxvcc/linux-temp-admin/internal/prefs" @@ -725,6 +727,19 @@ func (a *App) compact() int { func (a *App) compactLocked() int { rc := 0 + // A grant this sweep could not enumerate or could not remove must keep its + // auto-revoke task: cancelling the task is what leaves an orphaned NOPASSWD + // drop-in with nothing left to remove it. + grantSweepIncomplete := false + grantStillPresent := map[string]bool{} + // For sudoers, disappearance of the file removes the grant. SSH exceptions + // additionally require a confirmed daemon reload; a failed Remove must keep + // its retry task even when the drop-in was already unlinked. + pinIfStillOnDisk := func(user, path string) { + if _, err := os.Lstat(path); err == nil || !os.IsNotExist(err) { + grantStillPresent[user] = true + } + } // Sweep the live grants BEFORE the registry rows: compacting drops the rows // that name these accounts, and a grant nobody can name any more is a grant // nobody will ever find. @@ -733,6 +748,7 @@ func (a *App) compactLocked() int { if err != nil { a.warnf("%v", err) rc = 1 + grantSweepIncomplete = true } for _, u := range orphans { if err := a.SSHD.Remove(u); err != nil { @@ -741,6 +757,7 @@ func (a *App) compactLocked() int { // that does not assert the removal failed. a.warnf("%s: %v", a.P.M("清理孤儿 sshd 例外时", "while cleaning up the orphaned sshd exception"), err) rc = 1 + grantStillPresent[u] = true continue } a.info(a.P.M("已移除孤儿 sshd 例外:"+a.SSHD.FilePath(u), @@ -755,6 +772,7 @@ func (a *App) compactLocked() int { if err != nil { a.warnf("%v", err) rc = 1 + grantSweepIncomplete = true } for _, u := range orphans { // Announce the removal only once it happened: this used to print "removed" @@ -764,6 +782,7 @@ func (a *App) compactLocked() int { a.errorf("%s: %v", a.P.M("无法移除孤儿 sudo 授权(该文件仍会在用户名被复用时立即生效,请手动删除)", "could not remove an orphaned sudo grant (it re-arms the instant its username is reused; delete it by hand)"), err) rc = 1 + pinIfStillOnDisk(u, a.Sudoers.FilePath(u)) continue } a.info(a.P.M("已移除孤儿 sudo 授权:"+a.Sudoers.FilePath(u), @@ -783,6 +802,15 @@ func (a *App) compactLocked() int { rc = 1 } for _, u := range orphans { + if grantSweepIncomplete || grantStillPresent[u] { + // Keep the task. It is the only remaining mechanism that would strip a + // grant this run could not confirm gone. + a.warnf("%s%s", a.P.M( + "保留自动删除任务,因为本次未能确认该账号的授权已清除:", + "keeping the auto-delete task because this run could not confirm the account's grants were removed: "), u) + rc = 1 + continue + } if err := a.Scheduler.Cancel(u, ""); err != nil { a.warnf("%s: %v", a.P.M("无法移除孤儿自动删除任务", "could not remove orphaned auto-delete task"), err) rc = 1 @@ -808,7 +836,7 @@ func (a *App) compactLocked() int { if a.Users == nil { return false, fmt.Errorf("verify absent account database for %s: user manager is unavailable", rec.User) } - if err := a.Users.VerifyAccountDatabaseAfterExternalDeletion(rec.User, rec.UID, rec.SequentialID); err != nil { + if err := a.Users.VerifyAccountDatabaseAfterExternalDeletion(rec.User, rec.UID, rec.UID, rec.SequentialID); err != nil { return false, fmt.Errorf("verify absent account database for %s: %w", rec.User, err) } return false, nil @@ -984,13 +1012,19 @@ func (a *App) doctorRegistryIdentity() (doctorRegistryState, doctorResult) { } state.records = records state.readable = true - if highest := a.Registry.LostRegistryHighest(); highest > 0 { - // CheckIntegrity cannot see this: by the time it runs, Init has already - // recreated the data file, and an empty registry trivially satisfies every - // sequence invariant. Only Init observes the absence, so it records it. + // CheckIntegrity cannot see this: once Init has recreated the data file, an + // empty registry trivially satisfies every sequence invariant. doctor runs in + // its own process and never calls Init, so it asks the store to observe the + // absence directly rather than reading a flag only invite and revoke can set. + lostHighest, registryLost, lostErr := a.Registry.InspectRegistryLoss() + if lostErr != nil { + a.warnf("%s: %v", a.P.M("无法判断登记表数据文件是否丢失", "cannot determine whether the registry data file was lost"), lostErr) + result.fail() + } + if registryLost { a.warnf("%s", a.P.M( - fmt.Sprintf("登记表数据文件缺失但身份序列已记录到 %d:这不是全新安装,本工具此前创建的账号已失去证明其归属的登记行。撤销和孤儿清扫都依赖该证据。请从可信备份恢复登记表,不要以当前这份空表继续运作。", highest), - fmt.Sprintf("the registry data file was missing while the identity sequence already recorded %d: this is not a fresh install, and every account this tool created before now has lost the row that proves it owns them. Revoke and the orphan sweeps rely on that evidence. Restore the registry from trusted backup rather than continuing on the empty one it had to recreate.", highest))) + fmt.Sprintf("登记表数据文件缺失但身份序列已记录先前的分配(最高 %d):这不是全新安装,本工具此前创建的账号已失去证明其归属的登记行。撤销和孤儿清扫都依赖该证据。请从可信备份恢复登记表,不要以当前这份空表继续运作。", lostHighest), + fmt.Sprintf("the registry data file was missing while the identity sequence already recorded prior allocations (highest %d): this is not a fresh install, and every account this tool created before now has lost the row that proves it owns them. Revoke and the orphan sweeps rely on that evidence. Restore the registry from trusted backup rather than continuing on the empty one it had to recreate.", lostHighest))) result.fail() } if integrityErr := a.Registry.CheckIntegrity(); integrityErr != nil { @@ -1215,7 +1249,13 @@ func (a *App) doctorScheduleValidity(state doctorRegistryState) doctorResult { if !r.AutoRevoke || !exists { continue } - valid, err := a.Scheduler.ValidSchedule(r.User, r.UID, r.Generation, r.AutoUnit) + deadline, parseErr := expiry.ParseDisplay(r.Expires) + if parseErr != nil { + a.warnf("%s %s: %v", a.P.M("无法验证自动删除到期时间:", "cannot verify auto-delete deadline:"), r.User, parseErr) + result.fail() + continue + } + valid, err := a.Scheduler.ValidSchedule(r.User, r.UID, r.Generation, r.AutoUnit, deadline) if err != nil { a.warnf("%s %s: %v", a.P.M("无法验证自动删除任务:", "cannot verify auto-delete task:"), r.User, err) result.fail() @@ -1227,8 +1267,8 @@ func (a *App) doctorScheduleValidity(state doctorRegistryState) doctorResult { } if len(strandedAuto) > 0 { for _, u := range strandedAuto { - 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%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, deadline, and body must all match; chage only provides a later, day-granularity lockout backstop): "), u) } a.warnf("%s", a.P.M("到期后请用 `linux-temp-admin revoke --user <名>` 手动删除。", "remove them with `linux-temp-admin revoke --user ` once expired.")) diff --git a/internal/cli/compact_sweep_test.go b/internal/cli/compact_sweep_test.go new file mode 100644 index 0000000..16d8016 --- /dev/null +++ b/internal/cli/compact_sweep_test.go @@ -0,0 +1,187 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/schedule" + "github.com/xxvcc/linux-temp-admin/internal/sshdconf" + "github.com/xxvcc/linux-temp-admin/internal/sudoers" +) + +// compactOrphanHost seeds a host that has exactly one orphaned auto-revoke task: +// a unit pair on disk for a username with no registry row and no account. That is +// what makes Scheduler.Orphans return a name and the sweep loop — the only place +// the keep-the-task guard lives — actually execute. +func compactOrphanHost(t *testing.T, user string) (*App, *strings.Builder, *recordingCanceller) { + t.Helper() + if os.Getuid() != 0 { + t.Skip("the registry state directory must be root-owned") + } + a, _, _ := newTestApp(t, "") + var errb strings.Builder + a.Err = &errb + if err := a.Registry.Init(); err != nil { + t.Fatal(err) + } + + systemdDir := t.TempDir() + sys := &recordingCanceller{} + a.Scheduler = &schedule.Scheduler{ + SystemdDir: systemdDir, + InstallPath: a.InstallPath, + UnitPrefix: "linux-temp-admin-v2-revoke-", + Now: a.Now, + Sys: sys, + } + for _, suffix := range []string{".service", ".timer"} { + path := filepath.Join(systemdDir, "linux-temp-admin-v2-revoke-"+user+suffix) + if err := os.WriteFile(path, []byte("# orphan\n"), 0o644); err != nil { + t.Fatal(err) + } + } + // Prove the premise: without it the sweep loop never runs and any assertion + // below would be vacuous. + orphans, err := a.Scheduler.Orphans(a.accountIsOursAndLive) + if err != nil { + t.Fatalf("seeding an orphan task: %v", err) + } + if len(orphans) != 1 || orphans[0] != user { + t.Fatalf("orphan sweep sees %v, want exactly [%s]; the guarded loop would not execute", orphans, user) + } + return a, &errb, sys +} + +// Cancelling an orphan's auto-revoke task is what makes a leftover NOPASSWD +// drop-in permanent: the task is the only remaining thing that would have removed +// it. So a run that could not enumerate or could not remove the grants must leave +// the task armed. +func TestCompactKeepsTheAutoRevokeTaskWhenGrantCleanupIsUnconfirmed(t *testing.T) { + const user = "xxvcc-orphan1" + + t.Run("the grant sweep could not enumerate", func(t *testing.T) { + a, errb, sys := compactOrphanHost(t, user) + // An unreadable sudoers directory: enumeration cannot answer at all. + a.Sudoers = &sudoers.Manager{Dir: a.Registry.File} + + rc := a.compactLocked() + + if len(sys.cancelled) != 0 { + t.Fatalf("cancelled %v while the grant sweep could not enumerate", sys.cancelled) + } + if got := errb.String(); !strings.Contains(got, "keeping the auto-delete task") || !strings.Contains(got, user) { + t.Fatalf("stderr = %q, want the keep-the-task line naming %s", got, user) + } + if rc == 0 { + t.Fatal("compact reported success though it could not confirm the grants were removed") + } + }) + + t.Run("a confirmed-clean sweep still cancels the orphan task", func(t *testing.T) { + a, errb, sys := compactOrphanHost(t, user) + // A readable, empty sudoers dir: enumeration succeeds and finds nothing. + a.Sudoers = &sudoers.Manager{Dir: t.TempDir()} + + a.compactLocked() + + if len(sys.cancelled) == 0 { + t.Fatalf("the orphan task was kept even though the grant sweep was clean; stderr=%q", errb.String()) + } + if got := errb.String(); strings.Contains(got, "keeping the auto-delete task") { + t.Fatalf("stderr = %q, want no keep-the-task line on a clean sweep", got) + } + }) +} + +// recordingCanceller reports a systemd host and records the units Cancel touches, +// so the test can tell "the task was cancelled" from "the task was kept". +type recordingCanceller struct { + revokeTestScheduleSystem + cancelled []string +} + +func (r *recordingCanceller) HasSystemctl() bool { return true } + +func (r *recordingCanceller) Systemctl(args ...string) error { + for _, a := range args { + if strings.HasPrefix(a, "linux-temp-admin-") { + r.cancelled = append(r.cancelled, a) + } + } + return nil +} + +func (r *recordingCanceller) ScheduleAt(string, time.Time) (string, error) { + return "", nil +} + +// A missing drop-in is not proof that a running daemon forgot its exception. +// Keep the retry until Remove confirms both validation and reload succeeded. +func TestCompactDistinguishesARemovedGrantFromASurvivingOne(t *testing.T) { + const user = "xxvcc-orphan2" + + t.Run("removed but not reloaded keeps the task until retry succeeds", func(t *testing.T) { + a, errb, sys := compactOrphanHost(t, user) + dir := t.TempDir() + reloadCalls := 0 + a.SSHD = &sshdconf.Manager{Dir: dir, Validate: func() error { return nil }, Reload: func() error { + reloadCalls++ + return sshdconf.ErrNoReloadMechanism + }} + a.Sudoers = &sudoers.Manager{Dir: t.TempDir()} + // The drop-in exists, so the sweep finds it and removes it; only the reload + // cannot be performed. + if err := os.WriteFile(a.SSHD.FilePath(user), []byte("Match User "+user+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + a.compactLocked() + + if _, err := os.Lstat(a.SSHD.FilePath(user)); !os.IsNotExist(err) { + t.Fatalf("the drop-in survived the sweep: %v", err) + } + if reloadCalls != 1 { + t.Fatalf("reload calls = %d, want 1", reloadCalls) + } + if _, err := os.Lstat(a.SSHD.FilePath(user) + ".remove-pending"); err != nil { + t.Fatalf("pending removal evidence missing: %v", err) + } + if len(sys.cancelled) != 0 { + t.Fatalf("cancelled %v before reload confirmation; stderr=%q", sys.cancelled, errb.String()) + } + a.SSHD.Reload = func() error { reloadCalls++; return nil } + if rc := a.compactLocked(); rc != 0 { + t.Fatalf("retry failed: %s", errb.String()) + } + if reloadCalls != 2 || len(sys.cancelled) == 0 { + t.Fatalf("successful retry did not reload and cancel: reloads=%d cancelled=%v", reloadCalls, sys.cancelled) + } + if _, err := os.Lstat(a.SSHD.FilePath(user) + ".remove-pending"); !os.IsNotExist(err) { + t.Fatalf("pending removal survived confirmed reload: %v", err) + } + }) + + t.Run("a drop-in that really survived pins the task", func(t *testing.T) { + a, errb, sys := compactOrphanHost(t, user) + a.SSHD = &sshdconf.Manager{Dir: t.TempDir(), Reload: func() error { return nil }} + a.Sudoers = &sudoers.Manager{Dir: t.TempDir()} + // A directory at the drop-in path: the sweep sees the managed name, its + // removal genuinely fails, and the path is still there afterwards. This is + // the one case that must still pin the task. + if err := os.Mkdir(a.SSHD.FilePath(user), 0o700); err != nil { + t.Fatal(err) + } + + a.compactLocked() + + if _, err := os.Lstat(a.SSHD.FilePath(user)); err != nil { + t.Fatalf("the artifact vanished, so this case no longer tests a survivor: %v", err) + } + if len(sys.cancelled) != 0 { + t.Fatalf("cancelled %v while the drop-in is still on disk; stderr=%q", sys.cancelled, errb.String()) + } + }) +} diff --git a/internal/cli/doctor_deadline_test.go b/internal/cli/doctor_deadline_test.go new file mode 100644 index 0000000..43fc176 --- /dev/null +++ b/internal/cli/doctor_deadline_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "strings" + "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/registry" + "github.com/xxvcc/linux-temp-admin/internal/schedule" + "github.com/xxvcc/linux-temp-admin/internal/user" +) + +type doctorDeadlineSystem struct { + failingScheduleSystem + jobs []schedule.AtJob +} + +func (s doctorDeadlineSystem) AtJobs() ([]schedule.AtJob, error) { return s.jobs, nil } + +func TestDoctorChecksRecordedRevokeDeadline(t *testing.T) { + const name = "xxvcc-a1" + const generation = "0123456789abcdef0123456789abcdef" + now := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name, expires string + queued time.Time + want int + }{ + {"matching", "2026-07-07 13:00:00 UTC", now.Add(time.Hour), 0}, + {"later job", "2026-07-07 13:00:00 UTC", now.Add(24 * time.Hour), 1}, + {"expired record but future job", "2026-07-07 11:00:00 UTC", now.Add(24 * time.Hour), 1}, + {"unknown legacy timezone", "2026-07-07 13:00:00 CST", now.Add(time.Hour), 1}, + } { + t.Run(tc.name, func(t *testing.T) { + a, _, errb := newTestApp(t, "") + a.LookupUser = func(string) (user.Passwd, bool, error) { return user.Passwd{Name: name, UID: 1001}, true, nil } + a.Scheduler = &schedule.Scheduler{InstallPath: "/usr/local/sbin/linux-temp-admin", Now: func() time.Time { return now }} + a.Scheduler.Sys = doctorDeadlineSystem{jobs: []schedule.AtJob{{ID: "57", OwnerUID: 0, ScheduledAt: tc.queued, Body: a.Scheduler.RevokeCommand(name, 1001, generation) + "\n"}}} + rec := registry.Record{User: name, Expires: tc.expires, AutoRevoke: true, AutoUnit: "at:57", UID: 1001, Generation: generation, IdentityBound: true} + result := a.doctorScheduleValidity(doctorRegistryState{readable: true, records: []registry.Record{rec}}) + if result.status() != tc.want { + t.Fatalf("status=%d want=%d diagnostics=%s", result.status(), tc.want, errb.String()) + } + if tc.want != 0 && !strings.Contains(errb.String(), name) { + t.Fatalf("missing account diagnostic: %s", errb.String()) + } + }) + } +} diff --git a/internal/cli/doctor_stages_test.go b/internal/cli/doctor_stages_test.go index 1cfa911..74612b1 100644 --- a/internal/cli/doctor_stages_test.go +++ b/internal/cli/doctor_stages_test.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "errors" "os" "path/filepath" @@ -70,3 +71,56 @@ func TestDoctorOrphanStageReportsSafeConfiguredSudoersDirectory(t *testing.T) { t.Fatalf("safe sudoers diagnostic did not report configured directory %q: %q", sudoersDir, got) } } + +// doctor runs in its own process and never calls Registry.Init, so a lost +// registry has to be observed from the host rather than from a flag only invite +// and revoke can set. What matters is the sentence the operator reads. +func TestDoctorReportsALostRegistryDataFileItNeverInitialized(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("root-owned identity-sequence metadata requires root") + } + newHost := func(t *testing.T, sequence string) (*App, *bytes.Buffer) { + t.Helper() + a, _, errb := newTestApp(t, "") + if sequence != "" { + if err := os.WriteFile(filepath.Join(a.Registry.Dir, "identity-sequence"), []byte(sequence), 0o600); err != nil { + t.Fatal(err) + } + } + return a, errb + } + + t.Run("sequence records prior allocations while the data file is gone", func(t *testing.T) { + a, errb := newHost(t, "# linux-temp-admin identity sequence v1\nhighest\t1500\nsafe-after\tnone\n") + _, result := a.doctorRegistryIdentity() + got := errb.String() + if !strings.Contains(got, "the registry data file was missing") || !strings.Contains(got, "1500") { + t.Fatalf("doctor output = %q, want the lost-registry warning naming the high-water mark", got) + } + if !strings.Contains(got, "Restore the registry from trusted backup") { + t.Fatalf("doctor output = %q, want the operator told what to do", got) + } + if result.status() == 0 { + t.Fatal("doctor exited 0 on a host whose registry rows are gone") + } + }) + + t.Run("a migrated v2 sequence proves prior use through its deadline alone", func(t *testing.T) { + a, errb := newHost(t, "# linux-temp-admin identity sequence v1\nhighest\t0\nsafe-after\t2026-08-01T12:01:05Z\n") + _, result := a.doctorRegistryIdentity() + if got := errb.String(); !strings.Contains(got, "the registry data file was missing") { + t.Fatalf("doctor output = %q, want the warning for a migrated-v2 host too", got) + } + if result.status() == 0 { + t.Fatal("doctor exited 0 on a migrated host whose registry rows are gone") + } + }) + + t.Run("a fresh install says nothing about a lost registry", func(t *testing.T) { + a, errb := newHost(t, "# linux-temp-admin identity sequence v1\nhighest\t0\nsafe-after\tnone\n") + a.doctorRegistryIdentity() + if got := errb.String(); strings.Contains(got, "the registry data file was missing") { + t.Fatalf("doctor output = %q, want no lost-registry warning on a fresh install", got) + } + }) +} diff --git a/internal/cli/invite.go b/internal/cli/invite.go index 8cadc8c..e4564d1 100644 --- a/internal/cli/invite.go +++ b/internal/cli/invite.go @@ -188,6 +188,16 @@ func (a *App) invite(args []string) int { a.errorf("%s", a.P.M("--yes 模式请显式传入 --host", "--yes mode requires an explicit --host")) return 1 } + if grantSudo == "yes" && fYes && generatedUsername { + // The confirmation names the account being granted root. With a generated + // name there is nothing to name yet: the old message interpolated this + // run's throwaway name, and the next run generated a different one, so an + // automated caller could never satisfy it. + a.errorf("%s", a.P.M( + "通过 --sudo --yes 授权必须显式指定 --user <名称> 并传入同名的 --confirm-sudo <名称>:随机生成的用户名无法事先确认", + "granting sudo via --sudo --yes requires an explicit --user NAME together with a matching --confirm-sudo NAME; a generated username cannot be confirmed in advance")) + return 1 + } if grantSudo == "yes" && fYes && *confirmSudo != username { a.errorf("%s", a.P.M("通过 --sudo --yes 授权需同时传入 --confirm-sudo "+username, "granting sudo via --sudo --yes also requires --confirm-sudo "+username)) @@ -930,23 +940,26 @@ type inviteTransaction struct { plan loginPlan generatedUsername bool - kp *sshkey.KeyPair - password string - generation string - fingerprint string - permanent bool - createdAt time.Time - revokeDeadline time.Time - expiresDisplay string - rec registry.Record - registered bool - pw user.Passwd - rollbackIdentity user.Passwd - groups []string - sshdDropIn string - sudoGranted bool - autoUnit string - autoScheduled bool + kp *sshkey.KeyPair + // password is the issued login secret for a --password-login invite, kept as + // bytes so rollback and the printer can clear it; a Go string could not be. + password []byte + generation string + fingerprint string + permanent bool + createdAt time.Time + revokeDeadline time.Time + expiresDisplay string + expiresServerLocal string + rec registry.Record + registered bool + pw user.Passwd + rollbackIdentity user.Passwd + groups []string + sshdDropIn string + sudoGranted bool + autoUnit string + autoScheduled bool identityIsolationReady bool sudoRemovalConfirmed bool @@ -978,6 +991,14 @@ func newInviteTransaction(a *App, username, host string, port, hours int, wantSu // available for the deferred-job cleanup policy. func (a *App) runInviteWithIdentityPolicy(username, host string, port, hours int, wantSudo, wantAuto bool, plan loginPlan, generatedUsername bool) int { tx := newInviteTransaction(a, username, host, port, hours, wantSudo, wantAuto, plan, generatedUsername) + // printInvite zeroes the credentials it renders, but nothing reached it on a + // failure path. The interactive menu keeps one long-lived root process, so a + // rolled-back invite left the unencrypted private key and the plaintext + // password live in the heap for the rest of the session — through later + // privileged actions and into any core dump, ptrace, swap or hibernation + // image. clear() on an already-zeroed slice is harmless, so this is safe + // alongside printInvite's own deferred clears. + defer tx.clearSecrets() if !tx.preflightAndGenerateCredentials() { return 1 } @@ -1043,11 +1064,12 @@ func (tx *inviteTransaction) preflightAndGenerateCredentials() bool { tx.expiresDisplay = a.P.M("永久(不会过期,也不会自动删除)", "never (does not expire or auto-delete)") if !tx.permanent { tx.revokeDeadline = expiry.Deadline(tx.createdAt, tx.hours) - tx.expiresDisplay = expiry.DisplayLocal(tx.revokeDeadline) + tx.expiresDisplay = expiry.Display(tx.revokeDeadline) + tx.expiresServerLocal = expiry.DisplayServerLocal(tx.revokeDeadline) } tx.rec = registry.Record{ User: tx.username, - Created: tx.createdAt.Format("2006-01-02 15:04:05 MST"), + Created: expiry.Display(tx.createdAt), Expires: tx.expiresDisplay, Sudo: tx.wantSudo, Host: tx.host, @@ -1077,8 +1099,28 @@ func (tx *inviteTransaction) confirmSSHDRemoved() error { return err } +// clearSecrets zeroes every credential this transaction generated. It is +// idempotent: printInvite clears the same backing arrays on the success path. +func (tx *inviteTransaction) clearSecrets() { + if tx == nil { + return + } + if tx.kp != nil { + clear(tx.kp.PrivatePEM) + } + clear(tx.password) +} + func (tx *inviteTransaction) rollback() error { tx.a.warnf("%s", tx.a.P.M("创建失败,正在回滚:"+tx.username, "creation failed; rolling back: "+tx.username)) + if tx.a.stableCommandReplaced { + // Not a cleanup step: the replaced bytes are gone. It belongs in the + // rollback report because "the invite was rolled back" otherwise reads as + // "the host is unchanged", and the root binary is not unchanged. + tx.a.warnf("%s", tx.a.P.M( + "注意:本次运行已替换 "+tx.a.InstallPath+" 上的稳定命令,回滚不会还原它。", + "note: this run already replaced the stable command at "+tx.a.InstallPath+"; the rollback does not restore it.")) + } var rollbackErrs []error for i := len(tx.cleanups) - 1; i >= 0; i-- { if err := tx.cleanups[i](); err != nil { @@ -1199,8 +1241,18 @@ func (tx *inviteTransaction) reserveAndCreatePendingIdentity() bool { // creation must leave a registry witness even if no sudo/sshd/schedule artifact // exists. The reserved UID is already known and is verified against passwd as // soon as the helper returns. - if err := a.Registry.Record(tx.rec); err != nil { - return tx.failf("%s: %v", a.P.M("登记账号创建意图失败", "recording account creation intent failed"), err) + // A DurabilityError means AtomicWriteFileAt already renamed the new registry + // into place and only the directory fsync failed: the row IS on disk while + // Record reports failure. Adopting it before failing is what lets rollback + // release it — bailing out here left a pending row that tx.registered = false + // and the unregistered cleanup below could never clean up. Every other module + // that writes through this primitive handles the same case explicitly. + recordErr := a.Registry.Record(tx.rec) + if recordErr != nil { + var committed *fsutil.DurabilityError + if !errors.As(recordErr, &committed) { + return tx.failf("%s: %v", a.P.M("登记账号创建意图失败", "recording account creation intent failed"), recordErr) + } } tx.registered = true // This cleanup was registered first, so reverse-order rollback runs it last, @@ -1241,6 +1293,13 @@ func (tx *inviteTransaction) reserveAndCreatePendingIdentity() bool { } return nil }) + if recordErr != nil { + // Committed but not durable. The row and its release cleanup are registered + // above, so rollback can now let it go; the transaction still fails, because + // a registry this tool cannot prove is on stable storage is not a witness it + // may rely on for anything that follows. + return tx.failf("%s: %v", a.P.M("登记账号创建意图失败", "recording account creation intent failed"), recordErr) + } // useradd can create the account before reporting an error. Close the // registry-removal gate before invoking it. CreatePendingIdentity returns a @@ -1377,10 +1436,11 @@ func (tx *inviteTransaction) drainAndFinalizeIdentity() bool { // otherwise that safety wait would shorten a nominal one-hour invite. Persist // the adjusted display and absolute target while the account is still pending. tx.createdAt = a.Now() - tx.rec.Created = tx.createdAt.Format("2006-01-02 15:04:05 MST") + tx.rec.Created = expiry.Display(tx.createdAt) if !tx.permanent { tx.revokeDeadline = expiry.Deadline(tx.createdAt, tx.hours) - tx.expiresDisplay = expiry.DisplayLocal(tx.revokeDeadline) + tx.expiresDisplay = expiry.Display(tx.revokeDeadline) + tx.expiresServerLocal = expiry.DisplayServerLocal(tx.revokeDeadline) tx.rec.Expires = tx.expiresDisplay } if err := a.Registry.Record(tx.rec); err != nil { @@ -1395,6 +1455,15 @@ func (tx *inviteTransaction) drainAndFinalizeIdentity() bool { completed := tx.rec completed.Pending = false if err := a.Registry.Record(completed); err != nil { + // Same committed-but-unsynced case: the completed row is already the one on + // disk. Keeping tx.rec on the superseded Pending shape would desynchronize + // this transaction from the registry, and persistDeletionStarted compares + // the recorded fields strictly — so invite's own rollback would abort and + // leave the live account behind. + var committed *fsutil.DurabilityError + if errors.As(err, &committed) { + tx.rec = completed + } return tx.failf("%s: %v", a.P.M("完成新账号身份登记失败", "finalizing the new account identity record failed"), err) } tx.rec = completed @@ -1571,7 +1640,7 @@ func (tx *inviteTransaction) scheduleActivateAndReport() int { if err := a.printInvite(inviteBundle{ user: tx.username, host: tx.host, port: tx.port, hours: tx.hours, sudo: tx.sudoGranted, auto: tx.autoScheduled, autoUnit: tx.autoUnit, - permanent: tx.permanent, expires: tx.expiresDisplay, + permanent: tx.permanent, expires: tx.expiresDisplay, expiresServerLocal: tx.expiresServerLocal, registered: tx.registered, kp: tx.kp, password: tx.password, sshdDropIn: tx.sshdDropIn, verified: tx.plan.verified, unverified: tx.plan.unverified, }); err != nil { @@ -1670,7 +1739,7 @@ func (a *App) rollbackInviteAccount(username string, rec registry.Record, expect return a.persistDeletionStarted(rec, true, expected) }, stillMatches, deleteExpected) if err != nil { - return fmt.Errorf("fail-closed account teardown stopped at stage %d: %w", stage, err) + return fmt.Errorf("fail-closed account teardown stopped at stage %s: %w", stage, err) } return nil } @@ -1694,13 +1763,16 @@ type inviteBundle struct { sudo, auto bool permanent bool expires string - autoUnit string - registered bool - kp *sshkey.KeyPair // nil for a password invite - password string // empty for a key invite - sshdDropIn string // empty when sshd was not touched - verified bool // the effective-config check completed without a blocker or unknown - unverified string // why it could not be confirmed; set exactly when verified is false + // expiresServerLocal is the same instant in the server's zone, shown only + // when the server is not already on UTC. + expiresServerLocal string + autoUnit string + registered bool + kp *sshkey.KeyPair // nil for a password invite + password []byte // nil for a key invite + sshdDropIn string // empty when sshd was not touched + verified bool // the effective-config check completed without a blocker or unknown + unverified string // why it could not be confirmed; set exactly when verified is false } func loginKind(p loginPlan) string { @@ -1713,7 +1785,7 @@ func loginKind(p loginPlan) string { // byPassword reports whether this invite's credential is a password. Exactly one // secret is ever issued, so a non-empty password is what distinguishes the two // kinds of invite. -func (b inviteBundle) byPassword() bool { return b.password != "" } +func (b inviteBundle) byPassword() bool { return len(b.password) != 0 } // loginLine renders the invite's Login: field. It is a computed value, never a // literal: the old invite asserted "SSH key only" on every host, including the @@ -1755,6 +1827,12 @@ func (a *App) printInvite(b inviteBundle) error { if b.kp != nil { defer clear(b.kp.PrivatePEM) } + // The password gets the same treatment the key already had. It is the only + // other credential this function renders, and it is equally unrecoverable once + // the invite is printed. + if len(b.password) != 0 { + defer clear(b.password) + } yesno := func(v bool) string { if v { return "yes" @@ -1771,7 +1849,7 @@ func (a *App) printInvite(b inviteBundle) error { Host: %s Port: %d User: %s -Expires: %s +Expires: %s%s Sudo: %s Login: %s Password login: %s @@ -1779,7 +1857,7 @@ Auto revoke: %s Auto revoke unit: %s Sshd exception: %s `, - b.host, b.port, b.user, b.expires, yesno(b.sudo), + b.host, b.port, b.user, b.expires, b.serverLocalSuffix(), yesno(b.sudo), b.loginLine(), passwordLine, yesno(b.auto), orNone(b.autoUnit), orNone(b.sshdDropIn)) // The credential only. The SSH login command that used to sit here was dropped: @@ -1791,11 +1869,15 @@ Sshd exception: %s } else { fmt.Fprintf(&out, ` %s +( +umask 077 +set -C +[ ! -e './%s.key' ] && [ ! -L './%s.key' ] || exit 1 cat > './%s.key' <<'EOF_KEY' %sEOF_KEY -chmod 600 './%s.key' +) `, - a.P.M("保存私钥命令:", "Save private key command:"), b.user, b.kp.PrivatePEM, b.user) + a.P.M("保存私钥命令:", "Save private key command:"), b.user, b.user, b.user, b.kp.PrivatePEM) } if b.sshdDropIn != "" { @@ -1870,10 +1952,21 @@ func (a *App) ensureStableInstalled() error { } force := false installed, versionErr := a.Selfmanage.InstalledVersion() + // A readable version means a command is already at InstallPath; only then can + // a write displace something. + hadInstalledBinary := versionErr == nil 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. + // + // Say so. This replaces the signed release at a shared root path that every + // other account's auto-revoke timer already points at, and it used to happen + // with no line on screen and no audit record. + a.warnf("%s", fmt.Sprintf(a.P.M( + "当前运行的是开发构建 %s,将用它覆盖已安装的 %s(路径 %s):该路径上的稳定命令由其他账号的自动撤销任务共用。", + "this is development build %s and it will replace the installed %s at %s: that stable command is shared by every other account's auto-revoke task."), + buildinfo.Version, installed, a.InstallPath)) force = true } else if !version.Greater(buildinfo.Version, installed) { return nil @@ -1890,9 +1983,26 @@ func (a *App) ensureStableInstalled() error { if err != nil { return err } - if _, err = a.Selfmanage.Install(bin, force); err != nil { + replaced, err := a.Selfmanage.Install(bin, force) + if err != nil { return err } + if replaced { + // installLocked records every other path that writes this file; a silent + // write here left no trace that the root binary had changed. + // + // Install reports true for three different things: a first-ever install, a + // metadata-normalising rewrite of identical bytes, and a genuine content + // replacement. Only the last one displaced a command other accounts' timers + // already point at, so only that one may claim a replacement or put a + // not-restored note in the rollback report. + if hadInstalledBinary { + a.audit("install", "", "ok", fmt.Sprintf("stable command replaced with %s while preparing an account action", buildinfo.Version), nil) + a.stableCommandReplaced = true + } else { + a.audit("install", "", "ok", fmt.Sprintf("stable command installed at %s while preparing an account action", buildinfo.Version), nil) + } + } installed, err = a.Selfmanage.InstalledVersion() if err != nil { return fmt.Errorf("verify installed command version: %w", err) @@ -1956,3 +2066,14 @@ func (a *App) promptHost(detected string) string { } return detected } + +// serverLocalSuffix appends the server's own rendering of the deadline after the +// authoritative UTC one. The recipient acts on Expires, so the UTC instant leads; +// the operator reading the same bundle still gets the local time, with a numeric +// offset rather than a zone abbreviation two regions can both claim. +func (b inviteBundle) serverLocalSuffix() string { + if b.permanent || b.expiresServerLocal == "" { + return "" + } + return " (server local " + b.expiresServerLocal + ")" +} diff --git a/internal/cli/invite_flags_test.go b/internal/cli/invite_flags_test.go new file mode 100644 index 0000000..09e5d7a --- /dev/null +++ b/internal/cli/invite_flags_test.go @@ -0,0 +1,122 @@ +package cli + +import ( + "errors" + "os" + "strings" + "testing" + + "github.com/xxvcc/linux-temp-admin/internal/sshkey" +) + +// `--sudo --yes` without `--user` used to fail with a message naming the +// throwaway username this run had just generated. The next run generated a +// different one, so an operator automating "create a sudo temp admin" looped +// forever on an instruction that could never be satisfied. +func TestSudoYesWithoutAnExplicitUserNamesTheRealRequirement(t *testing.T) { + a, _, errb := newTestApp(t, "") + + rc := a.invite([]string{"--host", "203.0.113.5", "--hours", "24", "--sudo", "--yes", + "--allow-non-tty-private-key-output"}) + if rc == 0 { + t.Fatal("invite --sudo --yes without --user succeeded; it cannot confirm a generated name") + } + got := errb.String() + if !strings.Contains(got, "requires an explicit --user") { + t.Fatalf("stderr = %q, want the message to name --user as the requirement", got) + } + if strings.Contains(got, "xxvcc-") { + t.Fatalf("stderr = %q, want no generated username the operator can never reuse", got) + } +} + +// A rolled-back invite must not leave the credentials it generated live in the +// process. The interactive menu keeps one root process for the whole session, so +// anything left here survives into every later action, and into swap or a core +// dump. +func TestFailedInviteZeroesTheCredentialsItGenerated(t *testing.T) { + a, _, _ := newTestApp(t, "") + tx := newInviteTransaction(a, "xxvcc-a1", "203.0.113.5", 22, 24, false, false, loginPlan{verified: true}, false) + + key := []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n") + tx.kp = &sshkey.KeyPair{PrivatePEM: key} + password := []byte("correct horse battery staple") + tx.password = password + + tx.clearSecrets() + + for _, b := range key { + if b != 0 { + t.Fatalf("private key still in memory after clearSecrets: %q", key) + } + } + for _, b := range password { + if b != 0 { + t.Fatalf("password still in memory after clearSecrets: %q", password) + } + } + + // Idempotent: printInvite clears the same arrays on the success path. + tx.clearSecrets() +} + +// Go's flag package accepts several spellings of the same boolean. Any of them +// means "unattended", so none of them may reach the first-run language prompt — +// a prompt nobody is there to answer aborts the run. +func TestEveryYesSpellingSuppressesTheFirstRunLanguagePrompt(t *testing.T) { + for _, arg := range []string{ + "--yes", "-yes", "--y", "-y", + "--yes=true", "-yes=true", "--y=1", "-y=1", "--yes=false", "-y=0", + } { + t.Run(arg, func(t *testing.T) { + if shouldAskLang([]string{"revoke", "--user", "xxvcc-a1", arg}, true, true, true) { + t.Fatalf("shouldAskLang with %q = true, want an unattended run left alone", arg) + } + }) + } + + // Only the boolean itself: a value that happens to look like one must not + // silently suppress the prompt for an interactive operator. + for _, arg := range []string{"yes", "--yesterday", "--user", "-yy", "--dry-yes"} { + t.Run("not a yes flag: "+arg, func(t *testing.T) { + if !shouldAskLang([]string{"revoke", arg}, true, true, true) { + t.Fatalf("shouldAskLang with %q = false, want the prompt still offered", arg) + } + }) + } +} + +// The fix for the credential-leak finding is one line — `defer tx.clearSecrets()` +// in runInviteWithIdentityPolicy. A test that calls clearSecrets directly guards +// the helper, not the wiring: move, reorder or drop the defer and the leak comes +// back with the suite green. This drives a real failing invite and inspects the +// bytes the generator handed out. +func TestRolledBackInviteLeavesNoCredentialBytesInMemory(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("Registry.Init writes root-owned state and requires root") + } + a, _, _ := newTestApp(t, "") + + issued := []byte("correct horse battery staple") + a.RandPassword = func(int) ([]byte, error) { return issued, nil } + // Fail immediately after the credential exists, so the run aborts on a path + // that reaches the deferred clear and nothing else. + a.RandHex = func(n int) (string, error) { + if n == 16 { + return "", errors.New("injected generation failure") + } + return "abcdef0123", nil + } + + rc := a.runInviteWithIdentityPolicy("xxvcc-a1", "203.0.113.5", 22, 24, false, false, + loginPlan{password: true, verified: true}, false) + if rc == 0 { + t.Fatal("the invite was expected to fail at generation") + } + + for i, b := range issued { + if b != 0 { + t.Fatalf("the issued password is still in memory after a failed invite (byte %d = %q): %q", i, b, issued) + } + } +} diff --git a/internal/cli/invite_login_verdict_test.go b/internal/cli/invite_login_verdict_test.go new file mode 100644 index 0000000..1a583c5 --- /dev/null +++ b/internal/cli/invite_login_verdict_test.go @@ -0,0 +1,148 @@ +package cli + +import ( + "strings" + "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/expiry" + "github.com/xxvcc/linux-temp-admin/internal/sshkey" +) + +// The Login: field is the only thing telling the operator whether this key will +// actually be accepted, and it is the basis for shipping the bundle to a +// collaborator. Nothing exercised the UNVERIFIED branch: a change that made every +// invite claim "verified" passed the entire suite. +func TestInviteRendersTheLoginVerdictItWasGiven(t *testing.T) { + render := func(t *testing.T, b inviteBundle) string { + t.Helper() + a, out, _ := newTestApp(t, "") + b.user, b.host, b.port = "xxvcc-a1", "203.0.113.5", 22 + b.expires = "2026-07-08 04:00:00 UTC" + if b.kp == nil && !b.byPassword() { + b.kp = &sshkey.KeyPair{PrivatePEM: []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nx\n")} + } + if err := a.printInvite(b); err != nil { + t.Fatalf("printInvite: %v", err) + } + return out.String() + } + + const verifiedText = "verified against the effective sshd config" + + t.Run("an unverified key invite says so, and does not claim verification", func(t *testing.T) { + got := render(t, inviteBundle{verified: false, unverified: "sshd has a Match Address block"}) + if !strings.Contains(got, "Login: SSH key only (UNVERIFIED: sshd has a Match Address block)") { + t.Fatalf("invite = %q, want the UNVERIFIED verdict and its reason", got) + } + if strings.Contains(got, "Login: SSH key only ("+verifiedText) { + t.Fatalf("invite = %q, want no verified claim on an unverified invite", got) + } + }) + + t.Run("an unverified invite with no reason still says UNVERIFIED", func(t *testing.T) { + got := render(t, inviteBundle{verified: false}) + if !strings.Contains(got, "UNVERIFIED: the effective sshd config could not be read") { + t.Fatalf("invite = %q, want the fallback reason", got) + } + }) + + t.Run("a verified key invite says verified", func(t *testing.T) { + got := render(t, inviteBundle{verified: true}) + if !strings.Contains(got, "Login: SSH key only ("+verifiedText+")") { + t.Fatalf("invite = %q, want the verified verdict", got) + } + if strings.Contains(got, "UNVERIFIED") { + t.Fatalf("invite = %q, want no UNVERIFIED text", got) + } + }) + + t.Run("a password invite carries the same verdict distinction", func(t *testing.T) { + unverified := render(t, inviteBundle{password: []byte("correct horse"), unverified: "sshd has a Match Group"}) + if !strings.Contains(unverified, "Login: password (UNVERIFIED: sshd has a Match Group)") { + t.Fatalf("password invite = %q, want the UNVERIFIED verdict", unverified) + } + verified := render(t, inviteBundle{password: []byte("correct horse"), verified: true}) + if !strings.Contains(verified, "Login: password ("+verifiedText+")") { + t.Fatalf("password invite = %q, want the verified verdict", verified) + } + }) +} + +// loginSummary is the same verdict shown before the operator types YES. It has +// to agree with the bundle: approving a "verified" invite that prints +// UNVERIFIED, or the reverse, is the failure this pins. +func TestLoginSummaryStatesTheSameVerdictAsTheBundle(t *testing.T) { + a, _, _ := newTestApp(t, "") + + if got := a.loginSummary(loginPlan{verified: true}, "xxvcc-a1"); !strings.Contains(got, "verified against the effective sshd config") { + t.Fatalf("verified summary = %q", got) + } + got := a.loginSummary(loginPlan{unverified: "sshd has a Match Address block"}, "xxvcc-a1") + if !strings.Contains(got, "UNVERIFIED: sshd has a Match Address block") { + t.Fatalf("unverified summary = %q, want the reason the operator must weigh", got) + } + if strings.Contains(got, "(verified") { + t.Fatalf("unverified summary = %q, want no verified claim", got) + } + if pw := a.loginSummary(loginPlan{password: true}, "xxvcc-a1"); !strings.Contains(pw, "weakest grant") { + t.Fatalf("password summary = %q", pw) + } +} + +// The Expires line is what the recipient acts on. It used to carry an ambiguous +// zone abbreviation ("CST"), which a reader in another region resolves to a +// different instant — in the unsafe direction. The bundle now leads with the UTC +// instant the scheduler actually fires on, and adds the server's own reading with +// a numeric offset. +func TestInviteBundleExpiresIsUnambiguous(t *testing.T) { + shanghai, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Skipf("tzdata unavailable: %v", err) + } + deadline := time.Date(2026, 7, 8, 12, 0, 0, 0, shanghai) + + render := func(t *testing.T, b inviteBundle) string { + t.Helper() + a, out, _ := newTestApp(t, "") + b.user, b.host, b.port = "xxvcc-a1", "203.0.113.5", 22 + b.kp = &sshkey.KeyPair{PrivatePEM: []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nx\n")} + if err := a.printInvite(b); err != nil { + t.Fatalf("printInvite: %v", err) + } + return out.String() + } + + got := render(t, inviteBundle{ + verified: true, + expires: expiry.Display(deadline), + expiresServerLocal: expiry.DisplayServerLocal(deadline), + }) + if !strings.Contains(got, "Expires: 2026-07-08 04:00:00 UTC") { + t.Fatalf("invite = %q, want the UTC instant the timer fires on", got) + } + if !strings.Contains(got, "(server local 2026-07-08 12:00:00 +0800)") { + t.Fatalf("invite = %q, want the server-local reading with a numeric offset", got) + } + for _, ambiguous := range []string{" CST", " MST", " EST", " IST"} { + if strings.Contains(got, ambiguous) { + t.Fatalf("invite = %q, want no zone abbreviation anywhere", got) + } + } + + // A UTC server must not print the same instant twice. + utc := render(t, inviteBundle{ + verified: true, + expires: expiry.Display(deadline.UTC()), + expiresServerLocal: expiry.DisplayServerLocal(deadline.UTC()), + }) + if strings.Contains(utc, "server local") { + t.Fatalf("invite on a UTC server = %q, want no redundant server-local suffix", utc) + } + + // A permanent invite has no deadline to qualify. + perm := render(t, inviteBundle{verified: true, permanent: true, expires: "never (does not expire or auto-delete)"}) + if strings.Contains(perm, "server local") { + t.Fatalf("permanent invite = %q, want no server-local suffix", perm) + } +} diff --git a/internal/cli/invite_reuse_root_test.go b/internal/cli/invite_reuse_root_test.go index f3094ab..a885a86 100644 --- a/internal/cli/invite_reuse_root_test.go +++ b/internal/cli/invite_reuse_root_test.go @@ -85,7 +85,7 @@ func inviteApp(t *testing.T) (*cli.App, *sudoers.Manager, *sshdconf.Manager, str } return "abcdef0123", nil }, - RandPassword: func(int) (string, error) { return "pw-abcdefgh", nil }, + RandPassword: func(int) ([]byte, error) { return []byte("pw-abcdefgh"), nil }, StdoutIsTTY: func() bool { return true }, StdinIsTTY: func() bool { return false }, Geteuid: func() int { return 0 }, @@ -1062,7 +1062,7 @@ type expiryFailRunner struct { func (r expiryFailRunner) Run(name string, args ...string) error { if !*r.failed && name == "chage" && len(args) >= 2 && args[0] == "-E" && - args[1] != "-1" && args[1] != "1970-01-01" { + args[1] != "-1" && args[1] != "1970-01-02" { *r.failed = true return errors.New("injected expiry failure") } diff --git a/internal/cli/invite_save_command_test.go b/internal/cli/invite_save_command_test.go new file mode 100644 index 0000000..893fc67 --- /dev/null +++ b/internal/cli/invite_save_command_test.go @@ -0,0 +1,99 @@ +package cli + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/sshkey" +) + +func TestInviteSaveCommandCreatesPrivateFileWithoutClobber(t *testing.T) { + const dummy = "dummy test credential\n" + a, out, _ := newTestApp(t, "") + if err := a.printInvite(inviteBundle{user: "xxvcc-save", kp: &sshkey.KeyPair{PrivatePEM: []byte(dummy)}}); err != nil { + t.Fatal(err) + } + rendered := out.String() + start := strings.Index(rendered, "\n(\n") + end := strings.Index(rendered, "\n)\n") + if start < 0 || end <= start { + t.Fatalf("save command missing: %s", rendered) + } + script := "umask 022\n" + rendered[start:end+3] + for _, shell := range []string{"sh", "bash"} { + if _, err := exec.LookPath(shell); err != nil { + t.Fatal(err) + } + for _, kind := range []string{"new", "file", "symlink", "dangling symlink", "fifo"} { + t.Run(shell+"/"+kind, func(t *testing.T) { + dir := t.TempDir() + key := filepath.Join(dir, "xxvcc-save.key") + target := filepath.Join(dir, "target") + const old = "existing data\n" + switch kind { + case "file": + if err := os.WriteFile(key, []byte(old), 0o644); err != nil { + t.Fatal(err) + } + case "symlink": + if err := os.WriteFile(target, []byte(old), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, key); err != nil { + t.Fatal(err) + } + case "dangling symlink": + if err := os.Symlink(target, key); err != nil { + t.Fatal(err) + } + case "fifo": + if err := syscall.Mkfifo(key, 0o600); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, shell, "-c", script) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if kind == "new" { + if err != nil { + t.Fatalf("save: %v: %s", err, output) + } + info, err := os.Stat(key) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode=%o", info.Mode().Perm()) + } + data, err := os.ReadFile(key) + if err != nil || string(data) != dummy { + t.Fatalf("data=%q err=%v", data, err) + } + } else { + if err == nil || ctx.Err() != nil { + t.Fatalf("existing %s must fail promptly: err=%v context=%v", kind, err, ctx.Err()) + } + switch kind { + case "file", "symlink": + data, err := os.ReadFile(key) + if err != nil || string(data) != old { + t.Fatalf("existing content changed: %q %v", data, err) + } + case "dangling symlink": + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("created dangling target: %v", err) + } + } + } + }) + } + } +} diff --git a/internal/cli/manage_root_test.go b/internal/cli/manage_root_test.go index 5324fdb..79bc5ef 100644 --- a/internal/cli/manage_root_test.go +++ b/internal/cli/manage_root_test.go @@ -34,6 +34,7 @@ func (fakeSys) ScheduleAt(string, time.Time) (string, error) { return "", nil } func (fakeSys) RemoveAtJobsFor(string) error { return nil } func (fakeSys) AtrmJob(string) error { return nil } func (fakeSys) AtJobs() ([]schedule.AtJob, error) { return nil, nil } +func (fakeSys) AtDaemonRunning() (bool, error) { return true, nil } type failedCreateRunner struct{} @@ -121,7 +122,7 @@ func (r *inviteTimingRunner) Run(name string, args ...string) error { return fmt.Errorf("unexpected chage arguments: %v", args) } *r.events = append(*r.events, "expiry:"+args[1]) - if args[1] != "1970-01-01" && (r.activationMutation != nil || r.activationErr != nil) { + if args[1] != "1970-01-02" && (r.activationMutation != nil || r.activationErr != nil) { if r.activationMutation != nil { r.activationMutation(&r.account) } @@ -196,7 +197,7 @@ func TestRunInviteReleasesIntentWhenCreatePreflightFails(t *testing.T) { a.Users = &user.Manager{ Runner: failedCreateRunner{}, InspectPrivateGroupState: func(string, int, bool) (bool, error) { return false, nil }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, ValidateManagedMailRoots: func() error { return wantErr }, PrepareManagedHome: func(string) error { t.Fatal("managed Home preflight ran after mail-root preflight failed") @@ -260,7 +261,7 @@ func TestRunInviteRetainsPendingRegistryWhenCreateHelperReportsFailure(t *testin PrepareManagedHome: func(string) error { return nil }, CreateManagedHome: func(user.Passwd) error { return nil }, InspectPrivateGroupState: func(string, int, bool) (bool, error) { return false, nil }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, } a.LookupUser = a.Users.LookupUser a.IdentityAllocationRange = func() (int, int, error) { return 4242, 4242, nil } @@ -294,10 +295,10 @@ func TestRunInviteRetainsPendingRegistryWhenCreateHelperReportsFailure(t *testin if clockCalls != 1 { t.Fatalf("invite transaction read its creation clock %d times, want once", clockCalls) } - if got, want := rec.Created, createdAt.Format("2006-01-02 15:04:05 MST"); got != want { + if got, want := rec.Created, expiry.Display(createdAt); got != want { t.Fatalf("recorded creation = %q, want %q", got, want) } - if got, want := rec.Expires, expiry.DisplayLocal(expiry.Deadline(createdAt, 1)); got != want { + if got, want := rec.Expires, expiry.Display(expiry.Deadline(createdAt, 1)); got != want { t.Fatalf("recorded deadline = %q, want %q", got, want) } if !strings.Contains(errb.String(), "account artifact cleanup is unconfirmed") { @@ -414,7 +415,7 @@ func TestRunInviteClearsStaleJobsBeforeCredentialAndRebasesLifetime(t *testing.T } return generation, nil } - a.RandPassword = func(int) (string, error) { return "password-for-timing-test", nil } + a.RandPassword = func(int) ([]byte, error) { return []byte("password-for-timing-test"), nil } sshdConfig := sysinfo.ParseSSHD("passwordauthentication yes\n") a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { return sshdConfig, nil } @@ -427,16 +428,16 @@ func TestRunInviteClearsStaleJobsBeforeCredentialAndRebasesLifetime(t *testing.T if runner.recordErr != nil || !runner.recordFound { t.Fatalf("registry row at credential: found=%v err=%v", runner.recordFound, runner.recordErr) } - if got, want := strings.Join(runner.eventsBeforeCredential, ","), "mail,expiry:1970-01-01,password-lock,kill,clear,drain,kill,clear,mail,home,home-validate"; got != want { + if got, want := strings.Join(runner.eventsBeforeCredential, ","), "mail,expiry:1970-01-02,password-lock,kill,clear,drain,kill,clear,mail,home,home-validate"; got != want { t.Fatalf("events before credential = %q, want %q", got, want) } if mailCalls < 2 { t.Fatalf("mail cleanup calls before/during rollback = %d, want at least create and post-drain sweeps", mailCalls) } - if got, want := runner.recordAtCredential.Created, t1.Format("2006-01-02 15:04:05 MST"); got != want { + if got, want := runner.recordAtCredential.Created, expiry.Display(t1); got != want { t.Fatalf("creation time at credential = %q, want %q", got, want) } - if got, want := runner.recordAtCredential.Expires, expiry.DisplayLocal(expiry.Deadline(t1, 1)); got != want { + if got, want := runner.recordAtCredential.Expires, expiry.Display(expiry.Deadline(t1, 1)); got != want { t.Fatalf("expiry at credential = %q, want %q", got, want) } if runner.recordAtCredential.Pending { @@ -503,7 +504,7 @@ func TestGeneratedInviteHonorsLegacyMigrationIsolationWindow(t *testing.T) { NameInUse: func(string) (bool, error) { return false, nil }, InspectPrivateGroupState: func(string, int, bool) (bool, error) { return runner.present, nil }, InspectSameNameGroupState: func(string) (bool, error) { return runner.present, nil }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, PrepareManagedHome: func(string) error { return nil }, CreateManagedHome: func(user.Passwd) error { events = append(events, "home") @@ -534,7 +535,7 @@ func TestGeneratedInviteHonorsLegacyMigrationIsolationWindow(t *testing.T) { } return generation, nil } - a.RandPassword = func(int) (string, error) { return "password-for-migration-test", nil } + a.RandPassword = func(int) ([]byte, error) { return []byte("password-for-migration-test"), nil } a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD("passwordauthentication yes\n"), nil } @@ -621,14 +622,14 @@ func TestRunPermanentInviteClearsSafetyExpiry(t *testing.T) { a.TerminateProcesses = func(int) error { events = append(events, "kill"); return nil } a.DrainScheduledJobs = func() error { events = append(events, "drain"); return nil } a.RandHex = func(int) (string, error) { return generation, nil } - a.RandPassword = func(int) (string, error) { return "password-for-permanent-test", nil } + a.RandPassword = func(int) ([]byte, error) { return []byte("password-for-permanent-test"), nil } sshdConfig := sysinfo.ParseSSHD("passwordauthentication yes\n") a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { return sshdConfig, nil } if rc := a.runInviteWithIdentityPolicy(username, "192.0.2.1", 22, 1, false, false, loginPlan{password: true, verified: true}, false); rc != 0 { t.Fatalf("permanent runInvite rc = %d: %s", rc, errb.String()) } - want := "mail,expiry:1970-01-01,password-lock,kill,clear,drain,kill,clear,mail,home,home-validate,credential,expiry:-1" + want := "mail,expiry:1970-01-02,password-lock,kill,clear,drain,kill,clear,mail,home,home-validate,credential,expiry:-1" if got := strings.Join(events, ","); got != want { t.Fatalf("permanent invite events = %q, want %q", got, want) } @@ -737,7 +738,7 @@ func TestRunInviteRollbackUsesStableIdentityOnceActivationMayStart(t *testing.T) NameInUse: func(string) (bool, error) { return false, nil }, InspectPrivateGroupState: func(string, int, bool) (bool, error) { return runner.present, nil }, InspectSameNameGroupState: func(string) (bool, error) { return runner.present, nil }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, PrepareManagedHome: func(string) error { return nil }, CreateManagedHome: func(user.Passwd) error { events = append(events, "home") @@ -762,7 +763,7 @@ func TestRunInviteRollbackUsesStableIdentityOnceActivationMayStart(t *testing.T) a.TerminateProcesses = func(int) error { events = append(events, "kill"); return nil } a.DrainScheduledJobs = func() error { events = append(events, "drain"); return nil } a.RandHex = func(int) (string, error) { return "fedcba9876543210fedcba9876543210", nil } - a.RandPassword = func(int) (string, error) { return "password-for-activation-test", nil } + a.RandPassword = func(int) ([]byte, error) { return []byte("password-for-activation-test"), nil } a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { return sysinfo.ParseSSHD("passwordauthentication yes\n"), nil } @@ -828,7 +829,7 @@ func newManageApp(t *testing.T, in string, users ...string) (*App, *bytes.Buffer NameInUse: func(string) (bool, error) { return false, nil }, InspectPrivateGroupState: func(string, int, bool) (bool, error) { return false, nil }, InspectSameNameGroupState: func(string) (bool, error) { return false, nil }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, } // The store's dir has to be root-owned for its symlink-safety checks to pass; // t.TempDir() belongs to whoever runs the suite. diff --git a/internal/cli/revoke.go b/internal/cli/revoke.go index 88a2e06..3cc138f 100644 --- a/internal/cli/revoke.go +++ b/internal/cli/revoke.go @@ -9,12 +9,18 @@ import ( "time" "github.com/xxvcc/linux-temp-admin/internal/config" + "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/user" "github.com/xxvcc/linux-temp-admin/internal/validate" ) +// errQuarantineLoginStillOpen marks a quarantine attempt that failed at its very +// first step, disabling the login. The caller must not then report the account as +// disabled. +var errQuarantineLoginStillOpen = errors.New("could not disable the login") + var errPersistentQuarantineUnavailable = errors.New("persistent identity quarantine unavailable") func (a *App) revoke(args []string) int { @@ -27,7 +33,11 @@ func (a *App) revoke(args []string) int { } opts.manualInvocation = true if opts.username == "" { - opts.username = a.selectUser() + picked, ok := a.selectUser() + if !ok { + return 1 + } + opts.username = picked } if !validate.Username(opts.username) { a.errorf("%s", a.P.M("用户名不合法,拒绝删除:"+opts.username, "invalid username; refusing deletion: "+opts.username)) @@ -304,7 +314,7 @@ func (tx *revokeTransaction) cleanupAbsentAccount() int { // It may be released only after proving that no same-name group or // subordinate-ID assignment remains. In particular, a crash-left pending // row is retained when such residue needs manual inspection. - reconcileErr = a.Users.VerifyAccountDatabaseAfterExternalDeletion(rec.User, rec.UID, rec.SequentialID) + reconcileErr = a.Users.VerifyAccountDatabaseAfterExternalDeletion(rec.User, rec.UID, rec.UID, rec.SequentialID) } } cleanupErr := errors.Join( @@ -490,7 +500,7 @@ func (tx *revokeTransaction) stripGrantsAndCheckProtection() revokePhaseResult { 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) + a.audit("account.delete", username, "fail", protectedRevokeAuditDetail(grantErr), nil) return finishRevoke(1) } return continueRevoke() @@ -526,7 +536,7 @@ func (tx *revokeTransaction) honorExistingQuarantine() revokePhaseResult { a.errorf("%s: %v", a.P.M("隔离期内的账号身份已改变,无法确认关闭的是同一个账号", "the quarantined account's identity changed; cannot confirm the gates were closed on the same account"), err) return finishRevoke(1) } - a.info(a.P.M("账号访问已撤销,用户名和 UID 隔离保留至:", "account access is revoked; name and UID remain quarantined until: ") + deadline.Local().Format("2006-01-02 15:04:05 MST")) + a.info(a.P.M("账号访问已撤销,用户名和 UID 隔离保留至:", "account access is revoked; name and UID remain quarantined until: ") + expiry.Display(deadline)) return finishRevoke(0) } if a.Now().Before(deadline) { @@ -616,6 +626,15 @@ func (tx *revokeTransaction) revalidateAndPrepareDeletion() revokePhaseResult { return finishRevoke(0) } if quarantineErr != nil && !errors.Is(quarantineErr, errPersistentQuarantineUnavailable) { + // Do not claim the account is disabled when disabling it is what failed: + // beginIdentityQuarantine calls DisableLogin before anything else, so that + // error arrives here too, and telling the operator the door is shut when + // it may still be open is the one thing this message must never do. + if errors.Is(quarantineErr, errQuarantineLoginStillOpen) { + a.errorf("%s: %v", a.P.M("无法禁用登录,因此无法建立身份隔离;账号保留且可能仍可登录,请立即人工处理", + "could not disable the login, so no identity quarantine was established; the account is retained and may still be reachable; inspect immediately"), quarantineErr) + return finishRevoke(1) + } a.errorf("%s: %v", a.P.M("无法安全建立身份隔离;账号已禁用并保留", "cannot safely establish identity quarantine; the account is disabled and retained"), quarantineErr) return finishRevoke(1) } @@ -639,13 +658,28 @@ func (tx *revokeTransaction) deleteAndFinalize() revokePhaseResult { if rec.QuarantineUntil != "" && !opts.synchronousFinalization { teardown = a.teardownQuarantinedAccount } else if rec.QuarantineUntil != "" { - deadline, _ := time.Parse(time.RFC3339, rec.QuarantineUntil) + // Never let a corrupt deadline select a teardown. Discarding this error left + // deadline at the zero time, which is before every real clock reading, so an + // unparseable value silently chose the quarantined path — the one that skips + // the synchronous drain. honorExistingQuarantine refuses the same value + // earlier, so this is defence in depth rather than a live path; it must not + // be the weaker of the two readings. + deadline, parseErr := time.Parse(time.RFC3339, rec.QuarantineUntil) + if parseErr != nil { + a.errorf("%s: %v", a.P.M("隔离删除截止时间损坏,拒绝继续", "quarantine deletion deadline is corrupt; refusing to continue"), parseErr) + return finishRevoke(1) + } if !a.Now().Before(deadline) { teardown = a.teardownQuarantinedAccount } } stage, teardownErr := teardown(username, pw, persistDeletion, rec.SequentialID) switch stage { + case revokeIdentityUnstable: + a.errorf("%s: %v", a.P.M("该账号的身份已与本次撤销授权的对象不符;本次拆除在禁用或删除任何东西之前就停下了,账号、登记和自动删除任务均保留。注意本次运行的更早步骤可能已经禁用登录并终止了该 UID 的进程,请立即人工核查账号当前状态", + "the account no longer matches the identity this revocation was authorized for; this teardown stopped before disabling or deleting anything, and the account, registry record, and auto-delete task were all retained. Earlier steps in this run may already have disabled the login and terminated its processes, so verify the account's current state immediately"), teardownErr) + a.audit("account.delete", username, "fail", "identity changed before teardown: "+teardownErr.Error(), nil) + return finishRevoke(1) 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) @@ -762,7 +796,7 @@ func (a *App) beginIdentityQuarantine(rec registry.Record, expected user.Passwd) return false, fmt.Errorf("%w: finalizer command is unavailable: %v", errPersistentQuarantineUnavailable, err) } if err := a.Users.DisableLogin(rec.User); err != nil { - return false, err + return false, fmt.Errorf("%w: %v", errQuarantineLoginStillOpen, err) } if err := a.revokeAccountStillMatches(rec.User, expected); err != nil { return false, err @@ -801,8 +835,8 @@ func (a *App) beginIdentityQuarantine(rec registry.Record, expected user.Passwd) // and exits without releasing the identity. cleanupErr := a.Scheduler.CancelAuto(rec.User, rec.AutoUnit) a.success(a.P.M( - "访问已撤销;账号身份隔离至 "+deadline.Local().Format("2006-01-02 15:04:05 MST")+",届时自动完成删除。", - "access revoked; account identity is quarantined until "+deadline.Local().Format("2006-01-02 15:04:05 MST")+" and will then be deleted automatically.")) + "访问已撤销;账号身份隔离至 "+expiry.Display(deadline)+",届时自动完成删除。", + "access revoked; account identity is quarantined until "+expiry.Display(deadline)+" and will then be deleted automatically.")) return true, errors.Join(handoffErr, cleanupErr) } @@ -828,12 +862,35 @@ func (a *App) verifyCommittedQuarantine(rec registry.Record, uid int, deadline t type revokeAccountStage uint8 const ( - revokeDisableLogin revokeAccountStage = iota + // revokeIdentityUnstable is the pre-teardown check: the account no longer + // matches the identity this revoke was authorized for, and nothing has been + // attempted yet. It is a separate stage because reporting it as a failed + // disable told the operator a step had been tried and had failed. + revokeIdentityUnstable revokeAccountStage = iota + revokeDisableLogin revokeQuiesceAccount revokeDeleteAccount revokeAccountRemoved ) +// String names the stage. The diagnostic used to print the raw iota value, so +// inserting a stage silently changed what every previously-logged number meant. +func (s revokeAccountStage) String() string { + switch s { + case revokeIdentityUnstable: + return "identity-unstable" + case revokeDisableLogin: + return "disable-login" + case revokeQuiesceAccount: + return "quiesce-account" + case revokeDeleteAccount: + return "delete-account" + case revokeAccountRemoved: + return "account-removed" + } + return fmt.Sprintf("stage(%d)", uint8(s)) +} + // 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. @@ -853,7 +910,7 @@ func (a *App) teardownLocalAccountWith( deleteExpected func(string, user.Passwd, func() error) error, ) (revokeAccountStage, error) { if err := stillMatches(username, expected); err != nil { - return revokeDisableLogin, err + return revokeIdentityUnstable, err } if err := a.Users.DisableLogin(username); err != nil { return revokeDisableLogin, err @@ -887,7 +944,7 @@ func (a *App) teardownLocalAccountWith( func (a *App) teardownQuarantinedAccount(username string, expected user.Passwd, persistDeletion func() error, removePrivateGroup bool) (revokeAccountStage, error) { if err := a.revokeAccountStillMatches(username, expected); err != nil { - return revokeDisableLogin, err + return revokeIdentityUnstable, err } if err := a.Users.DisableLogin(username); err != nil { return revokeDisableLogin, err @@ -1052,7 +1109,7 @@ func (a *App) reconcileDeletionStarted(rec registry.Record) error { } return errors.Join( a.Users.ReconcileManagedMailAfterDeletion(rec.User, rec.UID), - a.Users.ReconcileAccountDatabaseAfterDeletion(rec.User, rec.UID, rec.SequentialID), + a.Users.ReconcileAccountDatabaseAfterDeletion(rec.User, rec.UID, rec.UID, rec.SequentialID), ) } @@ -1307,20 +1364,43 @@ func (a *App) removeSSHDException(username string) error { // only way to name it here is to type it. (manageUsers takes the opposite branch // on an empty list, but for a reason that does not apply here: it is reached from // the menu, where a prompt nobody can answer would eat the next menu choice.) -func (a *App) selectUser() string { +func (a *App) selectUser() (string, bool) { recs, err := a.Registry.List() if err != nil { a.warnf("%v", err) + // An unreadable registry is not an empty one. Saying "no registered + // temporary users" here states a fact about registration that this read + // could not establish, on the one command whose next step is deletion, and + // points the operator at --force for an account that may well be + // registered. status, manageUsers and cleanupExpired all bail instead. + a.errorf("%s", a.P.M( + "登记表读取失败,无法列出已登记账号;在修复登记表之前不要按未登记账号处理。", + "the registry could not be read, so no account list is available; do not treat any account as unregistered until the registry is repaired.")) + return "", false } if len(recs) == 0 { a.warnf("%s", a.P.M("没有已登记的临时用户;如需删除未登记账号,请输入完整用户名(配合 --force)。", "no registered temporary users; to delete an unregistered account, type its full username (with --force).")) } else { - a.printf("%s", a.usersTable(recs, true).String()) + // usersView, not the raw table: the 7-column account table is wider than a + // normal terminal, and every other picker (status, manageUsers, + // cleanupExpired) already falls back to its numbered vertical form. This one + // asks the operator to choose a row it may have garbled. + a.printf("%s", a.usersView(recs, true)) } choice := strings.TrimSpace(a.prompt(a.P.M("请输入编号或用户名: ", "enter a number or a username: "))) if n, err := strconv.Atoi(choice); err == nil && n >= 1 && n <= len(recs) { - return recs[n-1].User + return recs[n-1].User, true + } + return choice, true +} + +// protectedRevokeAuditDetail states what actually happened to the grants. It used +// to always claim they were stripped, so an incident reviewer reading the audit +// log concluded the NOPASSWD drop-in was gone while it was still granting root. +func protectedRevokeAuditDetail(grantErr error) string { + if grantErr != nil { + return "protected target; grants NOT fully stripped: " + grantErr.Error() } - return choice + return "protected target; grants stripped" } diff --git a/internal/cli/revoke_messages_test.go b/internal/cli/revoke_messages_test.go new file mode 100644 index 0000000..3c1e8ad --- /dev/null +++ b/internal/cli/revoke_messages_test.go @@ -0,0 +1,177 @@ +package cli + +import ( + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/config" + "github.com/xxvcc/linux-temp-admin/internal/user" +) + +// An unreadable registry is not an empty one. The picker used to say "no +// registered temporary users" and point at --force — a statement about +// registration the failed read could not support, on the one command whose next +// step is deletion. +func TestRevokePickerRefusesToCallAnUnreadableRegistryEmpty(t *testing.T) { + a, _, errb := newTestApp(t, "xxvcc-a1\n") + // A directory where the registry file belongs: readable path, unreadable rows. + if err := os.Mkdir(a.Registry.File, 0o700); err != nil { + t.Fatal(err) + } + + picked, ok := a.selectUser() + if ok { + t.Fatalf("selectUser returned %q on an unreadable registry, want a refusal", picked) + } + got := errb.String() + if strings.Contains(got, "no registered temporary users") { + t.Fatalf("stderr = %q, want no claim that the registry is empty", got) + } + if !strings.Contains(got, "the registry could not be read") { + t.Fatalf("stderr = %q, want the real reason", got) + } + if strings.Contains(got, "--force") { + t.Fatalf("stderr = %q, want no --force suggestion for a possibly-registered account", got) + } +} + +// A genuinely empty registry still prompts: revoke --force exists to dig out an +// account whose row was lost, and the only way to name it is to type it. +func TestRevokePickerStillPromptsOnAGenuinelyEmptyRegistry(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("Registry.Init writes root-owned state and requires root") + } + a, _, errb := newTestApp(t, "xxvcc-a1\n") + if err := a.Registry.Init(); err != nil { + t.Fatal(err) + } + + picked, ok := a.selectUser() + if !ok || picked != "xxvcc-a1" { + t.Fatalf("selectUser = %q, %v; want the typed name accepted", picked, ok) + } + if !strings.Contains(errb.String(), "no registered temporary users") { + t.Fatalf("stderr = %q, want the empty-registry notice", errb.String()) + } +} + +// The first identity check runs before anything is attempted. Reporting it as +// "could not fully disable the login" told the operator a step had been tried and +// failed, and sent them looking at the wrong thing. +func TestTeardownDistinguishesAnIdentityChangeFromAFailedDisable(t *testing.T) { + a, _, _ := newTestApp(t, "") + expected := user.Passwd{Name: "xxvcc-a1", UID: 1500, GID: 1500, Home: "/home/xxvcc-a1"} + + t.Run("the quarantined (async) teardown reports it the same way", func(t *testing.T) { + // deleteAndFinalize picks teardownQuarantinedAccount whenever a quarantine + // deadline is recorded and finalization is not synchronous — the ordinary + // deletion path. The fix touched both teardowns, so both must be pinned. + a, _, _ := newTestApp(t, "") + var disableCalls int + a.LookupUser = func(string) (user.Passwd, bool, error) { + // A different account now wears the name: identity no longer matches. + return user.Passwd{Name: "xxvcc-a1", UID: 4242, GID: 4242, Home: "/home/someone-else"}, true, nil + } + runner := &recordingRunner{calls: &disableCalls} + a.Users = &user.Manager{LookupUser: a.LookupUser, Runner: runner} + + stage, err := a.teardownQuarantinedAccount("xxvcc-a1", expected, func() error { return nil }, false) + if stage != revokeIdentityUnstable { + t.Fatalf("stage = %v, want revokeIdentityUnstable on the async path too", stage) + } + if err == nil { + t.Fatal("want the identity error returned") + } + if disableCalls != 0 { + t.Fatalf("%d account-mutating commands ran before the identity check passed", disableCalls) + } + }) + + t.Run("identity changed before any action", func(t *testing.T) { + stage, err := a.teardownLocalAccountWith("xxvcc-a1", expected, func() error { return nil }, + func(string, user.Passwd) error { return errors.New("passwd entry no longer matches") }, + func(string, user.Passwd, func() error) error { + t.Fatal("deleteExpected must not run after the identity check failed") + return nil + }) + if stage != revokeIdentityUnstable { + t.Fatalf("stage = %v, want revokeIdentityUnstable", stage) + } + if err == nil { + t.Fatal("want the identity error returned") + } + }) +} + +// The quarantine window has to cover one complete cron/at polling cycle, or the +// name, UID and GID return to the allocatable pool while a cached deferred job +// for that identity can still be dispatched. Replacing the round-up with +// truncation leaves ~5 s of quarantine and used to break nothing. +func TestQuarantineDeadlineNeverFallsBelowTheConfiguredFloor(t *testing.T) { + floor := time.Duration(config.IdentityQuarantineSeconds) * time.Second + base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) + + for second := 0; second < 60; second++ { + for _, nanos := range []int{0, 1, 999999999} { + now := base.Add(time.Duration(second)*time.Second + time.Duration(nanos)) + got := quarantineDeadline(now) + + if window := got.Sub(now); window < floor { + t.Fatalf("quarantineDeadline(%s) gives a %s window, want at least %s", now.Format(time.RFC3339Nano), window, floor) + } + // Bounded on the other side too: the window is a floor, not an excuse to + // hold an identity out of the pool for minutes. + if window := got.Sub(now); window >= floor+time.Minute { + t.Fatalf("quarantineDeadline(%s) gives a %s window, want under %s", now.Format(time.RFC3339Nano), window, floor+time.Minute) + } + // Minute-aligned in UTC: at(1) and OnCalendar both take whole minutes, + // and registry validation refuses a deadline carrying seconds. + if got.Second() != 0 || got.Nanosecond() != 0 || got.Location() != time.UTC { + t.Fatalf("quarantineDeadline(%s) = %s, want a whole UTC minute", now.Format(time.RFC3339Nano), got.Format(time.RFC3339Nano)) + } + } + } +} + +// recordingRunner counts every account-mutating command. The pre-teardown +// identity check must run before any of them. +type recordingRunner struct{ calls *int } + +func (recordingRunner) Look(string) bool { return true } +func (r recordingRunner) Run(string, ...string) error { + *r.calls++ + return nil +} +func (r recordingRunner) RunInput(string, string, ...string) error { + *r.calls++ + return nil +} + +// The audit log is what an incident reviewer reads after the fact. It used to +// state "grants stripped" on a protected revoke whose grant removal had failed, +// so the reviewer concluded a NOPASSWD drop-in was gone while it was still +// granting root. +func TestProtectedRevokeAuditDetailMatchesWhatActuallyHappened(t *testing.T) { + for _, tc := range []struct { + name string + grantErr error + wantDetail string + notDetail string + }{ + {"grants really were stripped", nil, "grants stripped", "NOT fully stripped"}, + {"grant removal failed", errors.New("sudoers remove failed"), "grants NOT fully stripped: sudoers remove failed", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + detail := protectedRevokeAuditDetail(tc.grantErr) + if !strings.Contains(detail, tc.wantDetail) { + t.Fatalf("audit detail = %q, want it to contain %q", detail, tc.wantDetail) + } + if tc.notDetail != "" && strings.Contains(detail, tc.notDetail) { + t.Fatalf("audit detail = %q, want it not to contain %q", detail, tc.notDetail) + } + }) + } +} diff --git a/internal/cli/revoke_process.go b/internal/cli/revoke_process.go index 5ad2048..d6b9e6d 100644 --- a/internal/cli/revoke_process.go +++ b/internal/cli/revoke_process.go @@ -49,7 +49,11 @@ func runningLegacyRevokeProcess(procRoot, installPath, username string) (bool, e pidDir := filepath.Join(procRoot, entry) cmdline, cmdErr := readProcFile(filepath.Join(pidDir, "cmdline"), procCmdlineMaxBytes) if cmdErr != nil { - if errors.Is(cmdErr, os.ErrNotExist) { + // A process that exits between the directory read and this open reports + // ESRCH, not ENOENT. Treating only ENOENT as "it vanished" aborted the + // whole scan whenever any unrelated short-lived process happened to exit, + // which on the invite path rolled back a half-created account. + if procEntryVanished(cmdErr) { continue } if !errors.Is(cmdErr, errProcFileTooLarge) { @@ -67,7 +71,7 @@ func runningLegacyRevokeProcess(procRoot, installPath, username string) (bool, e } status, statusErr := readProcFile(filepath.Join(pidDir, "status"), procStatusMaxBytes) if statusErr != nil { - if errors.Is(statusErr, os.ErrNotExist) { + if procEntryVanished(statusErr) { continue } return false, fmt.Errorf("read process %s credentials: %w", entry, statusErr) @@ -177,3 +181,12 @@ func effectiveUID(status []byte) (uint32, error) { } return euid, nil } + +// procEntryVanished reports whether err means the process disappeared between +// listing /proc and reading the entry. The kernel answers ENOENT for a pid +// directory that is already gone and ESRCH for one being torn down while the +// open is in flight; both are the same fact, and internal/user already treats +// them as one. +func procEntryVanished(err error) bool { + return errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ESRCH) +} diff --git a/internal/cli/revoke_process_test.go b/internal/cli/revoke_process_test.go index 5d49500..c1241a4 100644 --- a/internal/cli/revoke_process_test.go +++ b/internal/cli/revoke_process_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "golang.org/x/sys/unix" ) func TestEffectiveUIDAcceptsFullLinuxUIDRange(t *testing.T) { @@ -112,3 +114,29 @@ func TestRunningLegacyRevokeProcessRecognizesOversizedMatchingShellPrefix(t *tes t.Fatalf("runningLegacyRevokeProcess = (%v, %v), want (true, nil)", found, err) } } + +// /proc entries disappear under any busy host. The kernel reports ENOENT for a +// pid directory that is already gone and ESRCH for one being torn down mid-open; +// only the first was treated as "it vanished", so one unrelated short-lived +// process exiting at the wrong moment aborted the scan — and on the invite path +// that rolled back a half-created account. +func TestProcEntryVanishedCoversBothKernelAnswers(t *testing.T) { + for _, tc := range []struct { + name string + err error + want bool + }{ + {"already gone", os.ErrNotExist, true}, + {"torn down mid-open", unix.ESRCH, true}, + {"wrapped ESRCH", fmt.Errorf("read process 4242 command line: %w", unix.ESRCH), true}, + {"wrapped ENOENT", fmt.Errorf("open: %w", os.ErrNotExist), true}, + {"permission denied is a real failure", os.ErrPermission, false}, + {"oversized file is a real failure", errProcFileTooLarge, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := procEntryVanished(tc.err); got != tc.want { + t.Fatalf("procEntryVanished(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/internal/cli/revoke_test.go b/internal/cli/revoke_test.go index f803d4b..0748512 100644 --- a/internal/cli/revoke_test.go +++ b/internal/cli/revoke_test.go @@ -43,6 +43,7 @@ func (s revokeTestScheduleSystem) RemoveAtJobsFor(string) error { } func (revokeTestScheduleSystem) AtrmJob(string) error { return nil } func (revokeTestScheduleSystem) AtJobs() ([]schedule.AtJob, error) { return nil, nil } +func (revokeTestScheduleSystem) AtDaemonRunning() (bool, error) { return true, nil } func (r *orderedTeardownRunner) Run(name string, _ ...string) error { *r.events = append(*r.events, name) @@ -91,7 +92,7 @@ func newOrderedTeardownApp(t *testing.T, pw user.Passwd, failClearCall int, clea NameInUse: func(string) (bool, error) { return false, nil }, InspectPrivateGroupState: func(string, int, bool) (bool, error) { return false, nil }, InspectSameNameGroupState: func(string) (bool, error) { return false, nil }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, RemoveManagedMail: appendArtifact("mail"), RemoveManagedHome: appendArtifact("home"), }, @@ -1358,7 +1359,7 @@ func TestTeardownContinuesAcrossConcurrentUserWritablePasswdChanges(t *testing.T LookupUser: lookup, NameInUse: func(string) (bool, error) { return false, nil }, InspectSameNameGroupState: func(string) (bool, error) { return false, nil }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, RemoveManagedMail: func(user.Passwd) error { events = append(events, "mail"); return nil }, RemoveManagedHome: func(user.Passwd) error { events = append(events, "home"); return nil }, }, @@ -1602,7 +1603,7 @@ func TestRevokeRetriesPostDeletionMailAndKeepsOrdinaryAbsentRowsNarrow(t *testin NameInUse: func(string) (bool, error) { return false, nil }, InspectPrivateGroupState: func(string, int, bool) (bool, error) { return false, nil }, InspectSameNameGroupState: func(string) (bool, error) { return false, nil }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, RemoveManagedMail: removeMail, } a.Scheduler = &schedule.Scheduler{ @@ -1873,7 +1874,7 @@ func TestCompactRetainsWholeRegistryWhenAbsentAccountDatabaseIsUnclean(t *testin name: "subordinate ID remains", wantError: "injected subordinate ID residue", configure: func(m *user.Manager, blocked string) { - m.CheckSubordinateIDsAbsent = func(name string) error { + m.CheckSubordinateIDsAbsent = func(name string, _ int) error { if name == blocked { return errors.New("injected subordinate ID residue") } @@ -1935,7 +1936,7 @@ func TestCompactRetainsWholeRegistryWhenAbsentAccountDatabaseIsUnclean(t *testin return false, nil }, InspectSameNameGroupState: func(string) (bool, error) { return false, nil }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, } tc.configure(a.Users, blocked.User) @@ -1975,7 +1976,7 @@ func TestCompactRemovesCleanAbsentAccountDatabaseRow(t *testing.T) { } return false, nil }, - CheckSubordinateIDsAbsent: func(name string) error { + CheckSubordinateIDsAbsent: func(name string, _ int) error { subIDChecks++ if name != rec.User { t.Fatalf("subordinate-ID inspection = %q, want %q", name, rec.User) @@ -2176,3 +2177,66 @@ func TestQuarantineRegateBindsToTheCapturedIdentity(t *testing.T) { }) } } + +// systemctlSched reports a systemd host so beginIdentityQuarantine gets past its +// availability gate; everything else comes from the existing fake. +type systemctlSched struct{ failingScheduleSystem } + +func (systemctlSched) HasSystemctl() bool { return true } + +// TestQuarantineHandoffDoesNotClaimADisabledAccount pins the distinction the +// operator message depends on. beginIdentityQuarantine disables the login as its +// very first step, so that failure reaches the same branch that otherwise +// reports "the account is disabled and retained" — the one claim this path must +// never make while the door may still be open. +func TestQuarantineHandoffDoesNotClaimADisabledAccount(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + pw := user.Passwd{ + Name: "xxvcc-quar2", UID: 1001, GID: 1001, + GECOS: config.ManagedGenerationGECOSPrefix + generation, + Home: "/home/xxvcc-quar2", Shell: "/bin/sh", + } + a, _, _ := newTestApp(t, "") + a.Users = &user.Manager{Runner: &revokeRunner{failOn: "chage"}} + a.LookupUser = func(string) (user.Passwd, bool, error) { return pw, true, nil } + a.EnsureScheduledCommand = func() error { return nil } + a.Scheduler = &schedule.Scheduler{Sys: systemctlSched{}} + + started, err := a.beginIdentityQuarantine(registry.Record{ + User: pw.Name, UID: pw.UID, Generation: generation, IdentityBound: true, Port: 22, + }, pw) + if started { + t.Fatal("quarantine reported started while the login disable failed") + } + if !errors.Is(err, errQuarantineLoginStillOpen) { + t.Fatalf("beginIdentityQuarantine error = %v, want it to mark the login as still open", err) + } +} + +// TestDeleteAndFinalizeRefusesACorruptQuarantineDeadline pins the parse error +// that used to be discarded. The zero time it left behind is before every real +// clock reading, so an unparseable deadline silently selected the quarantined +// teardown — the one that skips the synchronous drain. +func TestDeleteAndFinalizeRefusesACorruptQuarantineDeadline(t *testing.T) { + a, _, errb := newTestApp(t, "") + a.Users = &user.Manager{Runner: &revokeRunner{}} + pw := user.Passwd{Name: "xxvcc-quar3", UID: 1001, GID: 1001, Home: "/home/xxvcc-quar3", Shell: "/bin/sh"} + a.LookupUser = func(string) (user.Passwd, bool, error) { return pw, true, nil } + tx := &revokeTransaction{ + app: a, username: pw.Name, registered: true, pw: pw, + // The parse is on the synchronous-finalization branch; the asynchronous one + // selects the quarantined teardown without reading the deadline at all. + opts: revokeOptions{synchronousFinalization: true}, + rec: registry.Record{ + User: pw.Name, UID: pw.UID, Port: 22, DeletionStarted: true, IdentityBound: true, + Generation: "0123456789abcdef0123456789abcdef", QuarantineUntil: "not-a-timestamp", + }, + } + res := tx.deleteAndFinalize() + if !res.done || res.status != 1 { + t.Fatalf("deleteAndFinalize = done:%v status:%d, want a refusal\nstderr:\n%s", res.done, res.status, errb.String()) + } + if !strings.Contains(errb.String(), "corrupt") { + t.Fatalf("refusal did not name the corrupt deadline:\n%s", errb.String()) + } +} diff --git a/internal/cli/selfcmd.go b/internal/cli/selfcmd.go index d074278..020f1c1 100644 --- a/internal/cli/selfcmd.go +++ b/internal/cli/selfcmd.go @@ -15,6 +15,7 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/fsutil" "github.com/xxvcc/linux-temp-admin/internal/selfmanage" "github.com/xxvcc/linux-temp-admin/internal/validate" + "github.com/xxvcc/linux-temp-admin/internal/version" "golang.org/x/sys/unix" ) @@ -96,9 +97,22 @@ func (a *App) installLocked(force bool) int { } 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 + // Mirror of the MarkUninstalled branch in teardown: RemoveFile syncs the + // parent only AFTER the unlink committed, so a *fsutil.DurabilityError + // means the marker is already gone and the gate is already open. + // Reporting that as "could not be cleared" and exiting 1 told the operator + // the tool was still disabled while it was not — and a retry then skips + // this branch entirely, because wasUninstalled is false the second time. + var committed *fsutil.DurabilityError + if errors.As(err, &committed) { + a.warnf("%s: %v", a.P.M("卸载状态标记已清除但持久性未确认;命令已可用,安装继续", + "the uninstall-state marker is cleared but its durability is unconfirmed; the command is usable and the install continues"), err) + a.audit("install", "", "warn", "uninstall marker cleared with unconfirmed durability: "+err.Error(), nil) + } else { + 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 { @@ -205,7 +219,7 @@ func (a *App) upgradeResult(args []string) commandResult { if customURL { candidate, err = a.Selfmanage.PrepareUpgrade(binURL, sigURL) } else { - candidate, err = a.prepareOfficialUpgrade() + candidate, err = a.prepareOfficialUpgrade(force) } if err != nil { a.errorf("%s: %v", a.P.M("升级失败", "upgrade failed"), err) @@ -220,7 +234,7 @@ func (a *App) upgradeResult(args []string) commandResult { return result } -func (a *App) prepareOfficialUpgrade() (*selfmanage.UpgradeCandidate, error) { +func (a *App) prepareOfficialUpgrade(force bool) (*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) @@ -237,6 +251,28 @@ func (a *App) prepareOfficialUpgrade() (*selfmanage.UpgradeCandidate, error) { return a.Selfmanage.PrepareReleaseUpgrade( config.GitHubLatestReleaseBaseURL, asset, "") } + // The mirror's manifest is the sole version selector on this path, and nothing + // downstream distinguishes "you already have the newest" from "the mirror is + // offering something older than what you run". Both surfaced as a silent + // already-up-to-date exit, which is the wrong report for a stale, rolled-back + // or hostile index — the one case an operator would want to see. Say it, and + // leave a record: the upgrade itself still declines, as it did before. + if installed, verErr := a.Selfmanage.InstalledVersion(); verErr == nil && version.Greater(installed, manifest.Version) { + // Report the outcome this run will actually have. Saying "nothing was + // upgraded" and auditing a skip before the decision meant a --force + // downgrade proceeded anyway and was reported as an upgrade in the same run. + if force { + a.warnf("%s", a.P.M( + "官方镜像索引提供的版本 "+manifest.Version+" 低于当前安装的 "+installed+";--force 将执行降级 "+installed+" → "+manifest.Version+"。索引可能陈旧、被回滚或被篡改,请先核实。", + "the official mirror index offers "+manifest.Version+", which is older than the installed "+installed+"; --force will downgrade "+installed+" -> "+manifest.Version+". The index may be stale, rolled back or tampered with; verify it first.")) + a.audit("upgrade", "", "warn", "forced downgrade from "+installed+" to mirror manifest "+manifest.Version, nil) + } else { + a.warnf("%s", a.P.M( + "官方镜像索引提供的版本 "+manifest.Version+" 低于当前安装的 "+installed+";未升级。索引可能陈旧、被回滚或被篡改,请核实后再重试。", + "the official mirror index offers "+manifest.Version+", which is older than the installed "+installed+"; nothing was upgraded. The index may be stale, rolled back or tampered with; verify it before retrying.")) + a.audit("upgrade", "", "skip", "mirror manifest offered "+manifest.Version+" below installed "+installed, nil) + } + } 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.")) diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go index 7ba57b1..264aa81 100644 --- a/internal/cli/uninstall.go +++ b/internal/cli/uninstall.go @@ -391,7 +391,7 @@ func (a *App) v1RegistryUsers() ([]string, error) { } // printTeardownPlan shows what is about to happen while it can still be stopped. -func (a *App) printTeardownPlan(p teardownPlan) { +func (a *App) printTeardownPlan(p teardownPlan, opts uninstallOptions) { a.info(a.P.M("卸载将移除:", "The uninstall will remove:")) if len(p.accounts) == 0 { @@ -416,6 +416,13 @@ func (a *App) printTeardownPlan(p teardownPlan) { state = a.P.M("存在(身份核验后尝试撤销)", "live (revoke after identity checks)") } } + // This table is printed under "The uninstall will remove:", so a row the + // run is going to excuse must not be listed as one it will act on. The + // plan alone cannot tell: only opts decides which foreign markers are + // ignored. + if p.ignores(opts, acc) { + state = a.P.M("保留(已按 --ignore-foreign-markers 跳过)", "left alone (skipped by --ignore-foreign-markers)") + } ws := make([]string, 0, len(acc.witnesses)) for _, w := range acc.witnesses { ws = append(ws, string(w)) @@ -520,7 +527,7 @@ func (a *App) authorizeUninstall(plan teardownPlan, opts uninstallOptions) bool return false } - a.printTeardownPlan(plan) + a.printTeardownPlan(plan, opts) if ignored := plan.ignoredForeignMarkers(opts); len(ignored) > 0 { a.warnf("%s %s", a.P.M( @@ -602,9 +609,25 @@ func (a *App) authorizeUninstall(plan teardownPlan, opts uninstallOptions) bool // ignored foreign marker is left alone, so demanding --remove-users for it // would name a deletion that is not going to happen. pending := 0 + // Split for the message only: the gate stays on the witness count (see + // below), but the refusal used to assert that this many ACCOUNTS exist, + // which is false for a stale v1 row, an orphaned drop-in or a leftover timer. + liveAccounts := 0 for _, acc := range plan.accounts { + // Deliberately counts witnesses, not just live accounts. An audit finding + // read this as demanding a mass-deletion flag for deletions that will not + // happen, and narrowing it to acc.exists broke + // TestUninstallWithAccountsRefusesNonInteractivelyWithoutTheFlag, whose + // prose states the intent: the gate covers the whole teardown of + // account-related state, so a run nobody is watching never removes a + // registry row, a sudoers drop-in or a scheduled task implicitly either. + // The flag name is about what it authorizes, not about what each host + // happens to hold. if !plan.ignores(opts, acc) { pending++ + if acc.exists { + liveAccounts++ + } } } if pending > 0 && !opts.removeUsers { @@ -616,8 +639,8 @@ func (a *App) authorizeUninstall(plan teardownPlan, opts uninstallOptions) bool // count is the compensation, not an equal. if opts.yes || !a.StdinIsTTY() { a.errorf("%s", a.P.M( - fmt.Sprintf("非交互模式不会删除账号。这台机器上有 %d 个由本工具管理的账号,卸载必须先删除它们;确认请加 --remove-users。", pending), - 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.", pending))) + fmt.Sprintf("非交互模式不会删除账号或清理账号相关状态。这台机器上有 %d 项由本工具管理的账号状态,其中 %d 个是仍然存在的账号,其余为登记行、sudo 授权或自动删除任务等残留;卸载必须先清除它们,确认请加 --remove-users。", pending, liveAccounts), + fmt.Sprintf("a non-interactive run will not delete accounts or clear account state. This host has %d item(s) of account state managed by this tool, of which %d are live accounts and the rest are leftover registry rows, sudo grants or auto-delete tasks; the uninstall must clear them first, so pass --remove-users to say so.", pending, liveAccounts))) a.warnf("%s", a.P.M("(不能只卸载命令、留下受管账号:这会让工具失去撤销这些账号、清理授权和执行已有自动删除任务的能力。)", "(uninstalling the command while keeping managed accounts is not an option: it removes the ability to revoke those accounts, clean their grants, and run any auto-delete tasks already scheduled.)")) return false @@ -784,6 +807,18 @@ func (a *App) teardown(plan teardownPlan, opts uninstallOptions) int { a.audit("uninstall", "", "fail", "residual: "+strings.Join(residual.names(), " ")+"; failed revokes: "+strings.Join(failedRevokes, " "), nil) return 1 } + // The re-inventory exists to catch state that changed since the plan was + // approved, and an install path that became unremovable in that window is + // exactly such a change. Computing the blocker and discarding it left the + // half-uninstalled end state the pre-flight check exists to prevent. + if residual.binaryBlocker != "" { + a.errorf("%s:%s(%s)", a.P.M("拒绝卸载:无法移除已安装的命令", "refusing to uninstall: the installed command cannot be removed"), + residual.binaryPath, residual.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.")) + a.audit("uninstall", "", "fail", "install path became unremovable after the plan: "+residual.binaryBlocker, nil) + return 1 + } // 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 @@ -801,10 +836,21 @@ func (a *App) teardown(plan teardownPlan, opts uninstallOptions) int { 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 + // The marker is written by rename and only then synced, so a durability + // failure means it is already visible and already blocking every later + // mutation. Reporting that as "not recorded" told the operator the gate + // was not armed while it silently was. + var committed *fsutil.DurabilityError + if errors.As(err, &committed) { + a.warnf("%s: %v", a.P.M("卸载状态标记已写入但持久性未确认;它已在生效,卸载继续", + "the uninstall-state marker is recorded but its durability is unconfirmed; it is already in force, and the uninstall continues"), err) + a.audit("uninstall", "", "warn", "uninstall marker committed with unconfirmed durability: "+err.Error(), nil) + } else { + 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 { @@ -843,6 +889,18 @@ func (a *App) teardown(plan teardownPlan, opts uninstallOptions) int { a.audit("uninstall", "", "ok", a.InstallPath, map[string]string{"accounts": fmt.Sprint(len(plan.accounts)), "purged": "no"}) } + // The accounts --ignore-foreign-markers deliberately left behind are still + // live. The warning that named them printed before the YES prompt and has + // scrolled away by now, so this line is the operator's last word on the host + // — it must not say they are gone. + if left := residual.ignoredForeignMarkers(opts); len(left) > 0 { + a.success(a.P.M( + fmt.Sprintf("已卸载:授权、自动删除任务、状态与命令均已移除。以下 %d 个仅由 passwd 标记指认的账号按 --ignore-foreign-markers 保留,未被删除,请人工处理:%s", + len(left), strings.Join(left, ", ")), + fmt.Sprintf("uninstalled: the grants, the auto-delete tasks, the state and the command are gone. %d account(s) named only by a passwd marker were left in place by --ignore-foreign-markers and must be dealt with by hand: %s", + len(left), strings.Join(left, ", ")))) + return 0 + } a.success(a.P.M("已卸载:临时账号、授权、自动删除任务、状态与命令均已移除。", "uninstalled: the temporary accounts, their grants, their auto-delete tasks, the state and the command are gone.")) return 0 diff --git a/internal/cli/uninstall_root_test.go b/internal/cli/uninstall_root_test.go index d95061d..b8dd6f7 100644 --- a/internal/cli/uninstall_root_test.go +++ b/internal/cli/uninstall_root_test.go @@ -64,8 +64,12 @@ func uninstallApp(t *testing.T, in string, users ...string) (*App, *strings.Buil a.SSHD = nil // no sshd is touched by these tests a.Scheduler = &schedule.Scheduler{ SystemdDir: mk("systemd", 0o755), SystemdTimerStateDir: mk("systemd-timer-state", 0o755), InstallPath: a.InstallPath, - UnitPrefix: config.AutoRevokeUnitPrefix, LegacyUnitPrefixes: []string{config.V1AutoRevokeUnitPrefix}, - Now: a.Now, Sys: fakeUninstallSystem{}, + // Mirror schedule.New(): production sweeps the quarantine namespace too, and + // a fixture that omits it leaves the one prefix naming a disabled-but-present + // account untested by every uninstall case. + UnitPrefix: config.AutoRevokeUnitPrefix, + LegacyUnitPrefixes: []string{config.V1AutoRevokeUnitPrefix, config.QuarantineUnitPrefix}, + 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. @@ -953,6 +957,7 @@ func TestDoctorReportsAutoDeleteAccountsWithNoTaskLeft(t *testing.T) { t.Fatal(err) } rec.AutoRevoke = true + rec.Expires = "2099-07-08 12:00:00 UTC" rec.AutoUnit = unit if err := a.Registry.Record(rec); err != nil { t.Fatal(err) diff --git a/internal/cli/uninstall_test.go b/internal/cli/uninstall_test.go index 5615350..d193047 100644 --- a/internal/cli/uninstall_test.go +++ b/internal/cli/uninstall_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/xxvcc/linux-temp-admin/internal/config" + "github.com/xxvcc/linux-temp-admin/internal/fsutil" + "github.com/xxvcc/linux-temp-admin/internal/lifecycle" "github.com/xxvcc/linux-temp-admin/internal/registry" "github.com/xxvcc/linux-temp-admin/internal/selfmanage" "github.com/xxvcc/linux-temp-admin/internal/user" @@ -445,3 +447,196 @@ func TestQuarantinedAccountRemainsAuthorizedForSynchronousUninstallCleanup(t *te t.Fatal("a valid quarantined account could not be synchronously finalized by uninstall") } } + +// uninstallHost builds the same shape as TestUninstallIgnoreForeignMarkersCompletesTeardown: +// a self-contained root, state dir, registry and install path, with no managed +// accounts unless the caller adds one. +func uninstallHost(t *testing.T) (*App, *strings.Builder, *strings.Builder) { + t.Helper() + if os.Getuid() != 0 { + t.Skip("the teardown reads and writes root-owned state") + } + a, _, _ := newTestApp(t, "") + var outb, errb strings.Builder + a.Out = &outb + a.Err = &errb + root := t.TempDir() + a.StateDir = filepath.Join(root, "state") + a.AuditLogDir = filepath.Join(root, "audit") + registryDir := filepath.Join(a.StateDir, "v2") + if err := os.MkdirAll(registryDir, 0o700); err != nil { + t.Fatal(err) + } + a.Registry = ®istry.Store{ + Dir: registryDir, + File: filepath.Join(registryDir, "registry.tsv"), + Lock: filepath.Join(registryDir, "registry.lock"), + } + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + a.InstallPath = filepath.Join(binDir, "linux-temp-admin") + if err := os.WriteFile(a.InstallPath, []byte("installed"), 0o755); err != nil { + t.Fatal(err) + } + a.Selfmanage = selfmanage.New(a.InstallPath, 0) + return a, &outb, &errb +} + +// The install path is probed during the inventory precisely so a path that cannot +// be removed is discovered BEFORE anything is destroyed. The post-revoke +// re-inventory recomputed that blocker and threw it away, which is the one window +// where the discovery still matters: state changed after the plan was approved. +func TestUninstallRefusesWhenTheInstallPathBecomesUnremovableAfterThePlan(t *testing.T) { + a, _, errb := uninstallHost(t) + + // Approve a plan against a removable file, then make the path unremovable in + // the window the re-inventory exists to cover. + plan := a.teardownPlan(false, false) + if plan.binaryBlocker != "" { + t.Fatalf("premise: the plan already blocks (%q)", plan.binaryBlocker) + } + if err := os.Remove(a.InstallPath); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(a.InstallPath, 0o755); err != nil { + t.Fatal(err) + } + + opts := uninstallOptions{yes: true, removeUsers: true} + if rc := a.teardown(a.teardownPlan(false, false), opts); rc == 0 { + t.Fatalf("teardown returned 0 though the command cannot be removed; stderr=%q", errb.String()) + } + if got := errb.String(); !strings.Contains(got, "refusing to uninstall") { + t.Fatalf("stderr = %q, want the refusal naming the install path", got) + } + if _, err := os.Lstat(a.StateDir); err != nil { + t.Fatalf("the state directory was removed despite the refusal: %v", err) + } +} + +// The marker is written by rename and synced afterwards, so a durability failure +// means it is already on disk and already blocking every later mutation. +// Reporting that as "not recorded" told the operator the gate was not armed. +func TestUninstallContinuesWhenTheMarkerIsCommittedButUnsynced(t *testing.T) { + a, _, errb := uninstallHost(t) + a.Lifecycle = markerLock(t, &fsutil.DurabilityError{Operation: "uninstall marker directory", Err: os.ErrInvalid}) + + opts := uninstallOptions{yes: true, removeUsers: true} + rc := a.teardown(a.teardownPlan(false, false), opts) + + if rc != 0 { + t.Fatalf("teardown = %d, want the uninstall to continue past a committed-but-unsynced marker; stderr=%q", rc, errb.String()) + } + if got := errb.String(); !strings.Contains(got, "durability is unconfirmed") { + t.Fatalf("stderr = %q, want the operator told the marker IS recorded", got) + } + if _, err := os.Lstat(a.StateDir); !os.IsNotExist(err) { + t.Fatalf("the state directory survived a continuing uninstall: %v", err) + } +} + +// A marker that genuinely could not be written still stops the uninstall. +func TestUninstallStopsWhenTheMarkerCannotBeRecordedAtAll(t *testing.T) { + a, _, errb := uninstallHost(t) + a.Lifecycle = markerLock(t, os.ErrPermission) + + opts := uninstallOptions{yes: true, removeUsers: true} + if rc := a.teardown(a.teardownPlan(false, false), opts); rc == 0 { + t.Fatalf("teardown = 0 though the marker was never recorded; stderr=%q", errb.String()) + } + if _, err := os.Lstat(a.StateDir); err != nil { + t.Fatalf("state was removed although the gate was never armed: %v", err) + } +} + +// The non-interactive refusal used to call every witness an account. A stale +// registry row with no passwd entry behind it is leftover state, not an account. +func TestNonInteractiveRefusalSeparatesLiveAccountsFromLeftoverState(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + a, _, errb := uninstallHost(t) + if err := a.Registry.Init(); err != nil { + t.Fatal(err) + } + if _, _, err := a.Registry.ReserveIdentity(1600, 1600); err != nil { + t.Fatal(err) + } + // One row whose account really exists, and one whose account is long gone. + // The gate counts both witnesses; the message must not call both accounts. + for _, rec := range []registry.Record{ + {User: "xxvcc-live1", Port: 22, UID: 1500, Generation: generation, IdentityBound: true}, + {User: "xxvcc-stale1", Port: 22, UID: 1501, Generation: generation, IdentityBound: true}, + } { + if err := a.Registry.Record(rec); err != nil { + t.Fatal(err) + } + } + a.LookupUser = func(name string) (user.Passwd, bool, error) { + if name != "xxvcc-live1" { + return user.Passwd{}, false, nil + } + return user.Passwd{ + Name: name, UID: 1500, GID: 1500, + GECOS: config.ManagedGenerationGECOSPrefix + generation + ",,,," + config.ManagedGenerationGECOSWitnessPrefix + generation, + Home: "/home/" + name, Shell: "/bin/sh", + }, true, nil + } + + plan := a.teardownPlan(false, false) + if a.authorizeUninstall(plan, uninstallOptions{yes: true}) { + t.Fatal("a non-interactive run authorized itself without --remove-users") + } + + got := errb.String() + if !strings.Contains(got, "2 item(s) of account state") { + t.Fatalf("stderr = %q, want both witnesses counted as account state", got) + } + if !strings.Contains(got, "1 are live accounts") { + t.Fatalf("stderr = %q, want exactly one of them reported as a live account", got) + } + if !strings.Contains(got, "leftover registry rows") { + t.Fatalf("stderr = %q, want the remainder named as leftover state", got) + } +} + +// markerLock returns a real lifecycle lock whose marker write fails with err, so +// the two marker branches can be driven without a filesystem that misbehaves on +// demand. +func markerLock(t *testing.T, err error) *lifecycle.Lock { + t.Helper() + l := lifecycle.New(filepath.Join(t.TempDir(), "lifecycle.lock")) + l.WriteRootFile = func(string, []byte, os.FileMode, int, int) error { return err } + return l +} + +// --ignore-foreign-markers deliberately leaves live accounts in place, and the +// warning that named them printed before the YES prompt and has scrolled away by +// the end. The closing line is the operator's last word on the host, so it must +// not say the accounts are gone. +func TestUninstallClosingLineNamesTheAccountsItDeliberatelyLeft(t *testing.T) { + const name = "someone-else" + a, outb, errb := uninstallHost(t) + pw := user.Passwd{ + Name: name, UID: 4242, GID: 4242, GECOS: config.ManagedGECOS, + Home: "/home/" + name, Shell: "/bin/sh", + } + a.ListMarkerAccounts = func() ([]string, error) { return []string{name}, nil } + a.LookupUser = func(string) (user.Passwd, bool, error) { return pw, true, nil } + + opts := uninstallOptions{yes: true, force: true, ignoreForeignMarkers: true} + if rc := a.teardown(a.teardownPlan(false, true), opts); rc != 0 { + t.Fatalf("teardown = %d, want a completed teardown; stderr=%q", rc, errb.String()) + } + + out := outb.String() + if !strings.Contains(out, name) { + t.Fatalf("closing output = %q, want the deliberately-kept account named", out) + } + if !strings.Contains(out, "left in place by --ignore-foreign-markers") { + t.Fatalf("closing output = %q, want it to say the account was left in place", out) + } + if strings.Contains(out, "the temporary accounts, their grants") { + t.Fatalf("closing output = %q, want no claim that every account is gone", out) + } +} diff --git a/internal/executil/executil.go b/internal/executil/executil.go index 6c938ee..bd70617 100644 --- a/internal/executil/executil.go +++ b/internal/executil/executil.go @@ -11,15 +11,26 @@ import ( "io" "os" "os/exec" + "strings" "sync" "syscall" "time" + "unsafe" + + "golang.org/x/sys/unix" ) const ( DefaultTimeout = 30 * time.Second DefaultMaxOutput = int64(1 << 20) defaultWaitDelay = time.Second + // HelperWorkingDir is a directory that always exists and that this tool never + // removes, so nothing a helper persists can depend on the caller's CWD. It is + // exported so the at-job inventory can tell a job pinned here from one that + // still carries a pre-upgrade caller CWD. + HelperWorkingDir = "/" + // maxErrorDetail bounds how much captured stderr is folded into an error. + maxErrorDetail = 400 ) // Options controls one helper invocation. Helpers receive a minimal environment @@ -69,39 +80,194 @@ func run(name string, args []string, opts Options, combined bool) ([]byte, error 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 + if err := ctx.Err(); err != nil { + return nil, err } + cmd := exec.Command(name, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.WaitDelay = defaultWaitDelay cmd.Stdin = opts.Stdin cmd.Env = append(helperEnvironment(), opts.ExtraEnv...) + // Pin the working directory the same way the environment is pinned. at(1) + // persists getcwd() into the queued job as a hard `cd || exit 1` + // prologue, so an inherited caller CWD that later disappears turns the + // one-shot auto-revoke into a no-op that never reaches revoke. + cmd.Dir = HelperWorkingDir 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() + err := runWithProcessGroup(ctx, cmd, stdout, stderr) if stdout.exceededLimit() || stderr.exceededLimit() { return stdout.bytes(), fmt.Errorf("%w (%d bytes)", ErrOutputLimit, maxOutput) } - if ctx.Err() == context.DeadlineExceeded { + if errors.Is(err, context.DeadlineExceeded) { return stdout.bytes(), fmt.Errorf("command timed out after %s: %w", timeout, context.DeadlineExceeded) } + if err != nil && !combined { + if detail := firstLines(stderr.bytes()); detail != "" { + return stdout.bytes(), fmt.Errorf("%w: %s", err, detail) + } + } return stdout.bytes(), err } +// runWithProcessGroup keeps the group leader waitable until output has drained +// or exceptional completion has killed the group. Killing after cmd.Wait is not +// safe: Wait reaps the leader, allowing its numeric PID/PGID to be reused. +func runWithProcessGroup(ctx context.Context, cmd *exec.Cmd, stdout, stderr io.Writer) error { + outRead, outWrite, err := os.Pipe() + if err != nil { + return err + } + defer outRead.Close() + defer outWrite.Close() + errRead, errWrite, err := os.Pipe() + if err != nil { + return err + } + defer errRead.Close() + defer errWrite.Close() + cmd.Stdout, cmd.Stderr = outWrite, errWrite + if stdout == stderr { + // Preserve CombinedOutput's ordering by giving both descriptors the + // same pipe, rather than racing independent stdout/stderr readers. + cmd.Stderr = outWrite + } + if err := cmd.Start(); err != nil { + return err + } + _ = outWrite.Close() + _ = errWrite.Close() + streamErrors := make(chan error, 2) + var pumps sync.WaitGroup + pumps.Add(2) + for _, stream := range []struct { + dst io.Writer + src *os.File + }{{stdout, outRead}, {stderr, errRead}} { + go func() { + defer pumps.Done() + _, err := io.Copy(stream.dst, stream.src) + streamErrors <- err + }() + } + drained := make(chan struct{}) + go func() { pumps.Wait(); close(drained) }() + type exitObservation struct { + success bool + err error + } + exited := make(chan exitObservation, 1) + go func() { + var info unix.Siginfo + var err error + for { + err = unix.Waitid(unix.P_PID, cmd.Process.Pid, &info, unix.WEXITED|unix.WNOWAIT, nil) + if !errors.Is(err, unix.EINTR) { + break + } + } + // Linux SIGCHLD's union begins at pointer alignment after the three + // common int32 fields; pid and uid precede si_status. x/sys exposes + // the union as padding, so read only this ABI-defined status field. + type childInfo struct { + header [3]int32 + align [unsafe.Sizeof(uintptr(0)) - 4]byte + pid int32 + uid uint32 + status int32 + } + status := (*childInfo)(unsafe.Pointer(&info)).status + exited <- exitObservation{success: err == nil && info.Code == 1 && status == 0, err: err} + }() + killGroup := func() error { + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return nil + } + return err + } + var cause, killErr error + groupOwned := true + select { + case observed := <-exited: + if observed.err != nil { + cause = fmt.Errorf("observe helper exit: %w", observed.err) + // ECHILD means another reaper already took ownership; never signal + // a numeric group that can now name a different process. + groupOwned = !errors.Is(observed.err, unix.ECHILD) + if groupOwned { + killErr = killGroup() + } + } else if !observed.success { + killErr = killGroup() + } else { + timer := time.NewTimer(defaultWaitDelay) + select { + case <-drained: + case <-ctx.Done(): + cause, killErr = ctx.Err(), killGroup() + case <-timer.C: + cause, killErr = exec.ErrWaitDelay, killGroup() + } + timer.Stop() + } + case <-ctx.Done(): + cause, killErr = ctx.Err(), killGroup() + observed := <-exited + if observed.err != nil { + cause = errors.Join(cause, observed.err) + } + } + // A helper can deliberately leave the group/session. Bound pipe cleanup + // even then; the security boundary is this command's original process group. + timer := time.NewTimer(defaultWaitDelay) + select { + case <-drained: + case <-timer.C: + _ = outRead.Close() + _ = errRead.Close() + <-drained + cause = errors.Join(cause, exec.ErrWaitDelay) + } + timer.Stop() + if copyErr := errors.Join(<-streamErrors, <-streamErrors); copyErr != nil && cause == nil { + cause = fmt.Errorf("read helper output: %w", copyErr) + if groupOwned { + killErr = errors.Join(killErr, killGroup()) + } + } + // Drained output and cancellation can become ready together. Selecting the + // drain does not turn an output-limit/timeout failure into success: finish + // checking cancellation while the leader still pins this numeric PGID. + if ctxErr := ctx.Err(); ctxErr != nil { + cause = errors.Join(cause, ctxErr) + if groupOwned { + killErr = errors.Join(killErr, killGroup()) + } + } + return errors.Join(cmd.Wait(), cause, killErr) +} + +// firstLines condenses captured stderr into a single bounded line suitable for an +// error message. Helpers such as sshd -T name the offending file and line there, +// and dropping it left the operator with nothing but an exit status. +func firstLines(b []byte) string { + text := strings.TrimSpace(string(b)) + if text == "" { + return "" + } + fields := strings.Fields(strings.ReplaceAll(text, "\n", " ")) + text = strings.Join(fields, " ") + if len(text) > maxErrorDetail { + text = strings.TrimSpace(text[:maxErrorDetail]) + "..." + } + return text +} + func helperEnvironment() []string { return []string{ "PATH=" + os.Getenv("PATH"), diff --git a/internal/executil/executil_test.go b/internal/executil/executil_test.go index a0a93b1..3690bda 100644 --- a/internal/executil/executil_test.go +++ b/internal/executil/executil_test.go @@ -3,7 +3,9 @@ package executil import ( "context" "errors" + "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -63,3 +65,124 @@ func TestCombinedOutputBoundsTimeOutputAndDescendants(t *testing.T) { // Kept behind a helper so the test does not need to compare error strings. func contextDeadlineExceeded() error { return context.DeadlineExceeded } + +func TestRunPinsWorkingDirectoryAndKeepsStderrInTheError(t *testing.T) { + t.Run("working directory is pinned regardless of the caller CWD", func(t *testing.T) { + // at(1) persists the submitting process's CWD into the queued job, so a + // helper must never observe a caller-controlled directory that can vanish. + gone := t.TempDir() + restore, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(gone); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(restore) }) + + out, err := Output(helper(t, "pwd"), nil, Options{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("pwd helper: %v", err) + } + if got := strings.TrimSpace(string(out)); got != HelperWorkingDir { + t.Fatalf("helper CWD = %q, want %q (an inherited CWD is what breaks the at fallback)", got, HelperWorkingDir) + } + }) + + t.Run("stderr reaches the operator instead of a bare exit status", func(t *testing.T) { + _, err := Output(helper(t, "echo '/etc/ssh/sshd_config.d/50-cloud.conf: line 4: Bad configuration option' >&2; exit 255"), nil, Options{Timeout: 5 * time.Second}) + if err == nil { + t.Fatal("expected an error from a helper that exits 255") + } + if !strings.Contains(err.Error(), "50-cloud.conf: line 4: Bad configuration option") { + t.Fatalf("error = %q, want the captured stderr folded in", err) + } + if !strings.Contains(err.Error(), "exit status 255") { + t.Fatalf("error = %q, want the exit status preserved", err) + } + }) + + t.Run("oversized stderr is bounded in the error", func(t *testing.T) { + _, err := Output(helper(t, "i=0; while [ $i -lt 200 ]; do printf 'aaaaaaaaaaaaaaaaaaaa' >&2; i=$((i+1)); done; exit 3"), nil, Options{Timeout: 5 * time.Second}) + if err == nil { + t.Fatal("expected an error") + } + if len(err.Error()) > maxErrorDetail+64 { + t.Fatalf("error length = %d, want it bounded near %d", len(err.Error()), maxErrorDetail) + } + }) + + t.Run("CombinedOutput keeps stderr in the output, not duplicated into the error", func(t *testing.T) { + out, err := CombinedOutput(helper(t, "echo marker >&2; exit 1"), nil, Options{Timeout: 5 * time.Second}) + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(string(out), "marker") { + t.Fatalf("combined output = %q, want the stderr line", out) + } + if strings.Contains(err.Error(), "marker") { + t.Fatalf("error = %q, want no duplicated stderr for CombinedOutput", err) + } + }) +} + +func TestEarlyParentExitCleansFailedHelpersButKeepsSuccessfulDaemons(t *testing.T) { + for _, tc := range []struct { + name string + body string + wantErr bool + wantWaitDelay bool + wantMarker bool + }{ + {"inherited pipes", "(sleep 1.4; printf survived > '%s') & exit 0", true, true, false}, + {"failed parent with detached output", "(sleep 0.2; printf survived > '%s') >/dev/null 2>&1 & exit 7", true, false, false}, + {"signaled parent", "(sleep 0.2; printf survived > '%s') >/dev/null 2>&1 & kill -TERM $$", true, false, false}, + {"successful daemon", "(sleep 0.2; printf survived > '%s') >/dev/null 2>&1 & exit 0", false, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + marker := filepath.Join(t.TempDir(), "marker") + start := time.Now() + _, err := CombinedOutput(helper(t, fmt.Sprintf(tc.body, marker)), nil, Options{Timeout: 4 * time.Second}) + if (err != nil) != tc.wantErr || errors.Is(err, exec.ErrWaitDelay) != tc.wantWaitDelay { + t.Fatalf("helper err=%v, wantErr=%v waitDelay=%v", err, tc.wantErr, tc.wantWaitDelay) + } + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("helper completion exceeded bound: %s", elapsed) + } + // Wait past the child's intended write. This checks observable work, + // including detached stdout, rather than treating a zombie as alive. + if tc.wantWaitDelay { + time.Sleep(650 * time.Millisecond) + } else { + time.Sleep(500 * time.Millisecond) + } + _, statErr := os.Stat(marker) + if got := statErr == nil; got != tc.wantMarker { + t.Fatalf("descendant marker present=%v want=%v (stat=%v)", got, tc.wantMarker, statErr) + } + }) + } +} + +func TestOutputLimitAfterSuccessfulParentExitKillsDetachedOutputDescendants(t *testing.T) { + // Race the two readiness notifications repeatedly: the helper succeeds and + // closes its pipes just as the output pump exceeds its bound. An error result + // must not leave the detached-output descendant alive to write afterward. + dir := t.TempDir() + for attempt := 0; attempt < 64; attempt++ { + marker := filepath.Join(dir, fmt.Sprintf("survived-%d", attempt)) + body := fmt.Sprintf("(sleep 0.2; printf survived > '%s') >/dev/null 2>&1 & printf oversized; exit 0", marker) + _, err := CombinedOutput(helper(t, body), nil, Options{Timeout: 5 * time.Second, MaxOutput: 1}) + if !errors.Is(err, ErrOutputLimit) { + t.Fatalf("attempt %d: err=%v want output limit", attempt, err) + } + } + time.Sleep(350 * time.Millisecond) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("output-limit failure left descendants able to write: %v", entries) + } +} diff --git a/internal/expiry/expiry.go b/internal/expiry/expiry.go index ac171fb..290675f 100644 --- a/internal/expiry/expiry.go +++ b/internal/expiry/expiry.go @@ -8,7 +8,10 @@ // lock before the deadline. package expiry -import "time" +import ( + "fmt" + "time" +) const dateLayout = "2006-01-02" @@ -35,8 +38,38 @@ func LockInstant(date string) (time.Time, error) { return time.ParseInLocation(dateLayout, date, time.UTC) } -// DisplayLocal formats the shared revoke deadline for the invite output. Date -// supplies only the later day-granularity lockout backstop. -func DisplayLocal(deadline time.Time) string { - return deadline.Format("2006-01-02 15:04:05 MST") +// Display formats the shared revoke deadline for the invite output and the +// status table. Date supplies only the later day-granularity lockout backstop. +// +// It renders the UTC instant every scheduler backend actually fires on. The +// previous local-time-with-abbreviation form was ambiguous to the one reader who +// acts on it: a recipient elsewhere reads the server's "CST" as their own and can +// be wrong about the account's lifetime by a whole working day. Zone +// abbreviations are not unique and Go prints whatever the host's database calls +// the zone, so no abbreviation can be made safe here. +func Display(deadline time.Time) string { + return deadline.UTC().Format("2006-01-02 15:04:05 UTC") +} + +// DisplayServerLocal renders the same instant in the server's own zone with a +// numeric offset, for the one line of invite output that shows the operator what +// the deadline means locally. It returns "" when the server already runs UTC, so +// the caller prints nothing rather than the same time twice. +func DisplayServerLocal(deadline time.Time) string { + if _, offset := deadline.Zone(); offset == 0 { + return "" + } + return deadline.Format("2006-01-02 15:04:05 -0700") +} + +// ParseDisplay recovers an absolute deadline only from unambiguous registry +// formats. Historical zone abbreviations such as CST cannot identify an instant +// without the original host timezone and must not silently become UTC. +func ParseDisplay(value string) (time.Time, error) { + for _, layout := range []string{"2006-01-02 15:04:05 UTC", "2006-01-02 15:04:05 -0700", time.RFC3339} { + if deadline, err := time.Parse(layout, value); err == nil { + return deadline, nil + } + } + return time.Time{}, fmt.Errorf("expiry has no verifiable absolute deadline: %q (requires UTC or numeric offset)", value) } diff --git a/internal/expiry/expiry_test.go b/internal/expiry/expiry_test.go index 9f39c8d..3271085 100644 --- a/internal/expiry/expiry_test.go +++ b/internal/expiry/expiry_test.go @@ -1,6 +1,11 @@ package expiry import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" "testing" "time" ) @@ -114,3 +119,120 @@ func TestDateIsFirstUTCMidnightAfterDeadline(t *testing.T) { } } } + +// TestLockInstantMatchesWhatChageActuallyStores anchors this package's model to +// the tool it models. LockInstant restates Date's own assumption — that chage +// reads the date as UTC — so every test built on it agrees with Date by +// construction and none of them can notice if chage disagrees. This one asks +// chage, against a throwaway root, and compares the stored sp_expire with the +// day count LockInstant predicts. +// +// Measured while writing it: shadow-utils on this host stores the same value +// under TZ=UTC, TZ=Pacific/Kiritimati (UTC+14) and TZ=Pacific/Honolulu (UTC-10), +// so the UTC model holds here. The point of the test is that a shadow which +// parsed the date locally would now be caught instead of agreeing with a model +// that assumed it away. +func TestLockInstantMatchesWhatChageActuallyStores(t *testing.T) { + if _, err := exec.LookPath("chage"); err != nil { + t.Skip("chage is not installed") + } + const date = "2026-09-08" + want, err := LockInstant(date) + if err != nil { + t.Fatal(err) + } + wantDays := want.Unix() / 86400 + + for _, tz := range []string{"UTC", "Pacific/Kiritimati", "Pacific/Honolulu"} { + t.Run(tz, func(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "etc"), 0o755); err != nil { + t.Fatal(err) + } + files := map[string]string{ + "passwd": "tu:x:4242:4242::/home/tu:/bin/sh\n", + "shadow": "tu:!:19000:0:99999:7:::\n", + "group": "tu:x:4242:\n", + "gshadow": "tu:!::\n", + } + for name, body := range files { + if err := os.WriteFile(filepath.Join(root, "etc", name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + cmd := exec.Command("chage", "--root", root, "-E", date, "tu") + cmd.Env = append(os.Environ(), "TZ="+tz) + if out, err := cmd.CombinedOutput(); err != nil { + t.Skipf("chage --root is unusable here: %v: %s", err, out) + } + shadow, err := os.ReadFile(filepath.Join(root, "etc", "shadow")) + if err != nil { + t.Fatal(err) + } + fields := strings.Split(strings.TrimSpace(string(shadow)), ":") + if len(fields) < 8 { + t.Fatalf("unexpected shadow shape %q", shadow) + } + got, err := strconv.ParseInt(fields[7], 10, 64) + if err != nil { + t.Fatalf("sp_expire %q is not a day count: %v", fields[7], err) + } + if got != wantDays { + t.Fatalf("chage stored sp_expire=%d for %s under TZ=%s; LockInstant predicts %d. "+ + "This package's UTC model does not match the shadow on this host.", got, date, tz, wantDays) + } + }) + } +} + +// The Expires field is the one value a third party acts on, and a zone +// abbreviation is not unique: a server on Asia/Shanghai prints "CST", which a +// reader in Chicago reads as US Central — a 14-hour error in the account's +// lifetime, in the unsafe direction. +func TestDisplayIsUnambiguousAboutTheInstantTheTimerFiresOn(t *testing.T) { + shanghai, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Skipf("tzdata unavailable: %v", err) + } + deadline := time.Date(2026, 7, 8, 12, 0, 0, 0, shanghai) + + got := Display(deadline) + if got != "2026-07-08 04:00:00 UTC" { + t.Fatalf("Display() = %q, want the UTC instant the scheduler fires on", got) + } + for _, ambiguous := range []string{"CST", "MST", "EST", "IST"} { + if strings.Contains(got, ambiguous) { + t.Fatalf("Display() = %q, want no zone abbreviation", got) + } + } + + // The operator still gets the server's own reading, with a numeric offset. + if local := DisplayServerLocal(deadline); local != "2026-07-08 12:00:00 +0800" { + t.Fatalf("DisplayServerLocal() = %q, want the local time with a numeric offset", local) + } + + // A UTC server would otherwise print the same instant twice. + if local := DisplayServerLocal(deadline.UTC()); local != "" { + t.Fatalf("DisplayServerLocal() on a UTC server = %q, want nothing to append", local) + } + + // Display must agree with what the scheduler and the chage backstop use. + if !strings.HasPrefix(Date(deadline), "2026-07-09") { + t.Fatalf("Date() = %q, want the UTC-anchored backstop day after the deadline", Date(deadline)) + } +} + +func TestParseDisplayAbsoluteDeadline(t *testing.T) { + want := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) + for _, value := range []string{"2026-09-26 12:00:00 UTC", "2026-09-26 20:00:00 +0800", "2026-09-26T12:00:00Z"} { + got, err := ParseDisplay(value) + if err != nil || !got.Equal(want) { + t.Errorf("ParseDisplay(%q) = %v, %v", value, got, err) + } + } + for _, value := range []string{"2026-09-26 12:00:00 CST", "2026-09-26 12:00:00", "none", "", "2026-02-30 12:00:00 UTC"} { + if _, err := ParseDisplay(value); err == nil { + t.Errorf("accepted unverifiable deadline %q", value) + } + } +} diff --git a/internal/integrationtest/user.go b/internal/integrationtest/user.go index 1ee5606..66233c6 100644 --- a/internal/integrationtest/user.go +++ b/internal/integrationtest/user.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "github.com/xxvcc/linux-temp-admin/internal/validate" "golang.org/x/sys/unix" ) @@ -65,7 +66,27 @@ func RequireUserAbsent(t interface { } } +// testAccountName reports whether name is one this suite may delete. removeUser +// runs `userdel -r -f` as root from every fixture in the tree, which is the exact +// combination internal/user documents as unsafe for the production path, and it +// took whatever name it was handed. A typo or a fixture reusing a real login +// would have taken that account and its home directory with it. +func testAccountName(name string) bool { + if !validate.Username(name) { + return false + } + for _, prefix := range []string{"xxvcc-", "lta"} { + if strings.HasPrefix(name, prefix) { + return true + } + } + return false +} + func removeUser(name string, force bool) error { + if !testAccountName(name) { + return fmt.Errorf("refusing to remove %q: integration cleanup only deletes accounts in this suite's own namespace", name) + } args := []string{"-r"} if force { args = append(args, "-f") @@ -187,7 +208,12 @@ func databaseHasName(data []byte, name string, parseGID bool) (bool, error) { seen := make(map[string]bool) found := false for lineNumber, line := range strings.Split(string(data), "\n") { - if line == "" { + // Skip what the system's own readers skip. glibc's nss_files ignores blank + // and '#'-comment lines, and both glibc and shadow-utils accept the NIS + // compatibility entries beginning with '+' or '-'. Treating one of those as + // a malformed record failed this inspection outright on a host that carries + // one, for a line that names no group. + if line == "" || line[0] == '#' || line[0] == '+' || line[0] == '-' { continue } parts := strings.Split(line, ":") diff --git a/internal/integrationtest/user_test.go b/internal/integrationtest/user_test.go index d3a7007..7e94c75 100644 --- a/internal/integrationtest/user_test.go +++ b/internal/integrationtest/user_test.go @@ -344,3 +344,64 @@ func TestInspectLocalGroupDatabaseFilesIncludesGShadow(t *testing.T) { t.Fatalf("malformed gshadow error = %v", err) } } + +// TestRemoveUserRefusesNamesOutsideTheSuite pins the guard on a helper that runs +// `userdel -r -f` as root from roughly thirty fixtures. It used to delete +// whatever name it was handed, so a typo or a fixture reusing a real login would +// have taken that account and its home directory with it. +func TestRemoveUserRefusesNamesOutsideTheSuite(t *testing.T) { + for _, name := range []string{"root", "nobody", "postgres", "", "not a name", "../etc"} { + if err := removeUser(name, true); err == nil || !strings.Contains(err.Error(), "refusing to remove") { + t.Errorf("removeUser(%q) = %v, want a namespace refusal", name, err) + } + } + // Names this suite does create must still be accepted by the guard; they fail + // later, on the absent account, not here. + for _, name := range []string{"xxvcc-a1b2c3d4e5", "ltalegacycompact1"} { + if err := removeUser(name, true); err != nil && strings.Contains(err.Error(), "refusing to remove") { + t.Errorf("removeUser(%q) was refused by the namespace guard: %v", name, err) + } + } +} + +// TestRealGroupInspectorReadsBothDatabases drives inspectLocalGroupDatabaseFiles +// itself. Every behavioural subtest in this file replaces it with a fake that +// derives the whole artifacts value from lookupSystemGroup, so the real reader — +// the one that parses /etc/group and /etc/gshadow and decides what +// removeSameNameGroup may touch — was never exercised by any of them. +func TestRealGroupInspectorReadsBothDatabases(t *testing.T) { + dir := t.TempDir() + oldGroup, oldGShadow := groupDatabasePath, gshadowDatabasePath + t.Cleanup(func() { groupDatabasePath, gshadowDatabasePath = oldGroup, oldGShadow }) + write := func(name, body string) string { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path + } + for _, tc := range []struct { + name string + group string + gshadow string + wantGroup bool + wantGShadow bool + }{ + {name: "present in both", group: "xxvcc-g1:x:2001:\n", gshadow: "xxvcc-g1:!::\n", wantGroup: true, wantGShadow: true}, + {name: "group only", group: "xxvcc-g1:x:2001:\n", gshadow: "other:!::\n", wantGroup: true}, + {name: "absent from both", group: "other:x:2002:\n", gshadow: "other:!::\n"}, + {name: "comment and NIS lines are not entries", group: "# c\n+::::\nother:x:2002:\n", gshadow: "# c\nother:!::\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + groupDatabasePath = write("group", tc.group) + gshadowDatabasePath = write("gshadow", tc.gshadow) + got, err := inspectLocalGroupDatabaseFiles("xxvcc-g1") + if err != nil { + t.Fatalf("inspectLocalGroupDatabaseFiles: %v", err) + } + if got.group != tc.wantGroup || got.gshadow != tc.wantGShadow { + t.Fatalf("artifacts = %+v, want group=%v gshadow=%v", got, tc.wantGroup, tc.wantGShadow) + } + }) + } +} diff --git a/internal/lifecycle/lock.go b/internal/lifecycle/lock.go index 21639a4..430ab30 100644 --- a/internal/lifecycle/lock.go +++ b/internal/lifecycle/lock.go @@ -22,6 +22,12 @@ var ErrBusy = errors.New("lifecycle lock is busy") // inode at the same pathname. type Lock struct { Path string + // WriteRootFile is a filesystem fault-injection hook, following the same + // convention as selfmanage.Manager: production leaves it nil and uses + // fsutil.AtomicWriteFileAs. It exists so a test can drive the + // committed-but-unsynced marker branch, which no real filesystem produces on + // demand. + WriteRootFile func(path string, data []byte, perm os.FileMode, uid, gid int) error } // New returns a lifecycle lock at path. @@ -111,11 +117,19 @@ func (l *Lock) acquire(operation int) (func() error, error) { // 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. +// +// A *fsutil.DurabilityError means the marker IS on disk and IsUninstalled will +// read it — only the parent-directory fsync failed. Callers must not report that +// as "the marker was not recorded": the gate it arms is already in force. func (l *Lock) MarkUninstalled() error { if l == nil || l.Path == "" { return nil } - return fsutil.AtomicWriteFileAs(l.tombstonePath(), []byte(tombstoneContent), 0o600, os.Geteuid(), os.Getegid()) + write := fsutil.AtomicWriteFileAs + if l.WriteRootFile != nil { + write = l.WriteRootFile + } + return write(l.tombstonePath(), []byte(tombstoneContent), 0o600, os.Geteuid(), os.Getegid()) } // IsUninstalled validates and reads the marker. Unsafe marker metadata is an diff --git a/internal/lifecycle/lock_test.go b/internal/lifecycle/lock_test.go index 7aa0dbb..5646cf8 100644 --- a/internal/lifecycle/lock_test.go +++ b/internal/lifecycle/lock_test.go @@ -190,3 +190,93 @@ func TestUninstallMarkerRejectsSymlink(t *testing.T) { t.Fatal("symlink uninstall marker was accepted") } } + +// The marker arms the gate that refuses every mutation. Its own doc comment says +// an unsafe or malformed marker must be an ERROR, never "installed" — otherwise a +// local user who can create a file at that path re-enables the tool by making the +// marker unreadable. Only the symlink case was covered; turning either of the +// other two branches into (false, nil) used to pass the whole suite. +func TestUninstallMarkerFailsClosedOnUnsafeMetadataAndBadContent(t *testing.T) { + newMarked := func(t *testing.T) *Lock { + t.Helper() + l := New(filepath.Join(t.TempDir(), "lifecycle.lock")) + if err := l.MarkUninstalled(); err != nil { + t.Fatal(err) + } + if uninstalled, err := l.IsUninstalled(); err != nil || !uninstalled { + t.Fatalf("freshly written marker = %v, %v; want true, nil", uninstalled, err) + } + return l + } + + for _, tc := range []struct { + name string + damage func(t *testing.T, l *Lock) + want string + }{ + { + name: "group- or world-readable mode", + damage: func(t *testing.T, l *Lock) { + if err := os.Chmod(l.tombstonePath(), 0o644); err != nil { + t.Fatal(err) + } + }, + want: "unsafe metadata", + }, + { + name: "not a regular file", + damage: func(t *testing.T, l *Lock) { + if err := os.Remove(l.tombstonePath()); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(l.tombstonePath(), 0o600); err != nil { + t.Fatal(err) + } + }, + want: "unsafe metadata", + }, + { + name: "content from another version", + damage: func(t *testing.T, l *Lock) { + if err := os.WriteFile(l.tombstonePath(), []byte("uninstalled-v2\n"), 0o600); err != nil { + t.Fatal(err) + } + }, + want: "invalid content", + }, + { + name: "truncated content", + damage: func(t *testing.T, l *Lock) { + if err := os.WriteFile(l.tombstonePath(), []byte("uninstalled-v1"), 0o600); err != nil { + t.Fatal(err) + } + }, + want: "invalid content", + }, + { + name: "empty marker", + damage: func(t *testing.T, l *Lock) { + if err := os.WriteFile(l.tombstonePath(), nil, 0o600); err != nil { + t.Fatal(err) + } + }, + want: "invalid content", + }, + } { + t.Run(tc.name, func(t *testing.T) { + l := newMarked(t) + tc.damage(t, l) + + uninstalled, err := l.IsUninstalled() + if err == nil { + t.Fatalf("IsUninstalled() = %v, nil; a damaged marker must fail closed, not read as installed", uninstalled) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("IsUninstalled() error = %v, want it to name %q", err, tc.want) + } + if uninstalled { + t.Fatal("IsUninstalled() returned true alongside an error") + } + }) + } +} diff --git a/internal/mountinfo/mountinfo.go b/internal/mountinfo/mountinfo.go index cc41f2f..dbf3b6f 100644 --- a/internal/mountinfo/mountinfo.go +++ b/internal/mountinfo/mountinfo.go @@ -41,10 +41,25 @@ func RejectUnder(r io.Reader, root string) error { seen := false for sc.Scan() { seen = true - fields := strings.Fields(sc.Text()) + // Split on the separator the kernel actually writes, not on "any Unicode + // space". mountinfo fields are single-space separated and the kernel escapes + // exactly four bytes inside path fields — space, tab, newline and backslash + // (fs/proc_namespace.c passes " \t\n\\" to seq_dentry and seq_path_root). + // strings.Fields splits on unicode.IsSpace, which also covers \v, \f and \r, + // so an unescaped one of those inside a path added a field and shifted the + // positional mountpoint this check reads — turning a real mount under the + // removal root into a "no mount here" verdict for a destructive sweep. + fields := strings.Split(strings.TrimSuffix(sc.Text(), "\r"), " ") if len(fields) < 10 { return fmt.Errorf("malformed mountinfo line") } + for _, field := range fields { + if field == "" { + // The kernel never writes an empty field: every separator is exactly one + // space. Refuse rather than guess at a line this parser cannot model. + return fmt.Errorf("malformed mountinfo line") + } + } if _, err := strconv.ParseUint(fields[0], 10, 64); err != nil { return fmt.Errorf("malformed mountinfo mount id %q", fields[0]) } diff --git a/internal/mountinfo/mountinfo_test.go b/internal/mountinfo/mountinfo_test.go index e445ab1..2ef1dbb 100644 --- a/internal/mountinfo/mountinfo_test.go +++ b/internal/mountinfo/mountinfo_test.go @@ -53,3 +53,32 @@ func TestRejectUnderRejectsUnsafeRoot(t *testing.T) { } } } + +// TestRejectUnderSplitsOnlyOnTheKernelSeparator pins the parser to mountinfo's +// actual format. The kernel escapes exactly space, tab, newline and backslash +// inside path fields, so a vertical tab reaches this parser literally. +// +// The payload matters: strings.Fields turns "/home/u/x\v/etc" into two fields, +// so the positional mountpoint read at index 4 becomes "/etc" — a perfectly +// valid absolute path that is not under the removal root. The check then passes +// the line as an unrelated mount and reports no mount under the root, while the +// real mountpoint in field 5 is /home/u/hidden. A payload whose split halves are +// not valid paths would merely trip the mountpoint validation and pass this test +// against either implementation. +func TestRejectUnderSplitsOnlyOnTheKernelSeparator(t *testing.T) { + shifted := "50 25 0:44 /home/u/x\v/etc /home/u/hidden rw,relatime - ext4 /dev/sda1 rw\n" + if err := RejectUnder(strings.NewReader(shifted), "/home/u"); err == nil { + t.Fatal("RejectUnder reported no mount under the root; the mountpoint field was shifted by an unescaped byte") + } + // The same line without the odd byte must still be caught, so the test is not + // merely detecting a parse refusal. + plain := "50 25 0:44 /home/u/xetc /home/u/hidden rw,relatime - ext4 /dev/sda1 rw\n" + if err := RejectUnder(strings.NewReader(plain), "/home/u"); err == nil { + t.Fatal("RejectUnder accepted a plain mount under the root") + } + // And an unrelated mount must still pass, so it is not refusing everything. + other := "50 25 0:44 / /var/lib/other rw,relatime - ext4 /dev/sda1 rw\n" + if err := RejectUnder(strings.NewReader(other), "/home/u"); err != nil { + t.Fatalf("RejectUnder rejected an unrelated mount: %v", err) + } +} diff --git a/internal/registry/record_test.go b/internal/registry/record_test.go index 99ad277..de2c437 100644 --- a/internal/registry/record_test.go +++ b/internal/registry/record_test.go @@ -368,3 +368,67 @@ func TestV4SchemaStopsOlderWriters(t *testing.T) { t.Fatal("v4 parser accepted a v5 row and could silently discard identity quarantine state") } } + +// These four guards are each the only thing standing between a malformed row and +// a registry that reads as authoritative. None of them had a test: inverting any +// one left the whole registry suite green. +func TestRecordValidationPinsIdentityAndQuarantineInvariants(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + base := func() Record { + return Record{User: "xxvcc-a1", Port: 22, UID: 1500, Generation: generation, IdentityBound: true} + } + reject := func(t *testing.T, r Record, want string) { + t.Helper() + if _, _, err := ParseLine(r.TSV()); err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("ParseLine(%q) error = %v, want %q", r.TSV(), err, want) + } + } + + t.Run("a completed identity-bound row must carry a real non-root UID", func(t *testing.T) { + r := base() + r.UID = 0 + reject(t, r, "completed identity-bound record has no valid uid") + // A negative UID is refused earlier, by the field parser itself. + r.UID = -1 + reject(t, r, "invalid uid") + // A pending row is the one shape allowed to record UID 0: the account does + // not exist yet, so there is no UID to bind to. + pending := base() + pending.UID, pending.Pending = 0, true + if _, ok, err := ParseLine(pending.TSV()); err != nil || !ok { + t.Fatalf("pending row with UID 0 = ok:%v err:%v, want accepted", ok, err) + } + }) + + t.Run("the quarantine deadline must be UTC", func(t *testing.T) { + r := base() + r.DeletionStarted = true + r.QuarantineUnit = "linux-temp-admin-v2-quarantine-xxvcc-a1" + r.QuarantineUntil = "2026-08-01T12:02:00Z" + if _, ok, err := ParseLine(r.TSV()); err != nil || !ok { + t.Fatalf("UTC quarantine deadline = ok:%v err:%v, want accepted", ok, err) + } + // The same instant written with an offset must not parse: the finalizer and + // every comparison against it assume a UTC wall clock. + for _, offset := range []string{"2026-08-01T20:02:00+08:00", "2026-08-01T04:02:00-08:00"} { + r.QuarantineUntil = offset + reject(t, r, "invalid quarantine deadline") + } + }) + + t.Run("quarantine requires an identity-bound deletion row with a finalizer", func(t *testing.T) { + r := base() + r.QuarantineUntil = "2026-08-01T12:02:00Z" + r.QuarantineUnit = "linux-temp-admin-v2-quarantine-xxvcc-a1" + reject(t, r, "quarantine requires an identity-bound deletion row") + + r.DeletionStarted, r.QuarantineUnit = true, "" + reject(t, r, "quarantine has no scheduled finalizer") + }) + + t.Run("a uid-only deletion row must not carry a generation", func(t *testing.T) { + r := base() + r.IdentityBound, r.DeletionStarted = false, true + reject(t, r, "uid-only deletion-started record carries a generation") + }) +} diff --git a/internal/registry/sequence.go b/internal/registry/sequence.go index 5ccbdc3..27f6199 100644 --- a/internal/registry/sequence.go +++ b/internal/registry/sequence.go @@ -137,6 +137,19 @@ func (s *Store) CheckIntegrity() error { } else if err != nil { return err } + // A sequence sitting beside a legacy-header registry is validated as an + // object, but its high-water mark is NOT yet required to cover the rows. + // The doc comment above states the rule — the sequence becomes mandatory + // once a v5 header is visible — and Init implements it: the legacy + // migration branch reseeds the sequence from the highest recorded UID + // before it writes the v5 header. Demanding coverage here reported a state + // Init fixes silently as an integrity failure, and one that neither + // recover-identity-sequence nor RepairMissingIdentitySequence will touch, + // because it is not ErrIdentitySequenceMissing and the registry is not v5. + if _, err := s.requireIdentitySequence(); err != nil { + return fmt.Errorf("identity sequence integrity: %w", err) + } + return nil } _, err = s.requireIdentitySequenceCovering(recs) if err != nil { diff --git a/internal/registry/store.go b/internal/registry/store.go index e8a99cd..8364099 100644 --- a/internal/registry/store.go +++ b/internal/registry/store.go @@ -32,11 +32,15 @@ type Store struct { Sequence string Now func() time.Time - // lostRegistryHighest is nonzero when Init found no registry data file while - // the identity sequence already recorded allocations. It is process-local - // state for doctor, not persisted: the condition is only observable at the - // moment Init recreates the file. - lostRegistryHighest int + // lostRegistryHighest is the sequence high-water mark Init saw when it found no + // registry data file beside a sequence that records prior use. It is + // process-local state for doctor, not persisted: the condition is only + // observable at the moment Init recreates the file. lostRegistryObserved + // carries the finding on its own, because a registry migrated from the + // nine-column v2 format seeds the sequence with highest 0 and is still proof + // of prior use through its isolation deadline. + lostRegistryHighest int + lostRegistryObserved bool } // Default returns a Store using the configured registry paths. @@ -115,8 +119,9 @@ func (s *Store) Init() error { // this tool owns it. Init cannot fail closed here without taking doctor and // uninstall down with it, which would turn a recoverable state into an // unrecoverable one, so it records the fact for doctor to report instead. - if seq, seqErr := s.requireIdentitySequence(); seqErr == nil && seq.highest > 0 { + if seq, seqErr := s.requireIdentitySequence(); seqErr == nil && sequenceRecordsPriorUse(seq) { s.lostRegistryHighest = seq.highest + s.lostRegistryObserved = true } if err := s.ensureIdentitySequence(0, true, time.Time{}); err != nil { return err @@ -544,6 +549,7 @@ func (s *Store) BeginQuarantine(user string, uid int, generation string, deadlin if !current.IdentityBound || (current.UID != 0 && current.UID != uid) || current.Generation != generation { return fmt.Errorf("registry identity changed before quarantine") } + promotesUID := current.UID == 0 && uid > 0 current.UID = uid current.DeletionStarted = true current.QuarantineUntil = deadlineText @@ -555,6 +561,16 @@ func (s *Store) BeginQuarantine(user string, uid int, generation string, deadlin return fmt.Errorf("invalid quarantine transition: %w", err) } out[i] = current + if promotesUID && !isLegacyHeader(snapshot.header) { + // Same order as BeginDeletion's UID-only branch: burn the number + // before publishing the witness that records it. Without this the + // commit's own coverage check rejects the transition this call just + // made, and every retry fails identically — the account becomes + // permanently undeletable by the tool that created it. + if err := s.ensureIdentitySequence(uid, false, time.Time{}); err != nil { + return err + } + } return s.commitMutation(snapshot, out) } return fmt.Errorf("registry identity disappeared before quarantine") @@ -830,6 +846,13 @@ func (s *Store) completelyAbsent() (bool, error) { return true, nil } +// LostRegistryHighest reports ONLY the high-water mark Init observed, and is not +// sufficient to detect loss on its own: a registry migrated from the nine-column +// v2 format records highest 0, so this returns 0 for a host that did lose its +// rows. Use InspectRegistryLoss, which is also the only form that answers in a +// process that never called Init. This accessor remains for the tests that assert +// what Init observed. +// // LostRegistryHighest reports the identity-sequence high-water mark that was // present when Init had to recreate a missing registry data file, or zero when // the registry was intact or genuinely fresh. A nonzero value means rows were @@ -841,3 +864,48 @@ func (s *Store) LostRegistryHighest() int { } return s.lostRegistryHighest } + +// InspectRegistryLoss reports the same condition as LostRegistryHighest without +// depending on this process having called Init. A standalone `doctor` never calls +// Init, so the process-local flag is always clear there and the operator was told +// nothing about a registry whose rows are gone. This probe reads only: it never +// creates the directory, the lock, or the data file. +// +// It answers only while the data file is still absent. Once Init has recreated +// it, an empty v5 registry beside a used sequence is indistinguishable from a +// host whose accounts were all legitimately revoked, so the in-process flag +// remains the authority for that window. +func (s *Store) InspectRegistryLoss() (highest int, lost bool, err error) { + if s == nil { + return 0, false, fmt.Errorf("nil registry store") + } + if s.lostRegistryObserved { + return s.lostRegistryHighest, true, nil + } + if err := s.validateLayout(); err != nil { + return 0, false, err + } + if _, statErr := os.Lstat(s.File); statErr == nil { + return 0, false, nil + } else if !os.IsNotExist(statErr) { + return 0, false, statErr + } + seq, seqErr := s.requireIdentitySequence() + if seqErr != nil { + // No sequence beside a missing registry is a fresh or uninstalled host, not + // a loss. Every other sequence fault is CheckIntegrity's to report. + return 0, false, nil + } + if !sequenceRecordsPriorUse(seq) { + return 0, false, nil + } + return seq.highest, true, nil +} + +// sequenceRecordsPriorUse reports whether a sequence proves this tool already +// allocated identities on this host. A genuinely fresh sequence is written with +// highest 0 and "safe-after none"; a legacy migration stamps a real deadline even +// when the old rows carried no UID column to seed the high-water mark from. +func sequenceRecordsPriorUse(seq identitySequence) bool { + return seq.highest > 0 || !seq.safeAfter.IsZero() +} diff --git a/internal/registry/store_root_test.go b/internal/registry/store_root_test.go index 4fba61f..83b792c 100644 --- a/internal/registry/store_root_test.go +++ b/internal/registry/store_root_test.go @@ -1395,3 +1395,72 @@ func TestInitReportsARecreatedRegistryWhoseSequenceProvesPriorUse(t *testing.T) t.Fatalf("Lookup after the loss: found=%v err=%v, want the row to be gone", found, err) } } + +// TestCheckIntegrityLeavesALegacyRegistryToInit pins the rule the package's own +// doc comment states: a legacy registry may legitimately predate the sequence, +// and the sequence becomes mandatory only once a v5 header is visible. The check +// demanded coverage as soon as a sequence file merely existed beside a legacy +// header — a state Init fixes silently on its migration path, and one that +// neither recover-identity-sequence nor RepairMissingIdentitySequence will +// touch, because the error is not ErrIdentitySequenceMissing and the registry is +// not v5. The operator was told to restore from backup for a host that was fine. +func TestCheckIntegrityLeavesALegacyRegistryToInit(t *testing.T) { + s := newStore(t) + reserveThrough(t, s, 1200) + + // A v4 registry restored from backup beside the sequence this install already + // created, carrying a row above the sequence's high-water mark. + legacy := "# linux-temp-admin registry v4\n" + "xxvcc-legacy1\t2026-01-01 00:00:00 UTC\tnever\tno\t203.0.113.5\t22\tSHA256:x\tno\t\t1500\t\tno\tno\tno\n" + if err := os.WriteFile(s.File, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + if err := s.CheckIntegrity(); err != nil { + t.Fatalf("CheckIntegrity on a legacy registry with a present sequence = %v, want nil", err) + } +} + +// A pending row records UID 0 until the account exists, so quarantining one +// promotes the row to the UID useradd actually assigned. When that UID is above +// the sequence's high-water mark — the ordinary state on a host migrated from a +// legacy registry, whose seed only saw the completed rows — the commit's own +// coverage check rejected the transition the call had just made. Every retry +// failed identically, so an account the security model promises is manually +// recoverable could never be deleted by this tool again. +func TestBeginQuarantinePromotesThePendingUIDThroughTheIdentitySequence(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + deadline := time.Date(2026, 8, 1, 12, 2, 0, 0, time.UTC) + s := newStore(t) + + // The sequence knows only the completed row's 1001; the pending account got 1002. + reserveThrough(t, s, 1001) + if err := s.Record(registry.Record{ + User: "xxvcc-pending1", Port: 22, UID: 0, Generation: generation, + IdentityBound: true, Pending: true, + }); err != nil { + t.Fatal(err) + } + + unit := "linux-temp-admin-v2-quarantine-xxvcc-pending1" + if err := s.BeginQuarantine("xxvcc-pending1", 1002, generation, deadline, unit); err != nil { + t.Fatalf("BeginQuarantine on a pending row above the sequence: %v", err) + } + + got, found, err := s.Lookup("xxvcc-pending1") + if err != nil || !found || !got.DeletionStarted || got.UID != 1002 || got.QuarantineUnit != unit { + t.Fatalf("quarantine transition = found=%v rec=%+v err=%v", found, got, err) + } + + // The promoted UID must also be burned, or the next invite could hand 1002 out + // again while this quarantined identity is still being torn down. + sequence, err := os.ReadFile(filepath.Join(s.Dir, "identity-sequence")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(sequence), "highest\t1002\n") { + t.Fatalf("identity sequence = %q, want the promoted UID 1002 burned", sequence) + } + + if err := s.BeginQuarantine("xxvcc-pending1", 1002, generation, deadline, unit); err != nil { + t.Fatalf("idempotent BeginQuarantine after promotion: %v", err) + } +} diff --git a/internal/registry/store_test.go b/internal/registry/store_test.go index b930650..2d93657 100644 --- a/internal/registry/store_test.go +++ b/internal/registry/store_test.go @@ -336,3 +336,210 @@ func TestFinishDeletionRecoveryRecordsRequiresExactModeAndIdentity(t *testing.T) t.Fatalf("idempotent missing finish = changed %v records %+v err %v", changed, missing, err) } } + +// A registry migrated from the released nine-column v2 format carries no UID +// column, so its sequence is seeded with highest 0 and only a real isolation +// deadline. Treating "highest > 0" as the sole proof of prior use meant losing +// the data file on exactly those hosts reported a clean bill of health. +func TestRegistryLossIsDetectedFromAnyEvidenceOfPriorUse(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("root-owned registry and sequence metadata require root") + } + newDir := func(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + return &Store{ + Dir: dir, + File: filepath.Join(dir, "registry.tsv"), + Lock: filepath.Join(dir, "registry.lock"), + Sequence: filepath.Join(dir, "identity-sequence"), + Now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }, + } + } + writeSequence := func(t *testing.T, s *Store, body string) { + t.Helper() + if err := os.WriteFile(s.sequencePath(), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + + for _, tc := range []struct { + name string + sequence string + wantLost bool + wantHigh int + }{ + { + name: "migrated v2 registry: no UID column, but a real isolation deadline", + sequence: "# linux-temp-admin identity sequence v1\nhighest\t0\nsafe-after\t2026-08-01T12:01:05Z\n", + wantLost: true, + }, + { + name: "ordinary v5 host with allocations", + sequence: "# linux-temp-admin identity sequence v1\nhighest\t1500\nsafe-after\tnone\n", + wantLost: true, + wantHigh: 1500, + }, + { + name: "genuinely fresh install", + sequence: "# linux-temp-admin identity sequence v1\nhighest\t0\nsafe-after\tnone\n", + wantLost: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + s := newDir(t) + writeSequence(t, s, tc.sequence) + highest, lost, err := s.InspectRegistryLoss() + if err != nil { + t.Fatal(err) + } + if lost != tc.wantLost || highest != tc.wantHigh { + t.Fatalf("InspectRegistryLoss() = %d, %v; want %d, %v", highest, lost, tc.wantHigh, tc.wantLost) + } + }) + } + + t.Run("an existing data file is never reported as lost", func(t *testing.T) { + s := newDir(t) + writeSequence(t, s, "# linux-temp-admin identity sequence v1\nhighest\t1500\nsafe-after\tnone\n") + if err := os.WriteFile(s.File, []byte(Header+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, lost, err := s.InspectRegistryLoss(); err != nil || lost { + t.Fatalf("InspectRegistryLoss() with a present registry = %v, %v; want not lost", lost, err) + } + }) + + t.Run("no sequence at all is a fresh or uninstalled host", func(t *testing.T) { + s := newDir(t) + if _, lost, err := s.InspectRegistryLoss(); err != nil || lost { + t.Fatalf("InspectRegistryLoss() with no sequence = %v, %v; want not lost", lost, err) + } + }) +} + +// The read cap is the only thing between an oversized registry file and a silent +// partial read, and Record's refusal is what keeps deletion-recovery witnesses +// exclusive to BeginDeletion. Neither had a test. +func TestReadCapAndRecoveryRowRefusalAreEnforced(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("root-owned registry metadata requires root") + } + newStore := func(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + return &Store{ + Dir: dir, + File: filepath.Join(dir, "registry.tsv"), + Lock: filepath.Join(dir, "registry.lock"), + Sequence: filepath.Join(dir, "identity-sequence"), + Now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }, + } + } + + t.Run("a registry above the read cap fails closed instead of truncating", func(t *testing.T) { + s := newStore(t) + f, err := os.OpenFile(s.File, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(Header + "\n"); err != nil { + t.Fatal(err) + } + // One byte past the cap is enough; the reader must not report the rows it + // did manage to read as the whole registry. + if err := f.Truncate(maxRegistryBytes + 1); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + _, _, err = s.readAllWithHeader() + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("readAllWithHeader on an oversized registry = %v, want a size refusal", err) + } + }) + + t.Run("Record refuses to create a deletion-recovery row", func(t *testing.T) { + s := newStore(t) + err := s.Record(Record{ + User: "xxvcc-a1", Port: 22, UID: 1500, + DeletionStarted: true, + }) + if err == nil || !strings.Contains(err.Error(), "BeginDeletion") { + t.Fatalf("Record(DeletionStarted) = %v, want it routed to BeginDeletion", err) + } + // It must be refused before any lock or file is touched: a recovery witness + // that Record could mint is a deletion authorization nobody proved. + if _, statErr := os.Lstat(s.File); !os.IsNotExist(statErr) { + t.Fatalf("Record created registry state before refusing: %v", statErr) + } + }) +} + +// The sequence is monotonic in both dimensions. Inverting either comparison left +// the whole registry suite green while handing out a retired UID, or erasing an +// active isolation deadline. +func TestIdentitySequenceNeverMovesBackwards(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("writing a root-owned identity sequence requires root") + } + dir := t.TempDir() + s := &Store{ + Dir: dir, + File: filepath.Join(dir, "registry.tsv"), + Lock: filepath.Join(dir, "registry.lock"), + Sequence: filepath.Join(dir, "identity-sequence"), + Now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }, + } + safeAfter := time.Date(2026, 8, 1, 12, 1, 5, 0, time.UTC) + if err := s.ensureIdentitySequence(5000, true, safeAfter); err != nil { + t.Fatal(err) + } + read := func(t *testing.T) identitySequence { + t.Helper() + seq, err := readIdentitySequence(s.sequencePath()) + if err != nil { + t.Fatal(err) + } + return seq + } + + t.Run("a lower seed does not lower the high-water mark", func(t *testing.T) { + if err := s.ensureIdentitySequence(1000, false, time.Time{}); err != nil { + t.Fatal(err) + } + if got := read(t); got.highest != 5000 { + t.Fatalf("highest = %d after seeding 1000, want it held at 5000", got.highest) + } + }) + + t.Run("a zero safe-after does not erase an active isolation deadline", func(t *testing.T) { + if err := s.ensureIdentitySequence(0, false, time.Time{}); err != nil { + t.Fatal(err) + } + if got := read(t); !got.safeAfter.Equal(safeAfter) { + t.Fatalf("safe-after = %v, want the active deadline %v preserved", got.safeAfter, safeAfter) + } + }) + + t.Run("an earlier safe-after does not shorten the isolation window", func(t *testing.T) { + if err := s.ensureIdentitySequence(0, false, safeAfter.Add(-time.Hour)); err != nil { + t.Fatal(err) + } + if got := read(t); !got.safeAfter.Equal(safeAfter) { + t.Fatalf("safe-after = %v, want the later deadline %v kept", got.safeAfter, safeAfter) + } + }) + + t.Run("higher values still advance both", func(t *testing.T) { + later := safeAfter.Add(time.Hour) + if err := s.ensureIdentitySequence(6000, false, later); err != nil { + t.Fatal(err) + } + got := read(t) + if got.highest != 6000 || !got.safeAfter.Equal(later) { + t.Fatalf("sequence = highest %d safe-after %v, want 6000 and %v", got.highest, got.safeAfter, later) + } + }) +} diff --git a/internal/schedule/audit_regression_test.go b/internal/schedule/audit_regression_test.go new file mode 100644 index 0000000..7ce2d99 --- /dev/null +++ b/internal/schedule/audit_regression_test.go @@ -0,0 +1,219 @@ +package schedule + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestSystemdAbsenceRequiresBothBootMarkerAndInitEvidence(t *testing.T) { + for _, tc := range []struct { + name, comm string + marker, absent bool + }{ + {"non-systemd init", "openrc-init\n", false, true}, + {"systemd init without marker", "systemd\n", false, false}, + {"booted marker", "container-init\n", true, false}, + {"unreadable init", "", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + marker, comm := filepath.Join(dir, "system"), filepath.Join(dir, "comm") + if tc.marker { + if err := os.Mkdir(marker, 0o755); err != nil { + t.Fatal(err) + } + } + if tc.comm != "" { + if err := os.WriteFile(comm, []byte(tc.comm), 0o644); err != nil { + t.Fatal(err) + } + } + if got := systemdDefinitelyAbsent(marker, comm); got != tc.absent { + t.Fatalf("absent=%v want=%v", got, tc.absent) + } + }) + } +} + +func TestEnsureAtdVerifiesBootPersistenceEvenWhenAlreadyRunning(t *testing.T) { + for _, backend := range []string{"systemd", "openrc", "chkconfig", "update-rc.d"} { + failures := []string{"", "enable", "verify", "running"} + if backend == "systemd" { + failures = append(failures, "enabled-runtime", "static", "indirect", "alias") + } + for _, failure := range failures { + t.Run(backend+"/"+failure, func(t *testing.T) { + dir := t.TempDir() + log := filepath.Join(dir, "commands") + write := func(name, body string) { + writeCommand(t, dir, name, "printf '%s %s\\n' '"+name+"' \"$*\" >> '"+log+"'\n"+body) + } + status := "exit 0" + if failure == "running" { + status = "exit 1" + } + enable := "exit 0" + if failure == "enable" { + enable = "exit 1" + } + switch backend { + case "systemd": + verify := "printf 'enabled\\n'" + if failure == "enabled-runtime" || failure == "static" || failure == "indirect" || failure == "alias" { + verify = "printf '" + failure + "\\n'" + } + if failure == "verify" { + verify = "exit 1" + } + write("systemctl", "case \"$1\" in enable) "+enable+";; is-enabled) "+verify+";; is-active) "+status+";; *) exit 91;; esac") + case "openrc": + verify := "printf 'atd | default\\n'" + if failure == "verify" { + verify = "printf 'crond | default\\n'" + } + write("rc-update", "case \"$1\" in add) "+enable+";; show) "+verify+";; *) exit 91;; esac") + write("rc-service", status) + case "chkconfig": + verify := "printf 'atd 0:off 1:off 2:on 3:on 4:on 5:on 6:off\\n'" + if failure == "verify" { + verify = "printf 'atd 0:off 1:off 2:on 3:off 4:on 5:on 6:off\\n'" + } + write("chkconfig", "if [ \"$1\" = --list ]; then "+verify+"; else "+enable+"; fi") + write("service", status) + case "update-rc.d": + write("update-rc.d", enable) + write("service", status) + init := filepath.Join(dir, "init.d") + if err := os.Mkdir(init, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(init, "atd"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + for level := 2; level <= 5; level++ { + rc := filepath.Join(dir, fmt.Sprintf("rc%d.d", level)) + if err := os.Mkdir(rc, 0o755); err != nil { + t.Fatal(err) + } + if failure == "verify" && level == 3 { + continue + } + if err := os.Symlink("../init.d/atd", filepath.Join(rc, "S01atd")); err != nil { + t.Fatal(err) + } + } + } + t.Setenv("PATH", dir) + if got := ensureAtdWithSystemd(backend == "systemd", dir); got != (failure == "") { + t.Fatalf("ensureAtd=%v failure=%q", got, failure) + } + calls, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + if backend == "openrc" && !strings.Contains(string(calls), "rc-update add atd default") { + t.Fatalf("running atd was never enabled: %s", calls) + } + }) + } + } +} + +func TestValidScheduleChecksRecordedDeadlineOnBothBackends(t *testing.T) { + for _, backend := range []string{"at", "systemd"} { + if backend == "systemd" && os.Geteuid() != 0 { + continue + } + for _, tc := range []struct { + name string + shift time.Duration + missing bool + want bool + }{ + {"matching", 0, false, true}, + {"late by minute", time.Minute, false, false}, + {"late by day", 24 * time.Hour, false, false}, + {"early", -time.Minute, false, false}, + {"unknown recorded deadline", 0, true, false}, + } { + t.Run(backend+"/"+tc.name, func(t *testing.T) { + sys := &fakeSystem{} + s := newScheduler(t.TempDir(), sys) + deadline := s.Now().Add(time.Hour) + unit := "at:42" + if backend == "at" { + sys.atJobs = []AtJob{{ID: "42", Body: s.RevokeCommand("xxvcc-a1", 1001, testGeneration), ScheduledAt: deadline.Add(tc.shift)}} + } else { + unit = s.UnitName("xxvcc-a1") + writeSchedulePair(t, s, "xxvcc-a1", 1001, testGeneration, deadline.Add(tc.shift)) + } + if tc.missing { + deadline = time.Time{} + } + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit, deadline) + if err != nil || valid != tc.want { + t.Fatalf("ValidSchedule=%v err=%v want=%v", valid, err, tc.want) + } + }) + } + } +} + +func TestScheduleDeadlineMinuteRounding(t *testing.T) { + deadline := time.Date(2026, 9, 26, 12, 0, 17, 0, time.UTC) + for _, tc := range []struct { + trigger time.Time + want bool + }{ + {deadline, true}, {deadline.Truncate(time.Minute).Add(time.Minute), true}, + {deadline.Truncate(time.Minute), false}, {deadline.Add(time.Minute), false}, + {time.Time{}, false}, + } { + if got := scheduleDeadlineMatches(tc.trigger, deadline); got != tc.want { + t.Fatalf("trigger=%v valid=%v want=%v", tc.trigger, got, tc.want) + } + } +} + +func TestNonSystemdHostWithSystemctlAllowsEmptyCleanupAndAtInventory(t *testing.T) { + if !systemdDefinitelyAbsent("/run/systemd/system", "/proc/1/comm") { + t.Skip("requires an actual non-systemd PID 1; exercised in the ordinary test container") + } + dir := t.TempDir() + marker := filepath.Join(dir, "systemctl-called") + writeCommand(t, dir, "systemctl", "printf '%s\\n' \"$*\" > '"+marker+"'; echo 'System has not been booted with systemd as init system (PID 1). Cannot operate.' >&2; exit 1") + t.Setenv("PATH", dir) + s := newScheduler(t.TempDir(), realSystem{}) + if err := s.Cancel("xxvcc-a1", ""); err != nil { + t.Fatalf("empty Cancel on a confirmed non-systemd host: %v", err) + } + if users, err := s.ScheduledUsers(); err != nil || len(users) != 0 { + t.Fatalf("empty inventory=%v err=%v", users, err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("queried an absent manager: %v", err) + } + unit := s.UnitName("xxvcc-a1") + path := filepath.Join(s.SystemdDir, unit+".timer") + if err := os.WriteFile(path, []byte("evidence"), 0o644); err != nil { + t.Fatal(err) + } + if err := s.Cancel("xxvcc-a1", unit); err == nil { + t.Fatal("discarded managed unit evidence while manager was absent") + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("unit evidence not preserved: %v", err) + } +} + +func TestSystemdCleanupDoesNotTreatDBusFailureAsManagerAbsence(t *testing.T) { + sys := &fakeSystem{hasSystemctl: true, systemctlErr: func(...string) error { return fmt.Errorf("Failed to connect to bus: Permission denied") }} + s := newScheduler(t.TempDir(), sys) + if err := s.Cancel("xxvcc-a1", ""); err == nil || !strings.Contains(err.Error(), "Permission denied") { + t.Fatalf("broken manager was suppressed: %v", err) + } +} diff --git a/internal/schedule/orphans.go b/internal/schedule/orphans.go index 9751fb1..b7f49be 100644 --- a/internal/schedule/orphans.go +++ b/internal/schedule/orphans.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/xxvcc/linux-temp-admin/internal/validate" + "syscall" ) // UnitUsers returns every account named by an auto-revoke unit file on disk, @@ -63,7 +64,18 @@ func (s *Scheduler) UnitUsers() ([]string, error) { return users, nil } -var readSystemdDir = os.ReadDir +// readSystemdDir refuses a symlinked directory, the discipline the sibling +// artifact managers apply to every directory they scan (sshdconf and sudoers +// both open with O_NOFOLLOW|O_DIRECTORY). os.ReadDir resolves the path, so a +// replaced unit directory would have been enumerated as if it were ours. +var readSystemdDir = func(name string) ([]os.DirEntry, error) { + f, err := os.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY|syscall.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + defer f.Close() + return f.ReadDir(-1) +} // ScheduledUsers returns accounts named by either systemd units or queued at // jobs. This is the complete uninstall inventory even when registry rows vanish. diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go index f3c9960..54a0dbd 100644 --- a/internal/schedule/schedule.go +++ b/internal/schedule/schedule.go @@ -40,12 +40,22 @@ type System interface { // can inventory root-created jobs whose registry row has been lost. Missing // inventory commands or an unparseable owner header are errors. AtJobs() ([]AtJob, error) + // AtDaemonRunning reports whether atd is currently able to dispatch queued + // jobs. Unlike the submission path it never starts or enables anything: a + // validity check must observe the host, not change it. It returns false with + // no error only when a probe positively answered "not running"; when no probe + // could answer at all it returns an error so callers fail closed instead of + // reporting a queue nobody will drain as healthy. + AtDaemonRunning() (bool, error) } type AtJob struct { ID string Body string OwnerUID uint32 + // ScheduledAt is the queued run time atq reported, or the zero time when atq + // did not print one in a shape this tool parses. + ScheduledAt time.Time } // Scheduler writes units / queues jobs. Paths and time source are fields for tests. @@ -395,6 +405,13 @@ func (s *Scheduler) disableAndConfirmTimerStopped(timerUnit string) error { func (s *Scheduler) scheduleAt(user string, uid int, generation string, deadline time.Time) (string, error) { if !s.Sys.HasAt() { + // Name only what this branch actually observed. Schedule() reaches the at + // fallback both when systemctl is absent and when it is present but failed, + // and reporting the first case in the second sent operators to look for a + // missing tool that was installed and had just been used. + if s.Sys.HasSystemctl() { + return "", fmt.Errorf("at is not available for the fallback task") + } return "", fmt.Errorf("no systemctl or at available") } if !deadline.After(s.now()) { @@ -494,7 +511,7 @@ func (s *Scheduler) Cancel(user, recordedUnit string) error { } } if hasUnitEvidence { - errs = append(errs, fmt.Errorf("systemctl is unavailable; cannot confirm %s.timer is stopped, preserving its unit files and registry evidence", unit)) + errs = append(errs, fmt.Errorf("systemctl is unavailable or no systemd manager is running; cannot confirm %s.timer is stopped, preserving its unit files and registry evidence", unit)) continue } } diff --git a/internal/schedule/schedule_test.go b/internal/schedule/schedule_test.go index 78feedf..9889991 100644 --- a/internal/schedule/schedule_test.go +++ b/internal/schedule/schedule_test.go @@ -28,6 +28,8 @@ type fakeSystem struct { atJobsErr error loadedErr error systemctlErr func(args ...string) error + atdStopped bool + atdProbeErr error } func (f *fakeSystem) HasSystemctl() bool { return f.hasSystemctl } @@ -53,6 +55,15 @@ func (f *fakeSystem) RemoveAtJobsFor(command string) error { } func (f *fakeSystem) AtrmJob(id string) error { f.atrmd = append(f.atrmd, id); return f.atrmErr } func (f *fakeSystem) AtJobs() ([]AtJob, error) { return f.atJobs, f.atJobsErr } + +// atdStopped/atdProbeErr default to a running daemon so existing cases keep their +// meaning; the at-validity tests set them explicitly. +func (f *fakeSystem) AtDaemonRunning() (bool, error) { + if f.atdProbeErr != nil { + return false, f.atdProbeErr + } + return !f.atdStopped, nil +} func (f *fakeSystem) loadedSystemdUnits() ([]string, error) { return f.loadedUnits, f.loadedErr } @@ -962,3 +973,33 @@ func TestCancelRemovesLegacyUnitsToo(t *testing.T) { t.Errorf("the v1 timer was never disabled; systemctl calls: %v", sys.calls) } } + +// The at-fallback failure used to report "no systemctl or at available" on a host +// where systemctl was present and had just been used, sending the operator to +// look for a tool that was installed. +func TestAtFallbackFailureNamesOnlyWhatThisHostIsMissing(t *testing.T) { + for _, tc := range []struct { + name string + hasSystemctl bool + want, notWant string + }{ + {"systemd host whose unit write failed", true, "at is not available", "no systemctl"}, + {"host with neither backend", false, "no systemctl or at available", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + sys := &fakeSystem{hasSystemctl: tc.hasSystemctl, hasAt: false} + s := newScheduler(t.TempDir(), sys) + + _, err := s.scheduleAt("xxvcc-a1", 1001, testGeneration, deadlineAfter(s, 24)) + if err == nil { + t.Fatal("scheduleAt succeeded with no at backend") + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want it to name %q", err, tc.want) + } + if tc.notWant != "" && strings.Contains(err.Error(), tc.notWant) { + t.Fatalf("error = %v, want it not to blame %q on a host that has it", err, tc.notWant) + } + }) + } +} diff --git a/internal/schedule/system.go b/internal/schedule/system.go index 545964c..ecb5d8a 100644 --- a/internal/schedule/system.go +++ b/internal/schedule/system.go @@ -5,7 +5,9 @@ import ( "context" "errors" "fmt" + "os" "os/exec" + "path/filepath" "strconv" "strings" "time" @@ -57,7 +59,22 @@ 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") } +// HasSystemctl keeps uncertain/broken managers on the error-reporting path. +// The binary alone is not evidence of systemd: distribution containers and +// non-systemd hosts commonly install it. Require both sd_booted's marker to be +// absent and a readable non-systemd PID 1 before treating the manager as absent. +func (realSystem) HasSystemctl() bool { + return has("systemctl") && !systemdDefinitelyAbsent("/run/systemd/system", "/proc/1/comm") +} + +func systemdDefinitelyAbsent(bootMarker, initComm string) bool { + if _, err := os.Stat(bootMarker); !os.IsNotExist(err) { + return false + } + comm, err := os.ReadFile(initComm) + name := strings.TrimSpace(string(comm)) + return err == nil && name != "" && name != "systemd" +} func (realSystem) HasAt() bool { return has("at") || has("atq") || has("atrm") || has("atd") || has("batch") } @@ -186,7 +203,7 @@ func (realSystem) ScheduleAt(command string, deadline time.Time) (string, error) } } if !ensureAtd() { - return "", fmt.Errorf("atd is not running and could not be started; use systemd or start atd") + return "", fmt.Errorf("atd could not be confirmed running and enabled at boot; use systemd or configure a persistent atd service") } opts := schedulerCommandOptions(schedulerOutputLimit) // POSIX at -t is minute-granular and interprets its operand in the process @@ -260,50 +277,121 @@ func parseAtJobID(out string) string { return id } -// 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. +// ensureAtd requires both a running daemon and persistent boot enablement. +// A process-name probe cannot prove the latter and is never a submission backend. func ensureAtd() bool { + return ensureAtdWithSystemd((realSystem{}).HasSystemctl(), "/etc") +} + +func ensureAtdWithSystemd(systemd bool, initDir string) bool { 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 - } - _ = executil.Run("systemctl", []string{"enable", "--now", "atd"}, schedulerCommandOptions(schedulerOutputLimit)) - if run("systemctl", "is-active", "--quiet", "atd") { - return true + if systemd { + // enable must succeed even when atd was already started manually. Then + // query both properties; active alone permits a reboot to strand jobs. + if !run("systemctl", "enable", "--now", "atd") { + return false } + out, err := executil.Output("systemctl", []string{"is-enabled", "atd"}, schedulerCommandOptions(schedulerOutputLimit)) + // A zero exit also covers static, indirect and enabled-runtime. Only + // persistent enabled proves that this service is armed for later boots. + return err == nil && strings.TrimSpace(string(out)) == "enabled" && + run("systemctl", "is-active", "--quiet", "atd") } if has("rc-service") { - if run("rc-service", "atd", "status") { - return true + if !has("rc-update") || !run("rc-update", "add", "atd", "default") { + return false } - _ = executil.Run("rc-service", []string{"atd", "start"}, schedulerCommandOptions(schedulerOutputLimit)) - if run("rc-service", "atd", "status") { - return true + out, err := executil.Output("rc-update", []string{"show", "default"}, schedulerCommandOptions(schedulerOutputLimit)) + if err != nil || !openRCAtdEnabled(string(out)) { + return false } + if !run("rc-service", "atd", "status") && !run("rc-service", "atd", "start") { + return false + } + return run("rc-service", "atd", "status") } if has("service") { - if run("service", "atd", "status") { - return true + switch { + case has("chkconfig"): + if !run("chkconfig", "atd", "on") { + return false + } + out, err := executil.Output("chkconfig", []string{"--list", "atd"}, schedulerCommandOptions(schedulerOutputLimit)) + if err != nil || !sysVAtdEnabled(string(out)) { + return false + } + case has("update-rc.d"): + if !run("update-rc.d", "atd", "enable") || !sysVAtdLinksEnabled(initDir) { + return false + } + default: + return false } - _ = run("service", "atd", "start") - if run("service", "atd", "status") { - return true + if !run("service", "atd", "status") && !run("service", "atd", "start") { + return false } + return run("service", "atd", "status") } - if has("pgrep") { - // atd starts as root and may drop its effective credentials to the daemon - // account, but its real UID remains 0. Binding the fallback probe to real root - // prevents an unprivileged process from spoofing only the short name "atd". - return run("pgrep", "-x", "-U", "0", "atd") + return false +} + +func openRCAtdEnabled(out string) bool { + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) == 3 && fields[0] == "atd" && fields[1] == "|" && fields[2] == "default" { + return true + } } return false } +func sysVAtdEnabled(out string) bool { + fields := strings.Fields(strings.TrimSpace(out)) + if len(fields) != 8 || fields[0] != "atd" { + return false + } + for level := 2; level <= 5; level++ { + if fields[level+1] != strconv.Itoa(level)+":on" { + return false + } + } + return true +} + +func sysVAtdLinksEnabled(initDir string) bool { + // Debian's atd init script declares multi-user levels 2,3,4,5. Check the + // enabled links update-rc.d persisted, including their executable target. + for level := 2; level <= 5; level++ { + entries, err := os.ReadDir(filepath.Join(initDir, "rc"+strconv.Itoa(level)+".d")) + if err != nil { + return false + } + found := false + for _, entry := range entries { + name := entry.Name() + if len(name) != 6 || name[0] != 'S' || name[1] < '0' || name[1] > '9' || + name[2] < '0' || name[2] > '9' || name[3:] != "atd" { + continue + } + path := filepath.Join(initDir, "rc"+strconv.Itoa(level)+".d", name) + target, err := filepath.EvalSymlinks(path) + if err != nil || target != filepath.Join(initDir, "init.d", "atd") { + continue + } + info, err := os.Stat(target) + if err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 { + found = true + } + } + if !found { + return false + } + } + return true +} + func (realSystem) AtrmJob(id string) error { if id == "" { return fmt.Errorf("empty at job id") @@ -543,17 +631,24 @@ func (realSystem) AtJobs() ([]AtJob, error) { defer cancel() queueOpts := schedulerCommandOptions(atQueueOutputLimit) queueOpts.Context = ctx + // Pin the read-back zone the same way ScheduleAt pins the submission zone. + // executil gives helpers no TZ, so atq would otherwise render queued times in + // /etc/localtime while this process parses them in its own $TZ — on a host + // where those differ, a pending deadline reads as already passed (or the + // reverse) by the zone offset. + queueOpts.ExtraEnv = append(queueOpts.ExtraEnv, "TZ=UTC") out, err := executil.Output("atq", nil, queueOpts) if err != nil { return nil, fmt.Errorf("atq: %w", err) } - ids, err := atqueue.ParseInventory(out, int(schedulerOutputLimit)) + entries, err := atqueue.ParseInventoryEntries(out, int(schedulerOutputLimit)) if err != nil { return nil, err } var jobs []AtJob totalBodyBytes := int64(0) - for _, id := range ids { + for _, entry := range entries { + id := entry.ID body, owner, present, err := readAtJobContext(ctx, id) if err != nil { return nil, fmt.Errorf("inspect at job %s: %w", id, err) @@ -562,18 +657,71 @@ func (realSystem) AtJobs() ([]AtJob, error) { continue } if owner != 0 { - jobs = append(jobs, AtJob{ID: id, OwnerUID: owner}) + jobs = append(jobs, AtJob{ID: id, OwnerUID: owner, ScheduledAt: entry.ScheduledAt}) continue } 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: body, OwnerUID: owner}) + jobs = append(jobs, AtJob{ID: id, Body: body, OwnerUID: owner, ScheduledAt: entry.ScheduledAt}) } return jobs, nil } +// AtDaemonRunning probes atd without starting or enabling it. Each branch only +// reports a definite answer; when no probe on this host can answer, the caller +// gets an error rather than a healthy-looking false negative. +func (realSystem) AtDaemonRunning() (bool, error) { + probe := func(name string, args ...string) error { + return executil.Run(name, args, schedulerCommandOptions(schedulerOutputLimit)) + } + if has("systemctl") { + if probe("systemctl", "is-active", "--quiet", "atd") == nil { + return true, nil + } + // `is-active` exits non-zero for a known-but-inactive unit and for an + // unknown one alike, so only a unit systemd actually knows is evidence. + if probe("systemctl", "cat", "--no-pager", "atd.service") == nil { + return false, nil + } + } + if has("rc-service") { + if probe("rc-service", "atd", "status") == nil { + return true, nil + } + if probe("rc-service", "--exists", "atd") == nil { + return false, nil + } + } + if has("service") { + if probe("service", "atd", "status") == nil { + return true, nil + } + } + if has("pgrep") { + // Bind to real root: atd keeps real UID 0 even after dropping its effective + // credentials, so an unprivileged process that merely names itself "atd" + // must not satisfy this probe. Without -U 0 any local user — including the + // temporary account this tool creates — could make a dead at backend report + // healthy, defeating the check this probe exists for. + switch err := probe("pgrep", "-x", "-U", "0", "atd"); { + case err == nil: + return true, nil + case isExitCode(err, 1): + return false, nil + } + } + return false, fmt.Errorf("no available probe could confirm whether atd is running") +} + +// isExitCode reports whether err is a plain non-zero exit with the given status, +// which distinguishes "the probe ran and said no" from "the probe never ran". +func isExitCode(err error, code int) bool { + var exit *exec.ExitError + return errors.As(err, &exit) && exit.ExitCode() == code +} + func rootAtBodyMatches(body string, match func(string) (bool, error)) (bool, error) { owner, err := atqueue.ParseOwner([]byte(body), int(atJobBodyLimit)) if err != nil { @@ -697,3 +845,38 @@ func atBodyHasExactCommand(body, command string) bool { } return false } + +// atBodyRunsFromAPinnedDirectory reports whether the queued script's own `cd` +// prologue — at(1) records getcwd() at submission time — names a directory that +// will still be there when the job fires. Jobs queued by releases before the +// working directory was pinned carry the submitting operator's CWD, and the +// script exits 1 at that cd without ever reaching revoke. Such a job is in the +// queue but is not a schedule, so ValidSchedule must not accept it. +func atBodyRunsFromAPinnedDirectory(body string) bool { + for _, line := range strings.Split(body, "\n") { + trimmed := strings.TrimSpace(line) + rest, ok := strings.CutPrefix(trimmed, "cd ") + if !ok { + continue + } + // at writes `cd || {` — take the directory up to the first separator. + dir := strings.TrimSpace(rest) + if i := strings.Index(dir, "||"); i >= 0 { + dir = strings.TrimSpace(dir[:i]) + } + dir = strings.Trim(dir, `"'`) + if dir == "" { + continue + } + if dir == executil.HelperWorkingDir { + continue + } + // Anything else is the caller's inherited CWD. Accept it only while it is + // still present: an existing directory still runs, and reporting every + // pre-upgrade job as stranded would be its own false alarm. + if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { + return false + } + } + return true +} diff --git a/internal/schedule/system_test.go b/internal/schedule/system_test.go index 1e7a4b2..c172d8d 100644 --- a/internal/schedule/system_test.go +++ b/internal/schedule/system_test.go @@ -380,13 +380,13 @@ func TestEnsureAtdRejectsWhenNoProbeCanConfirmIt(t *testing.T) { } } -func TestEnsureAtdPgrepFallbackRequiresRootRealUID(t *testing.T) { +func TestEnsureAtdRejectsPgrepWithoutBootEnablement(t *testing.T) { dir := t.TempDir() writeCommand(t, dir, "pgrep", `[ "$1" = -x ] && [ "$2" = -U ] && [ "$3" = 0 ] && [ "$4" = atd ]`) t.Setenv("PATH", dir) - if !ensureAtd() { - t.Fatal("ensureAtd did not accept the root-bound pgrep confirmation") + if ensureAtd() { + t.Fatal("ensureAtd accepted process presence without persistent boot enablement") } } @@ -399,8 +399,8 @@ func TestEnsureAtdDoesNotTrustServiceStartExitAlone(t *testing.T) { 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) + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("service was started without a persistent boot backend: %v", err) } } @@ -424,7 +424,8 @@ 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, "rc-service", "exit 0") + writeCommand(t, dir, "rc-update", `if [ "$1" = show ]; then printf 'atd | default\n'; fi`) writeCommand(t, dir, "at", "[ \"$LC_ALL\" = C ] || { echo localized-output >&2; exit 9; }; [ \"$TZ\" = UTC ] || { echo wrong-timezone >&2; exit 8; }; [ \"$1\" = -t ] && [ \"$2\" = 203001020804 ] || { echo wrong-deadline >&2; exit 7; }; IFS= read -r first; IFS= read -r second; [ \"$first\" = 'unset TZ' ] && [ \"$second\" = true ] || { echo wrong-job-body >&2; exit 6; }; echo 'job 7 at Fri Jul 24 00:00:00 2026'") t.Setenv("PATH", dir) t.Setenv("LC_ALL", "C.UTF-8") @@ -449,7 +450,8 @@ func TestScheduleAtRollsBackAmbiguouslySubmittedJob(t *testing.T) { queued := filepath.Join(dir, "queued") body := filepath.Join(dir, "body") removed := filepath.Join(dir, "removed") - writeCommand(t, dir, "pgrep", "exit 0") + writeCommand(t, dir, "rc-service", "exit 0") + writeCommand(t, dir, "rc-update", `if [ "$1" = show ]; then printf 'atd | default\n'; fi`) writeCommand(t, dir, "atq", "[ -f '"+queued+"' ] && printf '42 x\\n'; exit 0") writeCommand(t, dir, "atrm", "/bin/rm -f '"+queued+"'; printf '%s\\n' \"$1\" > '"+removed+"'") writeCommand(t, dir, "at", `if [ "$1" = "-c" ]; then @@ -659,3 +661,113 @@ func TestRemoveAtJobsForFailsClosedOnPartialAtBackend(t *testing.T) { t.Fatalf("partial at backend error = %v, want inventory failure", err) } } + +// TestEnsureAtdPersistsOnNonSystemdHosts pins the enablement the non-systemd +// branches were missing. The systemd branch arms atd for later boots with +// `enable --now`; OpenRC and sysvinit only started it, so on exactly the hosts +// where the at fallback IS the auto-revoke mechanism, every queued revocation +// stopped firing after the next reboot. +func TestEnsureAtdPersistsOnNonSystemdHosts(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "enabled") + write := func(name, body string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body), 0o755); err != nil { + t.Fatal(err) + } + } + // status never confirms, so ensureAtd runs the whole branch and returns false; + // what is under test is whether it armed atd for the next boot on the way. + write("rc-service", "exit 1\n") + write("rc-update", "printf '%s\\n' \"$*\" >> "+marker+"\nexit 0\n") + // PATH holds only these, so systemctl/service/pgrep are absent and the OpenRC + // branch is the one taken. + t.Setenv("PATH", dir) + + if ensureAtd() { + t.Fatal("ensureAtd claimed success while rc-service status never confirmed") + } + got, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("atd was started but never armed for later boots: %v", err) + } + if want := "add atd default\nshow default\n"; string(got) != want { + t.Fatalf("rc-update invocation = %q, want %q", got, want) + } +} + +// AtDaemonRunning decides whether a queued at job will ever be dispatched, so a +// spoofable probe makes doctor report a dead backend as healthy. It must bind to +// real root exactly as ensureAtd's fallback does, and — unlike ensureAtd — it must +// never start or enable anything: a validity check reports on a host, it does not +// change one. +func TestAtDaemonRunningProbesWithoutMutatingAndBindsPgrepToRealRoot(t *testing.T) { + t.Run("pgrep must be bound to real UID 0", func(t *testing.T) { + dir := t.TempDir() + // Accepts ONLY the root-bound form; a bare `pgrep -x atd` exits 1 here, + // which is the spoofable call this test exists to forbid. + writeCommand(t, dir, "pgrep", `[ "$1" = -x ] && [ "$2" = -U ] && [ "$3" = 0 ] && [ "$4" = atd ]`) + t.Setenv("PATH", dir) + + running, err := realSystem{}.AtDaemonRunning() + if err != nil || !running { + t.Fatalf("AtDaemonRunning = %v, %v; want the root-bound pgrep form accepted", running, err) + } + }) + + t.Run("an unprivileged process merely named atd does not count", func(t *testing.T) { + dir := t.TempDir() + // Mimics pgrep on a host where only a non-root process is named atd: the + // root-bound query finds nothing and exits 1. + writeCommand(t, dir, "pgrep", `exit 1`) + t.Setenv("PATH", dir) + + running, err := realSystem{}.AtDaemonRunning() + if err != nil { + t.Fatalf("AtDaemonRunning error = %v, want a definite negative", err) + } + if running { + t.Fatal("AtDaemonRunning accepted a process that is not real-root atd") + } + }) + + t.Run("it never starts or enables atd", func(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "mutated") + for _, name := range []string{"systemctl", "rc-service", "service", "rc-update"} { + // Any start/enable/add verb records itself and succeeds, so a probe that + // mutates would both be detected and look successful. + writeCommand(t, dir, name, "case \"$*\" in *start*|*enable*|*add*) : > '"+marker+"'; exit 0;; esac; exit 1") + } + writeCommand(t, dir, "pgrep", `exit 1`) + t.Setenv("PATH", dir) + + if _, err := (realSystem{}).AtDaemonRunning(); err != nil { + t.Fatalf("AtDaemonRunning error = %v", err) + } + if _, err := os.Stat(marker); err == nil { + t.Fatal("AtDaemonRunning started or enabled atd; a validity probe must not change the host") + } + }) + + t.Run("no probe available fails closed with an error", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + running, err := realSystem{}.AtDaemonRunning() + if err == nil { + t.Fatalf("AtDaemonRunning = %v, nil; want an error when nothing can answer", running) + } + if running { + t.Fatal("AtDaemonRunning returned true alongside an error") + } + }) + + t.Run("a systemctl-confirmed active unit is enough", func(t *testing.T) { + dir := t.TempDir() + writeCommand(t, dir, "systemctl", `[ "$1" = is-active ]`) + t.Setenv("PATH", dir) + + running, err := realSystem{}.AtDaemonRunning() + if err != nil || !running { + t.Fatalf("AtDaemonRunning = %v, %v; want the systemctl confirmation accepted", running, err) + } + }) +} diff --git a/internal/schedule/valid.go b/internal/schedule/valid.go index 1edbab2..efb45a5 100644 --- a/internal/schedule/valid.go +++ b/internal/schedule/valid.go @@ -25,8 +25,9 @@ var ( // 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) { +// failures that prevent a reliable inventory return an error. The trigger must +// match the recorded deadline, allowing only upward rounding to its next minute. +func (s *Scheduler) ValidSchedule(user string, uid int, generation, recordedUnit string, deadline time.Time) (bool, error) { if !validate.Username(user) || !validate.AccountID(uid) || !validate.Generation(generation) { return false, nil } @@ -47,6 +48,8 @@ func (s *Scheduler) ValidSchedule(user string, uid int, generation, recordedUnit return false, fmt.Errorf("inventory at jobs: %w", err) } found := false + var queued time.Time + var body string for _, job := range jobs { if job.ID != id { continue @@ -55,8 +58,32 @@ func (s *Scheduler) ValidSchedule(user string, uid int, generation, recordedUnit return false, nil } found = true + queued = job.ScheduledAt + body = job.Body } - return found, nil + if !found { + return false, nil + } + // Presence in the queue is not validity. The systemd branch below rejects a + // trigger that has already passed; the at branch must reject the same shape, + // or a deadline that went by while atd was stopped reads as healthy forever. + if !scheduleDeadlineMatches(queued, deadline) || !queued.After(s.nowFunc()()) { + return false, nil + } + // A job queued before the working directory was pinned still carries the + // submitting operator's CWD in its own `cd` prologue, and exits there + // without reaching revoke once that directory is gone. It is in the queue + // but it is not a schedule. + if !atBodyRunsFromAPinnedDirectory(body) { + return false, nil + } + // A queue nobody drains is not a schedule. This probe never starts atd: + // reporting on a host must not change it. + running, err := s.Sys.AtDaemonRunning() + if err != nil { + return false, fmt.Errorf("confirm atd is running: %w", err) + } + return running, nil } unit := s.UnitName(user) @@ -83,16 +110,27 @@ func (s *Scheduler) ValidSchedule(user string, uid int, generation, recordedUnit if err != nil { return false, nil } - now := time.Now - if s.Now != nil { - now = s.Now - } - if !trigger.After(now().UTC()) { + now := s.nowFunc() + if !scheduleDeadlineMatches(trigger, deadline) || !trigger.After(now().UTC()) { return false, nil } return s.systemdTimerExecutable(unit + ".timer") } +// scheduleDeadlineMatches accepts exact deadlines and the single upward minute +// rounding used by at. It never treats a missing timestamp or a whole extra +// minute as healthy; older non-minute registry timestamps remain inspectable. +func scheduleDeadlineMatches(trigger, deadline time.Time) bool { + if trigger.IsZero() || deadline.IsZero() { + return false + } + rounded := deadline.Truncate(time.Minute) + if !deadline.Equal(rounded) { + rounded = rounded.Add(time.Minute) + } + return trigger.Equal(deadline) || trigger.Equal(rounded) +} + // ValidQuarantine reports whether recordedUnit is the exact persistent systemd // finalizer for this deletion generation and deadline. Quarantine never uses at, // and its namespace is deliberately separate from the account's expiry task. @@ -232,3 +270,11 @@ func readScheduleFile(path string) ([]byte, bool, error) { 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 } + +// nowFunc returns the Scheduler's time source, defaulting to time.Now. +func (s *Scheduler) nowFunc() func() time.Time { + if s != nil && s.Now != nil { + return s.Now + } + return time.Now +} diff --git a/internal/schedule/valid_test.go b/internal/schedule/valid_test.go index 6d28bbc..b7a63b5 100644 --- a/internal/schedule/valid_test.go +++ b/internal/schedule/valid_test.go @@ -2,6 +2,7 @@ package schedule import ( "errors" + "fmt" "os" "path/filepath" "strconv" @@ -15,15 +16,17 @@ import ( const testGeneration = "0123456789abcdef0123456789abcdef" +var validTestDeadline = time.Date(2026, 7, 7, 13, 0, 0, 0, time.UTC) + 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"}, + {ID: "42", Body: "SHELL=/bin/sh\n" + s.RevokeCommand("xxvcc-a1", 1001, testGeneration) + "\n", ScheduledAt: validTestDeadline}, } - valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42") + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42", validTestDeadline) if err != nil { t.Fatal(err) } @@ -54,7 +57,7 @@ func TestValidScheduleRejectsReservedLinuxUID(t *testing.T) { sys := &fakeSystem{atJobsErr: errors.New("must not inventory")} s := newScheduler(t.TempDir(), sys) reservedKernelID := uint64(^uint32(0)) - valid, err := s.ValidSchedule("xxvcc-a1", int(reservedKernelID), testGeneration, "at:42") + valid, err := s.ValidSchedule("xxvcc-a1", int(reservedKernelID), testGeneration, "at:42", validTestDeadline) if err != nil || valid { t.Fatalf("ValidSchedule reserved UID = %v, %v; want false, nil", valid, err) } @@ -65,7 +68,7 @@ func TestValidScheduleRejectsWrongAtIdentityAndPropagatesInventoryError(t *testi 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") + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42", validTestDeadline) if err != nil { t.Fatal(err) } @@ -74,14 +77,14 @@ func TestValidScheduleRejectsWrongAtIdentityAndPropagatesInventoryError(t *testi } sys.atJobsErr = errors.New("atq failed") - if _, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42"); err == nil || !strings.Contains(err.Error(), "atq failed") { + if _, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42", validTestDeadline); 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") + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42", validTestDeadline) if err == nil || valid || !strings.Contains(err.Error(), "no system backend") { t.Fatalf("ValidSchedule = %v, %v; want bounded backend error", valid, err) } @@ -96,7 +99,7 @@ func TestValidScheduleAcceptsExactRootOwnedSystemdPair(t *testing.T) { 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) + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit, validTestDeadline) if err != nil { t.Fatal(err) } @@ -153,7 +156,7 @@ func TestValidScheduleAcceptsLegacyOneMinuteAccuracyTimer(t *testing.T) { t.Fatal(err) } - valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit) + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit, validTestDeadline) if err != nil || !valid { t.Fatalf("legacy timer validity = %v, %v; want true", valid, err) } @@ -226,7 +229,7 @@ func TestValidScheduleRequiresEnabledAndActiveSystemdTimer(t *testing.T) { 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) + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit, validTestDeadline) if tt.wantErr == "" && err != nil { t.Fatalf("ValidSchedule: %v", err) } @@ -364,7 +367,7 @@ func TestValidScheduleRejectsTamperedOrExpiredSystemdPair(t *testing.T) { 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) + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit, validTestDeadline) if err != nil { t.Fatal(err) } @@ -379,7 +382,7 @@ func TestValidScheduleRejectsTamperedOrExpiredSystemdPair(t *testing.T) { 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) + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, unit, validTestDeadline) if err != nil { t.Fatal(err) } @@ -413,7 +416,7 @@ func TestValidScheduleMetadataRequiresRootOwnedRegular0644File(t *testing.T) { 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) + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, recorded, validTestDeadline) if err != nil { t.Fatalf("recorded %q: %v", recorded, err) } @@ -425,7 +428,7 @@ func TestValidScheduleRejectsUnexpectedRecordedUnit(t *testing.T) { func TestValidScheduleRejectsNilSchedulerWithoutPanicking(t *testing.T) { var s *Scheduler - valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42") + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:42", validTestDeadline) if err == nil || valid || !strings.Contains(err.Error(), "no scheduler configured") { t.Fatalf("nil ValidSchedule = %v, %v", valid, err) } @@ -448,3 +451,185 @@ func writeSchedulePair(t *testing.T, s *Scheduler, user string, uid int, generat } } } + +// A queued at job is only a schedule if its deadline is still ahead and +// something is left to dispatch it. Presence in the queue proves neither. +func TestValidScheduleRejectsAtJobWhoseDeadlinePassedOrWhoseDaemonIsGone(t *testing.T) { + now := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC) + setup := func(t *testing.T) (*Scheduler, *fakeSystem) { + t.Helper() + sys := &fakeSystem{} + s := newScheduler(t.TempDir(), sys) + s.Now = func() time.Time { return now } + sys.atJobs = []AtJob{{ + ID: "57", + Body: s.RevokeCommand("xxvcc-a1", 1001, testGeneration) + "\n", + ScheduledAt: now.Add(24 * time.Hour), + }} + return s, sys + } + + t.Run("future deadline with a running atd is valid", func(t *testing.T) { + s, _ := setup(t) + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:57", now.Add(24*time.Hour)) + if err != nil || !valid { + t.Fatalf("ValidSchedule = %v, %v; want true, nil", valid, err) + } + }) + + t.Run("deadline already passed while the job sat in the queue", func(t *testing.T) { + s, sys := setup(t) + sys.atJobs[0].ScheduledAt = now.Add(-96 * time.Hour) + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:57", now.Add(24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if valid { + t.Fatal("a job whose deadline passed four days ago was reported as a valid schedule") + } + }) + + t.Run("atd is not running, so nothing will ever dispatch the job", func(t *testing.T) { + s, sys := setup(t) + sys.atdStopped = true + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:57", now.Add(24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if valid { + t.Fatal("a queued job on a host with a stopped atd was reported as a valid schedule") + } + }) + + t.Run("no probe could answer, so it fails closed with an error", func(t *testing.T) { + s, sys := setup(t) + sys.atdProbeErr = errors.New("no available probe") + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:57", now.Add(24*time.Hour)) + if err == nil || valid { + t.Fatalf("ValidSchedule = %v, %v; want false and an error", valid, err) + } + if !strings.Contains(err.Error(), "atd") { + t.Fatalf("error = %v, want it to name the atd probe", err) + } + }) + + t.Run("an atq without a parseable time cannot prove the deadline", func(t *testing.T) { + s, sys := setup(t) + sys.atJobs[0].ScheduledAt = time.Time{} + valid, err := s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:57", now.Add(24*time.Hour)) + if err != nil || valid { + t.Fatalf("ValidSchedule = %v, %v; want false, nil for an unknown deadline", valid, err) + } + }) +} + +// Cancel must never report success while the timer it was asked to cancel is +// still active in the manager: that is the shape that leaves a stale task able +// to delete a later account that reused the name. +func TestCancelFailsClosedWhenTheTimerSurvivesAnExplicitStop(t *testing.T) { + unitFileMissing := func(unit string) *systemctlError { + return &systemctlError{ + args: []string{"disable", "--now", unit}, + err: errors.New("exit status 1"), + output: fmt.Sprintf("Failed to disable unit: Unit file %s does not exist.", unit), + } + } + + newCase := func(t *testing.T, isActive error) error { + t.Helper() + sys := &fakeSystem{hasSystemctl: true} + s := newScheduler(t.TempDir(), sys) + unit := s.UnitName("xxvcc-a1") + ".timer" + sys.systemctlErr = func(args ...string) error { + switch { + case len(args) == 3 && args[0] == "disable": + return unitFileMissing(unit) + case len(args) == 2 && args[0] == "stop": + return nil + case len(args) == 2 && args[0] == "is-active": + return isActive + } + return nil + } + return s.Cancel("xxvcc-a1", s.UnitName("xxvcc-a1")) + } + + t.Run("is-active reports the timer is still running", func(t *testing.T) { + err := newCase(t, nil) + if err == nil { + t.Fatal("Cancel reported success while the timer was still active") + } + if !strings.Contains(err.Error(), "remains active after stop") { + t.Fatalf("error = %v, want it to name the surviving timer", err) + } + }) + + t.Run("is-active failed in a way that proves nothing", func(t *testing.T) { + err := newCase(t, &systemctlError{ + args: []string{"is-active", "linux-temp-admin-v2-revoke-xxvcc-a1.timer"}, + err: errors.New("exit status 1"), + output: "Failed to connect to bus: No such file or directory", + }) + if err == nil { + t.Fatal("Cancel reported success though no probe confirmed the timer stopped") + } + if !strings.Contains(err.Error(), "confirm timer stopped") { + t.Fatalf("error = %v, want the unconfirmed-state refusal", err) + } + }) + + t.Run("a confirmed inactive timer is success", func(t *testing.T) { + err := newCase(t, &systemctlError{ + args: []string{"is-active", "linux-temp-admin-v2-revoke-xxvcc-a1.timer"}, + err: errSystemdUnitInactive, + output: "inactive", + }) + if err != nil { + t.Fatalf("Cancel with a confirmed-inactive timer = %v, want nil", err) + } + }) +} + +// Hosts that upgrade to the pinned-CWD release still carry jobs queued by the old +// one, each with at(1)'s `cd || exit 1` prologue. Once that +// directory is gone the job exits before reaching revoke, so it is in the queue +// but is not a schedule. +func TestValidScheduleRejectsAnAtJobPinnedToAVanishedDirectory(t *testing.T) { + now := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC) + build := func(t *testing.T, prologue string) (bool, error) { + t.Helper() + sys := &fakeSystem{} + s := newScheduler(t.TempDir(), sys) + s.Now = func() time.Time { return now } + sys.atJobs = []AtJob{{ + ID: "57", + Body: prologue + s.RevokeCommand("xxvcc-a1", 1001, testGeneration) + "\n", + ScheduledAt: now.Add(24 * time.Hour), + }} + return s.ValidSchedule("xxvcc-a1", 1001, testGeneration, "at:57", now.Add(24*time.Hour)) + } + + gone := filepath.Join(t.TempDir(), "deploy-2026-09") + present := t.TempDir() + + for _, tc := range []struct { + name string + prologue string + want bool + }{ + {"pinned to the always-present directory", "cd / || {\n echo bad >&2\n exit 1\n}\nunset TZ\n", true}, + {"no prologue at all", "unset TZ\n", true}, + {"a caller CWD that still exists", "cd " + present + " || {\n exit 1\n}\nunset TZ\n", true}, + {"a caller CWD that is gone", "cd " + gone + " || {\n echo 'Execution directory inaccessible' >&2\n exit 1\n}\nunset TZ\n", false}, + } { + t.Run(tc.name, func(t *testing.T) { + valid, err := build(t, tc.prologue) + if err != nil { + t.Fatal(err) + } + if valid != tc.want { + t.Fatalf("ValidSchedule = %v, want %v", valid, tc.want) + } + }) + } +} diff --git a/internal/selfmanage/release_pipeline_installer_test.go b/internal/selfmanage/release_pipeline_installer_test.go index 6737100..7136006 100644 --- a/internal/selfmanage/release_pipeline_installer_test.go +++ b/internal/selfmanage/release_pipeline_installer_test.go @@ -487,7 +487,7 @@ func InstallerPinnedReleaseEndToEnd(t *testing.T) { if err := os.WriteFile(tombstone, []byte("uninstalled-v1\n"), 0o600); err != nil { t.Fatal(err) } - candidate := []byte(fmt.Sprintf(`#!/bin/sh + candidate := elfWrappedScript(t, fmt.Sprintf(` case "$1" in version) awk '$1 == "Max" && $2 == "core" && $3 == "file" && $4 == "size" { print $5, $6 }' /proc/self/limits > "$LTA_CORE_EVIDENCE" @@ -699,7 +699,7 @@ func InstallerOfficialMirrorFallbackBoundary(t *testing.T) { } asset := "linux-temp-admin-linux-" + runtime.GOARCH - candidate := []byte("#!/bin/sh\n[ \"$1\" = version ] && printf '2.8.0\\n'\n") + candidate := elfWrappedScript(t, "[ \"$1\" = version ] && printf '2.8.0\\n'\n") pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) @@ -1416,3 +1416,52 @@ fi run(t, "/overflow", 1) }) } + +// elfWrappedScript compiles script into a real ELF executable for this host. +// +// The installer now reads EI_CLASS/EI_DATA/e_machine out of the downloaded bytes +// before installing them, because the release signature covers raw bytes and +// binds neither the asset name nor the architecture. A shell-script fixture is +// correctly refused by that gate, so these tests wrap the same script in a +// genuine binary: the behaviour under test is unchanged, and the bytes are the +// shape a real release has. +func elfWrappedScript(t *testing.T, script string) []byte { + t.Helper() + dir := t.TempDir() + src := filepath.Join(dir, "main.go") + program := `package main + +import ( + "os" + "os/exec" +) + +const script = ` + strconv.Quote(script) + ` + +func main() { + args := append([]string{"-c", script, os.Args[0]}, os.Args[1:]...) + cmd := exec.Command("/bin/sh", args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + if exit, ok := err.(*exec.ExitError); ok { + os.Exit(exit.ExitCode()) + } + os.Exit(1) + } +} +` + if err := os.WriteFile(src, []byte(program), 0o600); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "candidate") + build := exec.Command("go", "build", "-buildvcs=false", "-o", out, src) + build.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+runtime.GOARCH) + if combined, err := build.CombinedOutput(); err != nil { + t.Fatalf("build ELF-wrapped candidate: %v\n%s", err, combined) + } + bin, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + return bin +} diff --git a/internal/selfmanage/release_pipeline_mirror_test.go b/internal/selfmanage/release_pipeline_mirror_test.go index e106296..c03a3bc 100644 --- a/internal/selfmanage/release_pipeline_mirror_test.go +++ b/internal/selfmanage/release_pipeline_mirror_test.go @@ -658,23 +658,23 @@ func TestGeneratedReleaseSafetyPrimitivesAreCurrentAndSelfContained(t *testing.T allFunctions := []string{ "local_with_timeout", "require_trusted_tmp", "require_safe_directory_path", "require_regular_file_path", "require_safe_file_path", "require_real_directory_path", - "require_safe_source_repo", "require_safe_new_output_path", "bounded_copy", "sync_output_directory", + "require_safe_source_repo", "require_safe_new_output_path", "file_limit_blocks", "bounded_copy", "sync_output_directory", } expected := map[string]map[string]bool{ "../../scripts/prepare-release.sh": { "local_with_timeout": true, "require_trusted_tmp": true, "require_safe_directory_path": true, "require_safe_source_repo": true, "require_safe_new_output_path": true, - "bounded_copy": true, "sync_output_directory": true, + "file_limit_blocks": true, "bounded_copy": true, "sync_output_directory": true, }, "../../scripts/offline-sign-release.sh": { "local_with_timeout": true, "require_trusted_tmp": true, "require_safe_directory_path": true, "require_regular_file_path": true, "require_safe_file_path": true, "require_real_directory_path": true, - "require_safe_new_output_path": true, "bounded_copy": true, "sync_output_directory": true, + "require_safe_new_output_path": true, "file_limit_blocks": true, "bounded_copy": true, "sync_output_directory": true, }, "../../scripts/publish-release.sh": { "local_with_timeout": true, "require_trusted_tmp": true, "require_safe_directory_path": true, "require_regular_file_path": true, "require_safe_file_path": true, "require_real_directory_path": true, - "require_safe_source_repo": true, "bounded_copy": true, + "require_safe_source_repo": true, "file_limit_blocks": true, "bounded_copy": true, }, } for path, wanted := range expected { @@ -697,7 +697,7 @@ func TestGeneratedReleaseSafetyPrimitivesAreCurrentAndSelfContained(t *testing.T func TestGeneratedBoundedCopyStopsWhenFileLimitCannotBeSet(t *testing.T) { template := readReleaseFile(t, "../../scripts/release-safety-primitives.inc") - start := strings.Index(template, "bounded_copy() {") + start := strings.Index(template, "file_limit_blocks() {") if start < 0 { t.Fatal("bounded_copy template function was not found") } diff --git a/internal/selfmanage/release_pipeline_trusted_scripts_test.go b/internal/selfmanage/release_pipeline_trusted_scripts_test.go index 20f55c6..9e0bc69 100644 --- a/internal/selfmanage/release_pipeline_trusted_scripts_test.go +++ b/internal/selfmanage/release_pipeline_trusted_scripts_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "testing" @@ -140,9 +141,12 @@ func runFailedFileLimitFixture(t *testing.T, body string) { t.Helper() dir := t.TempDir() script := filepath.Join(dir, "file-limit.sh") - fixture := `#!/bin/bash -set -Eeuo pipefail + fixture := "#!/bin/bash\nset -Eeuo pipefail\n" + shellRegion(t, readReleaseFile(t, "../../scripts/prepare-release.sh"), "file_limit_blocks() {", "\nbounded_copy() {") + ` ulimit() { + if [[ "$*" == '-f 1' ]]; then + builtin ulimit "$@" + return + fi printf 'limit\n' >> "$TEST_CALL_LOG" return 1 } @@ -191,7 +195,7 @@ func TestTrustedReleaseWritersStopWhenFileLimitCannotBeSet(t *testing.T) { prepareDownload := shellRegion(t, prepare, "download_draft_asset() {", "\ndownload_draft_asset linux-temp-admin-linux-amd64") publishDownload := extractPublisherFunction(t, "download_draft_asset", "replace_bound_draft_assets") - archiveExport := shellRegion(t, prepare, `source_blocks=$(( (MAX_SOURCE_ARCHIVE_BYTES + 1023) / 1024 ))`, "\n[[ -s \"$source_archive\"") + archiveExport := shellRegion(t, prepare, `source_blocks="$(file_limit_blocks "$MAX_SOURCE_ARCHIVE_BYTES")" || exit 1`, "\n[[ -s \"$source_archive\"") publicFetch := extractPublisherFunction(t, "public_fetch", "verify_public_set") t.Run("prepare draft download", func(t *testing.T) { @@ -203,7 +207,7 @@ TEST_EXPECTED_OUTPUT="$work/ci/asset" draft_assets=$'asset\t1\thttps://api.github.com/repos/mock/repo/releases/assets/1' gh_with_timeout() { mark_writer; } `+prepareDownload+` -if download_draft_asset asset 1024; then +if download_draft_asset asset 2048; then echo "prepare download ignored the failed file-size limit" >&2 exit 90 fi @@ -240,7 +244,7 @@ gh_with_timeout() { mark_writer } `+publishDownload+` -if download_draft_asset asset 1024 "$TEST_EXPECTED_OUTPUT"; then +if download_draft_asset asset 2048 "$TEST_EXPECTED_OUTPUT"; then echo "publisher draft download ignored the failed file-size limit" >&2 exit 90 fi @@ -253,7 +257,7 @@ TEST_EXPECTED_OUTPUT="$TEST_STATE/public-asset" timeout() { mark_writer; } sleep() { :; } `+publicFetch+` -if public_fetch https://example.test/asset "$TEST_EXPECTED_OUTPUT" 1024; then +if public_fetch https://example.test/asset "$TEST_EXPECTED_OUTPUT" 2048; then echo "public download ignored the failed file-size limit" >&2 exit 90 fi @@ -261,6 +265,96 @@ fi }) } +func TestTrustedReleaseFileLimitsAtBoundaryInPOSIXAndDefaultShells(t *testing.T) { + prepare := readReleaseFile(t, "../../scripts/prepare-release.sh") + primitives := shellRegion(t, prepare, "file_limit_blocks() {", "\nsync_output_directory() {") + prepareDownload := shellRegion(t, prepare, "download_draft_asset() {", "\ndownload_draft_asset linux-temp-admin-linux-amd64") + publishDownload := extractPublisherFunction(t, "download_draft_asset", "replace_bound_draft_assets") + archiveExport := shellRegion(t, prepare, `source_blocks="$(file_limit_blocks "$MAX_SOURCE_ARCHIVE_BYTES")" || exit 1`, "\ntimeout -k 5 \"$LOCAL_COMMAND_TIMEOUT_SECONDS\" tar") + publicFetch := extractPublisherFunction(t, "public_fetch", "verify_public_set") + paths := map[string]string{ + "prepare draft": prepareDownload + ` +work="$TEST_STATE" +mkdir "$work/ci" +OUTPUT="$work/ci/asset" +draft_assets=$'asset\t65536\thttps://api.github.com/repos/mock/repo/releases/assets/1' +gh_with_timeout() { head -c "$TEST_BYTES" /dev/zero; } +run_writer() { download_draft_asset asset 65536; } +`, + "publisher draft": publishDownload + ` +EXPECTED_RELEASE_ID=12345 +gh_with_timeout() { + if [[ "$*" == *--paginate* ]]; then + printf '65536\thttps://api.github.com/repos/mock/repo/releases/assets/1\n' + else + head -c "$TEST_BYTES" /dev/zero + fi +} +run_writer() { download_draft_asset asset 65536 "$OUTPUT"; } +`, + "source archive": ` +MAX_SOURCE_ARCHIVE_BYTES=65536 +SOURCE_DIR=/mock/source +tag_commit=deadbeef +source_archive="$OUTPUT" +git_with_timeout() { head -c "$TEST_BYTES" /dev/zero; } +run_writer() ( +` + archiveExport + ` +) +`, + "public download": publicFetch + ` +timeout() { + local output= previous= argument + for argument do + [[ "$previous" != -o ]] || output=$argument + previous=$argument + done + head -c "$TEST_BYTES" /dev/zero > "$output" +} +sleep() { :; } +run_writer() { public_fetch https://example.test/asset "$OUTPUT" 65536; } +`, + "bounded copy": ` +head -c "$TEST_BYTES" /dev/zero > "$TEST_STATE/source" +run_writer() { bounded_copy "$TEST_STATE/source" "$OUTPUT" 65536; } +`, + } + for name, body := range paths { + for _, posix := range []bool{false, true} { + for _, size := range []int{65536, 65537} { + t.Run(fmt.Sprintf("%s/posix=%t/bytes=%d", name, posix, size), func(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "boundary.sh") + fixture := "#!/bin/bash\nset -Eeuo pipefail\nulimit -c 0\n" + primitives + ` +REPO=mock/repo +OUTPUT="$TEST_STATE/output" +local_with_timeout() { "$@"; } +` + body + ` +if run_writer; then + [[ "$TEST_BYTES" -le 65536 ]] + [[ "$(wc -c < "$OUTPUT")" -eq "$TEST_BYTES" ]] +else + [[ "$TEST_BYTES" -gt 65536 ]] + [[ ! -f "$OUTPUT" || "$(wc -c < "$OUTPUT")" -le 65536 ]] +fi +` + if err := os.WriteFile(script, []byte(fixture), 0o700); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/bash", script) + cmd.Env = []string{"PATH=/usr/bin:/bin", "LC_ALL=C", "TEST_STATE=" + dir, fmt.Sprintf("TEST_BYTES=%d", size)} + if posix { + cmd.Env = append(cmd.Env, "POSIXLY_CORRECT=1") + } + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("bounded writer failed: %v\n%s", err, out) + } + }) + } + } + } +} + func TestTrustedTemporaryDirectoryGuardWithMockStat(t *testing.T) { offline := readReleaseFile(t, "../../scripts/offline-sign-release.sh") start := strings.Index(offline, "require_trusted_tmp() {") @@ -2333,3 +2427,96 @@ func readReleaseFile(t *testing.T, path string) string { } return string(b) } + +// unsetVariables returns every variable name the script clears with `unset`. +func unsetVariables(script string) map[string]bool { + out := map[string]bool{} + for _, line := range strings.Split(script, "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "unset ") + if !ok { + continue + } + for _, name := range strings.Fields(rest) { + if name == "" || strings.ContainsAny(name, "$\"'`") { + continue + } + out[name] = true + } + } + return out +} + +// The list-of-required-strings test above cannot notice a hardening line added to +// only one of the two online scripts — which is exactly how prepare-release.sh +// came to keep an inherited GNUPGHOME while publish-release.sh cleared it, +// letting a caller's gpg.conf choose the policy for the earlier and more +// decisive of the two tag verifications. +// +// This compares the two scripts against each other instead of against a list, so +// any future variable cleared in one and forgotten in the other fails here. +func TestBothOnlineReleaseScriptsClearTheSameInheritedVariables(t *testing.T) { + prepare := unsetVariables(readReleaseFile(t, "../../scripts/prepare-release.sh")) + publish := unsetVariables(readReleaseFile(t, "../../scripts/publish-release.sh")) + + // prepare-release.sh also builds, so it alone clears the Go toolchain's + // environment. The exemption is an explicit list rather than a GO* prefix: + // a prefix would silently excuse any future variable that happens to start + // with those two letters, which is the same blind spot this test replaced. + buildOnly := map[string]bool{ + "GOROOT": true, "GOEXPERIMENT": true, "GOFIPS140": true, "GO111MODULE": true, + "GOCACHE": true, "GOMODCACHE": true, "GOPATH": true, "GOTMPDIR": true, + "GOPROXY": true, "GOSUMDB": true, "GONOSUMDB": true, "GOPRIVATE": true, + "GONOPROXY": true, "GOINSECURE": true, "GOVCS": true, "GOAUTH": true, + "GOTELEMETRY": true, "GOFIPS": true, + } + toolchainOnly := func(name string) bool { return buildOnly[name] } + + var missingFromPublish, missingFromPrepare []string + for name := range prepare { + if !publish[name] && !toolchainOnly(name) { + missingFromPublish = append(missingFromPublish, name) + } + } + for name := range publish { + if !prepare[name] { + missingFromPrepare = append(missingFromPrepare, name) + } + } + sort.Strings(missingFromPublish) + sort.Strings(missingFromPrepare) + + if len(missingFromPublish) > 0 { + t.Errorf("publish-release.sh does not clear %v, which prepare-release.sh does", missingFromPublish) + } + if len(missingFromPrepare) > 0 { + t.Errorf("prepare-release.sh does not clear %v, which publish-release.sh does", missingFromPrepare) + } + + // Both scripts must also EXPORT the same pinned values, not merely unset the + // same names: a hardening that pins a variable in one script and forgets the + // other is the same one-sided drift, and `unset` lines alone cannot see it. + for _, pinned := range []string{ + "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "LC_ALL=C", + "GIT_TERMINAL_PROMPT=0", + } { + for name, content := range map[string]string{ + "prepare": readReleaseFile(t, "../../scripts/prepare-release.sh"), + "publish": readReleaseFile(t, "../../scripts/publish-release.sh"), + } { + if !strings.Contains(content, pinned) { + t.Errorf("%s-release.sh does not pin %s", name, pinned) + } + } + } + + // The one this test was written for: both gpg callers must decide their own + // OpenPGP trust material rather than inherit the caller's. + for name, vars := range map[string]map[string]bool{"prepare": prepare, "publish": publish} { + for _, required := range []string{"GNUPGHOME", "GPG_TTY"} { + if !vars[required] { + t.Errorf("%s-release.sh does not unset %s before verifying the release tag", name, required) + } + } + } +} diff --git a/internal/selfmanage/selfmanage.go b/internal/selfmanage/selfmanage.go index 05b1589..6617621 100644 --- a/internal/selfmanage/selfmanage.go +++ b/internal/selfmanage/selfmanage.go @@ -9,6 +9,7 @@ import ( "context" "crypto/ed25519" "crypto/sha256" + "debug/elf" "encoding/json" "errors" "fmt" @@ -19,6 +20,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "sync" "sync/atomic" @@ -28,6 +30,7 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/fsutil" "github.com/xxvcc/linux-temp-admin/internal/validate" "github.com/xxvcc/linux-temp-admin/internal/version" + "golang.org/x/sys/unix" ) // Manager performs install/uninstall/upgrade. Fields are injectable for tests. @@ -48,6 +51,10 @@ type Manager struct { 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) + // RequireHostMachine gates a candidate on being an ELF built for this host's + // architecture. Production leaves it nil and uses requireHostMachine; it is a + // field only so tests can present non-ELF fixture bytes. + RequireHostMachine func([]byte) 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 @@ -348,6 +355,18 @@ func (m *Manager) FetchReleaseManifest(manifestURL, expectedRoot string) (Releas if !canonicalPublishedAt(manifest.PublishedAt) { return ReleaseManifest{}, fmt.Errorf("published_at is not canonical UTC RFC3339") } + // published_at was validated for shape and then used only to rebuild the + // canonical bytes, so it constrained nothing about WHICH release the mirror + // names. A future timestamp is the one reading that is wrong on its face + // rather than merely old: no release can be published after now, so it means a + // skewed or fabricated index. Staleness itself stays a judgement this code + // cannot make without a trusted clock reference; the version lower bound in + // the caller is what covers a rolled-back index. + if published, parseErr := time.Parse(time.RFC3339, manifest.PublishedAt); parseErr == nil { + if published.After(time.Now().UTC().Add(publishedAtSkewAllowance)) { + return ReleaseManifest{}, fmt.Errorf("published_at %s is in the future", manifest.PublishedAt) + } + } canonical, err := json.Marshal(struct { Version string `json:"version"` Tag string `json:"tag"` @@ -364,6 +383,10 @@ func (m *Manager) FetchReleaseManifest(manifestURL, expectedRoot string) (Releas return manifest, nil } +// publishedAtSkewAllowance tolerates ordinary clock disagreement between the +// mirror and this host without accepting a timestamp that is meaningfully ahead. +const publishedAtSkewAllowance = 24 * time.Hour + 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' { @@ -585,6 +608,16 @@ func (m *Manager) prepareVerifiedCandidate(bin, sig []byte, expectedVersion stri if !verified { return nil, fmt.Errorf("signature verification failed; refusing to install") } + // The signature covers raw bytes only: it carries no asset name and no + // architecture, and the release-version witness is byte-identical across the + // amd64 and arm64 builds of the same release. SHA256SUMS, the only artifact + // that binds a name to a digest, is unsigned. So a party controlling what the + // mirror serves can hand this host the OTHER architecture's genuinely signed + // binary under this architecture's asset name and every check above passes. + // The bytes themselves still say which machine they are for; require that. + if err := m.machineCheck()(bin); err != nil { + return nil, err + } signedVersion, err := releaseVersionWitness(bin) if err != nil { return nil, fmt.Errorf("read signed release version: %w", err) @@ -945,12 +978,31 @@ func (m *Manager) probeVersion(bin []byte) (string, error) { if err := fsutil.RootSafeDir(dir); err != nil { return "", fmt.Errorf("install dir unsafe: %w", err) } + // RootSafeDir and CreateTemp resolve dir separately, and this is the one + // privileged write+exec in the tree that works by name rather than through a + // pinned directory fd. Restructuring it to openat/fexecve is a larger change + // than this warrants, but the gap it leaves — dir being replaced between the + // safety verdict and the write — is closed by binding the two resolutions to + // the same inode. + var before unix.Stat_t + if err := unix.Lstat(dir, &before); err != nil { + return "", fmt.Errorf("pin install dir: %w", err) + } f, err := os.CreateTemp(dir, ".lta-upgrade-*") if err != nil { return "", err } tmp := f.Name() defer os.Remove(tmp) + var after unix.Stat_t + if err := unix.Lstat(dir, &after); err != nil { + _ = f.Close() + return "", fmt.Errorf("recheck install dir: %w", err) + } + if before.Dev != after.Dev || before.Ino != after.Ino { + _ = f.Close() + return "", fmt.Errorf("install dir %s was replaced while staging the version probe", dir) + } if _, err := io.Copy(f, bytes.NewReader(bin)); err != nil { _ = f.Close() return "", err @@ -1111,3 +1163,53 @@ func checkDialAddr(address string, allowPrivate bool) error { func isPublicIP(ip net.IP) bool { return validate.PublicIP(ip) } + +// machineCheck returns the architecture gate, defaulting to requireHostMachine. +// RequireHostMachine is a field so a test can present a non-ELF fixture; nothing +// in production sets it, and leaving it nil keeps the strict check. +func (m *Manager) machineCheck() func([]byte) error { + if m != nil && m.RequireHostMachine != nil { + return m.RequireHostMachine + } + return requireHostMachine +} + +// hostELFMachine is the ELF machine this build must run on. An architecture that +// is not listed cannot be checked, and the official upgrade path already refuses +// to select an asset for one. +func hostELFMachine() (elf.Machine, bool) { + switch runtime.GOARCH { + case "amd64": + return elf.EM_X86_64, true + case "arm64": + return elf.EM_AARCH64, true + } + return 0, false +} + +// requireHostMachine refuses a candidate that is not a 64-bit little-endian Linux +// ELF executable for this host's architecture. It is deliberately a check on the +// downloaded bytes rather than on the name they arrived under: the name is the +// part an attacker controls. +func requireHostMachine(bin []byte) error { + want, known := hostELFMachine() + if !known { + return nil + } + f, err := elf.NewFile(bytes.NewReader(bin)) + if err != nil { + return fmt.Errorf("candidate is not a readable ELF executable: %w", err) + } + defer f.Close() + if f.Class != elf.ELFCLASS64 || f.Data != elf.ELFDATA2LSB { + return fmt.Errorf("candidate ELF class/encoding %s/%s is not the 64-bit little-endian build this host runs", f.Class, f.Data) + } + if f.Type != elf.ET_EXEC && f.Type != elf.ET_DYN { + return fmt.Errorf("candidate ELF type %s is not an executable", f.Type) + } + if f.Machine != want { + return fmt.Errorf("candidate is built for %s but this host runs %s (%s); refusing to install another architecture's release", + f.Machine, want, runtime.GOARCH) + } + return nil +} diff --git a/internal/selfmanage/selfmanage_root_test.go b/internal/selfmanage/selfmanage_root_test.go index 28432b1..98a98bf 100644 --- a/internal/selfmanage/selfmanage_root_test.go +++ b/internal/selfmanage/selfmanage_root_test.go @@ -39,7 +39,7 @@ func rootDir(t *testing.T) string { func TestInstallIdempotentAndForce(t *testing.T) { dir := rootDir(t) - m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin")} + m := &Manager{RequireHostMachine: allowAnyMachine, InstallPath: filepath.Join(dir, "linux-temp-admin")} installed, err := m.Install([]byte("v1"), false) if err != nil { t.Fatal(err) @@ -92,7 +92,7 @@ func TestInstallIdempotentAndForce(t *testing.T) { func TestInstallFailsClosedOnTargetInspectionError(t *testing.T) { dir := rootDir(t) wrote := false - m := &Manager{ + m := &Manager{RequireHostMachine: allowAnyMachine, InstallPath: filepath.Join(dir, "linux-temp-admin"), Lstat: func(string) (os.FileInfo, error) { return nil, fs.ErrPermission }, WriteRootFile: func(string, []byte, os.FileMode) error { @@ -114,7 +114,7 @@ func TestInstallRequiresForceForExistingSpecialFile(t *testing.T) { if err := syscall.Mkfifo(path, 0o600); err != nil { t.Fatal(err) } - m := &Manager{InstallPath: path} + m := &Manager{RequireHostMachine: allowAnyMachine, 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) } @@ -134,7 +134,7 @@ func TestInstallCreatesMissingRootSafeParent(t *testing.T) { t.Fatal(err) } installDir := filepath.Join(localDir, "sbin") - m := &Manager{InstallPath: filepath.Join(installDir, "linux-temp-admin")} + m := &Manager{RequireHostMachine: allowAnyMachine, InstallPath: filepath.Join(installDir, "linux-temp-admin")} installed, err := m.Install([]byte("candidate"), false) if err != nil || !installed { @@ -159,7 +159,7 @@ func TestInstallCreatesMissingRootSafeParent(t *testing.T) { func TestInstallRepairsUnsafeMetadataOnIdenticalFile(t *testing.T) { dir := rootDir(t) path := filepath.Join(dir, "linux-temp-admin") - m := &Manager{InstallPath: path} + m := &Manager{RequireHostMachine: allowAnyMachine, InstallPath: path} if _, err := m.Install([]byte("same"), false); err != nil { t.Fatal(err) } @@ -195,7 +195,7 @@ func TestInstallRepairsUnsafeMetadataOnIdenticalFile(t *testing.T) { func TestInstallReportsVisibleReplacementOnDurabilityFailure(t *testing.T) { dir := rootDir(t) - m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin")} + m := &Manager{RequireHostMachine: allowAnyMachine, 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 @@ -264,7 +264,7 @@ func TestPrepareReleaseUpgradeVerifiesCompleteSet(t *testing.T) { 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} + m := &Manager{RequireHostMachine: allowAnyMachine, 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) @@ -302,7 +302,7 @@ func TestPrepareReleaseUpgradeClassifiesFallbackBoundary(t *testing.T) { 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} + m := &Manager{RequireHostMachine: allowAnyMachine, 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) @@ -318,7 +318,7 @@ func TestUpgradeVerifiesSignatureAndInstalls(t *testing.T) { sig := ed25519.Sign(priv, bin) srv := signedServer(t, bin, sig) - m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20} + m := &Manager{RequireHostMachine: allowAnyMachine, 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", false) if err != nil { t.Fatalf("Upgrade: %v", err) @@ -336,7 +336,7 @@ func TestPreparedUpgradeRechecksInstalledVersionAtCommit(t *testing.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} + m := &Manager{RequireHostMachine: allowAnyMachine, 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) } @@ -364,7 +364,7 @@ func TestPreparedUpgradeRejectsDowngradeBeforeExecutingCandidate(t *testing.T) { pub, priv, _ := ed25519.GenerateKey(rand.Reader) evidence := filepath.Join(dir, "candidate-executed") candidateBytes := []byte("#!/bin/sh\n# LTA_RELEASE_VERSION_V1{2.0.0}\nprintf executed > '" + evidence + "'\nprintf '2.0.0\\n'\n") - m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, MaxBytes: 1 << 20} + m := &Manager{RequireHostMachine: allowAnyMachine, InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, MaxBytes: 1 << 20} if wrote, err := m.Install(newBinary("3.0.0"), false); err != nil || !wrote { t.Fatalf("seed newer install: wrote=%v err=%v", wrote, err) } @@ -389,7 +389,7 @@ func TestHistoricalSignedUpgradeRequiresForceBeforeProbe(t *testing.T) { pub, priv, _ := ed25519.GenerateKey(rand.Reader) evidence := filepath.Join(dir, "historical-candidate-executed") candidateBytes := []byte("#!/bin/sh\nprintf executed > '" + evidence + "'\nprintf '2.0.0\\n'\n") - m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, MaxBytes: 1 << 20} + m := &Manager{RequireHostMachine: allowAnyMachine, InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, MaxBytes: 1 << 20} candidate, err := m.prepareVerifiedCandidate( candidateBytes, ed25519.Sign(priv, candidateBytes), "2.0.0") @@ -419,7 +419,7 @@ func TestForcedHistoricalCandidateStillMustMatchSelectedRelease(t *testing.T) { pub, priv, _ := ed25519.GenerateKey(rand.Reader) evidence := filepath.Join(dir, "mismatched-candidate-executed") candidateBytes := []byte("#!/bin/sh\nprintf executed > '" + evidence + "'\nprintf '2.0.1\\n'\n") - m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, MaxBytes: 1 << 20} + m := &Manager{RequireHostMachine: allowAnyMachine, InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, MaxBytes: 1 << 20} candidate, err := m.prepareVerifiedCandidate( candidateBytes, ed25519.Sign(priv, candidateBytes), "2.0.0") if err != nil { @@ -446,7 +446,7 @@ func TestUpgradeRejectsBadSignature(t *testing.T) { badSig := ed25519.Sign(wrongPriv, bin) srv := signedServer(t, bin, badSig) - m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20} + m := &Manager{RequireHostMachine: allowAnyMachine, 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", false); err == nil { t.Fatal("Upgrade must reject a bad signature") } @@ -462,7 +462,7 @@ func TestUpgradeSkipsWhenNotNewer(t *testing.T) { sig := ed25519.Sign(priv, bin) srv := signedServer(t, bin, sig) - m := &Manager{InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20} + m := &Manager{RequireHostMachine: allowAnyMachine, InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKey: pub, Client: srv.Client(), MaxBytes: 1 << 20} if installed, err := m.Install(bin, false); err != nil || !installed { t.Fatalf("seed installed version: installed=%v err=%v", installed, err) } @@ -493,7 +493,7 @@ func TestUpgradeUsesInstalledCommandAsVersionBaseline(t *testing.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} + m := &Manager{RequireHostMachine: allowAnyMachine, 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) } @@ -521,7 +521,7 @@ func TestUpgradeReturnsCandidateWithDurabilityFailure(t *testing.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 := &Manager{RequireHostMachine: allowAnyMachine, 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 @@ -542,7 +542,7 @@ func TestUpgradeAcceptsAnyKeyInKeyring(t *testing.T) { second, secondPriv, _ := ed25519.GenerateKey(rand.Reader) bin := newBinary("2.0.1") srv := signedServer(t, bin, ed25519.Sign(secondPriv, bin)) - m := &Manager{ + m := &Manager{RequireHostMachine: allowAnyMachine, InstallPath: filepath.Join(dir, "linux-temp-admin"), PublicKeys: []ed25519.PublicKey{first, second}, Client: srv.Client(), @@ -555,7 +555,7 @@ func TestUpgradeAcceptsAnyKeyInKeyring(t *testing.T) { func TestProbeVersionIsBounded(t *testing.T) { dir := rootDir(t) - m := &Manager{ + m := &Manager{RequireHostMachine: allowAnyMachine, InstallPath: filepath.Join(dir, "linux-temp-admin"), ProbeTimeout: 100 * time.Millisecond, ProbeMaxOutput: 32, @@ -577,3 +577,49 @@ func statT(t *testing.T, path string) *syscall.Stat_t { } return fi.Sys().(*syscall.Stat_t) } + +// allowAnyMachine lets the shell-script candidate fixtures through the ELF +// architecture gate. The gate itself is covered by +// TestCandidateForAnotherArchitectureIsRefused. +func allowAnyMachine([]byte) error { return nil } + +// docs/security-model.md requires the probe output to match BOTH the signed +// witness and the selected release. The "selected release" half is covered by +// TestForcedHistoricalCandidateStillMustMatchSelectedRelease; this pins the +// other half, which no fixture could previously produce because newBinary makes +// the static witness and the runtime output agree by construction. +func TestCandidateWhoseProbeDisagreesWithItsSignedWitnessIsRefused(t *testing.T) { + dir := rootDir(t) + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + + // Signed witness says 2.0.1; running it reports 2.0.9 — the build-metadata + // inconsistency buildinfo.VersionMetadataConsistent exists to catch. + candidateBytes := []byte("#!/bin/sh\n# LTA_RELEASE_VERSION_V1{2.0.1}\n[ \"$1\" = version ] && echo 2.0.9\nexit 0\n") + m := &Manager{ + RequireHostMachine: allowAnyMachine, + InstallPath: filepath.Join(dir, "linux-temp-admin"), + PublicKey: pub, MaxBytes: 1 << 20, + } + // expectedVersion "" is the `--url` shape: with no selected release to compare + // against, the signed-witness equality is the ONLY thing that can refuse this + // candidate. Preparing with "2.0.1" would let the selected-release check fire + // on the same fixture and mask the removal of the witness check. + candidate, err := m.prepareVerifiedCandidate(candidateBytes, ed25519.Sign(priv, candidateBytes), "") + if err != nil { + t.Fatalf("prepare candidate: %v", err) + } + if candidate.expected != "" { + t.Fatalf("premise: candidate.expected = %q, want empty so only the witness check applies", candidate.expected) + } + + got, err := m.ApplyUpgrade(candidate, false) + if err == nil { + t.Fatalf("ApplyUpgrade = %q, nil; a candidate that reports a different version than it is signed for must be refused", got) + } + if !strings.Contains(err.Error(), "does not match signed release-version witness") { + t.Fatalf("error = %v, want the signed-witness mismatch refusal", err) + } + if _, err := os.Lstat(m.InstallPath); !os.IsNotExist(err) { + t.Fatalf("the inconsistent candidate was installed anyway: %v", err) + } +} diff --git a/internal/selfmanage/selfmanage_test.go b/internal/selfmanage/selfmanage_test.go index d77bac8..6275bcf 100644 --- a/internal/selfmanage/selfmanage_test.go +++ b/internal/selfmanage/selfmanage_test.go @@ -19,6 +19,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "slices" "strings" "sync" @@ -314,7 +315,7 @@ func TestPrepareVerifiedCandidateDoesNotExecuteSignedBytes(t *testing.T) { } evidence := filepath.Join(t.TempDir(), "candidate-executed") bin := []byte("#!/bin/sh\n# LTA_RELEASE_VERSION_V1{2.9.5}\nprintf executed > '" + evidence + "'\nprintf '2.9.5\\n'\n") - m := &Manager{PublicKey: pub} + m := &Manager{PublicKey: pub, RequireHostMachine: func([]byte) error { return nil }} candidate, err := m.prepareVerifiedCandidate(bin, ed25519.Sign(priv, bin), "2.9.5") if err != nil || candidate.Version() != "2.9.5" { t.Fatalf("prepareVerifiedCandidate = version %q, err=%v", candidate.Version(), err) @@ -913,3 +914,154 @@ func TestDownloadDoesNotRetryPermanentStatus(t *testing.T) { t.Fatalf("404 made %d requests, want 1", requests) } } + +// TestFetchReleaseManifestRejectsAFuturePublishedAt pins the one reading of +// published_at that is wrong on its face. The field was validated for shape and +// then used only to rebuild the canonical bytes, so it constrained nothing about +// which release the mirror names; a timestamp ahead of now cannot describe a +// published release and means a skewed or fabricated index. +func TestFetchReleaseManifestRejectsAFuturePublishedAt(t *testing.T) { + root := "https://dl.ll.cd/linux-temp-admin" + body := func(published string) string { + return `{"version":"2.8.0","tag":"v2.8.0","base_url":"` + root + `/v2.8.0","published_at":"` + published + `"}` + "\n" + } + fetch := func(published string) error { + 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(body(published))), Request: req}, nil + })}, RetryDelay: 0} + _, err := m.FetchReleaseManifest(root+"/latest.json", root) + return err + } + future := time.Now().UTC().Add(72 * time.Hour).Format("2006-01-02T15:04:05Z") + if err := fetch(future); err == nil || !strings.Contains(err.Error(), "in the future") { + t.Fatalf("a manifest published %s was accepted: %v", future, err) + } + // Ordinary clock disagreement stays acceptable, and so does any past date. + for _, ok := range []string{ + time.Now().UTC().Add(time.Hour).Format("2006-01-02T15:04:05Z"), + "2026-07-27T05:00:00Z", + } { + if err := fetch(ok); err != nil { + t.Fatalf("a manifest published %s was rejected: %v", ok, err) + } + } +} + +// The detached signature covers raw bytes only — no asset name, no architecture — +// and the release-version witness is byte-identical in the amd64 and arm64 builds +// of one release. SHA256SUMS, the only artifact binding a name to a digest, is +// unsigned. So a mirror that serves the other architecture's genuinely signed +// binary under this architecture's asset name passes signature, checksum and +// version checks alike. The bytes still say which machine they are for. +func TestCandidateForAnotherArchitectureIsRefused(t *testing.T) { + elfHeader := func(machine uint16, class, data byte) []byte { + b := make([]byte, 64) + copy(b, []byte{0x7f, 'E', 'L', 'F'}) + b[4], b[5], b[6] = class, data, 1 + b[16], b[17] = 2, 0 // ET_EXEC + b[18], b[19] = byte(machine), byte(machine>>8) + b[20] = 1 // e_version + return b + } + const ( + emX8664 = 62 + emAarch64 = 183 + ) + var self, other uint16 = emX8664, emAarch64 + if runtime.GOARCH == "arm64" { + self, other = emAarch64, emX8664 + } + + t.Run("this host's architecture is accepted", func(t *testing.T) { + if err := requireHostMachine(elfHeader(self, 2, 1)); err != nil { + t.Fatalf("requireHostMachine on a native ELF = %v, want nil", err) + } + }) + + t.Run("the other architecture is refused", func(t *testing.T) { + err := requireHostMachine(elfHeader(other, 2, 1)) + if err == nil { + t.Fatal("a signed binary for the other architecture was accepted") + } + if !strings.Contains(err.Error(), "refusing to install another architecture") { + t.Fatalf("error = %v, want the architecture refusal", err) + } + }) + + t.Run("a 32-bit or big-endian build is refused", func(t *testing.T) { + for _, bad := range [][]byte{elfHeader(self, 1, 1), elfHeader(self, 2, 2)} { + if err := requireHostMachine(bad); err == nil { + t.Fatal("a non-64-bit-little-endian ELF was accepted") + } + } + }) + + t.Run("a non-ELF candidate is refused", func(t *testing.T) { + if err := requireHostMachine([]byte("#!/bin/sh\necho hi\n")); err == nil { + t.Fatal("a shell script was accepted as a release binary") + } + }) + + t.Run("the gate runs inside prepareVerifiedCandidate", func(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + bin := elfHeader(other, 2, 1) + m := &Manager{PublicKey: pub} + if _, err := m.prepareVerifiedCandidate(bin, ed25519.Sign(priv, bin), ""); err == nil { + t.Fatal("prepareVerifiedCandidate accepted a validly signed foreign-architecture binary") + } + }) +} + +// The architecture gate must accept the binaries this project actually ships. A +// synthetic 64-byte header cannot catch a tightening that rejects a real static +// Go build (ET_DYN when built as PIE, a larger header, extra program headers), so +// this runs the gate over bytes produced by the real toolchain. +func TestArchitectureGateAcceptsARealStaticReleaseBinary(t *testing.T) { + if testing.Short() { + t.Skip("builds a binary") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go toolchain unavailable") + } + dir := t.TempDir() + native := filepath.Join(dir, "native") + build := exec.Command("go", "build", "-buildvcs=false", + "-tags", "osusergo,netgo", "-ldflags", "-s -w", + "-o", native, "../../cmd/linux-temp-admin") + build.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+runtime.GOARCH) + if out, err := build.CombinedOutput(); err != nil { + t.Skipf("could not build a native release binary: %v\n%s", err, out) + } + bin, err := os.ReadFile(native) + if err != nil { + t.Fatal(err) + } + if err := requireHostMachine(bin); err != nil { + t.Fatalf("the gate rejected a genuine static release binary for this host: %v", err) + } + + // And the other architecture's genuine binary is still refused. + other := "arm64" + if runtime.GOARCH == "arm64" { + other = "amd64" + } + foreign := filepath.Join(dir, "foreign") + cross := exec.Command("go", "build", "-buildvcs=false", + "-tags", "osusergo,netgo", "-ldflags", "-s -w", + "-o", foreign, "../../cmd/linux-temp-admin") + cross.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+other) + if out, err := cross.CombinedOutput(); err != nil { + t.Skipf("could not cross-build for %s: %v\n%s", other, err, out) + } + foreignBin, err := os.ReadFile(foreign) + if err != nil { + t.Fatal(err) + } + if err := requireHostMachine(foreignBin); err == nil { + t.Fatalf("the gate accepted a genuine %s binary on a %s host", other, runtime.GOARCH) + } +} diff --git a/internal/sshdconf/sshdconf.go b/internal/sshdconf/sshdconf.go index cc1e64f..86939da 100644 --- a/internal/sshdconf/sshdconf.go +++ b/internal/sshdconf/sshdconf.go @@ -179,6 +179,16 @@ func (m *Manager) Grant(user string, groups []string, report sysinfo.LoginReport return fmt.Errorf("the host's sshd configuration is already invalid; refusing to touch it: %w", err) } path := m.FilePath(user) + // A drop-in already on disk may already be in daemon memory from an earlier + // granted-and-reloaded call, and WriteRootFile below replaces it in place. + // The rollback's "the daemon has not seen this file" shortcut is only true + // for a file this call created, so record which case this is before writing. + preexisting := false + if _, statErr := os.Lstat(path); statErr == nil { + preexisting = true + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("inspect the existing sshd drop-in: %w", statErr) + } rollback := func(cause error, restoreDaemon bool) error { pendingExisted, staged, err := m.stageRemovalLocked(path) if err != nil { @@ -187,10 +197,13 @@ func (m *Manager) Grant(user string, groups []string, report sysinfo.LoginReport 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 { + // Before the first reload attempt, a drop-in THIS CALL created 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, or replaced a file the daemon may already be enforcing — + // unlinking that one without a reload would leave the running sshd holding + // a grant whose file this tool believes it has removed. + if !restoreDaemon && !pendingExisted && !preexisting { if err := clearPending(path + removePendingSuffix); err != nil { return errors.Join(cause, fmt.Errorf("complete failed sshd grant removal: %w", err)) } @@ -230,7 +243,12 @@ func (m *Manager) Grant(user string, groups []string, report sysinfo.LoginReport // Whether such an unevaluable rule downgrades the invite to UNVERIFIED is the // caller's decision, taken from the same report; it is not this check's job. if rep := sysinfo.CheckKeyLogin(cfg, user, groups); !rep.OK() { - return rollback(fmt.Errorf("the sshd drop-in is not present in the effective config (is `Include %s/*.conf` present in /etc/ssh/sshd_config?)", m.Dir), false) + // Name the blocker that actually survived. A missing Include is only one + // possible cause: a Match block in a drop-in that sorts before this one + // wins the directive, and blaming the Include sent the operator to check a + // line that was already there. + return rollback(fmt.Errorf("the sshd exception did not take effect: %s still applies; either `Include %s/*.conf` is absent from /etc/ssh/sshd_config, or another Match block sets the same directive first", + describeSurvivingBlockers(rep), m.Dir), false) } switch err := m.Reload(); { case err == nil: @@ -853,3 +871,34 @@ func sshdProcessStartTime(pid int) (time.Time, error) { } return time.Unix(bootSeconds+seconds, nanos), nil } + +// describeSurvivingBlockers renders the blockers that are still in force, with +// the effective value each one came from when the probe reported it. +func describeSurvivingBlockers(rep sysinfo.LoginReport) string { + parts := make([]string, 0, len(rep.Blockers)) + for _, b := range rep.Blockers { + name := b.String() + if b == sysinfo.BlockKeyAlgorithm && rep.AlgoDirective != "" { + // Name the directive under the spelling this host's sshd used: it was + // renamed in 8.5, and printing the other one sends the operator to grep + // for a line that is not in their config — the exact failure this + // message was rewritten to stop. invite.go:696 does the same. + name = rep.AlgoDirective + } + detail := rep.Detail[b] + // These two Blocker names already embed their value ("PubkeyAuthentication + // no"), so appending Detail would print it twice. + if b == sysinfo.BlockPubkeyDisabled || b == sysinfo.BlockPasswordDisabled { + detail = "" + } + if detail != "" { + parts = append(parts, name+" ("+detail+")") + continue + } + parts = append(parts, name) + } + if len(parts) == 0 { + return "an unnamed blocker" + } + return strings.Join(parts, "; ") +} diff --git a/internal/sshdconf/sshdconf_root_test.go b/internal/sshdconf/sshdconf_root_test.go index 107fc9c..4e36320 100644 --- a/internal/sshdconf/sshdconf_root_test.go +++ b/internal/sshdconf/sshdconf_root_test.go @@ -215,6 +215,20 @@ func TestDropInRestoresScopeForLaterIncludedFiles(t *testing.T) { 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) } + // The assertion above does NOT discriminate on this host, and saying so is the + // point. Measured against OpenSSH 9.2 here: deleting the managed file's final + // `Match all` leaves this same query answering "no", because that sshd ends + // Match scope at the file boundary rather than carrying it into the next file + // expanded by the Include glob. The package doc gives the opposite behaviour as + // the guard's reason, so on this version the guard is defence against a + // behaviour that no longer reproduces. + // + // Keep the guard — older sshd is exactly what a compatibility guard is for — + // but pin its presence directly, so removing it from the renderer fails + // something instead of passing a test that cannot tell. + if !strings.HasSuffix(strings.TrimRight(string(body), "\n"), "Match all") { + t.Fatalf("the managed drop-in no longer ends with the Match all scope guard:\n%s", body) + } } func TestEnsureSSHDPrivilegeSeparationDirLifecycle(t *testing.T) { @@ -594,3 +608,58 @@ func TestGrantSucceedsDespiteAnUnverifiableAllowUsers(t *testing.T) { t.Errorf("the working drop-in was rolled back: %v", err) } } + +// TestGrantRollbackReloadsWhenItReplacedALiveDropIn pins the boundary of the +// rollback's "the running daemon has not seen this file" shortcut. That holds +// for a drop-in the failing call created, but WriteRootFile replaces one that is +// already on disk, and such a file may already be in daemon memory from an +// earlier granted-and-reloaded call. Unlinking it without a reload would leave +// sshd enforcing a grant this tool believes it removed. +func TestGrantRollbackReloadsWhenItReplacedALiveDropIn(t *testing.T) { + // Grant requires a root-owned config directory, so this belongs with the other + // root fixtures: in the non-root CI job t.TempDir() is owned by the runner and + // the very first Grant fails on the directory rather than on anything tested. + blocked := report("pubkeyauthentication no\n") + reloads, validates := 0, 0 + // Grant validates once before it writes and once after. Only the post-write + // check may fail here: failing the pre-check would refuse the whole call + // before anything is written, and no rollback would run at all. + failAfterValidate := -1 + m := &Manager{ + Dir: rootDir(t), + Validate: func() error { + validates++ + if failAfterValidate > 0 && validates == failAfterValidate { + return errors.New("injected sshd -t failure") + } + return nil + }, + Effective: func(string) (*sysinfo.SSHDConfig, error) { + return sysinfo.ParseSSHD("pubkeyauthentication yes\n"), nil + }, + Reload: func() error { reloads++; return nil }, + } + if _, err := m.Grant(acct, []string{acct}, blocked); err != nil { + t.Fatalf("first Grant: %v", err) + } + if reloads != 1 { + t.Fatalf("first Grant reloads = %d, want 1", reloads) + } + if _, err := os.Lstat(m.FilePath(acct)); err != nil { + t.Fatalf("first Grant left no drop-in: %v", err) + } + + // The daemon is now holding this account's grant. The second Grant replaces + // that file and then fails its post-write validation. + failAfterValidate = validates + 2 + before := reloads + if _, err := m.Grant(acct, []string{acct}, blocked); err == nil { + t.Fatal("second Grant must fail on the injected post-write validation error") + } + if _, err := os.Lstat(m.FilePath(acct)); !os.IsNotExist(err) { + t.Fatalf("rollback left the drop-in behind: err=%v", err) + } + if reloads == before { + t.Fatal("rollback unlinked a drop-in the daemon may already enforce without asking sshd to re-read") + } +} diff --git a/internal/sshdconf/sshdconf_test.go b/internal/sshdconf/sshdconf_test.go index b15481a..0301755 100644 --- a/internal/sshdconf/sshdconf_test.go +++ b/internal/sshdconf/sshdconf_test.go @@ -701,3 +701,59 @@ func TestMatchBlockRefusesInvalidUsername(t *testing.T) { } } } + +// The post-grant rollback used to blame a missing Include for every surviving +// blocker, sending the operator to check a line that was already there. It must +// name the blocker that actually survived — under the directive spelling this +// host's sshd uses, since OpenSSH renamed it in 8.5. +func TestDescribeSurvivingBlockersNamesTheRealBlocker(t *testing.T) { + for _, tc := range []struct { + name string + report sysinfo.LoginReport + want, notWant string + }{ + { + name: "a Match block that wins the directive", + report: sysinfo.LoginReport{ + Blockers: []sysinfo.Blocker{sysinfo.BlockPubkeyDisabled}, + Detail: map[sysinfo.Blocker]string{sysinfo.BlockPubkeyDisabled: "no"}, + }, + // The Blocker name already embeds its value; it must not be printed twice. + want: "PubkeyAuthentication no", + notWant: "PubkeyAuthentication no (no)", + }, + { + name: "the algorithm directive under this host's spelling", + report: sysinfo.LoginReport{ + Blockers: []sysinfo.Blocker{sysinfo.BlockKeyAlgorithm}, + Detail: map[sysinfo.Blocker]string{sysinfo.BlockKeyAlgorithm: "rsa-sha2-512"}, + AlgoDirective: "PubkeyAcceptedKeyTypes", + }, + want: "PubkeyAcceptedKeyTypes (rsa-sha2-512)", + notWant: "PubkeyAcceptedAlgorithms", + }, + { + name: "no AlgoDirective reported falls back to the modern spelling", + report: sysinfo.LoginReport{ + Blockers: []sysinfo.Blocker{sysinfo.BlockKeyAlgorithm}, + Detail: map[sysinfo.Blocker]string{sysinfo.BlockKeyAlgorithm: "rsa-sha2-512"}, + }, + want: "PubkeyAcceptedAlgorithms (rsa-sha2-512)", + }, + { + name: "nothing named at all still says something", + report: sysinfo.LoginReport{}, + want: "an unnamed blocker", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := describeSurvivingBlockers(tc.report) + if !strings.Contains(got, tc.want) { + t.Fatalf("describeSurvivingBlockers = %q, want it to contain %q", got, tc.want) + } + if tc.notWant != "" && strings.Contains(got, tc.notWant) { + t.Fatalf("describeSurvivingBlockers = %q, want it not to contain %q", got, tc.notWant) + } + }) + } +} diff --git a/internal/sudoers/sudoers_root_test.go b/internal/sudoers/sudoers_root_test.go index d3cd8ea..9a8dff1 100644 --- a/internal/sudoers/sudoers_root_test.go +++ b/internal/sudoers/sudoers_root_test.go @@ -85,3 +85,53 @@ func TestRemove(t *testing.T) { t.Error("Remove should delete the drop-in") } } + +// The invariant is an ORDER, not an outcome: the exact bytes are validated before +// anything lands in sudoers.d, so a syntactically broken drop-in never briefly +// breaks sudo for the whole host. The existing failure tests only assert the file +// is absent afterwards, which is equally true of a write-then-validate-then-remove +// implementation — and that implementation is exactly the bug. +func TestGrantValidatesBeforeTheDropInEverExists(t *testing.T) { + dir := rootDir(t) + path := filepath.Join(dir, "linux-temp-admin-xxvcc-a1") + + var validatedContent []byte + var existedAtValidation, existedAtVerification bool + m := &Manager{ + Dir: dir, + Validate: func(content []byte) error { + validatedContent = append([]byte(nil), content...) + _, err := os.Lstat(path) + existedAtValidation = err == nil + return nil + }, + Verify: func(string) error { + _, err := os.Lstat(path) + existedAtVerification = err == nil + return nil + }, + } + + if err := m.Grant("xxvcc-a1"); err != nil { + t.Fatalf("Grant: %v", err) + } + + if existedAtValidation { + t.Fatal("the drop-in was already live in sudoers.d when validation ran; a broken file would have been live host-wide first") + } + if !existedAtVerification { + t.Fatal("verification ran before the drop-in was live, so it cannot have confirmed the real policy") + } + if want := "xxvcc-a1 ALL=(ALL) NOPASSWD:ALL\n"; string(validatedContent) != want { + t.Fatalf("validated content = %q, want the exact bytes that go on disk (%q)", validatedContent, want) + } + + // And the placed file is byte-identical to what was validated. + onDisk, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(onDisk) != string(validatedContent) { + t.Fatalf("on-disk content %q differs from the validated bytes %q", onDisk, validatedContent) + } +} diff --git a/internal/sysinfo/sshd.go b/internal/sysinfo/sshd.go index 4d78303..99fc2be 100644 --- a/internal/sysinfo/sshd.go +++ b/internal/sysinfo/sshd.go @@ -23,6 +23,7 @@ import ( "time" "github.com/xxvcc/linux-temp-admin/internal/executil" + "github.com/xxvcc/linux-temp-admin/internal/validate" "golang.org/x/sys/unix" ) @@ -229,6 +230,18 @@ func fileHasConnectionScopedMatch(path, baseDir string, scan *sshdIncludeScan, d if !parsed { return false, false } + if keyword == "" && len(fields) != 0 { + // Not a blank line: sshd treats a leading '=' as the keyword/value + // separator and honours the directive after it. Verified against + // OpenSSH 9.2 — a config carrying "=Match Address 203.0.113.0/24" then + // "PubkeyAuthentication no" reports pubkeyauthentication no for a + // matching connection, and yes without that line. parseSSHDDirective + // reports an empty keyword for it, and skipping it as whitespace hid a + // connection-scoped Match from this scan, letting an invite claim a + // verified login that sshd will deny. This parser cannot model the line, + // so the scan fails closed instead of guessing. + return false, false + } if keyword == "" || len(fields) == 0 { continue } @@ -685,7 +698,7 @@ func CheckKeyLogin(c *SSHDConfig, user string, groups []string) LoginReport { if !yes(c.First("pubkeyauthentication")) { r.block(BlockPubkeyDisabled, c.First("pubkeyauthentication")) } - if akf := c.Values("authorizedkeysfile"); len(akf) > 0 && !readsDefaultAuthorizedKeys(akf) { + if akf := c.Values("authorizedkeysfile"); len(akf) > 0 && !readsDefaultAuthorizedKeys(akf, user) { r.block(BlockAuthorizedKeysFile, strings.Join(akf, " ")) } if m := c.Values("authenticationmethods"); !methodsSatisfiedBy(m, "publickey") { @@ -785,15 +798,75 @@ func pubkeyAlgorithms(c *SSHDConfig) (algs []string, directive string) { // readsDefaultAuthorizedKeys reports whether any AuthorizedKeysFile entry names // the per-user file this tool writes. "none" and central paths like // /etc/ssh/authorized_keys/%u do not. -func readsDefaultAuthorizedKeys(entries []string) bool { +// +// sshd expands %h to the account's home and %u to its login name, and this tool +// always writes /home//.ssh/authorized_keys. An absolute entry that expands +// to exactly that file therefore needs no drop-in: comparing two literal +// spellings only made `/home/%u/.ssh/authorized_keys` look like a central store, +// which either refused a working invite or wrote an sshd exception on a host that +// needed none. +// +// user may be empty before a name is chosen; %u then stays unexpanded and only +// the home-relative spellings match, which is the old, conservative behaviour. +func readsDefaultAuthorizedKeys(entries []string, user string) bool { for _, e := range entries { if e == ".ssh/authorized_keys" || e == "%h/.ssh/authorized_keys" { return true } + // Both sides return "" when they cannot answer (no username yet, an + // unresolvable token), and "" must never compare equal to "". + if managed := managedAuthorizedKeysPath(user); managed != "" && expandAuthorizedKeysPath(e, user) == managed { + return true + } } return false } +// managedAuthorizedKeysPath is the file this tool writes for user, or "" when no +// managed path can be named. The home comes from validate.ManagedHomePath, the +// single spelling every managed-home check derives from, so this equality +// cannot drift away from the one the account creation and cleanup paths use. +func managedAuthorizedKeysPath(user string) string { + home := validate.ManagedHomePath(user) + if home == "" { + return "" + } + return home + "/.ssh/authorized_keys" +} + +// expandAuthorizedKeysPath applies the two AuthorizedKeysFile tokens whose values +// this tool knows: %h (the managed home) and %u (the login name). It returns "" +// for a relative entry, which sshd resolves against the home directory and which +// the literal cases above already cover, and for any entry carrying a token this +// function cannot resolve — guessing there would be the unsafe direction. +func expandAuthorizedKeysPath(entry, user string) string { + if entry == "" || !strings.HasPrefix(entry, "/") || !validate.Username(user) { + return "" + } + var out strings.Builder + for i := 0; i < len(entry); i++ { + if entry[i] != '%' { + out.WriteByte(entry[i]) + continue + } + if i+1 >= len(entry) { + return "" + } + i++ + switch entry[i] { + case 'h': + out.WriteString(validate.ManagedHomePath(user)) + case 'u': + out.WriteString(user) + case '%': + out.WriteByte('%') + default: + return "" // an unknown token: do not guess what it expands to + } + } + return out.String() +} + // methodsSatisfiedBy reports whether an AuthenticationMethods setting can be // satisfied by `method` on its own. The setting is a space-separated list of // alternatives, each a comma-separated chain that must be completed in full; a diff --git a/internal/sysinfo/sshd_test.go b/internal/sysinfo/sshd_test.go index aad84eb..e9bbd2b 100644 --- a/internal/sysinfo/sshd_test.go +++ b/internal/sysinfo/sshd_test.go @@ -775,3 +775,82 @@ func TestBracketAllowUsersIsNotAFalseVerify(t *testing.T) { t.Errorf("expected the whitelist to block; blockers=%v", rep.Blockers) } } + +// TestConnectionScopedMatchFailsClosedOnAnEmptyKeyword pins a line sshd honours +// but this parser cannot model. sshd treats a leading '=' as the keyword/value +// separator, so "=Match Address 203.0.113.0/24" applies — verified against +// OpenSSH 9.2, where that line plus "PubkeyAuthentication no" yields +// pubkeyauthentication no for a matching connection and yes without it. +// parseSSHDDirective reports an empty keyword for such a line; skipping it as +// whitespace hid the Match and let an invite claim a verified login sshd denies. +func TestConnectionScopedMatchFailsClosedOnAnEmptyKeyword(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 _, tc := range []struct { + name string + content string + want bool + }{ + {name: "leading equals hides a Match", content: "PubkeyAuthentication yes\n=Match Address 203.0.113.0/24\nPubkeyAuthentication no\n", want: true}, + {name: "quoted empty first token", content: "PubkeyAuthentication yes\n\"\" Match Address 203.0.113.0/24\n", want: true}, + {name: "genuinely blank lines still scan clean", content: "PubkeyAuthentication yes\n\n \n# comment\n", want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.WriteFile(main, []byte(tc.content), 0o644); err != nil { + t.Fatal(err) + } + if got := HasConnectionScopedMatch(); got != tc.want { + t.Fatalf("HasConnectionScopedMatch = %v, want %v", got, tc.want) + } + }) + } +} + +// sshd expands %h and %u, and this tool always writes +// /home//.ssh/authorized_keys. An absolute entry that resolves to exactly +// that file needs no sshd drop-in; treating it as a central key store either +// refused a working invite or wrote an exception on a host that needed none. +func TestAuthorizedKeysFileTokensAreExpandedBeforeBlocking(t *testing.T) { + const user = "xxvcc-a1" + for _, tc := range []struct { + name string + entries []string + want bool + }{ + {"home-relative", []string{".ssh/authorized_keys"}, true}, + {"%h token", []string{"%h/.ssh/authorized_keys"}, true}, + {"absolute with %u", []string{"/home/%u/.ssh/authorized_keys"}, true}, + {"absolute literal", []string{"/home/xxvcc-a1/.ssh/authorized_keys"}, true}, + {"absolute with %h", []string{"%h/.ssh/authorized_keys", "none"}, true}, + {"second entry matches", []string{"/etc/ssh/authorized_keys/%u", "/home/%u/.ssh/authorized_keys"}, true}, + + {"central store", []string{"/etc/ssh/authorized_keys/%u"}, false}, + {"disabled", []string{"none"}, false}, + {"another user's file", []string{"/home/someoneelse/.ssh/authorized_keys"}, false}, + {"different filename", []string{"/home/%u/.ssh/authorized_keys2"}, false}, + {"unknown token is not guessed", []string{"/home/%U/.ssh/authorized_keys"}, false}, + {"trailing percent", []string{"/home/%u/.ssh/authorized_keys%"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := readsDefaultAuthorizedKeys(tc.entries, user); got != tc.want { + t.Fatalf("readsDefaultAuthorizedKeys(%q) = %v, want %v", tc.entries, got, tc.want) + } + }) + } + + // Before a username exists, only the home-relative spellings can be judged. + if readsDefaultAuthorizedKeys([]string{"/home/%u/.ssh/authorized_keys"}, "") { + t.Fatal("an absolute %u entry must not be accepted before a username is known") + } + if !readsDefaultAuthorizedKeys([]string{"%h/.ssh/authorized_keys"}, "") { + t.Fatal("the home-relative spelling must still be accepted before a username is known") + } +} diff --git a/internal/sysinfo/sysinfo.go b/internal/sysinfo/sysinfo.go index ff5f25d..7e96e33 100644 --- a/internal/sysinfo/sysinfo.go +++ b/internal/sysinfo/sysinfo.go @@ -16,6 +16,7 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/executil" "golang.org/x/sys/unix" + "net" ) // sshdConfigPath is overridable in tests. @@ -223,6 +224,26 @@ func sshPortFromSshdT() (int, error) { if err != nil { return 0, err } + // listenaddress is authoritative, not port. OpenSSH's ListenAddress with an + // explicit port creates a listener on it without adding to options->ports, and + // fill_default_server_options still leaves ports[0] = 22, so `sshd -T` prints + // "port 22" for a host that listens only on 2222. Reading port alone therefore + // hands the invitee a port nothing is listening on, confidently and without a + // warning. sshd -T always renders every listener as a listenaddress with an + // explicit port, including the default one, so the listener set is complete. + if ports, ok := sshdListenPorts(cfg); ok { + switch len(ports) { + case 1: + for port := range ports { + return port, nil + } + default: + // Several distinct listener ports: which one an invitee should use is the + // operator's decision, not a guess this tool may make. Fail closed so the + // caller demands an explicit --port. + return 0, fmt.Errorf("sshd listens on %d different ports; pass an explicit port", len(ports)) + } + } raw := cfg.First("port") p, err := strconv.Atoi(raw) if err != nil || p < 1 || p > 65535 { @@ -231,6 +252,29 @@ func sshPortFromSshdT() (int, error) { return p, nil } +// sshdListenPorts returns the distinct ports of every listenaddress sshd -T +// reported. ok is false when there are none to read, or when any entry cannot be +// parsed — an unreadable listener must not silently narrow the set. +func sshdListenPorts(cfg *SSHDConfig) (map[int]struct{}, bool) { + values := cfg.Values("listenaddress") + if len(values) == 0 { + return nil, false + } + ports := make(map[int]struct{}, len(values)) + for _, value := range values { + _, rawPort, splitErr := net.SplitHostPort(strings.TrimSpace(value)) + if splitErr != nil { + return nil, false + } + port, convErr := strconv.Atoi(rawPort) + if convErr != nil || port < 1 || port > 65535 { + return nil, false + } + ports[port] = struct{}{} + } + return ports, true +} + 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 { @@ -257,6 +301,14 @@ func sshPortFromConfig(path string) (int, bool, error) { if !complete { return 0, false, fmt.Errorf("sshd config %s contains a directive the static port fallback cannot parse", path) } + if keyword == "" && len(args) != 0 { + // sshd treats a leading '=' as the keyword/value separator and honours the + // directive after it, so "=Port 2244" IS a Port line. The Match/Include + // scanner in sshd.go fails closed on exactly this shape; skipping it here + // dropped the directive silently and defeated the hasInclude completeness + // guard with an "=Include" line. + return 0, false, fmt.Errorf("sshd config %s contains a directive the static port fallback cannot parse", path) + } if keyword == "" { continue } @@ -267,6 +319,20 @@ func sshPortFromConfig(path string) (int, bool, error) { hasInclude = true continue } + if strings.EqualFold(keyword, "listenaddress") { + // sshd also takes its port from a ListenAddress that carries one, so a + // config that sets the port only that way has a port this scan cannot + // see. Silently returning "no port configured" for such a host is the + // same silent-drop shape the leading-'=' guard above rejects, so refuse + // rather than answer from an incomplete read. + if len(args) != 1 { + return 0, false, fmt.Errorf("sshd config %s contains ListenAddress without exactly one value", path) + } + if _, portText, splitErr := net.SplitHostPort(args[0]); splitErr == nil && portText != "" { + return 0, false, fmt.Errorf("sshd config %s sets the port through ListenAddress, which the static port fallback cannot resolve", path) + } + continue + } if !strings.EqualFold(keyword, "port") { continue } diff --git a/internal/sysinfo/sysinfo_test.go b/internal/sysinfo/sysinfo_test.go index 75e90e6..9b81028 100644 --- a/internal/sysinfo/sysinfo_test.go +++ b/internal/sysinfo/sysinfo_test.go @@ -410,3 +410,103 @@ func TestMissingDepsReportsGroupdelAsBaseDependency(t *testing.T) { t.Fatalf("MissingDeps(false, false) = %v, want [groupdel]", missing) } } + +// TestSSHDListenPortsReadsEveryListener pins the rule that makes listenaddress +// authoritative over the reported port. OpenSSH's ListenAddress with an explicit +// port creates a listener without adding to options->ports, so `sshd -T` prints +// "port 22" for a host listening only on 2222 — verified against OpenSSH 9.2: +// +// ListenAddress 0.0.0.0:2222 -> port 22 / listenaddress 0.0.0.0:2222 +// Port 2222 -> port 2222 / listenaddress 0.0.0.0:2222 +// (defaults) -> port 22 / listenaddress 0.0.0.0:22 +// +// This covers the decision function. Its use inside sshPortFromSshdT is a few +// lines that cannot be exercised here because SSHDEffective shells out to sshd. +func TestSSHDListenPortsReadsEveryListener(t *testing.T) { + for _, tc := range []struct { + name string + config string + wantOK bool + wantPorts []int + }{ + { + name: "listener disagrees with the reported port", + config: "port 22\nlistenaddress 0.0.0.0:2222\n", + wantOK: true, + wantPorts: []int{2222}, + }, + { + name: "defaults agree", + config: "port 22\nlistenaddress [::]:22\nlistenaddress 0.0.0.0:22\n", + wantOK: true, + wantPorts: []int{22}, + }, + { + name: "several distinct listeners stay ambiguous", + config: "port 22\nlistenaddress 0.0.0.0:2222\nlistenaddress 127.0.0.1:2200\n", + wantOK: true, + wantPorts: []int{2200, 2222}, + }, + {name: "no listenaddress at all", config: "port 2222\n", wantOK: false}, + { + name: "an unreadable listener must not narrow the set", + config: "port 22\nlistenaddress 0.0.0.0:2222\nlistenaddress not-an-address\n", + wantOK: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ports, ok := sshdListenPorts(ParseSSHD(tc.config)) + if ok != tc.wantOK { + t.Fatalf("sshdListenPorts ok = %v, want %v", ok, tc.wantOK) + } + if !ok { + return + } + if len(ports) != len(tc.wantPorts) { + t.Fatalf("ports = %v, want %v", ports, tc.wantPorts) + } + for _, want := range tc.wantPorts { + if _, found := ports[want]; !found { + t.Fatalf("ports = %v, want it to contain %d", ports, want) + } + } + }) + } +} + +// OpenSSH treats a leading '=' as the keyword/value separator, so "=Port 2244" is +// a Port directive and "=Include ..." is an Include. The Match scanner already +// fails closed on this shape; the static port fallback used to drop the line as +// if it were blank, leaving the parse silently incomplete. +func TestSSHPortFromConfigFailsClosedOnLeadingEqualsDirectives(t *testing.T) { + for _, tc := range []struct{ name, config string }{ + {"port", "=Port 2244\n"}, + {"include", "Port 22\n=Include /etc/ssh/sshd_config.d/*.conf\n"}, + {"after a normal directive", "Port 22\n=PermitRootLogin no\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "sshd_config") + if err := os.WriteFile(path, []byte(tc.config), 0o600); err != nil { + t.Fatal(err) + } + port, ok, err := sshPortFromConfig(path) + if err == nil { + t.Fatalf("sshPortFromConfig = %d, %v, nil; want a refusal rather than a silently incomplete parse", port, ok) + } + if !strings.Contains(err.Error(), "cannot parse") { + t.Fatalf("error = %v, want the unparseable-directive refusal", err) + } + }) + } + + t.Run("an ordinary config still parses", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "sshd_config") + if err := os.WriteFile(path, []byte("# comment\n\nPort 2244\n"), 0o600); err != nil { + t.Fatal(err) + } + port, ok, err := sshPortFromConfig(path) + if err != nil || !ok || port != 2244 { + t.Fatalf("sshPortFromConfig = %d, %v, %v; want 2244, true, nil", port, ok, err) + } + }) +} diff --git a/internal/user/accountdb.go b/internal/user/accountdb.go index a246cb4..06ebe4c 100644 --- a/internal/user/accountdb.go +++ b/internal/user/accountdb.go @@ -35,7 +35,7 @@ func (m *Manager) preflightSequentialAccountCreation(name string, reservedID int if present { return fmt.Errorf("same-name private group %s already exists before account creation", name) } - return m.ensureSubordinateIDsAbsent(name) + return m.ensureSubordinateIDsAbsent(name, reservedID) } // preflightPrivateGroupRemoval proves that the same-name, same-GID private @@ -63,12 +63,20 @@ func (m *Manager) preflightPrivateGroupRemoval(expected Passwd) error { // and verifies that shadow's userdel did not leave subordinate-ID assignments. // A deletion-recovery row may authorize the group only when its SequentialID bit // proves that the recorded UID was also the private GID. -func (m *Manager) ReconcileAccountDatabaseAfterDeletion(name string, gid int, removePrivateGroup bool) error { +// +// uid and gid are separate because subuid(5)/subgid(5) key their owner field on +// the login name or the UID and never on a GID, while the private-group proof +// needs the GID. Passing one value for both reported numeric-form subordinate +// residue as clean on every account whose UID and GID differ. +func (m *Manager) ReconcileAccountDatabaseAfterDeletion(name string, uid, gid int, removePrivateGroup bool) error { if err := validateMutationName(name); err != nil { return err } - if removePrivateGroup && !validate.AccountID(gid) { - return fmt.Errorf("invalid expected private-group GID %d", gid) + // The same floor the account protection and the allocator use. groupdel is + // destructive and validate.AccountID only asks for gid > 0, so a system-range + // GID could authorize removing a group this tool never created. + if removePrivateGroup && (!validate.AccountID(gid) || gid < minAllocatableID) { + return fmt.Errorf("refusing to authorize private-group removal for out-of-range GID %d", gid) } absent, err := m.deletionState(name, nil, nil) if err != nil { @@ -78,7 +86,7 @@ func (m *Manager) ReconcileAccountDatabaseAfterDeletion(name string, gid int, re return fmt.Errorf("account %s exists; refusing account-database reconciliation", name) } groupErr := m.reconcilePrivateGroupAfterDeletion(name, gid, removePrivateGroup) - subIDErr := m.ensureSubordinateIDsAbsent(name) + subIDErr := m.ensureSubordinateIDsAbsent(name, uid) absent, stateErr := m.deletionState(name, nil, nil) if stateErr != nil { stateErr = fmt.Errorf("verify account absence after account-database reconciliation: %w", stateErr) @@ -93,7 +101,7 @@ func (m *Manager) ReconcileAccountDatabaseAfterDeletion(name string, gid int, re // registry row can identify. It never invokes groupdel: without a durable // deletion-started witness, an exact-looking group is still not deletion // authority and must be handled by an operator. -func (m *Manager) VerifyAccountDatabaseAfterExternalDeletion(name string, gid int, sequentialID bool) error { +func (m *Manager) VerifyAccountDatabaseAfterExternalDeletion(name string, uid, gid int, sequentialID bool) error { if err := validateMutationName(name); err != nil { return err } @@ -121,7 +129,7 @@ func (m *Manager) VerifyAccountDatabaseAfterExternalDeletion(name string, gid in groupErr = fmt.Errorf("same-name group %s remains after external account deletion, but the registry does not prove its GID; remove it manually, then retry", name) } } - subIDErr := m.ensureSubordinateIDsAbsent(name) + subIDErr := m.ensureSubordinateIDsAbsent(name, uid) absent, stateErr := m.deletionState(name, nil, nil) if stateErr != nil { stateErr = fmt.Errorf("verify account absence after account-database inspection: %w", stateErr) @@ -197,11 +205,11 @@ func (m *Manager) inspectSameNameGroup(name string) (bool, error) { return inspectSameNameGroup(name) } -func (m *Manager) ensureSubordinateIDsAbsent(name string) error { +func (m *Manager) ensureSubordinateIDsAbsent(name string, numericOwner int) error { if m.CheckSubordinateIDsAbsent != nil { - return m.CheckSubordinateIDsAbsent(name) + return m.CheckSubordinateIDsAbsent(name, numericOwner) } - return ensureSubordinateIDsAbsent(name) + return ensureSubordinateIDsAbsent(name, numericOwner) } // inspectPrivateGroup accepts only the shape created by useradd -U: one local @@ -216,7 +224,7 @@ func inspectPrivateGroup(name string, expectedGID int, allowPrimaryUser bool) (b seenNames := make(map[string]bool) found := false for lineNumber, line := range strings.Split(string(groups), "\n") { - if line == "" { + if skipNonEntryLine(line) { continue } parts := strings.Split(line, ":") @@ -270,7 +278,7 @@ func inspectPrivateGroup(name string, expectedGID int, allowPrimaryUser bool) (b } seenUsers := make(map[string]bool) for lineNumber, line := range strings.Split(string(passwd), "\n") { - if line == "" { + if skipNonEntryLine(line) { continue } pw, parseErr := parsePasswdEntry(line) @@ -298,7 +306,7 @@ func inspectSameNameGroup(name string) (bool, error) { seen := make(map[string]bool) found := false for lineNumber, line := range strings.Split(string(groups), "\n") { - if line == "" { + if skipNonEntryLine(line) { continue } parts := strings.Split(line, ":") @@ -340,7 +348,7 @@ func inspectPrivateGShadow(name string) (found, exists bool, err error) { } seen := make(map[string]bool) for lineNumber, line := range strings.Split(string(data), "\n") { - if line == "" { + if skipNonEntryLine(line) { continue } parts := strings.Split(line, ":") @@ -366,7 +374,14 @@ func inspectPrivateGShadow(name string) (found, exists bool, err error) { return found, true, nil } -func ensureSubordinateIDsAbsent(name string) error { +// numericOwner is the account's numeric identity, matched alongside the login +// name because subuid(5)/subgid(5) define the first field as "login name or +// UID" and newuidmap/newgidmap honour both. The manual page even recommends the +// numeric form on hosts with many entries, so a config-management or container +// tool writing it is ordinary rather than exotic. Checking only the name +// reported such residue as clean, and the caller then dropped the registry row +// that was its last recovery pointer. Pass 0 when no numeric identity is known. +func ensureSubordinateIDsAbsent(name string, numericOwner int) error { var errs []error for _, database := range []struct { label string @@ -384,7 +399,7 @@ func ensureSubordinateIDsAbsent(name string) error { continue } for lineNumber, line := range strings.Split(string(data), "\n") { - if line == "" { + if skipNonEntryLine(line) { continue } parts := strings.Split(line, ":") @@ -398,7 +413,7 @@ func ensureSubordinateIDsAbsent(name string) error { errs = append(errs, fmt.Errorf("malformed %s range at line %d", database.label, lineNumber+1)) break } - if parts[0] == name { + if parts[0] == name || (numericOwner > 0 && parts[0] == strconv.Itoa(numericOwner)) { errs = append(errs, fmt.Errorf("%s assignment remains for deleted account %s", database.label, name)) break } @@ -406,3 +421,29 @@ func ensureSubordinateIDsAbsent(name string) error { } return errors.Join(errs...) } + +// skipNonEntryLine reports a line the system's own readers ignore rather than a +// record this tool must parse. glibc's nss_files skips blank and '#'-comment +// lines in passwd, group and gshadow, and both glibc and shadow-utils accept the +// NIS compatibility entries that begin with '+' or '-'. Treating any of them as a +// malformed record hard-failed every sequential invite on a host that carries +// one, for lines that define no group and no subordinate range. +func skipNonEntryLine(line string) bool { + // glibc's internal_getent advances past leading blanks BEFORE it tests for an + // empty line or a '#' comment, and tolerates the CR of a CRLF file. Testing + // byte 0 of the raw line left every indented comment and whitespace-only line + // routed into the strict parser, which is the same hard failure this skip + // exists to prevent. + // Normalize only this classification view. Real records are still passed to + // their strict parsers unchanged, and Unicode whitespace is not libc's ASCII + // whitespace in these account database formats. + trimmed := strings.TrimLeft(line, " \t\r\v\f") + if trimmed == "" { + return true + } + switch trimmed[0] { + case '#', '+', '-': + return true + } + return false +} diff --git a/internal/user/accountdb_test.go b/internal/user/accountdb_test.go index 04ad9a6..f5105af 100644 --- a/internal/user/accountdb_test.go +++ b/internal/user/accountdb_test.go @@ -234,7 +234,7 @@ func TestReconcileAccountDatabaseRemovesOnlyProvenPrivateGroup(t *testing.T) { } } } - if err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, true); err != nil { + if err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, accountDBTestID, true); err != nil { t.Fatal(err) } want := [][]string{{"groupdel", "--", accountDBTestName}} @@ -252,7 +252,7 @@ func TestReconcileAccountDatabaseRemovesOnlyProvenPrivateGroup(t *testing.T) { _ = os.WriteFile(groupPath, nil, 0o600) _ = os.WriteFile(gshadowPath, nil, 0o600) } - err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, true) + err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, accountDBTestID, true) if !errors.Is(err, errForced) || !strings.Contains(err.Error(), "reported incomplete cleanup") { t.Fatalf("reconcile error = %v, want retained helper failure", err) } @@ -263,7 +263,7 @@ func TestReconcileAccountDatabaseRemovesOnlyProvenPrivateGroup(t *testing.T) { contents.passwd = "" setAccountDBContents(t, contents) f := &fakeRunner{available: map[string]bool{"groupdel": true}} - err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, true) + err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, accountDBTestID, true) if err == nil || !strings.Contains(err.Error(), "reported success but private group") { t.Fatalf("reconcile error = %v, want retained private-group residue", err) } @@ -289,7 +289,7 @@ func TestReconcileAccountDatabaseRemovesOnlyProvenPrivateGroup(t *testing.T) { t.Fatal(err) } } - err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, true) + err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, accountDBTestID, true) if err == nil || !strings.Contains(err.Error(), "has GID 2002, want 2001") { t.Fatalf("reconcile error = %v, want replacement-group refusal", err) } @@ -301,7 +301,7 @@ func TestReconcileAccountDatabaseRemovesOnlyProvenPrivateGroup(t *testing.T) { contents.group = accountDBTestName + ":x:3456:\n" setAccountDBContents(t, contents) f := &fakeRunner{available: map[string]bool{"groupdel": true}} - err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, 0, false) + err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, 0, 0, false) if err == nil || !strings.Contains(err.Error(), "does not prove its GID") { t.Fatalf("legacy reconcile error = %v, want manual group recovery", err) } @@ -313,7 +313,7 @@ func TestReconcileAccountDatabaseRemovesOnlyProvenPrivateGroup(t *testing.T) { t.Run("subordinate IDs keep recovery open", func(t *testing.T) { setAccountDBContents(t, accountDBContents{subuid: accountDBTestName + ":100000:65536\n"}) f := &fakeRunner{available: map[string]bool{"groupdel": true}} - err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, true) + err := absentAccountManager(f).ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, accountDBTestID, true) if err == nil || !strings.Contains(err.Error(), "subuid assignment remains") { t.Fatalf("subuid reconcile error = %v", err) } @@ -350,9 +350,9 @@ func TestReconcileAccountDatabaseRejectsAccountReappearanceAroundGroupdel(t *tes InspectPrivateGroupState: func(string, int, bool) (bool, error) { return !run, nil }, - CheckSubordinateIDsAbsent: func(string) error { return nil }, + CheckSubordinateIDsAbsent: func(string, int) error { return nil }, } - err := m.ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, true) + err := m.ReconcileAccountDatabaseAfterDeletion(accountDBTestName, accountDBTestID, accountDBTestID, true) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("reconcile error = %v, want %q", err, tc.want) } @@ -495,3 +495,186 @@ func TestFailedUseraddWithoutPasswdIsStillCreationStarted(t *testing.T) { t.Fatalf("failed useradd calls = %v", f.calls) } } + +// TestSubordinateIDsAbsentMatchesTheNumericOwnerForm pins the numeric-owner form +// subuid(5) defines alongside the login name ("login name or UID"), and which +// the manual page recommends on hosts with many entries. Missing it reported +// real residue as clean, after which the caller dropped the registry row that +// was its last recovery pointer. +func TestSubordinateIDsAbsentMatchesTheNumericOwnerForm(t *testing.T) { + for _, tc := range []struct { + name string + entry string + owner int + wantError bool + }{ + {name: "login name form", entry: "xxvcc-db1:100000:65536\n", owner: 2001, wantError: true}, + {name: "numeric uid form", entry: "2001:100000:65536\n", owner: 2001, wantError: true}, + {name: "unrelated numeric owner", entry: "2002:100000:65536\n", owner: 2001, wantError: false}, + {name: "unrelated name", entry: "someone:100000:65536\n", owner: 2001, wantError: false}, + {name: "no numeric identity known", entry: "2001:100000:65536\n", owner: 0, wantError: false}, + } { + t.Run(tc.name, func(t *testing.T) { + setAccountDBContents(t, accountDBContents{subuid: tc.entry, subgid: tc.entry}) + err := ensureSubordinateIDsAbsent("xxvcc-db1", tc.owner) + if (err != nil) != tc.wantError { + t.Fatalf("ensureSubordinateIDsAbsent = %v, wantError=%v", err, tc.wantError) + } + }) + } +} + +// TestPrivateGroupRemovalRefusesSystemRangeGIDs pins the groupdel authorization +// to the same floor the account protection and the identity allocator use. +// validate.AccountID only asks for gid > 0, so a system-range GID could have +// authorized removing a group this tool never created. +func TestPrivateGroupRemovalRefusesSystemRangeGIDs(t *testing.T) { + m := &Manager{} + for _, gid := range []int{1, 99, 999} { + err := m.ReconcileAccountDatabaseAfterDeletion("xxvcc-db1", gid, gid, true) + if err == nil || !strings.Contains(err.Error(), "out-of-range GID") { + t.Errorf("ReconcileAccountDatabaseAfterDeletion(gid=%d) = %v, want an out-of-range refusal", gid, err) + } + } + // Without the removal request the GID is not an authorization and is not + // bounded here, so the call must get past this check. + if err := m.ReconcileAccountDatabaseAfterDeletion("xxvcc-db1", 99, 99, false); err != nil && + strings.Contains(err.Error(), "out-of-range GID") { + t.Errorf("a non-removing reconcile was refused on its GID: %v", err) + } +} + +// TestAccountDBParsersSkipWhatTheSystemSkips pins these parsers to the lines +// glibc's nss_files and shadow-utils already ignore. Treating a '#' comment or a +// NIS compatibility entry as a malformed record hard-failed every sequential +// invite on a host that carries one, for lines that define no group and no +// subordinate range at all. +func TestAccountDBParsersSkipWhatTheSystemSkips(t *testing.T) { + const name = accountDBTestName + // Exactly the shapes the system readers skip, in every database these parsers + // read. Each would otherwise trip the "not exactly N fields" rule. + noise := "# a local comment\n \t# indented comment\n \t\n\r\n\v\f# ASCII whitespace\n+::::::\n-badguy\n\n" + live := accountDBLiveContents() + live.passwd = noise + live.passwd + live.group = noise + live.group + live.gshadow = noise + live.gshadow + live.subuid = noise + live.subgid = noise + setAccountDBContents(t, live) + + // Only a parse verdict is under test here; each of these may still return its + // own business error for the fixture's live account. + rejected := func(what string, err error) { + t.Helper() + if err != nil && strings.Contains(err.Error(), "malformed") { + t.Fatalf("%s treated a line the system skips as a malformed record: %v", what, err) + } + } + rejected("subordinate-ID scan", ensureSubordinateIDsAbsent(name, 2001)) + _, groupErr := inspectPrivateGroup(name, 2001, false) + rejected("private-group inspection", groupErr) + _, _, gshadowErr := inspectPrivateGShadow(name) + rejected("gshadow inspection", gshadowErr) + _, sameNameErr := inspectSameNameGroup(name) + rejected("same-name group inspection", sameNameErr) +} + +// subuid(5)/subgid(5) key their first field on the login name or the UID, never +// on a GID. An account whose UID differs from its primary GID — every account +// created before this tool used `useradd -U`, and any host with +// USERGROUPS_ENAB=no — must still have its subordinate ranges scanned by UID, or +// revoke drops the registry row that was the only pointer left to the residue. +func TestSubordinateIDScanUsesTheUIDNotThePrimaryGID(t *testing.T) { + const uid, gid = 1001, 100 + + t.Run("reconcile after this tool's own deletion", func(t *testing.T) { + var got []int + m := absentAccountManager(&fakeRunner{}) + m.InspectPrivateGroupState = func(string, int, bool) (bool, error) { return false, nil } + m.InspectSameNameGroupState = func(string) (bool, error) { return false, nil } + m.CheckSubordinateIDsAbsent = func(_ string, owner int) error { + got = append(got, owner) + return nil + } + if err := m.ReconcileAccountDatabaseAfterDeletion(accountDBTestName, uid, gid, false); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != uid { + t.Fatalf("subordinate scan saw owner %v, want exactly [%d]; scanning the GID reports numeric-form residue as clean", got, uid) + } + }) + + t.Run("verify after an external deletion", func(t *testing.T) { + var got []int + m := absentAccountManager(&fakeRunner{}) + m.InspectPrivateGroupState = func(string, int, bool) (bool, error) { return false, nil } + m.InspectSameNameGroupState = func(string) (bool, error) { return false, nil } + m.CheckSubordinateIDsAbsent = func(_ string, owner int) error { + got = append(got, owner) + return nil + } + if err := m.VerifyAccountDatabaseAfterExternalDeletion(accountDBTestName, uid, gid, false); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != uid { + t.Fatalf("subordinate scan saw owner %v, want exactly [%d]", got, uid) + } + }) + + t.Run("numeric-form residue for the UID is reported, not swallowed", func(t *testing.T) { + f := &fakeRunner{} + m := absentAccountManager(f) + m.InspectPrivateGroupState = func(string, int, bool) (bool, error) { return false, nil } + m.InspectSameNameGroupState = func(string) (bool, error) { return false, nil } + m.CheckSubordinateIDsAbsent = func(_ string, owner int) error { + if owner == uid { + return errors.New("subordinate ID residue for 1001") + } + return nil + } + err := m.ReconcileAccountDatabaseAfterDeletion(accountDBTestName, uid, gid, false) + if err == nil || !strings.Contains(err.Error(), "subordinate ID residue") { + t.Fatalf("ReconcileAccountDatabaseAfterDeletion = %v, want the residue reported", err) + } + }) +} + +// Only classification of non-record lines may discard ASCII whitespace. Real +// records, malformed lines and non-ASCII lookalikes must still reach the strict +// parser; otherwise account or subordinate-ID residue could disappear from view. +func TestAccountDBNonRecordWhitespaceDoesNotHideDamage(t *testing.T) { + for _, line := range []string{ + " \tbroken", + "\u00a0# not an ASCII comment", + "\u2003", + "other:x: 3001 :", + } { + t.Run(line, func(t *testing.T) { + setAccountDBContents(t, accountDBContents{group: line + "\n"}) + if _, err := inspectSameNameGroup(accountDBTestName); err == nil || !strings.Contains(err.Error(), "malformed") { + t.Fatalf("inspectSameNameGroup accepted damaged record %q: %v", line, err) + } + }) + } + for _, line := range []string{" \tbroken", "\u00a0# not an ASCII comment", "\u2003"} { + t.Run("gshadow/"+line, func(t *testing.T) { + setAccountDBContents(t, accountDBContents{gshadow: line + "\n"}) + if _, _, err := inspectPrivateGShadow(accountDBTestName); err == nil || !strings.Contains(err.Error(), "malformed") { + t.Fatalf("inspectPrivateGShadow accepted damaged record %q: %v", line, err) + } + }) + } + for _, line := range []string{ + " \tbroken", + "\u00a0# not an ASCII comment", + "\u2003", + "other: 100000 :65536", + } { + t.Run("subids/"+line, func(t *testing.T) { + setAccountDBContents(t, accountDBContents{subuid: line + "\n", subgid: line + "\n"}) + if err := ensureSubordinateIDsAbsent(accountDBTestName, accountDBTestID); err == nil || !strings.Contains(err.Error(), "malformed") { + t.Fatalf("ensureSubordinateIDsAbsent accepted damaged record %q: %v", line, err) + } + }) + } +} diff --git a/internal/user/user.go b/internal/user/user.go index 530ad74..3bdf3b2 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -19,6 +19,7 @@ import ( "syscall" "time" + "bytes" "github.com/xxvcc/linux-temp-admin/internal/config" "github.com/xxvcc/linux-temp-admin/internal/executil" "github.com/xxvcc/linux-temp-admin/internal/fsutil" @@ -185,7 +186,11 @@ func InspectIdentityAllocation() (IdentityAllocationSnapshot, error) { return IdentityAllocationSnapshot{}, fmt.Errorf("read passwd database for identity allocation: %w", err) } for i, line := range strings.Split(string(passwd), "\n") { - if line == "" { + // Skip exactly what glibc's nss_files skips. Refusing a comment or a NIS + // compatibility entry here hard-failed every invite on a host that carries + // one, and told the operator the database was malformed when the system + // itself reads it fine. Genuinely malformed records still fail closed. + if skipNonEntryLine(line) { continue } pw, err := parsePasswdEntry(line) @@ -203,7 +208,7 @@ func InspectIdentityAllocation() (IdentityAllocationSnapshot, error) { return IdentityAllocationSnapshot{}, fmt.Errorf("read group database for identity allocation: %w", err) } for i, line := range strings.Split(string(groups), "\n") { - if line == "" { + if skipNonEntryLine(line) { continue } parts := strings.Split(line, ":") @@ -313,7 +318,7 @@ func LifecycleMarkerAccounts() ([]string, error) { seen := make(map[string]bool) var names []string for i, line := range strings.Split(string(data), "\n") { - if line == "" { + if skipNonEntryLine(line) { continue } pw, err := parsePasswdEntry(line) @@ -770,7 +775,7 @@ type Manager struct { NameInUse func(string) (bool, error) InspectPrivateGroupState func(string, int, bool) (bool, error) InspectSameNameGroupState func(string) (bool, error) - CheckSubordinateIDsAbsent func(string) error + CheckSubordinateIDsAbsent func(string, int) error ValidateManagedMailRoots func() error PrepareManagedHome func(string) error CreateManagedHome func(Passwd) error @@ -1122,19 +1127,29 @@ func (m *Manager) LockPassword(name string) error { // 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 { +func (m *Manager) SetPassword(name string, password []byte) error { if err := validateMutationName(name); err != nil { return err } if !m.Runner.Look("chpasswd") { return fmt.Errorf("chpasswd not available") } - if strings.ContainsAny(password, ":\n") { + if bytes.ContainsAny(password, ":\n") { // chpasswd's line format is user:password — a colon or newline would split // the record and set a different password than the one we printed. return fmt.Errorf("refusing a password containing ':' or a newline") } - return m.Runner.RunInput(name+":"+password+"\n", "chpasswd") + // Build the chpasswd line in a buffer this function owns and clear it before + // returning. RunInput still takes a string, so one copy remains beyond reach; + // zeroing what can be zeroed is the difference between one unclearable copy + // and four. + line := make([]byte, 0, len(name)+len(password)+2) + line = append(line, name...) + line = append(line, ':') + line = append(line, password...) + line = append(line, '\n') + defer clear(line) + return m.Runner.RunInput(string(line), "chpasswd") } // SetExpiry sets the account expiry date (YYYY-MM-DD) via chage. @@ -1156,9 +1171,23 @@ func (m *Manager) ClearExpiry(name string) error { } // 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 -// numeric form is days-since-epoch and reads ambiguously next to -E -1 ("never"). -const expiredDate = "1970-01-01" +// expired as of now. +// +// It must not be the epoch itself. chage stores this field as days since +// 1970-01-01, so -E 1970-01-01 writes a literal 0, and shadow(5) says of that +// value: "The value 0 should not be used as it is interpreted as either an +// account with no expiration, or as an expiration on Jan 1, 1970." shadow's own +// isexpired() takes the first reading — it requires sp_expire > 0 before it will +// call an account expired — so the epoch would leave the account unexpired on +// the very path DisableLogin relies on. That gate is the one that stops a +// public-key login; the password lock does not. One day past the epoch is still +// unambiguously in the past and encodes as 1. +// +// Passing a literal "0" as the argument would be wrong for a second, unrelated +// reason: chage's numeric form is days-since-epoch and reads ambiguously next to +// -E -1 ("never"). Avoiding that is what the previous value was reaching for; it +// simply moved the same ambiguity one layer down, into the stored field. +const expiredDate = "1970-01-02" // initialLockedPasswordHash is passed to useradd as an encrypted hash. It is not // a valid crypt(3) result, and the leading '!' has the conventional shadow meaning @@ -1274,7 +1303,7 @@ func (m *Manager) delete(name string, expected *Passwd, beforeDelete func() erro return fmt.Errorf("account %s reappeared during artifact cleanup sweep %d", name, sweep) } } - return m.ReconcileAccountDatabaseAfterDeletion(name, expected.GID, removePrivateGroup) + return m.ReconcileAccountDatabaseAfterDeletion(name, expected.UID, expected.GID, removePrivateGroup) } type helper struct { name string @@ -1333,7 +1362,7 @@ func (m *Manager) delete(name string, expected *Passwd, beforeDelete func() erro if !absent { return fmt.Errorf("account %s reappeared during final managed mail cleanup", name) } - return m.ReconcileAccountDatabaseAfterDeletion(name, expected.GID, removePrivateGroup) + return m.ReconcileAccountDatabaseAfterDeletion(name, expected.UID, expected.GID, removePrivateGroup) } if removePrivateGroup { // The artifact cleanup and final job drain above can be long. Repeat the @@ -1357,7 +1386,7 @@ func (m *Manager) delete(name string, expected *Passwd, beforeDelete func() erro if finalStateErr == nil && !stillAbsent { finalStateErr = fmt.Errorf("account %s reappeared during final managed mail cleanup", name) } - accountDBErr := m.ReconcileAccountDatabaseAfterDeletion(name, expected.GID, removePrivateGroup) + accountDBErr := m.ReconcileAccountDatabaseAfterDeletion(name, expected.UID, expected.GID, removePrivateGroup) if runErr != nil { return errors.Join(errors.Join(attemptErrs...), fmt.Errorf("%s removed the account but reported incomplete cleanup: %w", helper.name, runErr), @@ -2192,13 +2221,17 @@ func processesForUID(uid int) ([]int, error) { if !stable { stableEmpty = 0 disturbed++ - continue + } else { + stableEmpty++ + if stableEmpty == processEmptyConfirmations { + return nil, nil + } } - stableEmpty++ - if stableEmpty == processEmptyConfirmations { - return nil, nil + // Give short-lived background activity time to settle without exhausting + // every retry in one burst. Sustained unclassified churn fails closed. + if attempt+1 < processScanAttempts { + processScanSleep(processScanRetryDelay) } - processScanSleep(processScanRetryDelay) } // Name the cause. Every attempt reporting a disturbance means the snapshot kept // racing process activity this scan could not rule out, which is host state @@ -2209,63 +2242,32 @@ func processesForUID(uid int) ([]int, error) { procRoot, processScanAttempts, disturbed) } -// procEntry is one numeric /proc entry with the directory owner captured in the -// snapshot's fast first pass. known is false when the entry was already gone. -type procEntry struct { - tgid int - owner int - known bool -} - func processSnapshotForUID(uid int) ([]int, bool, error) { entries, err := readProcDirectory(procRoot) if err != nil { return nil, false, fmt.Errorf("scan %s: %w", procRoot, err) } - // First pass: one cheap stat per entry, taken while the listing is still fresh, - // records who owns each process directory. /proc/ carries the process's - // real UID, and moving a thread to another UID needs privilege this threat - // model does not grant an unprivileged local user. An entry owned by some other - // unprivileged account therefore cannot host a thread carrying the target UID, - // and neither can anything it forks, so its disappearance says nothing about - // whether the target UID is absent. - // - // This matters because the old snapshot let ANY vanishing entry invalidate the - // whole scan. Ordinary background churn on a busy host — let alone a deliberate - // fork loop from any local account — then denied every invite and every account - // deletion, since processesForUID needs two consecutive stable empty snapshots. - // - // Root and the target UID stay conservative: root can move a thread to the - // target UID, and the target's own processes are exactly what is being looked - // for. An entry that vanished before it could be attributed also stays - // conservative. Residual, deliberately accepted: a multi-threaded process whose - // leader has dropped to another UID while a worker thread still holds root - // would be attributed to the leader's UID. - procEntries := make([]procEntry, 0, len(entries)) + // /proc directory ownership reflects effective credentials (or root for a + // non-dumpable process), not every real/effective/saved/filesystem UID of every + // thread. A foreign-owned group may therefore still carry the target UID. Read + // credentials directly, without a separate stat pass that both adds a race + // window and cannot establish that a disappearing group was unrelated. + var pids []int + stable := true for _, entry := range entries { tgid, convErr := strconv.Atoi(entry.Name()) if convErr != nil { continue } - var st unix.Stat_t - if statErr := unix.Fstatat(unix.AT_FDCWD, filepath.Join(procRoot, entry.Name()), &st, unix.AT_SYMLINK_NOFOLLOW); statErr == nil { - procEntries = append(procEntries, procEntry{tgid: tgid, owner: int(st.Uid), known: true}) - continue - } - procEntries = append(procEntries, procEntry{tgid: tgid}) - } - var pids []int - stable := true - for _, entry := range procEntries { - matched, groupStable, err := processGroupHasUID(entry.tgid, uid) + matched, groupStable, err := processGroupHasUID(tgid, uid) if err != nil { - return nil, false, fmt.Errorf("read thread credentials for process %d: %w", entry.tgid, err) + return nil, false, fmt.Errorf("read thread credentials for process %d: %w", tgid, err) } - if !groupStable && (!entry.known || entry.owner == 0 || entry.owner == uid) { + if !groupStable { stable = false } if matched { - pids = append(pids, entry.tgid) + pids = append(pids, tgid) } } sort.Ints(pids) diff --git a/internal/user/user_test.go b/internal/user/user_test.go index eee9e39..c7cd049 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -699,7 +699,7 @@ func (f *fakeRunner) Look(name string) bool { return f.available[name] } func stubAbsentAccountDatabaseChecks(m *Manager) *Manager { m.InspectPrivateGroupState = func(string, int, bool) (bool, error) { return false, nil } m.InspectSameNameGroupState = func(string) (bool, error) { return false, nil } - m.CheckSubordinateIDsAbsent = func(string) error { return nil } + m.CheckSubordinateIDsAbsent = func(string, int) error { return nil } return m } @@ -735,7 +735,7 @@ func TestAccountMutationsRejectInvalidUsernameBeforeRunningHelpers(t *testing.T) {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 password", run: func(m *Manager) error { return m.SetPassword("bad:user", []byte("secret")) }}, {name: "set expiry", run: func(m *Manager) error { return m.SetExpiry("bad:user", "2026-07-09") }}, {name: "clear expiry", run: func(m *Manager) error { return m.ClearExpiry("bad:user") }}, {name: "delete", run: func(m *Manager) error { return m.DeleteExpected("bad:user", Passwd{}, noOpBeforeDelete) }}, @@ -766,7 +766,7 @@ func TestAccountMutationsRejectReservedUsernameBeforeRunningHelpers(t *testing.T {name: "mark managed", run: func(m *Manager) error { return m.MarkManaged("nobody", testGeneration) }}, {name: "disable key password", run: func(m *Manager) error { return m.DisablePasswordForKeyLogin("nobody") }}, {name: "lock password", run: func(m *Manager) error { return m.LockPassword("nobody") }}, - {name: "set password", run: func(m *Manager) error { return m.SetPassword("nobody", "secret") }}, + {name: "set password", run: func(m *Manager) error { return m.SetPassword("nobody", []byte("secret")) }}, {name: "set expiry", run: func(m *Manager) error { return m.SetExpiry("nobody", "2026-07-09") }}, {name: "clear expiry", run: func(m *Manager) error { return m.ClearExpiry("nobody") }}, {name: "disable login", run: func(m *Manager) error { return m.DisableLogin("nobody") }}, @@ -1818,51 +1818,123 @@ func TestValidateCreatedHomeRequiresNonRootOwnedRealDirectory(t *testing.T) { } } -// TestProcessSnapshotAttributesInstabilityByOwner pins the rule that keeps an -// unrelated account's process churn from denying every invite and revoke: a -// vanishing entry owned by another unprivileged account cannot host or fork a -// target-UID thread, so it must not invalidate the snapshot, while a root-owned -// one still must. -func TestProcessSnapshotAttributesInstabilityByOwner(t *testing.T) { - if os.Getuid() != 0 { - t.Skip("attribution test needs root to set a foreign directory owner") - } +// Directory ownership cannot prove that a vanished group did not retain the +// target in another UID column or in a worker thread's credentials. +func TestProcessSnapshotRequiresCredentialsRegardlessOfDirectoryOwner(t *testing.T) { const target = 1111 - const foreignUID = 65534 for _, tc := range []struct { - name string - owner int - wantStable bool + name string + owner int }{ - {name: "third-party owner does not destabilise", owner: foreignUID, wantStable: true}, - {name: "root owner still destabilises", owner: 0, wantStable: false}, - {name: "target owner still destabilises", owner: target, wantStable: false}, + {name: "current owner", owner: os.Geteuid()}, + {name: "third-party owner", owner: 65534}, + {name: "root owner", owner: 0}, + {name: "target owner", owner: target}, } { t.Run(tc.name, func(t *testing.T) { + if os.Geteuid() != 0 && tc.owner != os.Geteuid() { + t.Skip("foreign fixture ownership requires root in a disposable test environment") + } setProcRoot(t, nil) - // A process directory with no numeric task entries is exactly the shape - // processGroupHasUID reports as unstable. pidDir := filepath.Join(procRoot, "4242") if err := os.MkdirAll(filepath.Join(pidDir, "task"), 0o700); err != nil { t.Fatal(err) } - if err := os.Chown(pidDir, tc.owner, tc.owner); err != nil { - t.Fatal(err) + if tc.owner != os.Geteuid() { + if err := os.Chown(pidDir, tc.owner, tc.owner); err != nil { + t.Fatal(err) + } } pids, stable, err := processSnapshotForUID(target) - if err != nil { - t.Fatalf("processSnapshotForUID: %v", err) + if err != nil || len(pids) != 0 || stable { + t.Fatalf("processSnapshotForUID = %v, %v, %v; owner %d cannot justify a stable empty scan", pids, stable, err, tc.owner) + } + }) + } +} + +func TestProcessesForUIDDoesNotLoseMixedUIDForkLineage(t *testing.T) { + const target = 1111 + for _, sustained := range []bool{false, true} { + t.Run(fmt.Sprintf("sustained=%v", sustained), func(t *testing.T) { + // Match Linux's dumpable mixed-UID shape: real UID is the target, + // while the directory owner is its unrelated effective UID. + owner := os.Geteuid() + if owner == 0 { + owner = 65534 + } + status := fmt.Sprintf("State:\tS (sleeping)\nUid:\t%d\t%d\t%d\t%d\n", target, owner, owner, owner) + setProcRoot(t, nil) + writeMixed := func(pid int) { + writeProcProcess(t, pid, status) + if owner != os.Geteuid() { + if err := os.Chown(filepath.Join(procRoot, strconv.Itoa(pid)), owner, owner); err != nil { + t.Fatal(err) + } + } } - if len(pids) != 0 { - t.Fatalf("pids = %v, want none", pids) + writeMixed(77) + oldReadDir := readProcDirectory + t.Cleanup(func() { readProcDirectory = oldReadDir }) + vanished := 0 + readProcDirectory = func(path string) ([]os.DirEntry, error) { + entries, err := oldReadDir(path) + if err != nil || filepath.Base(path) != "task" || (!sustained && vanished > 0) { + return entries, err + } + // Fork after the root listing and vanish after the task listing, + // leaving the child absent from this scan's directory snapshot. + pidDir := filepath.Dir(path) + pid, err := strconv.Atoi(filepath.Base(pidDir)) + if err != nil { + t.Fatal(err) + } + writeMixed(pid + 1) + if err := os.RemoveAll(pidDir); err != nil { + t.Fatal(err) + } + vanished++ + return entries, nil } - if stable != tc.wantStable { - t.Fatalf("stable = %v, want %v for owner %d", stable, tc.wantStable, tc.owner) + pids, err := processesForUID(target) + if sustained { + if err == nil || !strings.Contains(err.Error(), "no consecutive stable empty process snapshots") || vanished != processScanAttempts { + t.Fatalf("processesForUID = %v, %v, vanished=%d; want bounded fail-closed scan", pids, err, vanished) + } + } else if err != nil || !reflect.DeepEqual(pids, []int{78}) { + t.Fatalf("processesForUID = %v, %v; want mixed-UID child 78", pids, err) + } + if _, err := os.Stat(filepath.Join(procRoot, strconv.Itoa(77+vanished))); err != nil { + t.Fatalf("test must retain its live mixed-UID descendant: %v", err) } }) } } +func TestProcessesForUIDAllowsTransientUnrelatedChurnToSettle(t *testing.T) { + setProcRoot(t, map[int]string{77: "State:\tS (sleeping)\nUid:\t9999\t9999\t9999\t9999\n"}) + oldReadDir, oldSleep := readProcDirectory, processScanSleep + t.Cleanup(func() { readProcDirectory, processScanSleep = oldReadDir, oldSleep }) + reads, waits := 0, 0 + readProcDirectory = func(path string) ([]os.DirEntry, error) { + entries, err := oldReadDir(path) + if err == nil && path == procRoot { + reads++ + if reads == 1 { + if err := os.RemoveAll(filepath.Join(procRoot, "77")); err != nil { + t.Fatal(err) + } + } + } + return entries, err + } + processScanSleep = func(time.Duration) { waits++ } + pids, err := processesForUID(1111) + if err != nil || len(pids) != 0 || reads != 3 || waits != 2 { + t.Fatalf("processesForUID = %v, %v, reads=%d waits=%d; want retry then two stable empty scans", pids, err, reads, waits) + } +} + func setProcRoot(t *testing.T, statuses map[int]string) { t.Helper() dir := t.TempDir() @@ -3412,7 +3484,7 @@ func TestDisableLoginExpiresBeforeLocking(t *testing.T) { 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", "xxvcc-u"}) { + if !reflect.DeepEqual(f.calls[0], []string{"chage", "-E", "1970-01-02", "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", "xxvcc-u"}) { @@ -3451,3 +3523,120 @@ func TestTerminateProcessesNeverSignalsRootOrAll(t *testing.T) { } } } + +// TestExpiredDateStoresANonZeroExpiry pins expiredDate away from the one value +// shadow(5) singles out as unusable: "The value 0 should not be used as it is +// interpreted as either an account with no expiration, or as an expiration on +// Jan 1, 1970." shadow's isexpired() takes the first reading and requires +// sp_expire > 0, and DisableLogin leans on that expiry as the gate that stops a +// public-key login, which the password lock does not. +func TestExpiredDateStoresANonZeroExpiry(t *testing.T) { + parsed, err := time.Parse("2006-01-02", expiredDate) + if err != nil { + t.Fatalf("expiredDate %q is not a chage date: %v", expiredDate, err) + } + // chage stores days since the epoch; this is the value that lands in the + // eighth /etc/shadow field. + days := int(parsed.UTC().Sub(time.Unix(0, 0).UTC()).Hours() / 24) + if days < 1 { + t.Fatalf("expiredDate %q stores sp_expire=%d, which shadow may read as no expiration at all", expiredDate, days) + } + if !parsed.Before(time.Now()) { + t.Fatalf("expiredDate %q is not safely in the past", expiredDate) + } +} + +// glibc's nss_files skips blank lines, '#' comments and the '+'/'-' NIS +// compatibility entries, and shadow's tools accept databases containing them. +// These two scans run before anything else in an invite, so treating such a line +// as "malformed" made a single comment in /etc/group stop every invite on the +// host — and blamed a database the system reads perfectly. +func TestIdentityScansSkipWhatTheSystemSkipsButStillFailClosedOnRealDamage(t *testing.T) { + const goodPasswd = "root:x:0:0:root:/root:/bin/sh\nalice:x:1500:1500::/home/alice:/bin/sh\n" + const goodGroup = "root:x:0:\nalice:x:1500:\n" + + t.Run("allocation range tolerates comment and NIS lines", func(t *testing.T) { + for _, tc := range []struct{ name, passwd, group string }{ + {"comment in group", goodPasswd, "# admin comment\n" + goodGroup}, + {"NIS compat in group", goodPasswd, goodGroup + "+:::\n"}, + {"comment in passwd", "# local comment\n" + goodPasswd, goodGroup}, + {"NIS compat in passwd", goodPasswd + "+::::::\n", goodGroup}, + {"NIS exclusion in passwd", goodPasswd + "-bob::::::\n", goodGroup}, + } { + t.Run(tc.name, func(t *testing.T) { + setIdentityDatabases(t, tc.passwd, tc.group, "UID_MIN 1000\nUID_MAX 60000\n") + minimum, _, err := IdentityAllocationRange() + if err != nil { + t.Fatalf("IdentityAllocationRange() = %v, want the scan to skip the line the system skips", err) + } + if minimum != 1501 { + t.Fatalf("minimum = %d, want 1501 (the skipped line must not change the high-water mark)", minimum) + } + }) + } + }) + + t.Run("a genuinely malformed record still fails closed", func(t *testing.T) { + setIdentityDatabases(t, goodPasswd, "broken:x:not-a-gid:\n", "UID_MIN 1000\n") + if _, _, err := IdentityAllocationRange(); err == nil || !strings.Contains(err.Error(), "malformed group GID") { + t.Fatalf("IdentityAllocationRange error = %v, want the malformed record still rejected", err) + } + }) + + t.Run("marker inventory tolerates the same lines", func(t *testing.T) { + setIdentityDatabases(t, "# local comment\n"+goodPasswd+"+::::::\n", goodGroup, "UID_MIN 1000\n") + names, err := LifecycleMarkerAccounts() + if err != nil { + t.Fatalf("LifecycleMarkerAccounts() = %v, want the comment skipped (it blocks uninstall and doctor)", err) + } + if len(names) != 0 { + t.Fatalf("LifecycleMarkerAccounts() = %v, want no marked accounts", names) + } + }) + + t.Run("marker inventory still fails closed on a malformed record", func(t *testing.T) { + setIdentityDatabases(t, goodPasswd+"truncated:x:1600\n", goodGroup, "UID_MIN 1000\n") + if _, err := LifecycleMarkerAccounts(); err == nil { + t.Fatal("LifecycleMarkerAccounts() accepted a truncated passwd record") + } + }) +} + +func TestIdentityScansAcceptASCIINonRecordWhitespace(t *testing.T) { + const passwd = "root:x:0:0:root:/root:/bin/sh\nalice:x:1500:1500::/home/alice:/bin/sh\n" + const group = "root:x:0:\nalice:x:1500:\n" + for _, noise := range []string{ + " \t# indented comment\n", + " \t\n", + "\r\n", + "\v\f\r\t # ASCII whitespace comment\n", + "\v\f\r\t \n", + } { + t.Run(strconv.Quote(noise), func(t *testing.T) { + setIdentityDatabases(t, noise+passwd, noise+group, "UID_MIN 1000\nUID_MAX 60000\n") + minimum, _, err := IdentityAllocationRange() + if err != nil || minimum != 1501 { + t.Fatalf("IdentityAllocationRange = %d, %v; want 1501 with system-valid non-record lines", minimum, err) + } + if names, err := LifecycleMarkerAccounts(); err != nil || len(names) != 0 { + t.Fatalf("LifecycleMarkerAccounts = %v, %v; want an empty inventory", names, err) + } + }) + } + for _, damaged := range []string{ + " \ttruncated:x:1600\n", + "\u00a0# not an ASCII comment\n", + "\u2003\n", + "broken:x: 1600 :1600::/home/broken:/bin/sh\n", + } { + t.Run("damage/"+strconv.Quote(damaged), func(t *testing.T) { + setIdentityDatabases(t, passwd+damaged, group, "UID_MIN 1000\nUID_MAX 60000\n") + if _, _, err := IdentityAllocationRange(); err == nil { + t.Fatalf("IdentityAllocationRange accepted damaged passwd line %q", damaged) + } + if _, err := LifecycleMarkerAccounts(); err == nil { + t.Fatalf("LifecycleMarkerAccounts accepted damaged passwd line %q", damaged) + } + }) + } +} diff --git a/internal/userjobs/jobs.go b/internal/userjobs/jobs.go index f016953..7c25849 100644 --- a/internal/userjobs/jobs.go +++ b/internal/userjobs/jobs.go @@ -105,7 +105,7 @@ func cronAtDaemonPresent(procRoot string) (bool, error) { commPath := filepath.Join(procRoot, entry, "comm") nameBody, nameErr := readProcEntry(commPath, 64) if nameErr != nil { - if errors.Is(nameErr, os.ErrNotExist) { + if procEntryVanished(nameErr) { continue } return false, fmt.Errorf("read process name %s: %w", commPath, nameErr) @@ -120,7 +120,7 @@ func cronAtDaemonPresent(procRoot string) (bool, error) { statusPath := filepath.Join(procRoot, entry, "status") rootProcess, statusErr := processRunsAsRoot(statusPath) if statusErr != nil { - if errors.Is(statusErr, os.ErrNotExist) { + if procEntryVanished(statusErr) { continue } return false, fmt.Errorf("verify process credentials %s: %w", statusPath, statusErr) @@ -131,7 +131,7 @@ func cronAtDaemonPresent(procRoot string) (bool, error) { executable, executableErr := drainExecutableInfo(procRoot, entry) if executableErr != nil { - if errors.Is(executableErr, os.ErrNotExist) { + if procEntryVanished(executableErr) { continue } return false, fmt.Errorf("verify process executable %s: %w", filepath.Join(procRoot, entry, "exe"), executableErr) @@ -556,3 +556,13 @@ func ownedSpoolArtifacts(directories []string, uid uint32) ([]string, error) { } return artifacts, nil } + +// procEntryVanished reports whether err means the process disappeared between +// listing /proc and reading the entry. The kernel answers ENOENT for a pid +// directory that is already gone and ESRCH for one being torn down while the open +// is in flight; both are the same fact. Treating only ENOENT as "it vanished" +// turned an unrelated short-lived process exiting at the wrong moment into a +// failed daemon scan. +func procEntryVanished(err error) bool { + return errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ESRCH) +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index ef4ae7b..070f321 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -56,7 +56,19 @@ func AccountID(id int) bool { return id > 0 && KernelID(id) } // a cleaned or symlink-resolved equivalent would weaken the pathname binding // used before recursive cleanup. func ManagedHome(user, home string) bool { - return Username(user) && home == "/home/"+user + return Username(user) && home == ManagedHomePath(user) +} + +// ManagedHomePath is the one spelling of the dedicated home this tool assigns. +// Everything that compares against that path derives it from here, so the +// copies cannot drift: a second spelling that disagrees would let one check +// accept a path another refuses. It returns "" for a name this tool could never +// have created, so no caller can build a path for one. +func ManagedHomePath(user string) string { + if !Username(user) { + return "" + } + return "/home/" + user } // Prefix reports whether s is a valid username prefix. diff --git a/scripts/install.sh b/scripts/install.sh index e6cc5fd..8b4ea0f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -133,8 +133,8 @@ MCowBQYDK2VwAyEAmCRx+wyfgvdhQ8idBF+KkxGA+Myifa1ShrsgAGFOrxw= # LTA_RELEASE_KEYS_END case "$(uname -m)" in - x86_64) arch=amd64 ;; - aarch64 | arm64) arch=arm64 ;; + x86_64) arch=amd64; elf_machine=62 ;; + aarch64 | arm64) arch=arm64; elf_machine=183 ;; *) fail "unsupported architecture: $(uname -m)" ;; esac asset="linux-temp-admin-linux-${arch}" @@ -667,6 +667,30 @@ parse_mirror_manifest() { ' "$1" } +# elf_machine_matches reads EI_CLASS, EI_DATA and e_machine straight out of the +# downloaded bytes and compares them with the architecture this host reported. +# +# The detached signature covers raw bytes only: it carries no asset name and no +# architecture, and the release-version witness is byte-identical in the amd64 and +# arm64 builds of one release. SHA256SUMS, the only artifact binding a name to a +# digest, is unsigned. So a mirror that serves the other architecture's genuinely +# signed binary under this architecture's asset name passes every check above. +# The bytes themselves still say which machine they are for. +elf_machine_matches() { + elf_want=$2 + od -An -N 20 -v -t u1 "$1" | awk -v want="$elf_want" ' + { for (i = 1; i <= NF; i++) b[++n] = $i } + END { + if (n < 20) exit 1 + # \x7f E L F, EI_CLASS 2 (64-bit), EI_DATA 1 (little endian) + if (b[1] != 127 || b[2] != 69 || b[3] != 76 || b[4] != 70) exit 1 + if (b[5] != 2 || b[6] != 1) exit 1 + # e_machine is a little-endian uint16 at offset 18 + if (b[19] + b[20] * 256 != want) exit 1 + } + ' +} + canonical_text_file() { od -An -v -t u1 "$1" | awk ' { @@ -895,6 +919,9 @@ stage=$(mktemp "${dest_dir}/.linux-temp-admin.XXXXXX") if [ ! -f "$stage" ] || [ -L "$stage" ]; then fail "could not create a safe staging file" fi +if ! elf_machine_matches "$tmp/bin" "$elf_machine"; then + fail "the downloaded binary is not a 64-bit little-endian Linux ELF for $arch; refusing to install another architecture's release" +fi cp -- "$tmp/bin" "$stage" chown 0:0 -- "$stage" chmod 0755 -- "$stage" @@ -904,7 +931,10 @@ chmod 0755 -- "$stage" # 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" + # stdin is still the pipe carrying the rest of this script when the documented + # `curl ... | sh` path is used, and the candidate is an untrusted binary at this + # point. Hand it /dev/null so it cannot consume or observe the installer text. + exec timeout -k 1 10 "$stage" version > "$tmp/version" 2> "$tmp/version.err" < /dev/null ); then fail "downloaded binary failed its pre-install version probe" fi @@ -930,7 +960,7 @@ if [ "$DEST" = "$MANAGED_DEST" ]; then # 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 + if ! timeout -k 1 30 "$stage" --lang en install --force >/dev/null 2>&1 < /dev/null; then fail "signed candidate could not complete the managed install/reactivation" fi rm -f -- "$stage" || fail "could not remove the verified staging file" diff --git a/scripts/mirror-receiver.py b/scripts/mirror-receiver.py index 0c52f42..0282847 100644 --- a/scripts/mirror-receiver.py +++ b/scripts/mirror-receiver.py @@ -32,7 +32,11 @@ RRSYNC = Path("/usr/bin/rrsync") MIRROR_BASE_URL = "https://dl.ll.cd/linux-temp-admin" TRANSFER_TIMEOUT_SECONDS = 300 +# Bounds the published-installer scan, which hashes one file per release while +# holding the deployment lock. +INSTALLER_SCAN_TIMEOUT_SECONDS = 120 STAGING_SCAN_INTERVAL_SECONDS = 0.1 +TERMINATION_SIGNALS = (signal.SIGTERM, signal.SIGHUP, signal.SIGINT) MAX_BINARY_BYTES = 64 * 1024 * 1024 MAX_METADATA_BYTES = 1024 * 1024 MAX_RELEASE_VERSION_BYTES = 128 @@ -99,6 +103,39 @@ def fail(message: str) -> None: raise ReceiverError(message) +@contextmanager +def controlled_termination() -> Iterator[None]: + # Raising once unwinds through transfer and staging cleanup. Repeated signals + # must not interrupt that cleanup and release the deployment lock early. + terminating = False + + def terminate(signum: int, _frame: object) -> None: + nonlocal terminating + if not terminating: + terminating = True + fail(f"receiver interrupted by {signal.Signals(signum).name}") + + previous = {signum: signal.getsignal(signum) for signum in TERMINATION_SIGNALS} + try: + for signum in TERMINATION_SIGNALS: + signal.signal(signum, terminate) + yield + finally: + for signum, handler in previous.items(): + signal.signal(signum, handler) + + +@contextmanager +def blocked_termination() -> Iterator[set[signal.Signals]]: + # Defer Python's asynchronous handler while acquiring ownership of a child + # or killing/reaping it. A pending signal is delivered after the safe point. + previous = signal.pthread_sigmask(signal.SIG_BLOCK, TERMINATION_SIGNALS) + try: + yield previous + finally: + signal.pthread_sigmask(signal.SIG_SETMASK, previous) + + @contextmanager def unmasked_creation() -> Iterator[None]: # The receiver is single-threaded. Set exact modes at creation so a crash @@ -554,9 +591,19 @@ def publish_version(staged: Path, destination: Path, *, owner: int) -> None: def matching_stable_installer_versions( project_root: Path, installer: Path, *, owner: int ) -> list[VersionKey]: + # This runs under the deployment lock and fully hashes the installer of every + # published release, so its cost grows with the archive while every other + # publication waits. Bound it the way every other long step here is bounded: + # a deadline that fails the publication instead of holding the lock forever. + deadline = time.monotonic() + INSTALLER_SCAN_TIMEOUT_SECONDS matches: list[VersionKey] = [] with os.scandir(project_root) as entries: for entry in entries: + if time.monotonic() > deadline: + raise ReceiverError( + "scanning published installers exceeded " + f"{INSTALLER_SCAN_TIMEOUT_SECONDS}s under the deployment lock" + ) if not entry.is_dir(follow_symlinks=False): continue try: @@ -777,12 +824,30 @@ def require_project_budget( ) +def observe_transfer(process: subprocess.Popen[bytes]) -> os.waitid_result | None: + # WNOWAIT keeps the leader's PID reserved until kill_transfer has retired + # its process group. Popen.wait/poll would reap it and allow that numeric + # PID/PGID to name an unrelated later session before killpg runs. + return os.waitid(os.P_PID, process.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) + + def kill_transfer(process: subprocess.Popen[bytes]) -> None: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - process.wait() + with blocked_termination(): + if process.returncode is not None: + # Idempotent cleanup after wait: the old numeric PGID is no longer + # ours to signal, even if a different group now has the same number. + return + # Prove the child is still ours before signaling the numeric group. No + # other code reaps this child; the receiver is deliberately single-threaded. + try: + observe_transfer(process) + except ChildProcessError: + fail("lost ownership of the transfer child; refusing to signal a reused process group") + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() def run_rrsync(stage: Path, original_command: str) -> None: @@ -795,38 +860,47 @@ def run_rrsync(stage: Path, original_command: str) -> None: "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, - ) + process: subprocess.Popen[bytes] | None = None try: + with blocked_termination() as previous_mask: + def prepare_child() -> None: + limit_receiver() + signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask) + + process = subprocess.Popen( + # -munge confines sender symlinks to staging; the accepted option + # profile includes --links, so they would otherwise escape it. + [str(RRSYNC), "-munge", "-wo", "-no-del", str(stage)], + env=environment, + preexec_fn=prepare_child, + start_new_session=True, + ) deadline = time.monotonic() + TRANSFER_TIMEOUT_SECONDS while True: remaining = deadline - time.monotonic() if remaining <= 0: fail("rsync transfer exceeded its time limit") - try: - status = process.wait( - timeout=min(STAGING_SCAN_INTERVAL_SECONDS, remaining) - ) + observed = observe_transfer(process) + if observed is not None: + status = observed.si_status if observed.si_code == os.CLD_EXITED else -observed.si_status break - except subprocess.TimeoutExpired: - # Count every current and residual transfer tree, not only this - # session. The deployment lock is included; its fixed metadata - # is negligible and including it avoids a pathname exception. - require_staging_budget(stage.parent) + time.sleep(min(STAGING_SCAN_INTERVAL_SECONDS, remaining)) + # Count every current and residual transfer tree, not only this + # session. The deployment lock is included; its fixed metadata + # is negligible and including it avoids a pathname exception. + require_staging_budget(stage.parent) + # Transfers never intentionally daemonize. Retire all remaining writers + # while the leader is still unreaped, before validating staged files. + kill_transfer(process) if status != 0: fail(f"rrsync rejected or failed the transfer with status {status}") # Close the race between the final periodic scan and process exit before any # release validation or publication begins. require_staging_budget(stage.parent) except BaseException as operation_error: - # No catchable receiver exit may leave a writer outside the deployment - # lock. The separate session lets one signal terminate rrsync and every - # rsync descendant before the lock can be released. - if process.returncode is None: + # Even an exited group leader can leave a writer. Kill the group and reap + # the direct child before the deployment lock may be released. + if process is not None: try: kill_transfer(process) except BaseException as cleanup_error: @@ -881,7 +955,7 @@ def open_lock(path: Path, *, owner: int, nonblocking: bool = False) -> int: return descriptor -def main() -> int: +def receive() -> int: owner = os.getuid() if owner == 0: fail("mirror receiver must not run as root") @@ -928,6 +1002,11 @@ def main() -> int: return 0 +def main() -> int: + with controlled_termination(): + return receive() + + if __name__ == "__main__": try: raise SystemExit(main()) diff --git a/scripts/mirror_receiver_test.py b/scripts/mirror_receiver_test.py index a99565f..0e97ed6 100644 --- a/scripts/mirror_receiver_test.py +++ b/scripts/mirror_receiver_test.py @@ -5,6 +5,8 @@ import os from pathlib import Path import shutil +import signal +import time import tempfile import unittest from unittest import mock @@ -245,18 +247,140 @@ def test_running_transfer_is_killed_as_soon_as_it_exceeds_budget(self) -> None: mirror_receiver.MAX_STAGING_ENTRIES, ) = original + def test_termination_signals_reap_transfer_before_unlock(self) -> None: + for signum in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): + with self.subTest(signal=signum): + stage = self.temporary / f"signal-{signum}" + stage.mkdir() + pidfile = stage / "writer.pid" + fake_rrsync = stage / "rrsync" + fake_rrsync.write_text( + "#!/usr/bin/python3\n" + "import os, signal, sys\n" + "from pathlib import Path\n" + "Path(sys.argv[-1], 'writer.pid').write_text(str(os.getpid()))\n" + "while True: signal.pause()\n", + encoding="ascii", + ) + fake_rrsync.chmod(0o755) + lock = stage / ".deploy.lock" + cleanup_ready = stage / "cleanup-ready" + cleanup_proceed = stage / "cleanup-proceed" + child = os.fork() + if child == 0: + try: + mirror_receiver.RRSYNC = fake_rrsync + original_kill = mirror_receiver.kill_transfer + + def paused_kill(process) -> None: + cleanup_ready.touch() + deadline = time.monotonic() + 5 + while not cleanup_proceed.exists(): + if time.monotonic() > deadline: + raise RuntimeError("test cleanup handshake timed out") + time.sleep(0.01) + original_kill(process) + + def locked_receive() -> int: + descriptor = mirror_receiver.open_lock( + lock, owner=os.getuid(), nonblocking=True + ) + try: + mirror_receiver.run_rrsync(stage, "accepted-test-command") + finally: + os.close(descriptor) + return 0 + + mirror_receiver.kill_transfer = paused_kill + mirror_receiver.receive = locked_receive + mirror_receiver.main() + except mirror_receiver.ReceiverError as exc: + os._exit(73 if signal.Signals(signum).name in str(exc) else 74) + except BaseException: + os._exit(75) + os._exit(76) + writer = None + reaped = False + try: + self.wait_for_file(pidfile) + writer = int(pidfile.read_text(encoding="ascii")) + os.kill(child, signum) + self.wait_for_file(cleanup_ready) + # Repeated, different termination signals cannot break the + # in-progress shutdown and release its deployment lock. + os.kill(child, signal.SIGHUP) + os.kill(child, signal.SIGTERM) + with self.assertRaisesRegex( + mirror_receiver.ReceiverError, "deployment is already active" + ): + mirror_receiver.open_lock(lock, owner=os.getuid(), nonblocking=True) + cleanup_proceed.touch() + deadline = time.monotonic() + 5 + while True: + waited, status = os.waitpid(child, os.WNOHANG) + if waited == child: + reaped = True + break + self.assertLess(time.monotonic(), deadline, "receiver did not stop") + time.sleep(0.01) + self.assertTrue(os.WIFEXITED(status)) + self.assertEqual(os.WEXITSTATUS(status), 73) + with self.assertRaises(ProcessLookupError): + os.kill(writer, 0) + descriptor = mirror_receiver.open_lock(lock, owner=os.getuid(), nonblocking=True) + os.close(descriptor) + finally: + if writer is not None: + try: + os.killpg(writer, signal.SIGKILL) + except ProcessLookupError: + pass + if not reaped: + os.kill(child, signal.SIGKILL) + os.waitpid(child, 0) + + def wait_for_file(self, path: Path) -> None: + deadline = time.monotonic() + 5 + while not path.exists() or path.name == "writer.pid" and not path.stat().st_size: + self.assertLess(time.monotonic(), deadline, f"timed out waiting for {path}") + time.sleep(0.01) + + def test_signal_during_spawn_cannot_orphan_unassigned_child(self) -> None: + stage = self.temporary / "spawn-signal" + stage.mkdir() + fake_rrsync = stage / "rrsync" + fake_rrsync.write_text("#!/bin/sh\nexec sleep 30\n", encoding="ascii") + fake_rrsync.chmod(0o755) + original_popen = mirror_receiver.subprocess.Popen + processes = [] + + def signal_after_spawn(*args, **kwargs): + process = original_popen(*args, **kwargs) + processes.append(process) + os.kill(os.getpid(), signal.SIGTERM) + return process + + with mock.patch.object(mirror_receiver, "RRSYNC", fake_rrsync), mock.patch.object( + mirror_receiver.subprocess, "Popen", side_effect=signal_after_spawn + ), mirror_receiver.controlled_termination(): + with self.assertRaisesRegex(mirror_receiver.ReceiverError, "SIGTERM"): + mirror_receiver.run_rrsync(stage, "accepted-test-command") + self.assertEqual(len(processes), 1) + self.assertIsNotNone(processes[0].returncode) + with self.assertRaises(ProcessLookupError): + os.kill(processes[0].pid, 0) + def test_nonreceiver_monitor_failure_kills_transfer_and_preserves_error(self) -> None: process = mock.Mock() process.returncode = None process.pid = 12345 - process.wait.side_effect = mirror_receiver.subprocess.TimeoutExpired( - cmd="rrsync", timeout=0.01 - ) primary = RuntimeError("monitor failed unexpectedly") stage = self.temporary / "monitor-failure-stage" stage.mkdir() with mock.patch.object( mirror_receiver.subprocess, "Popen", return_value=process + ), mock.patch.object( + mirror_receiver, "observe_transfer", return_value=None ), mock.patch.object( mirror_receiver, "require_staging_budget", side_effect=primary ), mock.patch.object(mirror_receiver, "kill_transfer") as kill: @@ -268,15 +392,14 @@ def test_nonreceiver_monitor_failure_kills_transfer_and_preserves_error(self) -> def test_transfer_kill_failure_preserves_the_monitor_error(self) -> None: process = mock.Mock() process.returncode = None - process.wait.side_effect = mirror_receiver.subprocess.TimeoutExpired( - cmd="rrsync", timeout=0.01 - ) primary = RuntimeError("monitor failed unexpectedly") cleanup = OSError("could not reap transfer group") stage = self.temporary / "kill-failure-stage" stage.mkdir() with mock.patch.object( mirror_receiver.subprocess, "Popen", return_value=process + ), mock.patch.object( + mirror_receiver, "observe_transfer", return_value=None ), mock.patch.object( mirror_receiver, "require_staging_budget", side_effect=primary ), mock.patch.object( @@ -287,6 +410,67 @@ def test_transfer_kill_failure_preserves_the_monitor_error(self) -> None: self.assertIs(caught.exception, primary) self.assertIs(caught.exception.__cause__, cleanup) + def test_exited_transfer_leader_stays_waitable_until_group_cleanup(self) -> None: + for status in (0, 7): + with self.subTest(status=status): + stage = self.temporary / f"waitable-{status}" + stage.mkdir() + fake_rrsync = stage / "rrsync" + marker = stage / "survived" + fake_rrsync.write_text( + "#!/usr/bin/python3\n" + "import os, sys, time\n" + "from pathlib import Path\n" + "child = os.fork()\n" + "if child == 0:\n" + " time.sleep(0.4)\n" + " Path(sys.argv[-1], 'survived').write_text('writer survived')\n" + " os._exit(0)\n" + f"os._exit({status})\n", + encoding="ascii", + ) + fake_rrsync.chmod(0o755) + original_killpg = os.killpg + killed = [] + + def require_waitable_before_kill(pid, signum): + # A real waitid proves ownership still exists at the signal. + # With the former Popen.wait loop this raises ECHILD: its + # numeric PGID could already name an unrelated new session. + observed = os.waitid(os.P_PID, pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) + self.assertIsNotNone(observed) + self.assertEqual(observed.si_status, status) + killed.append(pid) + return original_killpg(pid, signum) + + with mock.patch.object(mirror_receiver, "RRSYNC", fake_rrsync), mock.patch.object( + mirror_receiver.os, "killpg", side_effect=require_waitable_before_kill + ): + if status: + with self.assertRaisesRegex(mirror_receiver.ReceiverError, "status 7"): + mirror_receiver.run_rrsync(stage, "accepted-test-command") + else: + mirror_receiver.run_rrsync(stage, "accepted-test-command") + self.assertEqual(len(killed), 1) + time.sleep(0.5) + self.assertFalse(marker.exists(), "a descendant wrote after group cleanup") + with self.assertRaises(ChildProcessError): + os.waitid(os.P_PID, killed[0], os.WEXITED | os.WNOHANG | os.WNOWAIT) + + def test_cleanup_never_signals_a_reaped_or_unowned_group(self) -> None: + process = mirror_receiver.subprocess.Popen(["/bin/sh", "-c", "exit 0"], start_new_session=True) + process.wait() + with mock.patch.object(mirror_receiver.os, "killpg") as kill: + mirror_receiver.kill_transfer(process) + kill.assert_not_called() + + unowned = mock.Mock(pid=process.pid, returncode=None) + with mock.patch.object(mirror_receiver.os, "killpg") as kill: + with self.assertRaisesRegex(mirror_receiver.ReceiverError, "lost ownership"): + mirror_receiver.kill_transfer(unowned) + kill.assert_not_called() + unowned.wait.assert_not_called() + def test_nonblocking_deployment_lock_serializes_staging(self) -> None: lock = self.temporary / ".deploy.lock" first = mirror_receiver.open_lock(lock, owner=os.getuid(), nonblocking=True) diff --git a/scripts/offline-sign-release.sh b/scripts/offline-sign-release.sh index d3df938..cdd4192 100755 --- a/scripts/offline-sign-release.sh +++ b/scripts/offline-sign-release.sh @@ -120,9 +120,28 @@ require_safe_new_output_path() { require_safe_directory_path "$parent" "$label parent" 1 } +file_limit_blocks() { + local max=$1 unit + # Bash uses 1024-byte `ulimit -f` blocks normally but 512-byte blocks in POSIX + # or sh mode. Assuming 1024 halved the effective cap wherever the unit is 512, + # rejecting files that are within the limit. Probe the inherited kernel value in + # a command-substitution child, exactly as install.sh does, so the temporary + # soft limit cannot escape into the caller. + unit=$( + ulimit -f 1 || exit 1 + awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } + END { if (!found) exit 1 }' /proc/self/limits + ) || { echo "could not determine the shell file-size limit unit" >&2; return 1; } + case "$unit" in + 512 | 1024) ;; + *) echo "unsupported shell file-size limit unit: $unit" >&2; return 1 ;; + esac + printf '%s\n' "$(( (max + unit - 1) / unit ))" +} + bounded_copy() { local source=$1 destination=$2 max=$3 blocks size - blocks=$(( (max + 1023) / 1024 )) + blocks="$(file_limit_blocks "$max")" || return 1 if ! ( ulimit -f "$blocks" || exit 1; local_with_timeout \ cp --reflink=never --sparse=never -- "$source" "$destination" ); then echo "file exceeds its bounded-copy limit or could not be copied: $source" >&2 diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh index 1cf50aa..3c48c03 100755 --- a/scripts/prepare-release.sh +++ b/scripts/prepare-release.sh @@ -32,7 +32,7 @@ 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 GPG_TTY GNUPGHOME unset GOROOT GOEXPERIMENT GOFIPS140 GO111MODULE GOCACHE GOMODCACHE GOPATH GOTMPDIR unset GOPROXY GOSUMDB GONOSUMDB GOPRIVATE GONOPROXY GOINSECURE GOVCS GOAUTH GOTELEMETRY hash -r @@ -127,9 +127,28 @@ require_safe_new_output_path() { require_safe_directory_path "$parent" "$label parent" 1 } +file_limit_blocks() { + local max=$1 unit + # Bash uses 1024-byte `ulimit -f` blocks normally but 512-byte blocks in POSIX + # or sh mode. Assuming 1024 halved the effective cap wherever the unit is 512, + # rejecting files that are within the limit. Probe the inherited kernel value in + # a command-substitution child, exactly as install.sh does, so the temporary + # soft limit cannot escape into the caller. + unit=$( + ulimit -f 1 || exit 1 + awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } + END { if (!found) exit 1 }' /proc/self/limits + ) || { echo "could not determine the shell file-size limit unit" >&2; return 1; } + case "$unit" in + 512 | 1024) ;; + *) echo "unsupported shell file-size limit unit: $unit" >&2; return 1 ;; + esac + printf '%s\n' "$(( (max + unit - 1) / unit ))" +} + bounded_copy() { local source=$1 destination=$2 max=$3 blocks size - blocks=$(( (max + 1023) / 1024 )) + blocks="$(file_limit_blocks "$max")" || return 1 if ! ( ulimit -f "$blocks" || exit 1; local_with_timeout \ cp --reflink=never --sparse=never -- "$source" "$destination" ); then echo "file exceeds its bounded-copy limit or could not be copied: $source" >&2 @@ -345,7 +364,7 @@ download_draft_asset() { || { 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 )) + blocks="$(file_limit_blocks "$max")" || return 1 ( ulimit -f "$blocks" || exit 1; 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")" @@ -370,7 +389,7 @@ 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 )) +source_blocks="$(file_limit_blocks "$MAX_SOURCE_ARCHIVE_BYTES")" || exit 1 ( ulimit -f "$source_blocks" || exit 1; 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" ]] \ diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index d8fe1f2..f0e6f85 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -32,7 +32,7 @@ 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 GPG_TTY GNUPGHOME hash -r MAX_BINARY_BYTES=67108864 @@ -69,7 +69,7 @@ export GH_TOKEN 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 +for command_name in awk cat chmod 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 @@ -168,9 +168,28 @@ require_safe_source_repo() { done } +file_limit_blocks() { + local max=$1 unit + # Bash uses 1024-byte `ulimit -f` blocks normally but 512-byte blocks in POSIX + # or sh mode. Assuming 1024 halved the effective cap wherever the unit is 512, + # rejecting files that are within the limit. Probe the inherited kernel value in + # a command-substitution child, exactly as install.sh does, so the temporary + # soft limit cannot escape into the caller. + unit=$( + ulimit -f 1 || exit 1 + awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } + END { if (!found) exit 1 }' /proc/self/limits + ) || { echo "could not determine the shell file-size limit unit" >&2; return 1; } + case "$unit" in + 512 | 1024) ;; + *) echo "unsupported shell file-size limit unit: $unit" >&2; return 1 ;; + esac + printf '%s\n' "$(( (max + unit - 1) / unit ))" +} + bounded_copy() { local source=$1 destination=$2 max=$3 blocks size - blocks=$(( (max + 1023) / 1024 )) + blocks="$(file_limit_blocks "$max")" || return 1 if ! ( ulimit -f "$blocks" || exit 1; local_with_timeout \ cp --reflink=never --sparse=never -- "$source" "$destination" ); then echo "file exceeds its bounded-copy limit or could not be copied: $source" >&2 @@ -731,7 +750,7 @@ download_draft_asset() { || { 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 )) + blocks="$(file_limit_blocks "$max")" || return 1 ( ulimit -f "$blocks" || exit 1; 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")" @@ -916,9 +935,8 @@ 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 )) + # Probe the shell unit; exact byte checks also enforce the final partial block. + blocks="$(file_limit_blocks "$max")" || return 1 for attempt in 1 2 3 4 5 6; do rm -f -- "$out" fetch_url="$url" diff --git a/scripts/release-safety-primitives.inc b/scripts/release-safety-primitives.inc index f83901b..1c3dabd 100644 --- a/scripts/release-safety-primitives.inc +++ b/scripts/release-safety-primitives.inc @@ -116,9 +116,28 @@ require_safe_new_output_path() { # END COMPONENT new_output # BEGIN COMPONENT bounded_copy +file_limit_blocks() { + local max=$1 unit + # Bash uses 1024-byte `ulimit -f` blocks normally but 512-byte blocks in POSIX + # or sh mode. Assuming 1024 halved the effective cap wherever the unit is 512, + # rejecting files that are within the limit. Probe the inherited kernel value in + # a command-substitution child, exactly as install.sh does, so the temporary + # soft limit cannot escape into the caller. + unit=$( + ulimit -f 1 || exit 1 + awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } + END { if (!found) exit 1 }' /proc/self/limits + ) || { echo "could not determine the shell file-size limit unit" >&2; return 1; } + case "$unit" in + 512 | 1024) ;; + *) echo "unsupported shell file-size limit unit: $unit" >&2; return 1 ;; + esac + printf '%s\n' "$(( (max + unit - 1) / unit ))" +} + bounded_copy() { local source=$1 destination=$2 max=$3 blocks size - blocks=$(( (max + 1023) / 1024 )) + blocks="$(file_limit_blocks "$max")" || return 1 if ! ( ulimit -f "$blocks" || exit 1; local_with_timeout \ cp --reflink=never --sparse=never -- "$source" "$destination" ); then echo "file exceeds its bounded-copy limit or could not be copied: $source" >&2 diff --git a/scripts/sync-release-safety-primitives.py b/scripts/sync-release-safety-primitives.py index 0663b66..8c041b4 100644 --- a/scripts/sync-release-safety-primitives.py +++ b/scripts/sync-release-safety-primitives.py @@ -78,9 +78,15 @@ def replace_block(path: Path, expected: str) -> tuple[bool, str]: def atomic_write(path: Path, content: str) -> None: mode = path.stat().st_mode & 0o777 fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + # Ownership of fd moves to fdopen below; until then this frame must close it. + owned = False try: os.fchmod(fd, mode) + # From here fdopen owns fd and closes it; the handler below must not close + # that number again, because anything opened afterwards (directory_fd, just + # below) can inherit it and would be closed out from under its owner. with os.fdopen(fd, "w", encoding="ascii", newline="") as handle: + owned = True handle.write(content) handle.flush() os.fsync(handle.fileno()) @@ -91,10 +97,11 @@ def atomic_write(path: Path, content: str) -> None: finally: os.close(directory_fd) except BaseException: - try: - os.close(fd) - except OSError: - pass + if not owned: + try: + os.close(fd) + except OSError: + pass try: os.unlink(temporary) except FileNotFoundError: