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
8 changes: 7 additions & 1 deletion AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ The core value proposition:
password keys so one tool covers a whole fleet.
6. **Execution preview** — `--dry-run` explains the local execution plan without
connecting, executing, reading keyring secrets, or mutating state.
7. **Auditability** — non-dry-run invocations write structured JSONL audit
events under `~/.sshx/audit` by default, with secrets and stdout/stderr
excluded.

## 3. Scope & Boundaries (Non-Goals)

Expand Down Expand Up @@ -77,6 +80,7 @@ internal/app/ → CLI surface (argument parsing, routing, sub-comman
password.go → keyring-backed password get/set/list + secure input
usage.go → PrintUsage() help text (keep in sync with flags)
dryrun.go → --dry-run local execution plan preview
audit.go → local structured JSONL audit events + redaction
internal/sshclient/ → SSH/SFTP core
client.go → SSHClient: dial, auth, exec, SFTP, sudo-over-stdin
validate.go → command safety checks + CommandUsesSudo
Expand All @@ -103,6 +107,9 @@ pkg/logger/ → leveled logger (SSHX_LOG_LEVEL)
- **Secrets:** OS keyring under service name `sshx`
(macOS Keychain / Linux Secret Service / Windows Credential Manager).
- **Trust store:** `~/.ssh/known_hosts` (or `--known-hosts` / `SSH_KNOWN_HOSTS`).
- **Audit trail:** `~/.sshx/audit/sshx-YYYY-MM-DD.jsonl` by default; override
with `--audit-output=<dir>` / `SSHX_AUDIT_OUTPUT`, or disable with
`--no-audit` / `SSHX_NO_AUDIT=true`.

## 5. Tech Stack

Expand Down Expand Up @@ -235,7 +242,6 @@ A living, maintainer-adjustable plan. Items must respect the boundaries in §3.

**Long-term / under consideration**

- ⬜ Optional structured audit log of executed commands.
- ⬜ Pluggable secret backends behind the existing keyring abstraction.

Anything implying a daemon, MCP, tunneling, or a GUI is explicitly **rejected**
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Local structured audit events are written by default to
`~/.sshx/audit/sshx-YYYY-MM-DD.jsonl`. Use `--audit-output=<dir>` to choose a
directory or `--no-audit` / `SSHX_NO_AUDIT=true` to disable audit writing.
Audit events omit stdout/stderr and never store plaintext passwords or private
key contents.
- `--dry-run` prints a local execution plan without connecting, executing,
reading keyring secrets, mutating `known_hosts`, or writing local/remote
state. Combine with `--json` for agent-readable plan output.
Expand Down
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ Managing multiple servers means juggling different passwords and repeatedly ente
2. Password management (Keychain / Secret Service / Credential Manager).
3. Host configuration management with per-host SSH keys.
4. Dry-run execution plan preview for humans and agents.
5. Script execution and command security validation.
5. Local structured audit trail with safe default redaction.
6. Script execution and command security validation.

## Installation

Expand Down Expand Up @@ -266,6 +267,28 @@ sudo-key selection, safety-check result, and whether a real run would connect,
execute, read a secret, or mutate state. It does **not** prove the remote command
would succeed.

### Local audit trail

Every non-dry-run invocation writes one JSONL audit event by default:

```text
~/.sshx/audit/sshx-YYYY-MM-DD.jsonl
```

Use `--audit-output=<dir>` to place audit events next to a project, runbook, or
incident record:

```bash
sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx"
```

Audit events record metadata and outcomes such as mode/action, host resolution,
sudo/keyring decisions, safety status, auth method, exit code, and error kind.
They do **not** record plaintext passwords, private key contents, or
stdout/stderr. Command text is included for provenance but redacted for common
password/token-style arguments. Use `--no-audit` or `SSHX_NO_AUDIT=true` to
disable audit writing for a single invocation or environment.

### `--timeout` and `--pty`

```bash
Expand Down Expand Up @@ -549,6 +572,16 @@ export SUDO_PASSWORD=your_sudo_password
./bin/sshx "uptime"
```

### Audit Environment Variables

```bash
# Write audit events to a project-specific directory
export SSHX_AUDIT_OUTPUT=./.sshx-audit

# Disable audit writing
export SSHX_NO_AUDIT=true
```

### SSH Authentication Preferences

- `sshx` now prioritizes SSH keys and automatically falls back to password authentication when the server rejects your key (for example when a host only allows passwords). As long as a password is available, the client will transparently retry with a password-only session.
Expand Down
31 changes: 30 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\
2. 密码管理(Keychain / Secret Service / Credential Manager)。
3. 主机配置管理,支持为每台主机配置独立的 SSH 密钥。
4. 面向人和 agent 的 dry-run 执行计划预览。
5. 脚本执行和命令安全验证。
5. 本地结构化审计日志,并默认做安全脱敏。
6. 脚本执行和命令安全验证。

## 安装

Expand Down Expand Up @@ -260,6 +261,24 @@ sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx"
dry-run 只是本地计划预览。它会说明主机解析、模式/动作、sudo key 选择、安全检查结果,
以及真实执行时是否会连接、执行、读取 secret 或修改状态;它不承诺远程命令一定成功。

### 本地审计日志

每次非 dry-run 调用都会默认写入一条 JSONL 审计事件:

```text
~/.sshx/audit/sshx-YYYY-MM-DD.jsonl
```

可以使用 `--audit-output=<目录>` 把审计事件保存到项目目录、运维记录或事故目录旁边:

```bash
sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx"
```

审计事件记录模式/动作、主机解析、sudo/keyring 决策、安全检查状态、认证方式、退出码和错误类型等元数据。
它不会记录明文密码、私钥内容或 stdout/stderr。命令文本会作为溯源信息写入,但会对常见 password/token
类参数做尽力脱敏。可以用 `--no-audit` 或 `SSHX_NO_AUDIT=true` 禁用单次调用或当前环境的审计写入。

### `--timeout` 与 `--pty`

```bash
Expand Down Expand Up @@ -467,6 +486,16 @@ export SUDO_PASSWORD=your_sudo_password
./bin/sshx "uptime"
```

### 审计环境变量

```bash
# 将审计事件写到项目目录
export SSHX_AUDIT_OUTPUT=./.sshx-audit

# 禁用审计写入
export SSHX_NO_AUDIT=true
```

### SSH 认证偏好设置

- `sshx` 仍然会优先尝试 SSH 密钥认证,但如果服务器拒绝公钥(例如只允许密码登录),并且已经提供了密码,客户端会自动回退到“仅密码”重连,无需手动重试。
Expand Down
1 change: 1 addition & 0 deletions internal/app/agentmode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ func TestClassifyError(t *testing.T) {
// before any network work, so it reports error_kind "blocked" (not "connect")
// even though the host is never reachable.
func TestRun_BlockedCommandShortCircuits(t *testing.T) {
t.Setenv("HOME", t.TempDir())
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
Expand Down
27 changes: 19 additions & 8 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ func Run(args []string) (err error) {

// Parse command-line arguments
config := ParseArgs(args)
audit := newAuditRecorder(config)
defer func() {
if auditErr := audit.finish(config, err); auditErr != nil {
logger.GetLogger().Warning("failed to write audit event: %v", auditErr)
}
}()

if config.DryRun {
return emitDryRunPlan(config)
Expand All @@ -95,11 +101,11 @@ func Run(args []string) (err error) {
// Validate flags that only apply to command execution.
if config.Mode == "ssh" {
if config.Timeout < 0 {
return reportSSHFailure(config, sshclient.AuthMethodUnknown, "config",
return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config",
fmt.Errorf("invalid --timeout value (use e.g. 30s, 2m, or 30)"))
}
if config.JSONOutput && config.UsePTY {
return reportSSHFailure(config, sshclient.AuthMethodUnknown, "config",
return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config",
fmt.Errorf("--pty cannot be combined with --json (a PTY merges stderr into stdout)"))
}

Expand All @@ -108,7 +114,7 @@ func Run(args []string) (err error) {
// error_kind ("blocked") instead of being masked by a connect error.
if config.SafetyCheck && !config.Force {
if blockErr := sshclient.ValidateCommand(config.Command); blockErr != nil {
return reportSSHFailure(config, sshclient.AuthMethodUnknown, classifyError(blockErr), blockErr)
return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, classifyError(blockErr), blockErr)
}
}
}
Expand All @@ -135,16 +141,19 @@ func Run(args []string) (err error) {
// Create SSH client
client, err := sshclient.NewSSHClient(config)
if err != nil {
return reportSSHFailure(config, sshclient.AuthMethodUnknown, "config",
return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config",
fmt.Errorf("failed to create SSH client: %w", err))
}
defer errutil.HandleCloseError(&err, client)

// Connect to remote host (use direct connection for CLI mode, no need for pooling)
if err = client.ConnectDirect(); err != nil {
return reportSSHFailure(config, sshclient.AuthMethodUnknown, classifyError(err),
return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, classifyError(err),
fmt.Errorf("failed to connect: %w", err))
}
if audit != nil {
audit.event.AuthMethod = string(client.AuthMethodUsed())
}

// Handle SFTP mode
if config.Mode == "sftp" {
Expand All @@ -155,16 +164,17 @@ func Run(args []string) (err error) {
}

// Handle SSH command execution
return runCommand(client, config)
return runCommand(client, config, audit)
}

// runCommand runs the configured command and translates the result into either
// streamed human output or a single JSON object, then returns an error whose
// type tells the entry point which exit code to use.
func runCommand(client *sshclient.SSHClient, config *sshclient.Config) error {
func runCommand(client *sshclient.SSHClient, config *sshclient.Config, audit *auditRecorder) error {
start := time.Now()
res, execErr := client.RunCommand(config.JSONOutput)
dur := time.Since(start)
audit.recordCommandResult(config, client.AuthMethodUsed(), res, dur, classifyError(execErr), execErr)

if config.JSONOutput {
emitCommandJSON(config, client.AuthMethodUsed(), res, dur, classifyError(execErr), execErr)
Expand All @@ -189,7 +199,8 @@ func runCommand(client *sshclient.SSHClient, config *sshclient.Config) error {
// reportSSHFailure emits a JSON error object in --json command mode (and returns
// ErrReported so the caller exits silently), or returns the error unchanged for
// the normal streamed path.
func reportSSHFailure(config *sshclient.Config, authMethod sshclient.AuthMethod, kind string, err error) error {
func reportSSHFailure(config *sshclient.Config, audit *auditRecorder, authMethod sshclient.AuthMethod, kind string, err error) error {
audit.recordFailure(config, authMethod, kind, err)
if config.JSONOutput && config.Mode == "ssh" {
emitCommandJSON(config, authMethod, sshclient.ExecResult{ExitCode: -1}, 0, kind, err)
return ErrReported
Expand Down
1 change: 1 addition & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ func TestRun_ArgumentParsing(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
// Suppress output
oldStdout := os.Stdout
oldStderr := os.Stderr
Expand Down
Loading
Loading