From 7a3bf6f6c0111268eaf35ee1bf8d18e7c9707128 Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Sat, 1 Aug 2026 03:55:22 +0800 Subject: [PATCH 1/2] fix: support sticky mail spools and pending recovery --- CHANGELOG.md | 29 ++++ README.en.md | 2 +- README.md | 2 +- docs/installing.en.md | 15 ++ docs/installing.md | 15 ++ docs/operator-guide.en.md | 8 +- docs/operator-guide.md | 8 +- docs/releasing.md | 20 +-- docs/security-model.en.md | 9 +- docs/security-model.md | 9 +- internal/cli/cli_test.go | 6 + internal/cli/invite.go | 28 +++- internal/cli/manage_root_test.go | 45 ++++++ internal/cli/revoke.go | 71 ++++++--- internal/cli/revoke_test.go | 206 +++++++++++++++++++++++++- internal/registry/record.go | 9 +- internal/registry/store.go | 13 +- internal/user/user.go | 168 +++++++++++++++------ internal/user/user_test.go | 245 ++++++++++++++++++++++++++++++- 19 files changed, 790 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a4dd7c..5f9a70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to this project are documented here. +## v2.9.2 - 2026-08-01 + +- Validate conventional mail-pool roots before `useradd`, then reopen and + revalidate them during cleanup bound to the newly selected UID. Accept the + root-owned layouts used by supported systems, including `root:mail 3777` and + Arch Linux's `root:root 1777`; a world-writable root now requires sticky-bit + protection. Continue to reject a non-root owner, setuid, world-write without + sticky, and a `/var/mail` or `/var/spool/mail` alias that escapes those two + accepted locations. Mail cleanup remains limited to a non-symlink regular mbox + file whose owner matches the captured UID. +- Return a proved post-`useradd` passwd identity with mail or Home preparation + failures so invite rollback can safely use it, while classifying failures that + occur before the helper runs so a confirmed-absent account does not leave a + stale creation-intent row. Persist the deletion-recovery witness before + controlled mail/Home cleanup, preserving narrow post-disappearance recovery + when an account vanishes during teardown. +- Permit manual recovery of a live pending creation row retained by a release + before v2.9.2 only through a direct interactive `revoke --user --force` + and full-name confirmation. Require the row's random generation to match the + pending GECOS marker plus the exact safe account shape, then convert it to a + generation-less UID-only `DeletionStarted` witness before artifact cleanup. + `--yes`, automatic tasks, uninstall bulk removal, non-TTY input, and every + mismatched or incomplete pending identity remain fail-closed. +- Recheck both the local passwd database and NSS between absent-account mail + sweeps, and recheck mailbox absence after syncing its parent directory. A + same-name NSS identity or a mailbox recreated during durability confirmation + now stops cleanup and retains the recovery witness instead of being reported as + a completed deletion. + ## v2.9.1 - 2026-08-01 - Accept the exact C-locale diagnostic emitted by systemd 256 and later when diff --git a/README.en.md b/README.en.md index 474aa4b..8d5942e 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, revoke cleans only the registry, name-scoped grants, and tasks it can still identify safely; it does not guess at Home or mail cleanup after losing the identity witness. 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. 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. ## Everyday commands diff --git a/README.md b/README.md index 98f369f..56a8c38 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。若安全检查或清理失败,程序会返回非零,保留账号(若仍存在)和登记,并尽力禁用仍存在的账号,供 systemd 重试或人工处理。即使启用了自动撤销,用完后也应立即手动撤销。 +默认会安排在 24 小时后自动撤销。对于仍可用完整身份核对的账号,撤销成功会删除个人 crontab、UID 匹配的 `at`/`batch` 任务、账号、家目录、SSH key、sudo 授权和本工具创建的 sshd 例外;若账号已在程序外消失且没有删除恢复见证,只清理可安全识别的登记、按用户名授权和任务,不会猜测删除 Home/mail。已经授权的撤销若提前持久化了恢复见证,则只允许继续核对 UID 属主的传统单文件 mail spool 清扫,绝不会按缺失账号的旧路径恢复性删除 Home。若安全检查或清理失败,程序会返回非零,保留账号(若仍存在)和登记,并尽力禁用仍存在的账号,供 systemd 重试或人工处理。即使启用了自动撤销,用完后也应立即手动撤销。 ## 常用命令 diff --git a/docs/installing.en.md b/docs/installing.en.md index 33ac250..9b7b49b 100644 --- a/docs/installing.en.md +++ b/docs/installing.en.md @@ -15,6 +15,21 @@ This guide is for administrators who install and maintain `linux-temp-admin`. Se The binary has no dynamic-library or language-runtime dependency. Account lifecycle operations still use the system's `id`, `useradd`, `userdel`, `usermod`, and `chage`; password login additionally requires `chpasswd`, while granting sudo requires `sudo` and `visudo` for pre-commit policy validation. The tool does not fall back to a distro `adduser`/`deluser` or an arbitrary BusyBox account applet: command names alone cannot prove equivalent arguments, configuration, or compile-time shadow/group semantics. Missing tools can be installed through apt, dnf, yum, or apk after interactive confirmation. +### Conventional mail-pool compatibility boundary + +Account creation and revocation inspect the actual metadata of `/var/mail` and `/var/spool/mail` instead of allowing a layout by distribution name alone. The FHS does not specify an owner or mode for these directories. The following layouts were checked for this change; they do not make every custom layout automatically supported: + +| Distribution/family | Actual mail pool | Other path | Verified owner and mode | +| --- | --- | --- | --- | +| Debian 12/13, Ubuntu 22.04/24.04 | `/var/mail` | `/var/spool/mail -> ../mail` | `root:mail 2775` | +| RHEL, Rocky, Alma, Oracle Linux, Fedora, CentOS, and Amazon Linux families | `/var/spool/mail` | `/var/mail -> spool/mail` | `root:mail 0775` | +| Alpine | `/var/mail` | depends on the installation | `root:root 0755` | +| current Arch Linux `filesystem` package | `/var/spool/mail` | `/var/mail -> spool/mail` | `root:root 1777` | + +The product policy accepts only a real root-owned mail-pool directory. It may belong to group `mail` and carry setgid; if it is world-writable it must have sticky protection, and any setuid bit is refused. The observed `root:mail 3777` and Arch's `root:root 1777` therefore work, while mode `0777`/`2777` without sticky, a non-root owner such as `mail:mail`, or a symlink escaping the two paths fails closed before `useradd`. Mail-delivery services and local identities authorized to write this directory are inside the trust boundary. + +This compatibility covers only the traditional single-file mbox at `/var/mail/` or `/var/spool/mail/`. The tool does not traverse Maildir or treat aaPanel's `/www/vmail` as a mail pool. A complete revoke still removes the whole tool-managed Home under its separate Home-safety rules. + Arch Linux has no safe partial-upgrade mode, while `pacman -Syu` upgrades the whole system. The tool therefore never runs pacman automatically while creating an account. Complete the prompted upgrade and dependency installation deliberately first. ## Convenience install diff --git a/docs/installing.md b/docs/installing.md index 05f9905..dd9e660 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -15,6 +15,21 @@ 二进制本身不依赖动态库或语言运行时。账号生命周期仍会使用系统的 `id`、`useradd`、`userdel`、`usermod` 和 `chage`;密码登录还需要 `chpasswd`,授予 sudo 时还需要 `sudo` 和用于写入前策略校验的 `visudo`。程序不回退到发行版 `adduser`/`deluser` 或任意 BusyBox 账号 applet:这些实现的参数、配置及编译期 shadow/group 语义不能仅凭命令名证明与 shadow 工具链等价。缺失依赖可在交互确认后通过 apt、dnf、yum 或 apk 安装。 +### 传统邮件池兼容边界 + +账号创建与撤销会检查 `/var/mail` 和 `/var/spool/mail` 的实际元数据,而不是只按发行版名称放行。FHS 没有规定这些目录的属主和模式;以下是本次核验到的常见布局,不代表其他自定义布局自动受支持: + +| 发行版/系列 | 实际邮件池 | 另一路径 | 已核验属主与模式 | +| --- | --- | --- | --- | +| Debian 12/13、Ubuntu 22.04/24.04 | `/var/mail` | `/var/spool/mail -> ../mail` | `root:mail 2775` | +| RHEL、Rocky、Alma、Oracle Linux、Fedora、CentOS、Amazon Linux 系 | `/var/spool/mail` | `/var/mail -> spool/mail` | `root:mail 0775` | +| Alpine | `/var/mail` | 依具体安装而定 | `root:root 0755` | +| Arch Linux 当前 `filesystem` 包 | `/var/spool/mail` | `/var/mail -> spool/mail` | `root:root 1777` | + +产品策略只接受 root-owned 的真实邮件池目录;目录可以属于 `mail` 组并带 setgid,world-writable 时则必须有 sticky bit,同时任何 setuid 都会拒绝。因此现场可见的 `root:mail 3777` 和 Arch 的 `root:root 1777` 均兼容,而无 sticky 的 `0777`/`2777`、`mail:mail` 等非 root 属主及逃出上述两个路径的符号链接会在 `useradd` 前失败关闭。邮件投递服务和获准写入该目录的本地身份属于信任边界。 + +这里的兼容性只针对 `/var/mail/<用户名>` 或 `/var/spool/mail/<用户名>` 的传统单文件 mbox;程序不会把 Maildir 或宝塔 `/www/vmail` 当作邮件池遍历。完整撤销仍会按独立的 Home 安全规则清理本工具管理的整个 Home。 + Arch Linux 不允许安全的部分升级,而 `pacman -Syu` 会升级整个系统,因此本工具不会在创建账号时自动运行 pacman。请根据提示由管理员先完成完整升级和依赖安装。 ## 便利安装 diff --git a/docs/operator-guide.en.md b/docs/operator-guide.en.md index 9d4940b..2bd45ee 100644 --- a/docs/operator-guide.en.md +++ b/docs/operator-guide.en.md @@ -113,7 +113,9 @@ 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. Recursive Home cleanup proceeds only for a real directory owned by the registered account's UID/GID with no mount boundary underneath; a mail spool must likewise be a non-symlink regular file in an accepted system mail directory and is swept again after account absence is confirmed. 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, 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. + +Mail-specific cleanup handles only a traditional single-file mbox at `/var/mail/` or `/var/spool/mail/`. An existing mail pool must be root-owned and have no setuid bit; a world-writable pool 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 pool 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. Before deleting an `at` job, the tool rereads its body and rechecks the UID or exact revoke command so a reused job ID cannot authorize deletion of an unrelated task. `at` has no atomic compare-and-delete interface, so a very short local-root trust-boundary interval remains between that read and `atrm`. @@ -121,7 +123,9 @@ 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 every pre-deletion check passes, the tool persists a deletion-recovery witness before invoking `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. 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. +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 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. Deleting an unregistered account requires explicit `--force` and an additional username confirmation; it does not override protection for reserved names, UID 0, or unregistered/legacy-identity low-UID accounts. If a system assigns a new tool-created account a low UID, it remains normally revocable only while the current registry UID, random generation, and exact GECOS marker are fully bound. A real account without the tool's exact marker is never treated as managed. diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 43739ed..564ddcc 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -113,7 +113,9 @@ 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 例外和自动删除任务;账号若已在程序外消失,只清理仍可安全识别的登记、按用户名授权和任务。只有 Home 是真实目录、属于登记账号的 UID/GID 且不包含挂载边界时才会递归清理;mail spool 也必须是受信系统邮件目录中的非链接普通文件,并在账号确认消失后复扫一次。Home 清理使用目录描述符,不接受链接形式的 Home 根;内部链接只删除链接本身而不跟随目标。遍历会在文件系统调用之间检查 100,000 个条目、128 层和两分钟的协作式预算,因此单次阻塞的文件系统调用不能被该期限中断。cron/at 和进程结果是重复快照,不是原子冻结。任一安全条件、资源上限、任务/进程盘点或按用户名授权无法确认时,都会尝试禁用账号,保留仍存在的账号和登记并返回非零,避免用户名复用后继承旧数据、任务或权限。 +对于仍可用完整身份核对的账号,撤销会先禁用登录,删除并复核个人 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 和进程结果是重复快照,不是原子冻结。任一安全条件、资源上限、任务/进程盘点或按用户名授权无法确认时,都会尝试禁用账号,保留仍存在的账号和登记并返回非零,避免用户名复用后继承旧数据、任务或权限。 + +邮件专用清理只处理 `/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。 删除 `at` 作业前会重新读取作业正文并再次核对 UID 或精确撤销命令,避免已复用的作业 ID 指向无关任务;`at` 没有原子的比较删除接口,因此重新读取到 `atrm` 之间仍存在本机 root 信任边界内的极短窗口。 @@ -121,7 +123,9 @@ ssh -i ./USER.key -p PORT USER@HOST `doctor` 报告为 `legacy-unverified` 的账号来自旧版固定身份标记,无法排除同名/同 UID 重用。人工核查后,只能在交互终端运行 `revoke --user <名> --force` 并输入完整用户名确认。旧版 timer 使用的 `--yes --force --confirm-force` 以及其他非交互调用都不会获得这类账号的删除授权;`doctor` 会把仍存在的旧任务报告为孤儿任务,`cleanup-expired --compact` 会取消任务但保留活账号及登记供人工处理。 -通过全部删除前检查后,程序会在调用 `userdel` 前持久化删除恢复见证。若账号删除、删除后的 mail spool 复扫或任务清理中断,`status` 和 `doctor` 会显示删除恢复状态,同名 `invite` 会拒绝覆盖该见证,`cleanup-expired --compact` 也不会删除见证。账号已经不存在或仍精确匹配登记世代时,运行 `revoke --user <名>` 可继续恢复;这两种状态下仍保留可识别的自动任务,但只有 systemd 任务会按重启策略自动重试,`at` 和旧的一次性任务需要人工重试。旧版、未登记或 pending 回滚只保留 UID 见证,若账号仍存在,必须人工核查后在交互终端运行 `revoke --user <名> --force` 并输入完整用户名,任何非交互调用都会被拒绝;这类活账号的旧自动任务会被当作孤儿任务取消,登记见证则保留供人工恢复。 +从 v2.9.1 等旧版升级后,若 `status`/`doctor` 显示仍活着的 pending 创建登记,应先人工确认它确实来自失败的邀请。恢复只能在直接交互终端运行 `revoke --user <名> --force` 并输入完整用户名;程序还会核对随机 pending 世代、GECOS、登记 UID(0 或当前 UID)、受管 Home、非 root UID/GID 和非空 shell。核对通过后会先把登记转换为 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` 并输入完整用户名,任何非交互调用都会被拒绝;这类活账号的旧自动任务会被当作孤儿任务取消,登记见证则保留供人工恢复。 删除未登记账号需要显式 `--force`,并有额外用户名确认;它不会绕过保留名称、UID 0 或未登记/旧身份低 UID 账号的保护。若某些系统把本工具新建的账号分配到低 UID,只有当前登记 UID、随机世代和精确 GECOS 标记完整绑定时才能正常撤销。没有本工具精确标记的真实账号始终不会被当作受管账号删除。 diff --git a/docs/releasing.md b/docs/releasing.md index 79466e7..5a5b219 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -431,10 +431,10 @@ git -c user.name='XXV.CC' \ -c user.signingkey="${TAG_SIGNING_FPR}!" \ -c gpg.format=openpgp \ -c gpg.program=/usr/bin/gpg \ - tag -s v2.9.1 "$RELEASE_COMMIT" -m 'linux-temp-admin v2.9.1' + tag -s v2.9.2 "$RELEASE_COMMIT" -m 'linux-temp-admin v2.9.2' git -c gpg.format=openpgp -c gpg.program=/usr/bin/gpg \ - verify-tag --raw v2.9.1 -git push origin v2.9.1 + verify-tag --raw v2.9.2 +git push origin v2.9.2 ``` Before pushing, the `VALIDSIG` record from `verify-tag --raw` must identify the @@ -501,7 +501,7 @@ printf '\n' >/dev/tty || fail "GH_TOKEN must be one non-empty token without whitespace" export GH_TOKEN exec /opt/lta-release-tools/prepare-release.sh \ - v2.9.1 /srv/linux-temp-admin /srv/release-transfer/v2.9.1-prepared + v2.9.2 /srv/linux-temp-admin /srv/release-transfer/v2.9.2-prepared LTA_PREPARE_RELEASE ``` @@ -521,7 +521,7 @@ the candidate or transfer media: LTA_SIGN_KEY=/offline/keys/release-v1.key LTA_TRUSTED_SIGNER=/opt/lta-release-tools/lta-release LTA_TRUSTED_SIGNER_SHA256='' -LTA_EXPECTED_TAG=v2.9.1 +LTA_EXPECTED_TAG=v2.9.2 LTA_EXPECTED_COMMIT='' LTA_EXPECTED_PREPARED_MANIFEST_SHA256='' LTA_EXPECTED_RELEASE_SIGNER_PUBKEY='' @@ -533,7 +533,7 @@ LTA_EXPECTED_RELEASE_SIGNER_PUBKEY='/dev/tty || fail "GH_TOKEN must be one non-empty token without whitespace" export GH_TOKEN exec /opt/lta-release-tools/publish-release.sh \ - /srv/release-transfer/v2.9.1-signed /srv/linux-temp-admin + /srv/release-transfer/v2.9.2-signed /srv/linux-temp-admin LTA_PUBLISH_RELEASE ``` @@ -684,7 +684,7 @@ noncanonical stable tag or mutable recovery target, excludes the failed `TAG`, and binds both the mutation and resulting Latest state to one numeric Release ID: ```bash -TAG=v2.9.1 # the failed release; verify this value before running +TAG=v2.9.2 # the failed release; verify this value before running /usr/bin/sudo /usr/bin/env -i \ HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ TAG="$TAG" /bin/bash -p <<'LTA_LATEST_RECOVERY' @@ -849,7 +849,7 @@ announcement: ```bash gh workflow run mirror-release.yml --repo xxvcc/linux-temp-admin \ - --ref main -f tag=v2.9.1 + --ref main -f tag=v2.9.2 gh run list --repo xxvcc/linux-temp-admin \ --workflow mirror-release.yml --event workflow_dispatch --limit 1 ``` @@ -1080,7 +1080,7 @@ the release audit/signing record and a separate authenticated channel, then run: ```bash INSTALLER_COMMIT='replace-with-the-audited-40-hex-commit' INSTALLER_SHA256='replace-with-the-independent-64-hex-script-hash' -LTA_RELEASE_TAG='v2.9.1' +LTA_RELEASE_TAG='v2.9.2' /usr/bin/sudo /usr/bin/env -i \ HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ INSTALLER_COMMIT="$INSTALLER_COMMIT" INSTALLER_SHA256="$INSTALLER_SHA256" \ diff --git a/docs/security-model.en.md b/docs/security-model.en.md index 9d5e6d4..a8d9abd 100644 --- a/docs/security-model.en.md +++ b/docs/security-model.en.md @@ -10,6 +10,7 @@ The tool assumes these foundations remain trusted: - the Linux kernel, local root, filesystem, and system account database; - OpenSSH, sudo, systemd or `at`, and the system account-management commands; +- mail-delivery services and local identities that the administrator authorizes to modify entries in an accepted system mail pool; - the download trust chain explicitly chosen during installation; - the operator's private channel used to deliver an invite. @@ -58,6 +59,8 @@ The tool repeatedly rechecks the complete passwd snapshot at critical transactio 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. + 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. ## Deferred jobs, processes, and identity reuse @@ -76,7 +79,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 all pre-deletion checks pass and 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. 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 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. 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. @@ -89,7 +92,9 @@ If revoke cannot completely remove a name-scoped grant, it retains and attempts - 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; - 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 and any UID-matched conventional mail spool while the complete account identity is still available, then sweeps mail again after the account helper confirms absence to catch recreation during Home cleanup. 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. A mail spool must be a non-symlink regular file in an accepted system mail directory; +- 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 pool must be a real root-owned directory with no setuid bit; a world-writable pool 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; +- creation preflights the mail pools before `useradd`, then reopens and revalidates them while the account is bound to its selected UID but remains expired, locked, credential-less, and without a Home. A preflight failure creates no account. If a root becomes unsafe after the helper runs, the transaction attempts rollback with the complete captured identity and retains a disabled account plus its registry witness when cleanup cannot be confirmed. Mail-specific cleanup neither searches nor traverses Maildir and never touches aaPanel's `/www/vmail`; a Maildir inside the managed Home is still removed with that Home under the preceding rules during complete-account revocation; - sudoers files, sshd exceptions, and automatic tasks use restricted project names and are removed only as verified managed objects. Do not edit `/var/lib/linux-temp-admin/v2/registry.tsv` manually. An unreadable registry is never treated as an empty one. diff --git a/docs/security-model.md b/docs/security-model.md index 0ddd055..de2bb6e 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -10,6 +10,7 @@ - Linux 内核、本地 root、文件系统和系统账号数据库; - OpenSSH、sudo、systemd 或 `at` 及系统账号管理命令; +- 邮件投递服务,以及被管理员允许修改受信系统邮件池目录项的本地身份; - 安装时明确选择的下载信任链; - 操作者用于交付邀请的私密通信渠道。 @@ -58,6 +59,8 @@ 从旧登记格式迁移的固定标记账号显示为 `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 及任何不匹配状态始终失败关闭并保留账号和登记。 + root、UID 0 和保留名称始终不会删除。低 UID 账号只有在当前登记 UID、随机世代与精确 GECOS 标记完整绑定时,才能作为本工具创建的账号撤销;未登记或旧身份格式的低 UID 账号即使使用 `--force` 也受保护。没有本工具精确标记的真实账号同样不会作为受管账号删除。 ## 延迟任务、进程与身份复用 @@ -76,7 +79,7 @@ invite、revoke、cleanup、install、upgrade 和 uninstall 的受管状态提 同名 `invite` 另持独占账号屏障,当前撤销持共享侧屏障。兼容旧任务的不带 UID/世代 `revoke --yes` 若发现独占屏障已被同名创建占用,会不作删除并以成功状态跳过,防止旧 systemd 任务在创建结束后重试并命中新世代;人工发出的同形非交互命令也会被跳过,必须在并发操作完成后运行 `doctor` 并重新撤销。这是安全优先的迁移边界,而不是已经删除账号的证明。已经载入、且在新版屏障生效前开始等待全局锁的旧二进制无法由新版进程锁完全追溯;创建流程还会扫描精确的 root-owned 旧撤销进程并拒绝用户名复用,但系统 helper 和 `/proc` 观察仍不是原子 compare-and-swap,本机 root 保持在信任边界内。 -登记格式 v4 在所有删除前检查完成后、`userdel` 之前写入 `DeletionStarted`。精确世代账号保留 UID 与世代绑定;旧版、未登记及 pending 回滚只保留 UID 见证,避免一次已经授权的删除在账号消失后失去 mail spool 清扫依据,同时不把不完整身份变成无人值守删除权限。删除后的恢复只允许核对 UID 所有者的常规 mail spool 清扫,不会按缺失账号的旧 Home 路径递归删除。恢复行不能由普通登记更新、删除或 compact 覆盖,同名创建也必须等待恢复完成。活着的 UID-only 或世代不匹配账号只允许交互式 `--force` 人工恢复;旧自动任务会作为失效任务解除,避免无权完成恢复的无人值守命令持续重试。 +登记格式 v4 在删除授权、账号身份及删除前的延迟任务/进程静默检查通过后,并在受控的 mail/Home 清理和 `userdel` 之前写入 `DeletionStarted`;Home 清理后、`userdel` 前还会再次复核任务、进程和完整身份。精确世代账号保留 UID 与世代绑定;旧版、未登记及 pending 回滚只保留 UID 见证,避免一次已经授权的删除在账号消失后失去 mail spool 清扫依据,同时不把不完整身份变成无人值守删除权限。删除后的恢复只允许核对 UID 所有者的常规 mail spool 清扫,不会按缺失账号的旧 Home 路径递归删除;每次清扫前后还会同时确认本地 passwd 与 NSS 中没有同名身份。恢复行不能由普通登记更新、删除或 compact 覆盖,同名创建也必须等待恢复完成。活着的 UID-only 或世代不匹配账号只允许交互式 `--force` 人工恢复;旧自动任务会作为失效任务解除,避免无权完成恢复的无人值守命令持续重试。 创建失败会运行回滚栈,清理任务、sudoers、sshd 例外、登记和能够以创建时完整身份确认的新账号。若半创建账号的身份、授权清理或递归 Home 清理无法确认,工具会保留账号及登记供人工恢复,而不会按用户名猜测删除。任何回滚不完整都会明确报告并返回非零,不会把部分成功显示为成功。 @@ -89,7 +92,9 @@ invite、revoke、cleanup、install、upgrade 和 uninstall 的受管状态提 - 登记表、偏好和审计目录要求 root 所有及严格权限; - 登记表严格验证 schema、字段、UID、世代和大小,损坏或不可读时 fail closed; - 安装、升级和状态写入使用同目录临时文件、元数据验证、原子替换和必要的 fsync; -- 新账号只能使用创建前不存在的确定路径 `/home/<用户名>`;`/home` 必须由 root 管理,创建后的真实目录必须属于目标非 root UID/GID。撤销会在完整账号身份仍可核对时清理该目录及 UID 匹配的常规 mail spool,并在账号 helper 确认账号消失后再次清扫 mail,以处理 Home 清理期间的重建。Home 递归删除使用目录描述符;Home 根若为符号链接、属主不符或跨越活跃挂载边界会被拒绝,内部符号链接只删除链接本身而不跟随目标。遍历会在文件系统调用之间检查 100,000 个条目、128 层和两分钟的协作式预算,单次阻塞的文件系统调用不能由该期限中断;mail spool 只接受受信目录中的非链接普通文件; +- 新账号只能使用创建前不存在的确定路径 `/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 父目录后还会复核它没有重建; +- 创建会在 `useradd` 前预检邮件池,并在账号已绑定具体 UID、仍过期锁定且没有凭据或 Home 时重新打开目录做同一校验与旧 mbox 清理。前置失败不会创建账号;若目录在 helper 之后变得不安全,流程会以已捕获的完整身份尝试回滚,不能确认清理时保留禁用账号和登记见证。邮件专用逻辑不搜索或遍历 Maildir,也不触碰宝塔 `/www/vmail`;完整账号撤销时,位于受管 Home 内的 Maildir 仍随 Home 按上述规则清理; - sudoers、sshd 例外和自动任务使用受限、可预测的项目命名,只删除经过身份验证的受管对象。 不要手工编辑 `/var/lib/linux-temp-admin/v2/registry.tsv`。读不到登记状态不会被当作“没有账号”。 diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 4df92bb..873c170 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1385,6 +1385,7 @@ func TestRollbackInviteAccountUsesFailClosedTeardown(t *testing.T) { }, LookupUser: lookup, } + setTestRegistryRecord(t, a, rec) if err := a.rollbackInviteAccount("xxvcc-a1", rec, pw, true); err != nil { t.Fatal(err) } @@ -1394,6 +1395,11 @@ func TestRollbackInviteAccountUsesFailClosedTeardown(t *testing.T) { if homeTouched || mailCalls != 2 { t.Fatalf("absent cleanup touched Home=%v or mail calls=%d, want Home=false mail=2", homeTouched, mailCalls) } + stored, found, err := a.Registry.Lookup(rec.User) + if err != nil || !found || !stored.DeletionStarted || stored.UID != pw.UID || + stored.Generation != generation || !stored.IdentityBound { + t.Fatalf("absent rollback recovery witness = found %v record %+v err %v", found, stored, err) + } }) t.Run("same UID replacement is retained", func(t *testing.T) { diff --git a/internal/cli/invite.go b/internal/cli/invite.go index af78a25..aa21571 100644 --- a/internal/cli/invite.go +++ b/internal/cli/invite.go @@ -1107,13 +1107,18 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu return a.releaseRegistryAfterCleanup(username) }) - // useradd can create the account (and Home) before reporting an - // error. Close the registry-removal gate before invoking the helper: until a - // complete passwd identity is captured, rollback cannot prove which artifacts - // are safe to delete, so the pending row must remain as the recovery witness. + // useradd can create the account before reporting an error. Close the + // registry-removal gate before invoking it. CreatePendingIdentity returns a + // nonzero passwd snapshot with errors that happen after it has proved the full + // 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) - if err != nil { + 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 + // releases the creation-intent row. + accountCleanupConfirmed = true return failf("%s: %v", a.P.M("创建用户失败", "create user failed"), err) } // Keep a separately named rollback witness. A failed MarkManagedExpected call @@ -1135,6 +1140,9 @@ func (a *App) runInvite(username, host string, port, hours int, wantSudo, wantAu } return cleanupErr }) + if err != nil { + return failf("%s: %v", a.P.M("创建用户失败", "create user failed"), err) + } rec.UID = pw.UID // Persist the UID while the passwd entry still carries PendingGECOS. An older @@ -1443,9 +1451,13 @@ func (a *App) rollbackInviteAccount(username string, rec registry.Record, expect return a.reconcileDeletionStarted(rec) } if mayDelete { - return a.Users.DeleteExpected(username, expected, func() error { - return a.finalScheduledAccountCheck(username, expected) - }) + // The account vanished after this process captured its complete identity. + // Enter UID-only recovery before the narrow mail sweep so an unlink/fsync + // failure cannot leave an ordinary pending row that a later revoke drops. + if err := a.persistDeletionStarted(rec, true, expected); err != nil { + return fmt.Errorf("persist rollback deletion recovery: %w", err) + } + return a.Users.DeleteExpected(username, expected, func() error { return nil }) } return nil } diff --git a/internal/cli/manage_root_test.go b/internal/cli/manage_root_test.go index 4c3644b..e5457ab 100644 --- a/internal/cli/manage_root_test.go +++ b/internal/cli/manage_root_test.go @@ -150,6 +150,51 @@ func mustUserManaged(t *testing.T, name string) bool { return managed } +func TestRunInviteReleasesIntentWhenCreatePreflightFails(t *testing.T) { + dir := rootOwnedDir(t) + username := "ltapreflight" + if inUse, err := user.NameInUse(username); err != nil { + t.Fatal(err) + } else if inUse { + t.Skipf("test username %s is already in use", username) + } + + a, _, errb := newTestApp(t, "") + regDir := filepath.Join(dir, "registry") + a.Registry = ®istry.Store{ + Dir: regDir, File: filepath.Join(regDir, "registry.tsv"), Lock: filepath.Join(regDir, "registry.lock"), + } + wantErr := errors.New("unsafe mail root") + a.Users = &user.Manager{ + Runner: failedCreateRunner{}, + ValidateManagedMailRoots: func() error { return wantErr }, + PrepareManagedHome: func(string) error { + t.Fatal("managed Home preflight ran after mail-root preflight failed") + return nil + }, + } + a.Scheduler = &schedule.Scheduler{ + SystemdDir: filepath.Join(dir, "systemd"), InstallPath: filepath.Join(dir, "linux-temp-admin"), + UnitPrefix: config.AutoRevokeUnitPrefix, Now: a.Now, Sys: fakeSys{}, + } + a.RandHex = func(n int) (string, error) { + if n == 16 { + return "0123456789abcdef0123456789abcdef", nil + } + return "abcdef0123", nil + } + + if rc := a.runInvite(username, "192.0.2.1", 22, 1, false, true, loginPlan{verified: true}); rc != 1 { + t.Fatalf("runInvite rc=%d, want preflight failure", rc) + } + if found, err := a.Registry.Contains(username); err != nil || found { + t.Fatalf("creation intent after preflight failure: found=%v err=%v", found, err) + } + if !strings.Contains(errb.String(), wantErr.Error()) || strings.Contains(errb.String(), "account artifact cleanup is unconfirmed") { + t.Fatalf("preflight rollback output = %q", errb.String()) + } +} + func TestRunInviteRetainsPendingRegistryWhenCreateHelperReportsFailure(t *testing.T) { dir := rootOwnedDir(t) username := "" diff --git a/internal/cli/revoke.go b/internal/cli/revoke.go index aba03be..e0bfde9 100644 --- a/internal/cli/revoke.go +++ b/internal/cli/revoke.go @@ -109,6 +109,11 @@ func legacyRecoveryAuthorized(identityBound bool, opts revokeOptions, stdinTTY b opts.generation == "" && opts.expectedUID == 0 && !opts.yes && opts.liveConfirmed } +func pendingRecoveryAuthorized(opts revokeOptions, stdinTTY bool) bool { + return stdinTTY && opts.manualInvocation && opts.force && + opts.generation == "" && opts.expectedUID == 0 && !opts.yes && opts.liveConfirmed +} + func (a *App) parseRevokeArgs(args []string) (revokeOptions, bool) { fs := flag.NewFlagSet("revoke", flag.ContinueOnError) fs.SetOutput(a.Err) @@ -236,17 +241,22 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int { return 1 } - // A pending row was written before useradd and never became an active account - // record, so it cannot authorize deletion. Failed-invite rollback converts the - // row to a non-pending UID-only deletion witness immediately before userdel; if - // that attempt crashes while the account is still live, recovery deliberately - // comes back through the interactive --force path below rather than this branch. - if registered && rec.Pending { + stdinTTY := a.StdinIsTTY != nil && a.StdinIsTTY() + // A pending row was written before useradd and is not ordinary deletion + // authority; it may still carry the initial UID 0 or a later captured UID. + // Releases before v2.9.2 could nevertheless retain that row + // after capturing an exact pending-generation passwd identity. Permit recovery + // only after a direct interactive --force/full-name confirmation and a complete + // marker/Home/UID/GID shape match. The deletion transition below then strips the + // 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 { 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( - "该登记仍处于创建中的 pending 状态,无法证明当前同名账号的身份;已保留账号和登记,请人工核查后处理。", - "the registry row is still a pending creation intent, so the current account's identity cannot be proved; the account and registry record were retained for manual recovery.")) + "该登记仍处于创建中的 pending 状态,不能直接证明当前同名账号的身份;已保留账号和登记。人工核查后,只能在交互终端运行 revoke --user "+username+" --force,并输入完整用户名;程序还会严格核对 pending 世代与账号形状。", + "the registry row is still a pending creation intent and does not directly prove the current same-name identity; the account and registry record were retained. After manual inspection, recovery requires revoke --user "+username+" --force in an interactive terminal, the full username confirmation, and an exact pending-generation account-shape match.")) if cleanupErr != nil { a.errorf("%s: %v", a.P.M("清理 pending 账号的遗留授权或任务未完整完成", "cleanup of grants or schedules for the pending account did not complete"), cleanupErr) } @@ -271,9 +281,11 @@ func (a *App) revokeOptionsLocked(opts revokeOptions) int { // timers used the same --yes --force --confirm-force argv that an operator could // type, so no non-interactive invocation receives this exception. Scheduled and // uninstall-internal invocations remain blocked even though they carry --force. - stdinTTY := a.StdinIsTTY != nil && a.StdinIsTTY() allowLegacy := legacyRecoveryAuthorized(rec.IdentityBound, opts, stdinTTY) protected := user.IsProtectedRevokeEntry(username, pw, true, registered, rec.UID, rec.Generation, allowLegacy) + if pendingRecovery { + protected = false + } manualOnlyRecovery := registered && rec.DeletionStarted && (!rec.IdentityBound || !deletionRecordMatchesPasswd(rec, pw)) if registered && rec.DeletionStarted && rec.IdentityBound && !deletionRecordMatchesPasswd(rec, pw) { @@ -451,17 +463,18 @@ func (a *App) teardownLocalAccount(username string, expected user.Passwd, persis if err := a.accountStillMatches(username, expected); err != nil { return revokeDeleteAccount, err } + // Persist recovery authority before controlled mail/Home cleanup begins. The + // account can disappear out of band at any later syscall boundary; without this + // witness, a failed post-disappearance mail fsync could be mistaken on retry for + // an ordinary stale row and discarded without completing the narrow cleanup. + 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 { - if err := a.finalScheduledAccountCheck(username, expected); err != nil { - return err - } - if persistDeletion == nil { - return fmt.Errorf("deletion recovery persistence is not configured") - } - if err := persistDeletion(); err != nil { - return fmt.Errorf("persist deletion-started recovery state: %w", err) - } - return nil + return a.finalScheduledAccountCheck(username, expected) }); err != nil { return revokeDeleteAccount, err } @@ -504,11 +517,21 @@ func deletionRecordMatchesPasswd(rec registry.Record, pw user.Passwd) bool { return marker == wantPrefix+rec.Generation } -// persistDeletionStarted writes the mandatory pre-userdel witness. Completed -// generation-bound accounts retain that exact generation; legacy, unregistered, -// and pending rollback paths deliberately receive only a UID witness. Any change -// between the policy decision and this final transition stops before userdel can -// release the name or UID. +func pendingCreationRecordMatchesPasswd(rec registry.Record, pw user.Passwd) bool { + if !rec.Pending || rec.DeletionStarted || !rec.IdentityBound || + (rec.UID != 0 && rec.UID != pw.UID) { + return false + } + check := rec + check.UID = pw.UID + return deletionRecordMatchesPasswd(check, pw) +} + +// persistDeletionStarted writes the mandatory pre-artifact-cleanup witness. +// Completed generation-bound accounts retain that exact generation; legacy, +// unregistered, and pending rollback paths deliberately receive only a UID +// witness. Any change between the policy decision and this transition stops +// before controlled mail/Home cleanup or userdel can release the name or UID. func (a *App) persistDeletionStarted(rec registry.Record, registered bool, expected user.Passwd) error { if a.Registry == nil { return fmt.Errorf("registry is not configured") diff --git a/internal/cli/revoke_test.go b/internal/cli/revoke_test.go index ea9583b..d7bdaae 100644 --- a/internal/cli/revoke_test.go +++ b/internal/cli/revoke_test.go @@ -81,6 +81,7 @@ func newOrderedTeardownApp(t *testing.T, pw user.Passwd, failClearCall int, clea Users: &user.Manager{ Runner: &orderedTeardownRunner{events: &events, present: &present}, LookupUser: lookup, + NameInUse: func(string) (bool, error) { return false, nil }, RemoveManagedMail: appendArtifact("mail"), RemoveManagedHome: appendArtifact("home"), }, @@ -161,6 +162,85 @@ func TestLegacyRecoveryAuthorizationRequiresInteractiveConfirmation(t *testing.T } } +func TestPendingRecoveryAuthorizationRequiresInteractiveConfirmation(t *testing.T) { + base := revokeOptions{ + username: "xxvcc-pending1", + force: true, + manualInvocation: true, + liveConfirmed: true, + } + tests := []struct { + name string + stdinTTY bool + mutate func(*revokeOptions) + wantAuthorized bool + }{ + {name: "interactive direct recovery", stdinTTY: true, wantAuthorized: true}, + {name: "piped full-name confirmation"}, + {name: "noninteractive yes", stdinTTY: true, mutate: func(o *revokeOptions) { o.yes = true }}, + {name: "uninstall internal", stdinTTY: true, mutate: func(o *revokeOptions) { o.manualInvocation = false }}, + {name: "without force", stdinTTY: true, mutate: func(o *revokeOptions) { o.force = false }}, + {name: "without full-name confirmation", stdinTTY: true, mutate: func(o *revokeOptions) { o.liveConfirmed = false }}, + {name: "generation-bound task", stdinTTY: true, mutate: func(o *revokeOptions) { + o.expectedUID = 1001 + o.generation = "0123456789abcdef0123456789abcdef" + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opts := base + if tc.mutate != nil { + tc.mutate(&opts) + } + if got := pendingRecoveryAuthorized(opts, tc.stdinTTY); got != tc.wantAuthorized { + t.Fatalf("pendingRecoveryAuthorized = %v, want %v", got, tc.wantAuthorized) + } + }) + } +} + +func TestPendingCreationRecordRequiresExactBoundShape(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + pw := user.Passwd{ + Name: "xxvcc-pending1", UID: 1001, GID: 1001, + GECOS: config.PendingGenerationGECOSPrefix + generation, + Home: "/home/xxvcc-pending1", Shell: "/bin/sh", + } + base := registry.Record{ + User: pw.Name, Generation: generation, IdentityBound: true, Pending: true, Port: 22, + } + if !pendingCreationRecordMatchesPasswd(base, pw) { + t.Fatal("exact UID-zero pending identity was rejected") + } + withUID := base + withUID.UID = pw.UID + if !pendingCreationRecordMatchesPasswd(withUID, pw) { + t.Fatal("exact UID-bound pending identity was rejected") + } + + tests := []struct { + name string + mutate func(*registry.Record, *user.Passwd) + }{ + {name: "wrong recorded UID", mutate: func(r *registry.Record, _ *user.Passwd) { r.UID = 1002 }}, + {name: "not pending", mutate: func(r *registry.Record, _ *user.Passwd) { r.Pending = false }}, + {name: "unbound row", mutate: func(r *registry.Record, _ *user.Passwd) { r.IdentityBound = false }}, + {name: "deletion already started", mutate: func(r *registry.Record, _ *user.Passwd) { r.DeletionStarted = true }}, + {name: "wrong marker", mutate: func(_ *registry.Record, p *user.Passwd) { p.GECOS = config.ManagedGenerationGECOSPrefix + generation }}, + {name: "wrong home", mutate: func(_ *registry.Record, p *user.Passwd) { p.Home = "/srv/pending" }}, + {name: "root UID", mutate: func(_ *registry.Record, p *user.Passwd) { p.UID = 0 }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec, gotPW := base, pw + tc.mutate(&rec, &gotPW) + if pendingCreationRecordMatchesPasswd(rec, gotPW) { + t.Fatal("unsafe pending identity was accepted") + } + }) + } +} + func TestInteractiveRevokeBindsConfirmationToAccountGeneration(t *testing.T) { const ( username = "xxvcc-confirm1" @@ -296,6 +376,68 @@ func TestInteractiveLegacyAndUnregisteredDeletionPersistUIDWitnessBeforeUserdel( } } +func TestInteractivePendingCreationRecoveryPersistsUIDWitnessBeforeUserdel(t *testing.T) { + requireRootRegistryFixture(t) + const ( + username = "xxvcc-pending2" + 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} + runner.beforeUserdel = func() { + got, found, err := a.Registry.Lookup(username) + if err != nil || !found || !got.DeletionStarted || got.UID != pw.UID || + got.IdentityBound || got.Generation != "" || got.Pending { + t.Fatalf("pending recovery pre-userdel witness: found=%v rec=%+v err=%v", found, got, err) + } + } + 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 } + a.Scheduler = &schedule.Scheduler{ + SystemdDir: t.TempDir(), InstallPath: t.TempDir() + "/linux-temp-admin", + UnitPrefix: config.AutoRevokeUnitPrefix, Sys: revokeTestScheduleSystem{}, + } + + if rc := a.revokeOptionsLocked(revokeOptions{ + username: username, force: true, manualInvocation: true, liveConfirmed: true, + }); rc != 0 { + t.Fatalf("interactive pending recovery rc = %d", rc) + } + if present || !strings.Contains(strings.Join(events, ","), "userdel") { + t.Fatalf("pending recovery account state: present=%v events=%v", present, events) + } + if found, err := a.Registry.Contains(username); err != nil || found { + t.Fatalf("completed pending recovery witness: found=%v err=%v", found, err) + } +} + func TestUnregisteredDeletionWitnessWriteFailureBlocksUserdel(t *testing.T) { requireRootRegistryFixture(t) const username = "xxvcc-recovery2" @@ -415,8 +557,9 @@ func TestTeardownLocalAccountOrdersFinalCleanupBeforeUserdel(t *testing.T) { requireTeardownEvents(t, *events, "chage", "usermod", "kill", "clear", "drain", "kill", "clear", + "persist", "mail", "home", - "kill", "clear", "persist", + "kill", "clear", "userdel", "mail", ) } @@ -507,8 +650,67 @@ func TestTeardownLocalAccountPersistsDeletionPhaseBeforeUserdel(t *testing.T) { requireTeardownEvents(t, *events, "chage", "usermod", "kill", "clear", "drain", "kill", "clear", - "mail", "home", "kill", "clear", "persist", "userdel", "mail", + "persist", "mail", "home", "kill", "clear", "userdel", "mail", + ) +} + +func TestAccountDisappearanceDuringTeardownRetainsMailRecoveryWitness(t *testing.T) { + const generation = "0123456789abcdef0123456789abcdef" + pw := user.Passwd{ + Name: "xxvcc-a1", UID: 1001, GID: 1001, + GECOS: config.ManagedGenerationGECOSPrefix + generation, + Home: "/home/xxvcc-a1", Shell: "/bin/sh", + } + rec := registry.Record{ + User: pw.Name, UID: pw.UID, Generation: generation, IdentityBound: true, Port: 22, + } + a, events, present := newOrderedTeardownApp(t, pw, 0, nil) + setTestRegistryRecord(t, a, rec) + wantErr := errors.New("mail absence fsync failed") + mailCalls := 0 + a.Users.RemoveManagedMail = func(got user.Passwd) error { + if got.Name != pw.Name || got.UID != pw.UID { + t.Fatalf("mail recovery identity = %+v, want %+v", got, pw) + } + mailCalls++ + *events = append(*events, "mail") + if mailCalls == 2 { + return wantErr + } + return nil + } + + stage, err := a.teardownLocalAccount(pw.Name, pw, func() error { + if err := a.persistDeletionStarted(rec, true, pw); err != nil { + return err + } + *events = append(*events, "persist") + *present = false + return nil + }) + if stage != revokeDeleteAccount || !errors.Is(err, wantErr) { + t.Fatalf("teardownLocalAccount = stage %v, err %v; want retained mail failure", stage, err) + } + stored, found, lookupErr := a.Registry.Lookup(pw.Name) + if lookupErr != nil || !found || !stored.DeletionStarted || stored.UID != pw.UID || stored.Generation != generation { + t.Fatalf("mail recovery witness = found %v record %+v err %v", found, stored, lookupErr) + } + requireTeardownEvents(t, *events, + "chage", "usermod", + "kill", "clear", "drain", "kill", "clear", + "persist", "mail", "mail", ) + + a.Users.RemoveManagedMail = func(user.Passwd) error { return nil } + if err := a.reconcileDeletionStarted(stored); err != nil { + t.Fatalf("retry post-deletion mail recovery: %v", err) + } + if err := a.releaseRegistryAfterCleanup(pw.Name); err != nil { + t.Fatalf("release recovered registry witness: %v", err) + } + if found, err := a.Registry.Contains(pw.Name); err != nil || found { + t.Fatalf("recovered registry witness remains: found=%v err=%v", found, err) + } } func TestDeletionPhaseRegistryWriteFailureBlocksUserdel(t *testing.T) { diff --git a/internal/registry/record.go b/internal/registry/record.go index 487dc7a..6fc7092 100644 --- a/internal/registry/record.go +++ b/internal/registry/record.go @@ -61,10 +61,11 @@ type Record struct { // authorize unattended deletion; the creating process separately retains its // complete passwd snapshot for an immediate rollback. Pending bool - // DeletionStarted is written after the destructive identity policy and final - // quiescence checks, immediately before userdel. If the helper removes the - // account but the final mail-spool sweep fails, this root-owned phase witness - // authorizes a later retry of that narrow cleanup. A generation-bound row keeps + // DeletionStarted is written after the destructive identity policy and the + // pre-artifact quiescence checks, before controlled mail/Home cleanup and + // userdel. If the helper removes the account but the final mail-spool sweep + // fails, this root-owned phase witness authorizes a later retry of that narrow + // cleanup. A generation-bound row keeps // its exact identity; legacy, unregistered, and rollback-pending paths become // non-pending UID-only recovery rows. An ordinary stale row for an account // removed outside the tool leaves this false. diff --git a/internal/registry/store.go b/internal/registry/store.go index fba26a9..faea45a 100644 --- a/internal/registry/store.go +++ b/internal/registry/store.go @@ -343,12 +343,13 @@ func (s *Store) Record(rec Record) error { }) } -// BeginDeletion durably enters the phase immediately before userdel. A non-empty -// generation can only mark an existing, completed identity-bound row with the -// same user, UID, and generation. An empty generation requests a UID-only -// recovery witness: an existing legacy row is converted, an unregistered name is -// inserted, and a rollback-pending row is deliberately stripped of pending and -// generation authority. Repeating the exact same transition is harmless. +// BeginDeletion durably enters the phase before controlled mail/Home cleanup and +// userdel. A non-empty generation can only mark an existing, completed +// identity-bound row with the same user, UID, and generation. An empty generation +// requests a UID-only recovery witness: an existing legacy row is converted, an +// unregistered name is inserted, and a rollback-pending row is deliberately +// stripped of pending and generation authority. Repeating the exact same +// transition is harmless. func (s *Store) BeginDeletion(user string, uid int, generation string) error { if err := validateDeletionIdentity(user, uid, generation); err != nil { return err diff --git a/internal/user/user.go b/internal/user/user.go index 329e489..4407df7 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -33,7 +33,11 @@ var passwdPath = "/etc/passwd" const maxLocalPasswdBytes = 64 << 20 var ( - nssCommandOptions = executil.Options{ + // ErrAccountCreationNotStarted marks failures that happened before useradd was + // invoked. A transaction may release its creation-intent witness for this + // class after independently confirming that the account name is still absent. + ErrAccountCreationNotStarted = errors.New("account creation was not started") + nssCommandOptions = executil.Options{ Timeout: 10 * time.Second, MaxOutput: 256 << 10, ExtraEnv: []string{"LC_ALL=C", "LANG=C"}, @@ -453,25 +457,29 @@ func (execRunner) Look(name string) bool { _, err := exec.LookPath(name); return // Manager performs account mutations via its Runner. type Manager struct { - Runner Runner - LookupUser func(string) (Passwd, bool, error) - PrepareManagedHome func(string) error - CreateManagedHome func(Passwd) error - ValidateManagedHome func(Passwd) error - RemoveManagedMail func(Passwd) error - RemoveManagedHome func(Passwd) error + Runner Runner + LookupUser func(string) (Passwd, bool, error) + NameInUse func(string) (bool, error) + ValidateManagedMailRoots func() error + PrepareManagedHome func(string) error + CreateManagedHome func(Passwd) error + ValidateManagedHome func(Passwd) error + RemoveManagedMail func(Passwd) error + RemoveManagedHome func(Passwd) error } // New returns a Manager using real command execution. func New() *Manager { return &Manager{ - Runner: execRunner{}, - LookupUser: Lookup, - PrepareManagedHome: prepareManagedHome, - CreateManagedHome: createManagedHome, - ValidateManagedHome: validateCreatedHome, - RemoveManagedMail: removeManagedMail, - RemoveManagedHome: removeManagedHome, + Runner: execRunner{}, + LookupUser: Lookup, + NameInUse: NameInUse, + ValidateManagedMailRoots: validateManagedMailRoots, + PrepareManagedHome: prepareManagedHome, + CreateManagedHome: createManagedHome, + ValidateManagedHome: validateCreatedHome, + RemoveManagedMail: removeManagedMail, + RemoveManagedHome: removeManagedHome, } } @@ -530,7 +538,17 @@ func DefaultHome(name string) (string, error) { func (m *Manager) create(name, shell, gecos string, shouldCreateHome bool) (Passwd, error) { if err := validateMutationName(name); err != nil { - return Passwd{}, err + return Passwd{}, fmt.Errorf("%w: %w", ErrAccountCreationNotStarted, err) + } + validateMailRoots := m.ValidateManagedMailRoots + if validateMailRoots == nil { + validateMailRoots = validateManagedMailRoots + } + // Mail-root metadata does not depend on the UID selected by useradd. Reject a + // persistently unsafe layout before creating a pending account, then repeat the + // same validation while doing the UID-bound cleanup below to close path races. + if err := validateMailRoots(); err != nil { + return Passwd{}, fmt.Errorf("%w: validate managed mail roots before account creation: %w", ErrAccountCreationNotStarted, err) } home := managedHome(name) prepare := m.PrepareManagedHome @@ -538,7 +556,7 @@ func (m *Manager) create(name, shell, gecos string, shouldCreateHome bool) (Pass prepare = prepareManagedHome } if err := prepare(name); err != nil { - return Passwd{}, fmt.Errorf("prepare managed home: %w", err) + return Passwd{}, fmt.Errorf("%w: prepare managed home: %w", ErrAccountCreationNotStarted, err) } var err error switch { @@ -551,7 +569,7 @@ func (m *Manager) create(name, shell, gecos string, shouldCreateHome bool) (Pass err = m.Runner.Run("useradd", "-M", "-d", home, "-s", shell, "-c", gecos, "-e", expiredDate, "-p", initialLockedPasswordHash, name) default: - return Passwd{}, fmt.Errorf("useradd not available") + return Passwd{}, fmt.Errorf("%w: useradd not available", ErrAccountCreationNotStarted) } if err != nil { return Passwd{}, err @@ -595,13 +613,13 @@ func (m *Manager) create(name, shell, gecos string, shouldCreateHome bool) (Pass // credential. A different owner or special file fails closed and retains the // pending account to keep the name and UID occupied. if err := m.ClearManagedMailExpected(name, pw); err != nil { - return Passwd{}, fmt.Errorf("clear mail spool before using account identity: %w; account retained for manual recovery", err) + return pw, fmt.Errorf("clear mail spool before using account identity: %w; account retained for rollback or manual recovery", err) } if !shouldCreateHome { return pw, nil } if err := m.CreateManagedHomeExpected(name, pw); err != nil { - return Passwd{}, fmt.Errorf("%w; account retained for manual recovery", err) + return pw, fmt.Errorf("%w; account retained for rollback or manual recovery", err) } return pw, nil } @@ -852,18 +870,17 @@ func (m *Manager) delete(name string, expected *Passwd, beforeDelete func() erro // narrower and independently owner-checked, so it remains recoverable. Sweep // twice around an absence recheck to catch an in-flight delivery without ever // touching Home, jobs, processes, or an account helper. - if err := m.removeManagedMail(*expected); err != nil { - return err - } - if err := m.removeManagedMail(*expected); err != nil { - return err - } - absent, err = m.deletionState(name, expected) - if err != nil { - return fmt.Errorf("verify account remained absent after artifact cleanup: %w", err) - } - if !absent { - return fmt.Errorf("account %s reappeared during artifact cleanup", name) + for sweep := 1; sweep <= 2; sweep++ { + if err := m.removeManagedMail(*expected); err != nil { + return err + } + absent, err = m.deletionState(name, expected) + if err != nil { + return fmt.Errorf("verify account remained absent after artifact cleanup sweep %d: %w", sweep, err) + } + if !absent { + return fmt.Errorf("account %s reappeared during artifact cleanup sweep %d", name, sweep) + } } return nil } @@ -1009,17 +1026,21 @@ func (m *Manager) ReconcileManagedMailAfterDeletion(name string, uid int) error if !validate.AccountID(uid) { return fmt.Errorf("invalid expected account UID for post-deletion mail cleanup") } - if _, exists, err := m.lookup(name); err != nil { + absent, err := m.deletionState(name, nil) + if err != nil { return fmt.Errorf("verify account absence before managed mail cleanup: %w", err) - } else if exists { + } + if !absent { return fmt.Errorf("account %s exists; refusing post-deletion mail cleanup", name) } if err := m.removeManagedMail(Passwd{Name: name, UID: uid}); err != nil { return err } - if _, exists, err := m.lookup(name); err != nil { + absent, err = m.deletionState(name, nil) + if err != nil { return fmt.Errorf("verify account absence after managed mail cleanup: %w", err) - } else if exists { + } + if !absent { return fmt.Errorf("account %s reappeared during post-deletion mail cleanup", name) } return nil @@ -1199,6 +1220,20 @@ func syncRemovalParent(dir *os.File, operation string) error { return nil } +func syncAndConfirmManagedMailAbsent(dir *os.File, root, name, operation string) error { + if err := syncRemovalParent(dir, operation); err != nil { + return err + } + var spool unix.Stat_t + if err := unix.Fstatat(int(dir.Fd()), name, &spool, unix.AT_SYMLINK_NOFOLLOW); !errors.Is(err, unix.ENOENT) { + if err == nil { + return fmt.Errorf("managed mail spool %s/%s reappeared during cleanup", root, name) + } + return fmt.Errorf("verify managed mail spool absence after directory sync %s/%s: %w", root, name, err) + } + return nil +} + // removeManagedMail removes only a conventional single-file system mailbox // still owned by the captured account UID. Account helpers are intentionally // invoked without recursive-home flags, so this preserves the mail-spool part of @@ -1207,6 +1242,24 @@ func removeManagedMail(expected Passwd) error { if !validate.Username(expected.Name) || !validate.AccountID(expected.UID) { return fmt.Errorf("invalid expected account identity for mail cleanup") } + return visitManagedMailRoots(func(root string) error { + return removeManagedMailAt(root, expected) + }) +} + +// validateManagedMailRoots checks directory metadata before useradd. The real +// cleanup opens and validates each root again while the selected UID is bound. +func validateManagedMailRoots() error { + return visitManagedMailRoots(func(root string) error { + dir, err := openManagedMailRoot(root) + if err != nil { + return err + } + return dir.Close() + }) +} + +func visitManagedMailRoots(visit func(string) error) error { allowed := make(map[string]bool, len(managedMailRoots)) for _, root := range managedMailRoots { clean := filepath.Clean(root) @@ -1234,34 +1287,46 @@ func removeManagedMail(expected Passwd) error { continue } seen[resolved] = true - if err := removeManagedMailAt(resolved, expected); err != nil { + if err := visit(resolved); err != nil { return err } } return nil } -func removeManagedMailAt(root string, expected Passwd) error { +func openManagedMailRoot(root string) (*os.File, error) { dir, err := os.OpenFile(root, os.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0) if err != nil { - return fmt.Errorf("open managed mail root %s: %w", root, err) + return nil, fmt.Errorf("open managed mail root %s: %w", root, err) } - defer dir.Close() fi, err := dir.Stat() if err != nil { - return fmt.Errorf("stat managed mail root %s: %w", root, err) + dir.Close() + return nil, fmt.Errorf("stat managed mail root %s: %w", root, err) } st, ok := fi.Sys().(*syscall.Stat_t) - if !ok || !fi.IsDir() || st.Uid != 0 || fi.Mode().Perm()&0o002 != 0 { - return fmt.Errorf("managed mail root %s is not a root-owned, non-world-writable directory", root) + mode := fi.Mode() + worldWritableWithoutSticky := mode.Perm()&0o002 != 0 && mode&os.ModeSticky == 0 + if !ok || !fi.IsDir() || st.Uid != 0 || mode&os.ModeSetuid != 0 || worldWritableWithoutSticky { + dir.Close() + return nil, fmt.Errorf("managed mail root %s is not a safe root-owned directory (world-writable roots require sticky protection and setuid is forbidden)", root) + } + return dir, nil +} + +func removeManagedMailAt(root string, expected Passwd) error { + dir, err := openManagedMailRoot(root) + if err != nil { + return err } + defer dir.Close() var spool unix.Stat_t err = unix.Fstatat(int(dir.Fd()), expected.Name, &spool, unix.AT_SYMLINK_NOFOLLOW) if errors.Is(err, unix.ENOENT) { // This can be a retry after unlink succeeded but the previous directory sync // failed. Re-sync the observed absence before allowing the UID to be released. - return syncRemovalParent(dir, "managed mail spool absence confirmation") + return syncAndConfirmManagedMailAbsent(dir, root, expected.Name, "managed mail spool absence confirmation") } if err != nil { return fmt.Errorf("inspect managed mail spool %s/%s: %w", root, expected.Name, err) @@ -1274,7 +1339,7 @@ func removeManagedMailAt(root string, expected Passwd) error { } if err := unlinkManagedMailAt(int(dir.Fd()), expected.Name, 0); err != nil { if errors.Is(err, unix.ENOENT) { - return syncRemovalParent(dir, "managed mail spool absence confirmation") + return syncAndConfirmManagedMailAbsent(dir, root, expected.Name, "managed mail spool absence confirmation") } return fmt.Errorf("remove managed mail spool %s/%s: %w", root, expected.Name, err) } @@ -1284,7 +1349,7 @@ func removeManagedMailAt(root string, expected Passwd) error { } return fmt.Errorf("verify managed mail spool removal %s/%s: %w", root, expected.Name, err) } - return syncRemovalParent(dir, "managed mail spool removal") + return syncAndConfirmManagedMailAbsent(dir, root, expected.Name, "managed mail spool removal") } const ( @@ -1488,6 +1553,17 @@ func (m *Manager) deletionState(name string, expected *Passwd) (absent bool, err return false, err } if !exists { + nameInUse := m.NameInUse + if nameInUse == nil { + nameInUse = NameInUse + } + inUse, err := nameInUse(name) + if err != nil { + return false, fmt.Errorf("confirm account absence through NSS: %w", err) + } + if inUse { + return false, fmt.Errorf("account name %s remains present through NSS after local account disappearance", name) + } return true, nil } if expected != nil && current != *expected { diff --git a/internal/user/user_test.go b/internal/user/user_test.go index c0dcbe0..9c339e8 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -448,18 +448,21 @@ func (f *fakeRunner) Look(name string) bool { return f.available[name] } func managerWithStubbedHomeChecks(r Runner) *Manager { return &Manager{ - Runner: r, - PrepareManagedHome: func(string) error { return nil }, - CreateManagedHome: func(Passwd) error { return nil }, - ValidateManagedHome: func(Passwd) error { return nil }, - RemoveManagedMail: func(Passwd) error { return nil }, - RemoveManagedHome: func(Passwd) error { return nil }, + Runner: r, + NameInUse: func(string) (bool, error) { return false, nil }, + ValidateManagedMailRoots: func() error { return nil }, + PrepareManagedHome: func(string) error { return nil }, + CreateManagedHome: func(Passwd) error { return nil }, + ValidateManagedHome: func(Passwd) error { return nil }, + RemoveManagedMail: func(Passwd) error { return nil }, + RemoveManagedHome: func(Passwd) error { return nil }, } } func managerWithStubbedHomeRemoval(r Runner) *Manager { return &Manager{ Runner: r, + NameInUse: func(string) (bool, error) { return false, nil }, RemoveManagedMail: func(Passwd) error { return nil }, RemoveManagedHome: func(Passwd) error { return nil }, } @@ -904,6 +907,7 @@ func TestReconcileManagedMailAfterDeletionRequiresContinuousAbsence(t *testing.T mailCalls := 0 m := &Manager{ LookupUser: func(string) (Passwd, bool, error) { return Passwd{}, false, nil }, + NameInUse: func(string) (bool, error) { return false, nil }, RemoveManagedMail: func(got Passwd) error { mailCalls++ if got.Name != name || got.UID != 1001 || got.GID != 0 || got.Home != "" { @@ -934,6 +938,7 @@ func TestReconcileManagedMailAfterDeletionRequiresContinuousAbsence(t *testing.T } return replacement, true, nil }, + NameInUse: func(string) (bool, error) { return false, nil }, RemoveManagedMail: func(Passwd) error { return nil }, } err := m.ReconcileManagedMailAfterDeletion(name, 1001) @@ -941,6 +946,38 @@ func TestReconcileManagedMailAfterDeletionRequiresContinuousAbsence(t *testing.T t.Fatalf("reconciliation error = %v, want replacement refusal", err) } }) + + t.Run("NSS identity blocks local-absence cleanup", func(t *testing.T) { + mailCalls := 0 + m := &Manager{ + LookupUser: func(string) (Passwd, bool, error) { return Passwd{}, false, nil }, + NameInUse: func(string) (bool, error) { return true, nil }, + RemoveManagedMail: func(Passwd) error { mailCalls++; return nil }, + } + err := m.ReconcileManagedMailAfterDeletion(name, 1001) + if err == nil || !strings.Contains(err.Error(), "remains present through NSS") { + t.Fatalf("NSS replacement error = %v, want fail-closed refusal", err) + } + if mailCalls != 0 { + t.Fatalf("NSS identity reached mail cleanup %d time(s)", mailCalls) + } + }) + + t.Run("NSS lookup failure is not absence", func(t *testing.T) { + wantErr := errors.New("NSS unavailable") + m := &Manager{ + LookupUser: func(string) (Passwd, bool, error) { return Passwd{}, false, nil }, + NameInUse: func(string) (bool, error) { return false, wantErr }, + RemoveManagedMail: func(Passwd) error { + t.Fatal("failed NSS lookup reached mail cleanup") + return nil + }, + } + err := m.ReconcileManagedMailAfterDeletion(name, 1001) + if !errors.Is(err, wantErr) || !strings.Contains(err.Error(), "confirm account absence through NSS") { + t.Fatalf("NSS lookup error = %v, want %v", err, wantErr) + } + }) } func TestMarkManagedExpectedRefusesReplacementBeforeUsermod(t *testing.T) { @@ -985,6 +1022,92 @@ func TestCreateRequiresUseradd(t *testing.T) { } } +func TestCreateValidatesManagedMailRootsBeforeUseradd(t *testing.T) { + wantErr := errors.New("unsafe mail root") + f := &fakeRunner{available: map[string]bool{"useradd": true}} + m := managerWithStubbedHomeChecks(f) + m.ValidateManagedMailRoots = func() error { return wantErr } + m.PrepareManagedHome = func(string) error { + t.Fatal("managed Home preflight ran after mail-root preflight failed") + return nil + } + + err := m.Create("xxvcc-a1", "/bin/sh", testGeneration) + if !errors.Is(err, wantErr) || !strings.Contains(err.Error(), "before account creation") { + t.Fatalf("Create error = %v, want pre-useradd mail-root refusal", err) + } + if !errors.Is(err, ErrAccountCreationNotStarted) { + t.Fatalf("Create error = %v, want creation-not-started classification", err) + } + if len(f.calls) != 0 { + t.Fatalf("unsafe mail root reached useradd: %v", f.calls) + } +} + +func TestCreateRevalidatesMailRootAfterUseraddAndReturnsCapturedIdentity(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("mail-root ownership and mode transition requires root") + } + pendingMarker := config.PendingGenerationGECOSPrefix + testGeneration + expected := Passwd{ + Name: "xxvcc-a1", UID: 2345, GID: 2345, GECOS: pendingMarker, + Home: "/home/xxvcc-a1", Shell: "/bin/sh", + } + setPasswd(t, "xxvcc-a1:x:2345:2345:"+pendingMarker+":/home/xxvcc-a1:/bin/sh\n") + setProcRoot(t, map[int]string{}) + root := t.TempDir() + if err := os.Chown(root, 0, 2346); err != nil { + t.Fatal(err) + } + if err := os.Chmod(root, 0o775|os.ModeSetgid); err != nil { + t.Fatal(err) + } + oldRoots := managedMailRoots + managedMailRoots = []string{root} + t.Cleanup(func() { managedMailRoots = oldRoots }) + + f := &fakeRunner{available: map[string]bool{"useradd": true}} + f.onRun = func(name string) { + if name == "useradd" { + if err := os.Chmod(root, 0o777|os.ModeSetgid); err != nil { + t.Fatal(err) + } + } + } + preflightCalls := 0 + mailCleanupCalls := 0 + m := &Manager{ + Runner: f, + LookupUser: Lookup, + NameInUse: func(string) (bool, error) { return false, nil }, + ValidateManagedMailRoots: func() error { + preflightCalls++ + return validateManagedMailRoots() + }, + PrepareManagedHome: func(string) error { return nil }, + RemoveManagedMail: func(got Passwd) error { + mailCleanupCalls++ + if got != expected { + t.Fatalf("post-useradd mail identity = %+v, want %+v", got, expected) + } + return removeManagedMail(got) + }, + } + got, err := m.CreatePendingIdentity(expected.Name, expected.Shell, testGeneration) + if err == nil || !strings.Contains(err.Error(), "world-writable roots require sticky") { + t.Fatalf("CreatePendingIdentity error = %v, want post-useradd mode refusal", err) + } + if got != expected { + t.Fatalf("captured rollback identity = %+v, want %+v", got, expected) + } + if len(f.calls) != 1 || f.calls[0][0] != "useradd" { + t.Fatalf("post-preflight mode change helper calls = %v", f.calls) + } + if preflightCalls != 1 || mailCleanupCalls != 1 { + t.Fatalf("mail-root preflight/UID cleanup calls = %d/%d, want 1/1", preflightCalls, mailCleanupCalls) + } +} + func TestCreateEnforcesManagedHomeChecks(t *testing.T) { marker := config.ManagedGenerationGECOSPrefix + testGeneration setPasswd(t, "xxvcc-a1:x:2345:2345:"+marker+":/home/xxvcc-a1:/bin/sh\n") @@ -1904,10 +2027,12 @@ func TestRemoveManagedMailRequiresOwnedRegularSpool(t *testing.T) { t.Skip("mail-spool ownership checks require root") } root := t.TempDir() - if err := os.Chown(root, 0, 0); err != nil { + if err := os.Chown(root, 0, 2346); err != nil { t.Fatal(err) } - if err := os.Chmod(root, 0o2775); err != nil { + // The target host that exposed this regression uses the traditional shared + // sticky mail-spool layout: root:mail 3777. + if err := os.Chmod(root, 0o777|os.ModeSetgid|os.ModeSticky); err != nil { t.Fatal(err) } alias := filepath.Join(t.TempDir(), "mail-alias") @@ -1955,6 +2080,45 @@ func TestRemoveManagedMailRequiresOwnedRegularSpool(t *testing.T) { } } +func TestManagedMailRootModePolicy(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("mail-root ownership and special-mode checks require root") + } + expected := Passwd{Name: "xxvcc-u", UID: 2345, GID: 2346, Home: "/home/xxvcc-u"} + tests := []struct { + name string + uid int + gid int + mode os.FileMode + wantOK bool + }{ + {name: "Debian root mail setgid", uid: 0, gid: 2346, mode: 0o775 | os.ModeSetgid, wantOK: true}, + {name: "Arch root sticky shared", uid: 0, gid: 0, mode: 0o777 | os.ModeSticky, wantOK: true}, + {name: "target root mail setgid sticky", uid: 0, gid: 2346, mode: 0o777 | os.ModeSetgid | os.ModeSticky, wantOK: true}, + {name: "world-writable without sticky", uid: 0, gid: 2346, mode: 0o777 | os.ModeSetgid}, + {name: "setuid sticky", uid: 0, gid: 2346, mode: 0o777 | os.ModeSetuid | os.ModeSticky}, + {name: "non-root sticky owner", uid: 1234, gid: 2346, mode: 0o777 | os.ModeSticky}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + if err := os.Chown(root, tc.uid, tc.gid); err != nil { + t.Fatal(err) + } + if err := os.Chmod(root, tc.mode); err != nil { + t.Fatal(err) + } + err := removeManagedMailAt(root, expected) + if tc.wantOK && err != nil { + t.Fatalf("safe mail-root mode %v rejected: %v", tc.mode, err) + } + if !tc.wantOK && (err == nil || !strings.Contains(err.Error(), "not a safe root-owned directory")) { + t.Fatalf("unsafe mail-root mode %v/owner %d:%d error = %v", tc.mode, tc.uid, tc.gid, err) + } + }) + } +} + func TestRemoveManagedMailSyncsParentWhenSpoolDisappearsBeforeUnlink(t *testing.T) { if os.Geteuid() != 0 { t.Skip("mail-spool ownership checks require root") @@ -2000,6 +2164,44 @@ func TestRemoveManagedMailSyncsParentWhenSpoolDisappearsBeforeUnlink(t *testing. } } +func TestRemoveManagedMailRejectsSpoolRecreatedDuringDirectorySync(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("mail-spool ownership checks require root") + } + root := t.TempDir() + if err := os.Chown(root, 0, 0); err != nil { + t.Fatal(err) + } + expected := Passwd{Name: "xxvcc-u", UID: 2345, GID: 2346, Home: "/home/xxvcc-u"} + spool := filepath.Join(root, expected.Name) + if err := os.WriteFile(spool, []byte("old mail\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chown(spool, expected.UID, 8); err != nil { + t.Fatal(err) + } + + oldSync := syncRemovalDirectory + syncRemovalDirectory = func(dir *os.File) error { + if err := os.WriteFile(spool, []byte("new mail\n"), 0o600); err != nil { + return err + } + if err := os.Chown(spool, expected.UID, 8); err != nil { + return err + } + return dir.Sync() + } + t.Cleanup(func() { syncRemovalDirectory = oldSync }) + + err := removeManagedMailAt(root, expected) + if err == nil || !strings.Contains(err.Error(), "reappeared during cleanup") { + t.Fatalf("mail recreation error = %v, want fail-closed refusal", err) + } + if _, err := os.Lstat(spool); err != nil { + t.Fatalf("recreated spool was not retained for recovery: %v", err) + } +} + func TestAbsentManagedArtifactsResyncParentBeforeSuccess(t *testing.T) { if os.Geteuid() != 0 { t.Skip("managed artifact durability checks require root-owned directories") @@ -2137,6 +2339,7 @@ func TestDeleteExpectedAbsentAccountOnlyCleansOwnerCheckedMail(t *testing.T) { var order []string m := &Manager{ LookupUser: func(string) (Passwd, bool, error) { return Passwd{}, false, nil }, + NameInUse: func(string) (bool, error) { return false, nil }, RemoveManagedMail: func(Passwd) error { order = append(order, "mail") return nil @@ -2154,6 +2357,32 @@ func TestDeleteExpectedAbsentAccountOnlyCleansOwnerCheckedMail(t *testing.T) { } } +func TestDeleteExpectedRechecksAbsenceBetweenMailSweeps(t *testing.T) { + expected := Passwd{Name: "xxvcc-u", UID: 1001, GID: 1001, Home: "/home/xxvcc-u", Shell: "/bin/sh"} + var events []string + m := &Manager{ + LookupUser: func(string) (Passwd, bool, error) { + events = append(events, "local") + return Passwd{}, false, nil + }, + NameInUse: func(string) (bool, error) { + events = append(events, "nss") + return false, nil + }, + RemoveManagedMail: func(Passwd) error { + events = append(events, "mail") + return nil + }, + } + if err := m.DeleteExpected(expected.Name, expected, noOpBeforeDelete); err != nil { + t.Fatal(err) + } + want := []string{"local", "nss", "mail", "local", "nss", "mail", "local", "nss"} + if !reflect.DeepEqual(events, want) { + t.Fatalf("absent-account sweep sequence = %v, want %v", events, want) + } +} + func TestDeleteExpectedFinalMailSweepRemovesSpoolRecreatedDuringHomeCleanup(t *testing.T) { expected := Passwd{Name: "xxvcc-u", UID: 1001, GID: 1001, Home: "/home/xxvcc-u", Shell: "/bin/sh"} exists := true From c007c4cb65c709717038c892584197276a1575cc Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Sat, 1 Aug 2026 04:07:50 +0800 Subject: [PATCH 2/2] docs: use standard mail spool terminology --- CHANGELOG.md | 2 +- docs/installing.en.md | 8 ++++---- docs/installing.md | 8 ++++---- docs/operator-guide.en.md | 2 +- docs/operator-guide.md | 2 +- docs/security-model.en.md | 6 +++--- docs/security-model.md | 6 +++--- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9a70b..b7ef103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project are documented here. ## v2.9.2 - 2026-08-01 -- Validate conventional mail-pool roots before `useradd`, then reopen and +- Validate conventional mail-spool roots before `useradd`, then reopen and revalidate them during cleanup bound to the newly selected UID. Accept the root-owned layouts used by supported systems, including `root:mail 3777` and Arch Linux's `root:root 1777`; a world-writable root now requires sticky-bit diff --git a/docs/installing.en.md b/docs/installing.en.md index 9b7b49b..a3b8395 100644 --- a/docs/installing.en.md +++ b/docs/installing.en.md @@ -15,20 +15,20 @@ This guide is for administrators who install and maintain `linux-temp-admin`. Se The binary has no dynamic-library or language-runtime dependency. Account lifecycle operations still use the system's `id`, `useradd`, `userdel`, `usermod`, and `chage`; password login additionally requires `chpasswd`, while granting sudo requires `sudo` and `visudo` for pre-commit policy validation. The tool does not fall back to a distro `adduser`/`deluser` or an arbitrary BusyBox account applet: command names alone cannot prove equivalent arguments, configuration, or compile-time shadow/group semantics. Missing tools can be installed through apt, dnf, yum, or apk after interactive confirmation. -### Conventional mail-pool compatibility boundary +### Conventional mail-spool compatibility boundary Account creation and revocation inspect the actual metadata of `/var/mail` and `/var/spool/mail` instead of allowing a layout by distribution name alone. The FHS does not specify an owner or mode for these directories. The following layouts were checked for this change; they do not make every custom layout automatically supported: -| Distribution/family | Actual mail pool | Other path | Verified owner and mode | +| Distribution/family | Actual mail spool | Other path | Verified owner and mode | | --- | --- | --- | --- | | Debian 12/13, Ubuntu 22.04/24.04 | `/var/mail` | `/var/spool/mail -> ../mail` | `root:mail 2775` | | RHEL, Rocky, Alma, Oracle Linux, Fedora, CentOS, and Amazon Linux families | `/var/spool/mail` | `/var/mail -> spool/mail` | `root:mail 0775` | | Alpine | `/var/mail` | depends on the installation | `root:root 0755` | | current Arch Linux `filesystem` package | `/var/spool/mail` | `/var/mail -> spool/mail` | `root:root 1777` | -The product policy accepts only a real root-owned mail-pool directory. It may belong to group `mail` and carry setgid; if it is world-writable it must have sticky protection, and any setuid bit is refused. The observed `root:mail 3777` and Arch's `root:root 1777` therefore work, while mode `0777`/`2777` without sticky, a non-root owner such as `mail:mail`, or a symlink escaping the two paths fails closed before `useradd`. Mail-delivery services and local identities authorized to write this directory are inside the trust boundary. +The product policy accepts only a real root-owned mail-spool directory. It may belong to group `mail` and carry setgid; if it is world-writable it must have sticky protection, and any setuid bit is refused. The observed `root:mail 3777` and Arch's `root:root 1777` therefore work, while mode `0777`/`2777` without sticky, a non-root owner such as `mail:mail`, or a symlink escaping the two paths fails closed before `useradd`. Mail-delivery services and local identities authorized to write this directory are inside the trust boundary. -This compatibility covers only the traditional single-file mbox at `/var/mail/` or `/var/spool/mail/`. The tool does not traverse Maildir or treat aaPanel's `/www/vmail` as a mail pool. A complete revoke still removes the whole tool-managed Home under its separate Home-safety rules. +This compatibility covers only the traditional single-file mbox at `/var/mail/` or `/var/spool/mail/`. The tool does not traverse Maildir or treat aaPanel's `/www/vmail` as a mail spool. A complete revoke still removes the whole tool-managed Home under its separate Home-safety rules. Arch Linux has no safe partial-upgrade mode, while `pacman -Syu` upgrades the whole system. The tool therefore never runs pacman automatically while creating an account. Complete the prompted upgrade and dependency installation deliberately first. diff --git a/docs/installing.md b/docs/installing.md index dd9e660..c63c989 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -15,20 +15,20 @@ 二进制本身不依赖动态库或语言运行时。账号生命周期仍会使用系统的 `id`、`useradd`、`userdel`、`usermod` 和 `chage`;密码登录还需要 `chpasswd`,授予 sudo 时还需要 `sudo` 和用于写入前策略校验的 `visudo`。程序不回退到发行版 `adduser`/`deluser` 或任意 BusyBox 账号 applet:这些实现的参数、配置及编译期 shadow/group 语义不能仅凭命令名证明与 shadow 工具链等价。缺失依赖可在交互确认后通过 apt、dnf、yum 或 apk 安装。 -### 传统邮件池兼容边界 +### 传统系统邮箱目录兼容边界 账号创建与撤销会检查 `/var/mail` 和 `/var/spool/mail` 的实际元数据,而不是只按发行版名称放行。FHS 没有规定这些目录的属主和模式;以下是本次核验到的常见布局,不代表其他自定义布局自动受支持: -| 发行版/系列 | 实际邮件池 | 另一路径 | 已核验属主与模式 | +| 发行版/系列 | 实际邮箱目录 | 另一路径 | 已核验属主与模式 | | --- | --- | --- | --- | | Debian 12/13、Ubuntu 22.04/24.04 | `/var/mail` | `/var/spool/mail -> ../mail` | `root:mail 2775` | | RHEL、Rocky、Alma、Oracle Linux、Fedora、CentOS、Amazon Linux 系 | `/var/spool/mail` | `/var/mail -> spool/mail` | `root:mail 0775` | | Alpine | `/var/mail` | 依具体安装而定 | `root:root 0755` | | Arch Linux 当前 `filesystem` 包 | `/var/spool/mail` | `/var/mail -> spool/mail` | `root:root 1777` | -产品策略只接受 root-owned 的真实邮件池目录;目录可以属于 `mail` 组并带 setgid,world-writable 时则必须有 sticky bit,同时任何 setuid 都会拒绝。因此现场可见的 `root:mail 3777` 和 Arch 的 `root:root 1777` 均兼容,而无 sticky 的 `0777`/`2777`、`mail:mail` 等非 root 属主及逃出上述两个路径的符号链接会在 `useradd` 前失败关闭。邮件投递服务和获准写入该目录的本地身份属于信任边界。 +产品策略只接受 root-owned 的真实系统邮箱目录;目录可以属于 `mail` 组并带 setgid,world-writable 时则必须有 sticky bit,同时任何 setuid 都会拒绝。因此现场可见的 `root:mail 3777` 和 Arch 的 `root:root 1777` 均兼容,而无 sticky 的 `0777`/`2777`、`mail:mail` 等非 root 属主及逃出上述两个路径的符号链接会在 `useradd` 前失败关闭。邮件投递服务和获准写入该目录的本地身份属于信任边界。 -这里的兼容性只针对 `/var/mail/<用户名>` 或 `/var/spool/mail/<用户名>` 的传统单文件 mbox;程序不会把 Maildir 或宝塔 `/www/vmail` 当作邮件池遍历。完整撤销仍会按独立的 Home 安全规则清理本工具管理的整个 Home。 +这里的兼容性只针对 `/var/mail/<用户名>` 或 `/var/spool/mail/<用户名>` 的传统单文件 mbox;程序不会把 Maildir 或宝塔 `/www/vmail` 当作系统邮箱目录遍历。完整撤销仍会按独立的 Home 安全规则清理本工具管理的整个 Home。 Arch Linux 不允许安全的部分升级,而 `pacman -Syu` 会升级整个系统,因此本工具不会在创建账号时自动运行 pacman。请根据提示由管理员先完成完整升级和依赖安装。 diff --git a/docs/operator-guide.en.md b/docs/operator-guide.en.md index 2bd45ee..a9d582e 100644 --- a/docs/operator-guide.en.md +++ b/docs/operator-guide.en.md @@ -115,7 +115,7 @@ Status reports account identity, UID, expiry, auto-delete task, and registry ano 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. -Mail-specific cleanup handles only a traditional single-file mbox at `/var/mail/` or `/var/spool/mail/`. An existing mail pool must be root-owned and have no setuid bit; a world-writable pool 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 pool 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. +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. Before deleting an `at` job, the tool rereads its body and rechecks the UID or exact revoke command so a reused job ID cannot authorize deletion of an unrelated task. `at` has no atomic compare-and-delete interface, so a very short local-root trust-boundary interval remains between that read and `atrm`. diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 564ddcc..bc78063 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -115,7 +115,7 @@ ssh -i ./USER.key -p PORT USER@HOST 对于仍可用完整身份核对的账号,撤销会先禁用登录,删除并复核个人 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 和进程结果是重复快照,不是原子冻结。任一安全条件、资源上限、任务/进程盘点或按用户名授权无法确认时,都会尝试禁用账号,保留仍存在的账号和登记并返回非零,避免用户名复用后继承旧数据、任务或权限。 -邮件专用清理只处理 `/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。 +邮件专用清理只处理 `/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。 删除 `at` 作业前会重新读取作业正文并再次核对 UID 或精确撤销命令,避免已复用的作业 ID 指向无关任务;`at` 没有原子的比较删除接口,因此重新读取到 `atrm` 之间仍存在本机 root 信任边界内的极短窗口。 diff --git a/docs/security-model.en.md b/docs/security-model.en.md index a8d9abd..527931f 100644 --- a/docs/security-model.en.md +++ b/docs/security-model.en.md @@ -10,7 +10,7 @@ The tool assumes these foundations remain trusted: - the Linux kernel, local root, filesystem, and system account database; - OpenSSH, sudo, systemd or `at`, and the system account-management commands; -- mail-delivery services and local identities that the administrator authorizes to modify entries in an accepted system mail pool; +- mail-delivery services and local identities that the administrator authorizes to modify entries in an accepted system mail spool; - the download trust chain explicitly chosen during installation; - the operator's private channel used to deliver an invite. @@ -93,8 +93,8 @@ If revoke cannot completely remove a name-scoped grant, it retains and attempts - the registry validates schema, fields, UID, generation, and size and fails closed when corrupt or unreadable; - 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 pool must be a real root-owned directory with no setuid bit; a world-writable pool 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; -- creation preflights the mail pools before `useradd`, then reopens and revalidates them while the account is bound to its selected UID but remains expired, locked, credential-less, and without a Home. A preflight failure creates no account. If a root becomes unsafe after the helper runs, the transaction attempts rollback with the complete captured identity and retains a disabled account plus its registry witness when cleanup cannot be confirmed. Mail-specific cleanup neither searches nor traverses Maildir and never touches aaPanel's `/www/vmail`; a Maildir inside the managed Home is still removed with that Home under the preceding rules during complete-account revocation; +- 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; +- creation preflights the mail-spool directories before `useradd`, then reopens and revalidates them while the account is bound to its selected UID but remains expired, locked, credential-less, and without a Home. A preflight failure creates no account. If a root becomes unsafe after the helper runs, the transaction attempts rollback with the complete captured identity and retains a disabled account plus its registry witness when cleanup cannot be confirmed. Mail-specific cleanup neither searches nor traverses Maildir and never touches aaPanel's `/www/vmail`; a Maildir inside the managed Home is still removed with that Home under the preceding rules during complete-account revocation; - sudoers files, sshd exceptions, and automatic tasks use restricted project names and are removed only as verified managed objects. Do not edit `/var/lib/linux-temp-admin/v2/registry.tsv` manually. An unreadable registry is never treated as an empty one. diff --git a/docs/security-model.md b/docs/security-model.md index de2bb6e..8274106 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -10,7 +10,7 @@ - Linux 内核、本地 root、文件系统和系统账号数据库; - OpenSSH、sudo、systemd 或 `at` 及系统账号管理命令; -- 邮件投递服务,以及被管理员允许修改受信系统邮件池目录项的本地身份; +- 邮件投递服务,以及被管理员允许修改受信系统邮箱目录项的本地身份; - 安装时明确选择的下载信任链; - 操作者用于交付邀请的私密通信渠道。 @@ -93,8 +93,8 @@ invite、revoke、cleanup、install、upgrade 和 uninstall 的受管状态提 - 登记表严格验证 schema、字段、UID、世代和大小,损坏或不可读时 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 父目录后还会复核它没有重建; -- 创建会在 `useradd` 前预检邮件池,并在账号已绑定具体 UID、仍过期锁定且没有凭据或 Home 时重新打开目录做同一校验与旧 mbox 清理。前置失败不会创建账号;若目录在 helper 之后变得不安全,流程会以已捕获的完整身份尝试回滚,不能确认清理时保留禁用账号和登记见证。邮件专用逻辑不搜索或遍历 Maildir,也不触碰宝塔 `/www/vmail`;完整账号撤销时,位于受管 Home 内的 Maildir 仍随 Home 按上述规则清理; +- 常规邮件清理只检查 `/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 父目录后还会复核它没有重建; +- 创建会在 `useradd` 前预检系统邮箱目录,并在账号已绑定具体 UID、仍过期锁定且没有凭据或 Home 时重新打开目录做同一校验与旧 mbox 清理。前置失败不会创建账号;若目录在 helper 之后变得不安全,流程会以已捕获的完整身份尝试回滚,不能确认清理时保留禁用账号和登记见证。邮件专用逻辑不搜索或遍历 Maildir,也不触碰宝塔 `/www/vmail`;完整账号撤销时,位于受管 Home 内的 Maildir 仍随 Home 按上述规则清理; - sudoers、sshd 例外和自动任务使用受限、可预测的项目命名,只删除经过身份验证的受管对象。 不要手工编辑 `/var/lib/linux-temp-admin/v2/registry.tsv`。读不到登记状态不会被当作“没有账号”。