diff --git a/CHANGELOG.md b/CHANGELOG.md index afab9c3..8190d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to this project are documented here. +## v2.9.3 - 2026-08-01 + +- Replace the normal invite's foreground 65-second deferred-job drain with a + durable monotonic UID/GID allocator. The allocator advances a root-owned + high-water file before `useradd`, pins the account and its private group to the + same explicit ID, and never returns a burned ID to this tool after rollback or + deletion. Default generated usernames use substantially more entropy and take + two immediate process/job cleanup passes before activation. An explicit + `--user` can reuse historical name-keyed cron state, so that exceptional path + retains the synchronous drain and says why. Upgrading a pre-v5 registry starts + one 65-second isolation window for identities that older releases may already + have retired; a generated-name invite attempted inside that one-time window + still drains synchronously, while later generated-name invites do not wait. +- Make ordinary revoke return promptly without releasing a possibly reusable + identity too early. It immediately strips sudo and sshd grants, disables login, + terminates UID processes, and clears cron/at work, then keeps the passwd name, + UID, and GID in a durable systemd-backed quarantine for at least one daemon + polling cycle. A separate persistent timer repeats the checks and finishes + Home/mail/account deletion after the deadline, including after reboot. Hosts + without systemd retain the fail-closed synchronous drain. Before handing work + to that timer, revoke installs or verifies a sufficiently new stable command; + if it cannot, deletion stays synchronous. Uninstall likewise finalizes + synchronously before removing the command required by the timer. +- Upgrade the registry to v5 with the monotonic-identity and quarantine state, + separate expiry and quarantine unit namespaces, exact `doctor` validation, + menu recovery for proved pending generations, and status output for + quarantined or externally removed accounts. A v5 registry whose high-water + file is missing fails closed rather than reconstructing a value that could + reuse an already retired identity. + ## v2.9.2 - 2026-08-01 - Validate conventional mail-spool roots before `useradd`, then reopen and diff --git a/README.en.md b/README.en.md index 8d5942e..cb3323d 100644 --- a/README.en.md +++ b/README.en.md @@ -80,7 +80,7 @@ The real private key is shown only once. Never put an invite bundle in a group c /usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 ``` -By default, automatic revocation is scheduled after 24 hours. When the complete account identity can still be checked, a successful revoke removes the personal crontab, UID-matched `at`/`batch` jobs, account, home directory, SSH key, sudo grant, and any tool-created sshd exception. If the account disappeared outside the tool without a deletion-recovery witness, revoke cleans only the registry, name-scoped grants, and tasks it can still identify safely; it does not guess at Home or mail cleanup. When an already-authorized teardown persisted that witness first, recovery permits only an owner-checked conventional single-file mail-spool sweep and never recursively removes the absent account's old Home path. If a safety check or cleanup fails, the command returns nonzero, retains the account when it still exists and the registry witness, and attempts to disable any surviving account for a systemd retry or manual recovery. Revoke access immediately when work is finished even when automatic revocation is enabled. +By default, automatic revocation is scheduled after 24 hours. On a normal systemd host, a manual or expiry revoke immediately removes sudo/sshd grants, disables login, terminates processes, and clears personal jobs before returning. The passwd name and UID/GID remain quarantined for at least one scheduler polling cycle; a persistent timer repeats the checks and finishes account, Home, and mail deletion in the background, so the terminal does not wait 65 seconds for safety. Without systemd, the same safety drain and deletion remain synchronous. If the account disappeared outside the tool without a deletion-recovery witness, revoke cleans only the registry, name-scoped grants, and tasks it can still identify safely; it does not guess at Home or mail cleanup. When an already-authorized teardown persisted that witness first, recovery permits only an owner-checked conventional single-file mail-spool sweep and never recursively removes the absent account's old Home path. If a safety check or cleanup fails, the command returns nonzero, retains the account when it still exists and the registry witness, and attempts to disable any surviving account for a systemd retry or manual recovery. Revoke access immediately when work is finished even when automatic revocation is enabled. ## Everyday commands diff --git a/README.md b/README.md index 56a8c38..a2a4865 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ ssh -i ./xxvcc-a1b2c3d4e5.key -p 22 xxvcc-a1b2c3d4e5@203.0.113.10 /usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 ``` -默认会安排在 24 小时后自动撤销。对于仍可用完整身份核对的账号,撤销成功会删除个人 crontab、UID 匹配的 `at`/`batch` 任务、账号、家目录、SSH key、sudo 授权和本工具创建的 sshd 例外;若账号已在程序外消失且没有删除恢复见证,只清理可安全识别的登记、按用户名授权和任务,不会猜测删除 Home/mail。已经授权的撤销若提前持久化了恢复见证,则只允许继续核对 UID 属主的传统单文件 mail spool 清扫,绝不会按缺失账号的旧路径恢复性删除 Home。若安全检查或清理失败,程序会返回非零,保留账号(若仍存在)和登记,并尽力禁用仍存在的账号,供 systemd 重试或人工处理。即使启用了自动撤销,用完后也应立即手动撤销。 +默认会安排在 24 小时后自动撤销。正常 systemd 主机上的手动或到期撤销会立即删除 sudo/sshd 授权、禁用登录、终止进程并清理个人任务,然后在前台返回;passwd 中的用户名和 UID/GID 会继续隔离至少一个任务轮询周期,由持久化 timer 在后台复查并完成账号、Home 和 mail 删除,避免为了安全而让终端等待 65 秒。systemd 不可用时仍同步等待并完成同样的安全清场。若账号已在程序外消失且没有删除恢复见证,只清理可安全识别的登记、按用户名授权和任务,不会猜测删除 Home/mail。已经授权的撤销若提前持久化了恢复见证,则只允许继续核对 UID 属主的传统单文件 mail spool 清扫,绝不会按缺失账号的旧路径恢复性删除 Home。若安全检查或清理失败,程序会返回非零,保留账号(若仍存在)和登记,并尽力禁用仍存在的账号,供 systemd 重试或人工处理。即使启用了自动撤销,用完后也应立即手动撤销。 ## 常用命令 diff --git a/docs/operator-guide.en.md b/docs/operator-guide.en.md index a9d582e..6cd1e83 100644 --- a/docs/operator-guide.en.md +++ b/docs/operator-guide.en.md @@ -65,7 +65,7 @@ Without `--host`, interactive mode first queries fixed-address cloud metadata en With automatic removal disabled, only `revoke` deletes the account and `--hours` is ignored. -The account-database entry is past-date expired and password-locked from `useradd`, and `/etc/skel` is not copied. After the tool verifies the complete identity and finds no residual process for the new UID, the Home remains absent while it clears the same-name crontab, `at`/`batch` jobs for the reused UID, and any due job a daemon may already have read. When a cron/at command or running daemon is detected, this credential-less, still-expired pending account remains allocated for a 65-second drain; an unreliable process inventory also takes the conservative wait. An empty mode-`0700` Home is created only after cleanup and repeated checks pass, and the account is activated only after its password/key, grants, registry state, and automatic revoke task are complete. The requested lifetime starts after this cleanup, so the wait does not shorten the access requested by `--hours`. +The account-database entry is past-date expired and password-locked from `useradd`, and `/etc/skel` is not copied. Before that helper runs, the tool permanently burns a UID/GID in a root-only high-water file, then pins the account and private group to that same number. A default generated username has a long random suffix, so normal menu creation does not reuse a numeric identity previously released by this tool. While Home remains absent, two process-termination passes clear the same-name crontab and target-UID `at`/`batch` jobs without a 65-second foreground wait. An explicit `--user` may still reuse a historical name and a daemon-cached name-keyed job, so that exceptional path explains why it waits one polling cycle synchronously. An empty mode-`0700` Home is created only after cleanup, complete identity checks, and residual-process checks pass, and the account is activated only after its password/key, grants, registry state, and automatic revoke task are complete. The requested lifetime starts after cleanup, so an explicit-name safety wait does not shorten the access requested by `--hours`. ## Deliver the invite @@ -101,7 +101,7 @@ Unattended mode never installs dependencies or changes sshd implicitly; pass `-- /usr/bin/sudo /usr/local/sbin/linux-temp-admin status --user xxvcc-a1b2c3d4e5 ``` -Status reports account identity, UID, expiry, auto-delete task, and registry anomalies. `doctor` also reports orphaned sudoers files and sshd exceptions, plus orphaned, missing, or invalid registered revoke tasks; with no account awaiting a schedule, it does not independently prove that the systemd or `at` backend is available. +Status reports account identity, UID, expiry, auto-delete task, identity-quarantine deadline, and registry anomalies. `doctor` also reports orphaned sudoers files and sshd exceptions, plus orphaned, missing, or invalid expiry-revoke and quarantine-finalizer tasks; with no account awaiting a schedule, it does not independently prove that the systemd or `at` backend is available. ## Revoke an account @@ -113,7 +113,7 @@ Status reports account identity, UID, expiry, auto-delete task, and registry ano /usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 ``` -When the complete identity can still be checked, revoke first disables login; removes and verifies the personal crontab and target-UID `at`/`batch` jobs; waits for a 65-second daemon drain; repeats job/process cleanup; and then removes the account, deterministic `/home/` directory, any UID-matched conventional mail spool, public key, sudoers grant, account-scoped sshd exception, and automatic task. If the account disappeared outside the tool, it cleans only the registry, name-scoped grants, and tasks that remain safely identifiable, plus the narrow mail-spool cleanup authorized by an existing deletion-recovery witness. Recursive Home cleanup proceeds only for a real directory owned by the registered account's UID/GID with no mount boundary underneath. Home cleanup uses directory descriptors and rejects a symlink at the Home root; an internal symlink is unlinked without following its target. Traversal checks cooperative budgets of 100,000 entries, 128 levels, and two minutes between filesystem calls, so the deadline cannot interrupt one blocked filesystem call. Cron/at and process results are repeated snapshots, not an atomic freeze. If a safety condition, resource limit, job/process inventory, or name-scoped grant cannot be confirmed, revoke attempts to disable the account, retains any surviving account and the registry witness, and returns nonzero so username reuse cannot inherit old data, deferred work, or privilege. +When the complete identity can still be checked, normal systemd revoke first removes and confirms sudoers/sshd grants, disables login, and performs two personal-crontab, target-UID `at`/`batch`, and process cleanup passes. It then establishes persistent identity quarantine and returns immediately. The list shows “access revoked; quarantined”; the passwd entry holds the name and UID/GID for at least 65 seconds (less than 125 seconds after minute rounding) while remaining expired, password-locked, and without a tool-managed sudo/sshd entry. At the deadline, a background timer repeats the checks and then removes the account, deterministic `/home/` directory, UID-matched conventional mail spool, public key, and tasks. A persistent timer catches up after the host was down across the deadline. Without systemd, revoke still waits 65 seconds in the foreground and completes final deletion synchronously. Uninstall also finalizes synchronously so it cannot remove the command still needed by a background task. If the account disappeared outside the tool, revoke cleans only the registry, name-scoped grants, and tasks that remain safely identifiable, plus the narrow mail-spool cleanup authorized by an existing deletion-recovery witness. Recursive Home cleanup proceeds only for a real directory owned by the registered account's UID/GID with no mount boundary underneath. Home cleanup uses directory descriptors and rejects a symlink at the Home root; an internal symlink is unlinked without following its target. Traversal checks cooperative budgets of 100,000 entries, 128 levels, and two minutes between filesystem calls, so the deadline cannot interrupt one blocked filesystem call. Cron/at and process results are repeated snapshots, not an atomic freeze. If a safety condition, resource limit, job/process inventory, or name-scoped grant cannot be confirmed, revoke attempts to disable the account, retains any surviving account and the registry witness, and returns nonzero so username reuse cannot inherit old data, deferred work, or privilege. Mail-specific cleanup handles only a traditional single-file mbox at `/var/mail/` or `/var/spool/mail/`. An existing mail spool must be root-owned and have no setuid bit; a world-writable spool must have sticky protection. Thus `root:mail 3777` and Arch Linux's `root:root 1777` work, while mode `0777`/`2777` without sticky and a non-root owner such as `mail:mail` fail closed. The target mailbox must also be a non-symlink regular file owned by the captured UID. `invite` checks the spool before `useradd` and again after the UID is known. A preflight failure leaves no account; a post-helper failure attempts rollback and retains an expired, locked, credential-less account plus its registry witness when cleanup cannot be confirmed. This specialized path neither searches nor traverses Maildir and never touches aaPanel's `/www/vmail`; a Maildir inside a tool-managed Home is still removed with the whole Home under the normal complete-revocation rules. @@ -123,7 +123,7 @@ For compatibility with old automatic tasks, a `revoke --yes` command without UID An account reported by `doctor` as `legacy-unverified` carries an old fixed identity marker, so same-name/same-UID reuse cannot be excluded. After manual inspection, it can be recovered only by running `revoke --user --force` in an interactive terminal and typing the complete username. The historical timer's `--yes --force --confirm-force` arguments and every other non-interactive invocation are denied deletion authority for this account class. `doctor` reports any surviving old task as orphaned, and `cleanup-expired --compact` cancels that task while retaining the live account and registry row for manual handling. -After upgrading from v2.9.1 or another older release, manually verify any live pending creation row shown by `status` or `doctor` really came from a failed invite. Recovery requires `revoke --user --force` directly in an interactive terminal plus the full username. The tool also checks the random pending generation, GECOS, recorded UID (zero or the current UID), managed Home, non-root UID/GID, and nonempty shell. Only then does it convert the row to a UID-only deletion-recovery witness before cleanup. `--yes`, automatic revoke, uninstall bulk removal, piped input, and every identity mismatch refuse deletion. +After upgrading from v2.9.1 or another older release, manually verify any live pending creation row shown by `status` or `doctor` really came from a failed invite. Selecting that row in the menu automatically enters the same recovery gate as `revoke --user --force`, and an interactive terminal must still type the full username. The tool also checks the random pending generation, GECOS, recorded UID (zero or the current UID), managed Home, non-root UID/GID, and nonempty shell. With systemd, a proved pending identity is immediately stripped of access and retains its exact generation in background quarantine. Only the synchronous fallback weakens it to a UID-only deletion-recovery witness. `--yes`, automatic revoke, uninstall bulk removal, piped input, and every identity mismatch refuse the initial recovery authorization. After deletion authorization, identity checks, and the pre-artifact job/process quiescence pass succeed, the tool persists a deletion-recovery witness before controlled mail/Home cleanup and `userdel`; after Home cleanup it checks jobs, processes, and the complete identity again immediately before `userdel`. If account deletion, the post-deletion mail-spool sweep, or task cleanup is interrupted, `status` and `doctor` show the recovery state, a same-name `invite` refuses to overwrite the witness, and `cleanup-expired --compact` does not discard the witness. When the account is absent or still exactly matches the recorded generation, run `revoke --user ` to resume. For an absent account, every narrow mail sweep confirms before and after that neither the local passwd database nor NSS contains the name. An identifiable automatic task is retained in either state, but only a systemd job retries automatically under its restart policy; `at` and legacy one-shot jobs require a manual retry. Legacy, unregistered, and pending-rollback paths retain only a UID witness. If that account is still live, inspect it and run `revoke --user --force` in an interactive terminal, then type the complete username; every non-interactive invocation is refused. The old automatic task for such a live account is treated as orphaned and cancelled, while the registry witness remains for manual recovery. @@ -161,7 +161,7 @@ The tool creates a random password shown once only after the effective sshd conf The default lifetime is 24 hours with automatic revocation scheduled. A persistent systemd timer is preferred; an existing `at`/`atd` service is used only when systemd is unavailable or its failed scheduling attempt was safely rolled back. `at` is never installed automatically. If neither backend can schedule revocation successfully, the invite enters fail-closed rollback. If account, grant, or task cleanup cannot be confirmed, the command returns nonzero and, when necessary, retains a disabled account and registry witness for manual recovery instead of reporting an incomplete cleanup as success. -The lifetime is computed once after deferred-job cleanup for the new UID and the 65-second daemon drain complete, then rounded upward to a whole minute: the safety wait does not shorten the requested duration and rounding adds less than one minute. Display, systemd, and `at` share that absolute target; `at` uses an absolute UTC minute so daylight-saving changes cannot make it run early. `chage -E` provides only a possibly later, day-granularity lock fallback. Scheduler load, host downtime, and retries can delay actual removal; revoke access manually as soon as it is no longer needed. The task binds the original UID, random generation token, and registry record, refusing to delete an account that has been removed and recreated or no longer matches. +A default random-name invite computes its lifetime once after immediate job cleanup and no longer has a 65-second foreground wait. An explicit `--user` computes it after the name-reuse defense, so that safety wait does not shorten the requested duration. The target is rounded upward to a whole minute, adding less than one minute. Display, systemd, and `at` share that absolute target; `at` uses an absolute UTC minute so daylight-saving changes cannot make it run early. `chage -E` provides only a possibly later, day-granularity lock fallback. Scheduler load, host downtime, and retries can delay actual removal; revoke access manually as soon as it is no longer needed. The task binds the original UID, random generation token, and registry record, refusing to delete an account that has been removed and recreated or no longer matches. ## Uninstall @@ -185,12 +185,15 @@ The audit log remains at `/var/log/linux-temp-admin/audit.log` by default. The l ```text /usr/local/sbin/linux-temp-admin /var/lib/linux-temp-admin/v2/registry.tsv +/var/lib/linux-temp-admin/v2/identity-sequence /var/lib/linux-temp-admin/v2/prefs /var/log/linux-temp-admin/audit.log /run/linux-temp-admin.lock /run/linux-temp-admin.lock.uninstalled /etc/systemd/system/linux-temp-admin-v2-revoke-USER.service /etc/systemd/system/linux-temp-admin-v2-revoke-USER.timer +/etc/systemd/system/linux-temp-admin-v2-quarantine-USER.service +/etc/systemd/system/linux-temp-admin-v2-quarantine-USER.timer /etc/sudoers.d/linux-temp-admin-USER /etc/ssh/sshd_config.d/10-linux-temp-admin-USER.conf /home/USER/.ssh/authorized_keys diff --git a/docs/operator-guide.md b/docs/operator-guide.md index bc78063..78c7a39 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -65,7 +65,7 @@ 关闭自动删除后,账号只能通过 `revoke` 手动删除,`--hours` 会被忽略。 -账号数据库项自 `useradd` 起就使用过去日期过期并锁定密码,不会复制 `/etc/skel`。程序核对完整身份并确认新 UID 没有残留进程后,仍会让 Home 保持不存在,再清理同名 crontab、复用 UID 的 `at`/`batch` 任务和 daemon 可能已读取的到期任务。检测到 cron/at 命令或仍运行的 daemon 时,这个无凭据、保持过期的 pending 账号会继续占用身份并等待 65 秒;进程清单无法可靠读取时也会保守等待。只有清场和复查通过后才创建权限 `0700` 的空 Home;密码/公钥、授权、登记和自动撤销任务全部完成后账号才被激活。有效期从清场完成后开始,因此等待不会缩短 `--hours` 请求的访问时长。 +账号数据库项自 `useradd` 起就使用过去日期过期并锁定密码,不会复制 `/etc/skel`。程序先在 root-only 高水位文件中永久烧掉一个 UID/GID,再用 `useradd` 把账号和私有组固定到同一号码;默认随机用户名具有长随机后缀,因此正常菜单创建不会复用本工具曾释放的数值身份。Home 保持不存在时会执行两轮进程终止与同名 crontab、目标 UID `at`/`batch` 任务清理,不再前台等待 65 秒。显式 `--user` 仍可能复用历史名称和 daemon 已缓存的同名任务,所以该特殊路径会说明原因并同步等待一个轮询周期。只有清场、完整身份和无残留进程复查通过后才创建权限 `0700` 的空 Home;密码/公钥、授权、登记和自动撤销任务全部完成后账号才被激活。有效期从清场完成后开始,因此显式用户名的安全等待不会缩短 `--hours` 请求的访问时长。 ## 交付邀请 @@ -101,7 +101,7 @@ ssh -i ./USER.key -p PORT USER@HOST /usr/bin/sudo /usr/local/sbin/linux-temp-admin status --user xxvcc-a1b2c3d4e5 ``` -状态会显示账号身份、UID、有效期、自动删除任务和异常登记。`doctor` 还会报告孤儿 sudoers、sshd 例外,以及孤儿、缺失或无效的已登记撤销任务;它不会在没有待调度账号时单独证明 systemd 或 `at` 后端可用。 +状态会显示账号身份、UID、有效期、自动删除任务、身份隔离截止时间和异常登记。`doctor` 还会报告孤儿 sudoers、sshd 例外,以及孤儿、缺失或无效的到期撤销/隔离终删任务;它不会在没有待调度账号时单独证明 systemd 或 `at` 后端可用。 ## 撤销账号 @@ -113,7 +113,7 @@ ssh -i ./USER.key -p PORT USER@HOST /usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 ``` -对于仍可用完整身份核对的账号,撤销会先禁用登录,删除并复核个人 crontab 和目标 UID 的 `at`/`batch` 任务,等待 65 秒的 daemon drain 后重复任务/进程清理,再删除账号、确定的 `/home/<用户名>` 家目录、UID 匹配的常规 mail spool、公钥、sudoers、账号专属 sshd 例外和自动删除任务;账号若已在程序外消失,只清理仍可安全识别的登记、按用户名授权和任务,以及已有删除恢复见证授权的窄范围 mail spool。只有 Home 是真实目录、属于登记账号的 UID/GID 且不包含挂载边界时才会递归清理。Home 清理使用目录描述符,不接受链接形式的 Home 根;内部链接只删除链接本身而不跟随目标。遍历会在文件系统调用之间检查 100,000 个条目、128 层和两分钟的协作式预算,因此单次阻塞的文件系统调用不能被该期限中断。cron/at 和进程结果是重复快照,不是原子冻结。任一安全条件、资源上限、任务/进程盘点或按用户名授权无法确认时,都会尝试禁用账号,保留仍存在的账号和登记并返回非零,避免用户名复用后继承旧数据、任务或权限。 +对于仍可用完整身份核对的账号,正常 systemd 撤销会先删除并确认 sudoers/sshd 授权、禁用登录、执行两轮个人 crontab、目标 UID `at`/`batch` 和进程清理,再建立持久化身份隔离并立即返回。此后列表显示“已撤权,隔离待删”;passwd 项继续占用用户名和 UID/GID 至少 65 秒(向上取整到整分钟后不到 125 秒),期间已过期、密码锁定且没有本工具管理的 sudo/sshd 入口。后台 timer 到期后再次复查,再删除账号、确定的 `/home/<用户名>` 家目录、UID 匹配的常规 mail spool、公钥和任务。主机关机跨过截止时间时,持久化 timer 会在启动后补跑。systemd 不可用时,撤销仍在前台同步等待 65 秒并完成终删;卸载也必须同步终删,避免先移除后台任务需要的命令。账号若已在程序外消失,只清理仍可安全识别的登记、按用户名授权和任务,以及已有删除恢复见证授权的窄范围 mail spool。只有 Home 是真实目录、属于登记账号的 UID/GID 且不包含挂载边界时才会递归清理。Home 清理使用目录描述符,不接受链接形式的 Home 根;内部链接只删除链接本身而不跟随目标。遍历会在文件系统调用之间检查 100,000 个条目、128 层和两分钟的协作式预算,因此单次阻塞的文件系统调用不能被该期限中断。cron/at 和进程结果是重复快照,不是原子冻结。任一安全条件、资源上限、任务/进程盘点或按用户名授权无法确认时,都会尝试禁用账号,保留仍存在的账号和登记并返回非零,避免用户名复用后继承旧数据、任务或权限。 邮件专用清理只处理 `/var/mail/<用户名>` 或 `/var/spool/mail/<用户名>` 的传统单文件 mbox。存在的系统邮箱目录必须由 root 所有、不得带 setuid;world-writable 时必须带 sticky bit,所以 `root:mail 3777` 和 Arch Linux 的 `root:root 1777` 可用,而无 sticky 的 `0777`/`2777`、`mail:mail` 等非 root 属主都会失败关闭。目标 mailbox 还必须是 UID 匹配的非链接普通文件。`invite` 会在 `useradd` 前检查系统邮箱目录,并在 UID 已确定后重新检查;前置失败不会留下账号,helper 后的失败则会尝试回滚,无法确认时保留过期锁定、无凭据的账号和登记供恢复。专用逻辑不搜索或遍历 Maildir,也不触碰宝塔 `/www/vmail`;若 Maildir 位于本工具管理的 Home 内,完整撤销仍会按 Home 规则删除整个 Home。 @@ -123,7 +123,7 @@ ssh -i ./USER.key -p PORT USER@HOST `doctor` 报告为 `legacy-unverified` 的账号来自旧版固定身份标记,无法排除同名/同 UID 重用。人工核查后,只能在交互终端运行 `revoke --user <名> --force` 并输入完整用户名确认。旧版 timer 使用的 `--yes --force --confirm-force` 以及其他非交互调用都不会获得这类账号的删除授权;`doctor` 会把仍存在的旧任务报告为孤儿任务,`cleanup-expired --compact` 会取消任务但保留活账号及登记供人工处理。 -从 v2.9.1 等旧版升级后,若 `status`/`doctor` 显示仍活着的 pending 创建登记,应先人工确认它确实来自失败的邀请。恢复只能在直接交互终端运行 `revoke --user <名> --force` 并输入完整用户名;程序还会核对随机 pending 世代、GECOS、登记 UID(0 或当前 UID)、受管 Home、非 root UID/GID 和非空 shell。核对通过后会先把登记转换为 UID-only 删除恢复见证再开始清理。`--yes`、自动撤销、卸载批量、管道输入或任何身份不匹配都不会删除该账号。 +从 v2.9.1 等旧版升级后,若 `status`/`doctor` 显示仍活着的 pending 创建登记,应先人工确认它确实来自失败的邀请。菜单选择该行会自动进入与直接运行 `revoke --user <名> --force` 相同的恢复门,并仍要求交互终端输入完整用户名;程序还会核对随机 pending 世代、GECOS、登记 UID(0 或当前 UID)、受管 Home、非 root UID/GID 和非空 shell。systemd 可用时,验证通过的 pending 身份也会立即撤权并保留精确世代进入后台隔离;同步回退才会把它转换为 UID-only 删除恢复见证。`--yes`、自动撤销、卸载批量、管道输入或任何身份不匹配都拒绝首次授权这类恢复。 删除授权、身份及删除前的任务/进程静默检查通过后,程序会在受控的 mail/Home 清理和 `userdel` 前持久化删除恢复见证;Home 清理后、`userdel` 前仍会再次复核任务、进程和完整身份。若账号删除、删除后的 mail spool 复扫或任务清理中断,`status` 和 `doctor` 会显示删除恢复状态,同名 `invite` 会拒绝覆盖该见证,`cleanup-expired --compact` 也不会删除见证。账号已经不存在或仍精确匹配登记世代时,运行 `revoke --user <名>` 可继续恢复;账号缺失时,每轮窄范围邮件清扫前后都会复核本地 passwd 与 NSS 均无同名身份。上述两种状态下仍保留可识别的自动任务,但只有 systemd 任务会按重启策略自动重试,`at` 和旧的一次性任务需要人工重试。旧版、未登记或 pending 回滚只保留 UID 见证,若账号仍存在,必须人工核查后在交互终端运行 `revoke --user <名> --force` 并输入完整用户名,任何非交互调用都会被拒绝;这类活账号的旧自动任务会被当作孤儿任务取消,登记见证则保留供人工恢复。 @@ -161,7 +161,7 @@ ssh -i ./USER.key -p PORT USER@HOST 默认有效期为 24 小时并安排自动撤销。优先使用持久化 systemd timer;systemd 不可用,或排程失败且相关 timer 已安全回滚时,才使用已有的 `at`/`atd`,`at` 不会被自动安装。任一后端都无法成功创建任务时,邀请会进入失败关闭回滚;若账号、授权或任务清理无法确认,工具会返回非零,并在必要时保留已禁用账号和登记见证供人工恢复,而不会把不完整清理报告为成功。 -有效期在新 UID 的延迟任务清场和 65 秒 daemon drain 完成后只计算一次,并向上取整到整分钟:安全等待不会缩短请求时长,取整最多多不到一分钟。显示、systemd 和 `at` 共用这一绝对目标,其中 `at` 按 UTC 绝对分钟排程,不会因夏令时变化提前执行。`chage -E` 仅提供可能更晚的按天粒度兜底锁定。调度器忙碌、主机停机和重试都可能让实际删除延后;不再需要时应立即手动撤销。撤销任务绑定创建时的 UID、随机世代标识和登记记录,账号被删除重建或身份不匹配时会拒绝误删。 +默认随机用户名在即时任务清场后只计算一次有效期,不再经历 65 秒前台等待;显式 `--user` 则在名称复用防护完成后计算,因此安全等待不会缩短请求时长。目标向上取整到整分钟,最多多不到一分钟。显示、systemd 和 `at` 共用这一绝对目标,其中 `at` 按 UTC 绝对分钟排程,不会因夏令时变化提前执行。`chage -E` 仅提供可能更晚的按天粒度兜底锁定。调度器忙碌、主机停机和重试都可能让实际删除延后;不再需要时应立即手动撤销。撤销任务绑定创建时的 UID、随机世代标识和登记记录,账号被删除重建或身份不匹配时会拒绝误删。 ## 卸载 @@ -185,12 +185,15 @@ ssh -i ./USER.key -p PORT USER@HOST ```text /usr/local/sbin/linux-temp-admin /var/lib/linux-temp-admin/v2/registry.tsv +/var/lib/linux-temp-admin/v2/identity-sequence /var/lib/linux-temp-admin/v2/prefs /var/log/linux-temp-admin/audit.log /run/linux-temp-admin.lock /run/linux-temp-admin.lock.uninstalled /etc/systemd/system/linux-temp-admin-v2-revoke-USER.service /etc/systemd/system/linux-temp-admin-v2-revoke-USER.timer +/etc/systemd/system/linux-temp-admin-v2-quarantine-USER.service +/etc/systemd/system/linux-temp-admin-v2-quarantine-USER.timer /etc/sudoers.d/linux-temp-admin-USER /etc/ssh/sshd_config.d/10-linux-temp-admin-USER.conf /home/USER/.ssh/authorized_keys diff --git a/docs/security-model.en.md b/docs/security-model.en.md index 527931f..1b70e15 100644 --- a/docs/security-model.en.md +++ b/docs/security-model.en.md @@ -46,20 +46,22 @@ A future account has no NSS group membership before creation, so OpenSSH cannot Every new invite binds: -- the UID observed at creation; +- the matching UID/GID durably burned and explicitly selected before `useradd`; - a random 128-bit generation token; - an exact managed GECOS marker; - the corresponding registry record. Automatic revoke and ordinary `revoke` require these identity values to agree. If the account is deleted and recreated, the UID is reused, the marker changes, or the registry is corrupt, unattended deletion is refused rather than guessing that the same name is the same object. -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. After the complete passwd snapshot, non-root UID/GID, and residual-UID process scan pass, the Home remains absent throughout same-name/UID cron/at cleanup, the 65-second daemon drain, and the repeated checks. Only after another complete identity check passes 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. +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. `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 tool repeatedly rechecks the complete passwd snapshot at critical transaction stages; the GECOS identity-finalization and account-deletion paths also verify it 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. Accounts migrated from the old fixed-marker registry are shown as `legacy-unverified` and are never automatically deleted by timers, bulk cleanup, or uninstall. The historical timer's `--yes --force --confirm-force` arguments do not authorize deleting such an account. A surviving legacy task is reported as orphaned and may be cancelled by `cleanup-expired --compact` without deleting the live account or registry row. After manual inspection, an operator must run `revoke --force` in an interactive terminal and type the complete username; non-interactive deletion is always refused. -A release before v2.9.2 could retain a live pending creation row after `useradd` succeeded and later preparation failed. That row alone grants no ordinary or unattended deletion authority. Only after manual inspection may an operator make it a recovery candidate by running `revoke --user --force` directly in an interactive terminal and typing the full username. The random registry generation must exactly match the pending GECOS marker, the recorded UID must be either the not-yet-written zero or the current UID, the Home must be the deterministic managed path, both UID and GID must be non-root, and the shell must be nonempty. Before cleanup, the row is then weakened to a generation-less UID-only `DeletionStarted` witness. `--yes`, automatic tasks, uninstall bulk removal, non-TTY input, and every incomplete or mismatched state always fail closed and retain the account and row. +A release before v2.9.2 could retain a live pending creation row after `useradd` succeeded and later preparation failed. That row alone grants no ordinary or unattended deletion authority. Only after manual inspection may an operator make it a recovery candidate by selecting it in the menu or running `revoke --user --force` directly in an interactive terminal and typing the full username. The random registry generation must exactly match the pending GECOS marker, the recorded UID must be either the not-yet-written zero or the current UID, the Home must be the deterministic managed path, both UID and GID must be non-root, and the shell must be nonempty. With systemd, the exact pending generation enters persistent quarantine; only the synchronous fallback weakens it to a generation-less UID-only `DeletionStarted` witness before cleanup. `--yes`, automatic tasks, uninstall bulk removal, non-TTY input, and every incomplete or mismatched state always fail closed and retain the account and row. Root, UID 0, and reserved names are never deleted. A low-UID account is revocable as tool-created only when its current registry UID, random generation, and exact GECOS marker are fully bound; an unregistered or legacy-identity low-UID account remains protected even with `--force`. A real account without the tool's exact marker is likewise never deleted as managed. @@ -67,7 +69,9 @@ Root, UID 0, and reserved names are never deleted. A low-UID account is revocabl A personal crontab and `at`/`batch` jobs do not reliably disappear when login is disabled, current processes are killed, or plain `userdel --` runs. Before a new account receives a password, public key, or sudo grant, and before an old account releases its username/UID, the tool removes and verifies the same-name personal crontab, inventories every job through `atq` and the generated `atrun uid=` header from `at -c`, and removes jobs for the target UID. Immediately before each `atrm`, it reads the same ID again and rebinds it to either the expected UID or the tool's exact revoke command; after a removal error it also distinguishes a surviving target from a disappeared target or reused ID. The tool recognizes its own automatic revoke job only when the `atrun` header says that root owns it. It probes that owner header within a 64 KiB limit, so an oversized job already identified as non-root does not block automatic-task inventory; only a root job is retained and read in full under the larger bounded limit. The external `at` interface has no atomic compare-and-delete operation, so a very short interval remains between that fresh read and `atrm`; local root able to replace a job in that interval is inside the trust boundary. A partial at-tool installation, corrupt or oversized queue output, an unparseable owner, or a surviving artifact fails closed. -Direct spool verification explicitly supports the cron directories `/var/spool/cron/crontabs`, `/var/spool/cron`, and `/var/spool/cron/tabs`, and the at directories `/var/spool/cron/atjobs`, `/var/spool/at`, and `/var/spool/atjobs`. Implementations using other layouts are outside this file-level verification. When a cron/at command footprint is present, or `/proc/*/comm` still shows a running `cron`, `crond`, or `atd`, the account keeps its identity allocated and disabled for 65 seconds so a daemon can finish a due job it read before cleanup; jobs and UID processes are then cleared again. An unreliable process inventory conservatively takes the wait instead of skipping it. +Direct spool verification explicitly supports the cron directories `/var/spool/cron/crontabs`, `/var/spool/cron`, and `/var/spool/cron/tabs`, and the at directories `/var/spool/cron/atjobs`, `/var/spool/at`, and `/var/spool/atjobs`. Implementations using other layouts are outside this file-level verification. A new default random account uses a UID/GID this tool will not reuse, so its two immediate cleanup passes neither release the identity nor need to wait for a daemon poll. An explicitly reused username, the revoke fallback without persistent systemd, and uninstall finalization still synchronously hold the disabled identity for at least 65 seconds. An unreliable process inventory conservatively waits or retains the account rather than skipping the window. + +Normal systemd revoke does not wait in the foreground. It first removes and confirms the sudo/sshd grants, disables login, and performs two process-termination and cron/at cleanup passes. It then creates a persistent quarantine timer in a separate namespace and atomically records its deadline and unit as deletion-recovery state. Only after the timer is enabled and the state is durable does the command report that access is revoked. The passwd entry continues to occupy the name, UID, and GID while the account is expired, locked, and has no managed privilege entry. The deadline rounds “now plus 65 seconds” upward to a whole minute, so quarantine lasts at least 65 and less than 125 seconds. At expiry, the service repeats identity, job, and process checks before cleaning Home/mail and invoking `userdel`. `Persistent=true` catches up after a host was down across the deadline. `doctor` reports a missing or modified finalizer separately, and compaction does not discard its registry witness. Before revocation, every live thread in each thread group carrying the target UID is inspected; a group whose leader is already a zombie but whose worker still runs is not treated as empty. Linux pidfds bind signals to the inspected thread-group instance, avoiding a signal to an unrelated process after PID reuse, and thread credentials are checked again after the pidfd is opened. The UID is scanned again after every SIGKILL sweep, and account deletion proceeds only after two consecutive stable per-thread scans observe no live process. A TGID/TID disappearing before inspection resets that confirmation; an unreliable scan or exhausted bounded retries fails closed. Linux 5.3 plus usable `pidfd_open` and `pidfd_send_signal` are required for safe revocation. `doctor` probes them, and `invite` refuses creation when they are unavailable. @@ -79,7 +83,7 @@ Managed-state commits for invite, revoke, cleanup, install, upgrade, and uninsta A same-name `invite` also owns the exclusive side of an account barrier, while current revokes use its shared side. If a compatibility `revoke --yes` command without UID/generation arguments finds that a same-name creation already owns the exclusive barrier, it deletes nothing and skips successfully so an old systemd job cannot retry against the new generation; a manually issued non-interactive command of the same shape is skipped too and must be followed by `doctor` and a fresh revoke after the concurrent operation. This is a safety-first migration boundary, not proof that the account was deleted. An old binary that was already loaded and began waiting on the global lock before the new barrier took effect cannot be fully reconstructed by the new process locks; invite also scans for the exact root-owned legacy revoke process and refuses username reuse, but system helpers and `/proc` observation still are not an atomic compare-and-swap, and local root remains inside the trust boundary. -Registry schema v4 writes `DeletionStarted` after deletion authorization, account-identity checks, and the pre-artifact deferred-job/process quiescence pass, but before controlled mail/Home cleanup and `userdel`; after Home cleanup it checks jobs, processes, and the complete identity again immediately before `userdel`. An exact-generation account keeps its UID/generation binding; legacy, unregistered, and pending-rollback paths keep only a UID witness. This preserves authority for a previously approved post-deletion mail-spool sweep without turning incomplete identity evidence into unattended live-account deletion authority. Post-deletion recovery is limited to an owner-checked conventional mail spool and never recursively removes an absent account's old Home path; each sweep also confirms before and after that neither the local passwd database nor NSS contains the name. Ordinary registry updates, removal, and compaction cannot overwrite a recovery row, and same-name creation must wait for recovery to finish. A live UID-only or generation-mismatched account permits only interactive `--force` recovery; its old automatic task is cancelled as stale so an unattended command with no recovery authority does not keep retrying. +Registry schema v5 records the monotonic-identity bit, quarantine deadline, and separate finalizer unit. Normal systemd revoke writes exact UID/generation-bound `DeletionStarted` quarantine state after immediate access removal. Traditional synchronous paths write their deletion witness before controlled mail/Home cleanup and `userdel`. Jobs, processes, and the complete identity are checked again after Home cleanup and before `userdel`. Exact-generation accounts retain their UID/generation binding. Legacy, unregistered, and synchronous pending-rollback paths retain only a UID witness, preserving mail-spool cleanup authority after an already-authorized deletion loses the account without turning an incomplete identity into unattended live-account deletion authority. Post-deletion recovery permits only owner-checked conventional mail-spool cleanup and never recursively removes the absent account's old Home path; each sweep also confirms both before and after that no same-name local or NSS identity exists. An ordinary record update, removal, or compaction cannot overwrite a recovery row, and same-name creation waits for recovery to finish. A live UID-only or generation-mismatched account allows only interactive `--force` manual recovery; its stale automatic task is cancelled so an unattended command with no authority to finish recovery does not retry forever. An invite failure runs its rollback stack, cleaning the task, sudoers file, sshd exception, registry row, and any new account that can still be matched to the complete creation-time identity. If the half-created account identity, grant cleanup, or recursive Home cleanup cannot be confirmed, the tool retains the account and registry witness for manual recovery instead of guessing by username. Every incomplete rollback is reported explicitly with a nonzero status and is never presented as success. @@ -90,7 +94,7 @@ If revoke cannot completely remove a name-scoped grant, it retains and attempts ## Files and state - registry, preferences, and audit directories require root ownership and strict permissions; -- the registry validates schema, fields, UID, generation, and size and fails closed when corrupt or unreadable; +- the registry validates schema, fields, UID, generation, quarantine state, and size. The monotonic UID/GID high-water file must also be root-owned, mode `0600`, well-formed, and present alongside a v5 registry; corruption or unreadable state fails closed; - installation, upgrades, and state writes use same-directory temporary files, metadata checks, atomic replacement, and required fsync operations; - a new account uses only the deterministic `/home/` path when it did not already exist; `/home` must be root-managed and the created real directory must belong to the target non-root UID/GID. Revoke removes it while the complete account identity remains checkable. Recursive Home removal uses directory descriptors. A symlink at the Home root, an owner mismatch, or a live mount boundary is refused; an internal symlink is unlinked without following its target. Traversal checks cooperative budgets of 100,000 entries, 128 levels, and two minutes between filesystem calls, so the deadline cannot interrupt one blocked filesystem call; - conventional mail cleanup checks only the traditional single-file mbox locations `/var/mail/` and `/var/spool/mail/`. Those two paths may alias each other, but their resolved target cannot escape the two accepted directories. An existing mail spool must be a real root-owned directory with no setuid bit; a world-writable spool must also have sticky protection. Common `root:mail 2775`/`0775`, the observed `root:mail 3777`, and Arch Linux's `root:root 1777` are therefore accepted. Mode `0777`/`2777` without sticky, any non-root owner including `mail:mail`, and every setuid directory fail closed. A target mailbox must be a non-symlink regular file owned by the captured UID, and its absence is checked again after the parent directory is fsynced; @@ -101,7 +105,7 @@ Do not edit `/var/lib/linux-temp-admin/v2/registry.tsv` manually. An unreadable ## Expiry revocation -The requested lifetime starts after deferred-job cleanup for the new UID and the 65-second daemon drain complete. It is then converted once into an absolute deadline rounded upward to a whole minute, so the safety wait does not shorten nominal access and rounding adds 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. +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. The revoke task rechecks UID, generation token, GECOS marker, and registry row. 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. diff --git a/docs/security-model.md b/docs/security-model.md index 8274106..06d908b 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -46,20 +46,22 @@ 每个新邀请都绑定: -- 创建时的 UID; +- 在 `useradd` 前持久化烧号并显式指定的同号 UID/GID; - 随机 128 位世代标识; - 精确 GECOS 管理标记; - 登记表中的对应记录。 自动撤销和普通 `revoke` 要求这些身份信息一致。账号被删除重建、UID 被复用、标记改变或登记损坏时会拒绝自动删除,而不是猜测同名账号仍是原对象。 -账号数据库项使用 `useradd -M -e 1970-01-01 -p '!'` 创建,因此从出现起就处于过去日期过期且密码锁定的状态,并且不会复制可能含有本机认证材料的 `/etc/skel`。完整 passwd 快照、非 root UID/GID 和 UID 残留进程扫描通过后,Home 在同名/UID 遗留 cron/at 清场、65 秒 daemon drain 和复查全部完成前仍保持不存在;只有清场后再次核对完整身份通过,程序才通过固定 `/home` 目录描述符创建权限为 `0700` 的空 Home 并设置其属主。准备期间账号一直保持过去日期过期;密码或公钥、sshd/sudo 策略、登记和自动撤销任务均完成后,最后一次 `chage` 才写入请求的到期日或永久值并激活登录。 +登记表旁的 root-only 高水位文件在每次 `useradd` 前先原子前进;即使后续创建或回滚失败,这个 UID/GID 也不会再由本工具分配。候选值高于当前本地 passwd/group 中普通账号范围内的全部 UID/GID,并限制在 `login.defs` 的 UID/GID 重叠范围内;`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` 才写入请求的到期日或永久值并激活登录。 程序会在关键事务阶段重复核对完整 passwd 快照;完成 GECOS 身份标记和账号删除的路径还会在按用户名操作的 helper 返回后复核,以发现可观察到的同名替换。这些复核不能把 helper 变成原子的比较并交换。能够并发改写账号数据库的本机 root 仍属于信任边界。账号删除只调用不带 `-r/-f` 的 `userdel --`,不执行会读取 `/etc/deluser.conf` 并可能重新递归清理的发行版 `deluser`,也不接受编译期账号数据库语义不明的任意 BusyBox applet;shadow-utils 的 `-f` 还可能删除仍被其他账号用作主组的同名组,因此也被拒绝。 从旧登记格式迁移的固定标记账号显示为 `legacy-unverified`,不会被定时、批量清理或卸载自动删除。旧版 timer 使用的 `--yes --force --confirm-force` 不能获得这类账号的删除授权;遗留任务会被报告为孤儿任务,并可由 `cleanup-expired --compact` 取消而不删除活账号或登记。人工核对后,必须在交互终端运行 `revoke --force` 并输入完整用户名确认。非交互调用始终拒绝删除这类账号。 -v2.9.2 之前的版本可能在 `useradd` 已成功、后续准备失败时留下仍活着的 pending 创建登记。这种登记本身不提供普通或无人值守删除权限;只有管理员人工核查后,在直接交互终端运行 `revoke --user <名> --force` 并输入完整用户名,才会进入恢复候选。程序还要求登记中的随机世代精确匹配 pending GECOS、登记 UID 为尚未写入的 0 或与当前 UID 一致、Home 为确定的受管路径、UID/GID 均为非 root 且 shell 非空;随后在清理前把它降为不带世代的 UID-only `DeletionStarted` 见证。`--yes`、自动任务、卸载批量、非 TTY 及任何不匹配状态始终失败关闭并保留账号和登记。 +v2.9.2 之前的版本可能在 `useradd` 已成功、后续准备失败时留下仍活着的 pending 创建登记。这种登记本身不提供普通或无人值守删除权限;只有管理员人工核查后,在菜单选择该行或直接交互运行 `revoke --user <名> --force` 并输入完整用户名,才会进入恢复候选。程序还要求登记中的随机世代精确匹配 pending GECOS、登记 UID 为尚未写入的 0 或与当前 UID 一致、Home 为确定的受管路径、UID/GID 均为非 root 且 shell 非空。systemd 可用时会保留精确 pending 世代进入持久隔离;同步回退才在清理前把它降为不带世代的 UID-only `DeletionStarted` 见证。`--yes`、自动任务、卸载批量、非 TTY 及任何不匹配状态始终失败关闭并保留账号和登记。 root、UID 0 和保留名称始终不会删除。低 UID 账号只有在当前登记 UID、随机世代与精确 GECOS 标记完整绑定时,才能作为本工具创建的账号撤销;未登记或旧身份格式的低 UID 账号即使使用 `--force` 也受保护。没有本工具精确标记的真实账号同样不会作为受管账号删除。 @@ -67,7 +69,9 @@ root、UID 0 和保留名称始终不会删除。低 UID 账号只有在当前 个人 crontab 和 `at`/`batch` 任务不会因禁用登录、杀掉当前进程或普通 `userdel --` 而可靠消失。新账号在获取密码、公钥或 sudo 授权前,以及旧账号释放用户名/UID 前,都会删除并复核同名个人 crontab,通过 `atq` 和 `at -c` 生成的 `atrun uid=` 头盘点全部任务,并删除目标 UID 的任务。每次调用 `atrm` 前都会重新读取同一 ID,并再次绑定预期 UID 或本工具的精确撤销命令;删除失败后也会复核目标是否仍存在或 ID 是否已被复用。本工具自己的自动撤销作业只在 `atrun` 头表明其属于 root 时才被识别;程序先在 64 KiB 上限内读取 owner 头,因此已确定属于非 root 的超大作业不会阻断自动任务清单,而 root 作业才会在更大的有界范围内读取完整正文。外部 `at` 接口没有原子的比较删除操作,所以重新读取到 `atrm` 之间仍有极短窗口;能在该窗口替换作业的本机 root 属于信任边界。部分安装的 at 工具、损坏/过大的队列输出、无法解析的所有者或存活文件都会 fail closed。 -直接 spool 复核明确支持的 cron 目录是 `/var/spool/cron/crontabs`、`/var/spool/cron` 和 `/var/spool/cron/tabs`;at 目录是 `/var/spool/cron/atjobs`、`/var/spool/at` 和 `/var/spool/atjobs`。未使用这些常规布局的实现不在这项文件级复核范围内。检测到 cron/at 命令足迹,或在 `/proc/*/comm` 中看到仍运行的 `cron`、`crond`、`atd` 时,账号会继续占用身份并保持禁用 65 秒,让 daemon 完成清理前已读取的到期任务,然后再次清理任务和 UID 进程;若进程清单无法可靠扫描,也会保守等待而不是跳过窗口。 +直接 spool 复核明确支持的 cron 目录是 `/var/spool/cron/crontabs`、`/var/spool/cron` 和 `/var/spool/cron/tabs`;at 目录是 `/var/spool/cron/atjobs`、`/var/spool/at` 和 `/var/spool/atjobs`。未使用这些常规布局的实现不在这项文件级复核范围内。新建的默认随机账号使用不会被本工具复用的 UID/GID,因而两轮即时清场之间无需释放身份或等待 daemon 轮询。显式复用用户名、无持久 systemd 的撤销回退和卸载终删仍同步保持禁用身份至少 65 秒;若进程清单无法可靠扫描,也会保守等待或保留账号,而不是跳过窗口。 + +正常 systemd 撤销不会在前台等待:它先删除并确认 sudo/sshd 授权,禁用账号登录,执行两轮进程终止与 cron/at 清理,再创建独立命名空间的持久化 quarantine timer,并把截止时间和 unit 原子登记为删除恢复状态。只有 timer 已启用且状态已持久化后命令才报告访问已撤销;此时 passwd 项继续占用用户名、UID 和 GID,账号过期锁定且无受管提权入口。截止时间按“当前时间加 65 秒”向上取整到整分钟,因此实际隔离至少 65 秒、不到 125 秒;到期服务再次复核身份、任务和进程后才清理 Home/mail 并运行 `userdel`。`Persistent=true` 让关机跨过截止时间后仍会补跑。timer 丢失或正文不匹配会由 `doctor` 单独报告,登记不会被 compact 丢弃。 撤销账号前会逐个检查该 UID 线程组中的所有存活线程;主线程已经是 zombie 但工作线程仍在运行的线程组不会被当成空进程。Linux pidfd 把信号绑定到检查过的线程组实例,避免 PID 复用后误杀无关进程,且打开 pidfd 后还会再次核对线程凭据。每轮 SIGKILL 后都会重新扫描该 UID,只有连续两次稳定的逐线程复扫都观察不到存活进程才会继续删除账号;任一 TGID/TID 在读取前消失会重置确认,扫描不可靠或达到有界重试次数都会 fail closed。Linux 5.3、`pidfd_open` 和 `pidfd_send_signal` 是安全撤销所需能力;`doctor` 会实测,`invite` 在能力不可用时拒绝创建账号。 @@ -79,7 +83,7 @@ invite、revoke、cleanup、install、upgrade 和 uninstall 的受管状态提 同名 `invite` 另持独占账号屏障,当前撤销持共享侧屏障。兼容旧任务的不带 UID/世代 `revoke --yes` 若发现独占屏障已被同名创建占用,会不作删除并以成功状态跳过,防止旧 systemd 任务在创建结束后重试并命中新世代;人工发出的同形非交互命令也会被跳过,必须在并发操作完成后运行 `doctor` 并重新撤销。这是安全优先的迁移边界,而不是已经删除账号的证明。已经载入、且在新版屏障生效前开始等待全局锁的旧二进制无法由新版进程锁完全追溯;创建流程还会扫描精确的 root-owned 旧撤销进程并拒绝用户名复用,但系统 helper 和 `/proc` 观察仍不是原子 compare-and-swap,本机 root 保持在信任边界内。 -登记格式 v4 在删除授权、账号身份及删除前的延迟任务/进程静默检查通过后,并在受控的 mail/Home 清理和 `userdel` 之前写入 `DeletionStarted`;Home 清理后、`userdel` 前还会再次复核任务、进程和完整身份。精确世代账号保留 UID 与世代绑定;旧版、未登记及 pending 回滚只保留 UID 见证,避免一次已经授权的删除在账号消失后失去 mail spool 清扫依据,同时不把不完整身份变成无人值守删除权限。删除后的恢复只允许核对 UID 所有者的常规 mail spool 清扫,不会按缺失账号的旧 Home 路径递归删除;每次清扫前后还会同时确认本地 passwd 与 NSS 中没有同名身份。恢复行不能由普通登记更新、删除或 compact 覆盖,同名创建也必须等待恢复完成。活着的 UID-only 或世代不匹配账号只允许交互式 `--force` 人工恢复;旧自动任务会作为失效任务解除,避免无权完成恢复的无人值守命令持续重试。 +登记格式 v5 保存单调身份标志、quarantine 截止时间和独立终删 unit。正常 systemd 撤销在立即撤权后写入带精确 UID/世代的 `DeletionStarted` 隔离状态;传统同步路径则在受控的 mail/Home 清理和 `userdel` 之前写入删除见证。Home 清理后、`userdel` 前还会再次复核任务、进程和完整身份。精确世代账号保留 UID 与世代绑定;旧版、未登记及同步 pending 回滚只保留 UID 见证,避免一次已经授权的删除在账号消失后失去 mail spool 清扫依据,同时不把不完整身份变成无人值守删除权限。删除后的恢复只允许核对 UID 所有者的常规 mail spool 清扫,不会按缺失账号的旧 Home 路径递归删除;每次清扫前后还会同时确认本地 passwd 与 NSS 中没有同名身份。恢复行不能由普通登记更新、删除或 compact 覆盖,同名创建也必须等待恢复完成。活着的 UID-only 或世代不匹配账号只允许交互式 `--force` 人工恢复;旧自动任务会作为失效任务解除,避免无权完成恢复的无人值守命令持续重试。 创建失败会运行回滚栈,清理任务、sudoers、sshd 例外、登记和能够以创建时完整身份确认的新账号。若半创建账号的身份、授权清理或递归 Home 清理无法确认,工具会保留账号及登记供人工恢复,而不会按用户名猜测删除。任何回滚不完整都会明确报告并返回非零,不会把部分成功显示为成功。 @@ -90,7 +94,7 @@ invite、revoke、cleanup、install、upgrade 和 uninstall 的受管状态提 ## 文件与状态 - 登记表、偏好和审计目录要求 root 所有及严格权限; -- 登记表严格验证 schema、字段、UID、世代和大小,损坏或不可读时 fail closed; +- 登记表严格验证 schema、字段、UID、世代、隔离状态和大小;单调 UID/GID 高水位文件也要求 root 所有、`0600`、固定格式且与 v5 登记同时存在,损坏或不可读时 fail closed; - 安装、升级和状态写入使用同目录临时文件、元数据验证、原子替换和必要的 fsync; - 新账号只能使用创建前不存在的确定路径 `/home/<用户名>`;`/home` 必须由 root 管理,创建后的真实目录必须属于目标非 root UID/GID。撤销会在完整账号身份仍可核对时清理该目录。Home 递归删除使用目录描述符;Home 根若为符号链接、属主不符或跨越活跃挂载边界会被拒绝,内部符号链接只删除链接本身而不跟随目标。遍历会在文件系统调用之间检查 100,000 个条目、128 层和两分钟的协作式预算,单次阻塞的文件系统调用不能由该期限中断; - 常规邮件清理只检查 `/var/mail/<用户名>` 与 `/var/spool/mail/<用户名>` 这两个传统单文件 mbox 位置,允许二者互为符号链接,但解析结果不得逃出这两个目录。存在的系统邮箱目录必须是 root 所有的真实目录、不得带 setuid;若为 world-writable,则必须带 sticky bit。因而常见 `root:mail 2775`/`0775`、现场 `root:mail 3777` 和 Arch Linux `root:root 1777` 均可接受,而无 sticky 的 `0777`/`2777`、包括 `mail:mail` 在内的非 root 属主及任何 setuid 目录都会失败关闭。目标 mailbox 必须是非符号链接普通文件且 UID 与捕获身份一致;删除并 fsync 父目录后还会复核它没有重建; @@ -101,7 +105,7 @@ invite、revoke、cleanup、install、upgrade 和 uninstall 的受管状态提 ## 到期撤销 -请求的有效期在新 UID 的延迟任务清场和 65 秒 daemon drain 完成后开始,再只转换一次为向上取整到整分钟的绝对截止时间;因此安全等待不会缩短名义访问时长,取整最多多不到一分钟。邀请显示、`chage -E` 兜底日期、systemd timer 和 `at` 都由这一目标生成;`at` 使用绝对 UTC 分钟,不会因夏令时跳变而提前撤销。`chage -E` 仍只是更晚、按天粒度的锁定兜底。systemd 不可用,或其排程失败且任务已安全回滚时,才尝试 `at`;任一后端都无法成功排程,或者排程前截止时间已到时,邀请创建会回滚。调度器忙碌、主机停机和撤销重试都可能让实际删除延后,因此不再需要的访问应手动立即撤销。 +默认随机用户名的邀请在即时任务清场后计算有效期,不再经历 65 秒前台等待;显式 `--user` 的同步名称复用防护完成后才计算有效期,所以安全等待不会缩短名义访问时长。目标只转换一次为向上取整到整分钟的绝对截止时间,取整最多多不到一分钟。邀请显示、`chage -E` 兜底日期、systemd timer 和 `at` 都由这一目标生成;`at` 使用绝对 UTC 分钟,不会因夏令时跳变而提前撤销。`chage -E` 仍只是更晚、按天粒度的锁定兜底。systemd 不可用,或其排程失败且任务已安全回滚时,才尝试 `at`;任一后端都无法成功排程,或者排程前截止时间已到时,邀请创建会回滚。调度器忙碌、主机停机和撤销重试都可能让实际删除延后,因此不再需要的访问应手动立即撤销。 撤销任务再次验证 UID、世代标识、GECOS 和登记行。身份不匹配、登记丢失或账号已重建时会安全跳过,交由管理员检查。systemd 撤销失败会限速重试;一次性后端失败需要 `doctor` 和人工处理。 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0756534..995fe38 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -104,6 +104,14 @@ type App struct { // an older release before invite reuses the username. Production scans /proc; // tests inject the process inventory they intend to exercise. RunningLegacyRevoke func(installPath, username string) (bool, error) + // IdentityAllocationRange is the local UID/GID allocation policy probe. + // Production scans passwd, group, and login.defs; tests inject a deterministic + // range so their account helper and registry sequence agree. + IdentityAllocationRange func() (minimum, maximum int, err error) + // EnsureScheduledCommand is a test hook for the stable-command preflight used + // before a revoke hands final deletion to systemd. Production leaves it nil and + // installs or verifies the running binary through ensureStableInstalled. + EnsureScheduledCommand func() error inReader *bufio.Reader // lazily wraps In; reused so buffered stdin isn't lost between prompts } @@ -151,6 +159,7 @@ func NewApp(lang i18n.Lang) *App { RunningLegacyRevoke: func(installPath, username string) (bool, error) { return runningLegacyRevokeProcess("/proc", installPath, username) }, + IdentityAllocationRange: user.IdentityAllocationRange, } } @@ -175,6 +184,13 @@ func (a *App) runningLegacyRevoke(username string) (bool, error) { return runningLegacyRevokeProcess("/proc", a.InstallPath, username) } +func (a *App) identityAllocationRange() (int, int, error) { + if a.IdentityAllocationRange != nil { + return a.IdentityAllocationRange() + } + return user.IdentityAllocationRange() +} + func (a *App) terminateProcesses(uid int) error { if a.TerminateProcesses != nil { return a.TerminateProcesses(uid) diff --git a/internal/cli/cli_e2e_test.go b/internal/cli/cli_e2e_test.go index c487840..1498a20 100644 --- a/internal/cli/cli_e2e_test.go +++ b/internal/cli/cli_e2e_test.go @@ -112,6 +112,9 @@ func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("output u type partialFailingWriter struct{ wrote int } func (w *partialFailingWriter) Write(p []byte) (int, error) { + if !bytes.Contains(p, []byte("----- BEGIN LINUX TEMP ADMIN INVITE -----")) { + return len(p), nil + } n := len(p) / 2 w.wrote += n return n, errors.New("output interrupted after a partial credential write") diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 873c170..bacc50b 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -121,11 +121,24 @@ func setTestRegistryRecord(t *testing.T, a *App, rec registry.Record) { rec.Port = 22 } deletionStarted := rec.DeletionStarted + quarantineUntil, quarantineUnit := rec.QuarantineUntil, rec.QuarantineUnit rec.DeletionStarted = false + rec.QuarantineUntil = "" + rec.QuarantineUnit = "" if err := a.Registry.Record(rec); err != nil { t.Fatal(err) } if deletionStarted { + if quarantineUntil != "" { + deadline, err := time.Parse(time.RFC3339, quarantineUntil) + if err != nil { + t.Fatal(err) + } + if err := a.Registry.BeginQuarantine(rec.User, rec.UID, rec.Generation, deadline, quarantineUnit); err != nil { + t.Fatal(err) + } + return + } generation := rec.Generation if rec.Pending || !rec.IdentityBound { generation = "" diff --git a/internal/cli/commands.go b/internal/cli/commands.go index be15090..637f394 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" "strings" + "time" "unicode/utf8" "github.com/xxvcc/linux-temp-admin/internal/buildinfo" @@ -63,6 +64,9 @@ func (a *App) status(args []string) int { if rec.AutoUnit != "" { a.printf("auto-revoke unit=%s", rec.AutoUnit) } + if rec.QuarantineUntil != "" { + a.printf("quarantine-until=%s unit=%s", rec.QuarantineUntil, rec.QuarantineUnit) + } return 0 } a.errorf("%s", a.P.M("用户不存在:"+u, "user does not exist: "+u)) @@ -76,6 +80,8 @@ func (a *App) status(args []string) int { managed, identity = true, "generation-bound" case registeredRecoveryBound: identity = "deletion-recovery-bound" + case registeredQuarantine: + identity = "quarantined" case registeredRecoveryManual: identity = "deletion-recovery-manual" case registeredLegacyIdentity: @@ -97,6 +103,9 @@ func (a *App) status(args []string) int { if found && rec.AutoUnit != "" { a.printf("auto-revoke unit=%s", rec.AutoUnit) } + if found && rec.QuarantineUntil != "" { + a.printf("quarantine-until=%s unit=%s", rec.QuarantineUntil, rec.QuarantineUnit) + } return 0 } @@ -165,6 +174,8 @@ func (a *App) userCells(r registry.Record) []string { state = a.P.M("删除后恢复", "post-delete recovery") case registeredRecoveryBound: state = a.P.M("删除恢复(可续删)", "deletion recovery (bound retry)") + case registeredQuarantine: + state = a.P.M("已撤权,隔离待删", "access revoked; quarantined") case registeredRecoveryManual: state = a.P.M("删除恢复(需人工)", "deletion recovery (manual)") case registeredPending: @@ -280,6 +291,7 @@ const ( registeredUnknown registeredRecoveryAbsent registeredRecoveryBound + registeredQuarantine registeredRecoveryManual registeredPending registeredIdentityUnverified @@ -296,6 +308,8 @@ func classifyRegisteredAccount(rec registry.Record, pw user.Passwd, exists bool, return registeredUnknown case rec.DeletionStarted && !exists: return registeredRecoveryAbsent + case rec.QuarantineUntil != "" && rec.DeletionStarted && rec.IdentityBound && deletionRecordMatchesPasswd(rec, pw): + return registeredQuarantine case rec.DeletionStarted && rec.IdentityBound && deletionRecordMatchesPasswd(rec, pw): return registeredRecoveryBound case rec.DeletionStarted: @@ -422,7 +436,15 @@ func (a *App) manageUsers() int { } name = recs[n-1].User } - return a.revoke([]string{"--user", name}) + args := []string{"--user", name} + if rec, found, err := a.Registry.Lookup(name); err == nil && found && rec.Pending { + // Pending recovery is still protected by the direct TTY, full-name prompt, + // generation/GECOS/UID/Home checks, and manual-invocation gate. Supplying + // --force here makes the menu's advertised revoke action usable without + // weakening any of those checks. + args = append(args, "--force") + } + return a.revoke(args) } func (a *App) cleanupExpired(args []string) int { @@ -487,7 +509,7 @@ func (a *App) accountIsOursAndLive(name string) (bool, error) { return false, err } state := classifyRegisteredAccount(rec, pw, exists, nil) - return state == registeredActive || state == registeredLegacyIdentity, nil + return state == registeredActive || state == registeredQuarantine || state == registeredLegacyIdentity, nil } // accountNeedsAutoRevoke reports whether a managed auto-revoke task must be @@ -509,7 +531,7 @@ func (a *App) accountNeedsAutoRevoke(name string) (bool, error) { return false, err } state := classifyRegisteredAccount(rec, pw, exists, nil) - return state == registeredActive || state == registeredRecoveryAbsent || + return state == registeredActive || state == registeredQuarantine || state == registeredRecoveryAbsent || state == registeredRecoveryBound, nil } @@ -537,7 +559,7 @@ func (a *App) completedAccountIdentity(name string) (ours, live bool, err error) return false, true, nil } state := classifyRegisteredAccount(rec, pw, true, nil) - return state == registeredActive || state == registeredRecoveryBound, true, nil + return state == registeredActive || state == registeredQuarantine || state == registeredRecoveryBound, true, nil } // installedCommandVersion best-effort reads the version of the binary at @@ -855,6 +877,10 @@ func (a *App) doctor(args []string) int { "活账号与已持久化的删除世代精确匹配;这是可重试的中断删除,请运行 revoke 完成:", "the live account exactly matches a durably started deletion generation; this interrupted deletion can be retried with revoke: "), rec.User) rc = 1 + case registeredQuarantine: + a.info(a.P.M( + "账号访问已撤销,用户名和 UID 正隔离至 "+rec.QuarantineUntil+",之后由持久化任务完成删除:", + "account access is revoked; its name and UID are quarantined until "+rec.QuarantineUntil+" and a persistent task will then finish deletion: ") + rec.User) case registeredRecoveryManual: a.warnf("%s%s", a.P.M( "活账号的删除恢复见证未绑定当前世代或已不匹配;自动删除、--yes 和卸载批量删除均被拒绝。人工核查后,请直接运行 revoke --force 并输入完整用户名:", @@ -982,14 +1008,32 @@ func (a *App) doctor(args []string) int { // is not health: a stale unit/job can target another account generation, and a // modified service body can run something else entirely. if a.Scheduler != nil && a.Registry != nil && registryReadable { - var stranded []string + var strandedAuto, strandedQuarantine []string for _, r := range registryRecords { - exists, existsErr := user.Exists(r.User) + _, exists, existsErr := a.lookupUser(r.User) if existsErr != nil { a.warnf("%s %s: %v", a.P.M("无法确认账号状态:", "cannot determine account state:"), r.User, existsErr) rc = 1 continue } + if r.QuarantineUntil != "" && exists { + deadline, parseErr := time.Parse(time.RFC3339, r.QuarantineUntil) + if parseErr != nil { + a.warnf("%s %s: %v", a.P.M("无法验证身份隔离任务:", "cannot verify identity-quarantine task:"), r.User, parseErr) + rc = 1 + continue + } + valid, err := a.Scheduler.ValidQuarantine(r.User, r.UID, r.Generation, r.QuarantineUnit, deadline) + if err != nil { + a.warnf("%s %s: %v", a.P.M("无法验证身份隔离任务:", "cannot verify identity-quarantine task:"), r.User, err) + rc = 1 + continue + } + if !valid { + strandedQuarantine = append(strandedQuarantine, r.User) + } + continue + } if !r.AutoRevoke || !exists { continue } @@ -1000,11 +1044,11 @@ func (a *App) doctor(args []string) int { continue } if !valid { - stranded = append(stranded, r.User) + strandedAuto = append(strandedAuto, r.User) } } - if len(stranded) > 0 { - for _, u := range stranded { + 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) } @@ -1012,6 +1056,17 @@ func (a *App) doctor(args []string) int { "remove them with `revoke --user ` once expired.")) rc = 1 } + if len(strandedQuarantine) > 0 { + for _, u := range strandedQuarantine { + a.warnf("%s%s", a.P.M( + "账号访问已撤销且身份仍在隔离,但已无可验证的后台终删任务(任务必须匹配 UID、世代、隔离截止时间、记录的 unit 和正文):", + "account access is revoked and its identity remains quarantined, but no valid background finalizer remains (the UID, generation, quarantine deadline, recorded unit, and body must all match): "), u) + } + a.warnf("%s", a.P.M( + "请运行 `revoke --user <名>`;隔离截止时间已到时会立即续删,尚未到时会确认账号仍保持禁用。", + "run `revoke --user `; after the quarantine deadline it resumes deletion immediately, and before the deadline it reconfirms that access remains disabled.")) + rc = 1 + } } return rc } diff --git a/internal/cli/doctor_identity_test.go b/internal/cli/doctor_identity_test.go index 304e8ce..b5e3443 100644 --- a/internal/cli/doctor_identity_test.go +++ b/internal/cli/doctor_identity_test.go @@ -5,9 +5,11 @@ import ( "os" "strings" "testing" + "time" "github.com/xxvcc/linux-temp-admin/internal/config" "github.com/xxvcc/linux-temp-admin/internal/registry" + "github.com/xxvcc/linux-temp-admin/internal/schedule" "github.com/xxvcc/linux-temp-admin/internal/sysinfo" "github.com/xxvcc/linux-temp-admin/internal/user" ) @@ -149,6 +151,65 @@ func TestStatusReportsAbsentDeletionRecovery(t *testing.T) { } } +func TestStatusReportsQuarantineFieldsAfterExternalAccountRemoval(t *testing.T) { + deadline := time.Now().UTC().Add(2 * time.Minute).Truncate(time.Minute) + rec := registry.Record{ + User: "xxvcc-quarantine-status", UID: 1001, Port: 22, + Generation: "0123456789abcdef0123456789abcdef", IdentityBound: true, + DeletionStarted: true, QuarantineUntil: deadline.Format(time.RFC3339), + QuarantineUnit: config.QuarantineUnitPrefix + "xxvcc-quarantine-status", + } + a, out, _ := newTestApp(t, "") + setTestRegistryRecord(t, a, rec) + a.LookupUser = func(string) (user.Passwd, bool, error) { return user.Passwd{}, false, nil } + if rc := a.status([]string{"--user", rec.User}); rc != 0 { + t.Fatalf("status rc = %d, want recovery status", rc) + } + got := out.String() + if !strings.Contains(got, "identity=deletion-recovery-absent") || + !strings.Contains(got, "quarantine-until="+rec.QuarantineUntil+" unit="+rec.QuarantineUnit) { + t.Fatalf("status hid absent quarantine recovery: %q", got) + } +} + +func TestDoctorDistinguishesMissingQuarantineFinalizer(t *testing.T) { + const ( + name = "xxvcc-quarantine-doctor" + generation = "0123456789abcdef0123456789abcdef" + ) + a, _, errb := newTestApp(t, "") + deadline := a.Now().UTC().Add(2 * time.Minute).Truncate(time.Minute) + rec := registry.Record{ + User: name, UID: 1001, Port: 22, Generation: generation, IdentityBound: true, + DeletionStarted: true, SequentialID: true, + QuarantineUntil: deadline.Format(time.RFC3339), QuarantineUnit: config.QuarantineUnitPrefix + name, + } + setTestRegistryRecord(t, a, rec) + a.Scheduler = &schedule.Scheduler{ + SystemdDir: t.TempDir(), InstallPath: a.InstallPath, + UnitPrefix: config.AutoRevokeUnitPrefix, Now: a.Now, + Sys: revokeTestScheduleSystem{}, + } + a.LookupUser = func(string) (user.Passwd, bool, error) { + return user.Passwd{ + Name: name, UID: 1001, GID: 1001, + GECOS: config.ManagedGenerationGECOSPrefix + generation, + Home: "/home/" + name, Shell: "/bin/sh", + }, true, nil + } + a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { + return sysinfo.ParseSSHD("pubkeyauthentication yes\nauthorizedkeysfile .ssh/authorized_keys\n"), nil + } + if rc := a.doctor(nil); rc != 1 { + t.Fatalf("doctor rc = %d, want missing-finalizer failure", rc) + } + got := errb.String() + if !strings.Contains(got, "identity remains quarantined, but no valid background finalizer remains") || + !strings.Contains(got, name) || strings.Contains(got, "account set to auto-delete but has no valid task left") { + t.Fatalf("doctor did not distinguish quarantine finalizer loss: %q", got) + } +} + func TestDoctorReportsLifecycleMarkerWithoutRegistryRow(t *testing.T) { requireRootRegistryFixture(t) const ( diff --git a/internal/cli/invite.go b/internal/cli/invite.go index aa21571..8132aa0 100644 --- a/internal/cli/invite.go +++ b/internal/cli/invite.go @@ -106,6 +106,7 @@ func (a *App) invite(args []string) int { return 1 } username := *userFlag + generatedUsername := username == "" if username == "" { // Only the generation path uses the prefix. A prefix in the reserved // "systemd-" namespace would generate usernames the revoke path refuses to @@ -116,8 +117,12 @@ func (a *App) invite(args []string) int { "username prefix is in a reserved namespace (e.g. systemd-) and would create an unrevocable account: "+*prefix)) return 1 } + // Fill the username's remaining Linux-compatible length with entropy. Even + // the longest accepted prefix retains the historical 40-bit minimum, while + // the default prefix receives 104 bits. + suffixBytes := (31 - len(*prefix)) / 2 for attempt := 0; attempt < 20; attempt++ { - h, err := a.RandHex(5) + h, err := a.RandHex(suffixBytes) if err != nil { a.errorf("rand: %v", err) return 1 @@ -315,7 +320,7 @@ func (a *App) invite(args []string) int { } return a.withAccountExclusiveLock(username, func() int { return a.withLifecycleLock(func() int { - return a.runInvite(username, host, port, hours, grantSudo == "yes", autoRev == "yes", plan) + return a.runInviteWithIdentityPolicy(username, host, port, hours, grantSudo == "yes", autoRev == "yes", plan, generatedUsername) }) }) } @@ -898,8 +903,10 @@ func (a *App) installDeps(needSudo, needPassword bool, pkgs []string) bool { return true } -// runInvite performs the mutating steps with rollback on any failure. -func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAuto bool, plan loginPlan) int { +// runInviteWithIdentityPolicy performs the mutating steps with rollback on any +// failure. generatedUsername controls whether the fresh-name isolation proof is +// 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 { // Preflight the registry before creating anything, so a broken/unsafe // registry fails fast instead of leaving a stray account behind. if err := a.Registry.Init(); err != nil { @@ -1074,9 +1081,23 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu "检测到该用户名的旧版无世代绑定撤销进程仍在运行;已拒绝复用,请等待其退出并重新运行。", "a legacy revoke process without a generation binding is still running for this username; reuse was refused; wait for it to exit and retry.")) } + // Reserve and durably burn an identity above every currently allocated local UID + // and GID. The registry sequence commits before useradd: a crash may waste a + // number, but this tool never reuses it for another account generation. + minimumID, maximumID, err := a.identityAllocationRange() + if err != nil { + return failf("%s: %v", a.P.M("无法确定安全的 UID/GID 分配范围", "cannot determine a safe UID/GID allocation range"), err) + } + reservedID, identityIsolationReady, err := a.Registry.ReserveIdentity(minimumID, maximumID) + if err != nil { + return failf("%s: %v", a.P.M("无法持久化预留 UID/GID", "cannot durably reserve a UID/GID"), err) + } + rec.UID = reservedID + rec.SequentialID = true // Persist the account intent before useradd. A kill or power loss after account // creation must leave a registry witness even if no sudo/sshd/schedule artifact - // exists. UID 0 means pending and is replaced immediately after lookup. + // exists. The reserved UID is already known and is verified against passwd as + // soon as the helper returns. if err := a.Registry.Record(rec); err != nil { return failf("%s: %v", a.P.M("登记账号创建意图失败", "recording account creation intent failed"), err) } @@ -1113,7 +1134,7 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu // pending identity, allowing this transaction to attempt the same fail-closed // rollback instead of discarding evidence it already captured. accountCleanupConfirmed = false - pw, err := a.Users.CreatePendingIdentity(username, resolveShell(), generation) + pw, err := a.Users.CreatePendingIdentityWithID(username, resolveShell(), generation, reservedID) if err != nil && errors.Is(err, user.ErrAccountCreationNotStarted) { // No account helper ran, so there is no ambiguous partial account identity to // recover. The last registry cleanup still confirms local absence before it @@ -1162,13 +1183,24 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu if err := a.Users.DisableLogin(username); err != nil { return failf("%s: %v", a.P.M("建立新账号的安全失效状态失败", "establishing the new account's fail-closed login state failed"), err) } - // A previously deleted account can leave a same-name personal crontab or an at - // job carrying the numeric UID that useradd just selected. The pending account - // is expired, password-locked, and has no credential yet; it keeps that identity - // occupied while two clear/kill passes wait out daemon-cached work. Do this - // before the managed marker, password, key, or sudo policy exists, so inherited - // deferred work never reaches a grant. - if err := a.quiesceScheduledAccount(username, pw); err != nil { + // Generated names combine a fresh random suffix with the monotonic UID/GID pair, + // so neither name nor numeric identity is being reused by this tool. Clear and + // verify once without the old polling-cycle delay. Explicit operator-selected + // names retain the full drain because they may intentionally reuse a historical + // name even though their numeric identity is fresh. + quiesce := a.quiesceScheduledAccount + if generatedUsername && rec.SequentialID && identityIsolationReady { + quiesce = a.quiesceScheduledAccountImmediate + } else if generatedUsername { + a.info(a.P.M( + "登记刚从旧版迁移,历史已释放 UID 尚未跨过一次性隔离窗口;本次将同步等待约 65 秒,后续自动用户名创建不再等待。", + "the registry was just migrated from an older release, so historically released UIDs have not crossed the one-time isolation window; this invite waits about 65 seconds, and later generated-name invites do not.")) + } else { + a.info(a.P.M( + "显式用户名可能复用历史名称;为防止旧 cron/at 缓存任务跨世代执行,将同步等待约 65 秒完成清场。", + "an explicit username may reuse a historical name; waiting about 65 seconds to prevent cached cron/at work from crossing account generations.")) + } + if err := quiesce(username, pw); err != nil { return failf("%s: %v", a.P.M("无法清除同名账号或复用 UID 的遗留 cron/at 任务", "cannot clear cron/at work left by the reused username or UID"), err) } // The drain intentionally gives daemon-cached work a full polling cycle to diff --git a/internal/cli/manage_root_test.go b/internal/cli/manage_root_test.go index e5457ab..7b8d7f8 100644 --- a/internal/cli/manage_root_test.go +++ b/internal/cli/manage_root_test.go @@ -184,7 +184,7 @@ func TestRunInviteReleasesIntentWhenCreatePreflightFails(t *testing.T) { return "abcdef0123", nil } - if rc := a.runInvite(username, "192.0.2.1", 22, 1, false, true, loginPlan{verified: true}); rc != 1 { + if rc := a.runInviteWithIdentityPolicy(username, "192.0.2.1", 22, 1, false, true, loginPlan{verified: true}, false); rc != 1 { t.Fatalf("runInvite rc=%d, want preflight failure", rc) } if found, err := a.Registry.Contains(username); err != nil || found { @@ -219,10 +219,19 @@ func TestRunInviteRetainsPendingRegistryWhenCreateHelperReportsFailure(t *testin Dir: regDir, File: filepath.Join(regDir, "registry.tsv"), Lock: filepath.Join(regDir, "registry.lock"), } a.Users = &user.Manager{ - Runner: failedCreateRunner{}, + Runner: failedCreateRunner{}, + LookupUser: func(string) (user.Passwd, bool, error) { + return user.Passwd{ + Name: username, UID: 4242, GID: 4242, + GECOS: config.PendingGenerationGECOSPrefix + "0123456789abcdef0123456789abcdef", + Home: "/home/" + username, Shell: resolveShell(), + }, true, nil + }, PrepareManagedHome: func(string) error { return nil }, CreateManagedHome: func(user.Passwd) error { return nil }, } + a.LookupUser = a.Users.LookupUser + a.IdentityAllocationRange = func() (int, int, error) { return 4242, 4242, nil } createdAt := time.Date(2026, 7, 7, 12, 34, 59, 0, time.FixedZone("test", 8*60*60)) clockCalls := 0 a.Now = func() time.Time { @@ -240,14 +249,14 @@ func TestRunInviteRetainsPendingRegistryWhenCreateHelperReportsFailure(t *testin return "abcdef0123", nil } - if rc := a.runInvite(username, "192.0.2.1", 22, 1, false, true, loginPlan{verified: true}); rc != 1 { + if rc := a.runInviteWithIdentityPolicy(username, "192.0.2.1", 22, 1, false, true, loginPlan{verified: true}, false); rc != 1 { t.Fatalf("runInvite rc=%d, want helper failure", rc) } rec, found, err := a.Registry.Lookup(username) if err != nil { t.Fatal(err) } - if !found || !rec.Pending || rec.UID != 0 || !rec.IdentityBound { + if !found || !rec.Pending || rec.UID != 4242 || !rec.IdentityBound || !rec.SequentialID { t.Fatalf("pending recovery witness was removed after ambiguous helper failure: found=%v rec=%+v", found, rec) } if clockCalls != 1 { @@ -332,6 +341,7 @@ func TestRunInviteClearsStaleJobsBeforeCredentialAndRebasesLifetime(t *testing.T RemoveManagedHome: func(user.Passwd) error { return nil }, } a.LookupUser = runner.lookup + a.IdentityAllocationRange = func() (int, int, error) { return uid, uid, nil } t0 := time.Date(2026, 7, 7, 12, 0, 0, 0, time.FixedZone("test", 8*60*60)) t1 := t0.Add(65 * time.Second) @@ -376,7 +386,7 @@ func TestRunInviteClearsStaleJobsBeforeCredentialAndRebasesLifetime(t *testing.T sshdConfig := sysinfo.ParseSSHD("passwordauthentication yes\n") a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { return sshdConfig, nil } - if rc := a.runInvite(username, "192.0.2.1", 22, 1, false, true, loginPlan{password: true, verified: true}); rc != 1 { + if rc := a.runInviteWithIdentityPolicy(username, "192.0.2.1", 22, 1, false, true, loginPlan{password: true, verified: true}, false); rc != 1 { t.Fatalf("runInvite rc = %d, want injected credential failure", rc) } if !strings.Contains(errb.String(), stopErr.Error()) { @@ -405,6 +415,114 @@ func TestRunInviteClearsStaleJobsBeforeCredentialAndRebasesLifetime(t *testing.T } } +func TestGeneratedInviteHonorsLegacyMigrationIsolationWindow(t *testing.T) { + const ( + generation = "0123456789abcdef0123456789abcdef" + uid = 4_000_010 + ) + migratedAt := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + + for _, tc := range []struct { + name string + now time.Time + wantDrain bool + }{ + {name: "inside migration isolation window", now: migratedAt, wantDrain: true}, + {name: "after migration isolation window", now: migratedAt.Add(65 * time.Second), wantDrain: false}, + } { + t.Run(tc.name, func(t *testing.T) { + username := "xxvcc-migrate1" + if _, exists, err := user.Lookup(username); err != nil { + t.Fatal(err) + } else if exists { + t.Fatalf("test username %s already exists", username) + } + + baseDir := rootOwnedDir(t) + regDir := filepath.Join(baseDir, "registry") + if err := os.Mkdir(regDir, 0o700); err != nil { + t.Fatal(err) + } + store := ®istry.Store{ + Dir: regDir, File: filepath.Join(regDir, "registry.tsv"), Lock: filepath.Join(regDir, "registry.lock"), + Now: func() time.Time { return migratedAt }, + } + if err := os.WriteFile(store.File, []byte("# linux-temp-admin registry v4\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := store.Init(); err != nil { + t.Fatal(err) + } + store.Now = func() time.Time { return tc.now } + + a, out, errb := newTestApp(t, "") + a.Registry = store + a.Scheduler = &schedule.Scheduler{ + SystemdDir: filepath.Join(baseDir, "systemd"), InstallPath: filepath.Join(baseDir, "linux-temp-admin"), + UnitPrefix: config.AutoRevokeUnitPrefix, Sys: fakeSys{}, + } + events := []string{} + runner := &inviteTimingRunner{ + account: user.Passwd{UID: uid, GID: uid}, events: &events, registry: store, + } + a.Users = &user.Manager{ + Runner: runner, + LookupUser: runner.lookup, + PrepareManagedHome: func(string) error { return nil }, + CreateManagedHome: func(user.Passwd) error { + events = append(events, "home") + return errors.New("stop after identity isolation check") + }, + RemoveManagedMail: func(user.Passwd) error { return nil }, + RemoveManagedHome: func(user.Passwd) error { return nil }, + } + a.LookupUser = runner.lookup + a.IdentityAllocationRange = func() (int, int, error) { return uid, uid, nil } + a.Now = func() time.Time { return tc.now } + a.Scheduler.Now = a.Now + a.ClearScheduledJobs = func(string, int) error { + events = append(events, "clear") + return nil + } + a.TerminateProcesses = func(int) error { + events = append(events, "kill") + return nil + } + a.DrainScheduledJobs = func() error { + events = append(events, "drain") + return nil + } + a.RandHex = func(n int) (string, error) { + if n != 16 { + return "", fmt.Errorf("unexpected random byte count %d", n) + } + return generation, nil + } + a.RandPassword = func(int) (string, error) { return "password-for-migration-test", nil } + a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { + return sysinfo.ParseSSHD("passwordauthentication yes\n"), nil + } + + if rc := a.runInviteWithIdentityPolicy(username, "192.0.2.1", 22, 1, false, false, + loginPlan{password: true, verified: true}, true); rc != 1 { + t.Fatalf("runInviteWithIdentityPolicy rc=%d, want injected Home failure", rc) + } + joinedEvents := strings.Join(events, ",") + drainAt, homeAt := strings.Index(joinedEvents, "drain"), strings.Index(joinedEvents, "home") + drainedBeforeHome := drainAt >= 0 && homeAt >= 0 && drainAt < homeAt + if drainedBeforeHome != tc.wantDrain { + t.Fatalf("pre-Home drain called=%v, want %v; events=%v; stderr=%q", drainedBeforeHome, tc.wantDrain, events, errb.String()) + } + if tc.wantDrain && !strings.Contains(out.String(), "one-time isolation window") { + t.Fatalf("migration wait was not explained: %q", out.String()) + } + if strings.Contains(errb.String(), "rollback did not complete") { + t.Fatalf("injected failure left incomplete rollback: %q", errb.String()) + } + }) + } +} + func TestRunPermanentInviteClearsSafetyExpiry(t *testing.T) { const ( username = "xxvcc-permtime" @@ -462,6 +580,7 @@ func TestRunPermanentInviteClearsSafetyExpiry(t *testing.T) { RemoveManagedHome: func(user.Passwd) error { return nil }, } a.LookupUser = runner.lookup + a.IdentityAllocationRange = func() (int, int, error) { return uid, uid, nil } a.ClearScheduledJobs = func(string, int) error { events = append(events, "clear"); return nil } a.TerminateProcesses = func(int) error { events = append(events, "kill"); return nil } a.DrainScheduledJobs = func() error { events = append(events, "drain"); return nil } @@ -470,7 +589,7 @@ func TestRunPermanentInviteClearsSafetyExpiry(t *testing.T) { sshdConfig := sysinfo.ParseSSHD("passwordauthentication yes\n") a.SSHDConfig = func(string) (*sysinfo.SSHDConfig, error) { return sshdConfig, nil } - if rc := a.runInvite(username, "192.0.2.1", 22, 1, false, false, loginPlan{password: true, verified: true}); rc != 0 { + 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" diff --git a/internal/cli/revoke.go b/internal/cli/revoke.go index e0bfde9..171d8dd 100644 --- a/internal/cli/revoke.go +++ b/internal/cli/revoke.go @@ -6,6 +6,7 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/xxvcc/linux-temp-admin/internal/config" "github.com/xxvcc/linux-temp-admin/internal/registry" @@ -13,6 +14,8 @@ import ( "github.com/xxvcc/linux-temp-admin/internal/validate" ) +var errPersistentQuarantineUnavailable = errors.New("persistent identity quarantine unavailable") + func (a *App) revoke(args []string) int { if !a.requireRoot() { return 1 @@ -83,15 +86,18 @@ func (a *App) revoke(args []string) int { } type revokeOptions struct { - username string - confirmForce string - expectedUID int - generation string - yes bool - force bool - liveConfirmed bool - manualInvocation bool - confirmedIdentity *revokeIdentitySnapshot + username string + confirmForce string + expectedUID int + generation string + yes bool + force bool + liveConfirmed bool + manualInvocation bool + // synchronousFinalization is used only by uninstall, which cannot remove the + // installed finalizer while an account still depends on it. + synchronousFinalization bool + confirmedIdentity *revokeIdentitySnapshot } // revokeIdentitySnapshot binds an interactive full-name confirmation to the @@ -147,6 +153,7 @@ func (a *App) revokeLocked(args []string) int { return 1 } opts.liveConfirmed = true + opts.synchronousFinalization = true return a.revokeOptionsLocked(opts) } @@ -215,7 +222,7 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int { } } var cleanupErrs []error - if err := a.Scheduler.Cancel(username, rec.AutoUnit); err != nil { + if err := a.cancelAccountSchedules(username, rec); err != nil { cleanupErrs = append(cleanupErrs, err) } if err := a.removeSudoGrant(username); err != nil { @@ -251,7 +258,7 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int { // generation and persists only a UID recovery witness before artifact cleanup. pendingRecovery := registered && rec.Pending && pendingRecoveryAuthorized(opts, stdinTTY) && pendingCreationRecordMatchesPasswd(rec, pw) - if registered && rec.Pending && !pendingRecovery { + if registered && rec.Pending && !rec.DeletionStarted && !pendingRecovery { grantCleanupErr := errors.Join(a.removeSudoGrant(username), a.removeSSHDException(username)) cleanupErr := errors.Join(grantCleanupErr, a.Scheduler.Cancel(username, rec.AutoUnit)) a.errorf("%s", a.P.M( @@ -291,6 +298,9 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int { if registered && rec.DeletionStarted && rec.IdentityBound && !deletionRecordMatchesPasswd(rec, pw) { protected = true } + if registered && rec.DeletionStarted && rec.IdentityBound && deletionRecordMatchesPasswd(rec, pw) { + protected = false + } if registered && rec.DeletionStarted && !rec.IdentityBound && allowLegacy { protected = !uidOnlyDeletionCandidateMatches(rec, pw) } @@ -320,7 +330,7 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int { } if manualOnlyRecovery { if a.Scheduler != nil { - if err := a.Scheduler.Cancel(username, rec.AutoUnit); err != nil { + if err := a.cancelAccountSchedules(username, rec); err != nil { a.errorf("%s: %v", a.P.M("该恢复状态只允许人工处理,但自动删除任务未能完整解除;登记已保留,请立即人工清理任务", "this recovery state is manual-only, but its auto-delete task could not be fully cancelled; the registry witness was retained; remove the task manually"), err) } else { @@ -336,6 +346,30 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int { return 1 } + if rec.QuarantineUntil != "" { + 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 1 + } + if a.Now().Before(deadline) && !opts.synchronousFinalization { + // An old expiry task may race the new quarantine finalizer. Reassert the + // access gates, but never release the name or UID before the durable deadline. + err := errors.Join(a.removeSudoGrant(username), a.removeSSHDException(username), a.Users.DisableLogin(username)) + if err != nil { + a.errorf("%s: %v", a.P.M("账号处于身份隔离期,但无法重新确认全部访问门已关闭", "the account is quarantined, but not every access gate could be reconfirmed closed"), err) + return 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")) + return 0 + } + if a.Now().Before(deadline) { + a.info(a.P.M( + "卸载必须在移除命令前完成账号删除;将同步等待一个任务轮询周期后释放隔离身份。", + "uninstall must finish account deletion before removing the command; waiting one deferred-job polling cycle before releasing the quarantined identity.")) + } + } + // Removing grants and reloading sshd can take long enough for an out-of-band // administrator to replace the account. Do not disable one generation, signal // another UID, and then userdel a third: require the complete passwd entry to be @@ -391,12 +425,45 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int { return 1 } + // A completed generation-bound account can hand deletion to a persistent + // systemd quarantine. The live but disabled passwd entry holds both its name and + // numeric identity for one full deferred-job cycle, so the invoking terminal + // does not have to wait. Legacy/pending recovery and hosts without reliable + // systemd retain the synchronous fail-closed path below. + if registered && rec.IdentityBound && !rec.DeletionStarted && (!rec.Pending || pendingRecovery) && + (opts.manualInvocation || opts.generation != "") { + started, quarantineErr := a.beginIdentityQuarantine(rec, pw) + if started { + if quarantineErr != nil { + a.warnf("%s: %v", a.P.M("身份隔离已建立,但旧自动删除任务清理未完整确认", "identity quarantine is active, but cleanup of the old auto-delete task was not fully confirmed"), quarantineErr) + } + a.audit("account.quarantine", username, "ok", "access revoked; identity held for asynchronous deletion", nil) + return 0 + } + if quarantineErr != nil && !errors.Is(quarantineErr, errPersistentQuarantineUnavailable) { + a.errorf("%s: %v", a.P.M("无法安全建立身份隔离;账号已禁用并保留", "cannot safely establish identity quarantine; the account is disabled and retained"), quarantineErr) + return 1 + } + if quarantineErr != nil { + a.info(a.P.M("持久化身份隔离不可用;将同步等待约 65 秒后完成删除。", "persistent identity quarantine is unavailable; waiting about 65 seconds to finish deletion synchronously.")) + } + } + // Shut the door before taking the account apart. Until both expiry and password // locking land, the account may still be SSH-reachable: in particular, a failed // chage leaves public-key login open even when usermod -L succeeded. Never create // a scan-then-delete race by continuing from a partial disable. persistDeletion := func() error { return a.persistDeletionStarted(rec, registered, pw) } - stage, teardownErr := a.teardownLocalAccount(username, pw, persistDeletion) + teardown := a.teardownLocalAccount + if rec.QuarantineUntil != "" && !opts.synchronousFinalization { + teardown = a.teardownQuarantinedAccount + } else if rec.QuarantineUntil != "" { + deadline, _ := time.Parse(time.RFC3339, rec.QuarantineUntil) + if !a.Now().Before(deadline) { + teardown = a.teardownQuarantinedAccount + } + } + stage, teardownErr := teardown(username, pw, persistDeletion) switch stage { case revokeDisableLogin: a.errorf("%s: %v", a.P.M("无法完整禁用登录;保留账号、登记和自动删除任务,未终止进程或删除账号,请立即人工处理", @@ -422,7 +489,7 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int { } // Only now that the account is provably gone is the fallback safe to remove. - if err := a.Scheduler.Cancel(username, rec.AutoUnit); err != nil { + if err := a.cancelAccountSchedules(username, rec); err != nil { a.errorf("%s: %v", a.P.M("用户已删除,但自动删除任务清理失败;保留登记", "user deleted, but schedule cleanup failed; keeping the registry record"), err) return 1 } @@ -435,6 +502,77 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int { return 0 } +// cancelAccountSchedules removes expiry and identity-quarantine tasks through +// their separate namespaces. This does not depend on Scheduler.New having added +// quarantine to its inventory prefixes, which keeps injected schedulers and +// recovery paths from accidentally stranding the finalizer. +func (a *App) cancelAccountSchedules(username string, rec registry.Record) error { + if a.Scheduler == nil { + return fmt.Errorf("scheduler is not configured") + } + return errors.Join( + a.Scheduler.CancelAuto(username, rec.AutoUnit), + a.Scheduler.CancelQuarantine(username, rec.QuarantineUnit), + ) +} + +func quarantineDeadline(now time.Time) time.Time { + target := now.Add(time.Duration(config.IdentityQuarantineSeconds) * time.Second).UTC() + minute := target.Truncate(time.Minute) + if target.Equal(minute) { + return minute + } + return minute.Add(time.Minute) +} + +// beginIdentityQuarantine performs the immediate access revocation, schedules a +// separate finalizer, and only then commits the quarantine row. The finalizer +// namespace can coexist with a currently-running expiry service, closing the +// schedule handoff crash window. +func (a *App) beginIdentityQuarantine(rec registry.Record, expected user.Passwd) (bool, error) { + if a.Scheduler == nil || a.Scheduler.Sys == nil || !a.Scheduler.Sys.HasSystemctl() { + return false, errPersistentQuarantineUnavailable + } + // The finalizer executes InstallPath, not this process. A standalone newer + // binary may be running while that path is absent or still contains an older + // release that cannot read the v5 quarantine row. Establish the same stable + // command guarantee used before invite schedules auto-revoke; if it cannot be + // proved, retain the foreground synchronous deletion path below. + ensureCommand := a.ensureStableInstalled + if a.EnsureScheduledCommand != nil { + ensureCommand = a.EnsureScheduledCommand + } + if err := ensureCommand(); err != nil { + return false, fmt.Errorf("%w: finalizer command is unavailable: %v", errPersistentQuarantineUnavailable, err) + } + if err := a.Users.DisableLogin(rec.User); err != nil { + return false, err + } + if err := a.accountStillMatches(rec.User, expected); err != nil { + return false, err + } + if err := a.quiesceScheduledAccountImmediate(rec.User, expected); err != nil { + return false, err + } + deadline := quarantineDeadline(a.Now()) + unit, err := a.Scheduler.ScheduleQuarantine(rec.User, expected.UID, rec.Generation, deadline) + if err != nil { + return false, fmt.Errorf("%w: %v", errPersistentQuarantineUnavailable, err) + } + if err := a.Registry.BeginQuarantine(rec.User, expected.UID, rec.Generation, deadline, unit); err != nil { + cancelErr := a.Scheduler.CancelQuarantine(rec.User, unit) + return false, errors.Join(err, cancelErr) + } + // The quarantine timer is now the durable retry path. Failure to remove an old + // expiry task is non-fatal: an early duplicate invocation observes the deadline + // 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.")) + return true, cleanupErr +} + type revokeAccountStage uint8 const ( @@ -481,6 +619,30 @@ func (a *App) teardownLocalAccount(username string, expected user.Passwd, persis return revokeAccountRemoved, nil } +func (a *App) teardownQuarantinedAccount(username string, expected user.Passwd, persistDeletion func() error) (revokeAccountStage, error) { + if err := a.accountStillMatches(username, expected); err != nil { + return revokeDisableLogin, err + } + if err := a.Users.DisableLogin(username); err != nil { + return revokeDisableLogin, err + } + if err := a.quiesceScheduledAccountImmediate(username, expected); err != nil { + return revokeQuiesceAccount, err + } + if persistDeletion == nil { + return revokeDeleteAccount, fmt.Errorf("deletion recovery persistence is not configured") + } + if err := persistDeletion(); err != nil { + return revokeDeleteAccount, fmt.Errorf("persist deletion-started recovery state: %w", err) + } + if err := a.Users.DeleteExpected(username, expected, func() error { + return a.finalScheduledAccountCheck(username, expected) + }); err != nil { + return revokeDeleteAccount, err + } + return revokeAccountRemoved, nil +} + // uidOnlyDeletionCandidateMatches is the final local-shape check before a // legacy, unregistered, or rollback-pending account receives a UID-only deletion // witness. It is intentionally not identity proof: UID and lifecycle markers can @@ -553,7 +715,8 @@ func (a *App) persistDeletionStarted(rec registry.Record, registered bool, expec if registered { if current.User != rec.User || current.UID != rec.UID || current.Generation != rec.Generation || current.IdentityBound != rec.IdentityBound || current.Pending != rec.Pending || - current.DeletionStarted != rec.DeletionStarted { + current.DeletionStarted != rec.DeletionStarted || current.SequentialID != rec.SequentialID || + current.QuarantineUntil != rec.QuarantineUntil || current.QuarantineUnit != rec.QuarantineUnit { return fmt.Errorf("registry identity changed before deletion") } if current.IdentityBound { @@ -564,7 +727,7 @@ func (a *App) persistDeletionStarted(rec registry.Record, registered bool, expec if !deletionRecordMatchesPasswd(check, expected) { return fmt.Errorf("account no longer matches the registry deletion identity") } - if !current.Pending { + if !current.Pending || current.DeletionStarted { generation = current.Generation } } else if !uidOnlyDeletionCandidateMatches(current, expected) { @@ -607,10 +770,13 @@ func (a *App) releaseRegistryAfterCleanup(username string) error { // the passwd entry is gone: an owner-checked same-name mail spool sweep. Home is // intentionally excluded. Ordinary absent rows never call this function. func (a *App) reconcileDeletionStarted(rec registry.Record) error { - if !rec.DeletionStarted || rec.Pending || !validate.AccountID(rec.UID) || + if !rec.DeletionStarted || !validate.AccountID(rec.UID) || (rec.IdentityBound != validate.Generation(rec.Generation)) { return fmt.Errorf("incomplete deletion-started registry state") } + if rec.Pending && (rec.QuarantineUntil == "" || !rec.IdentityBound) { + return fmt.Errorf("incomplete pending deletion quarantine") + } if a.Users == nil { return fmt.Errorf("account manager is not configured") } @@ -667,6 +833,42 @@ func (a *App) quiesceScheduledAccount(username string, expected user.Passwd) err return nil } +// quiesceScheduledAccountImmediate closes process/job races without waiting for +// a daemon polling cycle. It is safe for a fresh monotonic identity and for the +// final pass after a live passwd entry has already held an old identity through +// the complete quarantine window. +func (a *App) quiesceScheduledAccountImmediate(username string, expected user.Passwd) error { + if err := a.accountStillMatches(username, expected); err != nil { + return err + } + var errs []error + if err := a.terminateProcesses(expected.UID); err != nil { + errs = append(errs, fmt.Errorf("process termination: %w", err)) + } + if err := a.accountStillMatches(username, expected); err != nil { + return errors.Join(errors.Join(errs...), err) + } + if err := a.clearScheduledJobs(username, expected.UID); err != nil { + errs = append(errs, fmt.Errorf("scheduled-job cleanup: %w", err)) + } + if err := a.accountStillMatches(username, expected); err != nil { + return errors.Join(errors.Join(errs...), err) + } + if err := a.terminateProcesses(expected.UID); err != nil { + errs = append(errs, fmt.Errorf("final process termination: %w", err)) + } + if err := a.accountStillMatches(username, expected); err != nil { + return errors.Join(errors.Join(errs...), err) + } + if err := a.clearScheduledJobs(username, expected.UID); err != nil { + errs = append(errs, fmt.Errorf("final scheduled-job cleanup: %w", err)) + } + if err := a.accountStillMatches(username, expected); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) +} + // finalScheduledAccountCheck runs after controlled Home/mail cleanup and just // before userdel. The earlier drain already waited out daemon-side cached work; // this last pass terminates processes first, then closes jobs raced in during diff --git a/internal/cli/revoke_test.go b/internal/cli/revoke_test.go index d7bdaae..fb3b3d6 100644 --- a/internal/cli/revoke_test.go +++ b/internal/cli/revoke_test.go @@ -2,6 +2,7 @@ package cli import ( "errors" + "os" "path/filepath" "strings" "testing" @@ -22,9 +23,10 @@ type orderedTeardownRunner struct { type revokeTestScheduleSystem struct { removeAtCalls *int + hasSystemctl bool } -func (revokeTestScheduleSystem) HasSystemctl() bool { return false } +func (s revokeTestScheduleSystem) HasSystemctl() bool { return s.hasSystemctl } func (revokeTestScheduleSystem) Systemctl(...string) error { return nil } func (revokeTestScheduleSystem) HasAt() bool { return true } func (revokeTestScheduleSystem) ScheduleAt(string, time.Time) (string, error) { return "1", nil } @@ -438,6 +440,158 @@ func TestInteractivePendingCreationRecoveryPersistsUIDWitnessBeforeUserdel(t *te } } +func TestInteractivePendingRecoveryReturnsAfterDurableIdentityQuarantine(t *testing.T) { + requireRootRegistryFixture(t) + const ( + username = "xxvcc-pendingq" + generation = "0123456789abcdef0123456789abcdef" + ) + pw := user.Passwd{ + Name: username, UID: 1001, GID: 1001, + GECOS: config.PendingGenerationGECOSPrefix + generation, + Home: "/home/" + username, Shell: "/bin/sh", + } + rec := registry.Record{ + User: username, Generation: generation, IdentityBound: true, Pending: true, Port: 22, + } + a, _, _ := newTestApp(t, "") + a.StdinIsTTY = func() bool { return true } + setTestRegistryRecord(t, a, rec) + + present := true + events := []string{} + lookup := func(string) (user.Passwd, bool, error) { + if !present { + return user.Passwd{}, false, nil + } + return pw, true, nil + } + runner := &orderedTeardownRunner{events: &events, present: &present} + a.Users = &user.Manager{ + Runner: runner, LookupUser: lookup, + NameInUse: func(string) (bool, error) { return false, nil }, + RemoveManagedMail: func(user.Passwd) error { events = append(events, "mail"); return nil }, + RemoveManagedHome: func(user.Passwd) error { events = append(events, "home"); return nil }, + } + a.LookupUser = lookup + a.TerminateProcesses = func(int) error { events = append(events, "kill"); return nil } + a.ClearScheduledJobs = func(string, int) error { events = append(events, "clear"); return nil } + a.DrainScheduledJobs = func() error { events = append(events, "drain"); return nil } + now := time.Date(2026, 8, 1, 12, 0, 1, 0, time.UTC) + a.Now = func() time.Time { return now } + a.Scheduler = &schedule.Scheduler{ + SystemdDir: t.TempDir(), InstallPath: t.TempDir() + "/linux-temp-admin", + UnitPrefix: config.AutoRevokeUnitPrefix, Now: a.Now, + Sys: revokeTestScheduleSystem{hasSystemctl: true}, + } + ensureCalls := 0 + a.EnsureScheduledCommand = func() error { + ensureCalls++ + return nil + } + + if rc := a.revokeOptionsLocked(revokeOptions{ + username: username, force: true, manualInvocation: true, liveConfirmed: true, + }); rc != 0 { + t.Fatalf("pending quarantine revoke rc = %d", rc) + } + stored, found, err := a.Registry.Lookup(username) + if err != nil || !found || !stored.Pending || !stored.DeletionStarted || stored.UID != pw.UID || + stored.QuarantineUnit != config.QuarantineUnitPrefix+username { + t.Fatalf("pending quarantine state: found=%v rec=%+v err=%v", found, stored, err) + } + if !present || strings.Contains(strings.Join(events, ","), "drain") || strings.Contains(strings.Join(events, ","), "userdel") { + t.Fatalf("foreground pending revoke did not return in quarantine: present=%v events=%v", present, events) + } + if ensureCalls != 1 { + t.Fatalf("stable finalizer command preflight calls = %d, want 1", ensureCalls) + } + if got := strings.Join(events, ","); !strings.Contains(got, "kill,clear,kill,clear") { + t.Fatalf("immediate quarantine cleanup missed its final queue pass: %v", events) + } + + deadline, err := time.Parse(time.RFC3339, stored.QuarantineUntil) + if err != nil { + t.Fatal(err) + } + scheduled := revokeOptions{ + username: username, yes: true, force: true, confirmForce: username, + expectedUID: pw.UID, generation: generation, + } + now = deadline.Add(-time.Second) + if rc := a.revokeOptionsLocked(scheduled); rc != 0 || !present { + t.Fatalf("pre-deadline finalizer rc=%d present=%v", rc, present) + } + now = deadline.Add(time.Second) + if rc := a.revokeOptionsLocked(scheduled); rc != 0 || present { + t.Fatalf("mature finalizer rc=%d present=%v events=%v", rc, present, events) + } + if found, err := a.Registry.Contains(username); err != nil || found { + t.Fatalf("mature quarantine retained registry: found=%v err=%v", found, err) + } + for _, suffix := range []string{".service", ".timer"} { + path := filepath.Join(a.Scheduler.SystemdDir, config.QuarantineUnitPrefix+username+suffix) + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("mature quarantine retained %s: %v", path, err) + } + } +} + +func TestRevokeFallsBackToSynchronousDeletionWhenFinalizerCommandIsUnavailable(t *testing.T) { + requireRootRegistryFixture(t) + const ( + username = "xxvcc-commandq" + generation = "0123456789abcdef0123456789abcdef" + ) + pw := user.Passwd{ + Name: username, UID: 1001, GID: 1001, + GECOS: config.ManagedGenerationGECOSPrefix + generation, + Home: "/home/" + username, Shell: "/bin/sh", + } + a, _, _ := newTestApp(t, "") + ordered, events, present := newOrderedTeardownApp(t, pw, 0, nil) + a.Users = ordered.Users + a.LookupUser = ordered.LookupUser + a.ClearScheduledJobs = ordered.ClearScheduledJobs + a.DrainScheduledJobs = ordered.DrainScheduledJobs + a.TerminateProcesses = ordered.TerminateProcesses + a.StdinIsTTY = func() bool { return true } + setTestRegistryRecord(t, a, registry.Record{ + User: username, UID: pw.UID, Port: 22, Generation: generation, IdentityBound: true, + }) + a.Scheduler = &schedule.Scheduler{ + SystemdDir: t.TempDir(), InstallPath: t.TempDir() + "/linux-temp-admin", + UnitPrefix: config.AutoRevokeUnitPrefix, Now: time.Now, + Sys: revokeTestScheduleSystem{hasSystemctl: true}, + } + preflightErr := errors.New("installed command is missing") + a.EnsureScheduledCommand = func() error { return preflightErr } + + if rc := a.revokeOptionsLocked(revokeOptions{ + username: username, manualInvocation: true, liveConfirmed: true, + }); rc != 0 { + t.Fatalf("synchronous fallback revoke rc = %d", rc) + } + if *present { + t.Fatal("synchronous fallback retained the account") + } + if got := strings.Join(*events, ","); !strings.Contains(got, "drain") || !strings.Contains(got, "userdel") { + t.Fatalf("synchronous fallback events = %v, want drain and userdel", *events) + } + if found, err := a.Registry.Contains(username); err != nil || found { + t.Fatalf("synchronous fallback retained registry row: found=%v err=%v", found, err) + } +} + +func TestImmediateQuiescenceEndsWithASecondQueueSweep(t *testing.T) { + pw := user.Passwd{Name: "xxvcc-immediate", UID: 1001, GID: 1001, Home: "/home/xxvcc-immediate", Shell: "/bin/sh"} + a, events, _ := newOrderedTeardownApp(t, pw, 0, nil) + if err := a.quiesceScheduledAccountImmediate(pw.Name, pw); err != nil { + t.Fatal(err) + } + requireTeardownEvents(t, *events, "kill", "clear", "kill", "clear") +} + func TestUnregisteredDeletionWitnessWriteFailureBlocksUserdel(t *testing.T) { requireRootRegistryFixture(t) const username = "xxvcc-recovery2" diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go index 007338d..3c573cf 100644 --- a/internal/cli/uninstall.go +++ b/internal/cli/uninstall.go @@ -554,7 +554,7 @@ func liveTeardownAccountAuthorized(acc teardownAccount) bool { return false } state := classifyRegisteredAccount(acc.registryRecord, acc.passwd, true, nil) - return state == registeredActive || state == registeredRecoveryBound + return state == registeredActive || state == registeredQuarantine || state == registeredRecoveryBound } func sameTeardownPlan(a, b teardownPlan) bool { diff --git a/internal/cli/uninstall_test.go b/internal/cli/uninstall_test.go index f4f8a5e..f241fd2 100644 --- a/internal/cli/uninstall_test.go +++ b/internal/cli/uninstall_test.go @@ -289,3 +289,24 @@ func TestV1RegistryUsersAcceptsHistoricalTabSeparatedRows(t *testing.T) { t.Fatalf("v1RegistryUsers = %v, want %v", users, want) } } + +func TestQuarantinedAccountRemainsAuthorizedForSynchronousUninstallCleanup(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + pw := user.Passwd{ + Name: "xxvcc-quarantine", UID: 1001, GID: 1001, + GECOS: config.ManagedGenerationGECOSPrefix + generation, + Home: "/home/xxvcc-quarantine", Shell: "/bin/sh", + } + rec := registry.Record{ + User: pw.Name, UID: pw.UID, Port: 22, Generation: generation, IdentityBound: true, + DeletionStarted: true, QuarantineUntil: "2026-08-01T12:02:00Z", + QuarantineUnit: config.QuarantineUnitPrefix + pw.Name, + } + acc := teardownAccount{ + name: pw.Name, exists: true, registryFound: true, registryRecord: rec, passwd: pw, + witnesses: []witness{witnessRegistry}, + } + if !liveTeardownAccountAuthorized(acc) { + t.Fatal("a valid quarantined account could not be synchronously finalized by uninstall") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 18d64d8..53c5d5d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,13 +66,18 @@ const ( RegistryFile = RegistryDir + "/registry.tsv" // RegistryLockFile is the flock file for registry mutations. RegistryLockFile = RegistryDir + "/registry.lock" + // IdentitySequenceFile stores the highest numeric UID/GID reserved by the + // monotonic allocator. Once the registry is migrated to the matching schema, + // this file is mandatory: recreating it from only the currently-live accounts + // could reuse an identity that was retired after the last registry compaction. + IdentitySequenceFile = RegistryDir + "/identity-sequence" // PrefsFile holds the operator's remembered UI choices (currently just the // language). It shares the tool's state directory rather than /etc: it is a // convenience the tool wrote for itself, not configuration an operator is // expected to hand-edit or ship in a config-management repo. PrefsFile = RegistryDir + "/prefs" // RegistrySchema is written as the registry header's version marker. - RegistrySchema = 4 + RegistrySchema = 5 // AuditLogDir holds the best-effort JSONL operation audit log (root:root, 0700). AuditLogDir = "/var/log/" + ManagedTag @@ -93,6 +98,14 @@ const ( // load-bearing: it is baked into the unit filenames already written on // deployed hosts, so changing it would orphan their auto-revoke timers. AutoRevokeUnitPrefix = ManagedTag + "-v2-revoke-" + // QuarantineUnitPrefix names the short-lived persistent timers that finish an + // already-disabled account deletion after the identity-reuse quarantine. + QuarantineUnitPrefix = ManagedTag + "-v2-quarantine-" + // IdentityQuarantineSeconds covers one complete cron/at polling cycle plus a + // small margin. Normal creation avoids this delay with monotonic UID/GID + // allocation; revoke holds the disabled passwd identity for this interval and + // completes deletion asynchronously when systemd is available. + IdentityQuarantineSeconds = 65 // ReleaseMirrorBaseURL is the official mirror used for normal installation and // upgrades. latest.json selects one immutable version directory below it. diff --git a/internal/registry/record.go b/internal/registry/record.go index 6fc7092..bb60141 100644 --- a/internal/registry/record.go +++ b/internal/registry/record.go @@ -10,23 +10,26 @@ import ( "fmt" "strconv" "strings" + "time" + "github.com/xxvcc/linux-temp-admin/internal/config" "github.com/xxvcc/linux-temp-admin/internal/validate" ) -// Header is the current registry schema. v4 adds a durable deletion phase so a -// failed post-userdel mail cleanup can be resumed without treating every stale -// row as authority to remove name-scoped data. -const Header = "# linux-temp-admin registry v4" +// Header is the current registry schema. v5 records monotonic identity +// allocation and the durable asynchronous deletion quarantine. +const Header = "# linux-temp-admin registry v5" const legacyHeaderV2 = "# linux-temp-admin registry v2" const legacyHeaderV3 = "# linux-temp-admin registry v3" +const legacyHeaderV4 = "# linux-temp-admin registry v4" const ( legacyFieldCount = 9 legacyMaxFieldCount = 11 legacyV3FieldCount = 13 - currentFieldCount = 14 + legacyV4FieldCount = 14 + currentFieldCount = 17 ) // Record is one managed temporary account. @@ -55,11 +58,12 @@ type Record struct { // in its passwd GECOS marker. Migrated v2 rows remain false even when they have // a generation column: released v2 accounts used one shared fixed marker. IdentityBound bool - // Pending marks a creation intent written before useradd. It is cleared only - // after the new account's UID has been read and durably recorded. A pending row - // alone cannot prove the identity of a live same-name account and must never - // authorize unattended deletion; the creating process separately retains its - // complete passwd snapshot for an immediate rollback. + // Pending marks both a creation intent written before useradd and the pending + // generation marker left in passwd. It is normally cleared after creation. A + // pending row alone cannot authorize unattended deletion; after a direct + // interactive recovery proves its full account shape, BeginQuarantine may add a + // deletion witness while retaining this bit so later retries verify the right + // marker. Pending bool // DeletionStarted is written after the destructive identity policy and the // pre-artifact quiescence checks, before controlled mail/Home cleanup and @@ -70,6 +74,18 @@ type Record struct { // non-pending UID-only recovery rows. An ordinary stale row for an account // removed outside the tool leaves this false. DeletionStarted bool + // SequentialID proves that this account received an explicitly reserved, + // monotonically increasing UID/GID pair. Generated-name invites with this bit + // can skip the daemon polling delay because neither numeric identity is reused. + SequentialID bool + // QuarantineUntil is the UTC RFC3339 deadline after which a disabled live + // account may be removed without another cron/at polling delay. The account + // itself holds both its name and numeric identity until that deadline. + QuarantineUntil string + // QuarantineUnit records the persistent systemd timer that finishes deletion. + // It is separate from AutoUnit so the expiry task and the quarantine handoff + // cannot overwrite one another during a crash. + QuarantineUnit string } var fieldSanitizer = strings.NewReplacer("\t", " ", "\r", " ", "\n", " ") @@ -84,12 +100,15 @@ func boolYN(b bool) string { return "no" } -// Column indexes are retained while migrating deployed v2/v3 rows to v4. +// Column indexes are retained while migrating deployed v2/v3/v4 rows to v5. const uidField = 9 const generationField = 10 const pendingField = 11 const identityBoundField = 12 const deletionStartedField = 13 +const sequentialIDField = 14 +const quarantineUntilField = 15 +const quarantineUnitField = 16 // TSV renders the record as one tab-separated line (no trailing newline). func (r Record) TSV() string { @@ -108,12 +127,15 @@ func (r Record) TSV() string { boolYN(r.Pending), boolYN(r.IdentityBound), boolYN(r.DeletionStarted), + boolYN(r.SequentialID), + sanitize(r.QuarantineUntil), + sanitize(r.QuarantineUnit), }, "\t") } // ParseLine parses a current-schema registry line. It returns ok=false only for // the exact current header and blank lines. Every other non-empty line, including -// one beginning with '#', must be a valid 14-column record or is corruption. +// one beginning with '#', must be a valid 17-column record or is corruption. func ParseLine(line string) (Record, bool, error) { if line == "" || line == Header { return Record{}, false, nil @@ -135,6 +157,13 @@ func parseLegacyV3Line(line string) (Record, bool, error) { return parseFields(line, legacyV3FieldCount, legacyV3FieldCount) } +func parseLegacyV4Line(line string) (Record, bool, error) { + if line == "" { + return Record{}, false, nil + } + return parseFields(line, legacyV4FieldCount, legacyV4FieldCount) +} + func parseFields(line string, minFields, maxFields int) (Record, bool, error) { f := strings.Split(line, "\t") if len(f) < minFields || len(f) > maxFields { @@ -194,6 +223,18 @@ func parseFields(line string, minFields, maxFields int) (Record, bool, error) { } rec.DeletionStarted = f[deletionStartedField] == "yes" } + if len(f) > sequentialIDField { + if f[sequentialIDField] != "yes" && f[sequentialIDField] != "no" { + return Record{}, false, fmt.Errorf("invalid sequential-id field %q", f[sequentialIDField]) + } + rec.SequentialID = f[sequentialIDField] == "yes" + } + if len(f) > quarantineUntilField { + rec.QuarantineUntil = f[quarantineUntilField] + } + if len(f) > quarantineUnitField { + rec.QuarantineUnit = f[quarantineUnitField] + } // UID-only recovery rows created for an unregistered account have no honest // endpoint metadata to preserve. Port 0 is reserved for exactly that state; // every ordinary and migrated account row still requires a real SSH port. @@ -209,11 +250,30 @@ func parseFields(line string, minFields, maxFields int) (Record, bool, error) { if rec.DeletionStarted && !validate.AccountID(rec.UID) { return Record{}, false, fmt.Errorf("deletion-started record has no valid uid") } - if rec.DeletionStarted && rec.Pending { - return Record{}, false, fmt.Errorf("deletion-started record cannot remain pending") - } if rec.DeletionStarted && !rec.IdentityBound && rec.Generation != "" { return Record{}, false, fmt.Errorf("uid-only deletion-started record carries a generation") } + if rec.SequentialID && (!rec.IdentityBound || !validate.AccountID(rec.UID)) { + return Record{}, false, fmt.Errorf("sequential identity record is not safely identity-bound") + } + if rec.QuarantineUntil != "" { + deadline, err := time.Parse(time.RFC3339, rec.QuarantineUntil) + if err != nil || deadline.Location() != time.UTC { + return Record{}, false, fmt.Errorf("invalid quarantine deadline %q", rec.QuarantineUntil) + } + if !rec.DeletionStarted || !rec.IdentityBound { + return Record{}, false, fmt.Errorf("quarantine requires an identity-bound deletion row") + } + if rec.QuarantineUnit == "" { + return Record{}, false, fmt.Errorf("quarantine has no scheduled finalizer") + } + if rec.QuarantineUnit != config.QuarantineUnitPrefix+rec.User { + return Record{}, false, fmt.Errorf("invalid quarantine unit %q", rec.QuarantineUnit) + } + } else if rec.QuarantineUnit != "" { + return Record{}, false, fmt.Errorf("quarantine unit has no deadline") + } else if rec.DeletionStarted && rec.Pending { + return Record{}, false, fmt.Errorf("pending deletion recovery requires a durable quarantine") + } return rec, true, nil } diff --git a/internal/registry/record_test.go b/internal/registry/record_test.go index ed921ff..7d32070 100644 --- a/internal/registry/record_test.go +++ b/internal/registry/record_test.go @@ -4,6 +4,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/xxvcc/linux-temp-admin/internal/config" ) @@ -120,7 +121,7 @@ func TestParseLineRequiresUIDAndNonPendingStateForDeletionStarted(t *testing.T) {User: "xxvcc-a1", Port: 22, UID: 1001, Pending: true, DeletionStarted: true}, {User: "xxvcc-a1", Port: 22, UID: 1001, Generation: "0123456789abcdef0123456789abcdef", DeletionStarted: true}, } { - if _, _, err := ParseLine(rec.TSV()); err == nil || !strings.Contains(err.Error(), "deletion-started") { + if _, _, err := ParseLine(rec.TSV()); err == nil { t.Fatalf("ParseLine(%+v) error = %v, want incomplete deletion witness refusal", rec, err) } } @@ -144,6 +145,51 @@ func TestParseLineRequiresUIDAndNonPendingStateForDeletionStarted(t *testing.T) } } +func TestParseLineAcceptsOnlyDurablePendingDeletionQuarantine(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + rec := Record{ + User: "xxvcc-pending", Port: 22, UID: 1001, Generation: generation, + IdentityBound: true, Pending: true, DeletionStarted: true, + QuarantineUntil: time.Date(2026, 8, 1, 12, 2, 0, 0, time.UTC).Format(time.RFC3339), + QuarantineUnit: config.QuarantineUnitPrefix + "xxvcc-pending", + } + got, ok, err := ParseLine(rec.TSV()) + if err != nil || !ok || got != rec { + t.Fatalf("pending quarantine round trip = ok %v record %+v err %v", ok, got, err) + } + + for _, mutate := range []func(*Record){ + func(r *Record) { r.QuarantineUntil = ""; r.QuarantineUnit = "" }, + func(r *Record) { r.IdentityBound = false; r.Generation = "" }, + func(r *Record) { r.QuarantineUnit = config.QuarantineUnitPrefix + "xxvcc-other" }, + } { + bad := rec + mutate(&bad) + if _, _, err := ParseLine(bad.TSV()); err == nil { + t.Fatalf("invalid pending quarantine was accepted: %+v", bad) + } + } +} + +func TestParseLineRequiresSequentialIdentityBinding(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + valid := Record{ + User: "xxvcc-sequence", Port: 22, UID: 1001, Generation: generation, + IdentityBound: true, SequentialID: true, + } + if _, ok, err := ParseLine(valid.TSV()); err != nil || !ok { + t.Fatalf("valid sequential identity rejected: ok=%v err=%v", ok, err) + } + for _, bad := range []Record{ + {User: "xxvcc-sequence", Port: 22, UID: 1001, SequentialID: true}, + {User: "xxvcc-sequence", Port: 22, Generation: generation, IdentityBound: true, SequentialID: true, Pending: true}, + } { + if _, _, err := ParseLine(bad.TSV()); err == nil { + t.Fatalf("unbound sequential identity accepted: %+v", bad) + } + } +} + func TestParseLineRejectsReservedLinuxUID(t *testing.T) { if strconv.IntSize < 64 { t.Skip("int cannot represent the reserved uint32 uid sentinel") @@ -208,7 +254,7 @@ func TestParseLegacyV3RowDefaultsDeletionPhase(t *testing.T) { func TestV4SchemaStopsOlderWriters(t *testing.T) { line := Record{User: "xxvcc-a1", Port: 22, UID: 1001, AutoUnit: "u.timer", Pending: true}.TSV() f := strings.Split(line, "\t") - if Header == legacyHeaderV2 || Header == legacyHeaderV3 { + if Header == legacyHeaderV2 || Header == legacyHeaderV3 || Header == legacyHeaderV4 { t.Fatal("current and legacy registry headers must differ") } if len(f) != currentFieldCount { @@ -220,4 +266,7 @@ func TestV4SchemaStopsOlderWriters(t *testing.T) { if _, _, err := parseLegacyV3Line(line); err == nil { t.Fatal("v3 parser accepted a v4 row and could silently discard deletion state") } + if _, _, err := parseLegacyV4Line(line); err == nil { + t.Fatal("v4 parser accepted a v5 row and could silently discard identity quarantine state") + } } diff --git a/internal/registry/sequence.go b/internal/registry/sequence.go new file mode 100644 index 0000000..a320e1b --- /dev/null +++ b/internal/registry/sequence.go @@ -0,0 +1,138 @@ +package registry + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + "syscall" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/fsutil" + "github.com/xxvcc/linux-temp-admin/internal/validate" +) + +const ( + identitySequenceHeader = "# linux-temp-admin identity sequence v1" + maxSequenceBytes = int64(4 << 10) +) + +type identitySequence struct { + highest int + safeAfter time.Time +} + +func identitySequenceBytes(sequence identitySequence) []byte { + safeAfter := "none" + if !sequence.safeAfter.IsZero() { + safeAfter = sequence.safeAfter.UTC().Format(time.RFC3339) + } + return []byte(fmt.Sprintf("%s\nhighest\t%d\nsafe-after\t%s\n", identitySequenceHeader, sequence.highest, safeAfter)) +} + +// ensureIdentitySequence creates or advances the durable allocation high-water +// mark. allowCreate is false after a v5 header has become visible: absence then +// means retired identity history was lost and must fail closed. +func (s *Store) ensureIdentitySequence(seed int, allowCreate bool, safeAfter time.Time) error { + if seed < 0 || !validate.KernelID(seed) { + return fmt.Errorf("invalid identity sequence seed %d", seed) + } + if !safeAfter.IsZero() { + safeAfter = safeAfter.UTC().Truncate(time.Second) + } + path := s.sequencePath() + sequence, err := readIdentitySequence(path) + if os.IsNotExist(err) { + if !allowCreate { + return fmt.Errorf("identity sequence %s is missing for a v5 registry", path) + } + return fsutil.WriteRootFile(path, identitySequenceBytes(identitySequence{highest: seed, safeAfter: safeAfter}), 0o600) + } + if err != nil { + return err + } + changed := false + if sequence.highest < seed { + sequence.highest = seed + changed = true + } + if sequence.safeAfter.Before(safeAfter) { + sequence.safeAfter = safeAfter + changed = true + } + if !changed { + return nil + } + return fsutil.WriteRootFile(path, identitySequenceBytes(sequence), 0o600) +} + +func readIdentitySequence(path string) (identitySequence, error) { + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) + if err != nil { + return identitySequence{}, err + } + defer f.Close() + if err := requireRootFileFD(path, f, 0o600); err != nil { + return identitySequence{}, err + } + b, err := io.ReadAll(io.LimitReader(f, maxSequenceBytes+1)) + if err != nil { + return identitySequence{}, err + } + if int64(len(b)) > maxSequenceBytes { + return identitySequence{}, fmt.Errorf("identity sequence exceeds %d bytes", maxSequenceBytes) + } + lines := strings.Split(string(b), "\n") + if len(lines) != 4 || lines[0] != identitySequenceHeader || lines[3] != "" { + return identitySequence{}, fmt.Errorf("identity sequence is malformed") + } + highestFields := strings.Split(lines[1], "\t") + safeFields := strings.Split(lines[2], "\t") + if len(highestFields) != 2 || highestFields[0] != "highest" || len(safeFields) != 2 || safeFields[0] != "safe-after" { + return identitySequence{}, fmt.Errorf("identity sequence is malformed") + } + highest, err := strconv.Atoi(highestFields[1]) + if err != nil || !validate.KernelID(highest) { + return identitySequence{}, fmt.Errorf("identity sequence has invalid high-water mark %q", highestFields[1]) + } + var safeAfter time.Time + if safeFields[1] != "none" { + safeAfter, err = time.Parse(time.RFC3339, safeFields[1]) + if err != nil || safeAfter.Location() != time.UTC { + return identitySequence{}, fmt.Errorf("identity sequence has invalid isolation deadline %q", safeFields[1]) + } + } + return identitySequence{highest: highest, safeAfter: safeAfter}, nil +} + +// ReserveIdentity durably burns and returns a UID/GID pair. minimum is the +// first currently-free local account ID and maximum is the configured upper +// bound shared by UID and GID allocation. The write commits before useradd, so +// a crash can waste an ID but can never make a later invite reuse it. +func (s *Store) ReserveIdentity(minimum, maximum int) (reserved int, isolated bool, err error) { + if !validate.AccountID(minimum) || !validate.AccountID(maximum) || minimum > maximum { + return 0, false, fmt.Errorf("invalid identity allocation range %d..%d", minimum, maximum) + } + err = s.withLock(func() error { + sequence, err := readIdentitySequence(s.sequencePath()) + if err != nil { + return err + } + candidate := sequence.highest + 1 + if candidate < minimum { + candidate = minimum + } + if candidate > maximum || !validate.AccountID(candidate) { + return fmt.Errorf("monotonic UID/GID range %d..%d is exhausted", minimum, maximum) + } + sequence.highest = candidate + if err := fsutil.WriteRootFile(s.sequencePath(), identitySequenceBytes(sequence), 0o600); err != nil { + return err + } + reserved = candidate + isolated = sequence.safeAfter.IsZero() || !s.now().Before(sequence.safeAfter) + return nil + }) + return reserved, isolated, err +} diff --git a/internal/registry/store.go b/internal/registry/store.go index faea45a..8857858 100644 --- a/internal/registry/store.go +++ b/internal/registry/store.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "syscall" + "time" "github.com/xxvcc/linux-temp-admin/internal/config" "github.com/xxvcc/linux-temp-admin/internal/fsutil" @@ -20,14 +21,19 @@ const maxRegistryBytes = int64(16 << 20) // be absolute; File and Lock must be distinct direct children. Paths are fields // so tests can point the complete layout at a temporary directory. type Store struct { - Dir string - File string - Lock string + Dir string + File string + Lock string + Sequence string + Now func() time.Time } // Default returns a Store using the configured registry paths. func Default() *Store { - return &Store{Dir: config.RegistryDir, File: config.RegistryFile, Lock: config.RegistryLockFile} + return &Store{ + Dir: config.RegistryDir, File: config.RegistryFile, Lock: config.RegistryLockFile, + Sequence: config.IdentitySequenceFile, Now: time.Now, + } } // Init creates the registry directory (0700 root), the registry file (with the @@ -80,9 +86,22 @@ func (s *Store) Init() error { return &fsutil.DurabilityError{Operation: "registry lock directory entry", Err: err} } + // The sequence is committed before a fresh v5 registry or a legacy-to-v5 + // migration. Once the v5 header is visible, a missing sequence is corruption: + // recreating it from only live rows could reuse an already-retired identity. + _, registryErr := os.Lstat(s.File) + registryMissing := os.IsNotExist(registryErr) + if registryErr != nil && !registryMissing { + return registryErr + } + if registryMissing { + if err := s.ensureIdentitySequence(0, true, time.Time{}); err != nil { + return err + } + } // Upgrade deployed registries only while holding their lock. New writes use a - // v4 header that older binaries reject, preventing them from dropping the - // deletion recovery phase during a delayed rewrite. + // v5 header that older binaries reject, preventing them from dropping the + // deletion quarantine or monotonic identity marker during a delayed rewrite. if err := ensureFile(s.File, []byte(Header+"\n")); err != nil { return err } @@ -90,10 +109,27 @@ func (s *Store) Init() error { if err != nil { return err } - if header == legacyHeaderV2 || header == legacyHeaderV3 { + if header == legacyHeaderV2 || header == legacyHeaderV3 || header == legacyHeaderV4 { + highest := 0 + for _, rec := range recs { + if rec.UID > highest { + highest = rec.UID + } + } + safeAfter := s.now().Add(time.Duration(config.IdentityQuarantineSeconds) * time.Second).UTC() + if err := s.ensureIdentitySequence(highest, true, safeAfter); err != nil { + return err + } return s.writeAll(recs) } - return nil + return s.ensureIdentitySequence(0, false, time.Time{}) +} + +func (s *Store) now() time.Time { + if s != nil && s.Now != nil { + return s.Now() + } + return time.Now() } func (s *Store) validateLayout() error { @@ -104,18 +140,29 @@ func (s *Store) validateLayout() error { if s.Dir == "" || !filepath.IsAbs(s.Dir) || dir != s.Dir || dir == string(filepath.Separator) { return fmt.Errorf("unsafe registry directory %q", s.Dir) } - for label, path := range map[string]string{"file": s.File, "lock": s.Lock} { + sequence := s.sequencePath() + for label, path := range map[string]string{"file": s.File, "lock": s.Lock, "sequence": sequence} { clean := filepath.Clean(path) if path == "" || !filepath.IsAbs(path) || clean != path || filepath.Dir(clean) != dir || clean == dir { return fmt.Errorf("registry %s %q must be a direct child of %s", label, path, dir) } } - if s.File == s.Lock { - return fmt.Errorf("registry file and lock must be different paths") + if s.File == s.Lock || s.File == sequence || s.Lock == sequence { + return fmt.Errorf("registry file, lock, and identity sequence must be different paths") } return nil } +func (s *Store) sequencePath() string { + if s != nil && s.Sequence != "" { + return s.Sequence + } + if s == nil || s.Dir == "" { + return "" + } + return filepath.Join(s.Dir, "identity-sequence") +} + func openOrCreateLockAt(dir *os.File, name, path string) (*os.File, error) { if dir == nil || name == "" || filepath.Base(name) != name || name == "." || name == ".." { return nil, fmt.Errorf("unsafe registry lock name %q", name) @@ -207,7 +254,7 @@ func (s *Store) readAllWithHeader() ([]Record, string, error) { return nil, "", fmt.Errorf("registry exceeds %d bytes", maxRegistryBytes) } lines := strings.Split(string(b), "\n") - if len(lines) == 0 || (lines[0] != Header && lines[0] != legacyHeaderV3 && lines[0] != legacyHeaderV2) { + if len(lines) == 0 || (lines[0] != Header && lines[0] != legacyHeaderV4 && lines[0] != legacyHeaderV3 && lines[0] != legacyHeaderV2) { return nil, "", fmt.Errorf("registry header is missing or unsupported") } header := lines[0] @@ -215,7 +262,7 @@ func (s *Store) readAllWithHeader() ([]Record, string, error) { seenUsers := make(map[string]int) for i, line := range lines[1:] { lineNumber := i + 2 - if line == Header || line == legacyHeaderV3 || line == legacyHeaderV2 { + if line == Header || line == legacyHeaderV4 || line == legacyHeaderV3 || line == legacyHeaderV2 { return nil, "", fmt.Errorf("registry line %d: duplicate schema header", lineNumber) } var r Record @@ -224,6 +271,8 @@ func (s *Store) readAllWithHeader() ([]Record, string, error) { switch header { case Header: r, ok, err = ParseLine(line) + case legacyHeaderV4: + r, ok, err = parseLegacyV4Line(line) case legacyHeaderV3: r, ok, err = parseLegacyV3Line(line) case legacyHeaderV2: @@ -367,6 +416,61 @@ func (s *Store) BeginDeletion(user string, uid int, generation string) error { }) } +// BeginQuarantine converts an exact generation-bound account into a durable +// deletion row while the disabled passwd entry still holds its name, UID, and +// GID. A pending creation recovery may enter only after its caller has proved the +// complete pending passwd shape; keeping Pending records which marker retries +// must verify. The systemd finalizer may complete deletion after one full +// deferred-job polling window without keeping the invoking terminal blocked. +func (s *Store) BeginQuarantine(user string, uid int, generation string, deadline time.Time, unit string) error { + if err := validateDeletionIdentity(user, uid, generation); err != nil || generation == "" { + return fmt.Errorf("invalid quarantine identity") + } + if deadline.IsZero() || deadline.Location() != time.UTC || deadline.Nanosecond() != 0 { + return fmt.Errorf("invalid quarantine deadline") + } + if unit != config.QuarantineUnitPrefix+user { + return fmt.Errorf("invalid quarantine unit %q", unit) + } + return s.withLock(func() error { + recs, err := s.readAll() + if err != nil { + return err + } + out := append([]Record(nil), recs...) + for i := range out { + current := out[i] + if current.User != user { + continue + } + deadlineText := deadline.Format(time.RFC3339) + if current.DeletionStarted { + if current.UID == uid && current.IdentityBound && current.Generation == generation && + current.QuarantineUntil == deadlineText && current.QuarantineUnit == unit { + return nil + } + return fmt.Errorf("registry deletion recovery identity changed") + } + if !current.IdentityBound || (current.UID != 0 && current.UID != uid) || current.Generation != generation { + return fmt.Errorf("registry identity changed before quarantine") + } + current.UID = uid + current.DeletionStarted = true + current.QuarantineUntil = deadlineText + current.QuarantineUnit = unit + if _, ok, err := ParseLine(current.TSV()); err != nil || !ok { + if err == nil { + err = fmt.Errorf("quarantine transition did not produce a data row") + } + return fmt.Errorf("invalid quarantine transition: %w", err) + } + out[i] = current + return s.writeAll(out) + } + return fmt.Errorf("registry identity disappeared before quarantine") + }) +} + func validateDeletionIdentity(user string, uid int, generation string) error { if !validate.Username(user) || !validate.AccountID(uid) || (generation != "" && !validate.Generation(generation)) { @@ -416,6 +520,7 @@ func beginDeletionRecords(recs []Record, user string, uid int, generation string current.UID = uid current.Generation = "" current.IdentityBound = false + current.SequentialID = false current.Pending = false current.DeletionStarted = true } @@ -624,7 +729,7 @@ func (s *Store) Compact(keep func(Record) (bool, error)) (int, error) { // paired with an existing lock is a valid empty store; an existing data file // without its lock is damaged and must still fail in withLock. func (s *Store) completelyAbsent() (bool, error) { - for _, path := range []string{s.File, s.Lock} { + for _, path := range []string{s.File, s.Lock, s.sequencePath()} { if _, err := os.Lstat(path); err == nil { return false, nil } else if !os.IsNotExist(err) { diff --git a/internal/registry/store_root_test.go b/internal/registry/store_root_test.go index 74b8de3..40b7a94 100644 --- a/internal/registry/store_root_test.go +++ b/internal/registry/store_root_test.go @@ -10,6 +10,7 @@ import ( "sync" "syscall" "testing" + "time" "github.com/xxvcc/linux-temp-admin/internal/registry" ) @@ -30,6 +31,7 @@ func newStore(t *testing.T) *registry.Store { Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock"), + Now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }, } if err := s.Init(); err != nil { t.Fatalf("Init: %v", err) @@ -109,7 +111,7 @@ func TestConcurrentFirstInitUsesOneRegistryAndLock(t *testing.T) { } } -func TestInitMigratesV2RegistryToV4UnderLock(t *testing.T) { +func TestInitMigratesV2RegistryToV5UnderLock(t *testing.T) { if os.Getuid() != 0 { t.Skip("requires root") } @@ -120,7 +122,8 @@ func TestInitMigratesV2RegistryToV4UnderLock(t *testing.T) { if err := os.Chmod(dir, 0o700); err != nil { t.Fatal(err) } - s := ®istry.Store{Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock")} + now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + s := ®istry.Store{Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock"), Now: func() time.Time { return now }} v2row := strings.Join([]string{ "xxvcc-v2", "2026-07-07 12:00:00 UTC", "2026-07-08 12:00:00 UTC", "yes", "203.0.113.5", "22", "SHA256:abc", "yes", "unit.timer", @@ -140,15 +143,20 @@ func TestInitMigratesV2RegistryToV4UnderLock(t *testing.T) { t.Fatal(err) } if !strings.HasPrefix(string(b), registry.Header+"\n") { - t.Fatalf("registry was not migrated to v4: %q", b) + t.Fatalf("registry was not migrated to v5: %q", b) } recs, err := s.List() if err != nil || len(recs) != 1 || recs[0].UID != 1001 || recs[0].Pending || recs[0].IdentityBound { t.Fatalf("migrated records=%+v err=%v", recs, err) } + sequence, err := os.ReadFile(filepath.Join(dir, "identity-sequence")) + if err != nil || !strings.Contains(string(sequence), "highest\t1001\n") || + !strings.Contains(string(sequence), "safe-after\t2026-08-01T12:01:05Z\n") { + t.Fatalf("migrated identity sequence = %q err=%v", sequence, err) + } } -func TestInitMigratesReleasedV3RowsToV4(t *testing.T) { +func TestInitMigratesReleasedV3RowsToV5(t *testing.T) { if os.Getuid() != 0 { t.Skip("requires root") } @@ -159,7 +167,8 @@ func TestInitMigratesReleasedV3RowsToV4(t *testing.T) { if err := os.Chmod(dir, 0o700); err != nil { t.Fatal(err) } - s := ®istry.Store{Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock")} + now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + s := ®istry.Store{Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock"), Now: func() time.Time { return now }} const generation = "0123456789abcdef0123456789abcdef" active := strings.Join([]string{ "xxvcc-v3a", "2026-07-07 12:00:00 UTC", "2026-07-08 12:00:00 UTC", @@ -185,7 +194,7 @@ func TestInitMigratesReleasedV3RowsToV4(t *testing.T) { t.Fatal(err) } if !strings.HasPrefix(string(b), registry.Header+"\n") { - t.Fatalf("registry was not migrated to v4: %q", b) + t.Fatalf("registry was not migrated to v5: %q", b) } recs, err := s.List() if err != nil || len(recs) != 2 { @@ -201,6 +210,185 @@ func TestInitMigratesReleasedV3RowsToV4(t *testing.T) { } } +func TestInitMigratesReleasedV4RowsBeforePublishingV5Header(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + s := ®istry.Store{Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock"), Now: func() time.Time { return now }} + const generation = "0123456789abcdef0123456789abcdef" + row := strings.Join([]string{ + "xxvcc-v4", "2026-07-07 12:00:00 UTC", "2026-07-08 12:00:00 UTC", + "yes", "203.0.113.5", "22", "SHA256:active", "yes", "active.timer", + "1777", generation, "no", "yes", "no", + }, "\t") + if err := os.WriteFile(s.File, []byte("# linux-temp-admin registry v4\n"+row+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(s.Lock, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := s.Init(); err != nil { + t.Fatal(err) + } + sequence, err := os.ReadFile(filepath.Join(dir, "identity-sequence")) + if err != nil || !strings.Contains(string(sequence), "highest\t1777\n") || + !strings.Contains(string(sequence), "safe-after\t2026-08-01T12:01:05Z\n") { + t.Fatalf("v4 migration sequence = %q err=%v", sequence, err) + } + b, err := os.ReadFile(s.File) + if err != nil || !strings.HasPrefix(string(b), registry.Header+"\n") { + t.Fatalf("v4 migration registry = %q err=%v", b, err) + } +} + +func TestV5RegistryRequiresItsDurableIdentitySequence(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + s := ®istry.Store{Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock")} + if err := os.WriteFile(s.File, []byte(registry.Header+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(s.Lock, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := s.Init(); err == nil || !strings.Contains(err.Error(), "identity sequence") { + t.Fatalf("Init without v5 identity sequence error = %v", err) + } +} + +func TestReserveIdentityIsMonotonicAndFailsClosedAtLimit(t *testing.T) { + s := newStore(t) + for want := 1000; want <= 1002; want++ { + got, isolated, err := s.ReserveIdentity(1000, 1002) + if err != nil || got != want || !isolated { + t.Fatalf("reserve #%d = %d isolated=%v err=%v", want-999, got, isolated, err) + } + } + if _, _, err := s.ReserveIdentity(1000, 1002); err == nil || !strings.Contains(err.Error(), "exhausted") { + t.Fatalf("exhausted sequence error = %v", err) + } +} + +func TestMigratedIdentitySequenceRequiresOneIsolationWindow(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("requires root") + } + dir := t.TempDir() + if err := os.Chown(dir, 0, 0); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) + s := ®istry.Store{ + Dir: dir, File: filepath.Join(dir, "registry.tsv"), Lock: filepath.Join(dir, "registry.lock"), + Now: func() time.Time { return now }, + } + if err := os.WriteFile(s.File, []byte("# linux-temp-admin registry v4\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(s.Lock, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := s.Init(); err != nil { + t.Fatal(err) + } + if got, isolated, err := s.ReserveIdentity(1000, 1002); err != nil || got != 1000 || isolated { + t.Fatalf("first migrated reserve = %d isolated=%v err=%v", got, isolated, err) + } + now = now.Add(65 * time.Second) + if got, isolated, err := s.ReserveIdentity(1000, 1002); err != nil || got != 1001 || !isolated { + t.Fatalf("post-isolation reserve = %d isolated=%v err=%v", got, isolated, err) + } + sequence, err := os.ReadFile(filepath.Join(dir, "identity-sequence")) + if err != nil || !strings.Contains(string(sequence), "safe-after\t2026-08-01T12:01:05Z\n") { + t.Fatalf("isolation deadline was not preserved: %q err=%v", sequence, err) + } +} + +func TestBeginQuarantineBindsCompletedAndPendingGenerations(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + deadline := time.Date(2026, 8, 1, 12, 2, 0, 0, time.UTC) + for _, pending := range []bool{false, true} { + t.Run(fmt.Sprintf("pending=%v", pending), func(t *testing.T) { + s := newStore(t) + name := "xxvcc-bound" + uid := 1001 + recordedUID := uid + if pending { + name = "xxvcc-pending" + recordedUID = 0 + } + rec := registry.Record{ + User: name, Port: 22, UID: recordedUID, Generation: generation, + IdentityBound: true, Pending: pending, + } + if err := s.Record(rec); err != nil { + t.Fatal(err) + } + unit := "linux-temp-admin-v2-quarantine-" + name + if err := s.BeginQuarantine(name, uid, generation, deadline, unit); err != nil { + t.Fatal(err) + } + got, found, err := s.Lookup(name) + if err != nil || !found || !got.DeletionStarted || got.UID != uid || got.Pending != pending || + got.QuarantineUntil != deadline.Format(time.RFC3339) || got.QuarantineUnit != unit { + t.Fatalf("quarantine transition = found=%v rec=%+v err=%v", found, got, err) + } + if err := s.BeginQuarantine(name, uid, generation, deadline, unit); err != nil { + t.Fatalf("idempotent BeginQuarantine: %v", err) + } + }) + } +} + +func TestIdentitySequenceRejectsUnsafeOrCorruptFiles(t *testing.T) { + for _, tc := range []struct { + name string + make func(t *testing.T, path string) + }{ + {name: "symlink", make: func(t *testing.T, path string) { + if err := os.Symlink("/etc/passwd", path); err != nil { + t.Fatal(err) + } + }}, + {name: "malformed", make: func(t *testing.T, path string) { + if err := os.WriteFile(path, []byte("not a sequence\n"), 0o600); err != nil { + t.Fatal(err) + } + }}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newStore(t) + path := filepath.Join(s.Dir, "identity-sequence") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + tc.make(t, path) + if _, _, err := s.ReserveIdentity(1000, 2000); err == nil { + t.Fatal("unsafe identity sequence was accepted") + } + }) + } +} + func TestInitRejectsExistingNonRegularRegistryFiles(t *testing.T) { if os.Getuid() != 0 { t.Skip("requires root") diff --git a/internal/registry/store_test.go b/internal/registry/store_test.go index f60a2c8..a986cf8 100644 --- a/internal/registry/store_test.go +++ b/internal/registry/store_test.go @@ -160,12 +160,12 @@ func TestBeginDeletionRecordsSupportsBoundAndUIDOnlyRecovery(t *testing.T) { name: "pending rollback becomes recovery only", in: []Record{{ User: "xxvcc-pending", Port: 22, Generation: generation, - IdentityBound: true, Pending: true, + IdentityBound: true, SequentialID: true, Pending: true, }}, user: "xxvcc-pending", check: func(t *testing.T, got []Record) { if len(got) != 1 || !got[0].DeletionStarted || got[0].Pending || - got[0].IdentityBound || got[0].Generation != "" || got[0].UID != 1001 { + got[0].IdentityBound || got[0].SequentialID || got[0].Generation != "" || got[0].UID != 1001 { t.Fatalf("pending rollback transition = %+v", got) } }, diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go index 8894951..d81e964 100644 --- a/internal/schedule/schedule.go +++ b/internal/schedule/schedule.go @@ -72,7 +72,7 @@ func New() *Scheduler { // v1's units are still findable, never written. v1 installed to the same path // this binary occupies, so its timers invoke THIS code and its accounts strand // exactly like v2's would. - LegacyUnitPrefixes: []string{config.V1AutoRevokeUnitPrefix}, + LegacyUnitPrefixes: []string{config.V1AutoRevokeUnitPrefix, config.QuarantineUnitPrefix}, Now: time.Now, Sys: realSystem{}, } @@ -195,6 +195,62 @@ func (s *Scheduler) Schedule(user string, uid int, generation string, deadline t return unit, atErr } +// ScheduleQuarantine creates a systemd-only finalizer in a separate namespace. +// The original expiry task may be firing while revoke performs this handoff, so +// the two unit names must coexist until the quarantine row is durable. Hosts +// without a reliable systemd backend use the synchronous drain fallback instead. +func (s *Scheduler) ScheduleQuarantine(user string, uid int, generation string, deadline time.Time) (string, error) { + if !validate.Username(user) || !validate.AccountID(uid) || !validate.Generation(generation) { + return "", fmt.Errorf("invalid quarantine schedule identity") + } + if s == nil || s.Sys == nil || !s.Sys.HasSystemctl() { + return "", fmt.Errorf("persistent systemd quarantine is unavailable") + } + if !validDeadline(s.now(), deadline) { + return "", fmt.Errorf("invalid quarantine deadline %s", deadline.Format(time.RFC3339Nano)) + } + q := *s + q.UnitPrefix = config.QuarantineUnitPrefix + q.LegacyUnitPrefixes = nil + return q.scheduleSystemd(user, uid, generation, deadline.UTC()) +} + +// CancelAuto removes only expiry-era namespaces, leaving a just-created +// quarantine finalizer intact during the handoff. +func (s *Scheduler) CancelAuto(user, recordedUnit string) error { + if s == nil { + return fmt.Errorf("no scheduler backend configured") + } + auto := *s + auto.LegacyUnitPrefixes = []string{config.V1AutoRevokeUnitPrefix} + return auto.Cancel(user, recordedUnit) +} + +// CancelQuarantine removes only the asynchronous deletion finalizer namespace. +func (s *Scheduler) CancelQuarantine(user, recordedUnit string) error { + if s == nil || s.Sys == nil { + return fmt.Errorf("no scheduler backend configured") + } + q := *s + q.UnitPrefix = config.QuarantineUnitPrefix + q.LegacyUnitPrefixes = nil + q.Sys = systemdOnlySystem{System: s.Sys} + return q.Cancel(user, recordedUnit) +} + +// systemdOnlySystem prevents quarantine cleanup from sweeping an unrelated +// expiry-era at job. Quarantine scheduling never uses at; the wrapper retains +// only the systemd operations needed by Cancel. +type systemdOnlySystem struct{ System } + +func (s systemdOnlySystem) HasAt() bool { return false } +func (s systemdOnlySystem) ScheduleAt(string, time.Time) (string, error) { + return "", fmt.Errorf("at is disabled for identity quarantine") +} +func (s systemdOnlySystem) RemoveAtJobsFor(string) error { return nil } +func (s systemdOnlySystem) AtrmJob(string) error { return nil } +func (s systemdOnlySystem) AtJobs() ([]AtJob, error) { return nil, nil } + func (s *Scheduler) scheduleSystemd(user string, uid int, generation string, deadline time.Time) (string, error) { if !validManagedUnitPrefix(s.UnitPrefix) { return "", fmt.Errorf("unsafe managed systemd unit prefix %q", s.UnitPrefix) diff --git a/internal/schedule/schedule_test.go b/internal/schedule/schedule_test.go index 2f9ccd3..78feedf 100644 --- a/internal/schedule/schedule_test.go +++ b/internal/schedule/schedule_test.go @@ -128,6 +128,47 @@ func TestScheduleNoBackend(t *testing.T) { } } +func TestScheduleQuarantineUsesSeparateSystemdNamespaceWithoutAtFallback(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("systemd quarantine scheduling requires root-owned fixtures") + } + dir := t.TempDir() + sys := &fakeSystem{hasSystemctl: true, hasAt: true, atID: "42"} + s := newScheduler(dir, sys) + deadline := s.Now().Add(2 * time.Minute) + unit, err := s.ScheduleQuarantine("xxvcc-a1", 1001, testGeneration, deadline) + if err != nil { + t.Fatal(err) + } + wantUnit := config.QuarantineUnitPrefix + "xxvcc-a1" + if unit != wantUnit || sys.atCommand != "" { + t.Fatalf("quarantine schedule = %q at=%q, want %q and no at fallback", unit, sys.atCommand, wantUnit) + } + for _, suffix := range []string{".service", ".timer"} { + if _, err := os.Stat(filepath.Join(dir, wantUnit+suffix)); err != nil { + t.Fatalf("quarantine %s missing: %v", suffix, err) + } + } +} + +func TestCancelQuarantineNeverSweepsExpiryAtJobs(t *testing.T) { + dir := t.TempDir() + sys := &fakeSystem{hasSystemctl: true, hasAt: true} + s := newScheduler(dir, sys) + unit := config.QuarantineUnitPrefix + "xxvcc-a1" + for _, suffix := range []string{".service", ".timer"} { + if err := os.WriteFile(filepath.Join(dir, unit+suffix), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := s.CancelQuarantine("xxvcc-a1", unit); err != nil { + t.Fatal(err) + } + if len(sys.removedFor) != 0 || len(sys.atrmd) != 0 { + t.Fatalf("quarantine cleanup touched at jobs: sweep=%v atrm=%v", sys.removedFor, sys.atrmd) + } +} + func TestScheduleRefusesAtFallbackAfterDeadlinePasses(t *testing.T) { base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) deadline := base.Add(time.Minute) diff --git a/internal/schedule/valid.go b/internal/schedule/valid.go index 0d6c94e..fddb189 100644 --- a/internal/schedule/valid.go +++ b/internal/schedule/valid.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/xxvcc/linux-temp-admin/internal/config" "github.com/xxvcc/linux-temp-admin/internal/validate" "golang.org/x/sys/unix" ) @@ -91,6 +92,49 @@ func (s *Scheduler) ValidSchedule(user string, uid int, generation, recordedUnit return s.systemdTimerExecutable(unit + ".timer") } +// 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. +func (s *Scheduler) ValidQuarantine(user string, uid int, generation, recordedUnit string, deadline time.Time) (bool, error) { + if !validate.Username(user) || !validate.AccountID(uid) || !validate.Generation(generation) || + deadline.IsZero() || deadline.Second() != 0 || deadline.Nanosecond() != 0 { + return false, nil + } + if s == nil { + return false, fmt.Errorf("inventory quarantine: no scheduler configured") + } + q := *s + q.UnitPrefix = config.QuarantineUnitPrefix + q.LegacyUnitPrefixes = nil + unit := q.UnitName(user) + if recordedUnit != unit || strings.ContainsAny(unit, "/ ") { + return false, nil + } + service, valid, err := readScheduleFile(filepath.Join(q.SystemdDir, unit+".service")) + if err != nil || !valid { + return false, err + } + if string(service) != q.serviceContent(user, uid, generation) { + return false, nil + } + timer, valid, err := readScheduleFile(filepath.Join(q.SystemdDir, unit+".timer")) + if err != nil || !valid { + return false, err + } + calendar := OnCalendar(deadline) + if string(timer) != timerContent(unit, calendar) { + return false, nil + } + now := time.Now + if q.Now != nil { + now = q.Now + } + if !deadline.After(now().UTC()) { + return false, nil + } + return q.systemdTimerExecutable(unit + ".timer") +} + func (s *Scheduler) systemdTimerExecutable(timer string) (bool, error) { if s.Sys == nil { return false, fmt.Errorf("query systemd timer %s: no system backend", timer) diff --git a/internal/schedule/valid_test.go b/internal/schedule/valid_test.go index 8c7e74b..6d28bbc 100644 --- a/internal/schedule/valid_test.go +++ b/internal/schedule/valid_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/xxvcc/linux-temp-admin/internal/config" "golang.org/x/sys/unix" ) @@ -104,6 +105,39 @@ func TestValidScheduleAcceptsExactRootOwnedSystemdPair(t *testing.T) { } } +func TestValidQuarantineRequiresExactNamespaceIdentityAndDeadline(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("valid systemd schedule files must be root-owned") + } + dir := t.TempDir() + sys := &fakeSystem{} + s := newScheduler(dir, sys) + deadline := s.Now().Add(2 * time.Minute) + q := *s + q.UnitPrefix = config.QuarantineUnitPrefix + writeSchedulePair(t, &q, "xxvcc-a1", 1001, testGeneration, deadline) + unit := q.UnitName("xxvcc-a1") + valid, err := s.ValidQuarantine("xxvcc-a1", 1001, testGeneration, unit, deadline) + if err != nil || !valid { + t.Fatalf("ValidQuarantine exact pair = %v, %v", valid, err) + } + for _, tc := range []struct { + uid int + unit string + deadline time.Time + }{ + {uid: 1002, unit: unit, deadline: deadline}, + {uid: 1001, unit: s.UnitName("xxvcc-a1"), deadline: deadline}, + {uid: 1001, unit: unit, deadline: deadline.Add(time.Minute)}, + {uid: 1001, unit: unit, deadline: s.Now()}, + } { + valid, err := s.ValidQuarantine("xxvcc-a1", tc.uid, testGeneration, tc.unit, tc.deadline) + if err != nil || valid { + t.Fatalf("mismatched quarantine accepted: %+v valid=%v err=%v", tc, valid, err) + } + } +} + func TestValidScheduleAcceptsLegacyOneMinuteAccuracyTimer(t *testing.T) { if os.Geteuid() != 0 { t.Skip("valid systemd schedule files must be root-owned") diff --git a/internal/user/user.go b/internal/user/user.go index 4407df7..e48a958 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -29,8 +29,12 @@ import ( // passwdPath is the account database; overridable in tests. var passwdPath = "/etc/passwd" +var groupPath = "/etc/group" +var loginDefsPath = "/etc/login.defs" const maxLocalPasswdBytes = 64 << 20 +const maxLocalGroupBytes = 64 << 20 +const maxLoginDefsBytes = 1 << 20 var ( // ErrAccountCreationNotStarted marks failures that happened before useradd was @@ -135,6 +139,107 @@ func readPasswdDatabase(path string, maxBytes int64) ([]byte, error) { return b, nil } +// IdentityAllocationRange returns the first numeric identity above every local +// UID and GID in the ordinary login.defs account range, plus that range's upper +// bound. The registry's durable high-water mark is applied separately, under its +// own lock, immediately before useradd. +func IdentityAllocationRange() (minimum, maximum int, err error) { + uidMin, uidMax, gidMin, gidMax, err := loginIdentityBounds() + if err != nil { + return 0, 0, err + } + lower := uidMin + if gidMin > lower { + lower = gidMin + } + upper := uidMax + if gidMax < upper { + upper = gidMax + } + if !validate.AccountID(lower) || !validate.AccountID(upper) || lower > upper { + return 0, 0, fmt.Errorf("UID/GID allocation ranges do not overlap safely") + } + highest := lower - 1 + passwd, err := readPasswdDatabase(passwdPath, maxLocalPasswdBytes) + if err != nil { + return 0, 0, fmt.Errorf("read passwd database for identity allocation: %w", err) + } + for i, line := range strings.Split(string(passwd), "\n") { + if line == "" { + continue + } + pw, err := parsePasswdEntry(line) + if err != nil { + return 0, 0, fmt.Errorf("scan passwd identity at line %d: %w", i+1, err) + } + for _, id := range []int{pw.UID, pw.GID} { + if id >= lower && id <= upper && id > highest { + highest = id + } + } + } + groups, err := readPasswdDatabase(groupPath, maxLocalGroupBytes) + if err != nil { + return 0, 0, fmt.Errorf("read group database for identity allocation: %w", err) + } + for i, line := range strings.Split(string(groups), "\n") { + if line == "" { + continue + } + parts := strings.Split(line, ":") + if len(parts) != 4 || parts[0] == "" { + return 0, 0, fmt.Errorf("malformed group entry at line %d", i+1) + } + gid, parseErr := strconv.Atoi(parts[2]) + if parseErr != nil || !validate.KernelID(gid) { + return 0, 0, fmt.Errorf("malformed group GID at line %d", i+1) + } + if gid >= lower && gid <= upper && gid > highest { + highest = gid + } + } + if highest >= upper { + return 0, 0, fmt.Errorf("UID/GID allocation range %d..%d is exhausted", lower, upper) + } + return highest + 1, upper, nil +} + +func loginIdentityBounds() (uidMin, uidMax, gidMin, gidMax int, err error) { + uidMin, uidMax, gidMin, gidMax = 1000, 60000, 1000, 60000 + b, err := readPasswdDatabase(loginDefsPath, maxLoginDefsBytes) + if err != nil { + return 0, 0, 0, 0, fmt.Errorf("read login.defs: %w", err) + } + values := map[string]*int{ + "UID_MIN": &uidMin, "UID_MAX": &uidMax, "GID_MIN": &gidMin, "GID_MAX": &gidMax, + } + seen := make(map[string]bool) + for i, raw := range strings.Split(string(b), "\n") { + line := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0]) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + dst, wanted := values[fields[0]] + if !wanted { + continue + } + if len(fields) != 2 || seen[fields[0]] { + return 0, 0, 0, 0, fmt.Errorf("invalid or duplicate %s at login.defs line %d", fields[0], i+1) + } + value, parseErr := strconv.Atoi(fields[1]) + if parseErr != nil || !validate.AccountID(value) { + return 0, 0, 0, 0, fmt.Errorf("invalid %s at login.defs line %d", fields[0], i+1) + } + *dst = value + seen[fields[0]] = true + } + return uidMin, uidMax, gidMin, gidMax, nil +} + // Exists reports whether name is a local account. func Exists(name string) (bool, error) { _, ok, err := Lookup(name) @@ -490,7 +595,7 @@ func (m *Manager) Create(name, shell, generation string) error { if err != nil { return err } - _, err = m.create(name, shell, gecos, true) + _, err = m.create(name, shell, gecos, true, 0) return err } @@ -503,7 +608,7 @@ func (m *Manager) CreatePending(name, shell, generation string) error { if err != nil { return err } - _, err = m.create(name, shell, gecos, true) + _, err = m.create(name, shell, gecos, true, 0) return err } @@ -516,7 +621,21 @@ func (m *Manager) CreatePendingIdentity(name, shell, generation string) (Passwd, if err != nil { return Passwd{}, err } - return m.create(name, shell, gecos, false) + return m.create(name, shell, gecos, false, 0) +} + +// CreatePendingIdentityWithID creates the pending account with an explicitly +// reserved UID/GID pair. -U makes the private group deterministic; the complete +// passwd snapshot is rejected unless both numeric identities equal reservedID. +func (m *Manager) CreatePendingIdentityWithID(name, shell, generation string, reservedID int) (Passwd, error) { + if !validate.AccountID(reservedID) { + return Passwd{}, fmt.Errorf("%w: invalid reserved UID/GID %d", ErrAccountCreationNotStarted, reservedID) + } + gecos, err := pendingGECOSForGeneration(generation) + if err != nil { + return Passwd{}, err + } + return m.create(name, shell, gecos, false, reservedID) } var managedHomeRoot = "/home" @@ -536,7 +655,7 @@ func DefaultHome(name string) (string, error) { return managedHome(name), nil } -func (m *Manager) create(name, shell, gecos string, shouldCreateHome bool) (Passwd, error) { +func (m *Manager) create(name, shell, gecos string, shouldCreateHome bool, reservedID int) (Passwd, error) { if err := validateMutationName(name); err != nil { return Passwd{}, fmt.Errorf("%w: %w", ErrAccountCreationNotStarted, err) } @@ -566,13 +685,30 @@ func (m *Manager) create(name, shell, gecos string, shouldCreateHome bool) (Pass // depends on a later chage/usermod call for its initial login gate. -M also // prevents /etc/skel (including any locally provisioned SSH credential) from // being copied before the selected UID has been proved idle. - err = m.Runner.Run("useradd", "-M", "-d", home, "-s", shell, "-c", gecos, - "-e", expiredDate, "-p", initialLockedPasswordHash, name) + args := []string{"-M", "-d", home, "-s", shell, "-c", gecos, + "-e", expiredDate, "-p", initialLockedPasswordHash} + if reservedID > 0 { + id := strconv.Itoa(reservedID) + // Pin the private-group allocator to the same already-reserved number. + // -U normally prefers UID==GID, but login.defs gaps and concurrent local + // administration can otherwise make that an implementation detail. The + // post-create passwd check remains authoritative. + args = append(args, "-U", "-u", id, "-K", "GID_MIN="+id, "-K", "GID_MAX="+id) + } + args = append(args, name) + err = m.Runner.Run("useradd", args...) default: return Passwd{}, fmt.Errorf("%w: useradd not available", ErrAccountCreationNotStarted) } if err != nil { - return Passwd{}, err + pw, exists, lookupErr := m.lookup(name) + if lookupErr != nil { + return Passwd{}, errors.Join(err, fmt.Errorf("inspect account after failed useradd: %w", lookupErr)) + } + if !exists { + return Passwd{}, fmt.Errorf("%w: useradd failed without creating an account: %v", ErrAccountCreationNotStarted, err) + } + return pw, fmt.Errorf("useradd reported failure after creating an account: %w", err) } // useradd chooses a numeric UID automatically. A process left behind after an @@ -597,6 +733,10 @@ func (m *Manager) create(name, shell, gecos string, shouldCreateHome bool) (Pass fmt.Errorf("account was retained for manual recovery because identity %d:%d is unsafe", pw.UID, pw.GID), ) } + if reservedID > 0 && (pw.UID != reservedID || pw.GID != reservedID) { + return pw, fmt.Errorf("newly created account received identity %d:%d, want reserved %d:%d; account retained for rollback or manual recovery", + pw.UID, pw.GID, reservedID, reservedID) + } if pw.Name != name || pw.Home != home || pw.Shell != shell || gecosFullName(pw.GECOS) != gecos { return Passwd{}, fmt.Errorf("newly created account identity does not match the requested name, home, shell, and marker; account retained for manual recovery") } diff --git a/internal/user/user_test.go b/internal/user/user_test.go index 9c339e8..b0cde8b 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -31,6 +31,25 @@ func setPasswd(t *testing.T, content string) { t.Cleanup(func() { passwdPath = old }) } +func setIdentityDatabases(t *testing.T, passwd, group, loginDefs string) { + t.Helper() + dir := t.TempDir() + write := func(name, content string) string { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path + } + oldPasswd, oldGroup, oldLoginDefs := passwdPath, groupPath, loginDefsPath + passwdPath = write("passwd", passwd) + groupPath = write("group", group) + loginDefsPath = write("login.defs", loginDefs) + t.Cleanup(func() { + passwdPath, groupPath, loginDefsPath = oldPasswd, oldGroup, oldLoginDefs + }) +} + func writeUserCommand(t *testing.T, dir, name, body string) string { t.Helper() path := filepath.Join(dir, name) @@ -635,6 +654,65 @@ func TestCreatePendingAndMarkManagedArgv(t *testing.T) { } } +func TestCreatePendingIdentityPinsReservedUIDAndGID(t *testing.T) { + const reserved = 2345 + pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration + setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/bash\n") + setProcRoot(t, map[int]string{}) + f := &fakeRunner{available: map[string]bool{"useradd": true}} + m := managerWithStubbedHomeChecks(f) + pw, err := m.CreatePendingIdentityWithID("xxvcc-a1", "/bin/bash", testGeneration, reserved) + if err != nil || pw.UID != reserved || pw.GID != reserved { + t.Fatalf("reserved identity = %+v err=%v", pw, err) + } + want := []string{"useradd", "-M", "-d", "/home/xxvcc-a1", "-s", "/bin/bash", "-c", pendingMarker, + "-e", expiredDate, "-p", initialLockedPasswordHash, + "-U", "-u", "2345", "-K", "GID_MIN=2345", "-K", "GID_MAX=2345", "xxvcc-a1"} + if len(f.calls) != 1 || !reflect.DeepEqual(f.calls[0], want) { + t.Fatalf("reserved useradd argv = %v, want %v", f.calls, want) + } +} + +func TestCreatePendingIdentityRejectsReservedIDMismatch(t *testing.T) { + pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration + setPasswd(t, "xxvcc-a1:x:2345:2346:"+pendingMarker+":/home/xxvcc-a1:/bin/bash\n") + setProcRoot(t, map[int]string{}) + f := &fakeRunner{available: map[string]bool{"useradd": true}} + m := managerWithStubbedHomeChecks(f) + pw, err := m.CreatePendingIdentityWithID("xxvcc-a1", "/bin/bash", testGeneration, 2345) + if err == nil || pw.GID != 2346 || !strings.Contains(err.Error(), "want reserved 2345:2345") { + t.Fatalf("reserved mismatch = %+v err=%v", pw, err) + } +} + +func TestIdentityAllocationRangeScansLocalUIDsAndGIDs(t *testing.T) { + setIdentityDatabases(t, + "root:x:0:0:root:/root:/bin/sh\nalice:x:1500:1500::/home/alice:/bin/sh\n", + "root:x:0:\nalice:x:1500:\nservice:x:1600:\n", + "UID_MIN 1000\nUID_MAX 2000\nGID_MIN 1200\nGID_MAX 1900\n") + minimum, maximum, err := IdentityAllocationRange() + if err != nil || minimum != 1601 || maximum != 1900 { + t.Fatalf("IdentityAllocationRange = %d..%d err=%v, want 1601..1900", minimum, maximum, err) + } +} + +func TestIdentityAllocationRangeFailsClosedOnInvalidPolicyOrDatabase(t *testing.T) { + for _, tc := range []struct { + name, passwd, group, defs, want string + }{ + {name: "disjoint ranges", passwd: "root:x:0:0::/root:/bin/sh\n", group: "root:x:0:\n", defs: "UID_MIN 1000\nUID_MAX 1100\nGID_MIN 1200\nGID_MAX 1300\n", want: "do not overlap"}, + {name: "duplicate policy", passwd: "root:x:0:0::/root:/bin/sh\n", group: "root:x:0:\n", defs: "UID_MIN 1000\nUID_MIN 1001\n", want: "duplicate UID_MIN"}, + {name: "malformed group", passwd: "root:x:0:0::/root:/bin/sh\n", group: "broken:x:not-a-gid:\n", defs: "UID_MIN 1000\n", want: "malformed group GID"}, + } { + t.Run(tc.name, func(t *testing.T) { + setIdentityDatabases(t, tc.passwd, tc.group, tc.defs) + if _, _, err := IdentityAllocationRange(); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("IdentityAllocationRange error = %v, want %q", err, tc.want) + } + }) + } +} + func TestCreatePendingDefersHomeUntilExpectedIdentityCall(t *testing.T) { pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/bash\n")