From d667a3c0b88a49358693124ef34c7c6df2db0b55 Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Tue, 28 Jul 2026 00:26:45 +0800 Subject: [PATCH] docs: prepare v2.8.4 release --- .github/PULL_REQUEST_TEMPLATE.md | 7 +- CHANGELOG.md | 20 +- CONTRIBUTING.md | 11 +- README.en.md | 419 +++---------------- README.md | 418 +++--------------- SECURITY.md | 11 +- docs/installing.en.md | 115 +++++ docs/installing.md | 115 +++++ docs/operator-guide.en.md | 185 ++++++++ docs/operator-guide.md | 185 ++++++++ docs/releasing.md | 144 +++---- docs/security-model.en.md | 107 +++++ docs/security-model.md | 107 +++++ internal/selfmanage/release_pipeline_test.go | 281 ++++++++++++- internal/sudoers/sudoers.go | 3 + internal/sudoers/sudoers_test.go | 1 + 16 files changed, 1304 insertions(+), 825 deletions(-) create mode 100644 docs/installing.en.md create mode 100644 docs/installing.md create mode 100644 docs/operator-guide.en.md create mode 100644 docs/operator-guide.md create mode 100644 docs/security-model.en.md create mode 100644 docs/security-model.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d41378e..97e4f4b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,13 +12,18 @@ Release/install scripts, if changed: -- [ ] `bash -n` / `sh -n` and `shellcheck -S warning scripts/*.sh` +- [ ] `bash -n scripts/*.sh`, `sh -n scripts/install.sh`, and `shellcheck -S warning scripts/*.sh` +- [ ] `python3 -B -m unittest -v scripts/mirror_receiver_test.py` - [ ] Invalid version/tag rejection and static amd64/arm64 builds pass Workflows, if changed: - [ ] `actionlint` +Documentation, if changed: + +- [ ] Chinese and English user documents remain synchronized and all relative links resolve + ## Safety Notes - [ ] No real private keys (including the release signing key), invite bundles, hostnames, server IPs, `/etc/shadow` data, or production `authorized_keys` were committed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f7a081..083c5ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,30 @@ All notable changes to this project are documented here. -## Unreleased +## v2.8.4 - 2026-07-27 - Run mirror synchronization only from an explicit protected-`main` dispatch. GitHub binds a `release` event workflow to the released tag, so the automatic trigger could not satisfy the default-branch trust check and must not receive the mirror deployment credential. +- Restore a genuine 30-second quick start through the built-in official mirror: + propagate curl failure with `pipefail`, run the installer as root, and create + the first invitation only after installation succeeds. +- Document the convenience stream's exact first-install trust boundary: it does + not authenticate the installer or retract partial script bytes, and GitHub + fallback begins only after the installer is running. Retain the commit-, + independent-hash-, and exact-release-pinned high-assurance procedure. +- Align security, contribution, pull-request, and release guidance + with mirror-first complete-source downloads, protected-main mirror dispatch, + the full script/receiver checks, and the `v2.8.4` release examples. Enforce the + approved convenience command and high-assurance bootstrap properties in tests. +- Refocus the bilingual README files on first-time users. Move detailed install, + upgrade, operator, and security behavior into paired user guides while keeping + vulnerability reporting, contribution, and release procedures in their own + documents. +- Ignore third-party sudoers drop-ins whose names happen to be valid usernames. + Only files in the `linux-temp-admin-` namespace are now inventoried, so + `doctor`, cleanup, and uninstall do not report or act on unrelated policy. ## v2.8.3 - 2026-07-27 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c90328..bbf5eb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ The tool lives in `cmd/` and `internal/`, and ships as a signed static Go binary ## Before You Start -- Read `README.md` / `README.en.md` and `SECURITY.md`. +- Read `README.md` / `README.en.md`, the paired user guides under `docs/`, and `SECURITY.md`. - Do not commit real invite bundles, private keys (including the release signing key), hostnames, server IPs, `/etc/shadow` data, or `authorized_keys` from real systems. - Prefer focused pull requests: one behavior change, hardening fix, or documentation improvement at a time. @@ -38,8 +38,9 @@ host. **Release/install scripts**, if you touch `scripts/`: ```bash -bash -n scripts/release.sh scripts/sign-release.sh +bash -n scripts/*.sh sh -n scripts/install.sh +python3 -B -m unittest -v scripts/mirror_receiver_test.py shellcheck -S warning scripts/*.sh ``` @@ -54,11 +55,11 @@ For changes that touch account creation, revoke, sudoers, systemd timers, or `at - Prefer root-owned temporary files plus atomic rename for managed root files; set owner/mode on the file descriptor and never follow a symlink at the target. - Do not silently overwrite an existing installed command if doing so could break another registered user's auto-revoke task. - Keep non-interactive automation explicit: dangerous actions need `--yes` plus a specific confirmation value when relevant. -- Update both Chinese and English README files when user-facing behavior changes. +- Update both languages of every affected user document when user-facing behavior changes. - Add or update tests for validation, parsing, quoting, and safety boundary changes. ## Pull Request Checklist -- [ ] `build`, `vet` (with `-printf.funcs`), `gofmt`, and `test -race` pass; integration tests pass or are unaffected. (`scripts/` changes: ShellCheck passes.) -- [ ] README / CHANGELOG updated when behavior changes. +- [ ] `build`, `vet` (with `-printf.funcs`), `gofmt`, and `test -race` pass; integration tests pass or are unaffected. (`scripts/` changes: syntax checks, mirror receiver tests, and ShellCheck pass.) +- [ ] README, affected user-guide pair, and CHANGELOG updated when behavior changes. - [ ] Security-sensitive behavior was tested in a disposable environment or clearly explained. diff --git a/README.en.md b/README.en.md index b925036..be20add 100644 --- a/README.en.md +++ b/README.en.md @@ -1,433 +1,146 @@ # linux-temp-admin

- Linux - Debian - RHEL compatible + Linux amd64 and arm64 + Debian Ubuntu and RHEL compatible License

-> One command to grant a collaborator a **time-limited, auto-deleting** temporary SSH admin account. The tool prints an invite bundle you forward over private chat; the server stores only the public key, never the private key. +> One command creates a time-limited SSH administrator account for a trusted collaborator and removes it automatically when it expires. -**linux-temp-admin** is for temporarily giving a trusted collaborator, ops engineer, or automation agent an SSH admin entry point — without sharing the root password, without leaving long-lived accounts, and with automatic cleanup on expiry. +**linux-temp-admin** avoids sharing the root password and never stores the invite's private key on the server. It creates a temporary account, prints a bundle you can forward privately, and later removes the account, SSH key, and sudo grant. -It ships as a **single static binary**: zero runtime dependencies, glibc/musl alike (including Alpine/BusyBox). Key generation, downloads, date arithmetic, file locking, and process cleanup are all native, and it supports an **ed25519-signature-verified self-upgrade**. +The program is one static binary for amd64 and arm64 Linux, on both glibc and musl. Account, SSH, and scheduler operations still use the host's standard administration tools. [中文](README.md) | English ---- +## Quick start -## Contents - -- [Quick start (30 seconds)](#quick-start-30-seconds) -- [What it solves](#what-it-solves) -- [Language](#language) -- [Install, upgrade, and doctor](#install-upgrade-and-doctor) -- [Full walkthrough](#full-walkthrough) -- [Everyday commands](#everyday-commands) -- [Common usage](#common-usage) -- [Reference](#reference) -- [Security notes](#security-notes) -- [Development & license](#development--license) - -## Quick start (30 seconds) +Run this in a shell that supports `pipefail`: ```bash -/usr/bin/sudo /usr/bin/env -i \ - HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ - /bin/sh <<'LTA_BOOTSTRAP' && -set -eu -umask 077 -fail() { echo "error: $*" >&2; exit 1; } -ulimit -c 0 || fail "cannot disable core dumps" -[ -d /tmp ] && [ ! -L /tmp ] || fail "/tmp is not a real directory" -tmp_meta=$(stat -Lc '%u %a' -- /tmp) || fail "cannot inspect /tmp" -case "$tmp_meta" in - "0 1"[0-7][0-7][0-7]) ;; - *) fail "/tmp must be root-owned, sticky, and free of special bits other than sticky" ;; -esac - -if ! FSIZE_BLOCK_BYTES=$( - ulimit -f 1 || exit 1 - awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } - END { if (!found) exit 1 }' /proc/self/limits -); then - fail "cannot determine the shell file-size limit unit" -fi -case "$FSIZE_BLOCK_BYTES" in - 512 | 1024) ;; - *) fail "unsupported shell file-size limit unit" ;; -esac -INSTALLER_MAX_BYTES=1048576 -INSTALLER_BLOCKS=$(( (INSTALLER_MAX_BYTES + FSIZE_BLOCK_BYTES - 1) / FSIZE_BLOCK_BYTES )) -installer=$(mktemp /tmp/.lta-bootstrap.XXXXXXXXXX) || fail "cannot create root-owned installer file" -cleanup() { rm -f -- "$installer"; } -trap cleanup 0 -trap 'exit 1' HUP INT TERM -installer_downloaded=0 -for installer_url in \ - https://dl.ll.cd/linux-temp-admin/install.sh \ - https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh -do - installer_download_rc=0 - ( - ulimit -f "$INSTALLER_BLOCKS" || exit 1 - exec timeout -k 5 70 curl -q --fail --silent --show-error --location --max-redirs 0 \ - --connect-timeout 10 --max-time 60 --max-filesize "$INSTALLER_MAX_BYTES" \ - --proto '=https' --proto-redir '=https' \ - --output "$installer" "$installer_url" - ) || installer_download_rc=$? - if [ "$installer_url" = https://dl.ll.cd/linux-temp-admin/install.sh ] && \ - [ "$installer_download_rc" -eq 47 ]; then - fail "official mirror installer redirected; refusing source-policy fallback" - fi - if [ "$installer_download_rc" -eq 0 ]; then - installer_size=$(wc -c < "$installer") || fail "cannot measure installer" - case "$installer_size" in - '' | *[!0-9]*) fail "invalid installer size" ;; - esac - if [ "$installer_size" -gt 0 ] && [ "$installer_size" -le "$INSTALLER_MAX_BYTES" ]; then - installer_downloaded=1 - break - fi - fi -done -[ "$installer_downloaded" -eq 1 ] || fail "installer download failed or exceeded its limit" -/bin/sh "$installer" -LTA_BOOTSTRAP +set -o pipefail +curl -fsSL https://dl.ll.cd/linux-temp-admin/install.sh | /usr/bin/sudo /bin/sh && /usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo ``` -That's it. The tool will: - -1. Generate a fresh SSH key pair and create a temporary user (e.g. `xxvcc-a1b2c3d4e5`); -2. Print **an invite bundle** — forward it over private chat, and the recipient logs in by running the two commands inside it, **without needing to understand any of this**; -3. Delete that user, its home directory, and its key **automatically after 24 hours** by default. - -> Running `/usr/bin/sudo /usr/local/sbin/linux-temp-admin` with no subcommand opens an interactive menu. The menu is drawn on entry and whenever you press Enter, so each action's result stays on screen above the prompt instead of being scrolled away. The UI is bilingual; see [Language](#language). - -## What it solves - -Granting someone temporary SSH access usually goes wrong in these ways: - -- handing out the root password; -- creating a temporary account and forgetting to delete it; -- leaving a public key in `authorized_keys` that nobody cleans up; -- losing track of which temporary accounts you have opened; -- never taking back sudo. - -This tool standardizes the whole flow: **create → print invite bundle → register → inspect → revoke → auto-delete on expiry**. - -The default public-key flow does **not** store the private key, generate an account password, or modify sshd configuration. Only an explicit `--password-login` generates and prints an account password once, and only an explicit `--fix-sshd` writes an account-scoped sshd drop-in. The tool never sets a sudo password, touches the firewall, or opens an inbound port. - -## Language - -**Chinese by default, whatever the server's locale says.** The first time you run it at a terminal it asks once, then remembers: - -```text -Language / 语言: - 1) 中文 (默认) - 2) English -选择 / select [1-2]: -``` - -The choice is saved in `/var/lib/linux-temp-admin/v2/prefs`. Change it any time from the interactive menu under "Switch language / 切换语言" (that entry is labelled in both languages, so it is findable even if you picked the one you cannot read). - -Precedence: `--lang zh|en` > the `LINUX_TEMP_ADMIN_LANG` environment variable > the remembered choice > the question on first interactive use > **Chinese**. - -**The system locale (`LANG`/`LC_ALL`) is deliberately not consulted.** What language a server was installed in says little about the language of the person holding the invite. So a box with `LANG=en_US.UTF-8` still defaults to Chinese until you choose English. - -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin --lang en invite --sudo # this run only -``` - -A non-interactive run (a script, CI, the auto-revoke timer) has nobody to ask, so it uses the remembered choice or falls back to Chinese; `--lang` and the environment variable always override. Across sudo, prefer an explicit `--lang` instead of broadly preserving the caller's environment for one language variable. +The tool then: -## Install, upgrade, and doctor +1. creates a temporary account with a random name; +2. generates a one-time SSH key and prints an invite bundle; +3. grants passwordless sudo by default and removes the account after 24 hours; +4. checks the effective sshd configuration before creation, refusing a definite blocker and reporting incomplete knowledge as `UNVERIFIED`. -The install script is the recommended path: it must run as root and requires curl, OpenSSL 3, sha256sum, and timeout. GitHub CDN fallback also requires either `getent` or `nslookup` so the script can validate and pin a public address before requesting each redirect hop. It downloads the latest released binary for your architecture (amd64 / arm64), **verifies its SHA-256 and detached ed25519 signature against the release keyring embedded in the script**, and installs it to `/usr/local/sbin/linux-temp-admin`. There is no unsigned downgrade path. Downloads and redirects are HTTPS-only, every transfer has a kernel-enforced file-size ceiling and bounded retries, and the verified candidate is probed under time/output limits in an unpredictable file inside a root-safe destination directory before the atomic replacement. For rollback resistance on a first install, add `LTA_RELEASE=vX.Y.Z` to the root-environment assignments on the `/usr/bin/sudo /usr/bin/env -i` command above; the script downloads that exact tag and requires the candidate to report the matching version. +The quick start obtains the installer from the official mirror and sends it to a root shell. `set -o pipefail` propagates curl failures, so a failed install does not continue to `invite`; it **does not authenticate the script or stop an already received partial script from beginning execution**. Once the installer is running, the downloaded binary is still verified with SHA-256 and an ed25519 signature. Use the [high-assurance first-install procedure](docs/installing.en.md#high-assurance-first-install) when the script must be authenticated before execution. -The compiled-in official release source is `https://dl.ll.cd/linux-temp-admin`. A `latest` install or upgrade reads the mirror index and pins its exact version; an explicitly pinned release goes directly to that tag. It then fetches `SHA256SUMS`, the current-architecture binary, and its signature from one source; mirror and GitHub files are never mixed. Only a **transport failure** such as DNS, TLS, timeout, HTTP, empty/oversized response, or an incomplete download discards that whole set and falls back to GitHub. When a valid mirror index was obtained, the GitHub fallback remains pinned to the same tag. Official mirror URLs must directly return the canonical single-line index, lowercase newline-terminated `SHA256SUMS`, and a raw 64-byte signature; redirects, mirror-index semantics, checksum, ed25519 signature, and candidate-version failures abort immediately without fallback. The GitHub fallback may still follow public HTTPS redirects required by the Release CDN. +## Requirements -The convenience bootstrap tries the official mirror first and uses raw GitHub only if the installer transfer fails or returns an empty/oversized response; a mirror redirect aborts immediately. It trusts the TLS of the source ultimately used, plus either the mirror's stable-file deployment or GitHub's current `main`. A high-assurance first install should also pin an audited commit, verify the installer hash through an independent channel, and execute a root-owned copy; see the complete procedure in the [release guide](docs/releasing.md#host-install-and-upgrade). +- Linux 5.3 or newer on amd64 or arm64; +- primary support for Debian, Ubuntu, RHEL, Rocky, AlmaLinux, Fedora, and common aaPanel environments; +- best-effort support for Alpine and Arch Linux; +- permission to obtain root through `/usr/bin/sudo`; +- curl, OpenSSL 3, sha256sum, and timeout for the installer. -Run the [root-owned bootstrap in Quick start](#quick-start-30-seconds); it never hands `sudo` a temporary file that the invoking user can replace. Diagnose the completed installation separately: +After installation, run: ```bash /usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor ``` -Everyday maintenance: - -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor # check dependencies, sudoers.d, package manager, init system, SSH port -/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade # prefer the official mirror; redownload from GitHub after transport failure -/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade --yes # non-interactive confirmation -/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall # uninstall: accounts, grants, auto-delete tasks, state, command -/usr/bin/sudo ./linux-temp-admin install # put the binary in hand into place (note the leading ./) -``` - -- **`upgrade`** fetches a complete same-version `SHA256SUMS`, binary, and signature set from the official mirror by default, and redownloads the whole set from GitHub only if transport fails; files are never assembled across sources. A manifest-semantic, checksum, ed25519-signature, or candidate-version failure is fail-closed and never triggers fallback. Downloads are HTTPS-only, capped at 64 MiB, use bounded retries for transport failures and 408/425/429/5xx, and overwrite only when the version is newer. The address actually dialed after a redirect cannot be private or reserved (including documentation, benchmarking, NAT64, and 6to4 ranges), and candidate-version probing has time and output limits. Explicit `--url URL` and `--url-file /absolute/path` use only that custom source; no failure silently switches to the official mirror or GitHub. Use `--url URL` for a public custom source (its signature is `URL.sig`). Add `--force` only for an intentional same-version reinstall or downgrade, or to repair a target whose current version cannot be read. A URL containing credentials or signed query parameters must instead be stored in an absolute, root-owned `0600` file and passed with `--url-file`, keeping the secret out of shell history, sudo logs, and `/proc` command lines. The file's first line is the binary URL; an optional second line is an independent signature URL (each may retain its own presigned query). Only the one-line form derives `.sig` from the first line. The GitHub-specific cache bypass is applied only to official Release URLs and never rewrites a custom signed URL. **Use this for routine updates.** -- **`install`** places a binary you **already have** (no network, no signature check) — for an air-gapped host or a self-built binary. It copies the binary inode that is *currently running* through `/proc/self/exe`, so replacing the launch pathname cannot change what root installs. It is only meaningful when you run a copy from elsewhere (`/usr/bin/sudo ./linux-temp-admin install`, where the leading `./` is the point). It refuses to overwrite a *different* binary without `--force`. Even byte-identical content is a no-op only when the target is root:root, exactly `0755`, has no special bits, and its parent is safe; otherwise metadata is atomically repaired. Because auto-delete jobs execute the installed path, an invite refuses an unsafe installed command or one whose version cannot be read; development builds install the exact bytes currently running. +`doctor` checks dependencies, kernel capabilities, the package manager, sudoers, the init system, the SSH port, and public-key login conditions. -## Full walkthrough +## Create and deliver an invite -### 1. Install - -Use the [root-owned bootstrap above](#quick-start-30-seconds). For a high-assurance first install, use the [commit- and hash-pinned procedure in the release guide](docs/releasing.md#host-install-and-upgrade). - -### 2. Create an invite +The quick start already creates the first invite. Later invites can be created with: ```bash /usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo ``` -Interactive mode is short: a locally-detected public IP is used without asking (`--host` overrides for a domain or another address); sudo is granted by default (this is an admin tool — `--no-sudo` makes a plain account); it asks whether to auto-delete on expiry, and **only asks the lifetime when auto-delete is on**. It then shows a summary to confirm before printing the bundle. - -### 3. You get an invite bundle like this (redacted) - -The following is a format sample only and **cannot be used to log in**. The real private key is generated at run time and shown once, in your terminal. - -```text ------ BEGIN LINUX TEMP ADMIN INVITE ----- - -Host: 203.0.113.10 -Port: 22 -User: xxvcc-a1b2c3d4e5 -Expires: 2030-01-02 12:00:00 CST -Sudo: yes -Login: SSH key only (verified against the effective sshd config) -Password login: disabled -Auto revoke: yes -Auto revoke unit: linux-temp-admin-v2-revoke-xxvcc-a1b2c3d4e5 -Sshd exception: none - -Save private key command: -cat > './xxvcc-a1b2c3d4e5.key' <<'EOF_KEY' ------BEGIN OPENSSH PRIVATE KEY----- -[REDACTED: one-time private key generated at run time] ------END OPENSSH PRIVATE KEY----- -EOF_KEY -chmod 600 './xxvcc-a1b2c3d4e5.key' - -Security notes: the private key is shown only once and not stored on the server; send only via trusted private chat; revoke immediately after use. - ------ END LINUX TEMP ADMIN INVITE ----- -``` - -> The bundle's field names and command blocks stay in English and keep a fixed format so it can be forwarded verbatim; only the caption lines are localized. - -The `Login:` line is **a verdict, not a slogan**. Before anything is created, the tool reads `sshd -T -C user=` — sshd's effective configuration, with `Include`, `Match`, and the distro's crypto policy already resolved — and only claims a key login if that account really could log in. If the config cannot be read, the line says `UNVERIFIED` instead of guessing. +The interactive flow shows the account, host, port, expiry, sudo state, and login verdict, followed by a command that saves the one-time private key. Only the public key is stored on the server. -### 4. Forward the bundle to your collaborator over private chat - -They only need two steps, **without installing anything or understanding this tool**: - -- copy the "Save private key command" block, paste and run it locally → they get the key file; -- build the login command from the header's Host / Port / User, e.g. - `ssh -i ./xxvcc-a1b2c3d4e5.key -p 22 xxvcc-a1b2c3d4e5@203.0.113.10`. - -> ⚠️ The bundle contains a one-time private key. **Send it only over trusted private chat** — never in a group, a ticket, or a public page. - -### 5. Revoke when done (or let it auto-delete on expiry) +Send the complete bundle through trusted private chat. After saving the key, the collaborator builds the SSH command from the bundle's Host, Port, and User fields, for example: ```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 +ssh -i ./xxvcc-a1b2c3d4e5.key -p 22 xxvcc-a1b2c3d4e5@203.0.113.10 ``` -The user, home directory, and key are deleted automatically after 24 hours by default, but **revoking manually as soon as you are done is safest** — do not rely on expiry alone. +The real private key is shown only once. Never put an invite bundle in a group chat, ticket, Notion page, or public site. -## Everyday commands - -Show status (registered temporary users, expiry, auto-delete timer): +## Inspect and revoke ```bash +# Show all temporary accounts /usr/bin/sudo /usr/local/sbin/linux-temp-admin status -/usr/bin/sudo /usr/local/sbin/linux-temp-admin status --user xxvcc-a1b2c3d4e5 -``` - -Revoke/delete (pick a number from the list, or name the user): -```bash +# Choose an account from a list and revoke it /usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke + +# Revoke one account directly /usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 ``` -Clean up stale registry rows and orphaned grants: +By default, the account, home directory, SSH key, sudo grant, and any tool-created sshd exception are removed after 24 hours. Revoke access immediately when work is finished even when automatic removal is enabled. -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin cleanup-expired --compact -``` - -**`uninstall`** removes the temporary accounts (with their home directories), their sudo grants and sshd exceptions, their auto-delete tasks, the state directory (v1's leftovers included), and — last — the command itself. The lifecycle lock and uninstall marker deliberately remain so queued old processes cannot recreate state after teardown; the audit log is also retained by default. +## Everyday commands ```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall # interactive: shows an inventory, then asks for YES -/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --remove-users # non-interactive: --remove-users is required when accounts exist -/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --purge-audit # remove the audit log too +/usr/bin/sudo /usr/local/sbin/linux-temp-admin # Interactive menu +/usr/bin/sudo /usr/local/sbin/linux-temp-admin status # Account status +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor # Inspect this host +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade # Verified upgrade +/usr/bin/sudo /usr/local/sbin/linux-temp-admin cleanup-expired --compact +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall # Remove managed accounts and the tool ``` -- **The audit log is kept by default** at `/var/log/linux-temp-admin/audit.log`. It records who opened and closed root-capable accounts; erasing it on the way out is what covering your tracks looks like. `--purge-audit` removes it. The logger stops at 64 MiB instead of consuming the filesystem indefinitely; archive or rotate the file when that limit is reached. -- **If any account cannot be removed, neither the command nor the state directory is**, and the uninstall stops and names it. Leaving a sudo-capable account behind while deleting the only thing that manages it is worse than not uninstalling: its auto-delete task invokes that very command. -- **Uninstalling the command and keeping the accounts is not an option.** `--force` no longer bypasses this; it keeps only its original meaning (remove a target that is not a safe root-owned regular file). -- **Running it from a temporary account is refused** — the teardown would reap that account's own session partway through and leave the box half dismantled. Run it as root or another administrator. +The interface defaults to Chinese. The first interactive run offers Chinese or English, and the menu can switch languages later. Use `--lang zh` or `--lang en` for one invocation. -`--compact` removes registry entries naming accounts that no longer exist, and the **sudo grants, sshd exceptions, and auto-delete tasks those accounts left behind** (an orphaned grant is the dangerous one — it re-arms the moment its username is reused). It decides "orphan" by whether the name is a live account this tool still manages, so a leftover grant whose name a real account reused is caught too. This is the command `doctor` points you at when it finds one. +## Common scenarios -> `cleanup-expired` **never deletes an account**: use `revoke` for that, and `status` to see the list. Revoking unregistered or unknown accounts has extra guards — see [Security notes](#security-notes). - -## Common usage - -Set the lifetime in hours (1 to 8760): +Use a 12-hour lifetime: ```bash /usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --hours 12 ``` -No sudo (create a plain account): +Create a regular account without sudo: ```bash /usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --no-sudo ``` -Set the username prefix / host / port (the prefix allows lowercase letters, digits, underscores, and hyphens, up to 20 characters): - -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --prefix ops --sudo -/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --host 203.0.113.10 --port 22 --sudo -``` - -Create a permanent account (no expiry, no auto-delete — revoke by hand): +Set the host, port, or username prefix: ```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --no-auto-revoke +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --host admin.example.com --port 2222 --prefix ops --sudo ``` -**Automation / non-interactive** (CI or scripts). Non-interactive runs must pass `--host`; `--sudo --yes` must re-confirm the username; and when stdout is not a terminal you must explicitly allow printing the private key: - -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite \ - --user xxvcc-a1b2c3d4e5 \ - --host 203.0.113.10 --port 22 --hours 24 \ - --sudo --install-deps --yes \ - --confirm-sudo xxvcc-a1b2c3d4e5 \ - --allow-non-tty-private-key-output -``` - -### When the server does not accept public-key logins - -Some servers have key logins switched off (`PubkeyAuthentication no`), or redirect `authorized_keys` to a central path, or run an `AllowUsers` whitelist, or demand a second factor. On such a host sshd never reads the key written to `~/.ssh/authorized_keys`, and no invite — however pretty — can log in. - -**The tool now finds this out before it creates anything, and refuses** (the account does not exist yet, so nothing is left behind), naming the directive that blocks it. You have two ways forward. - -**1. Open a door for this one account** (recommended): +When public-key login is disabled, create an account-scoped sshd exception: ```bash /usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --fix-sshd ``` -It writes a dedicated drop-in containing an account-scoped `Match User` block, followed by `Match all` to restore global scope so later files expanded by the same Include glob are not accidentally captured: - -```text -# /etc/ssh/sshd_config.d/10-linux-temp-admin-xxvcc-a1b2c3d4e5.conf -Match User xxvcc-a1b2c3d4e5 - PubkeyAuthentication yes -Match all -``` - -- **The global policy is not edited at all.** Every other account keeps your baseline, byte for byte. -- The file is syntax-checked with `sshd -t`, then **proved effective** with `sshd -T -C user=`, and only then is sshd asked to `reload` (**reload, never restart**: live sessions survive). If any step fails, the file is removed, sshd is not reloaded, and the invite is refused. -- `revoke` (including the auto-delete timer) **deletes that file and reloads sshd**. "Restoring" is deleting our own file — there is no backup to keep, so the tool can never clobber a change you made to sshd in the meantime. - -An interactive run asks first. A `--yes` run never asks and never modifies sshd implicitly: it refuses unless `--fix-sshd` said so out loud, because a script must not quietly rewrite a remote host's sshd configuration while nobody is watching. - -**2. Fall back to a password** (leaves sshd alone): - -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --password-login -``` - -It first verifies that sshd really does accept passwords (and refuses otherwise), then issues a 24-character random password, shown once. **This is the weakest grant the tool issues**: the password is brute-forceable from anywhere for the account's whole lifetime and must be delivered in the clear. Prefer `--fix-sshd`. - -**What the tool will never do**: edit sshd's global configuration, or bypass an explicit `DenyUsers`/`DenyGroups` rule. Not being on an allow list is a default you never spoke about; an explicit deny is a decision you made. - -To find out where your server stands before you need an invite: - -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor -``` - -## Reference +This does not modify the global sshd policy, and the exception is removed with the account. See the [operator guide](docs/operator-guide.en.md) for automation, password login, permanent accounts, and complete troubleshooting. -### Supported systems +## Security essentials -- **Primary**: Debian / Ubuntu, common aaPanel Linux environments, RHEL / Rocky / AlmaLinux / Fedora -- **Best effort**: Alpine, Arch Linux -- **Kernel requirement**: Linux 5.3 or newer, with `pidfd_open` and `pidfd_send_signal` allowed by the seccomp/container policy. This is required to avoid signalling an unrelated process after PID reuse; `doctor` probes the live environment and `invite` refuses to create an account when the capability is unavailable. +- `--sudo` grants NOPASSWD sudo, which is effectively full root access. Use it only for trusted people; +- a temporary administrator with root can create separate persistence that this tool cannot discover or remove during revoke; +- the invite's private key is shown once, must travel only through trusted private chat, and should be revoked as soon as work ends; +- the official mirror at `https://dl.ll.cd/linux-temp-admin` is the default source. Only transport failures redownload a complete set from GitHub; after a valid mirror index, fallback remains on the same release, while checksum, signature, or version failures stop immediately; +- private-key output is refused when stdout is not a terminal unless automation explicitly acknowledges the output channel; +- do not edit the registry under `/var/lib/linux-temp-admin` by hand. -### Dependencies +See the [security model](docs/security-model.en.md) for detailed guarantees, trust boundaries, and failure handling. Report vulnerabilities privately through [SECURITY.md](SECURITY.md). -The binary itself has no runtime dependencies. It only calls the system's **account-management tools**; when those are missing it can install them interactively (confirm, or pass `--install-deps`) via `apt-get` / `dnf` / `yum` / `apk`: +## Documentation -- `id`, `useradd` or `adduser`, `userdel` or `deluser`, `usermod`, `chage` -- `sudo`: only needed when granting sudo - -Arch's `pacman` does not support partial upgrades, while the safe `pacman -Syu` upgrades the whole system. This tool therefore never runs pacman automatically while creating an account. Run the prompted `pacman -Syu --needed ...` deliberately first, then retry the invite. - -`doctor` shows **the running version and the installed command's version** (flagging a mismatch — the auto-delete task runs the installed one), checks each of the tools above and the pidfd capability, plus the package manager, the init system, the safety of `/etc/sudoers.d`, and the detected SSH port, and **rehearses whether a freshly created temporary account could log in by public key** (pointing you at `invite --fix-sshd` when sshd would refuse). It also reports **orphaned sudo grants, sshd exceptions, and auto-delete tasks** (the account is absent or its identity is unverified while the artifact remains), and accounts set to auto-delete with no task left to do it — pointing you at `cleanup-expired --compact` or `revoke`. - -`at` / `atd` is the auto-delete fallback backend for hosts without systemd. It is **not part of the dependency check and is never auto-installed**. - -### Expiry vs auto-delete - -The default lifetime is 24 hours, and **auto-delete is on by default**. With auto-delete on, the tool writes an exact-time auto-delete task (a persistent systemd timer preferred, `at` as fallback) and sets a day-granularity `chage -E` backstop. The backstop is deliberately never earlier than the displayed deadline and may lock up to roughly 24 hours later; the scheduled revoke is the exact deadline mechanism. If neither backend can create that task, the whole invite is rolled back instead of leaving an expiry-only account. The task invokes the installed command, so the tool ensures `/usr/local/sbin/linux-temp-admin` exists first. Each task is bound to the creation UID, a random 128-bit generation token, and the matching registry row; any mismatch (including a lost row or a recreated account) safely skips deletion. Failed systemd revokes retry with rate limiting; `at` and legacy one-shot failures need manual attention, and `doctor` reports registered accounts whose auto-delete task is missing. - -**Auto-delete off = a permanent account**: no expiry is set and it is never deleted — revoke it by hand. `--hours` is ignored in that case. - -Two host notes: - -- In interactive mode without `--host`, cloud metadata and local interfaces are probed **silently** (neither leaves this host or its link), and whatever they find becomes the default in the host prompt — press Enter to accept it, or type over it. Only when no public IP is found locally does it **ask** before querying `https://api.ipify.org`, `https://ifconfig.me/ip`, and `https://icanhazip.com`: that step discloses your server's address to a third party, so it needs an explicit yes. `--yes` mode never reaches out at all; it requires an explicit `--host`. -- `--host` accepts a plain domain, IPv4, or IPv6 only; do not append a port (use `--port`). The SSH command in the bundle brackets IPv6 addresses automatically. Auto-detection accepts only routable public addresses and excludes private, link-local, documentation, benchmarking, CGNAT, and other reserved ranges; an explicit domain or address remains the operator's choice. - -### Files written - -```text -/usr/local/sbin/linux-temp-admin # stable revoke command -/var/lib/linux-temp-admin/v2/registry.tsv # local registry (root:root 0600, dir 0700) -/var/lib/linux-temp-admin/v2/prefs # the remembered UI language (root:root 0600) -/var/log/linux-temp-admin/audit.log # operation audit log (root:root 0600, dir 0700) -/run/linux-temp-admin.lock # global account/install lifecycle lock -/run/linux-temp-admin.lock.uninstalled # completed-uninstall marker; cleared by an explicit install -/etc/systemd/system/linux-temp-admin-v2-revoke-USER.service # with NoNewPrivileges and similar light confinement -/etc/systemd/system/linux-temp-admin-v2-revoke-USER.timer -/etc/sudoers.d/linux-temp-admin-USER # only when NOPASSWD sudo is enabled -/etc/ssh/sshd_config.d/10-linux-temp-admin-USER.conf # only with --fix-sshd; account block plus Match all reset, removed by revoke -/home/USER/.ssh/authorized_keys -# plus a fallback auto-delete job in the at queue when systemd is unavailable -``` +- [Installation, upgrades, and download verification](docs/installing.en.md) +- [Operator guide](docs/operator-guide.en.md) +- [Security model](docs/security-model.en.md) +- [Changelog](CHANGELOG.md) +- [Contributing](CONTRIBUTING.md) +- [Maintainer release process](docs/releasing.md) -## Security notes - -- The private key is shown once at creation and never stored on the server. Key accounts use an unmatchable shadow value that disables password authentication without triggering Alpine/OpenSSH's whole-account lock check. Only explicit `--password-login` generates and prints an account password once; the tool never generates a sudo password. -- The invite's `Login:` line is **a verified conclusion**: before creating anything, the tool reads `sshd -T -C user=` to confirm the account really can log in, and says `UNVERIFIED` when it cannot read the config or finds connection-scoped `Match` criteria such as `Address`, `Host`, `LocalAddress`, or `LocalPort`. It never asserts a login method it did not check. -- **sshd's global configuration is never edited.** `--fix-sshd` writes a separate drop-in whose `Match User` block contains only directives needed to lift detected blockers, followed by `Match all` to reset the Include stream's scope; other accounts keep their effective policy. It is syntax-checked with `sshd -t`, proved effective with `sshd -T`, and reloaded (never restarted). Any failure triggers cleanup plus an independent retry by the invite transaction; an inability to remove or restore is surfaced as a rollback failure. `revoke` deletes the drop-in. **An explicit `DenyUsers`/`DenyGroups` rule is never bypassed.** -- `--password-login` is the weakest grant available (brute-forceable from anywhere, delivered in the clear). It is opt-in only, and refuses unless sshd is verified to accept passwords. -- **NOPASSWD sudo is essentially root.** Grant it only to trusted parties. Revoking deletes the account itself; it does not clean up processes, cron jobs, systemd units, or SUID files that account left behind as root. -- Deleting a user also deletes the home directory and SSH key. An SSH home must belong exactly to the target UID and can never be a root/UID-0 directory. If the system's delete command fails, the tool stops and tells you to check manually rather than pretending the revoke succeeded. -- **Guard against accidental deletion**: every new invite, including a permanent account with auto-delete disabled, gets an independent random generation embedded in both its exact GECOS marker and registry row. `revoke` normally requires the UID, generation, and marker all to match; any mismatch refuses or skips deletion. The generation is a readable account-incarnation binding, not a secret and not a defense against an attacker who already has root. Deleting an unregistered account with the exact marker requires explicit `--force`, plus `--confirm-force USER` when non-interactive. -- Fixed-GECOS accounts migrated from the v2 registry are reported as `managed=false identity=legacy-unverified`. A same-name, same-UID replacement can copy that old shared marker, so scheduled revocation, bulk cleanup, and uninstall never auto-delete these accounts. Inspect one manually, then invoke `revoke --user USER --force` directly and type the full username (non-interactive use also requires `--yes --confirm-force USER`). -- Even with `--force`, it refuses to delete root, well-known system accounts, UID 0, low-UID system accounts, and **any real account that this tool did not create (no exact marker)** — use the system's `userdel` for those. -- A failure at any creation step attempts a full rollback of the schedule, sudoers grant, sshd exception, registry row, and newly created account. Any rollback failure is reported and returns nonzero instead of presenting partial success as success. -- The **managed-state commits** of invite, revoke, cleanup, install, upgrade, and uninstall are serialized by one root-owned lifecycle lock outside removable state; account, schedule, grant, registry, and binary transitions cannot interleave. Human confirmation, dependency installation, and upgrade download/signature verification run outside the lock; after acquiring it, the command revalidates the account inventory or installed version before committing, so an interactive or network wait cannot delay an expired revoke. Usernames are checked through both the local passwd database and NSS before creation, so a local invite cannot shadow an LDAP/SSSD identity. -- If a sudoers grant or sshd exception cannot be fully removed during revoke, the account and registry row are retained and login is disabled when possible, preventing a surviving name-scoped grant from re-arming after username reuse. Cleanup, registry, and scheduler errors also return nonzero. -- The registry strictly validates its schema, fields, UID, and generation token. If it is corrupt or unreadable, `status`, `doctor`, cleanup, revoke, and uninstall fail closed instead of treating "unreadable" as "no accounts." -- Default upgrades use the official mirror as one complete preferred source and redownload the whole set from GitHub only after a transport failure; manifest-semantic, checksum, signature, or candidate-version failures abort. An explicit custom URL never switches to an official source. -- Every privileged action (account create/delete, install/upgrade/uninstall) is appended as a JSON line to the root-owned `/var/log/linux-temp-admin/audit.log` (time, actor `SUDO_USER`, action, target, result). A record is capped at 64 KiB and the log at 64 MiB; at the limit, the operation continues with a visible audit warning until the operator archives or rotates the file. -- When stdout is not a TTY, printing the private key is refused by default; pass `--allow-non-tty-private-key-output` only when the output channel is known to be safe. - -## Development & license - -- Contributing & local checks: [CONTRIBUTING.md](CONTRIBUTING.md) -- Report security issues privately per [SECURITY.md](SECURITY.md); version history in [CHANGELOG.md](CHANGELOG.md). - -License: MIT, see [LICENSE](LICENSE). +License: MIT. See [LICENSE](LICENSE). diff --git a/README.md b/README.md index e623377..721cc75 100644 --- a/README.md +++ b/README.md @@ -1,434 +1,146 @@ # linux-temp-admin

- Linux - Debian - RHEL compatible + Linux amd64 and arm64 + Debian Ubuntu and RHEL compatible License

-> 一条命令,给协作者开一个**有时限、用完自动删**的临时 SSH 管理员账号。工具输出一份可私聊转发的邀请包;服务器只保存公钥,不保存私钥。 +> 一条命令,为可信协作者创建一个有时限、用完自动删除的临时 SSH 管理员账号。 -**linux-temp-admin** 适合临时给可信的协作者、运维或自动化助手开一个 SSH 管理入口——不发 root 密码、不留长期账号、到期自动回收。 +**linux-temp-admin** 不需要分享 root 密码,也不会在服务器保存邀请私钥。它会创建临时账号、输出可私聊转发的邀请包,并在到期时自动撤销账号、SSH key 和 sudo 授权。 -它是一个**单静态二进制**:零运行时依赖,glibc/musl 通吃(含 Alpine/BusyBox),密钥生成、下载、日期计算、文件锁、进程清理全部原生实现,并支持 **ed25519 签名校验的自升级**。 +程序是一个支持 glibc 和 musl 的静态二进制,适用于 amd64 和 arm64 Linux。账号、SSH 和定时任务操作仍会调用系统已有的标准管理工具。 中文 | [English](README.en.md) ---- - -## 目录 - -- [30 秒上手](#30-秒上手) -- [它解决什么问题](#它解决什么问题) -- [语言](#语言) -- [安装、升级与诊断](#安装升级与诊断) -- [完整流程](#完整流程) -- [常用操作](#常用操作) -- [常见用法](#常见用法) -- [参考](#参考) -- [安全说明](#安全说明) -- [开发与许可证](#开发与许可证) - ## 30 秒上手 -```bash -/usr/bin/sudo /usr/bin/env -i \ - HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ - /bin/sh <<'LTA_BOOTSTRAP' && -set -eu -umask 077 -fail() { echo "error: $*" >&2; exit 1; } -ulimit -c 0 || fail "cannot disable core dumps" -[ -d /tmp ] && [ ! -L /tmp ] || fail "/tmp is not a real directory" -tmp_meta=$(stat -Lc '%u %a' -- /tmp) || fail "cannot inspect /tmp" -case "$tmp_meta" in - "0 1"[0-7][0-7][0-7]) ;; - *) fail "/tmp must be root-owned, sticky, and free of special bits other than sticky" ;; -esac - -if ! FSIZE_BLOCK_BYTES=$( - ulimit -f 1 || exit 1 - awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } - END { if (!found) exit 1 }' /proc/self/limits -); then - fail "cannot determine the shell file-size limit unit" -fi -case "$FSIZE_BLOCK_BYTES" in - 512 | 1024) ;; - *) fail "unsupported shell file-size limit unit" ;; -esac -INSTALLER_MAX_BYTES=1048576 -INSTALLER_BLOCKS=$(( (INSTALLER_MAX_BYTES + FSIZE_BLOCK_BYTES - 1) / FSIZE_BLOCK_BYTES )) -installer=$(mktemp /tmp/.lta-bootstrap.XXXXXXXXXX) || fail "cannot create root-owned installer file" -cleanup() { rm -f -- "$installer"; } -trap cleanup 0 -trap 'exit 1' HUP INT TERM -installer_downloaded=0 -for installer_url in \ - https://dl.ll.cd/linux-temp-admin/install.sh \ - https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh -do - installer_download_rc=0 - ( - ulimit -f "$INSTALLER_BLOCKS" || exit 1 - exec timeout -k 5 70 curl -q --fail --silent --show-error --location --max-redirs 0 \ - --connect-timeout 10 --max-time 60 --max-filesize "$INSTALLER_MAX_BYTES" \ - --proto '=https' --proto-redir '=https' \ - --output "$installer" "$installer_url" - ) || installer_download_rc=$? - if [ "$installer_url" = https://dl.ll.cd/linux-temp-admin/install.sh ] && \ - [ "$installer_download_rc" -eq 47 ]; then - fail "official mirror installer redirected; refusing source-policy fallback" - fi - if [ "$installer_download_rc" -eq 0 ]; then - installer_size=$(wc -c < "$installer") || fail "cannot measure installer" - case "$installer_size" in - '' | *[!0-9]*) fail "invalid installer size" ;; - esac - if [ "$installer_size" -gt 0 ] && [ "$installer_size" -le "$INSTALLER_MAX_BYTES" ]; then - installer_downloaded=1 - break - fi - fi -done -[ "$installer_downloaded" -eq 1 ] || fail "installer download failed or exceeded its limit" -/bin/sh "$installer" -LTA_BOOTSTRAP -/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo -``` - -就这样。工具会: - -1. 随机生成一对 SSH 密钥,创建一个临时用户(如 `xxvcc-a1b2c3d4e5`); -2. 在终端输出**一份邀请包**——私聊发给对方即可,对方照着里面两条命令就能登录,**不需要懂任何细节**; -3. 默认 **24 小时后自动删除**这个用户、家目录和密钥。 - -> 不带子命令直接 `/usr/bin/sudo /usr/local/sbin/linux-temp-admin` 会进入交互菜单。菜单只在进入时和你按回车时显示,所以每次操作的结果都留在提示符上方,不会被菜单顶走。界面中英双语,见下方[语言](#语言)。 - -## 它解决什么问题 - -临时给别人开 SSH 权限,最容易翻车的是: - -- 直接把 root 密码给出去; -- 临时账号开完忘了删,长期留着; -- 公钥留在 `authorized_keys` 里没人清; -- 不记得自己之前开过哪些临时号; -- 用完没收回 sudo。 - -这个工具把整套流程标准化:**创建 → 输出邀请包 → 登记 → 查看 → 撤销 → 到期自动删**。 - -默认的密钥登录流程**不会**保存私钥、生成账号密码或修改 sshd 配置;只有显式使用 `--password-login` 才会生成并一次性显示账号密码,只有显式使用 `--fix-sshd` 才会写入仅作用于新账号的 sshd drop-in。工具不会设置 sudo 密码、改防火墙或开放任何入站端口。 - -## 语言 - -**默认中文,与服务器的 locale 无关。** 第一次在终端里运行时,工具会先问一次语言,记住之后就不再问: - -```text -Language / 语言: - 1) 中文 (默认) - 2) English -选择 / select [1-2]: -``` - -选择保存在 `/var/lib/linux-temp-admin/v2/prefs`。想改随时进交互菜单选「切换语言 / Switch language」(这一项的标签是双语的,选错语言也找得到)。 - -优先级:`--lang zh|en` > 环境变量 `LINUX_TEMP_ADMIN_LANG` > 记住的选择 > 首次交互时的提问 > **中文**。 - -**系统 locale(`LANG`/`LC_ALL`)不再参与判断**——服务器装的是什么语言,跟拿着邀请的人说什么语言没多大关系。所以一台 `LANG=en_US.UTF-8` 的机器也默认中文,除非你选了英文。 +在支持 `pipefail` 的 shell 中运行: ```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin --lang en invite --sudo # 只影响这一次 +set -o pipefail +curl -fsSL https://dl.ll.cd/linux-temp-admin/install.sh | /usr/bin/sudo /bin/sh && +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo ``` -非交互运行(脚本、CI、到期自动撤销的定时器)问不了,所以用记住的选择,没有就用中文;`--lang`/环境变量始终可覆盖。通过 sudo 调用时优先显式传 `--lang`,不要为一个语言变量宽泛保留调用者环境。 +工具随后会: -## 安装、升级与诊断 +1. 创建一个随机命名的临时账号; +2. 生成一次性 SSH key,并在终端显示邀请包; +3. 默认授予免密 sudo,并在 24 小时后自动删除账号; +4. 在创建前检查当前 sshd 配置:明确阻止登录就拒绝,无法完整判断则如实标记 `UNVERIFIED`。 -推荐用安装脚本:它必须以 root 运行,并要求 curl、OpenSSL 3、sha256sum 和 timeout;GitHub CDN 回退还要求系统至少提供 `getent` 或 `nslookup`,以便在请求每一跳前验证并固定公网地址。脚本按架构(amd64 / arm64)下载最新发布的二进制,**校验 SHA-256 并用脚本内嵌的发布公钥 keyring 验证 ed25519 签名**后再装到 `/usr/local/sbin/linux-temp-admin`。它没有未验签降级路径;下载和重定向只允许 HTTPS,每次下载都由内核文件大小限制硬封顶并做有界重试。验签后的候选会先在 root-safe 目标目录的随机临时文件中受限执行 `version`,成功后才原子替换目标。首次安装需要防回滚时,在上方 `/usr/bin/sudo /usr/bin/env -i` 的根环境赋值中加入 `LTA_RELEASE=vX.Y.Z`;脚本会下载该精确标签并要求候选报告完全一致的版本。 +快速入口从官方镜像取得安装脚本并交给 root shell。`set -o pipefail` 会传播 curl 失败,因此安装失败后不会继续创建邀请;它**不认证脚本本身,也不能阻止已经收到的部分脚本开始执行**。安装器启动后,下载的二进制仍会经过 SHA-256 和 ed25519 签名验证。需要在执行前认证安装脚本时,请使用[高保证首次安装流程](docs/installing.md#高保证首次安装)。 -内置官方发布源是 `https://dl.ll.cd/linux-temp-admin`。安装或升级 `latest` 时会先读取镜像索引并锁定精确版本;显式固定版本则直接锁定该标签。随后从同一个来源取回 `SHA256SUMS`、当前架构二进制和签名,不会混用镜像与 GitHub 文件。只有 DNS、TLS、超时、HTTP、空/超限响应或下载不完整等**传输故障**才会丢弃这一整套下载并回退 GitHub;已有有效镜像索引时,GitHub 回退仍固定到同一标签。官方镜像 URL 必须直接返回规范单行索引、小写换行结尾的 `SHA256SUMS` 和 64 字节原始签名,不允许重定向;重定向、镜像索引语义、校验和、ed25519 签名或候选版本校验失败都立即中止,绝不回退。GitHub 回退仍允许 Release CDN 所需的公网 HTTPS 重定向。 +## 使用条件 -上面的便利安装脚本先使用官方镜像;只有脚本传输失败、空响应或超限时才尝试 raw GitHub,镜像发生重定向则立即终止。它信任最终所用来源的 TLS,以及镜像稳定文件部署或 GitHub 当前 `main`。高保证首次安装还应固定审计过的 commit、通过独立渠道核对安装脚本哈希,并从 root 独占副本执行;完整流程见 [发版文档](docs/releasing.md#host-install-and-upgrade)。 +- Linux 5.3 或更高版本,amd64 或 arm64; +- 主要支持 Debian、Ubuntu、RHEL、Rocky、AlmaLinux、Fedora 和常见宝塔环境; +- Alpine 和 Arch Linux 为尽力支持; +- 当前用户能够通过 `/usr/bin/sudo` 取得 root 权限; +- 安装器需要 curl、OpenSSL 3、sha256sum 和 timeout。 -执行[上方 30 秒上手](#30-秒上手)中的 root-owned bootstrap;它不会把当前用户可替换的临时文件交给 `sudo`。安装后可单独诊断: +安装后建议先运行: ```bash /usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor ``` -常用维护命令: +`doctor` 会检查依赖、内核能力、包管理器、sudoers、init 系统、SSH 端口和公钥登录条件。 -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor # 检查依赖、sudoers.d、包管理器、init 系统、SSH 端口 -/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade # 官方镜像优先,传输失败时从 GitHub 重新下载 -/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade --yes # 非交互确认 -/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall # 卸载:账号、授权、自动删除任务、状态与命令 -/usr/bin/sudo ./linux-temp-admin install # 把手头这个二进制装到位(注意前面的 ./) -``` +## 创建和交付邀请 -- **升级 `upgrade`**:默认从官方镜像取回同一版本的完整 `SHA256SUMS`、二进制和签名套件,只有整套传输失败才从 GitHub 重新下载;文件不会跨来源拼接。索引语义、校验和、ed25519 签名或候选版本失败会 fail closed,不触发回退。下载只接受 HTTPS、上限 64 MiB,对传输错误及 408/425/429/5xx 做有界重试,仅版本更新时才覆盖;重定向后的实际拨号地址不能是私网或保留地址(含文档、基准测试、NAT64、6to4 等范围),候选版本探测也有时间和输出上限。显式使用 `--url URL` 或 `--url-file /absolute/path` 时只访问该自定义来源,任何失败都不会静默切换到官方镜像或 GitHub。公开自定义来源使用 `--url URL`(其签名为 `URL.sig`);只有明确要同版重装、降级,或在无法读取现有版本时修复目标,才同时使用 `--force`。含凭据或签名查询参数的 URL 必须放进 root 所有、`0600` 的文件,并使用 `--url-file`,避免秘密出现在 shell 历史、sudo 日志或 `/proc` 命令行中。该文件第一行是二进制 URL;可选第二行是独立的签名 URL(两行都可保留各自的预签名 query),只有单行格式才从第一行派生 `.sig`。GitHub 专用的缓存绕过只用于官方 Release URL,不会改写自定义签名 URL。**日常更新用它。** -- **安装 `install`**:把你**手头已有**的二进制放到位(不联网、不验签),用于离线机器或自建二进制。目标已存在且内容不同时需显式 `--force`。它通过 `/proc/self/exe` 复制当前正在执行的二进制 inode,启动路径随后被替换也不会改变 root 实际安装的内容;因此只在你运行别处副本时才有意义(如 `/usr/bin/sudo ./linux-temp-admin install`,前面的 `./` 是关键)。即使目标字节相同,也只有 root:root、精确 `0755`、无特殊位且父目录安全才会 no-op,否则会原子修复元数据。自动删除任务执行安装路径,因此邀请前会拒绝不安全、不可读取版本的已安装命令;开发版会安装当前运行文件的精确字节。 - -## 完整流程 - -### 1. 安装 - -使用[上方 root-owned bootstrap](#30-秒上手);高保证首次安装使用[发版文档中的固定 commit 与哈希流程](docs/releasing.md#host-install-and-upgrade)。 - -### 2. 创建邀请 +快速入口已经创建了第一个邀请。以后可以单独运行: ```bash /usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo ``` -交互模式很短:探测到公网 IP 就直接用(`--host` 可改域名/其他地址)、默认授予 sudo(这是个建管理员的工具,`--no-sudo` 可建普通账号)、问是否到期自动删除;**选了自动删除才问有效期**。最后列出摘要让你确认,再输出邀请包。 - -### 3. 你会拿到这样一份邀请包(已脱敏) - -下面只是格式示例,**不能用于登录**。真实私钥只在运行时随机生成、并在终端显示一次。 - -```text ------ BEGIN LINUX TEMP ADMIN INVITE ----- - -Host: 203.0.113.10 -Port: 22 -User: xxvcc-a1b2c3d4e5 -Expires: 2030-01-02 12:00:00 CST -Sudo: yes -Login: SSH key only (verified against the effective sshd config) -Password login: disabled -Auto revoke: yes -Auto revoke unit: linux-temp-admin-v2-revoke-xxvcc-a1b2c3d4e5 -Sshd exception: none - -保存私钥命令: -cat > './xxvcc-a1b2c3d4e5.key' <<'EOF_KEY' ------BEGIN OPENSSH PRIVATE KEY----- -[REDACTED: 运行时生成的一次性私钥] ------END OPENSSH PRIVATE KEY----- -EOF_KEY -chmod 600 './xxvcc-a1b2c3d4e5.key' - -安全提醒: 私钥只显示这一次、服务器不保存;仅通过可信私聊发送;用完立即撤销。 - ------ END LINUX TEMP ADMIN INVITE ----- -``` +交互流程会显示账号、Host、端口、有效期、sudo 状态和登录验证结果,并输出一次性的私钥保存命令。服务器只保存公钥,私钥不会落盘。 -> 邀请包里的字段名和命令块保持英文/固定格式,方便原样复制转发;中文只出现在说明行上。 - -`Login:` 那行是**检查结果,不是口号**:创建任何东西之前,工具会读一遍 `sshd -T -C user=<新账号>`(sshd 的有效配置,已展开 `Include`、`Match` 和发行版加密策略),确认这个账号真的能用公钥登录,才敢这么写。读不到就如实标 `UNVERIFIED`。 - -### 4. 把这份邀请包私聊发给协作者 - -对方拿到后只需两步,**无需安装任何东西、也不用懂这个工具**: - -- 复制「保存私钥命令」那一段,在自己电脑上粘贴运行 → 得到私钥文件; -- 用头部的 Host / Port / User 拼出登录命令即可,例如: - `ssh -i ./xxvcc-a1b2c3d4e5.key -p 22 xxvcc-a1b2c3d4e5@203.0.113.10`。 - -> ⚠️ 邀请包含一次性私钥,**只通过可信私聊发送**,不要发群里、工单或公开页面。 - -### 5. 用完撤销(或等它到期自动删) +把完整邀请包通过可信私聊发给协作者。对方保存私钥后,使用邀请头部的 Host、Port 和 User 登录,例如: ```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 +ssh -i ./xxvcc-a1b2c3d4e5.key -p 22 xxvcc-a1b2c3d4e5@203.0.113.10 ``` -默认 24 小时后会自动删除用户、家目录和密钥;但**用完立即手动撤销最稳妥**,别只依赖到期兜底。 - -## 常用操作 +邀请包中的真实私钥只显示一次,不要发送到群聊、工单、Notion 或公开页面。 -查看状态(登记的临时用户、过期时间、自动删除 timer): +## 查看和撤销 ```bash +# 查看全部临时账号 /usr/bin/sudo /usr/local/sbin/linux-temp-admin status -/usr/bin/sudo /usr/local/sbin/linux-temp-admin status --user xxvcc-a1b2c3d4e5 -``` -撤销/删除(从列表选编号,或直接指定用户名): - -```bash +# 从列表选择并撤销 /usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke + +# 直接撤销指定账号 /usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 ``` -清理失效登记与孤儿授权: - -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin cleanup-expired --compact -``` +默认会在 24 小时后自动删除账号、家目录、SSH key、sudo 授权和本工具创建的 sshd 例外。即使启用了自动删除,用完后也应立即手动撤销。 -**卸载 `uninstall`**:移除临时账号(连同家目录)、它们的 sudo 授权与 sshd 例外、自动删除任务、状态目录(含 v1 遗留),最后才是命令本身。为串行化并阻止已排队的旧进程在卸载后重新创建状态,生命周期锁和卸载标记会有意保留;审计日志也默认保留。 +## 常用命令 ```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall # 交互:先列清单,再输 YES -/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --remove-users # 非交互:有账号时必须显式加 --remove-users -/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --purge-audit # 连审计日志一起删 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin # 交互菜单 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin status # 查看账号状态 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor # 检查当前主机 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade # 验签升级 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin cleanup-expired --compact +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall # 卸载并处理受管账号 ``` -- **审计日志默认保留**在 `/var/log/linux-temp-admin/audit.log`。它记录的是谁开过、谁删过 root 级账号;卸载顺手抹掉这份记录,正是入侵者会做的事。要删得显式 `--purge-audit`。日志达到 64 MiB 后停止继续写入,避免无限占满文件系统;此时应由管理员归档或轮转该文件。 -- **只要有一个账号删不掉,命令和状态目录都不会被删**,卸载中止并点名那个账号。留着一个带 sudo 的账号、却删掉唯一能管理它的命令,比不卸载更糟:它的自动删除任务执行的就是这个命令。 -- **不能只删命令、留下账号**。`--force` 不再绕过这一点(它现在只保留原意:目标不是安全的 root 属主普通文件时仍强删)。 -- **从临时账号自己运行卸载会被拒绝**——它会在删到自己时把自己的会话一起收走,留下拆到一半的机器。请用 root 或别的管理员身份运行。 +界面默认中文。第一次交互运行时可以选择中文或 English,之后可在菜单中切换;单次运行也可以加 `--lang zh` 或 `--lang en`。 -`--compact` 会清掉:登记表里指向已不存在账号的失效条目,以及那些账号遗留的 **sudo 授权、sshd 例外和自动删除任务**(孤儿授权最危险——用户名一旦被复用就会重新生效)。它按「是否本工具当前托管的活账号」判定孤儿,所以一个被真实账号复用了名字的残留授权也会被发现。`doctor` 发现孤儿时提示的就是这条命令。 +## 常见场景 -> `cleanup-expired` **从不删除账号**:删账号用 `revoke`,看列表用 `status`。撤销未登记/陌生账号有额外限制(防误删),见[安全说明](#安全说明)。 - -## 常见用法 - -指定有效期(小时,1 到 8760): +指定 12 小时有效期: ```bash /usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --hours 12 ``` -不授予 sudo(创建为普通账号): +创建不带 sudo 的普通账号: ```bash /usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --no-sudo ``` -指定用户名前缀 / Host / 端口(前缀仅允许小写字母、数字、下划线、连字符,最长 20 字符): - -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --prefix ops --sudo -/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --host 203.0.113.10 --port 22 --sudo -``` - -不自动删除——创建**永久账号**(不设到期、不删除,需手动 `revoke`): +指定 Host、端口或用户名前缀: ```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --no-auto-revoke +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --host admin.example.com --port 2222 --prefix ops --sudo ``` -**自动化 / 非交互**(在 CI 或脚本里用)。非交互必须指定 `--host`;`--sudo --yes` 必须重复确认用户名;stdout 不是终端时还要显式允许输出私钥: - -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite \ - --user xxvcc-a1b2c3d4e5 \ - --host 203.0.113.10 --port 22 --hours 24 \ - --sudo --install-deps --yes \ - --confirm-sudo xxvcc-a1b2c3d4e5 \ - --allow-non-tty-private-key-output -``` - -### 服务器不接受公钥登录时 - -有些服务器把公钥登录关掉了(`PubkeyAuthentication no`),或者把 `authorized_keys` 改到了集中路径、开了 `AllowUsers` 白名单、要求多因素认证。这时候写进 `~/.ssh/authorized_keys` 的公钥 sshd 根本不看,邀请再漂亮也登不进去。 - -**工具会在创建任何东西之前发现这一点并拒绝**(此时账号还没建,零残留),并告诉你到底卡在哪一条。你有两个选择: - -**① 只为这一个账号开一条口子**(推荐): +服务器禁止公钥登录时,只为新账号创建独立 sshd 例外: ```bash /usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --fix-sshd ``` -它会写一个独立的 drop-in:先放置仅作用于该账号的 `Match User` 块,再用结尾的 `Match all` 恢复全局作用域,避免同一 Include 通配符中后续 drop-in 被意外纳入这个账号的作用域: - -```text -# /etc/ssh/sshd_config.d/10-linux-temp-admin-xxvcc-a1b2c3d4e5.conf -Match User xxvcc-a1b2c3d4e5 - PubkeyAuthentication yes -Match all -``` - -- **全局策略一个字节都不动**:其他所有账号的登录策略原封不动,该关的还是关着。 -- 写入后先 `sshd -t` 校验语法,再用 `sshd -T -C user=<账号>` **证明确实生效**,最后才 `reload`(**reload 不是 restart**,现有会话不受影响)。任何一步不过,就删掉文件、不 reload、中止创建。 -- `revoke`(含到期自动删除)会**删掉这个文件并 reload sshd**。所谓"还原"就是删掉我们自己写的文件——不需要备份,因此**不可能覆盖你后来对 sshd 做的任何改动**。 +该操作不会修改 sshd 全局策略,并会在撤销账号时删除对应例外。自动化调用、密码登录、永久账号和完整故障处理见[管理员指南](docs/operator-guide.md)。 -交互式运行会先问你一句;`--yes` 非交互模式下**不会**弹问,必须显式写 `--fix-sshd`——脚本不该在无人值守时悄悄改远程机器的 sshd。 +## 安全要点 -**② 改用密码登录**(不碰 sshd): +- `--sudo` 授予的是 NOPASSWD sudo,基本等同完整 root 权限,只能发给可信对象; +- 临时管理员取得 root 后可以自行建立其他持久化,本工具无法在撤销时自动清理这些外部改动; +- 邀请私钥只显示一次,应通过可信私聊交付,并在使用结束后立即撤销; +- 官方镜像 `https://dl.ll.cd/linux-temp-admin` 是默认下载源,只有传输故障才会从 GitHub 重新下载完整套件;已经取得有效镜像索引时仍固定同一版本,校验和、签名或版本失败会立即停止; +- stdout 不是终端时默认拒绝输出私钥,脚本化使用必须显式确认输出通道安全; +- 不要手工修改 `/var/lib/linux-temp-admin` 中的登记数据。 -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --password-login -``` +详细保证、威胁边界和故障处理见[安全模型](docs/security-model.md)。安全漏洞请按 [SECURITY.md](SECURITY.md) 私下报告。 -先验证 sshd 真的接受密码登录(否则拒绝),然后生成一个 24 位随机密码,只打印一次。**这是本工具最弱的一种授权**:密码在账号整个生命周期里都能被全网爆破,而且必须明文交付。能用 `--fix-sshd` 就别用它。 - -**工具不会为你做的事**:它**永远不会**修改 sshd 的全局配置,也**永远不会**绕过 `DenyUsers` / `DenyGroups` 这类显式拒绝规则——"不在白名单里"是你没表过态的默认值,而"明确拒绝"是你的决定。 - -想提前知道自己的服务器行不行,直接跑: - -```bash -/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor -``` - -## 参考 - -### 支持的系统 - -- **主要支持**:Debian / Ubuntu、宝塔常见 Linux 环境、RHEL / Rocky / AlmaLinux / Fedora -- **尽力支持**:Alpine、Arch Linux -- **内核要求**:Linux 5.3 或更高版本,并且 seccomp / 容器策略允许 `pidfd_open` 与 `pidfd_send_signal`。这是避免 PID 复用时误杀无关进程所必需的;`doctor` 会实测当前环境,`invite` 会在能力不可用时拒绝创建账号。 - -### 依赖 - -二进制本身零运行时依赖。它只调用系统自带的**账号管理工具**;这些工具缺失时可交互安装(需确认或传 `--install-deps`),支持 `apt-get` / `dnf` / `yum` / `apk`: - -- `id`、`useradd` 或 `adduser`、`userdel` 或 `deluser`、`usermod`、`chage` -- `sudo`:仅在选择授予 sudo 时需要 - -Arch 的 `pacman` 不支持部分升级,而安全的 `pacman -Syu` 会升级整个系统,因此本工具不会在创建账号时自动运行它。请先由管理员明确执行提示中的 `pacman -Syu --needed ...`,再重试邀请。 - -`doctor` 会显示**运行中的版本与已安装命令的版本**(两者不一致会提示——自动删除任务执行的是已安装的那份),逐项检查上面这些工具和 pidfd 能力,外加包管理器、init 系统、`/etc/sudoers.d` 的安全性、探测到的 SSH 端口,并**预演一个新建临时账号能否通过公钥登录**(sshd 会拒绝时给出 `invite --fix-sshd` 提示)。它还会报告**孤儿的 sudo 授权、sshd 例外和自动删除任务**(账号不存在或身份无法验证却残留),以及设置了自动删除却已无对应任务的账号——都指向 `cleanup-expired --compact` 或 `revoke` 处理。 - -`at` / `atd` 是 systemd 不可用时自动删除的备用后端,**不在依赖检查里,也不会被自动安装**。 - -### 关于"过期"和"自动删除" - -默认有效期 24 小时,且**默认开启自动删除**。开启自动删除时,工具会写入精确到点的自动删除任务(优先持久化 systemd timer,`at` 兜底),同时用 `chage -E` 设置按天粒度的兜底锁定。该兜底绝不会早于邀请中显示的截止时间,但最多可能晚约 24 小时;真正的精确截止机制是自动删除任务。如果两个调度后端都无法创建任务,整个邀请会回滚,不会留下“仅设置账号过期”的账号。自动删除任务调用的是已安装命令,因此工具会先确保 `/usr/local/sbin/linux-temp-admin` 存在。任务同时绑定创建时的 UID、随机 128 位世代标识和登记行;任一不匹配(包括登记表丢失或账号被重建)都会安全跳过删除。systemd 撤销失败会限速重试;`at` 和旧版一次性任务失败后需人工处理,`doctor` 会报告登记账号缺失自动删除任务的情况。 - -**不开启自动删除 = 永久账号**:不设任何到期、也不会被删除,只能手动 `revoke`。此时 `--hours` 被忽略。 - -关于 Host 的两点用户须知: - -- 交互模式不传 `--host` 时,会**静默**探测云厂商 metadata 和本地网卡(这两者都不出本机/本链路),探到的地址作为默认值填进提示符,回车即接受、也可直接改写。只有在本机探不到公网 IP 时,才会**询问**是否访问 `https://api.ipify.org`、`https://ifconfig.me/ip`、`https://icanhazip.com`——这一步会把你的服务器地址暴露给第三方,所以必须显式同意。`--yes` 模式永远不会外联,必须显式传 `--host`。 -- `--host` 只接受普通域名、IPv4 或 IPv6;不要带端口(用 `--port` 单独指定),邀请包中的 SSH 命令会自动为 IPv6 加方括号。自动探测只接受可路由公网地址,会排除私网、链路本地、文档、基准测试、CGNAT 等保留范围;显式域名或地址仍由操作者决定。 - -### 写入的文件 - -```text -/usr/local/sbin/linux-temp-admin # 稳定撤销命令 -/var/lib/linux-temp-admin/v2/registry.tsv # 本地登记表(root:root 0600,目录 0700) -/var/lib/linux-temp-admin/v2/prefs # 记住的界面语言(root:root 0600) -/var/log/linux-temp-admin/audit.log # 操作审计日志(root:root 0600,目录 0700) -/run/linux-temp-admin.lock # 全局账号/安装生命周期锁 -/run/linux-temp-admin.lock.uninstalled # 卸载完成标记;显式重新安装时删除 -/etc/systemd/system/linux-temp-admin-v2-revoke-USER.service # 含 NoNewPrivileges 等轻量限制 -/etc/systemd/system/linux-temp-admin-v2-revoke-USER.timer -/etc/sudoers.d/linux-temp-admin-USER # 仅在启用免密 sudo 时 -/etc/ssh/sshd_config.d/10-linux-temp-admin-USER.conf # 仅在 --fix-sshd 时;账号作用域块及 Match all 重置,revoke 时删除 -/home/USER/.ssh/authorized_keys -# 以及在 systemd 不可用时,at 队列中的备用自动删除任务 -``` +## 文档 -## 安全说明 - -- 私钥只在创建时显示一次,服务器不保存;公钥账号使用不可能匹配任何密码、且不会触发 Alpine/OpenSSH 整账号锁定判定的 shadow 值禁用密码认证。只有显式的 `--password-login` 会生成并一次性显示账号密码;工具不生成 sudo 密码。 -- 邀请里的 `Login:` 是**验证过的结论**:创建前会读 `sshd -T -C user=<新账号>` 确认这个账号真能登进去;读不到配置,或发现 `Address`、`Host`、`LocalAddress`、`LocalPort` 等依赖连接属性的 `Match` 条件时会标 `UNVERIFIED`,绝不凭空断言。 -- **绝不修改 sshd 全局配置**。`--fix-sshd` 只写一个独立 drop-in,其中 `Match User` 块仅含解除已检测阻碍所需的指令,并以 `Match all` 重置 Include 流的作用域;其他账号的有效策略不变。写入前 `sshd -t` 校验、写入后 `sshd -T` 证明生效、只 `reload` 不 `restart`。任一步失败都会触发清理,邀请事务还会独立重试;删除或恢复失败会作为回滚错误明确报告。`revoke` 会删掉该文件。**绝不绕过 `DenyUsers`/`DenyGroups` 这类显式拒绝规则。** -- `--password-login` 是最弱的授权方式(密码可被全网爆破、必须明文交付),只在显式要求时启用,且会先验证 sshd 确实接受密码登录。 -- **NOPASSWD sudo 基本等同 root**,只给可信对象;撤销只删除该账号本身,不会清理它以 root 身份留下的进程、cron、systemd 单元或 SUID 文件。 -- 删除用户会一并删除家目录和 SSH key;SSH 家目录必须严格属于目标 UID,绝不会把 root/UID 0 的目录当作目标家目录操作。如果系统删除命令失败,工具会停下并提示手动检查,不会假装撤销成功。 -- **防误删**:每个新邀请(包括不开启自动删除的永久账号)都有独立的随机世代,并把它写入精确 GECOS 标记和登记行;`revoke` 默认只接受 UID、世代和标记全部匹配的登记目标,任一不匹配都会拒绝/跳过删除。世代是可读的账号代际关联标识,不是秘密,也不用于抵抗已经拥有 root 的攻击者。删除未登记但带精确标记的账号需显式 `--force`,非交互还需 `--confirm-force USER`。 -- 从 v2 登记表迁移来的固定 GECOS 账号会显示为 `managed=false identity=legacy-unverified`。因为同名同 UID 的替代账号可以复制旧固定标记,定时撤销、批量清理和卸载都不会自动删除这类账号;必须先人工核查,再直接运行 `revoke --user USER --force` 并输入完整用户名确认(非交互还需 `--yes --confirm-force USER`)。 -- 即使使用 `--force`,也会拒绝删除 root、常见系统账号、UID 0、低 UID 系统账号,以及**任何非本工具创建(无精确标记)**的真实账号——这类账号请改用系统的 `userdel`。 -- 创建过程中任一步失败都会尝试完整回滚自动撤销、sudoers、sshd 例外、登记记录和新建账号;任何回滚失败都会明确报告并返回非零,不会把部分成功伪装成成功。 -- invite、revoke、cleanup、install、upgrade、uninstall 的**受管状态提交**共用一把位于可删除状态目录之外的 root 生命周期锁,账号、任务、授权、登记和二进制变更不会相互穿插。人工确认、依赖安装和升级的下载/验签在锁外完成;拿锁后会重新核验账号清单或已安装版本再提交,避免长时间阻塞到期撤销。创建前同时检查本地 passwd 与 NSS,避免本地邀请覆盖 LDAP/SSSD 同名身份。 -- 撤销时若 sudoers 或 sshd 例外无法完全移除,会保留账号和登记并尝试禁用登录,避免残留的按用户名授权在账号复用后重新生效;清理、登记或调度错误同样返回非零。 -- 登记表会严格校验 schema、字段、UID 和世代标识;损坏或不可读时,`status`、`doctor`、清理、撤销和卸载都会 fail closed,而不是把“读不到”当成“没有账号”。 -- 默认升级把官方镜像作为首选完整来源,仅在传输故障时从 GitHub 重新下载整套文件;索引语义、校验和、签名或候选版本失败立即中止。显式自定义 URL 永不切换到官方来源。 -- 每次特权操作(建/删账号、install/upgrade/uninstall)会以 JSON 行追加写入 root 属主的 `/var/log/linux-temp-admin/audit.log`(记录时间、操作者 `SUDO_USER`、动作、目标、结果)。单条记录上限 64 KiB,日志总上限 64 MiB;达到上限后操作仍会继续,但会明确警告管理员先归档或轮转日志。 -- stdout 不是 TTY 时默认拒绝输出私钥,只有确认输出通道安全时才用 `--allow-non-tty-private-key-output`。 -- 不要把真实邀请包提交到 GitHub、Notion、工单或群聊;用完请立即 `revoke`,不要只依赖到期兜底。 - -## 开发与许可证 - -- 贡献流程与本地校验:[CONTRIBUTING.md](CONTRIBUTING.md) -- 安全问题请按 [SECURITY.md](SECURITY.md) 私下报告;版本变化见 [CHANGELOG.md](CHANGELOG.md)。 +- [安装、升级与下载验证](docs/installing.md) +- [管理员指南](docs/operator-guide.md) +- [安全模型](docs/security-model.md) +- [版本变化](CHANGELOG.md) +- [贡献指南](CONTRIBUTING.md) +- [维护者发版流程](docs/releasing.md) 许可证:MIT,详见 [LICENSE](LICENSE)。 diff --git a/SECURITY.md b/SECURITY.md index 41aa8f1..b47e251 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -47,11 +47,8 @@ Out of scope: - Treat every invite bundle as a secret because it contains a one-time private key. - Revoke access immediately after use; do not rely only on expiry. - Grant `--sudo` only to users you trust with full root access. -- Keep `/usr/local/sbin/linux-temp-admin` a root-owned regular file, never a symlink, and not group/world writable. Invites refuse to schedule against a command that fails those checks or cannot report a valid version. -- Keep `/var/lib/linux-temp-admin/v2/registry.tsv` root-owned and unmodified. Registry corruption or read failure is handled fail-closed; unattended revokes additionally require the recorded UID and random generation token to match, and every live deletion still requires the exact managed GECOS marker. - Treat revoke or rollback cleanup errors as unresolved incidents. The command returns nonzero and retains the account/registry when a name-scoped sudoers or sshd grant cannot be safely removed. -- The official installer is `https://dl.ll.cd/linux-temp-admin/install.sh`. Default install and `upgrade` operations prefer that official mirror and obtain the selected release's manifest/checksum data, binary, and detached ed25519 signature as a complete source set. They never combine mirror and GitHub files. -- GitHub is a fallback only for transport failures such as DNS, TLS, timeout, HTTP, empty/oversized-response, or incomplete-download failures. A valid mirror manifest pins fallback assets to the same tag. Manifest-semantic, checksum, signature, and candidate-version failures stop immediately without fallback. -- Official mirror URLs must return files directly without redirects. The mirror index is canonical single-line JSON, `SHA256SUMS` is lowercase NUL-free text ending in a newline, and detached signatures are exactly 64 raw bytes. A mirror redirect or noncanonical file is a source-policy failure and never triggers fallback; GitHub Release CDN redirects remain HTTPS-only and must resolve to public addresses. -- Explicit `upgrade --url` and `upgrade --url-file` requests use only the operator-selected source and never silently switch to the official mirror or GitHub. -- Both default paths fail closed. `upgrade` verifies the complete release set and detached ed25519 signature against the embedded keyring before installing; the `install.sh` bootstrap verifies the published SHA-256 checksum and detached signature, and requires OpenSSL 3 with no unsigned or checksum-only fallback. Report any way to bypass these checks or the source-selection rules above. +- The official installer is `https://dl.ll.cd/linux-temp-admin/install.sh`. Binary installation and upgrades verify the complete release set and detached ed25519 signature against the embedded keyring, with no unsigned or checksum-only fallback. +- The README convenience command streams the mirror's installer into a root shell and therefore trusts that first HTTPS/script path before binary verification begins. Use the [installation guide](docs/installing.en.md#high-assurance-first-install) when the script must be authenticated before execution. + +The complete runtime guarantees, failure behavior, and residual risks are documented in the [security model](docs/security-model.en.md). Installation source selection and verification are documented in the [installation guide](docs/installing.en.md). diff --git a/docs/installing.en.md b/docs/installing.en.md new file mode 100644 index 0000000..921769d --- /dev/null +++ b/docs/installing.en.md @@ -0,0 +1,115 @@ +# Installation, Upgrades, and Download Verification + +[中文](installing.md) | English + +This guide is for administrators who install and maintain `linux-temp-admin`. See the [operator guide](operator-guide.en.md) for creating and revoking accounts, and the [security model](security-model.en.md) for guarantees and trust boundaries. + +## Supported environment + +- Linux 5.3 or newer; +- amd64 or arm64; +- primary support for Debian, Ubuntu, RHEL, Rocky, AlmaLinux, Fedora, and common aaPanel environments; +- best-effort support for Alpine and Arch Linux; +- root access plus curl, OpenSSL 3, sha256sum, and timeout for installation; +- `getent` or `nslookup` for GitHub CDN fallback, so every redirect target can be validated and pinned to a public address. + +The binary has no dynamic-library or language-runtime dependency. Account lifecycle operations still use the system's `id`, `useradd`/`adduser`, `userdel`/`deluser`, `usermod`, and `chage`; granting sudo also requires `sudo`. Missing tools can be installed through apt, dnf, yum, or apk after interactive confirmation. + +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 + +Run this in a shell that supports `pipefail`: + +```bash +set -o pipefail +curl -fsSL https://dl.ll.cd/linux-temp-admin/install.sh | /usr/bin/sudo /bin/sh +``` + +After installation, run: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor +``` + +The convenience command obtains the script from the official mirror over HTTPS and streams it directly to a root shell. `pipefail` propagates curl DNS, TLS, HTTP, and transfer failures to the whole pipeline; it **does not authenticate the script or stop an already received partial script from beginning execution**. Before the first curl has obtained the installer, the installer is not running and cannot provide its own GitHub fallback. + +Once started, the installer applies a separate strict verification chain to the binary. SHA-256, the detached ed25519 signature, architecture, and candidate version must all pass before an atomic installation to `/usr/local/sbin/linux-temp-admin`. There is no unsigned or checksum-only fallback. + +## Official mirror and GitHub fallback + +The compiled-in preferred release source is: + +```text +https://dl.ll.cd/linux-temp-admin +``` + +For a `latest` install or upgrade: + +1. read the canonical mirror `latest.json` and pin one exact tag; only a transport failure while obtaining the index queries GitHub Latest; +2. download `SHA256SUMS`, the current-architecture binary, and its signature from that mirror version directory; +3. verify the three files as one source set without mixing in GitHub files; +4. only a transport failure discards the whole mirror set and redownloads it from the same GitHub tag; +5. checksum, signature, manifest-semantic, and candidate-version failures stop immediately. + +Transport failures include DNS, TLS, timeout, HTTP, empty or oversized responses, and incomplete downloads. A mirror redirect, noncanonical manifest, SHA-256 mismatch, ed25519 verification failure, or version mismatch is not a transport failure and must not be hidden by changing sources. + +Official mirror files must be returned directly without redirects. GitHub Release CDN redirects remain HTTPS-only and must resolve to validated public addresses. Every download has connect and total timeouts, hard size limits, and bounded retries. + +## High-assurance first install + +Before execution, the convenience path trusts the mirror HTTPS endpoint and stable-installer deployment. When the script must be authenticated before root executes it, independently pin all three of these values: + +- the audited 40-hex commit; +- the install script SHA-256 obtained through a separately authenticated channel; +- the exact `vX.Y.Z` release tag. + +The single canonical command, protected by dynamic failure tests, is in [Host install and upgrade in the maintainer release guide](releasing.md#host-install-and-upgrade). It first enters a sanitized root shell, creates a bounded root-owned temporary file, verifies the independent hash before execution, and forces the exact release. + +Do not obtain both the script and its "expected hash" from the same web page or download path; that is not independent authentication. Obtain the tag, commit, script hash, and version from the release audit record and another trusted channel. + +## Routine upgrade + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade --yes +``` + +The upgrader follows the same mirror-first, complete-source, fail-closed policy as the installer. By default it replaces only an older version. Use `--force` only for a deliberate same-version reinstall, downgrade, or repair of an unreadable target. + +For a public custom source: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade --url https://downloads.example.com/linux-temp-admin +``` + +A URL containing credentials, tokens, or signed query values must not appear in argv, shell history, or logs. Put the binary URL in an absolute root-owned `0600` file; an optional second line can hold an independent signature URL: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade --url-file /root/lta-upgrade-url +``` + +Explicit `--url` and `--url-file` requests use only the operator-selected source and never silently switch to the official mirror or GitHub. + +## Install a local binary + +```bash +/usr/bin/sudo ./linux-temp-admin install +``` + +`install` places the currently running binary at the standard path without network access or an additional signature check. It is intended for offline hosts and self-built binaries, and copies the current inode through `/proc/self/exe`. Use it only after independently trusting that binary. A different existing target requires explicit `--force`. + +## Diagnose installation failures + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin version +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor +``` + +Classify the failure first: + +- DNS, TLS, timeout, HTTP, or incomplete download: transport failure, eligible for the defined retry or fallback policy; +- checksum, signature, manifest, version, or architecture error: integrity failure, stop immediately and do not force a bypass; +- OpenSSL version, system tool, pidfd, or sshd condition: host-environment problem, follow the concrete `doctor` result. + +See [Uninstall](operator-guide.en.md#uninstall) for teardown and managed-account handling. diff --git a/docs/installing.md b/docs/installing.md new file mode 100644 index 0000000..ba2a20a --- /dev/null +++ b/docs/installing.md @@ -0,0 +1,115 @@ +# 安装、升级与下载验证 + +中文 | [English](installing.en.md) + +本文面向安装和维护 `linux-temp-admin` 的服务器管理员。日常创建、查看和撤销账号见[管理员指南](operator-guide.md),安全保证与威胁边界见[安全模型](security-model.md)。 + +## 支持范围 + +- Linux 5.3 或更高版本; +- amd64 或 arm64; +- 主要支持 Debian、Ubuntu、RHEL、Rocky、AlmaLinux、Fedora 和常见宝塔环境; +- Alpine 和 Arch Linux 为尽力支持; +- 安装需要 root 权限、curl、OpenSSL 3、sha256sum 和 timeout; +- GitHub CDN 回退还需要 `getent` 或 `nslookup`,用于验证并固定每个重定向目标的公网地址。 + +二进制本身不依赖动态库或语言运行时。账号生命周期仍会使用系统的 `id`、`useradd`/`adduser`、`userdel`/`deluser`、`usermod` 和 `chage`;授予 sudo 时还需要 `sudo`。缺失依赖可在交互确认后通过 apt、dnf、yum 或 apk 安装。 + +Arch Linux 不允许安全的部分升级,而 `pacman -Syu` 会升级整个系统,因此本工具不会在创建账号时自动运行 pacman。请根据提示由管理员先完成完整升级和依赖安装。 + +## 便利安装 + +在支持 `pipefail` 的 shell 中运行: + +```bash +set -o pipefail +curl -fsSL https://dl.ll.cd/linux-temp-admin/install.sh | /usr/bin/sudo /bin/sh +``` + +安装完成后执行: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor +``` + +便利入口通过 HTTPS 从官方镜像取得脚本并立即交给 root shell。`pipefail` 会让 curl 的 DNS、TLS、HTTP 或传输失败成为整个管道的失败;它**不认证脚本本身,也不能阻止已经收到的部分脚本开始执行**。第一次 curl 尚未取得安装器时,安装器还没有运行,因此无法自行回退 GitHub。 + +安装器启动后,二进制下载进入另一条严格验证链:SHA-256、detached ed25519 签名、架构和候选版本全部通过后,才会原子安装到 `/usr/local/sbin/linux-temp-admin`。不存在未验签或只有校验和的降级路径。 + +## 官方镜像与 GitHub 回退 + +程序内置的首选发布源是: + +```text +https://dl.ll.cd/linux-temp-admin +``` + +安装或升级 `latest` 时: + +1. 从镜像读取规范 `latest.json`,锁定一个精确 tag;只有索引本身发生传输故障才查询 GitHub Latest; +2. 从该镜像版本目录下载 `SHA256SUMS`、当前架构二进制和签名; +3. 三个文件作为同一套来源验证,不会与 GitHub 文件混用; +4. 只有传输故障才丢弃整套镜像文件,并从同一 GitHub tag 重新下载; +5. 校验和、签名、manifest 语义或候选版本失败都会立即停止。 + +传输故障包括 DNS、TLS、超时、HTTP、空响应、超限响应和不完整下载。镜像重定向、非规范 manifest、SHA-256 不匹配、ed25519 验签失败和版本不匹配不是传输故障,不能通过切换来源掩盖。 + +官方镜像文件必须直接返回,不允许重定向。GitHub Release CDN 可以使用经过公网地址验证的 HTTPS 重定向。所有下载都有连接/总超时、大小硬限制和有界重试。 + +## 高保证首次安装 + +便利入口在执行前信任镜像 HTTPS 和稳定安装脚本的部署。需要在 root 执行前认证脚本时,应同时固定: + +- 已审计的 40 位 commit; +- 通过独立认证渠道取得的安装脚本 SHA-256; +- 精确的 `vX.Y.Z` 发行标签。 + +唯一规范、受动态故障测试保护的命令位于[维护者发版文档的 Host install and upgrade 章节](releasing.md#host-install-and-upgrade)。该流程先进入清理过的 root shell,再创建 root 独占的有界临时文件,验证独立哈希后才执行,并强制安装精确版本。 + +不要从网页内容或同一下载链路同时取得脚本和“预期哈希”;那不能提供独立认证。发布 tag、commit、脚本哈希和版本应来自发行审计记录及另一个可信渠道。 + +## 日常升级 + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade --yes +``` + +升级器遵循与安装器相同的镜像优先、完整来源和 fail-closed 规则。默认只在版本更新时替换;明确需要同版重装、降级或修复不可读目标时才使用 `--force`。 + +自定义公开来源: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade --url https://downloads.example.com/linux-temp-admin +``` + +含凭据、token 或签名 query 的 URL 不应出现在 argv、shell 历史或日志中。把二进制 URL 放入 root 所有、`0600` 的绝对路径文件;可选第二行单独写签名 URL: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin upgrade --url-file /root/lta-upgrade-url +``` + +显式 `--url` 或 `--url-file` 只使用操作者选择的来源,失败时不会静默切换到官方镜像或 GitHub。 + +## 安装本地二进制 + +```bash +/usr/bin/sudo ./linux-temp-admin install +``` + +`install` 把当前正在运行的二进制放到标准路径,不联网,也不额外验签,适用于离线机器或自行构建的文件。它通过 `/proc/self/exe` 复制当前 inode;只有在已经独立信任该二进制时才能使用。目标内容不同时需要显式 `--force`。 + +## 诊断安装问题 + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin version +/usr/bin/sudo /usr/local/sbin/linux-temp-admin doctor +``` + +先区分错误类型: + +- DNS、TLS、超时、HTTP 或不完整下载:传输故障,可以按既定策略重试或回退; +- checksum、signature、manifest、version 或架构错误:完整性故障,立即停止,不要强制绕过; +- OpenSSL 版本、系统工具、pidfd 或 sshd 条件不满足:主机环境问题,按 `doctor` 的具体结果处理。 + +卸载和受管账号处理见[管理员指南](operator-guide.md#卸载)。 diff --git a/docs/operator-guide.en.md b/docs/operator-guide.en.md new file mode 100644 index 0000000..054d91e --- /dev/null +++ b/docs/operator-guide.en.md @@ -0,0 +1,185 @@ +# Operator Guide + +[中文](operator-guide.md) | English + +This guide covers routine account management with `linux-temp-admin`. See the [installation guide](installing.en.md) for installation and upgrades, and the [security model](security-model.en.md) for detailed guarantees. + +## Interactive menu and language + +Run without a subcommand to enter the interactive menu: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin +``` + +The menu is shown on entry and again after you press Enter, leaving the previous operation's result visible. The first interactive run asks for Chinese or English and stores the choice in `/var/lib/linux-temp-admin/v2/prefs`. The menu can switch languages later. + +Language precedence is `--lang zh|en`, `LINUX_TEMP_ADMIN_LANG`, the saved choice, the first-run prompt, then Chinese. System `LANG` and `LC_ALL` do not select the interface language. + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin --lang en status +``` + +Non-interactive tasks cannot prompt and use the saved choice, or Chinese if no choice exists. Prefer an explicit `--lang` through sudo instead of broadly preserving the caller's environment. + +## Create an invite + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo +``` + +The interactive flow: + +1. chooses a username, using a random suffix by default; +2. detects or asks for the invite host and SSH port; +3. grants sudo by default, with an option for a regular account; +4. asks whether to auto-delete and then asks the lifetime only when enabled; +5. shows the complete summary for confirmation; +6. creates the account, grants, and revoke task before printing the invite credential. + +Before creating anything, the tool evaluates the effective sshd configuration to rehearse whether the new account can log in. A definite blocker refuses creation; incomplete knowledge is reported as `UNVERIFIED` rather than presented as a verified result. + +### Host detection + +Without `--host`, interactive mode first checks cloud metadata and local interfaces; these checks do not leave the host or local link. Only when no public address is found does it ask permission to query a public IP service, which exposes the server's egress address to that third party. + +`--yes` mode never queries a public IP service and requires an explicit `--host`. The host accepts a plain domain, IPv4, or IPv6 value; pass the port separately with `--port`. + +### Common variants + +```bash +# 12 hours with sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --hours 12 + +# Regular account +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --no-sudo + +# Explicit username, prefix, host, or port +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --user ops-a1b2c3d4e5 --sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --prefix ops --sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --host admin.example.com --port 2222 --sudo + +# Permanent account: no expiry and no automatic removal +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --no-auto-revoke +``` + +With automatic removal disabled, only `revoke` deletes the account and `--hours` is ignored. + +## Deliver the invite + +The bundle contains Host, Port, User, expiry, sudo state, login verdict, and a command that saves the one-time private key. Only the public key is stored on the server; the private key is printed once after successful creation. + +Forward the complete bundle through trusted private chat. After saving the key, the collaborator forms the SSH command from the header fields: + +```bash +ssh -i ./USER.key -p PORT USER@HOST +``` + +Invite fields and command blocks use a fixed English format so the bundle can be forwarded verbatim. Never put a real invite in a group chat, ticket, knowledge base, or public page. + +## Automation and non-interactive use + +A non-interactive invite requires an explicit host. Granting sudo must repeat the username, and non-terminal stdout must explicitly acknowledge that the output channel can carry a private key: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite \ + --user xxvcc-a1b2c3d4e5 \ + --host 203.0.113.10 --port 22 --hours 24 \ + --sudo --install-deps --yes \ + --confirm-sudo xxvcc-a1b2c3d4e5 \ + --allow-non-tty-private-key-output +``` + +Unattended mode never installs dependencies or changes sshd implicitly; pass `--install-deps` or `--fix-sshd` explicitly. Treat logs, CI output, and downstream pipelines as private-key channels. + +## Inspect status + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin status +/usr/bin/sudo /usr/local/sbin/linux-temp-admin status --user xxvcc-a1b2c3d4e5 +``` + +Status reports account identity, UID, expiry, auto-delete task, and registry anomalies. `doctor` also reports orphaned sudoers files, sshd exceptions, revoke tasks, and missing schedulers. + +## Revoke an account + +```bash +# Choose from a list +/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke + +# Name one account +/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 +``` + +Revoke removes the account, home directory, public key, sudoers grant, account-scoped sshd exception, and automatic task. If a name-scoped grant cannot be removed safely, the tool retains and disables the account and returns nonzero so username reuse cannot reactivate a leftover grant. + +Deleting an unregistered account requires explicit `--force` and an additional username confirmation. Root, UID 0, low-UID system accounts, and real accounts without the tool's exact marker are never treated as managed accounts. + +## Clean anomalous state + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin cleanup-expired --compact +``` + +`cleanup-expired` removes stale registry rows and orphaned sudoers files, sshd exceptions, and revoke tasks. It **never deletes an account**. Use `revoke` to delete an account and `status` to list them. + +## Public-key login is disabled + +If sshd disables public-key login, changes the `authorized_keys` path, or uses an AllowUsers list, the tool detects the problem before creation and refuses the invite. + +The preferred repair is an exception scoped only to the new account: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --fix-sshd +``` + +This option writes an account-scoped sshd drop-in without changing global policy. It validates the file with `sshd -t` and `sshd -T -C user=...`, then reloads rather than restarts sshd. Any failure removes the file and aborts; `revoke` removes the exception and reloads again. Explicit `DenyUsers` and `DenyGroups` rules are never bypassed. + +When public keys cannot be used, password login can be selected explicitly: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --password-login +``` + +The tool first verifies that sshd accepts passwords, then creates a random password shown once. This is the weaker grant because the password can be attacked over the network throughout its lifetime; prefer public keys. + +## Expiry and automatic removal + +The default lifetime is 24 hours with automatic removal enabled. A persistent systemd timer is preferred; an existing `at`/`atd` service is the fallback when systemd is unavailable. `at` is never installed automatically. If neither backend can schedule removal, the entire invite rolls back. + +`chage -E` provides only a day-granularity lock fallback and can be later than the displayed expiry; the revoke task enforces the exact deadline. The task binds the original UID, random generation token, and registry record, refusing to delete an account that has been removed and recreated or no longer matches. + +## Uninstall + +```bash +# Interactive: show the complete inventory, then type YES +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall + +# Non-interactive; managed accounts require explicit removal authorization +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --remove-users + +# Remove managed accounts and also delete the audit log retained by default +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --remove-users --purge-audit +``` + +Uninstall removes managed accounts and their grants, exceptions, and tasks before deleting state and the program. A failure to delete any account aborts the uninstall instead of leaving a sudo-capable account without its management command. Running uninstall from the temporary account's own session is refused. + +The audit log remains at `/var/log/linux-temp-admin/audit.log` by default. The lifecycle lock and uninstall marker also remain to prevent already queued old processes from recreating state; an explicit reinstall handles the marker. + +## Written paths + +```text +/usr/local/sbin/linux-temp-admin +/var/lib/linux-temp-admin/v2/registry.tsv +/var/lib/linux-temp-admin/v2/prefs +/var/log/linux-temp-admin/audit.log +/run/linux-temp-admin.lock +/run/linux-temp-admin.lock.uninstalled +/etc/systemd/system/linux-temp-admin-v2-revoke-USER.service +/etc/systemd/system/linux-temp-admin-v2-revoke-USER.timer +/etc/sudoers.d/linux-temp-admin-USER +/etc/ssh/sshd_config.d/10-linux-temp-admin-USER.conf +/home/USER/.ssh/authorized_keys +``` + +An `at` queue entry may also exist when systemd is unavailable. The sshd file exists only after explicit `--fix-sshd`, and the sudoers file exists only for a sudo-enabled account. diff --git a/docs/operator-guide.md b/docs/operator-guide.md new file mode 100644 index 0000000..e56df2d --- /dev/null +++ b/docs/operator-guide.md @@ -0,0 +1,185 @@ +# 管理员指南 + +中文 | [English](operator-guide.en.md) + +本文说明 `linux-temp-admin` 的日常账号管理。安装与升级见[安装指南](installing.md),安全保证见[安全模型](security-model.md)。 + +## 交互菜单与语言 + +不带子命令运行会进入交互菜单: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin +``` + +菜单只在进入时和按回车后重新显示,上一项操作结果会留在屏幕上。第一次交互运行会询问中文或 English,并把选择保存到 `/var/lib/linux-temp-admin/v2/prefs`。以后可在菜单中选择“切换语言 / Switch language”。 + +语言优先级为:`--lang zh|en`、`LINUX_TEMP_ADMIN_LANG`、已保存选择、首次交互提示、中文。系统的 `LANG` 和 `LC_ALL` 不决定界面语言。 + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin --lang en status +``` + +非交互任务无法询问语言,会使用已保存选择,没有则使用中文。通过 sudo 调用时优先显式传 `--lang`,不要为一个语言变量宽泛保留调用者环境。 + +## 创建邀请 + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo +``` + +交互模式会: + +1. 选择用户名,默认使用随机后缀; +2. 探测或询问邀请中的 Host 和 SSH 端口; +3. 默认授予 sudo,也可以选择普通账号; +4. 询问是否自动删除,启用时再询问有效期; +5. 显示完整摘要并确认; +6. 创建账号、授权和撤销任务,最后才输出邀请私钥。 + +创建任何内容前,工具会用 sshd 的有效配置预演新账号能否登录。明确阻止登录时会直接拒绝,无法完整判断时会在邀请中标记 `UNVERIFIED`,不会伪造已验证结论。 + +### Host 探测 + +不传 `--host` 时,交互模式先读取云 metadata 和本地网卡,这些探测不会离开本机或本链路。只有找不到公网地址时,才会询问是否访问公网 IP 服务;这会向第三方暴露服务器出口地址,必须显式同意。 + +`--yes` 模式永远不会主动访问公网 IP 服务,必须显式提供 `--host`。Host 只接受普通域名、IPv4 或 IPv6;端口使用单独的 `--port`。 + +### 常用变体 + +```bash +# 12 小时、带 sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --hours 12 + +# 普通账号 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --no-sudo + +# 指定用户名、前缀、Host 或端口 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --user ops-a1b2c3d4e5 --sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --prefix ops --sudo +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --host admin.example.com --port 2222 --sudo + +# 永久账号:不设到期,也不会自动删除 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --no-auto-revoke +``` + +关闭自动删除后,账号只能通过 `revoke` 手动删除,`--hours` 会被忽略。 + +## 交付邀请 + +邀请包包含 Host、Port、User、截止时间、sudo 状态、登录验证结果和一次性私钥保存命令。服务器只保存公钥;私钥只在成功创建后显示一次。 + +通过可信私聊转发完整邀请包。协作者保存私钥后,使用头部字段组合 SSH 命令: + +```bash +ssh -i ./USER.key -p PORT USER@HOST +``` + +邀请字段和命令块使用固定英文格式,便于原样转发。不要把真实邀请放入群聊、工单、知识库或公开页面。 + +## 自动化与非交互运行 + +非交互创建必须明确 Host。授予 sudo 时必须重复确认用户名;stdout 不是终端时,还必须确认输出通道允许承载私钥: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite \ + --user xxvcc-a1b2c3d4e5 \ + --host 203.0.113.10 --port 22 --hours 24 \ + --sudo --install-deps --yes \ + --confirm-sudo xxvcc-a1b2c3d4e5 \ + --allow-non-tty-private-key-output +``` + +无人值守模式不会隐式安装依赖或修改 sshd;需要时必须显式传 `--install-deps` 或 `--fix-sshd`。日志系统、CI 输出和管道下游都应按私钥处理。 + +## 查看状态 + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin status +/usr/bin/sudo /usr/local/sbin/linux-temp-admin status --user xxvcc-a1b2c3d4e5 +``` + +状态会显示账号身份、UID、有效期、自动删除任务和异常登记。`doctor` 还会报告孤儿 sudoers、sshd 例外、撤销任务和缺失调度器。 + +## 撤销账号 + +```bash +# 从列表选择 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke + +# 指定账号 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin revoke --user xxvcc-a1b2c3d4e5 +``` + +撤销会删除账号、家目录、公钥、sudoers、账号专属 sshd 例外和自动删除任务。任一按用户名授权无法安全删除时,工具会保留并禁用账号、返回非零,避免用户名被复用后重新取得残留权限。 + +删除未登记账号需要显式 `--force`,并有额外用户名确认。root、UID 0、低 UID 系统账号及没有本工具精确标记的真实账号始终不会被当作受管账号删除。 + +## 清理异常状态 + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin cleanup-expired --compact +``` + +`cleanup-expired` 只清理失效登记及孤儿 sudoers、sshd 例外和撤销任务,**不会删除账号**。删除账号使用 `revoke`,查看列表使用 `status`。 + +## 公钥登录被禁用 + +如果 sshd 关闭公钥登录、改变 `authorized_keys` 路径或使用 AllowUsers 白名单,工具会在创建前发现并拒绝。 + +推荐只为新账号创建独立例外: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --fix-sshd +``` + +该选项只写账号作用域的 sshd drop-in,不修改全局配置。文件会经过 `sshd -t` 和 `sshd -T -C user=...` 验证,然后只 reload、不 restart。任一步失败都会删除文件并中止;`revoke` 会删除例外并再次 reload。显式 `DenyUsers` 或 `DenyGroups` 永远不会被绕过。 + +无法使用公钥时也可明确选择密码: + +```bash +/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo --password-login +``` + +工具会先验证 sshd 接受密码,再生成只显示一次的随机密码。这是较弱的授权方式,密码在有效期内可以被网络暴力尝试,应优先使用公钥。 + +## 到期与自动删除 + +默认有效期为 24 小时并启用自动删除。优先使用持久化 systemd timer,systemd 不可用时使用已有的 `at`/`atd`;`at` 不会被自动安装。两个后端都无法创建任务时,整个邀请回滚。 + +`chage -E` 仅提供按天粒度的兜底锁定,可能晚于邀请显示时间;精确截止由撤销任务实现。撤销任务绑定创建时的 UID、随机世代标识和登记记录,账号被删除重建或身份不匹配时会拒绝误删。 + +## 卸载 + +```bash +# 交互式:先显示完整清单,再输入 YES +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall + +# 非交互式;存在受管账号时必须明确允许删除 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --remove-users + +# 删除受管账号并同时删除默认保留的审计日志 +/usr/bin/sudo /usr/local/sbin/linux-temp-admin uninstall --yes --remove-users --purge-audit +``` + +卸载先删除受管账号及其授权、例外和任务,再删除状态与程序。任何账号删不掉时都会中止,不会留下带 sudo 的账号却删除管理命令。从临时账号自己的会话运行卸载会被拒绝。 + +审计日志默认保留在 `/var/log/linux-temp-admin/audit.log`。生命周期锁和卸载标记也会保留,用于阻止已经排队的旧进程在卸载后重建状态;显式重新安装会处理卸载标记。 + +## 写入位置 + +```text +/usr/local/sbin/linux-temp-admin +/var/lib/linux-temp-admin/v2/registry.tsv +/var/lib/linux-temp-admin/v2/prefs +/var/log/linux-temp-admin/audit.log +/run/linux-temp-admin.lock +/run/linux-temp-admin.lock.uninstalled +/etc/systemd/system/linux-temp-admin-v2-revoke-USER.service +/etc/systemd/system/linux-temp-admin-v2-revoke-USER.timer +/etc/sudoers.d/linux-temp-admin-USER +/etc/ssh/sshd_config.d/10-linux-temp-admin-USER.conf +/home/USER/.ssh/authorized_keys +``` + +在 systemd 不可用时,还可能创建 `at` 队列任务。sshd 文件只在显式使用 `--fix-sshd` 时存在,sudoers 文件只在授予 sudo 时存在。 diff --git a/docs/releasing.md b/docs/releasing.md index 3b3bd58..09fa134 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -422,10 +422,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.8.3 "$RELEASE_COMMIT" -m 'linux-temp-admin v2.8.3' + tag -s v2.8.4 "$RELEASE_COMMIT" -m 'linux-temp-admin v2.8.4' git -c gpg.format=openpgp -c gpg.program=/usr/bin/gpg \ - verify-tag --raw v2.8.3 -git push origin v2.8.3 + verify-tag --raw v2.8.4 +git push origin v2.8.4 ``` Before pushing, the `VALIDSIG` record from `verify-tag --raw` must identify the @@ -489,7 +489,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.8.3 /srv/linux-temp-admin /srv/release-transfer/v2.8.3-prepared + v2.8.4 /srv/linux-temp-admin /srv/release-transfer/v2.8.4-prepared LTA_PREPARE_RELEASE ``` @@ -509,7 +509,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.8.3 +LTA_EXPECTED_TAG=v2.8.4 LTA_EXPECTED_COMMIT='' LTA_EXPECTED_PREPARED_MANIFEST_SHA256='' LTA_EXPECTED_RELEASE_SIGNER_PUBKEY='' @@ -521,7 +521,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.8.3-signed /srv/linux-temp-admin + /srv/release-transfer/v2.8.4-signed /srv/linux-temp-admin LTA_PUBLISH_RELEASE ``` @@ -637,7 +637,7 @@ noncanonical published stable tag, excludes the failed `TAG`, and verifies the exact resulting Latest state: ```bash -TAG=v2.8.3 # the failed release; verify this value before running +TAG=v2.8.4 # 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' @@ -749,7 +749,7 @@ announcement: ```bash gh workflow run mirror-release.yml --repo xxvcc/linux-temp-admin \ - --ref main -f tag=v2.8.3 + --ref main -f tag=v2.8.4 gh run list --repo xxvcc/linux-temp-admin \ --workflow mirror-release.yml --event workflow_dispatch --limit 1 ``` @@ -929,87 +929,38 @@ out-of-band trust recovery and should be treated as potentially compromised. ## Host install and upgrade -Bootstrap installation requires root, OpenSSL 3, sha256sum, timeout, and curl: +End-user requirements, routine upgrades, and failure classification are in the +[installation guide](installing.en.md). This release guide owns the single +canonical high-assurance bootstrap below because its exact shell behavior is a +release gate and is exercised by dynamic failure tests. + +The convenience install requires curl and a calling shell that supports +`pipefail`. The downloaded installer runs as root and requires OpenSSL 3, +sha256sum, timeout, and curl: ```bash -/usr/bin/sudo /usr/bin/env -i \ - HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ - /bin/sh <<'LTA_BOOTSTRAP' -set -eu -umask 077 -fail() { echo "error: $*" >&2; exit 1; } -ulimit -c 0 || fail "cannot disable core dumps" -[ -d /tmp ] && [ ! -L /tmp ] || fail "/tmp is not a real directory" -tmp_meta=$(stat -Lc '%u %a' -- /tmp) || fail "cannot inspect /tmp" -case "$tmp_meta" in - "0 1"[0-7][0-7][0-7]) ;; - *) fail "/tmp must be root-owned, sticky, and free of other special bits" ;; -esac - -if ! FSIZE_BLOCK_BYTES=$( - ulimit -f 1 || exit 1 - awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4; found=1 } - END { if (!found) exit 1 }' /proc/self/limits -); then - fail "cannot determine the shell file-size limit unit" -fi -case "$FSIZE_BLOCK_BYTES" in - 512 | 1024) ;; - *) fail "unsupported shell file-size limit unit" ;; -esac -INSTALLER_MAX_BYTES=1048576 -INSTALLER_BLOCKS=$(( (INSTALLER_MAX_BYTES + FSIZE_BLOCK_BYTES - 1) / FSIZE_BLOCK_BYTES )) -installer=$(mktemp /tmp/.lta-bootstrap.XXXXXXXXXX) \ - || fail "cannot create root-owned installer file" -cleanup() { rm -f -- "$installer"; } -trap cleanup 0 -trap 'exit 1' HUP INT TERM -installer_downloaded=0 -for installer_url in \ - https://dl.ll.cd/linux-temp-admin/install.sh \ - https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh -do - installer_download_rc=0 - ( - ulimit -f "$INSTALLER_BLOCKS" || exit 1 - exec timeout -k 5 70 curl -q --fail --silent --show-error --location --max-redirs 0 \ - --connect-timeout 10 --max-time 60 --max-filesize "$INSTALLER_MAX_BYTES" \ - --proto '=https' --proto-redir '=https' \ - --output "$installer" "$installer_url" - ) || installer_download_rc=$? - if [ "$installer_url" = https://dl.ll.cd/linux-temp-admin/install.sh ] && \ - [ "$installer_download_rc" -eq 47 ]; then - fail "official mirror installer redirected; refusing source-policy fallback" - fi - if [ "$installer_download_rc" -eq 0 ]; then - installer_size=$(wc -c < "$installer") || fail "cannot measure installer" - case "$installer_size" in - '' | *[!0-9]*) fail "invalid installer size" ;; - esac - if [ "$installer_size" -gt 0 ] && [ "$installer_size" -le "$INSTALLER_MAX_BYTES" ]; then - installer_downloaded=1 - break - fi - fi -done -[ "$installer_downloaded" -eq 1 ] || fail "installer download failed or exceeded its limit" -/bin/sh "$installer" -LTA_BOOTSTRAP +set -o pipefail +curl -fsSL https://dl.ll.cd/linux-temp-admin/install.sh | /usr/bin/sudo /bin/sh ``` -That convenience bootstrap tries the official mirror first and uses raw GitHub -only after an installer transport, empty-response, or size-limit failure. An -official-mirror redirect is a source-policy failure and aborts instead. It -trusts the TLS and mutable script route of the source ultimately used. The -mirrored installer is copied from the released signed tag, but is not itself an -offline-ed25519-signed GitHub Release asset. +That convenience command streams the official mirror response directly into a +root shell. `pipefail` propagates curl DNS, TLS, HTTP, and transfer failures to +the caller, but it does not authenticate the script, bound it before execution, +or prevent already received partial script bytes from beginning execution. The +first curl cannot use installer-implemented GitHub fallback because the +installer is not running yet. Once running, the installer prefers the official +mirror for the complete signed binary release set and uses GitHub only after a +transport failure. The convenience path trusts curl, the HTTPS/redirect path it +accepts, and the mirror's stable-script deployment. The mirrored installer is +copied from the released signed tag, but is not itself an offline-ed25519-signed +GitHub Release asset. For a high-assurance first install, obtain all three values below through the 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.8.3' +LTA_RELEASE_TAG='v2.8.4' /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" \ @@ -1077,10 +1028,10 @@ ulimit -c 0 || fail "cannot disable core dumps" LTA_BOOTSTRAP ``` -Both downloads start only after a sanitized root shell is running. That root -process validates the pinned values, creates the unpredictable `0600` file in -an explicit trusted `/tmp`, downloads directly into it, checks its bounded size, -and (for the high-assurance flow) verifies the independent hash before execution. +The high-assurance download starts only after a sanitized root shell is running. +That root process validates the pinned values, creates the unpredictable `0600` +file in an explicit trusted `/tmp`, downloads directly into it, checks its +bounded size, and verifies the independent hash before execution. No caller-owned inode is copied, opened, or executed by root. `/tmp` is accepted only when it is a real root-owned directory with exactly the sticky special bit; another unprivileged user then cannot replace or remove the root-owned file. @@ -1088,11 +1039,12 @@ Root itself and the kernel/filesystem remain trusted. `/bin/sh` reads the file, so a `noexec` mount does not prevent this procedure. The exact release selector then prevents a release host from replaying a different still-valid signed version. -Each bootstrap download disables `.curlrc`, limits both the initial and redirected -protocols to HTTPS, and runs curl only after setting a kernel `RLIMIT_FSIZE` -calculated from the active shell's measured 512- or 1024-byte unit. It completes -successfully before any downloaded text is interpreted as shell code. The -installer has no unsigned/checksum-only fallback. It drops imported shell +The high-assurance bootstrap download disables `.curlrc`, limits both the initial +and redirected protocols to HTTPS, and runs curl only after setting a kernel +`RLIMIT_FSIZE` calculated from the active shell's measured 512- or 1024-byte +unit. It completes successfully and passes the independent hash check before any +downloaded text is interpreted as shell code. The installer itself has no +unsigned/checksum-only fallback. It drops imported shell functions before fixing a trusted root `PATH`, disables core dumps, neutralizes caller-controlled OpenSSL configuration/provider paths, and gives each download explicit connect/total timeouts plus a kernel `RLIMIT_FSIZE`, so chunked @@ -1163,12 +1115,14 @@ metadata are already safe; otherwise it is atomically repaired. - Reproducible comparison binds CI bytes to the audited tag under the fixed Go toolchain. The audited source, signed-tag identity, trusted preparation copy, Go distribution, and preparation workstation remain trusted. -- The convenience bootstrap obtains its script and embedded trust anchors over - TLS from the official mirror. The mirror takes that installer from the signed - Git tag, but the script is not currently an offline-ed25519-signed Release - asset. High-assurance bootstrap therefore still uses an audited 40-hex commit - in the raw GitHub URL and verifies the script hash through an independent - channel before running it. +- The convenience bootstrap starts at the official mirror's HTTPS URL, trusts + the redirect path accepted by curl, and streams the response into a root shell. + `pipefail` propagates curl failure but neither authenticates the script nor + retracts partial bytes that already began executing. The mirror takes that + installer from the signed Git tag, but the script is not currently an + offline-ed25519-signed Release asset. High-assurance bootstrap therefore still + uses an audited 40-hex commit in the raw GitHub URL and verifies the script hash + through an independent channel before running it. - A fresh bootstrap has no previously installed version state, so a release-host compromise can replay an older release that still has a valid offline signature. Pin an audited installer commit and pass `LTA_RELEASE=vX.Y.Z` when diff --git a/docs/security-model.en.md b/docs/security-model.en.md new file mode 100644 index 0000000..b90edda --- /dev/null +++ b/docs/security-model.en.md @@ -0,0 +1,107 @@ +# Security Model + +[中文](security-model.md) | English + +`linux-temp-admin` creates SSH-accessible Linux accounts and can grant NOPASSWD sudo. This document describes its guarantees, explicit non-goals, and failure behavior. See [SECURITY.md](../SECURITY.md) to report a vulnerability. + +## Trust and threat scope + +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; +- the download trust chain explicitly chosen during installation; +- the operator's private channel used to deliver an invite. + +It focuses on preventing its own command injection, path traversal, symlink overwrite, TOCTOU, account mis-deletion, UID/PID reuse mistakes, incomplete rollback, stale name-scoped grants, and unverified upgrades. + +An attacker who already has root can modify the program, kernel, account database, audit log, or registry. The tool cannot defend against root on the same host. It also does not repair vulnerabilities in the operating system, OpenSSH, sudo, or package manager. + +## Invite credentials + +- SSH keys are the default. The private key is printed once after successful creation and is not stored on the server; +- private-key or password output is refused when stdout is not a terminal unless `--allow-non-tty-private-key-output` explicitly acknowledges the channel; +- an invite bundle is itself a secret and must travel only through trusted private chat; +- `--password-login` generates a random password shown once, but that password can be attacked over the network throughout the account lifetime and is the weaker grant; +- key-based accounts use a shadow value that cannot match a valid password without triggering Alpine/OpenSSH's whole-account locked interpretation. + +## What sudo means + +`--sudo` writes an account-specific NOPASSWD sudoers grant and is effectively full root access. A trusted collaborator with root can create cron jobs, systemd units, SUID files, new accounts, or other persistence. Revoke removes only the account, grants, and tasks created and registered by this tool; it cannot infer and remove unrelated objects that collaborator created as root. + +A "temporary account" limits the lifetime of the managed entry point. It is not a sandbox for root behavior. Never issue a sudo invite to an untrusted person. + +## SSH login verdict + +Before creation, the tool evaluates the effective configuration equivalent to `sshd -T -C user=`, including Include, Match, and distribution crypto policy. The invite claims a verified key login only when that can be proved; incomplete knowledge is reported as `UNVERIFIED`, and a definite blocker refuses creation. + +`--fix-sshd` writes only an account-scoped drop-in and restores later configuration scope with `Match all`. It validates with `sshd -t` and an effective-configuration check, then reloads rather than restarts sshd. Any failure removes the file and rolls back. Explicit `DenyUsers` and `DenyGroups` rules are never bypassed. + +## Account identity and deletion safety + +Every new invite binds: + +- the UID observed at creation; +- a random 128-bit generation token; +- an exact managed GECOS marker; +- the corresponding registry record. + +Automatic revoke and ordinary `revoke` require these identity values to agree. If the account is deleted and recreated, the UID is reused, the marker changes, or the registry is corrupt, unattended deletion is refused rather than guessing that the same name is the same object. + +Accounts migrated from the old fixed-marker registry are shown as `legacy-unverified` and are never automatically deleted by timers, bulk cleanup, or uninstall. They require manual inspection and a fully confirmed `revoke --force`. + +Even with `--force`, root, UID 0, low-UID system accounts, and real accounts without the tool's exact marker are not deleted as managed accounts. + +## Processes and PID reuse + +Before revocation, processes belonging to the target UID are inspected and Linux pidfds bind signals to those exact process instances, avoiding a signal to an unrelated process after PID reuse. Linux 5.3 plus usable `pidfd_open` and `pidfd_send_signal` are required for safe revocation. `doctor` probes them, and `invite` refuses creation when they are unavailable. + +## Transactions, locking, and rollback + +Managed-state commits for invite, revoke, cleanup, install, upgrade, and uninstall share a root lifecycle lock so account, grant, registry, task, and binary changes do not interleave. Human confirmation, dependency installation, download, and signature verification are kept outside the lock where possible; state is revalidated after acquiring it. + +Any invite failure attempts to roll back the task, sudoers file, sshd exception, registry row, and account. A rollback failure is reported explicitly with a nonzero status and is never presented as success. + +If revoke cannot completely remove a name-scoped grant, it retains and attempts to disable the account so username reuse cannot reactivate the leftover privilege. Treat every rollback or revoke error as an unresolved security incident. + +## Files and state + +- registry, preferences, and audit directories require root ownership and strict permissions; +- the registry validates schema, fields, UID, generation, and size and fails closed when corrupt or unreadable; +- installation, upgrades, and state writes use same-directory temporary files, metadata checks, atomic replacement, and required fsync operations; +- an SSH home must belong to the target UID, and recursive removal refuses root/UID 0 homes and live mount boundaries; +- 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. + +## Expiry revocation + +The exact deadline is enforced by a systemd timer or an existing `at` backend; `chage -E` is only a day-granularity lock fallback. Invite creation rolls back when neither scheduling backend is available. + +The revoke task rechecks UID, generation token, GECOS marker, and registry row. An identity mismatch, missing registry, or recreated account is skipped safely for operator inspection. Failed systemd revokes use bounded retries; one-shot backend failures require `doctor` and manual action. + +## Installation and upgrade trust boundary + +Default binary installation and upgrades use the embedded ed25519 keyring to verify canonical `SHA256SUMS`, a detached signature, architecture, and version. The official mirror is the preferred complete source. After a valid mirror index, only a transport failure discards the complete set and redownloads it from the same GitHub tag; a transport failure obtaining the index queries GitHub Latest. Manifest-semantic, checksum, signature, and candidate-version failures stop immediately. + +The README convenience command streams the official mirror's installer into a root shell. `pipefail` propagates curl failures, but it cannot authenticate the script before execution or retract partial bytes already delivered to the shell. Once running, the installer verifies the binary. When the first script itself must be authenticated, use the [commit-, independent-hash-, and exact-version-pinned procedure](installing.en.md#high-assurance-first-install). + +A compromised mirror can replace the stable installer or manifest or deny service, threatening new convenience installs. It cannot forge an ed25519 binary accepted by an already installed client. The current v1 release private key was historically stored on a networked maintainer host, so it is not claimed to have been offline since generation; future rotation must use an overlap release to migrate the embedded keyring. + +A valid signature alone does not provide absolute rollback protection for a first install: control over version routing can replay an older version that remains validly signed. Independently pin the exact version and audit record when rollback resistance is required. + +## Audit log + +Privileged operations append JSON lines to `/var/log/linux-temp-admin/audit.log`, recording time, caller, action, target, and result. The root-owned file and directory have per-record and total limits; at 64 MiB operations continue with a warning to archive or rotate the log. + +This is a local trace, not a remote immutable log resistant to root. Uninstall retains it by default and removes it only with explicit `--purge-audit`. + +## Conditions requiring operator action + +- invite or revoke returns nonzero; +- `doctor` reports an orphaned grant, identity mismatch, or missing revoke task; +- registry corruption, permission drift, or an unsafe installation path; +- checksum, signature, manifest, or candidate-version failure; +- possible leakage of a private key, password, download credential, or invite bundle. + +Do not ignore these conditions because another source worked or the account appears unable to log in. Preserve evidence, revoke related access, fix the cause, and rerun `doctor`. diff --git a/docs/security-model.md b/docs/security-model.md new file mode 100644 index 0000000..6bc0b41 --- /dev/null +++ b/docs/security-model.md @@ -0,0 +1,107 @@ +# 安全模型 + +中文 | [English](security-model.en.md) + +`linux-temp-admin` 创建可通过 SSH 登录的 Linux 账号,并可授予 NOPASSWD sudo。本文件说明它提供的安全保证、明确不解决的问题和失败时的处理方式。漏洞报告方式见 [SECURITY.md](../SECURITY.md)。 + +## 信任与威胁范围 + +本工具假定以下基础仍可信: + +- Linux 内核、本地 root、文件系统和系统账号数据库; +- OpenSSH、sudo、systemd 或 `at` 及系统账号管理命令; +- 安装时明确选择的下载信任链; +- 操作者用于交付邀请的私密通信渠道。 + +它重点防止自身造成的命令注入、路径穿越、符号链接覆盖、TOCTOU、误删账号、UID/PID 复用误判、不完整回滚、残留用户名授权和未验签升级。 + +已经取得 root 的攻击者可以修改程序、内核、账号数据库、审计日志或登记状态,本工具不能在同一主机上对抗 root。底层操作系统、OpenSSH、sudo、包管理器的漏洞也不由本工具修复。 + +## 邀请凭据 + +- 默认生成 SSH key;私钥只在成功创建后输出一次,服务器不保存; +- stdout 不是 TTY 时默认拒绝输出私钥或密码,必须显式使用 `--allow-non-tty-private-key-output`; +- 邀请包本身就是秘密,只能通过可信私聊交付; +- `--password-login` 会生成只显示一次的随机密码,但密码可在账号有效期内被网络暴力尝试,是较弱的授权方式; +- 公钥账号使用不会匹配有效密码、也不会触发 Alpine/OpenSSH 整账号锁定判断的 shadow 值禁用密码认证。 + +## sudo 的实际含义 + +`--sudo` 写入账号专属 NOPASSWD sudoers,基本等同完整 root 权限。可信协作者取得 root 后可以创建 cron、systemd unit、SUID 文件、新账号或其他持久化。本工具撤销时只删除自己创建和登记的账号、授权及任务,不会猜测或清理对方以 root 创建的外部对象。 + +因此“临时账号”限制的是本工具管理的入口寿命,不是 root 行为的沙箱。不要把 sudo 邀请发给不可信对象。 + +## SSH 登录判定 + +创建前会运行等价于 `sshd -T -C user=<新账号>` 的有效配置检查,展开 Include、Match 和发行版加密策略。只有能够证明公钥登录可用时,邀请才会把 `Login` 标记为已验证;无法完整判断时显示 `UNVERIFIED`,明确阻碍则拒绝创建。 + +`--fix-sshd` 只写账号作用域 drop-in,并以 `Match all` 恢复后续配置作用域。写入前后分别通过 `sshd -t` 和有效配置检查,只 reload、不 restart。任一步失败都会删除文件并回滚。显式 `DenyUsers` 和 `DenyGroups` 不会被绕过。 + +## 账号身份与防误删 + +每个新邀请都绑定: + +- 创建时的 UID; +- 随机 128 位世代标识; +- 精确 GECOS 管理标记; +- 登记表中的对应记录。 + +自动撤销和普通 `revoke` 要求这些身份信息一致。账号被删除重建、UID 被复用、标记改变或登记损坏时会拒绝自动删除,而不是猜测同名账号仍是原对象。 + +从旧登记格式迁移的固定标记账号显示为 `legacy-unverified`,不会被定时、批量清理或卸载自动删除。人工核对后才能使用带完整确认的 `revoke --force`。 + +即使使用 `--force`,root、UID 0、低 UID 系统账号和没有本工具精确标记的真实账号也不会作为受管账号删除。 + +## 进程与 PID 复用 + +撤销账号前会检查该 UID 的进程,并使用 Linux pidfd 将信号绑定到已经检查过的进程实例,避免 PID 复用后误杀无关进程。Linux 5.3、`pidfd_open` 和 `pidfd_send_signal` 是安全撤销所需能力;`doctor` 会实测,`invite` 在能力不可用时拒绝创建账号。 + +## 事务、锁与回滚 + +invite、revoke、cleanup、install、upgrade 和 uninstall 的受管状态提交共用 root 生命周期锁,账号、授权、登记、任务和二进制变更不会互相穿插。需要人工等待的确认、依赖安装、下载和验签尽量在锁外完成;取得锁后会重新核验即将提交的状态。 + +创建中任一步失败都会尝试回滚任务、sudoers、sshd 例外、登记和新账号。回滚失败会明确报告并返回非零,不会把部分成功显示为成功。 + +撤销时如果用户名授权无法完全移除,会保留并尝试禁用账号,避免残留授权在用户名复用后重新生效。操作者应把任何回滚或撤销错误视为未解决的安全事件。 + +## 文件与状态 + +- 登记表、偏好和审计目录要求 root 所有及严格权限; +- 登记表严格验证 schema、字段、UID、世代和大小,损坏或不可读时 fail closed; +- 安装、升级和状态写入使用同目录临时文件、元数据验证、原子替换和必要的 fsync; +- SSH 家目录必须属于目标 UID,递归删除拒绝 root/UID 0 家目录及活跃挂载边界; +- sudoers、sshd 例外和自动任务使用受限、可预测的项目命名,只删除经过身份验证的受管对象。 + +不要手工编辑 `/var/lib/linux-temp-admin/v2/registry.tsv`。读不到登记状态不会被当作“没有账号”。 + +## 到期撤销 + +精确截止由 systemd timer 或已有的 `at` 后端执行;`chage -E` 只是按天粒度的兜底锁定。两个调度后端都不可用时,邀请创建会回滚。 + +撤销任务再次验证 UID、世代标识、GECOS 和登记行。身份不匹配、登记丢失或账号已重建时会安全跳过,交由管理员检查。systemd 撤销失败会限速重试;一次性后端失败需要 `doctor` 和人工处理。 + +## 安装与升级信任边界 + +默认二进制安装和升级使用内置 ed25519 公钥 keyring,验证规范 `SHA256SUMS`、detached 签名、架构和版本。官方镜像是首选完整来源;取得有效镜像索引后,只有传输故障才丢弃整套文件并从同一 GitHub tag 重新下载。索引本身传输失败时查询 GitHub Latest;manifest 语义、校验和、签名或候选版本失败立即停止。 + +README 的便利入口把官方镜像返回的安装脚本直接交给 root shell。`pipefail` 能传播 curl 失败,但不能在执行前认证脚本,也不能撤回已经流入 shell 的部分字节。已经运行的安装器会验签二进制;首次脚本链路需要更高保证时,使用[安装指南中的固定 commit、独立哈希和精确版本流程](installing.md#高保证首次安装)。 + +镜像被攻陷可能替换稳定安装脚本、manifest 或拒绝服务,因此会威胁新的便利安装。它不能伪造已安装客户端接受的 ed25519 二进制。当前 v1 发行私钥曾在联网维护主机上保存,不能声称从生成起就完全离线;后续轮换必须通过重叠版本迁移内置 keyring。 + +有效签名本身不提供首次安装的绝对防回滚:能够控制版本路由的一方可能重放一个仍由受信 key 签名的旧版本。需要防回滚时必须独立固定精确版本和审计记录。 + +## 审计日志 + +特权操作以 JSON 行追加到 `/var/log/linux-temp-admin/audit.log`,记录时间、调用者、动作、目标和结果。文件和目录为 root 所有,单条记录和总大小都有上限;达到 64 MiB 后操作继续但会警告管理员归档或轮转。 + +审计日志用于本机追踪,不是防 root 篡改的远程不可变日志。卸载默认保留它,只有显式 `--purge-audit` 才删除。 + +## 操作员必须处理的情况 + +- 邀请或撤销返回非零; +- `doctor` 报告孤儿授权、身份不匹配或缺失撤销任务; +- registry 损坏、权限变化或安装路径不安全; +- checksum、signature、manifest 或 candidate version 失败; +- 私钥、密码、下载凭据或邀请包可能泄露。 + +这些情况不能因为“换另一个来源成功”或“账号看起来已经不能登录”而忽略。保留证据、撤销相关访问、修复根因并重新运行 `doctor`。 diff --git a/internal/selfmanage/release_pipeline_test.go b/internal/selfmanage/release_pipeline_test.go index c237349..e940044 100644 --- a/internal/selfmanage/release_pipeline_test.go +++ b/internal/selfmanage/release_pipeline_test.go @@ -22,6 +22,7 @@ import ( "syscall" "testing" "time" + "unicode" "github.com/xxvcc/linux-temp-admin/internal/validate" ) @@ -1781,15 +1782,276 @@ fi }) } -func TestDocumentedBootstrapsRunInsideSanitizedRootShell(t *testing.T) { +func TestDocumentedConvenienceBootstrapPolicy(t *testing.T) { + const installPipeline = "curl -fsSL https://dl.ll.cd/linux-temp-admin/install.sh | /usr/bin/sudo /bin/sh" + const readmeBootstrap = "set -o pipefail\n" + installPipeline + " &&\n" + + "/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo" + const releaseBootstrap = "set -o pipefail\n" + installPipeline + + documents := map[string]struct { + path string + bootstrap string + riskText string + }{ + "README.md": { + path: "../../README.md", + bootstrap: readmeBootstrap, + riskText: "不认证脚本本身,也不能阻止已经收到的部分脚本开始执行", + }, + "README.en.md": { + path: "../../README.en.md", + bootstrap: readmeBootstrap, + riskText: "does not authenticate the script or stop an already received partial script from beginning execution", + }, + "installing.md": { + path: "../../docs/installing.md", + bootstrap: releaseBootstrap, + riskText: "不认证脚本本身,也不能阻止已经收到的部分脚本开始执行", + }, + "installing.en.md": { + path: "../../docs/installing.en.md", + bootstrap: releaseBootstrap, + riskText: "does not authenticate the script or stop an already received partial script from beginning execution", + }, + "releasing.md": { + path: "../../docs/releasing.md", + bootstrap: releaseBootstrap, + riskText: "does not authenticate the script", + }, + } + for name, document := range documents { + content := readReleaseFile(t, document.path) + if got := strings.Count(content, document.bootstrap); got != 1 { + t.Errorf("%s convenience bootstrap count=%d, want 1", name, got) + } + if got := strings.Count(content, installPipeline); got != 1 { + t.Errorf("%s streaming install pipeline count=%d, want 1", name, got) + } + if got := strings.Count(content, "curl -fsSL"); got != 1 { + t.Errorf("%s curl -fsSL count=%d, want 1", name, got) + } + if !strings.Contains(content, document.riskText) { + t.Errorf("%s does not disclose the streaming bootstrap trust boundary", name) + } + for _, forbidden := range []string{ + "| sudo sh", + "https://raw.githubusercontent.com/xxvcc/linux-temp-admin/main/scripts/install.sh", + } { + if strings.Contains(content, forbidden) { + t.Errorf("%s contains an unapproved convenience bootstrap form %q", name, forbidden) + } + } + } + + t.Run("curl failure stops invite but cannot retract streamed bytes", func(t *testing.T) { + dir := t.TempDir() + binDir := filepath.Join(dir, "bin") + if err := os.Mkdir(binDir, 0o700); err != nil { + t.Fatal(err) + } + curlFixture := `#!/bin/sh +printf '%s\n' '#!/bin/sh' ': > "$TEST_INSTALLER_MARKER"' +exit 55 +` + if err := os.WriteFile(filepath.Join(binDir, "curl"), []byte(curlFixture), 0o700); err != nil { + t.Fatal(err) + } + installerMarker := filepath.Join(dir, "installer-ran") + inviteMarker := filepath.Join(dir, "invite-ran") + script := strings.Replace(readmeBootstrap, "/usr/bin/sudo /bin/sh", "/bin/sh", 1) + script = strings.Replace(script, + "/usr/bin/sudo /usr/local/sbin/linux-temp-admin invite --sudo", + `: > "$TEST_INVITE_MARKER"`, 1) + cmd := exec.Command("/bin/bash", "-c", script) + env := make([]string, 0, len(os.Environ())+3) + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "PATH=") || strings.HasPrefix(entry, "TEST_") { + continue + } + env = append(env, entry) + } + cmd.Env = append(env, + "PATH="+binDir+":"+os.Getenv("PATH"), + "TEST_INSTALLER_MARKER="+installerMarker, + "TEST_INVITE_MARKER="+inviteMarker, + ) + if out, err := cmd.CombinedOutput(); err == nil { + t.Fatalf("convenience bootstrap succeeded after curl failure: %s", out) + } + if _, err := os.Stat(installerMarker); err != nil { + t.Fatalf("partial streamed installer did not begin, test no longer exercises the documented risk: %v", err) + } + if _, err := os.Stat(inviteMarker); !os.IsNotExist(err) { + t.Fatalf("invite ran after curl failure: %v", err) + } + }) +} + +func TestReadmesRemainFocusedUserEntrypoints(t *testing.T) { + documents := []struct { + name string + path string + required []string + forbidden []string + }{ + { + name: "README.md", + path: "../../README.md", + required: []string{ + "[安装、升级与下载验证](docs/installing.md)", + "[管理员指南](docs/operator-guide.md)", + "[安全模型](docs/security-model.md)", + }, + forbidden: []string{ + "## 安装、升级与诊断", "### 写入的文件", "### 关于\"过期\"和\"自动删除\"", + "--allow-non-tty-private-key-output", "--url-file", "Match all", + }, + }, + { + name: "README.en.md", + path: "../../README.en.md", + required: []string{ + "[Installation, upgrades, and download verification](docs/installing.en.md)", + "[Operator guide](docs/operator-guide.en.md)", + "[Security model](docs/security-model.en.md)", + }, + forbidden: []string{ + "## Install, upgrade, and doctor", "### Files written", "### Expiry vs auto-delete", + "--allow-non-tty-private-key-output", "--url-file", "Match all", + }, + }, + } + for _, document := range documents { + t.Run(document.name, func(t *testing.T) { + content := readReleaseFile(t, document.path) + lines := strings.Count(content, "\n") + if lines > 200 { + t.Errorf("%s has %d lines; user entrypoint limit is 200", document.name, lines) + } + for _, required := range document.required { + if !strings.Contains(content, required) { + t.Errorf("%s is missing user-guide link %q", document.name, required) + } + } + for _, forbidden := range document.forbidden { + if strings.Contains(content, forbidden) { + t.Errorf("%s absorbed advanced documentation %q", document.name, forbidden) + } + } + }) + } + + for _, pair := range []struct { + zhPath string + enPath string + zhLink string + enLink string + }{ + {"../../docs/installing.md", "../../docs/installing.en.md", "[English](installing.en.md)", "[中文](installing.md)"}, + {"../../docs/operator-guide.md", "../../docs/operator-guide.en.md", "[English](operator-guide.en.md)", "[中文](operator-guide.md)"}, + {"../../docs/security-model.md", "../../docs/security-model.en.md", "[English](security-model.en.md)", "[中文](security-model.md)"}, + } { + zh := readReleaseFile(t, pair.zhPath) + en := readReleaseFile(t, pair.enPath) + if !strings.Contains(zh, pair.zhLink) || !strings.Contains(en, pair.enLink) { + t.Errorf("bilingual document pair %s / %s lacks reciprocal navigation", pair.zhPath, pair.enPath) + } + zhLines := strings.Count(zh, "\n") + enLines := strings.Count(en, "\n") + if difference := zhLines - enLines; difference < -5 || difference > 5 { + t.Errorf("bilingual document pair %s / %s drifted in structure: %d vs %d lines", + pair.zhPath, pair.enPath, zhLines, enLines) + } + } +} + +func TestUserDocumentationRelativeLinksResolve(t *testing.T) { + headingSlugs := func(content string) map[string]bool { + result := make(map[string]bool) + inFence := false + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "```") { + inFence = !inFence + continue + } + if inFence { + continue + } + if !strings.HasPrefix(line, "#") { + continue + } + heading := strings.TrimSpace(strings.TrimLeft(line, "#")) + var slug strings.Builder + for _, character := range strings.ToLower(heading) { + switch { + case unicode.IsLetter(character), unicode.IsNumber(character), character == '-', character == '_': + slug.WriteRune(character) + case unicode.IsSpace(character): + slug.WriteByte('-') + } + } + result[slug.String()] = true + } + return result + } + documents := []string{ + "../../README.md", + "../../README.en.md", + "../../docs/installing.md", + "../../docs/installing.en.md", + "../../docs/operator-guide.md", + "../../docs/operator-guide.en.md", + "../../docs/security-model.md", + "../../docs/security-model.en.md", + "../../SECURITY.md", + } + for _, document := range documents { + content := readReleaseFile(t, document) + for lineNumber, line := range strings.Split(content, "\n") { + remaining := line + for { + start := strings.Index(remaining, "](") + if start < 0 { + break + } + remaining = remaining[start+2:] + end := strings.IndexByte(remaining, ')') + if end < 0 { + t.Fatalf("%s:%d has an unterminated Markdown link", document, lineNumber+1) + } + target := remaining[:end] + remaining = remaining[end+1:] + if target == "" || strings.HasPrefix(target, "#") || + strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "http://") || + strings.HasPrefix(target, "mailto:") { + continue + } + parts := strings.SplitN(target, "#", 2) + path := parts[0] + if path == "" { + continue + } + resolved := filepath.Clean(filepath.Join(filepath.Dir(document), path)) + targetContent, err := os.ReadFile(resolved) + if err != nil { + t.Errorf("%s:%d link target %q does not resolve: %v", document, lineNumber+1, target, err) + continue + } + if len(parts) == 2 && parts[1] != "" && !headingSlugs(string(targetContent))[parts[1]] { + t.Errorf("%s:%d link target %q has no matching heading", document, lineNumber+1, target) + } + } + } + } +} + +func TestDocumentedHighAssuranceBootstrapRunsInsideSanitizedRootShell(t *testing.T) { documents := map[string]struct { path string wantCurlCount int wantSudoDirect int }{ - "README.md": {path: "../../README.md", wantCurlCount: 1, wantSudoDirect: 0}, - "README.en.md": {path: "../../README.en.md", wantCurlCount: 1, wantSudoDirect: 0}, - "releasing.md": {path: "../../docs/releasing.md", wantCurlCount: 2, wantSudoDirect: 0}, + "releasing.md": {path: "../../docs/releasing.md", wantCurlCount: 1, wantSudoDirect: 0}, } for name, document := range documents { content := readReleaseFile(t, document.path) @@ -1803,7 +2065,7 @@ func TestDocumentedBootstrapsRunInsideSanitizedRootShell(t *testing.T) { streamingRootShell = true } } - if streamingRootShell || strings.Contains(content, "curl -fsSL") { + if streamingRootShell { t.Errorf("%s still documents a streaming root-shell bootstrap", name) } for _, required := range []string{ @@ -1889,8 +2151,7 @@ func TestDocumentedBootstrapsRunInsideSanitizedRootShell(t *testing.T) { continue } block := section[:end] - if strings.Contains(block, "raw.githubusercontent.com/xxvcc/linux-temp-admin/") || - strings.Contains(block, "https://dl.ll.cd/linux-temp-admin/install.sh") { + if strings.Contains(block, "raw.githubusercontent.com/xxvcc/linux-temp-admin/") { blocks = append(blocks, block) } } @@ -1915,8 +2176,8 @@ func TestDocumentedBootstrapsRunInsideSanitizedRootShell(t *testing.T) { return block[start : start+endRel], nil } highAssuranceBlocks := bootstrapBlocks(releasing) - if len(highAssuranceBlocks) != 2 { - t.Fatalf("releasing.md bootstrap block count=%d, want 2", len(highAssuranceBlocks)) + if len(highAssuranceBlocks) != 1 { + t.Fatalf("releasing.md high-assurance bootstrap block count=%d, want 1", len(highAssuranceBlocks)) } for name, document := range documents { blocks := bootstrapBlocks(readReleaseFile(t, document.path)) @@ -2031,7 +2292,7 @@ exec "$@" {name: "bash-posix", args: []string{"--posix"}}, } { t.Run("high-assurance malformed stat/"+shell.name, func(t *testing.T) { - body, err := heredocBody(highAssuranceBlocks[1]) + body, err := heredocBody(highAssuranceBlocks[0]) if err != nil { t.Fatal(err) } diff --git a/internal/sudoers/sudoers.go b/internal/sudoers/sudoers.go index 0312386..85cffe2 100644 --- a/internal/sudoers/sudoers.go +++ b/internal/sudoers/sudoers.go @@ -157,6 +157,9 @@ func (m *Manager) All() ([]string, error) { } var users []string for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), filePrefix) { + continue + } user := strings.TrimPrefix(entry.Name(), filePrefix) if user != "" && validate.Username(user) { users = append(users, user) diff --git a/internal/sudoers/sudoers_test.go b/internal/sudoers/sudoers_test.go index 1df0caa..d945fa1 100644 --- a/internal/sudoers/sudoers_test.go +++ b/internal/sudoers/sudoers_test.go @@ -177,6 +177,7 @@ func TestOrphansFindsGrantsWhoseAccountIsGone(t *testing.T) { filePrefix + "xxvcc-gone", // ours, account deleted -> orphan filePrefix + "xxvcc-alive", // ours, account exists -> not an orphan "90-someone-elses-file", // not ours: never report or remove it + "foreign-valid-user", // valid username, but still not our namespace filePrefix + "BAD NAME", // ours-looking but not a valid username -> ignore } { if err := os.WriteFile(filepath.Join(dir, name), []byte("x ALL=(ALL) NOPASSWD:ALL\n"), 0o440); err != nil {