Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

All notable changes to this project are documented here.

## Unreleased

- Read each account once per status display, sharing that observation between
table and narrow-terminal layouts while refreshing it on the next display.
- Return explicit upgrade outcomes and exit the menu after a visible executable
replacement even if its directory sync fails; retain the failure exit status.
- Combine the default terminal invite's automatic-removal and lifetime questions
into one prompt. Enter keeps 24 hours; `never` explicitly selects permanence.
Preserve existing CLI flags and piped-input behavior; EOF cancels the prompt.
- Separate invite argument parsing and preconditions from planning and execution;
share timer file validation and the final controlled account-deletion steps
without changing backend compatibility, identity policy, or recovery rules.
- Summarize successful dependency checks and show each shared doctor cleanup
suggestion once, retaining individual failure diagnostics and exit statuses.

## v2.10.7 - 2026-09-26

- Close the remaining audit findings: clean up helper process groups before
Expand Down
2 changes: 2 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ The quick start already creates the first invite. Later invites can be created w

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.

The terminal asks for the lifetime once: press Enter for the default 24 hours or enter another number of hours. A permanent account requires an explicit `never`. The complete summary is shown for confirmation before creation.

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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ curl -fsSL https://dl.ll.cd/linux-temp-admin/install.sh | /usr/bin/sudo /bin/sh

交互流程会显示账号、Host、端口、有效期、sudo 状态和登录验证结果,并输出一次性的私钥保存命令。服务器只保存公钥,私钥不会落盘。

终端中的有效期只需选择一次:回车使用默认 24 小时,或输入其他小时数;永久账号需明确输入 `never`。创建前会显示完整摘要并要求确认。

把完整邀请包通过可信私聊发给协作者。对方保存私钥后,使用邀请头部的 Host、Port 和 User 登录,例如:

```bash
Expand Down
4 changes: 3 additions & 1 deletion docs/operator-guide.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ 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;
4. uses one terminal prompt for the lifetime: Enter keeps 24 hours, and an explicit `never` selects a permanent account;
5. shows the complete summary for confirmation;
6. creates the account and grants, creates a task when automatic revocation is enabled, and only then prints the invite credential.

Explicit lifetime and automatic-removal flags keep their existing rules: `--auto-revoke` cannot be changed to permanent at the prompt, and `--no-auto-revoke` skips the lifetime question. Piped input retains the existing y/n automatic-removal choice; `--yes` never prompts. Invalid terminal input is retried, and end-of-input cancels. The final confirmation clearly identifies permanent accounts.

Before creating anything, the tool checks whether the planned credential is compatible with the effective sshd configuration. An unresolved blocker reported by the check refuses creation, and incomplete knowledge is reported as `UNVERIFIED`. "Verified against the effective sshd config" means only that this configuration check completed without a known blocker or unevaluated rule; it is not end-to-end proof of the network, firewall, PAM, SELinux, or running sshd state. Test the invite through the intended connection path before delivery.

### Host detection
Expand Down
4 changes: 3 additions & 1 deletion docs/operator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@
1. 选择用户名,默认使用随机后缀;
2. 探测或询问邀请中的 Host 和 SSH 端口;
3. 默认授予 sudo,也可以选择普通账号;
4. 询问是否自动删除,启用时再询问有效期;
4. 在终端中用一次选择设置有效期,回车默认 24 小时,明确输入 `never` 可选永久;
5. 显示完整摘要并确认;
6. 创建账号和授权;启用自动撤销时创建任务,最后才输出邀请凭据。

已显式指定的有效期和自动删除开关仍按原规则处理;`--auto-revoke` 不接受交互改成永久,`--no-auto-revoke` 跳过有效期询问。非终端输入仍保留原来的 y/n 自动删除选择,`--yes` 模式不会询问。输入错误会重新询问,输入结束会取消;永久选项会在最终确认摘要中明确显示。

创建任何内容前,工具会用 sshd 的有效配置检查计划凭据是否兼容。未解决的配置检查阻碍会拒绝创建,无法完整判断时会在邀请中标记 `UNVERIFIED`。显示“已对照 sshd 有效配置验证”只代表这项配置检查完整通过,不是对网络、防火墙、PAM、SELinux 或运行中 sshd 状态的端到端登录证明;交付前仍应沿实际连接路径测试邀请。

### Host 探测
Expand Down
25 changes: 1 addition & 24 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2531,23 +2531,6 @@ func TestPlanLoginNoPasswordFallbackWhenPasswordsOff(t *testing.T) {
}
}

// TestPromptHours covers the new interactive lifetime prompt: a value is taken,
// a blank keeps the default, and an out-of-range entry is re-asked.
func TestPromptHours(t *testing.T) {
if got := mustHours(t, "48\n", 24); got != 48 {
t.Errorf("hours = %d, want 48", got)
}
if got := mustHours(t, "\n", 24); got != 24 {
t.Errorf("blank hours = %d, want the default 24", got)
}
if got := mustHours(t, "0\n99999999\n72\n", 24); got != 72 {
t.Errorf("hours after invalid entries = %d, want 72", got)
}
if got := mustHours(t, "", 24); got != 24 { // EOF settles on the default, never loops
t.Errorf("EOF hours = %d, want 24", got)
}
}

func TestPromptYesNoRejectsTypos(t *testing.T) {
a, _, errb := newTestApp(t, "never\nmaybe\nn\n")
a.StdinIsTTY = func() bool { return true }
Expand Down Expand Up @@ -2636,12 +2619,6 @@ func TestClassifyRegisteredAccountIdentityStates(t *testing.T) {
}
}

func mustHours(t *testing.T, in string, def int) int {
t.Helper()
a, _, _ := newTestApp(t, in)
return a.promptHours(def)
}

// TestPlanDepsRefusesBeforeSummaryAndInstallsAfter is a lightweight check that the
// dependency split reports missing deps read-only. With no package manager the
// plan must refuse (returns false), never claiming an install it cannot do.
Expand Down Expand Up @@ -2726,7 +2703,7 @@ func TestGeneratedInviteReachesDependencyGateWithoutID(t *testing.T) {
}

// TestInviteSkipsHoursPromptOnNonTTYStdin is the regression guard for the
// promptHours infinite-loop. promptHours re-asks on invalid input, so on a
// lifetime prompt infinite-loop. The lifetime prompt re-asks on invalid input, so on a
// non-TTY stdin feeding non-numeric lines (the `yes n | lta invite` idiom, whose
// stream never blanks) it would spin forever. The hours prompt is therefore gated
// on StdinIsTTY. This asserts the gate directly — the lifetime question must never
Expand Down
36 changes: 23 additions & 13 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func (a *App) status(args []string) int {
// mechanically derived from the username, and would double the table's width to
// tell the reader something they already know; `status --user <name>` still
// prints it for the one account being examined.
func (a *App) usersTable(recs []registry.Record, numbered bool) *table.Table {
func (a *App) usersTable(rows [][]string, numbered bool) *table.Table {
headers := []string{
a.P.M("用户", "USER"),
a.P.M("状态", "STATE"),
Expand All @@ -157,8 +157,7 @@ func (a *App) usersTable(recs []registry.Record, numbered bool) *table.Table {
headers = append([]string{"#"}, headers...)
}
t := table.New(headers...)
for i, r := range recs {
cells := a.userCells(r)
for i, cells := range rows {
if numbered {
cells = append([]string{strconv.Itoa(i + 1)}, cells...)
}
Expand Down Expand Up @@ -211,7 +210,13 @@ func (a *App) userCells(r registry.Record) []string {
// usersView keeps the comparison table on ordinary terminals and switches to a
// vertical record view when the table would be wider than the actual terminal.
func (a *App) usersView(recs []registry.Record, numbered bool) string {
full := a.usersTable(recs, numbered).String()
// Use one observation per account for this render. A later refresh or a
// mutating command must obtain its own current account state.
rows := make([][]string, len(recs))
for i, rec := range recs {
rows[i] = a.userCells(rec)
}
full := a.usersTable(rows, numbered).String()
width := 0
if a.TerminalWidth != nil {
width = a.TerminalWidth()
Expand All @@ -229,8 +234,7 @@ func (a *App) usersView(recs []registry.Record, numbered bool) string {
a.P.M("端口", "port"),
}
var out strings.Builder
for i, rec := range recs {
cells := a.userCells(rec)
for i, cells := range rows {
prefix := "- "
if numbered {
prefix = fmt.Sprintf("%d) ", i+1)
Expand Down Expand Up @@ -929,16 +933,20 @@ func (a *App) doctorBaseEnvironment() doctorResult {
} else {
a.success(a.P.M("pidfd 进程撤销能力可用。", "pidfd process revocation is available."))
}
var presentDeps []string
for _, d := range sysinfo.RequiredDeps(true, true) {
if d.Present {
a.success(a.P.M("依赖存在:", "dependency found: ") + d.Label)
presentDeps = append(presentDeps, d.Label)
} else {
a.warnf("%s%s", a.P.M("缺少依赖:", "missing dependency: "), d.Label)
if doctorDependencyIsFatal(d.Label) {
result.fail()
}
}
}
if len(presentDeps) > 0 {
a.success(a.P.M("依赖检查通过:", "dependencies found: ") + strings.Join(presentDeps, ", "))
}
a.info(a.P.M("包管理器:", "package manager: ") + orNone(sysinfo.PackageManager()))
a.info(a.P.M("init 系统:", "init system: ") + sysinfo.InitSystem())
sshPort, sshPortErr := a.detectSSHPort()
Expand Down Expand Up @@ -1154,6 +1162,7 @@ func (a *App) doctorSudoersDirectory() string {

func (a *App) doctorOrphanedArtifacts() doctorResult {
var result doctorResult
needsCompact := false
// An sshd exception that outlived its account is a standing loosening of the
// host's policy, and it re-arms the moment the username is reused.
if a.SSHD != nil {
Expand All @@ -1165,8 +1174,7 @@ func (a *App) doctorOrphanedArtifacts() doctorResult {
a.warnf("%s%s", a.P.M("孤儿 sshd 例外(账号不存在或身份无法验证):",
"orphaned sshd exception (the account is absent or its identity is unverified): "), a.SSHD.FilePath(u))
}
a.warnf("%s", a.P.M("请用 `linux-temp-admin cleanup-expired --compact` 清理。",
"remove them with `linux-temp-admin cleanup-expired --compact`."))
needsCompact = true
result.fail()
}
}
Expand All @@ -1188,8 +1196,7 @@ func (a *App) doctorOrphanedArtifacts() doctorResult {
a.warnf("%s%s", a.P.M("孤儿 sudo 授权(账号不存在或身份无法验证,NOPASSWD:ALL 仍在):",
"orphaned sudo grant (the account is absent or its identity is unverified; NOPASSWD:ALL is still on disk): "), a.Sudoers.FilePath(u))
}
a.warnf("%s", a.P.M("请用 `linux-temp-admin cleanup-expired --compact` 清理。",
"remove them with `linux-temp-admin cleanup-expired --compact`."))
needsCompact = true
result.fail()
}
}
Expand All @@ -1204,11 +1211,14 @@ func (a *App) doctorOrphanedArtifacts() doctorResult {
a.warnf("%s%s", a.P.M("孤儿自动删除任务(账号不存在或身份无法验证):",
"orphaned auto-delete task (the account is absent or its identity is unverified): "), u)
}
a.warnf("%s", a.P.M("请用 `linux-temp-admin cleanup-expired --compact` 清理。",
"remove them with `linux-temp-admin cleanup-expired --compact`."))
needsCompact = true
result.fail()
}
}
if needsCompact {
a.warnf("%s", a.P.M("请用 `linux-temp-admin cleanup-expired --compact` 清理。",
"remove them with `linux-temp-admin cleanup-expired --compact`."))
}
return result
}

Expand Down
Loading
Loading