From 106203a357ee4252f00787069b816002a258f756 Mon Sep 17 00:00:00 2001 From: jettwang Date: Thu, 13 Aug 2026 17:10:30 +0800 Subject: [PATCH] feat(run): add agent execution contract and bounded multi-host fan-out Implement sshx run as the canonical agent contract: versioned request/result events, strict host selectors, byte-preserving script payloads, typed credential roles, explicit trust bypasses, and concurrency-bounded fan-out with JSONL completion semantics. Closes #39. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AGENT.md | 3 + CHANGELOG.md | 29 ++ README.md | 31 +- README_CN.md | 29 +- docs/agent-scripting.md | 20 + docs/roadmap.md | 4 +- docs/zh/agent-scripting.md | 18 + go.mod | 1 - go.sum | 2 - internal/app/agentmode_test.go | 39 +- internal/app/app.go | 78 ++-- internal/app/audit.go | 14 + internal/app/config.go | 192 ++++++++- internal/app/config_test.go | 17 +- internal/app/dryrun.go | 15 +- internal/app/host_manager.go | 104 ++++- internal/app/run.go | 424 +++++++++++++++++++ internal/app/settings.go | 76 +++- internal/app/usage.go | 42 +- internal/execution/errors.go | 187 +++++++++ internal/execution/executor.go | 631 ++++++++++++++++++++++++++++ internal/execution/executor_test.go | 196 +++++++++ internal/execution/payload.go | 73 ++++ internal/execution/payload_test.go | 50 +++ internal/execution/selector.go | 270 ++++++++++++ internal/execution/selector_test.go | 97 +++++ internal/execution/types.go | 313 ++++++++++++++ internal/execution/validate.go | 212 ++++++++++ internal/sshclient/client.go | 20 + skills/sshx/SKILL.md | 42 +- tests/e2e/run_e2e_test.go | 340 +++++++++++++++ 31 files changed, 3426 insertions(+), 143 deletions(-) create mode 100644 internal/app/run.go create mode 100644 internal/execution/errors.go create mode 100644 internal/execution/executor.go create mode 100644 internal/execution/executor_test.go create mode 100644 internal/execution/payload.go create mode 100644 internal/execution/payload_test.go create mode 100644 internal/execution/selector.go create mode 100644 internal/execution/selector_test.go create mode 100644 internal/execution/types.go create mode 100644 internal/execution/validate.go create mode 100644 tests/e2e/run_e2e_test.go diff --git a/AGENT.md b/AGENT.md index 45a1368..596c197 100644 --- a/AGENT.md +++ b/AGENT.md @@ -104,9 +104,11 @@ internal/app/ → CLI surface (argument parsing, routing, sub-comman 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 + run.go → sshx run: selectors, scripts, fan-out, versioned results skill.go → install the canonical Agent skill embedded in sshx plugin.go → local plugin create/list/show/validate/test/trust/remove inspect.go → one-shot capability execution + observation caching +internal/execution/ → versioned request/result model, selectors, executor internal/plugin/ → manifests, schemas, scaffolds, trust, built-ins internal/runtimepath/ → ~/.sshx / SSHX_HOME runtime-root resolution internal/skillinstall/ → conflict-safe, atomic Agent skill installation @@ -126,6 +128,7 @@ skills/ → canonical Agent skill plus its embedded asset packa | Mode | Trigger | Responsibility | |------------|-------------------------------------------|-----------------------------------------| | `ssh` | default; a command argument is present | run a remote command (sudo auto-fill) | +| `run` | `sshx run ...` | canonical multi-host/script execution | | `sftp` | `--upload/--download/--list/--mkdir/--rm` | file transfer & remote FS ops | | `password` | `--password-*` | manage keyring secrets | | `host` | `--host-*` | manage `settings.json` host entries | diff --git a/CHANGELOG.md b/CHANGELOG.md index d4b512a..618995b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add the canonical `sshx run` execution contract with versioned request/result + models, strict target selectors (`--target`/`--targets`/`--group`/`--tag`/ + `--all-hosts`/`--address`), byte-preserving `--script-file`/`--script-stdin` + payloads (SHA-256 digest + size limits), bounded multi-host fan-out + (`--concurrency` default 4 / hard max 32), `--failure-mode=continue|fail_fast`, + and JSONL run events (`run_started` / `target_*` / `run_finished`). +- Extend host inventory with `groups`, `tags`, `ssh_password_key`, and + `sudo_password_key` while keeping legacy `password_key` as a sudo-only alias. +- Correlate audit events with `run_id`, selector/payload digests, action intent, + bypass reason, and per-target completion certainty. + +### Changed + +- Stop implicitly loading a working-directory `.env` file. +- High-risk trust relaxations (`force`, safety-check disablement, unknown-host + acceptance, insecure host-key mode) now require explicit CLI/request fields; + inherited environment values are ignored with a diagnostic. +- Host diagnostics and execution paths no longer treat sudo password keys as SSH + login credentials. + +### Security + +- Separate SSH-login and sudo credential roles end-to-end so a host with only + `sudo_password_key` never attempts password authentication. +- Safety bypass on `sshx run` requires a non-empty `--bypass-reason` recorded in + dry-run, result, and audit metadata. + ## [0.2.0] - 2026-08-12 ### Added diff --git a/README.md b/README.md index 1f884c1..ef023ca 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,15 @@ It remains a single binary with one-shot invocations and no resident component o ## Key Features -1. Agent-friendly JSON, stable exit codes, separated stdout/stderr, and classified failures. -2. Dry-run execution plans and default-on local structured auditing with safe redaction. -3. Named host management and selective OpenSSH config import with per-host SSH keys. -4. Strict host-key verification, destructive-command guardrails, and explicit bypass semantics. -5. OS-keyring password management and sudo auto-fill over stdin. -6. Cross-platform SSH/SFTP command and file actions. -7. Direct server-to-server transfer, streamed through the local machine without touching local disk. -8. One-shot host inspection with built-in system/network capabilities, local +1. Agent-friendly JSON/JSONL, stable exit codes, separated stdout/stderr, and classified failures. +2. Canonical `sshx run` contract: strict selectors, byte-preserving scripts, and bounded multi-host fan-out. +3. Dry-run execution plans and default-on local structured auditing with safe redaction. +4. Named host management with groups/tags and selective OpenSSH config import. +5. Strict host-key verification, destructive-command guardrails, and explicit bypass semantics. +6. OS-keyring password management with distinct SSH-login and sudo credential roles. +7. Cross-platform SSH/SFTP command and file actions. +8. Direct server-to-server transfer, streamed through the local machine without touching local disk. +9. One-shot host inspection with built-in system/network capabilities, local sshx-owned plugins, explicit digest trust, and freshness-bounded observations. ## Installation @@ -277,6 +278,20 @@ On an `sshx`-level failure the object has `exit_code: -1` and a non-empty `exit_missing`, `config`, `error`), so it is always distinguishable from a remote command that happens to exit `255`. +### Canonical `sshx run` contract + +Prefer `sshx run` for strict aliases, complex scripts, and bounded multi-host +execution: + +```bash +sshx run --target=prod-web --json -- "systemctl is-active nginx" +sshx run --group=prod-web --tag=env=prod --concurrency=4 --jsonl -- "uptime" +sshx run --target=prod-web --script-file=./check.sh --json +``` + +Multi-target exit codes: `0` all succeeded, `1` partial failure/skip/uncertain, +`255` request-level failure (invalid selectors, zero matches, bad input). + ### `--dry-run` execution plan preview Add `--dry-run` to see how `sshx` would interpret an invocation before it opens diff --git a/README_CN.md b/README_CN.md index 016479c..7b42336 100644 --- a/README_CN.md +++ b/README_CN.md @@ -62,14 +62,15 @@ Agent 需要的不是另一个交互式 SSH shell,而是一份稳定、可组 ## 核心特性 -1. Agent 友好的 JSON、稳定退出码、stdout/stderr 分离和错误分类。 -2. dry-run 执行计划预览,以及默认启用、自动脱敏的本地结构化审计。 -3. 命名主机管理和 OpenSSH config 选择性导入,支持每台主机独立 SSH key。 -4. 严格 host-key 校验、危险命令护栏和显式安全绕过语义。 -5. 系统密钥链密码管理和通过 stdin 完成的 sudo 自动填充。 -6. 跨平台 SSH/SFTP 命令与文件动作。 -7. 服务器到服务器直接文件传输,数据经本机流式中转而不落地。 -8. 单次主机环境探测:内置系统/网络能力,应用级插件归 sshx 本地运行目录管理, +1. Agent 友好的 JSON/JSONL、稳定退出码、stdout/stderr 分离和错误分类。 +2. 规范 `sshx run` 契约:严格选择器、脚本字节保真、有界多主机并发执行。 +3. dry-run 执行计划预览,以及默认启用、自动脱敏的本地结构化审计。 +4. 命名主机管理(groups/tags)和 OpenSSH config 选择性导入。 +5. 严格 host-key 校验、危险命令护栏和显式安全绕过语义。 +6. 系统密钥链密码管理,SSH 登录与 sudo 凭据角色分离。 +7. 跨平台 SSH/SFTP 命令与文件动作。 +8. 服务器到服务器直接文件传输,数据经本机流式中转而不落地。 +9. 单次主机环境探测:内置系统/网络能力,应用级插件归 sshx 本地运行目录管理, 支持摘要信任和有有效期的观察快照。 ## 安装 @@ -279,6 +280,18 @@ sshx -h=prod-web --json "systemctl is-active nginx" `timeout`、`auth`、`host_key`、`connect`、`blocked`、`exit_missing`、`config`、`error` 之一),因此始终可以与"远程命令恰好退出 255"区分开来。 +### 规范契约 `sshx run` + +严格别名、复杂脚本和有界多主机执行请优先使用: + +```bash +sshx run --target=prod-web --json -- "systemctl is-active nginx" +sshx run --group=prod-web --tag=env=prod --concurrency=4 --jsonl -- "uptime" +sshx run --target=prod-web --script-file=./check.sh --json +``` + +多主机退出码:`0` 全部成功,`1` 部分失败/跳过/不确定,`255` 请求级失败。 + ### `--dry-run` 执行计划预览 加上 `--dry-run` 可以在真正连接 SSH、执行命令、执行 SFTP 操作、读取 keyring 明文、 diff --git a/docs/agent-scripting.md b/docs/agent-scripting.md index a938277..b71bc83 100644 --- a/docs/agent-scripting.md +++ b/docs/agent-scripting.md @@ -2,6 +2,26 @@ `sshx` is designed to be called by scripts and AI agents. The contract is intentionally simple: predictable streams, predictable exit codes, optional JSON, and optional local audit events. +## Canonical Run Contract + +Prefer `sshx run` for strict alias selection, complex scripts, and multi-host fan-out: + +```bash +sshx run --target=prod-web --json -- "systemctl is-active nginx" +sshx run --group=prod-web --tag=env=prod --concurrency=4 --jsonl -- "uptime" +sshx run --target=prod-web --script-file=./check.sh --dry-run --json +cat ./check.sh | sshx run --target=prod-web --script-stdin --json +``` + +- Selectors resolve configured hosts only. Use `--address=` for one literal address. +- Script payloads are streamed on SSH stdin and are not reconstructed through shell joining. +- Dry-run and results expose payload SHA-256 and byte length, not raw script contents. +- Multi-target `--jsonl` streams `run_started`, per-target events, and `run_finished`. +- Multi-target exit codes: `0` all succeeded, `1` partial/failed/skipped/uncertain, `255` request-level failure. +- High-risk bypasses require explicit flags; `sshx run` also requires `--bypass-reason=`. +- Working-directory `.env` files are not loaded. Inherited `SSH_FORCE` / + `SSH_NO_SAFETY_CHECK` / host-key env switches do not authorize trust relaxation. + ## Default Stream Behavior By default `sshx` does not request a PTY. That keeps stdout and stderr separate and avoids terminal control characters in script output. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0eae047..b1cc3cb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -238,7 +238,7 @@ Agent / 自动化 / 人类运维者 | Agent Skill 安装 | 高 | 本地调用者权限 | 是,本地 Agent 信任目录 | ✅ 编译后二进制离线安装/幂等复用 | ✅ 内容冲突与 symlink 目标拒绝 | ✅ 默认目录/显式目录 | ✅ 冲突不覆盖,显式 force 后恢复官方版本 | `tests/e2e/skill_e2e_test.go` | | 单主机探测与内置基线 | 高 | 是 | 否,cache off | ✅ 自定义插件与 `system.baseline` | ✅ 未信任、污染/超限输出、超时、非零退出、不支持平台 | ✅ operator/reader/sudo-required | 不适用:不修改远端状态 | `tests/e2e/inspect_plugin_e2e_test.go`、`tests/e2e/keyring_e2e_test.go` | | 远端观察缓存 | 高 | 是 | 是,远端 JSON | ✅ 冷写入/热复用/并发原子替换 | ✅ TTL/boot ID、格式、大小、属主、权限、symlink、只读端 | ✅ 可写/只读 SFTP | ✅ 失败写入保留原有效快照 | `tests/e2e/inspect_plugin_e2e_test.go` | -| 有界多主机执行(方向) | 高 | 是 | 可能,多主机 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | `--host-test-all` 仅覆盖连接测试,不等同批量执行 | -| 可解释执行治理(方向) | 高 | 是 | 可能 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | 现有 `--dry-run`、安全检查与审计是基础,不构成完整能力 | +| 有界多主机执行 | 高 | 是 | 可能,多主机 | ✅ `sshx run` 组/标签选择 + concurrency 1/4/8/32 | ✅ fail_fast、部分失败、零匹配 | ✅ operator 密码角色 | ✅ 每个选中目标都有终态事件 | `tests/e2e/run_e2e_test.go`、`internal/execution/*_test.go` | +| 可解释执行治理 | 高 | 是 | 可能 | ✅ run 契约 dry-run/digest/intent/bypass_reason | ✅ blocked、uncertain completion、typed error.kind | ✅ SSH login vs sudo key 分离 | ✅ completion 指导 verify_first/unsafe | `tests/e2e/run_e2e_test.go`、`internal/app/run.go`、`internal/execution` | 当前已达到已实现一级能力的覆盖底线。表中的剩余红项属于尚未实现的方向能力,而不是用组件测试掩盖的既有质量债。未来任何一级能力不得只以参数解析或组件测试作为完成依据;必须沿用编译后二进制边界补充 E2E,并同步更新本矩阵。 diff --git a/docs/zh/agent-scripting.md b/docs/zh/agent-scripting.md index 197bc09..02fd9b5 100644 --- a/docs/zh/agent-scripting.md +++ b/docs/zh/agent-scripting.md @@ -2,6 +2,24 @@ `sshx` 设计上可以被脚本和 AI agent 调用。契约很简单:稳定的 stdout/stderr、稳定退出码、可选 JSON、可选本地审计事件。 +## 规范执行契约 `sshx run` + +复杂脚本、严格别名选择和有界多主机执行请优先使用: + +```bash +sshx run --target=prod-web --json -- "systemctl is-active nginx" +sshx run --group=prod-web --tag=env=prod --concurrency=4 --jsonl -- "uptime" +sshx run --target=prod-web --script-file=./check.sh --dry-run --json +``` + +- 选择器只解析已配置主机;字面地址用 `--address=`,不能进入 group/tag 扩散。 +- 脚本经 SSH stdin 原样传输,不经本地 `strings.Join` 拼装。 +- dry-run/结果暴露 payload SHA-256 与字节数,默认不回传脚本全文。 +- 多主机 `--jsonl` 输出 `run_started` / `target_*` / `run_finished`。 +- 多主机退出码:`0` 全成功,`1` 部分失败/跳过/不确定,`255` 请求级失败。 +- 高风险绕过需显式 CLI;`sshx run` 还要求 `--bypass-reason=`。 +- 不再隐式加载工作目录 `.env`;`SSH_FORCE` 等环境变量不能授权信任降级。 + ## 默认输出流 默认不请求 PTY,这样 stdout 和 stderr 会保持分离,也不会把终端控制字符混进脚本输出。 diff --git a/go.mod b/go.mod index dd5b08c..7991169 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/talkincode/sshx go 1.25.10 require ( - github.com/joho/godotenv v1.5.1 github.com/pkg/sftp v1.13.10 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index ed46dcd..c75dae9 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,6 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= diff --git a/internal/app/agentmode_test.go b/internal/app/agentmode_test.go index 5191d4d..31654f9 100644 --- a/internal/app/agentmode_test.go +++ b/internal/app/agentmode_test.go @@ -430,17 +430,19 @@ func TestRun_DryRunResolvesNamedHostAndSudoKey(t *testing.T) { func TestRun_DryRunHostTestUsesConfiguredKeyAndPasswordKey(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - passwordKeyName := "prod-web-password" //nolint:gosec // G101: keyring key name used in a test, not secret material. + sudoKeyName := "prod-web-sudo" //nolint:gosec // G101: keyring key name used in a test, not secret material. + sshKeyName := "prod-web-login" //nolint:gosec // G101: keyring key name used in a test, not secret material. err := SaveSettings(&Settings{ Key: "/keys/default.pem", Hosts: []HostConfig{ { - Name: "prod-web", - Host: "10.0.0.5", - Port: "2222", - User: "root", - Key: "/keys/prod-web.pem", - PasswordKey: passwordKeyName, + Name: "prod-web", + Host: "10.0.0.5", + Port: "2222", + User: "root", + Key: "/keys/prod-web.pem", + SudoPasswordKey: sudoKeyName, + SSHPasswordKey: sshKeyName, }, }, }) @@ -462,14 +464,31 @@ func TestRun_DryRunHostTestUsesConfiguredKeyAndPasswordKey(t *testing.T) { if result["key_path"] != "/keys/prod-web.pem" { t.Errorf("expected configured host key path, got %v", result["key_path"]) } - if result["sudo_key"] != passwordKeyName { - t.Errorf("expected configured password key, got %v", result["sudo_key"]) + if result["sudo_key"] != sudoKeyName { + t.Errorf("expected configured sudo password key, got %v", result["sudo_key"]) } if result["would_connect"] != true { t.Errorf("expected real host test would connect, got %v", result["would_connect"]) } + // Host diagnostics may read only the typed SSH login password key. if result["would_read_secret"] != true { - t.Errorf("expected real host test would read configured password key, got %v", result["would_read_secret"]) + t.Errorf("expected real host test would read SSH password key, got %v", result["would_read_secret"]) + } + + // sudo-only hosts must not imply an SSH login secret read. + err = SaveSettings(&Settings{ + Hosts: []HostConfig{{ + Name: "sudo-only", + Host: "10.0.0.6", + SudoPasswordKey: sudoKeyName, + }}, + }) + if err != nil { + t.Fatalf("SaveSettings() error = %v", err) + } + sudoOnly := runDryRunJSON(t, []string{"sshx", "--host-test=sudo-only", "--dry-run", "--json"}) + if sudoOnly["would_read_secret"] != false { + t.Errorf("expected sudo-only host test not to read secrets, got %v", sudoOnly["would_read_secret"]) } } diff --git a/internal/app/app.go b/internal/app/app.go index d430b9f..de09888 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,11 +6,9 @@ import ( "fmt" "net" "os" - "strings" "time" - "github.com/joho/godotenv" - + "github.com/talkincode/sshx/internal/execution" "github.com/talkincode/sshx/internal/sshclient" "github.com/talkincode/sshx/pkg/errutil" "github.com/talkincode/sshx/pkg/logger" @@ -59,9 +57,9 @@ func Run(args []string) (err error) { return ErrUsage } - // Load environment variables - //nolint:errcheck // Loading .env is optional - _ = godotenv.Load() + // Do not implicitly load a working-directory .env file. Repository-local + // files must not alter trust, safety, or host-key policy. Process env and + // explicit CLI flags remain the supported configuration surfaces. // Set log level from environment variable if logLevelStr := os.Getenv("SSHX_LOG_LEVEL"); logLevelStr != "" { @@ -71,6 +69,9 @@ func Run(args []string) (err error) { // Parse command-line arguments config := ParseArgs(args) + if config.ArgumentError != "" { + return fmt.Errorf("%w: %s", execution.ErrConfig, config.ArgumentError) + } audit := newAuditRecorder(config) defer func() { if auditErr := audit.finish(config, err); auditErr != nil { @@ -78,6 +79,11 @@ func Run(args []string) (err error) { } }() + // Canonical multi-host / script execution contract. + if config.Mode == "run" { + return HandleRun(config, audit) + } + if config.DryRun { return emitDryRunPlan(config) } @@ -269,39 +275,14 @@ func emitCommandJSON(config *sshclient.Config, authMethod sshclient.AuthMethod, // classifyError maps an sshx-level error to a stable machine-readable kind so an // agent can branch on the failure category without parsing free-form text. +// Compatibility projection: unknown maps to the legacy "error" kind for the +// existing single-command JSON surface. func classifyError(err error) string { - if err == nil { - return "" - } - switch { - case errors.Is(err, sshclient.ErrCommandTimeout): - return "timeout" - case errors.Is(err, sshclient.ErrNoExitStatus): - return "exit_missing" - } - var blocked *sshclient.CommandBlockedError - if errors.As(err, &blocked) { - return "blocked" - } - msg := strings.ToLower(err.Error()) - switch { - case strings.Contains(msg, "known_hosts"), strings.Contains(msg, "host key"): - return "host_key" - case strings.Contains(msg, "unable to authenticate"), - strings.Contains(msg, "no authentication"), - strings.Contains(msg, "no supported methods"), - strings.Contains(msg, "password fallback"), - strings.Contains(msg, "handshake"): - return "auth" - case strings.Contains(msg, "connection refused"), - strings.Contains(msg, "no route to host"), - strings.Contains(msg, "i/o timeout"), - strings.Contains(msg, "failed to connect"), - strings.Contains(msg, "dial"): - return "connect" - default: + kind := execution.Classify(err) + if kind == execution.ErrorKindUnknown { return "error" } + return kind } // isIPAddress checks if a string is a valid IP address @@ -338,10 +319,18 @@ func resolveHostFromSettings(config *sshclient.Config) error { } } - // Use configured password key if available - if hostConfig.PasswordKey != "" && config.SudoKey == sshclient.DefaultSudoKey { - config.SudoKey = hostConfig.PasswordKey - logger.GetLogger().Success("Using password key: %s", hostConfig.PasswordKey) + // Use configured sudo password key if available (legacy password_key is sudo-only). + sudoKey := hostConfig.EffectiveSudoPasswordKey() + if sudoKey != "" && config.SudoKey == sshclient.DefaultSudoKey { + config.SudoKey = sudoKey + logger.GetLogger().Success("Using sudo password key: %s", sudoKey) + } + // SSH login password key is a distinct role and never falls back to sudo keys. + if config.SSHPasswordKey == "" { + if sshKey := hostConfig.EffectiveSSHPasswordKey(); sshKey != "" { + config.SSHPasswordKey = sshKey + logger.GetLogger().Success("Using SSH password key: %s", sshKey) + } } // Use per-host SSH key if available, otherwise fall back to the default key @@ -356,5 +345,14 @@ func resolveHostFromSettings(config *sshclient.Config) error { } } + // Resolve typed SSH login password only when that role is requested. + if config.Password == "" && config.SSHPasswordKey != "" { + if password, pwdErr := sshclient.GetSudoPassword(config.SSHPasswordKey); pwdErr != nil { + logger.GetLogger().Warning("failed to get SSH password from keyring (%s): %v", config.SSHPasswordKey, pwdErr) + } else { + config.Password = password + } + } + return nil } diff --git a/internal/app/audit.go b/internal/app/audit.go index b4c1843..67b16f4 100644 --- a/internal/app/audit.go +++ b/internal/app/audit.go @@ -97,6 +97,20 @@ type auditEvent struct { DurationMs int64 `json:"duration_ms"` Outcome auditStatus `json:"outcome"` Redaction auditRedaction `json:"redaction"` + + // Run-contract correlation fields (additive). + RunID string `json:"run_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + SelectorDigest string `json:"selector_digest,omitempty"` + PayloadSHA256 string `json:"payload_sha256,omitempty"` + ActionIntent string `json:"action_intent,omitempty"` + BypassReason string `json:"bypass_reason,omitempty"` + Concurrency int `json:"concurrency,omitempty"` + FailureMode string `json:"failure_mode,omitempty"` + TargetCount int `json:"target_count,omitempty"` + TargetIndex *int `json:"target_index,omitempty"` + Completion string `json:"completion,omitempty"` + Phase string `json:"phase,omitempty"` } type auditRecorder struct { diff --git a/internal/app/config.go b/internal/app/config.go index 89b8a07..0383196 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -50,6 +50,7 @@ func ParseArgs(args []string) *sshclient.Config { Force: false, UseKeyAuth: true, AuditEnabled: true, + RunTags: map[string]string{}, } if password := os.Getenv("SSH_PASSWORD"); password != "" { @@ -71,19 +72,13 @@ func ParseArgs(args []string) *sshclient.Config { if noAudit := os.Getenv("SSHX_NO_AUDIT"); strings.EqualFold(noAudit, "true") || noAudit == "1" { config.AuditEnabled = false } - if acceptUnknown := os.Getenv("SSH_ACCEPT_UNKNOWN_HOST"); strings.EqualFold(acceptUnknown, "true") || acceptUnknown == "1" { - config.AcceptUnknownHost = true - } - if insecure := os.Getenv("SSH_INSECURE_HOST_KEY"); strings.EqualFold(insecure, "true") || insecure == "1" { - config.AllowInsecureHostKey = true - } + // High-risk trust relaxations must be explicit CLI/request fields. Inherited + // environment values and repository-local .env files must not authorize them. + warnDeprecatedTrustEnv("SSH_ACCEPT_UNKNOWN_HOST") + warnDeprecatedTrustEnv("SSH_INSECURE_HOST_KEY") + warnDeprecatedTrustEnv("SSH_NO_SAFETY_CHECK") + warnDeprecatedTrustEnv("SSH_FORCE") - if os.Getenv("SSH_NO_SAFETY_CHECK") == "true" { - config.SafetyCheck = false - } - if os.Getenv("SSH_FORCE") == "true" { - config.Force = true - } if timeoutStr := os.Getenv("SSH_TIMEOUT"); timeoutStr != "" { if d, err := parseTimeout(timeoutStr); err == nil { config.Timeout = d @@ -97,6 +92,9 @@ func ParseArgs(args []string) *sshclient.Config { sudoKey = sshclient.DefaultSudoKey } config.SudoKey = sudoKey + if sshPasswordKey := os.Getenv("SSH_PASSWORD_KEY"); sshPasswordKey != "" { + config.SSHPasswordKey = sshPasswordKey + } if len(args) > 1 { switch args[1] { @@ -109,6 +107,9 @@ func ParseArgs(args []string) *sshclient.Config { case "inspect": parseInspectArgs(config, args[2:]) return config + case "run": + parseRunArgs(config, args[2:]) + return config } } @@ -354,6 +355,173 @@ func parsePluginArgs(config *sshclient.Config, args []string) { } } +// warnDeprecatedTrustEnv emits a diagnostic when a high-risk env switch is set +// without applying it. Explicit CLI flags remain the only authorization path. +func warnDeprecatedTrustEnv(name string) { + val := os.Getenv(name) + if val == "" { + return + } + if strings.EqualFold(val, "true") || val == "1" { + fmt.Fprintf(os.Stderr, "sshx: ignoring deprecated trust env %s=%q; use an explicit CLI flag/request field instead\n", name, val) + } +} + +func parseRunArgs(config *sshclient.Config, args []string) { + config.Mode = "run" + config.FailureMode = "continue" + config.RunConcurrency = 4 + commandParts := []string{} + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + commandParts = append(commandParts, args[i+1:]...) + break + } + switch { + case strings.HasPrefix(arg, "--target="): + name := strings.TrimSpace(strings.SplitN(arg, "=", 2)[1]) + if name != "" { + config.RunTargets = append(config.RunTargets, name) + } + case strings.HasPrefix(arg, "--targets="): + raw := strings.SplitN(arg, "=", 2)[1] + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + config.RunTargets = append(config.RunTargets, part) + } + } + case strings.HasPrefix(arg, "--group="): + g := strings.TrimSpace(strings.SplitN(arg, "=", 2)[1]) + if g != "" { + config.RunGroups = append(config.RunGroups, g) + } + case strings.HasPrefix(arg, "--tag="): + raw := strings.SplitN(arg, "=", 2)[1] + kv := strings.SplitN(raw, "=", 2) + if len(kv) != 2 || strings.TrimSpace(kv[0]) == "" { + config.ArgumentError = fmt.Sprintf("invalid --tag value %q (want key=value)", raw) + continue + } + if config.RunTags == nil { + config.RunTags = map[string]string{} + } + config.RunTags[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + case arg == "--all-hosts": + config.RunAllHosts = true + case strings.HasPrefix(arg, "--address="): + config.RunAddress = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "-h="), strings.HasPrefix(arg, "--host="): + // Compatibility alias for a single strict target name. + name := strings.TrimSpace(strings.SplitN(arg, "=", 2)[1]) + if name != "" { + config.RunTargets = append(config.RunTargets, name) + } + case strings.HasPrefix(arg, "-p="), strings.HasPrefix(arg, "--port="): + config.Port = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "-u="), strings.HasPrefix(arg, "--user="): + config.User = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "-i="), strings.HasPrefix(arg, "--key="): + config.KeyPath = strings.SplitN(arg, "=", 2)[1] + config.UseKeyAuth = true + case strings.HasPrefix(arg, "-pk="), strings.HasPrefix(arg, "--password-key="), strings.HasPrefix(arg, "--sudo-password-key="): + config.SudoKey = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--ssh-password-key="): + config.SSHPasswordKey = strings.SplitN(arg, "=", 2)[1] + case arg == "--no-key", arg == "--password-only": + config.UseKeyAuth = false + config.KeyPath = "" + case arg == "--key-auth": + config.UseKeyAuth = true + case arg == "--force", arg == "-f": + config.Force = true + case arg == "--no-safety-check": + config.SafetyCheck = false + case strings.HasPrefix(arg, "--bypass-reason="): + config.BypassReason = strings.SplitN(arg, "=", 2)[1] + case arg == "--accept-unknown-host": + config.AcceptUnknownHost = true + case arg == "--insecure-hostkey": + config.AllowInsecureHostKey = true + case arg == "--strict-host-key": + config.AllowInsecureHostKey = false + case strings.HasPrefix(arg, "--known-hosts="): + config.KnownHostsPath = strings.SplitN(arg, "=", 2)[1] + case arg == "--dry-run": + config.DryRun = true + case strings.HasPrefix(arg, "--audit-output="): + config.AuditOutput = strings.SplitN(arg, "=", 2)[1] + case arg == "--no-audit": + config.AuditEnabled = false + case arg == "--json": + config.JSONOutput = true + case arg == "--jsonl": + config.JSONLOutput = true + config.JSONOutput = true + case strings.HasPrefix(arg, "--timeout="): + raw := strings.SplitN(arg, "=", 2)[1] + if d, err := parseTimeout(raw); err == nil { + config.Timeout = d + } else { + config.Timeout = -1 + } + case strings.HasPrefix(arg, "--concurrency="): + raw := strings.SplitN(arg, "=", 2)[1] + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + config.ArgumentError = fmt.Sprintf("invalid --concurrency value %q", raw) + } else { + config.RunConcurrency = n + } + case strings.HasPrefix(arg, "--failure-mode="): + config.FailureMode = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--intent="): + config.RunIntent = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--request-id="): + config.RequestID = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--script-file="): + config.ScriptFile = strings.SplitN(arg, "=", 2)[1] + config.RunActionKind = "script" + case arg == "--script-stdin": + config.ScriptStdin = true + config.RunActionKind = "script" + case arg == "--sudo": + config.RunUseSudo = true + case strings.HasPrefix(arg, "--max-output-bytes="): + raw := strings.SplitN(arg, "=", 2)[1] + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + config.ArgumentError = fmt.Sprintf("invalid --max-output-bytes value %q", raw) + } else { + config.MaxOutputBytes = n + } + case strings.HasPrefix(arg, "--max-payload-bytes="): + raw := strings.SplitN(arg, "=", 2)[1] + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + config.ArgumentError = fmt.Sprintf("invalid --max-payload-bytes value %q", raw) + } else { + config.MaxPayloadBytes = n + } + case strings.HasPrefix(arg, "--host-group="): + // Host management convenience while adding hosts is separate; ignore here. + config.ArgumentError = fmt.Sprintf("unknown run option %q (did you mean --group=)", arg) + case !strings.HasPrefix(arg, "-"): + commandParts = append(commandParts, args[i:]...) + i = len(args) + default: + config.ArgumentError = fmt.Sprintf("unknown run option %q", arg) + } + } + if len(commandParts) > 0 { + config.Command = strings.Join(commandParts, " ") + if config.RunActionKind == "" { + config.RunActionKind = "command" + } + } +} + func parseInspectArgs(config *sshclient.Config, args []string) { config.Mode = "inspect" config.InspectCacheMode = "off" diff --git a/internal/app/config_test.go b/internal/app/config_test.go index 33be0fd..085a372 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -565,11 +565,12 @@ func TestParseArgs_EnvVariables(t *testing.T) { if config.KeyPath != "/env/key/path" { t.Errorf("Expected key path from env '/env/key/path', got %s", config.KeyPath) } - if config.SafetyCheck { - t.Errorf("Expected SafetyCheck to be false from env") + // High-risk trust switches must not be authorized by environment variables. + if !config.SafetyCheck { + t.Errorf("Expected SafetyCheck to remain true; SSH_NO_SAFETY_CHECK must be ignored") } - if !config.Force { - t.Errorf("Expected Force to be true from env") + if config.Force { + t.Errorf("Expected Force to remain false; SSH_FORCE must be ignored") } if config.SudoKey != "custom-sudo" { t.Errorf("Expected sudo key 'custom-sudo', got %s", config.SudoKey) @@ -580,11 +581,11 @@ func TestParseArgs_EnvVariables(t *testing.T) { if config.KnownHostsPath != "/env/known_hosts" { t.Errorf("Expected KnownHostsPath '/env/known_hosts', got %s", config.KnownHostsPath) } - if !config.AcceptUnknownHost { - t.Errorf("Expected AcceptUnknownHost to be true from env") + if config.AcceptUnknownHost { + t.Errorf("Expected AcceptUnknownHost to remain false; SSH_ACCEPT_UNKNOWN_HOST must be ignored") } - if !config.AllowInsecureHostKey { - t.Errorf("Expected AllowInsecureHostKey to be true from env") + if config.AllowInsecureHostKey { + t.Errorf("Expected AllowInsecureHostKey to remain false; SSH_INSECURE_HOST_KEY must be ignored") } } diff --git a/internal/app/dryrun.go b/internal/app/dryrun.go index 0466c5e..213c707 100644 --- a/internal/app/dryrun.go +++ b/internal/app/dryrun.go @@ -227,8 +227,11 @@ func resolveDryRunSSHHost(config *sshclient.Config, plan *dryRunPlan) { config.User = hostConfig.User } } - if hostConfig.PasswordKey != "" && config.SudoKey == sshclient.DefaultSudoKey { - config.SudoKey = hostConfig.PasswordKey + if sudoKey := hostConfig.EffectiveSudoPasswordKey(); sudoKey != "" && config.SudoKey == sshclient.DefaultSudoKey { + config.SudoKey = sudoKey + } + if config.SSHPasswordKey == "" { + config.SSHPasswordKey = hostConfig.EffectiveSSHPasswordKey() } if config.UseKeyAuth && config.KeyPath == "" { switch { @@ -276,10 +279,14 @@ func resolveDryRunHostTest(config *sshclient.Config, plan *dryRunPlan) { if config.UseKeyAuth && config.KeyPath == "" { config.KeyPath = firstNonEmpty(hostConfig.Key, settings.Key) } - if hostConfig.PasswordKey != "" { - config.SudoKey = hostConfig.PasswordKey + // Host diagnostics only read an SSH login password key — never sudo keys. + if sshKey := hostConfig.EffectiveSSHPasswordKey(); sshKey != "" { + config.SSHPasswordKey = sshKey plan.hostTestReadsSecret = true } + if sudoKey := hostConfig.EffectiveSudoPasswordKey(); sudoKey != "" { + config.SudoKey = sudoKey + } plan.HostResolved = config.Host plan.Port = config.Port diff --git a/internal/app/host_manager.go b/internal/app/host_manager.go index 9646627..b23d050 100644 --- a/internal/app/host_manager.go +++ b/internal/app/host_manager.go @@ -49,14 +49,17 @@ func handleHostAdd(config *sshclient.Config) error { // If host configuration is provided via command line if config.HostName != "" { host = HostConfig{ - Name: config.HostName, - Description: config.HostDescription, - Host: config.Host, - Port: config.Port, - User: config.User, - Key: config.KeyPath, - PasswordKey: config.SudoKey, - Type: config.HostType, + Name: config.HostName, + Description: config.HostDescription, + Host: config.Host, + Port: config.Port, + User: config.User, + Key: config.KeyPath, + SudoPasswordKey: config.SudoKey, + SSHPasswordKey: config.SSHPasswordKey, + Groups: append([]string(nil), config.RunGroups...), + Tags: cloneTags(config.RunTags), + Type: config.HostType, } } else { // Interactive mode @@ -104,10 +107,24 @@ func handleHostAdd(config *sshclient.Config) error { host.Key = strings.TrimSpace(keyPath) } - // Password key (optional) - fmt.Print("Password key (optional): ") + // Sudo password key (optional) + fmt.Print("Sudo password key (optional): ") if pwdKey, err := reader.ReadString('\n'); err == nil { - host.PasswordKey = strings.TrimSpace(pwdKey) + host.SudoPasswordKey = strings.TrimSpace(pwdKey) + } + // SSH login password key (optional, distinct from sudo) + fmt.Print("SSH password key (optional): ") + if pwdKey, err := reader.ReadString('\n'); err == nil { + host.SSHPasswordKey = strings.TrimSpace(pwdKey) + } + fmt.Print("Groups (comma-separated, optional): ") + if groups, err := reader.ReadString('\n'); err == nil { + for _, g := range strings.Split(groups, ",") { + g = strings.TrimSpace(g) + if g != "" { + host.Groups = append(host.Groups, g) + } + } } // Type (optional, default: linux) @@ -331,9 +348,24 @@ func handleHostUpdate(config *sshclient.Config) error { } if config.SudoKey != "" && config.SudoKey != sshclient.DefaultSudoKey { - host.PasswordKey = config.SudoKey - } else if existingHost.PasswordKey != "" { - host.PasswordKey = existingHost.PasswordKey + host.SudoPasswordKey = config.SudoKey + } else if existingHost.EffectiveSudoPasswordKey() != "" { + host.SudoPasswordKey = existingHost.EffectiveSudoPasswordKey() + } + if config.SSHPasswordKey != "" { + host.SSHPasswordKey = config.SSHPasswordKey + } else { + host.SSHPasswordKey = existingHost.SSHPasswordKey + } + if len(config.RunGroups) > 0 { + host.Groups = append([]string(nil), config.RunGroups...) + } else { + host.Groups = append([]string(nil), existingHost.Groups...) + } + if len(config.RunTags) > 0 { + host.Tags = cloneTags(config.RunTags) + } else { + host.Tags = cloneTags(existingHost.Tags) } if config.KeyPath != "" { @@ -399,8 +431,17 @@ func handleHostList(config *sshclient.Config) error { if host.Key != "" { fmt.Printf(" Key: %s\n", host.Key) } - if host.PasswordKey != "" { - fmt.Printf(" Password Key: %s\n", host.PasswordKey) + if sudoKey := host.EffectiveSudoPasswordKey(); sudoKey != "" { + fmt.Printf(" Sudo Password Key: %s\n", sudoKey) + } + if sshKey := host.EffectiveSSHPasswordKey(); sshKey != "" { + fmt.Printf(" SSH Password Key: %s\n", sshKey) + } + if len(host.Groups) > 0 { + fmt.Printf(" Groups: %s\n", strings.Join(host.Groups, ", ")) + } + if len(host.Tags) > 0 { + fmt.Printf(" Tags: %s\n", formatTags(host.Tags)) } if host.Type != "" { fmt.Printf(" Type: %s\n", host.Type) @@ -622,11 +663,13 @@ func buildHostTestConfig(hostConfig *HostConfig, settings *Settings, baseConfig } } - if hostConfig.PasswordKey != "" { - if password, err := sshclient.GetSudoPassword(hostConfig.PasswordKey); err == nil { + // Only the typed SSH login password key may authorize password auth. + // Legacy password_key / sudo_password_key are sudo-only and must not be used here. + if sshKey := hostConfig.EffectiveSSHPasswordKey(); sshKey != "" { + if password, err := sshclient.GetSudoPassword(sshKey); err == nil { testConfig.Password = password } else { - logger.GetLogger().Warning("failed to get password from keyring (%s): %v", hostConfig.PasswordKey, err) + logger.GetLogger().Warning("failed to get SSH password from keyring (%s): %v", sshKey, err) } } @@ -659,3 +702,26 @@ type hostTestResult struct { func (r hostTestResult) Success() bool { return r.ConnectionSuccess && r.CommandSuccess } + +func formatTags(tags map[string]string) string { + if len(tags) == 0 { + return "" + } + keys := make([]string, 0, len(tags)) + for k := range tags { + keys = append(keys, k) + } + // stable display order + for i := 0; i < len(keys); i++ { + for j := i + 1; j < len(keys); j++ { + if keys[j] < keys[i] { + keys[i], keys[j] = keys[j], keys[i] + } + } + } + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+tags[k]) + } + return strings.Join(parts, ", ") +} diff --git a/internal/app/run.go b/internal/app/run.go new file mode 100644 index 0000000..65ed886 --- /dev/null +++ b/internal/app/run.go @@ -0,0 +1,424 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/talkincode/sshx/internal/execution" + "github.com/talkincode/sshx/internal/sshclient" + "github.com/talkincode/sshx/pkg/logger" +) + +// keyringSecrets adapts the OS keyring to typed credential roles. +type keyringSecrets struct{} + +func (keyringSecrets) GetSSHPassword(key string) (string, error) { + return sshclient.GetSudoPassword(key) // same keyring service; role is caller-enforced +} + +func (keyringSecrets) GetSudoPassword(key string) (string, error) { + return sshclient.GetSudoPassword(key) +} + +// HandleRun executes the versioned sshx run contract. +func HandleRun(config *sshclient.Config, audit *auditRecorder) error { + req, payload, buildErr := buildRunRequest(config) + if buildErr != nil { + return reportRunRequestFailure(config, audit, buildErr) + } + + hosts, defaults, loadErr := loadHostRecords(config) + if loadErr != nil { + return reportRunRequestFailure(config, audit, loadErr) + } + + if config.DryRun { + plan := execution.BuildDryRunPlan(req, hosts, defaults, payload) + return emitRunDryRun(config, plan) + } + + if normErr := execution.NormalizeRequest(req); normErr != nil { + return reportRunRequestFailure(config, audit, normErr) + } + if safetyErr := execution.SafetyCheck(req, payloadBytes(payload)); safetyErr != nil { + return reportRunRequestFailure(config, audit, safetyErr) + } + + snap, resolveErr := execution.ResolveTargets(hosts, req.Targets, defaults) + if resolveErr != nil { + return reportRunRequestFailure(config, audit, resolveErr) + } + + ctx := context.Background() + if req.Limits.Timeout > 0 { + // Overall run budget: per-target timeout still applies inside sessions. + // Use a generous multiple so fan-out can complete under continue mode. + budget := req.Limits.Timeout * time.Duration(max(1, (snap.Count+req.Limits.Concurrency-1)/req.Limits.Concurrency)) + budget += 5 * time.Second + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, budget) + defer cancel() + } + + var events execution.EventWriter + switch { + case req.JSONLOutput: + events = &execution.JSONLWriter{W: os.Stdout} + case req.JSONOutput && snap.Count == 1: + events = executionNoopEvents{} + case req.JSONOutput && snap.Count > 1: + // Multi-target JSON mode defaults to JSONL stream. + events = &execution.JSONLWriter{W: os.Stdout} + req.JSONLOutput = true + default: + events = &execution.HumanWriter{W: os.Stdout} + } + + outcome, execErr := execution.Execute(ctx, execution.RunOptions{ + Request: req, + Snapshot: snap, + Payload: payload, + Secrets: keyringSecrets{}, + Events: events, + }) + if execErr != nil { + return reportRunRequestFailure(config, audit, execErr) + } + + recordRunAudit(audit, config, req, snap, outcome) + + // Single-target --json emits one versioned result document. + if req.JSONOutput && !req.JSONLOutput && outcome.Single != nil { + if err := encodeJSON(outcome.Single); err != nil { + logger.GetLogger().Error("failed to encode run result: %v", err) + } + } + + code := execution.ProcessExitCode(outcome.Counts, nil) + switch code { + case 0: + return nil + case 255: + return fmt.Errorf("run failed") + default: + return &ExitError{Code: code} + } +} + +type executionNoopEvents struct{} + +func (executionNoopEvents) WriteEvent(execution.Event) error { return nil } + +func emitRunDryRun(config *sshclient.Config, plan execution.DryRunPlan) error { + if config.JSONOutput || config.JSONLOutput { + return encodeJSON(plan) + } + fmt.Println("=== sshx run dry-run ===") + fmt.Printf("Valid: %t\n", plan.Valid) + fmt.Printf("Action: %s intent=%s\n", plan.Action.Kind, plan.Action.Intent) + if plan.Action.PayloadSHA256 != "" { + fmt.Printf("Payload: sha256=%s bytes=%d\n", plan.Action.PayloadSHA256, plan.Action.PayloadBytes) + } + if plan.Action.Command != "" { + fmt.Printf("Command: %s\n", plan.Action.Command) + } + fmt.Printf("Targets: %d digest=%s\n", plan.Snapshot.Count, plan.Snapshot.SelectorDigest) + for _, t := range plan.Snapshot.Targets { + alias := t.Alias + if alias == "" { + alias = "(literal)" + } + fmt.Printf(" - [%d] %s %s@%s:%s sudo_key=%s ssh_pw_key=%s\n", + t.Index, alias, t.User, t.Address, t.Port, t.SudoPasswordKey, t.SSHPasswordKey) + } + for _, s := range plan.Snapshot.Skipped { + fmt.Printf(" skip %s: %s\n", s.Alias, s.Reason) + } + fmt.Printf("Concurrency: %d failure_mode=%s\n", plan.Limits.Concurrency, plan.Policy.FailureMode) + fmt.Printf("Would connect/execute/read_secret/mutate_remote: %t/%t/%t/%t\n", + plan.WouldConnect, plan.WouldExecute, plan.WouldReadSecret, plan.WouldMutateRemote) + if plan.Error != nil { + fmt.Printf("Error: kind=%s message=%s\n", plan.Error.Kind, plan.Error.Message) + } + return nil +} + +func reportRunRequestFailure(config *sshclient.Config, audit *auditRecorder, err error) error { + kind := execution.Classify(err) + if kind == "" { + kind = execution.ErrorKindConfig + } + if audit != nil { + audit.recordFailure(config, sshclient.AuthMethodUnknown, kind, err) + } + if config.JSONOutput || config.JSONLOutput { + res := execution.Result{ + SchemaVersion: execution.ResultSchemaVersion, + Status: execution.StatusFailed, + Phase: execution.PhaseResolve, + Completion: execution.CompletionNotStarted, + ExitCode: -1, + Success: false, + Error: execution.BuildError(err, kind, execution.IntentUnknown, execution.CompletionNotStarted), + ErrorKind: kind, + } + if encErr := encodeJSON(res); encErr != nil { + logger.GetLogger().Error("failed to encode run failure: %v", encErr) + } + return ErrReported + } + return err +} + +func encodeJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + return enc.Encode(v) +} + +func payloadBytes(p *execution.Payload) []byte { + if p == nil { + return nil + } + return p.Bytes +} + +func loadHostRecords(config *sshclient.Config) ([]execution.HostRecord, execution.HostRecord, error) { + settings, err := LoadSettings() + if err != nil { + return nil, execution.HostRecord{}, fmt.Errorf("%w: load settings: %v", execution.ErrConfig, err) + } + hosts := make([]execution.HostRecord, 0, len(settings.Hosts)) + for _, h := range settings.Hosts { + hosts = append(hosts, hostToRecord(h)) + } + defaults := execution.HostRecord{ + Port: firstNonEmptyStr(config.Port, sshclient.DefaultSSHPort), + User: firstNonEmptyStr(config.User, sshclient.DefaultSSHUser), + KeyPath: firstNonEmptyStr(config.KeyPath, settings.Key), + SSHPasswordKey: config.SSHPasswordKey, + SudoPasswordKey: config.SudoKey, + } + return hosts, defaults, nil +} + +func hostToRecord(h HostConfig) execution.HostRecord { + return execution.HostRecord{ + Name: h.Name, + Address: h.Host, + Port: h.Port, + User: h.User, + KeyPath: h.Key, + SSHPasswordKey: h.EffectiveSSHPasswordKey(), + SudoPasswordKey: h.EffectiveSudoPasswordKey(), + Groups: append([]string(nil), h.Groups...), + Tags: cloneTags(h.Tags), + } +} + +func cloneTags(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func firstNonEmptyStr(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func buildRunRequest(config *sshclient.Config) (*execution.Request, *execution.Payload, error) { + req := &execution.Request{ + SchemaVersion: execution.RequestSchemaVersion, + RequestID: config.RequestID, + Targets: execution.TargetSelector{ + Names: append([]string(nil), config.RunTargets...), + Groups: append([]string(nil), config.RunGroups...), + Tags: cloneTags(config.RunTags), + AllHosts: config.RunAllHosts, + Address: config.RunAddress, + Port: config.Port, + User: config.User, + }, + Action: execution.ActionSpec{ + Kind: config.RunActionKind, + Intent: config.RunIntent, + Command: config.Command, + ScriptPath: config.ScriptFile, + ScriptFromStdin: config.ScriptStdin, + ScriptRunner: execution.ScriptRunnerSH, + UseSudo: config.RunUseSudo, + }, + Limits: execution.Limits{ + Concurrency: config.RunConcurrency, + Timeout: config.Timeout, + MaxOutputBytesPerTarget: config.MaxOutputBytes, + MaxPayloadBytes: config.MaxPayloadBytes, + }, + Policy: execution.Policy{ + FailureMode: config.FailureMode, + SafetyCheckEnabled: config.SafetyCheck, + SafetyBypass: config.Force || !config.SafetyCheck, + BypassReason: config.BypassReason, + AcceptUnknownHost: config.AcceptUnknownHost, + AllowInsecureHostKey: config.AllowInsecureHostKey, + KnownHostsPath: config.KnownHostsPath, + UseKeyAuth: config.UseKeyAuth, + KeyPath: config.KeyPath, + SSHPasswordKey: config.SSHPasswordKey, + SudoPasswordKey: config.SudoKey, + SSHPassword: config.Password, + }, + JSONOutput: config.JSONOutput, + JSONLOutput: config.JSONLOutput, + DryRun: config.DryRun, + AuditEnabled: config.AuditEnabled, + AuditOutput: config.AuditOutput, + } + + // Infer action kind when not set explicitly. + if req.Action.Kind == "" { + switch { + case config.ScriptFile != "" || config.ScriptStdin: + req.Action.Kind = execution.ActionScript + default: + req.Action.Kind = execution.ActionCommand + } + } + if req.Action.Intent == "" { + if req.Action.UseSudo || sshclient.CommandUsesSudo(req.Action.Command) { + req.Action.Intent = execution.IntentChange + } else { + req.Action.Intent = execution.IntentRead + } + } + if req.Policy.FailureMode == "" { + req.Policy.FailureMode = execution.FailureContinue + } + if req.Limits.Concurrency == 0 { + req.Limits.Concurrency = execution.DefaultConcurrency + } + + // Compatibility: single --target from -h when using run with host alias flag mapping. + if len(req.Targets.Names) == 0 && req.Targets.Address == "" && !req.Targets.AllHosts && + len(req.Targets.Groups) == 0 && len(req.Targets.Tags) == 0 && config.Host != "" { + // In run mode -h is treated as strict alias unless --address was set. + req.Targets.Names = []string{config.Host} + } + + if err := execution.NormalizeRequest(req); err != nil { + return nil, nil, err + } + + var payload *execution.Payload + if req.Action.Kind == execution.ActionScript { + var err error + if req.Action.ScriptFromStdin { + p, loadErr := execution.LoadScriptStdin(os.Stdin, req.Limits.MaxPayloadBytes) + if loadErr != nil { + return nil, nil, loadErr + } + payload = &p + } else { + p, loadErr := execution.LoadScriptFile(req.Action.ScriptPath, req.Limits.MaxPayloadBytes) + if loadErr != nil { + return nil, nil, loadErr + } + payload = &p + } + req.Action.PayloadSHA256 = payload.SHA256 + req.Action.PayloadBytes = payload.Size + _ = err + } + + return req, payload, nil +} + +func recordRunAudit(audit *auditRecorder, config *sshclient.Config, req *execution.Request, snap execution.TargetSnapshot, outcome execution.RunOutcome) { + if audit == nil { + return + } + audit.event.Mode = "run" + audit.event.Action = req.Action.Kind + audit.event.RunID = outcome.RunID + audit.event.RequestID = req.RequestID + audit.event.SelectorDigest = snap.SelectorDigest + audit.event.PayloadSHA256 = req.Action.PayloadSHA256 + audit.event.ActionIntent = req.Action.Intent + audit.event.BypassReason = req.Policy.BypassReason + audit.event.Concurrency = req.Limits.Concurrency + audit.event.FailureMode = req.Policy.FailureMode + audit.event.TargetCount = snap.Count + if outcome.Counts.Succeeded == outcome.Counts.Selected && outcome.Counts.Failed == 0 { + code := 0 + audit.event.ExitCode = &code + audit.event.Outcome = auditStatus{Status: "succeeded"} + } else { + code := 1 + audit.event.ExitCode = &code + audit.event.Outcome = auditStatus{Status: "failed", ErrorKind: "aggregate", Message: fmt.Sprintf("succeeded=%d failed=%d skipped=%d uncertain=%d", outcome.Counts.Succeeded, outcome.Counts.Failed, outcome.Counts.Skipped, outcome.Counts.Uncertain)} + } + // Per-target audit lines are best-effort additional records. + for _, tr := range outcome.Results { + if auditErr := writeTargetAudit(config, outcome.RunID, req, tr); auditErr != nil { + logger.GetLogger().Error("failed to write target audit event: %v", auditErr) + } + } +} + +func writeTargetAudit(config *sshclient.Config, runID string, req *execution.Request, tr execution.TargetResult) error { + if config == nil || !config.AuditEnabled || config.DryRun { + return nil + } + rec := newAuditRecorder(config) + if rec == nil { + return nil + } + rec.event.Mode = "run" + rec.event.Action = req.Action.Kind + rec.event.RunID = runID + rec.event.RequestID = req.RequestID + rec.event.HostInput = tr.Target.Alias + rec.event.HostResolved = tr.Target.Address + rec.event.Port = tr.Target.Port + rec.event.User = tr.Target.User + rec.event.Command = redactSensitiveText(req.Action.Command) + rec.event.PayloadSHA256 = req.Action.PayloadSHA256 + rec.event.ActionIntent = req.Action.Intent + rec.event.BypassReason = req.Policy.BypassReason + rec.event.TargetIndex = &tr.Target.Index + rec.event.Completion = tr.Completion + rec.event.Phase = tr.Phase + rec.event.AuthMethod = tr.AuthMethod + code := tr.ExitCode + rec.event.ExitCode = &code + rec.event.DurationMs = tr.DurationMs + if tr.Status == execution.StatusSucceeded { + rec.event.Outcome = auditStatus{Status: "succeeded"} + } else if tr.Error != nil { + rec.event.Outcome = auditStatus{Status: "failed", ErrorKind: tr.Error.Kind, Message: redactSensitiveText(tr.Error.Message)} + } else { + rec.event.Outcome = auditStatus{Status: "failed"} + } + return rec.finish(config, nil) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/app/settings.go b/internal/app/settings.go index d5a3ade..64b28a9 100644 --- a/internal/app/settings.go +++ b/internal/app/settings.go @@ -21,14 +21,61 @@ const ( // HostConfig represents a configured host type HostConfig struct { - Name string `json:"name"` // Host name (unique identifier) - Description string `json:"description,omitempty"` // Description - Host string `json:"host"` // IP or hostname - Port string `json:"port,omitempty"` // Port (default: 22) - User string `json:"user,omitempty"` // Username (default: master) - Key string `json:"key,omitempty"` // SSH private key path (optional, overrides global key) - PasswordKey string `json:"password_key,omitempty"` // Password key name (optional) - Type string `json:"type,omitempty"` // System type (linux/windows/macos) + Name string `json:"name"` // Host name (unique identifier) + Description string `json:"description,omitempty"` // Description + Host string `json:"host"` // IP or hostname + Port string `json:"port,omitempty"` // Port (default: 22) + User string `json:"user,omitempty"` // Username (default: master) + Key string `json:"key,omitempty"` // SSH private key path (optional, overrides global key) + // PasswordKey is the legacy sudo-only keyring reference. Prefer SudoPasswordKey. + // It must never be treated as an SSH login credential. + PasswordKey string `json:"password_key,omitempty"` + // SSHPasswordKey is a typed keyring reference for SSH password authentication only. + SSHPasswordKey string `json:"ssh_password_key,omitempty"` + // SudoPasswordKey is a typed keyring reference for sudo auto-fill only. + SudoPasswordKey string `json:"sudo_password_key,omitempty"` + // Groups are optional inventory labels used by multi-host selectors. + Groups []string `json:"groups,omitempty"` + // Tags are optional key/value inventory labels; selectors AND all predicates. + Tags map[string]string `json:"tags,omitempty"` + Type string `json:"type,omitempty"` // System type (linux/windows/macos) +} + +// EffectiveSudoPasswordKey returns the sudo keyring reference, preferring the +// typed field and falling back to the legacy password_key alias. +func (h HostConfig) EffectiveSudoPasswordKey() string { + if h.SudoPasswordKey != "" { + return h.SudoPasswordKey + } + return h.PasswordKey +} + +// EffectiveSSHPasswordKey returns the SSH-login keyring reference only. +func (h HostConfig) EffectiveSSHPasswordKey() string { + return h.SSHPasswordKey +} + +// NormalizeCredentialKeys migrates legacy password_key into sudo_password_key +// in memory. Loading settings must not rewrite the file by itself. +func (h *HostConfig) NormalizeCredentialKeys() { + if h == nil { + return + } + if h.SudoPasswordKey == "" && h.PasswordKey != "" { + h.SudoPasswordKey = h.PasswordKey + } +} + +// ForSave returns a copy prepared for explicit settings serialization. Legacy +// password_key is emitted as sudo_password_key and omitted when identical. +func (h HostConfig) ForSave() HostConfig { + h.NormalizeCredentialKeys() + out := h + if out.SudoPasswordKey != "" { + // Prefer typed field on explicit writes; drop legacy alias to avoid dual meaning. + out.PasswordKey = "" + } + return out } // Settings represents the user-level configuration @@ -83,6 +130,10 @@ func LoadSettings() (*Settings, error) { if settings.Hosts == nil { settings.Hosts = make([]HostConfig, 0) } + // In-memory credential-role normalization only; do not rewrite the file here. + for i := range settings.Hosts { + settings.Hosts[i].NormalizeCredentialKeys() + } return &settings, nil } @@ -104,8 +155,15 @@ func SaveSettings(settings *Settings) error { return err } + // Explicit saves serialize typed credential keys (legacy password_key → sudo_password_key). + toSave := *settings + toSave.Hosts = make([]HostConfig, len(settings.Hosts)) + for i := range settings.Hosts { + toSave.Hosts[i] = settings.Hosts[i].ForSave() + } + // Marshal settings to JSON with indentation - data, err := json.MarshalIndent(settings, "", " ") + data, err := json.MarshalIndent(toSave, "", " ") if err != nil { return fmt.Errorf("failed to marshal settings: %w", err) } diff --git a/internal/app/usage.go b/internal/app/usage.go index 6fa43cf..c169503 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -11,7 +11,9 @@ func PrintUsage() { fmt.Printf("\nSSHX — Agent-native remote host execution over SSH\nVersion: %s\n", Version) fmt.Println(` Usage: - sshx -h= [options] # SSH mode + sshx -h= [options] # SSH mode (compatibility) + sshx run [selectors] [options] -- # Canonical execution contract + sshx run --script-file=PATH ... # Byte-preserving script payload sshx -h= [options] --upload= # SFTP upload sshx -h= [options] --download= # SFTP download sshx --transfer=: --to=: # Server-to-server transfer @@ -31,12 +33,13 @@ Usage: sshx inspect -h= [options] # Run one structured host inspection SSH Options: - -h, --host=HOST Remote host address (required) + -h, --host=HOST Remote host address (required in compatibility mode) -p, --port=PORT SSH port (default: 22) -u, --user=USER SSH username (default: master) -i, --key=PATH SSH private key path (default: ~/.ssh/id_rsa) -pk, --password-key=KEY Sudo password keyring key name (default: master) Used only when the remote command starts with sudo + --ssh-password-key=KEY SSH login password keyring key (never used for sudo) --dry-run Print the local execution plan without side effects --audit-output=DIR Write audit JSONL files to DIR (default: ~/.sshx/audit) --no-audit Disable local audit event writing for this invocation @@ -46,17 +49,44 @@ SSH Options: --version Show version information (alias: -v) --help Show this help message +Run Contract (preferred for Agents): + sshx run --target=prod-web --json -- "systemctl is-active nginx" + sshx run --group=prod-web --tag=env=prod --concurrency=4 --jsonl -- "uptime" + sshx run --target=prod-web --script-file=./check.sh --json + cat ./check.sh | sshx run --target=prod-web --script-stdin --json + + Selectors (configured hosts only; multi-host never treats names as DNS): + --target=NAME strict alias (repeatable via --targets=a,b) + --group=NAME union with other names/groups (repeatable) + --tag=key=value AND filter (repeatable) + --all-hosts all configured hosts before tag filters + --address=HOST explicit single literal address (not for fan-out) + + Limits / policy: + --concurrency=N default 4, hard max 32 + --failure-mode=continue|fail_fast default continue + --intent=read|change|unknown + --force / --no-safety-check require --bypass-reason=TEXT + --jsonl stream run_started/target_*/run_finished events + + Multi-target exit codes: + 0 all selected targets succeeded + 1 run accepted but at least one target failed/skipped/uncertain + 255 request-level failure (bad selectors, zero matches, invalid input) + Agent / Scripting Mode: By default command output streams live with stdout and stderr kept on separate channels (no PTY), and the remote command's exit status is propagated as sshx's own exit code. - --json emits one JSON object on stdout: + Compatibility --json emits one JSON object on stdout: {host, port, user, command, exit_code, success, stdout, stderr, stdout_truncated, stderr_truncated, duration_ms, auth_method, error_kind, error} + sshx run --json adds versioned fields (schema_version, run_id, status, + phase, completion, structured error). - Exit codes: + Exit codes (single-host compatibility mode): 0 command succeeded 1..254 remote command's exit status (propagated verbatim) 255 sshx-level failure (connect/auth/host-key/timeout/blocked/...) @@ -64,6 +94,10 @@ Agent / Scripting Mode: error_kind (timeout, auth, host_key, connect, blocked, exit_missing, config, error), so it is always distinguishable from a remote exit 255. + Trust note: high-risk bypasses (force, no-safety-check, accept-unknown-host, + insecure-hostkey) require explicit CLI flags. Inherited env values and + working-directory .env files are ignored for those decisions. + Sudo Auto-fill: sshx auto-fills a sudo password only when the remote command starts with sudo, for example: diff --git a/internal/execution/errors.go b/internal/execution/errors.go new file mode 100644 index 0000000..931e1a3 --- /dev/null +++ b/internal/execution/errors.go @@ -0,0 +1,187 @@ +package execution + +import ( + "errors" + "fmt" + "strings" + + "github.com/talkincode/sshx/internal/sshclient" +) + +// Classify maps an error to a stable machine-readable kind. +// Typed/sentinel errors are preferred; free-form matching is only a fallback +// at external-library boundaries. +func Classify(err error) string { + if err == nil { + return "" + } + switch { + case errors.Is(err, sshclient.ErrCommandTimeout): + return ErrorKindTimeout + case errors.Is(err, sshclient.ErrNoExitStatus): + return ErrorKindExitMissing + case errors.Is(err, ErrConfig): + return ErrorKindConfig + case errors.Is(err, ErrLocalIO): + return ErrorKindLocalIO + case errors.Is(err, ErrRemoteIO): + return ErrorKindRemoteIO + case errors.Is(err, ErrBlocked): + return ErrorKindBlocked + } + var blocked *sshclient.CommandBlockedError + if errors.As(err, &blocked) { + return ErrorKindBlocked + } + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "known_hosts"), strings.Contains(msg, "host key"): + return ErrorKindHostKey + case strings.Contains(msg, "unable to authenticate"), + strings.Contains(msg, "no authentication"), + strings.Contains(msg, "no supported methods"), + strings.Contains(msg, "password fallback"), + strings.Contains(msg, "handshake"): + return ErrorKindAuth + case strings.Contains(msg, "connection refused"), + strings.Contains(msg, "no route to host"), + strings.Contains(msg, "i/o timeout"), + strings.Contains(msg, "failed to connect"), + strings.Contains(msg, "dial"): + return ErrorKindConnect + case strings.Contains(msg, "sftp"), strings.Contains(msg, "remote file"), strings.Contains(msg, "remote path"): + return ErrorKindRemoteIO + case strings.Contains(msg, "read "), strings.Contains(msg, "open "), strings.Contains(msg, "write "): + return ErrorKindLocalIO + default: + return ErrorKindUnknown + } +} + +// BuildError constructs ErrorInfo with retry classification based on action intent +// and completion certainty. +func BuildError(err error, kind, intent, completion string) *ErrorInfo { + if err == nil && kind == "" { + return nil + } + if kind == "" { + kind = Classify(err) + } + msg := "" + if err != nil { + msg = err.Error() + } + info := &ErrorInfo{ + Kind: kind, + Message: msg, + Retryable: false, + RetrySafety: RetryUnknown, + } + switch kind { + case ErrorKindTimeout, ErrorKindConnect, ErrorKindProtocol: + info.Retryable = true + info.RetrySafety = RetryVerifyFirst + case ErrorKindAuth, ErrorKindHostKey, ErrorKindBlocked, ErrorKindConfig, ErrorKindLocalIO: + info.Retryable = false + info.RetrySafety = RetryUnsafe + case ErrorKindRemoteExit: + info.Retryable = false + info.RetrySafety = RetryVerifyFirst + case ErrorKindExitMissing: + info.Retryable = false + info.RetrySafety = RetryVerifyFirst + } + + switch completion { + case CompletionCompletedUnconfirmed, CompletionPartial, CompletionUnknown: + if intent == IntentChange { + info.Retryable = false + if info.RetrySafety == RetrySafe { + info.RetrySafety = RetryVerifyFirst + } + if info.RetrySafety == "" || info.RetrySafety == RetryUnknown { + info.RetrySafety = RetryVerifyFirst + } + if completion == CompletionPartial || completion == CompletionUnknown { + info.RetrySafety = RetryUnsafe + if completion == CompletionPartial { + info.RetrySafety = RetryVerifyFirst + } + } + } + case CompletionNotStarted: + if kind == ErrorKindConnect || kind == ErrorKindTimeout || kind == ErrorKindAuth { + // Auth failures are not safely auto-retried without inspection. + if kind == ErrorKindConnect { + info.Retryable = true + info.RetrySafety = RetrySafe + } + } + case CompletionCompleted: + if kind == ErrorKindRemoteExit { + info.RetrySafety = RetryVerifyFirst + } + } + + // Uncertain change actions never report safe automatic retry. + if intent == IntentChange && (completion == CompletionPartial || + completion == CompletionCompletedUnconfirmed || + completion == CompletionUnknown) { + if info.RetrySafety == RetrySafe { + info.RetrySafety = RetryVerifyFirst + } + info.Retryable = false + } + return info +} + +var ( + // ErrConfig indicates a request/schema/selector configuration failure. + ErrConfig = errors.New("execution config error") + // ErrLocalIO indicates a local file/stdin failure before network access. + ErrLocalIO = errors.New("local io error") + // ErrRemoteIO indicates a remote filesystem/protocol I/O failure. + ErrRemoteIO = errors.New("remote io error") + // ErrBlocked indicates the safety policy refused the action. + ErrBlocked = errors.New("action blocked by safety policy") + // ErrNoTargets indicates selector resolution matched zero hosts. + ErrNoTargets = fmt.Errorf("%w: no targets matched", ErrConfig) +) + +// CompletionFor maps phase + error kind onto observed execution certainty. +func CompletionFor(phase, kind string, remoteStarted bool, exitObserved bool) string { + if !remoteStarted { + switch phase { + case PhaseResolve, PhaseAdmission, PhaseConnect, PhaseAuthenticate: + return CompletionNotStarted + default: + if kind == ErrorKindBlocked || kind == ErrorKindConfig || kind == ErrorKindLocalIO { + return CompletionNotStarted + } + } + } + if exitObserved { + return CompletionCompleted + } + switch kind { + case ErrorKindExitMissing: + return CompletionCompletedUnconfirmed + case ErrorKindTimeout: + if remoteStarted { + return CompletionPartial + } + return CompletionNotStarted + case ErrorKindProtocol, ErrorKindConnect: + if remoteStarted { + return CompletionUnknown + } + return CompletionNotStarted + case "": + return CompletionCompleted + default: + if remoteStarted { + return CompletionUnknown + } + return CompletionNotStarted + } +} diff --git a/internal/execution/executor.go b/internal/execution/executor.go new file mode 100644 index 0000000..46c8f85 --- /dev/null +++ b/internal/execution/executor.go @@ -0,0 +1,631 @@ +package execution + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/talkincode/sshx/internal/sshclient" + "github.com/talkincode/sshx/pkg/errutil" +) + +// SecretResolver reads typed keyring references. Implementations must not be +// called during dry-run or selector-only operations. +type SecretResolver interface { + // GetSSHPassword returns an SSH login password for the given keyring key. + GetSSHPassword(key string) (string, error) + // GetSudoPassword returns a sudo password for the given keyring key. + GetSudoPassword(key string) (string, error) +} + +// Dialer creates and connects an SSH client for one target. +type Dialer interface { + Connect(cfg *sshclient.Config) (*sshclient.SSHClient, error) +} + +// DefaultDialer uses sshclient.NewSSHClient + ConnectDirect. +type DefaultDialer struct{} + +// Connect implements Dialer. +func (DefaultDialer) Connect(cfg *sshclient.Config) (*sshclient.SSHClient, error) { + client, err := sshclient.NewSSHClient(cfg) + if err != nil { + return nil, err + } + if err := client.ConnectDirect(); err != nil { + _ = client.ForceClose() //nolint:errcheck // best-effort cleanup + return nil, err + } + return client, nil +} + +// EventWriter receives ordered JSONL events. +type EventWriter interface { + WriteEvent(Event) error +} + +// JSONLWriter writes one JSON object per line to w. +type JSONLWriter struct { + W io.Writer + mu sync.Mutex + enc *json.Encoder +} + +// WriteEvent implements EventWriter. +func (j *JSONLWriter) WriteEvent(ev Event) error { + j.mu.Lock() + defer j.mu.Unlock() + if j.enc == nil { + j.enc = json.NewEncoder(j.W) + j.enc.SetEscapeHTML(false) + } + return j.enc.Encode(ev) +} + +// HumanWriter prints target-prefixed human output without interleaving lines. +type HumanWriter struct { + W io.Writer + mu sync.Mutex +} + +// WriteEvent implements EventWriter for human mode (subset of events). +func (h *HumanWriter) WriteEvent(ev Event) error { + h.mu.Lock() + defer h.mu.Unlock() + switch ev.Kind { + case EventRunStarted: + _, err := fmt.Fprintf(h.W, "run %s started targets=%d concurrency=%d\n", ev.RunID, ev.Counts.Selected, ev.Concurrency) + return err + case EventTargetFinished: + if ev.Result == nil { + return nil + } + alias := ev.Result.Target.Alias + if alias == "" { + alias = ev.Result.Target.Address + } + prefix := fmt.Sprintf("[%d:%s]", ev.Result.Target.Index, alias) + if ev.Result.Stdout != "" { + for _, line := range splitKeep(ev.Result.Stdout) { + if _, err := fmt.Fprintf(h.W, "%s stdout: %s\n", prefix, line); err != nil { + return err + } + } + } + if ev.Result.Stderr != "" { + for _, line := range splitKeep(ev.Result.Stderr) { + if _, err := fmt.Fprintf(h.W, "%s stderr: %s\n", prefix, line); err != nil { + return err + } + } + } + status := ev.Result.Status + if ev.Result.Error != nil { + _, err := fmt.Fprintf(h.W, "%s %s exit=%d completion=%s error_kind=%s\n", + prefix, status, ev.Result.ExitCode, ev.Result.Completion, ev.Result.Error.Kind) + return err + } + _, err := fmt.Fprintf(h.W, "%s %s exit=%d completion=%s\n", + prefix, status, ev.Result.ExitCode, ev.Result.Completion) + return err + case EventRunFinished: + if ev.Counts == nil { + return nil + } + _, err := fmt.Fprintf(h.W, "run %s finished selected=%d succeeded=%d failed=%d skipped=%d uncertain=%d\n", + ev.RunID, ev.Counts.Selected, ev.Counts.Succeeded, ev.Counts.Failed, ev.Counts.Skipped, ev.Counts.Uncertain) + return err + } + return nil +} + +func splitKeep(s string) []string { + if s == "" { + return nil + } + // Trim a single trailing newline for cleaner display; keep internal newlines. + if len(s) > 0 && s[len(s)-1] == '\n' { + s = s[:len(s)-1] + } + return splitLines(s) +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start <= len(s) { + lines = append(lines, s[start:]) + } + return lines +} + +// RunOptions configures one executor invocation. +type RunOptions struct { + Request *Request + Snapshot TargetSnapshot + Payload *Payload + Secrets SecretResolver + Dialer Dialer + Events EventWriter + // ActiveSessions is optional instrumentation for tests. + ActiveSessions *atomic.Int64 + // MaxObserved is optional peak concurrent sessions counter. + MaxObserved *atomic.Int64 +} + +// RunOutcome is the process-level summary for one accepted run. +type RunOutcome struct { + RunID string + Counts RunCounts + Results []TargetResult + // Single is set when exactly one target finished and JSON mode is requested. + Single *Result +} + +// NewRunID returns a random opaque run identifier. +func NewRunID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("run-%d", time.Now().UnixNano()) + } + return "run-" + hex.EncodeToString(b[:]) +} + +// Execute runs the validated request against the frozen snapshot. +func Execute(ctx context.Context, opts RunOptions) (RunOutcome, error) { + if opts.Request == nil { + return RunOutcome{}, fmt.Errorf("%w: request is nil", ErrConfig) + } + req := opts.Request + if err := NormalizeRequest(req); err != nil { + return RunOutcome{}, err + } + if opts.Snapshot.Count == 0 || len(opts.Snapshot.Targets) == 0 { + return RunOutcome{}, ErrNoTargets + } + if opts.Dialer == nil { + opts.Dialer = DefaultDialer{} + } + if opts.Events == nil { + if req.JSONLOutput { + opts.Events = &JSONLWriter{W: os.Stdout} + } else if !req.JSONOutput { + opts.Events = &HumanWriter{W: os.Stdout} + } else { + opts.Events = noopEvents{} + } + } + + runID := NewRunID() + var seq atomic.Int64 + var emitMu sync.Mutex + emit := func(ev Event) { + // Assign sequence and publish under one lock so JSONL stream order is + // strictly monotonic even when workers finish concurrently. + emitMu.Lock() + defer emitMu.Unlock() + ev.SchemaVersion = EventSchemaVersion + ev.RunID = runID + ev.RequestID = req.RequestID + ev.Sequence = seq.Add(1) + ev.Timestamp = time.Now().UTC().Format(time.RFC3339Nano) + _ = opts.Events.WriteEvent(ev) //nolint:errcheck // event write failure must not rewrite remote outcomes + } + + counts := RunCounts{ + Selected: len(opts.Snapshot.Targets), + Skipped: len(opts.Snapshot.Skipped), + } + emit(Event{ + Kind: EventRunStarted, + Counts: &counts, + SelectorDigest: opts.Snapshot.SelectorDigest, + Concurrency: req.Limits.Concurrency, + FailureMode: req.Policy.FailureMode, + Action: &req.Action, + }) + + type job struct { + target ResolvedTarget + } + jobs := make(chan job) + results := make([]TargetResult, len(opts.Snapshot.Targets)) + var resultsMu sync.Mutex + var startedCount atomic.Int64 + var failFast atomic.Bool + var wg sync.WaitGroup + + worker := func() { + defer wg.Done() + for j := range jobs { + if ctx.Err() != nil || (req.Policy.FailureMode == FailureFailFast && failFast.Load()) { + res := skippedResult(req, j.target, "not_admitted") + resultsMu.Lock() + results[j.target.Index] = res + resultsMu.Unlock() + emit(Event{Kind: EventTargetFinished, Target: &j.target, Result: &res}) + continue + } + startedCount.Add(1) + emit(Event{Kind: EventTargetStarted, Target: &j.target}) + if opts.ActiveSessions != nil { + cur := opts.ActiveSessions.Add(1) + if opts.MaxObserved != nil { + for { + max := opts.MaxObserved.Load() + if cur <= max || opts.MaxObserved.CompareAndSwap(max, cur) { + break + } + } + } + } + res := executeOne(ctx, opts, j.target) + if opts.ActiveSessions != nil { + opts.ActiveSessions.Add(-1) + } + resultsMu.Lock() + results[j.target.Index] = res + resultsMu.Unlock() + emit(Event{Kind: EventTargetFinished, Target: &j.target, Result: &res}) + if res.Status != StatusSucceeded { + if req.Policy.FailureMode == FailureFailFast { + failFast.Store(true) + } + } + } + } + + nWorkers := req.Limits.Concurrency + if nWorkers > len(opts.Snapshot.Targets) { + nWorkers = len(opts.Snapshot.Targets) + } + wg.Add(nWorkers) + for i := 0; i < nWorkers; i++ { + go worker() + } + +sendLoop: + for _, t := range opts.Snapshot.Targets { + select { + case <-ctx.Done(): + // Remaining targets get terminal skipped events after close. + break sendLoop + default: + } + if req.Policy.FailureMode == FailureFailFast && failFast.Load() { + res := skippedResult(req, t, "fail_fast") + resultsMu.Lock() + results[t.Index] = res + resultsMu.Unlock() + emit(Event{Kind: EventTargetFinished, Target: &t, Result: &res}) + continue + } + select { + case <-ctx.Done(): + res := skippedResult(req, t, "canceled") + resultsMu.Lock() + results[t.Index] = res + resultsMu.Unlock() + emit(Event{Kind: EventTargetFinished, Target: &t, Result: &res}) + case jobs <- job{target: t}: + } + } + close(jobs) + wg.Wait() + + // Ensure every selected target has a terminal result (canceled before admit). + for i := range results { + if results[i].Status == "" { + t := opts.Snapshot.Targets[i] + results[i] = skippedResult(req, t, "canceled") + emit(Event{Kind: EventTargetFinished, Target: &t, Result: &results[i]}) + } + } + + final := RunCounts{ + Selected: len(opts.Snapshot.Targets), + Started: int(startedCount.Load()), + } + for _, r := range results { + switch { + case r.Status == StatusSucceeded: + final.Succeeded++ + case r.Status == StatusSkipped: + // Runtime skips among the frozen selected set (fail_fast / cancel). + final.Skipped++ + case r.Completion == CompletionPartial || + r.Completion == CompletionCompletedUnconfirmed || + r.Completion == CompletionUnknown: + final.Failed++ + final.Uncertain++ + default: + final.Failed++ + } + } + emit(Event{ + Kind: EventRunFinished, + Counts: &final, + SelectorDigest: opts.Snapshot.SelectorDigest, + Concurrency: req.Limits.Concurrency, + FailureMode: req.Policy.FailureMode, + Action: &req.Action, + }) + + out := RunOutcome{RunID: runID, Counts: final, Results: results} + if len(results) == 1 { + out.Single = ToResult(runID, req.RequestID, results[0]) + } + return out, nil +} + +type noopEvents struct{} + +func (noopEvents) WriteEvent(Event) error { return nil } + +func skippedResult(req *Request, t ResolvedTarget, reason string) TargetResult { + return TargetResult{ + Target: t, + Action: req.Action, + Status: StatusSkipped, + Phase: PhaseAdmission, + Completion: CompletionNotStarted, + ExitCode: -1, + Error: &ErrorInfo{ + Kind: ErrorKindConfig, + Message: reason, + Retryable: false, + RetrySafety: RetryUnknown, + }, + } +} + +func executeOne(ctx context.Context, opts RunOptions, target ResolvedTarget) TargetResult { + req := opts.Request + start := time.Now() + res := TargetResult{ + Target: target, + Action: req.Action, + Status: StatusFailed, + Phase: PhaseConnect, + Completion: CompletionNotStarted, + ExitCode: -1, + } + + if err := ctx.Err(); err != nil { + res.Phase = PhaseAdmission + res.Error = BuildError(err, ErrorKindConfig, req.Action.Intent, CompletionNotStarted) + res.DurationMs = time.Since(start).Milliseconds() + return res + } + + cfg := buildSSHConfig(req, target) + // Resolve secrets only for this target and only for required roles. + if err := applySecrets(cfg, req, target, opts.Secrets); err != nil { + res.Phase = PhaseAuthenticate + res.Error = BuildError(err, ErrorKindAuth, req.Action.Intent, CompletionNotStarted) + res.DurationMs = time.Since(start).Milliseconds() + return res + } + + if err := SafetyCheck(req, payloadBytes(opts.Payload)); err != nil { + res.Phase = PhaseAdmission + res.Error = BuildError(err, ErrorKindBlocked, req.Action.Intent, CompletionNotStarted) + res.DurationMs = time.Since(start).Milliseconds() + return res + } + + client, err := opts.Dialer.Connect(cfg) + if err != nil { + kind := Classify(err) + phase := PhaseConnect + if kind == ErrorKindAuth || kind == ErrorKindHostKey { + phase = PhaseAuthenticate + } + res.Phase = phase + res.Completion = CompletionFor(phase, kind, false, false) + res.Error = BuildError(err, kind, req.Action.Intent, res.Completion) + res.DurationMs = time.Since(start).Milliseconds() + return res + } + defer errutil.HandleCloseError(&err, client) + + res.AuthMethod = string(client.AuthMethodUsed()) + res.Phase = PhaseExecute + remoteStarted := true + + var execRes sshclient.ExecResult + var execErr error + switch req.Action.Kind { + case ActionCommand: + cfg.Command = req.Action.Command + execRes, execErr = client.RunCommand(true) + case ActionScript: + if opts.Payload == nil { + execErr = fmt.Errorf("%w: missing script payload", ErrConfig) + } else { + useSudo := req.Action.UseSudo + execRes, execErr = client.RunScript(opts.Payload.Bytes, useSudo) + } + default: + execErr = fmt.Errorf("%w: action kind %q not executable by run executor", ErrConfig, req.Action.Kind) + } + + res.DurationMs = time.Since(start).Milliseconds() + res.Stdout = execRes.Stdout + res.Stderr = execRes.Stderr + res.StdoutTruncated = execRes.StdoutTruncated + res.StderrTruncated = execRes.StderrTruncated + res.AuthMethod = string(client.AuthMethodUsed()) + + if execErr != nil { + kind := Classify(execErr) + exitObserved := false + res.ExitCode = execRes.ExitCode + if kind == ErrorKindExitMissing { + res.Phase = PhaseCollect + } + res.Completion = CompletionFor(res.Phase, kind, remoteStarted, exitObserved) + // Timeout after start is partial. + if kind == ErrorKindTimeout { + res.Phase = PhaseExecute + res.Completion = CompletionPartial + } + res.Status = StatusFailed + res.Error = BuildError(execErr, kind, req.Action.Intent, res.Completion) + return res + } + + res.ExitCode = execRes.ExitCode + res.Phase = PhaseComplete + res.Completion = CompletionCompleted + if execRes.ExitCode != 0 { + res.Status = StatusFailed + res.Error = BuildError( + fmt.Errorf("remote command exited with status %d", execRes.ExitCode), + ErrorKindRemoteExit, + req.Action.Intent, + CompletionCompleted, + ) + return res + } + res.Status = StatusSucceeded + return res +} + +func buildSSHConfig(req *Request, target ResolvedTarget) *sshclient.Config { + cfg := &sshclient.Config{ + Host: target.Address, + Port: target.Port, + User: target.User, + KeyPath: firstNonEmpty(req.Policy.KeyPath, target.KeyPath), + UseKeyAuth: req.Policy.UseKeyAuth, + Timeout: req.Limits.Timeout, + SafetyCheck: req.Policy.SafetyCheckEnabled && !req.Policy.SafetyBypass, + Force: req.Policy.SafetyBypass, + AcceptUnknownHost: req.Policy.AcceptUnknownHost, + AllowInsecureHostKey: req.Policy.AllowInsecureHostKey, + KnownHostsPath: req.Policy.KnownHostsPath, + JSONOutput: true, + SudoKey: firstNonEmpty(target.SudoPasswordKey, req.Policy.SudoPasswordKey), + Command: req.Action.Command, + Mode: "ssh", + } + if cfg.Port == "" { + cfg.Port = "22" + } + if cfg.User == "" { + cfg.User = "master" + } + return cfg +} + +func applySecrets(cfg *sshclient.Config, req *Request, target ResolvedTarget, secrets SecretResolver) error { + // SSH login password: only explicit password, SSH_PASSWORD, or ssh_password_key. + if req.Policy.SSHPassword != "" { + cfg.Password = req.Policy.SSHPassword + } + sshKey := firstNonEmpty(req.Policy.SSHPasswordKey, target.SSHPasswordKey) + if sshKey != "" { + if secrets == nil { + return fmt.Errorf("%w: ssh password key %q requested without secret resolver", ErrConfig, sshKey) + } + pw, err := secrets.GetSSHPassword(sshKey) + if err != nil { + return err + } + cfg.Password = pw + } + + needSudo := req.Action.UseSudo || (req.Action.Kind == ActionCommand && sshclient.CommandUsesSudo(req.Action.Command)) + if !needSudo { + return nil + } + sudoKey := firstNonEmpty(req.Policy.SudoPasswordKey, target.SudoPasswordKey) + if sudoKey == "" { + sudoKey = sshclient.DefaultSudoKey + } + cfg.SudoKey = sudoKey + if secrets == nil { + return fmt.Errorf("%w: sudo password key %q requested without secret resolver", ErrConfig, sudoKey) + } + pw, err := secrets.GetSudoPassword(sudoKey) + if err != nil { + // Match legacy behavior for command mode: continue without auto-fill. + // For explicit script --sudo, fail closed. + if req.Action.UseSudo && req.Action.Kind == ActionScript { + return err + } + return nil + } + cfg.SudoPassword = pw + return nil +} + +// ToResult projects a TargetResult into the versioned single-target document +// with compatibility fields. +func ToResult(runID, requestID string, tr TargetResult) *Result { + r := &Result{ + SchemaVersion: ResultSchemaVersion, + RunID: runID, + RequestID: requestID, + Target: tr.Target, + Action: tr.Action, + Status: tr.Status, + Phase: tr.Phase, + Completion: tr.Completion, + ExitCode: tr.ExitCode, + Success: tr.Status == StatusSucceeded && tr.ExitCode == 0, + Error: tr.Error, + Host: tr.Target.Address, + Port: tr.Target.Port, + User: tr.Target.User, + Command: tr.Action.Command, + Stdout: tr.Stdout, + Stderr: tr.Stderr, + StdoutTruncated: tr.StdoutTruncated, + StderrTruncated: tr.StderrTruncated, + DurationMs: tr.DurationMs, + AuthMethod: tr.AuthMethod, + } + if tr.Error != nil { + r.ErrorKind = tr.Error.Kind + } + return r +} + +// ProcessExitCode maps a run outcome to the multi-target process exit code. +// +// 0 all selected targets completed successfully +// 1 run accepted but at least one selected target failed, was skipped, or is uncertain +// 255 request-level failure before a valid run could execute +func ProcessExitCode(counts RunCounts, requestErr error) int { + if requestErr != nil { + return 255 + } + if counts.Selected == 0 { + return 255 + } + if counts.Succeeded == counts.Selected && counts.Failed == 0 && counts.Skipped == 0 && counts.Uncertain == 0 { + return 0 + } + return 1 +} + +// IsRequestLevelError reports whether err should become process exit 255. +func IsRequestLevelError(err error) bool { + return err != nil && (errors.Is(err, ErrConfig) || errors.Is(err, ErrLocalIO) || errors.Is(err, ErrNoTargets) || errors.Is(err, ErrBlocked) && false) +} diff --git a/internal/execution/executor_test.go b/internal/execution/executor_test.go new file mode 100644 index 0000000..df08c5d --- /dev/null +++ b/internal/execution/executor_test.go @@ -0,0 +1,196 @@ +package execution + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/talkincode/sshx/internal/sshclient" +) + +type failDialer struct { + err error + active *atomic.Int64 + peak *atomic.Int64 + delay time.Duration + calls atomic.Int64 +} + +func (d *failDialer) Connect(cfg *sshclient.Config) (*sshclient.SSHClient, error) { + d.calls.Add(1) + if d.active != nil { + cur := d.active.Add(1) + defer d.active.Add(-1) + if d.peak != nil { + for { + max := d.peak.Load() + if cur <= max || d.peak.CompareAndSwap(max, cur) { + break + } + } + } + } + if d.delay > 0 { + time.Sleep(d.delay) + } + if d.err != nil { + return nil, d.err + } + return nil, fmt.Errorf("connect refused to %s", cfg.Host) +} + +func TestNormalizeRequest_BypassRequiresReason(t *testing.T) { + req := &Request{ + Action: ActionSpec{Kind: ActionCommand, Command: "uptime", Intent: IntentRead}, + Policy: Policy{SafetyCheckEnabled: true, SafetyBypass: true}, + Targets: TargetSelector{Names: []string{"a"}}, + } + if err := NormalizeRequest(req); err == nil { + t.Fatal("expected bypass reason error") + } + req.Policy.BypassReason = "approved change window" + if err := NormalizeRequest(req); err != nil { + t.Fatalf("NormalizeRequest: %v", err) + } +} + +func TestNormalizeRequest_MutualExclusiveInputs(t *testing.T) { + req := &Request{ + Action: ActionSpec{ + Kind: ActionScript, + ScriptPath: "a.sh", + Command: "echo hi", + Intent: IntentRead, + }, + Targets: TargetSelector{Names: []string{"a"}}, + } + if err := NormalizeRequest(req); err == nil { + t.Fatal("expected mutual exclusion error") + } +} + +func TestProcessExitCode(t *testing.T) { + if got := ProcessExitCode(RunCounts{Selected: 3, Succeeded: 3}, nil); got != 0 { + t.Fatalf("got %d want 0", got) + } + if got := ProcessExitCode(RunCounts{Selected: 3, Succeeded: 2, Failed: 1}, nil); got != 1 { + t.Fatalf("got %d want 1", got) + } + if got := ProcessExitCode(RunCounts{}, ErrNoTargets); got != 255 { + t.Fatalf("got %d want 255", got) + } +} + +func TestExecute_FailFastSkipsRemaining(t *testing.T) { + var active, peak atomic.Int64 + dialer := &failDialer{ + err: errors.New("connection refused"), + active: &active, + peak: &peak, + delay: 20 * time.Millisecond, + } + hosts := make([]ResolvedTarget, 8) + for i := range hosts { + hosts[i] = ResolvedTarget{Index: i, Alias: fmt.Sprintf("h%d", i), Address: fmt.Sprintf("10.0.0.%d", i+1), Port: "22", User: "u"} + } + req := &Request{ + Action: ActionSpec{Kind: ActionCommand, Command: "probe", Intent: IntentRead}, + Limits: Limits{Concurrency: 2, MaxOutputBytesPerTarget: DefaultMaxOutput}, + Policy: Policy{FailureMode: FailureFailFast, SafetyCheckEnabled: true, UseKeyAuth: true}, + } + var events []Event + collector := &collectEvents{fn: func(e Event) { events = append(events, e) }} + out, err := Execute(context.Background(), RunOptions{ + Request: req, + Snapshot: TargetSnapshot{Targets: hosts, Count: len(hosts), SelectorDigest: "x"}, + Dialer: dialer, + Events: collector, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if out.Counts.Selected != 8 { + t.Fatalf("selected=%d", out.Counts.Selected) + } + if out.Counts.Failed+out.Counts.Skipped != 8 { + t.Fatalf("counts=%+v", out.Counts) + } + if out.Counts.Skipped == 0 { + t.Fatalf("expected some skipped under fail_fast, got %+v", out.Counts) + } + // Every selected target must have a terminal result. + if len(out.Results) != 8 { + t.Fatalf("results=%d", len(out.Results)) + } + for _, r := range out.Results { + if r.Status == "" { + t.Fatalf("empty status: %#v", r) + } + } + if peak.Load() > 2 { + t.Fatalf("peak concurrency %d > 2", peak.Load()) + } + // JSONL shape: one started/finished pair envelope. + var started, finished int + for _, e := range events { + switch e.Kind { + case EventRunStarted: + started++ + case EventRunFinished: + finished++ + } + } + if started != 1 || finished != 1 { + t.Fatalf("run envelope started=%d finished=%d", started, finished) + } +} + +func TestBuildError_ChangeUncertainNeverSafeRetry(t *testing.T) { + info := BuildError(errors.New("timeout"), ErrorKindTimeout, IntentChange, CompletionPartial) + if info.Retryable { + t.Fatal("expected retryable=false") + } + if info.RetrySafety == RetrySafe { + t.Fatal("expected non-safe retry safety") + } +} + +func TestCompletionFor(t *testing.T) { + if got := CompletionFor(PhaseConnect, ErrorKindConnect, false, false); got != CompletionNotStarted { + t.Fatalf("got %s", got) + } + if got := CompletionFor(PhaseExecute, ErrorKindTimeout, true, false); got != CompletionPartial { + t.Fatalf("got %s", got) + } + if got := CompletionFor(PhaseCollect, ErrorKindExitMissing, true, false); got != CompletionCompletedUnconfirmed { + t.Fatalf("got %s", got) + } +} + +type collectEvents struct { + mu sync.Mutex + fn func(Event) +} + +func (c *collectEvents) WriteEvent(e Event) error { + c.mu.Lock() + defer c.mu.Unlock() + c.fn(e) + return nil +} + +func TestPublicPolicyOmitsPassword(t *testing.T) { + p := PublicPolicy(Policy{SSHPassword: "secret", SudoPasswordKey: "sudo"}) + if p.SSHPasswordProvided != true { + t.Fatal("expected password provided flag") + } + raw := fmt.Sprintf("%#v", p) + if strings.Contains(raw, "secret") { + t.Fatal("password leaked into public policy") + } +} diff --git a/internal/execution/payload.go b/internal/execution/payload.go new file mode 100644 index 0000000..a6d27de --- /dev/null +++ b/internal/execution/payload.go @@ -0,0 +1,73 @@ +package execution + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" +) + +// Payload holds a byte-preserving script body and its digest metadata. +type Payload struct { + Bytes []byte + SHA256 string + Size int +} + +// LoadScriptFile reads one local regular file as a script payload. +func LoadScriptFile(path string, maxBytes int) (Payload, error) { + if path == "" { + return Payload{}, fmt.Errorf("%w: script path is empty", ErrConfig) + } + if maxBytes <= 0 { + maxBytes = DefaultMaxPayload + } + info, err := os.Stat(path) + if err != nil { + return Payload{}, fmt.Errorf("%w: stat script file: %v", ErrLocalIO, err) + } + if !info.Mode().IsRegular() { + return Payload{}, fmt.Errorf("%w: script path must be a regular file", ErrLocalIO) + } + if info.Size() > int64(maxBytes) { + return Payload{}, fmt.Errorf("%w: script payload exceeds %d-byte limit", ErrLocalIO, maxBytes) + } + data, err := os.ReadFile(path) // #nosec G304 -- caller-provided local script path + if err != nil { + return Payload{}, fmt.Errorf("%w: read script file: %v", ErrLocalIO, err) + } + return digestPayload(data, maxBytes) +} + +// LoadScriptStdin reads process stdin as a script payload. +func LoadScriptStdin(r io.Reader, maxBytes int) (Payload, error) { + if r == nil { + return Payload{}, fmt.Errorf("%w: script stdin reader is nil", ErrConfig) + } + if maxBytes <= 0 { + maxBytes = DefaultMaxPayload + } + // Read one extra byte to detect oversized input without loading unbounded data. + limited := io.LimitReader(r, int64(maxBytes)+1) + data, err := io.ReadAll(limited) + if err != nil { + return Payload{}, fmt.Errorf("%w: read script stdin: %v", ErrLocalIO, err) + } + return digestPayload(data, maxBytes) +} + +func digestPayload(data []byte, maxBytes int) (Payload, error) { + if len(data) == 0 { + return Payload{}, fmt.Errorf("%w: script payload is empty", ErrConfig) + } + if len(data) > maxBytes { + return Payload{}, fmt.Errorf("%w: script payload exceeds %d-byte limit", ErrLocalIO, maxBytes) + } + sum := sha256.Sum256(data) + return Payload{ + Bytes: data, + SHA256: hex.EncodeToString(sum[:]), + Size: len(data), + }, nil +} diff --git a/internal/execution/payload_test.go b/internal/execution/payload_test.go new file mode 100644 index 0000000..0d4dbd2 --- /dev/null +++ b/internal/execution/payload_test.go @@ -0,0 +1,50 @@ +package execution + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadScriptFile_DigestAndLimit(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "script.sh") + body := []byte("printf '%s\\n' \"a b\" '$HOME' \"$(literal)\" \"你好\" \"*.log\"\n") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + p, err := LoadScriptFile(path, DefaultMaxPayload) + if err != nil { + t.Fatalf("LoadScriptFile: %v", err) + } + if !bytes.Equal(p.Bytes, body) { + t.Fatalf("payload bytes changed") + } + sum := sha256.Sum256(body) + if p.SHA256 != hex.EncodeToString(sum[:]) { + t.Fatalf("digest mismatch") + } + + if _, err := LoadScriptFile(path, 4); err == nil { + t.Fatal("expected oversized script failure") + } +} + +func TestLoadScriptStdin(t *testing.T) { + body := []byte("echo hello\n") + p, err := LoadScriptStdin(bytes.NewReader(body), DefaultMaxPayload) + if err != nil { + t.Fatalf("LoadScriptStdin: %v", err) + } + if !bytes.Equal(p.Bytes, body) { + t.Fatalf("stdin payload mismatch") + } + big := bytes.Repeat([]byte("x"), 32) + if _, err := LoadScriptStdin(bytes.NewReader(big), 16); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("expected limit error, got %v", err) + } +} diff --git a/internal/execution/selector.go b/internal/execution/selector.go new file mode 100644 index 0000000..ebd7239 --- /dev/null +++ b/internal/execution/selector.go @@ -0,0 +1,270 @@ +package execution + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "sort" + "strings" +) + +// HostRecord is the inventory shape required by selector resolution. +// The app package adapts settings HostConfig into this type. +type HostRecord struct { + Name string + Address string + Port string + User string + KeyPath string + SSHPasswordKey string + SudoPasswordKey string + Groups []string + Tags map[string]string +} + +// ResolveTargets freezes a deterministic target snapshot from configured hosts. +// +// Semantics: +// - names and groups form a candidate union +// - every tag predicate is an AND filter +// - if only tags are provided, all configured hosts are the candidate set +// - --all-hosts selects the full inventory before tag filters +// - multi-host selectors never accept literal addresses +// - zero matches is a request-level failure (returned as error) +func ResolveTargets(hosts []HostRecord, sel TargetSelector, defaults HostRecord) (TargetSnapshot, error) { + if err := validateSelector(sel); err != nil { + return TargetSnapshot{}, err + } + + byName := make(map[string]HostRecord, len(hosts)) + for _, h := range hosts { + byName[h.Name] = h + } + + // Explicit literal single-target address path. + if strings.TrimSpace(sel.Address) != "" { + port := sel.Port + if port == "" { + port = defaults.Port + } + if port == "" { + port = "22" + } + user := sel.User + if user == "" { + user = defaults.User + } + if user == "" { + user = "master" + } + target := ResolvedTarget{ + Index: 0, + Address: strings.TrimSpace(sel.Address), + Port: port, + User: user, + KeyPath: defaults.KeyPath, + SSHPasswordKey: defaults.SSHPasswordKey, + SudoPasswordKey: defaults.SudoPasswordKey, + Literal: true, + } + snap := TargetSnapshot{ + Targets: []ResolvedTarget{target}, + Count: 1, + } + snap.SelectorDigest = snapshotDigest(snap) + return snap, nil + } + + candidates := map[string]HostRecord{} + var skipped []SkippedTarget + + useUnion := len(sel.Names) > 0 || len(sel.Groups) > 0 || sel.AllHosts + onlyTags := !useUnion && len(sel.Tags) > 0 + + if sel.AllHosts || onlyTags { + for _, h := range hosts { + candidates[h.Name] = h + } + } + + for _, name := range sel.Names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + h, ok := byName[name] + if !ok { + skipped = append(skipped, SkippedTarget{Alias: name, Reason: "alias_not_found"}) + continue + } + candidates[h.Name] = h + } + + if len(sel.Groups) > 0 { + groupSet := map[string]struct{}{} + for _, g := range sel.Groups { + g = strings.TrimSpace(g) + if g != "" { + groupSet[g] = struct{}{} + } + } + for _, h := range hosts { + for _, g := range h.Groups { + if _, ok := groupSet[g]; ok { + candidates[h.Name] = h + break + } + } + } + } + + // Apply AND tag filters. + if len(sel.Tags) > 0 { + for name, h := range candidates { + if !matchAllTags(h.Tags, sel.Tags) { + delete(candidates, name) + } + } + } + + if len(candidates) == 0 { + snap := TargetSnapshot{Skipped: skipped, Count: 0} + snap.SelectorDigest = snapshotDigest(snap) + return snap, ErrNoTargets + } + + names := make([]string, 0, len(candidates)) + for name := range candidates { + names = append(names, name) + } + sort.Strings(names) + + targets := make([]ResolvedTarget, 0, len(names)) + for i, name := range names { + h := candidates[name] + port := h.Port + if port == "" { + port = "22" + } + user := h.User + if user == "" { + user = "master" + } + keyPath := h.KeyPath + if keyPath == "" { + keyPath = defaults.KeyPath + } + targets = append(targets, ResolvedTarget{ + Index: i, + Alias: h.Name, + Address: h.Address, + Port: port, + User: user, + KeyPath: keyPath, + SSHPasswordKey: firstNonEmpty(h.SSHPasswordKey, defaults.SSHPasswordKey), + SudoPasswordKey: firstNonEmpty(h.SudoPasswordKey, defaults.SudoPasswordKey), + Groups: append([]string(nil), h.Groups...), + Tags: copyTags(h.Tags), + }) + } + + sort.Slice(skipped, func(i, j int) bool { + return skipped[i].Alias < skipped[j].Alias + }) + + snap := TargetSnapshot{ + Targets: targets, + Skipped: skipped, + Count: len(targets), + } + snap.SelectorDigest = snapshotDigest(snap) + return snap, nil +} + +func validateSelector(sel TargetSelector) error { + hasMulti := len(sel.Names) > 0 || len(sel.Groups) > 0 || len(sel.Tags) > 0 || sel.AllHosts + hasLiteral := strings.TrimSpace(sel.Address) != "" + if hasLiteral && hasMulti { + return fmt.Errorf("%w: --address cannot combine with multi-host selectors", ErrConfig) + } + if !hasLiteral && !hasMulti { + return fmt.Errorf("%w: at least one target selector is required", ErrConfig) + } + // Multi-host selectors must not smuggle literal IPs through --target names. + for _, name := range sel.Names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if net.ParseIP(name) != nil { + return fmt.Errorf("%w: literal address %q requires --address, not --target", ErrConfig, name) + } + } + return nil +} + +func matchAllTags(have, want map[string]string) bool { + if len(want) == 0 { + return true + } + if have == nil { + return false + } + for k, v := range want { + if have[k] != v { + return false + } + } + return true +} + +func snapshotDigest(snap TargetSnapshot) string { + type digTarget struct { + Alias string `json:"alias,omitempty"` + Address string `json:"address"` + Port string `json:"port"` + User string `json:"user"` + } + type digSnap struct { + Targets []digTarget `json:"targets"` + Skipped []SkippedTarget `json:"skipped,omitempty"` + } + d := digSnap{Skipped: snap.Skipped} + for _, t := range snap.Targets { + d.Targets = append(d.Targets, digTarget{ + Alias: t.Alias, + Address: t.Address, + Port: t.Port, + User: t.User, + }) + } + raw, err := json.Marshal(d) + if err != nil { + sum := sha256.Sum256([]byte(fmt.Sprintf("%d", snap.Count))) + return hex.EncodeToString(sum[:]) + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +func copyTags(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/internal/execution/selector_test.go b/internal/execution/selector_test.go new file mode 100644 index 0000000..028531c --- /dev/null +++ b/internal/execution/selector_test.go @@ -0,0 +1,97 @@ +package execution + +import ( + "errors" + "testing" +) + +func sampleHosts() []HostRecord { + return []HostRecord{ + {Name: "prod-web-1", Address: "10.0.1.11", Port: "22", User: "deploy", Groups: []string{"prod-web"}, Tags: map[string]string{"env": "prod", "role": "web"}}, + {Name: "prod-web-2", Address: "10.0.1.12", Port: "22", User: "deploy", Groups: []string{"prod-web"}, Tags: map[string]string{"env": "prod", "role": "web"}}, + {Name: "prod-db-1", Address: "10.0.2.11", Port: "22", User: "deploy", Groups: []string{"prod-db"}, Tags: map[string]string{"env": "prod", "role": "db"}}, + {Name: "stage-web-1", Address: "10.0.3.11", Port: "22", User: "deploy", Groups: []string{"stage-web"}, Tags: map[string]string{"env": "stage", "role": "web"}}, + } +} + +func TestResolveTargets_GroupAndTagAND(t *testing.T) { + snap, err := ResolveTargets(sampleHosts(), TargetSelector{ + Groups: []string{"prod-web"}, + Tags: map[string]string{"env": "prod", "role": "web"}, + }, HostRecord{}) + if err != nil { + t.Fatalf("ResolveTargets error: %v", err) + } + if snap.Count != 2 { + t.Fatalf("expected 2 targets, got %d", snap.Count) + } + if snap.Targets[0].Alias != "prod-web-1" || snap.Targets[1].Alias != "prod-web-2" { + t.Fatalf("expected stable alias sort, got %#v", snap.Targets) + } + if snap.SelectorDigest == "" { + t.Fatal("expected selector digest") + } +} + +func TestResolveTargets_NamesUnionGroups(t *testing.T) { + snap, err := ResolveTargets(sampleHosts(), TargetSelector{ + Names: []string{"prod-db-1"}, + Groups: []string{"prod-web"}, + }, HostRecord{}) + if err != nil { + t.Fatalf("ResolveTargets error: %v", err) + } + if snap.Count != 3 { + t.Fatalf("expected 3 targets, got %d", snap.Count) + } +} + +func TestResolveTargets_ZeroMatches(t *testing.T) { + _, err := ResolveTargets(sampleHosts(), TargetSelector{ + Groups: []string{"missing"}, + }, HostRecord{}) + if !errors.Is(err, ErrNoTargets) { + t.Fatalf("expected ErrNoTargets, got %v", err) + } +} + +func TestResolveTargets_StrictAliasNoLiteralFallback(t *testing.T) { + _, err := ResolveTargets(sampleHosts(), TargetSelector{ + Names: []string{"10.0.1.11"}, + }, HostRecord{}) + if err == nil { + t.Fatal("expected error for literal IP in --target") + } +} + +func TestResolveTargets_LiteralAddress(t *testing.T) { + snap, err := ResolveTargets(sampleHosts(), TargetSelector{ + Address: "192.0.2.10", + Port: "2222", + User: "ops", + }, HostRecord{}) + if err != nil { + t.Fatalf("ResolveTargets error: %v", err) + } + if snap.Count != 1 || !snap.Targets[0].Literal { + t.Fatalf("unexpected snapshot: %#v", snap) + } + if snap.Targets[0].Address != "192.0.2.10" || snap.Targets[0].Port != "2222" { + t.Fatalf("unexpected target: %#v", snap.Targets[0]) + } +} + +func TestResolveTargets_MissingNameSkippedStillMatches(t *testing.T) { + snap, err := ResolveTargets(sampleHosts(), TargetSelector{ + Names: []string{"prod-web-1", "missing-host"}, + }, HostRecord{}) + if err != nil { + t.Fatalf("ResolveTargets error: %v", err) + } + if snap.Count != 1 { + t.Fatalf("expected 1 target, got %d", snap.Count) + } + if len(snap.Skipped) != 1 || snap.Skipped[0].Alias != "missing-host" { + t.Fatalf("expected missing-host skipped, got %#v", snap.Skipped) + } +} diff --git a/internal/execution/types.go b/internal/execution/types.go new file mode 100644 index 0000000..9fa1475 --- /dev/null +++ b/internal/execution/types.go @@ -0,0 +1,313 @@ +// Package execution defines the versioned agent execution contract used by +// sshx run and shared by compatibility adapters for single-host paths. +package execution + +import ( + "time" +) + +const ( + RequestSchemaVersion = "sshx.request.v1" + ResultSchemaVersion = "sshx.result.v1" + EventSchemaVersion = "sshx.event.v1" + + DefaultConcurrency = 4 + MaxConcurrency = 32 + DefaultMaxOutput = 10 << 20 // 10 MiB + DefaultMaxPayload = 10 << 20 // 10 MiB + + ActionCommand = "command" + ActionScript = "script" + ActionInspect = "inspect" + ActionSFTP = "sftp" + ActionTransfer = "transfer" + + IntentRead = "read" + IntentChange = "change" + IntentUnknown = "unknown" + + FailureContinue = "continue" + FailureFailFast = "fail_fast" + + StatusSucceeded = "succeeded" + StatusFailed = "failed" + StatusSkipped = "skipped" + + CompletionNotStarted = "not_started" + CompletionPartial = "partial" + CompletionCompleted = "completed" + CompletionCompletedUnconfirmed = "completed_unconfirmed" + CompletionUnknown = "unknown" + + PhaseResolve = "resolve" + PhaseAdmission = "admission" + PhaseConnect = "connect" + PhaseAuthenticate = "authenticate" + PhaseExecute = "execute" + PhaseCollect = "collect" + PhasePersist = "persist" + PhaseComplete = "complete" + + EventRunStarted = "run_started" + EventTargetStarted = "target_started" + EventTargetFinished = "target_finished" + EventRunFinished = "run_finished" + + RetrySafe = "safe" + RetryUnsafe = "unsafe" + RetryVerifyFirst = "verify_first" + RetryUnknown = "unknown" + + ErrorKindConnect = "connect" + ErrorKindAuth = "auth" + ErrorKindHostKey = "host_key" + ErrorKindBlocked = "blocked" + ErrorKindTimeout = "timeout" + ErrorKindRemoteExit = "remote_exit" + ErrorKindExitMissing = "exit_missing" + ErrorKindProtocol = "protocol" + ErrorKindConfig = "config" + ErrorKindLocalIO = "local_io" + ErrorKindRemoteIO = "remote_io" + ErrorKindUnknown = "unknown" + + ScriptRunnerSH = "sh" +) + +// TargetSelector describes how hosts are chosen for one execution request. +type TargetSelector struct { + Names []string `json:"names,omitempty"` + Groups []string `json:"groups,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + AllHosts bool `json:"all_hosts,omitempty"` + // Address is an explicit single-target literal address path. It may not + // combine with multi-host selectors. + Address string `json:"address,omitempty"` + Port string `json:"port,omitempty"` + User string `json:"user,omitempty"` +} + +// ActionSpec describes the single action admitted by one request. +type ActionSpec struct { + Kind string `json:"kind"` + Intent string `json:"intent"` + Command string `json:"command,omitempty"` + ScriptPath string `json:"script_path,omitempty"` + ScriptFromStdin bool `json:"script_from_stdin,omitempty"` + ScriptRunner string `json:"script_runner,omitempty"` + UseSudo bool `json:"use_sudo,omitempty"` + PayloadSHA256 string `json:"payload_sha256,omitempty"` + PayloadBytes int `json:"payload_bytes,omitempty"` + SftpAction string `json:"sftp_action,omitempty"` + LocalPath string `json:"local_path,omitempty"` + RemotePath string `json:"remote_path,omitempty"` +} + +// Limits bounds one process run. +type Limits struct { + Concurrency int `json:"concurrency"` + Timeout time.Duration `json:"timeout,omitempty"` + MaxOutputBytesPerTarget int `json:"max_output_bytes_per_target"` + MaxPayloadBytes int `json:"max_payload_bytes,omitempty"` +} + +// Policy captures high-risk decisions that must be explicit per request. +type Policy struct { + FailureMode string `json:"failure_mode"` + SafetyCheckEnabled bool `json:"safety_check_enabled"` + SafetyBypass bool `json:"safety_bypass"` + BypassReason string `json:"bypass_reason,omitempty"` + AcceptUnknownHost bool `json:"accept_unknown_host"` + AllowInsecureHostKey bool `json:"allow_insecure_host_key"` + KnownHostsPath string `json:"known_hosts_path,omitempty"` + UseKeyAuth bool `json:"use_key_auth"` + KeyPath string `json:"key_path,omitempty"` + // SSHPasswordKey is a typed keyring reference for SSH login only. + SSHPasswordKey string `json:"ssh_password_key,omitempty"` + // SudoPasswordKey is a typed keyring reference for sudo auto-fill only. + SudoPasswordKey string `json:"sudo_password_key,omitempty"` + // SSHPassword is an already-resolved login password (for example SSH_PASSWORD). + // It is never serialized into dry-run or audit payloads. + SSHPassword string `json:"-"` +} + +// Request is the versioned internal execution unit. +type Request struct { + SchemaVersion string `json:"schema_version"` + RequestID string `json:"request_id,omitempty"` + Targets TargetSelector `json:"targets"` + Action ActionSpec `json:"action"` + Limits Limits `json:"limits"` + Policy Policy `json:"policy"` + JSONOutput bool `json:"json_output,omitempty"` + JSONLOutput bool `json:"jsonl_output,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + AuditEnabled bool `json:"audit_enabled,omitempty"` + AuditOutput string `json:"audit_output,omitempty"` +} + +// ResolvedTarget is one frozen host from selector resolution. +type ResolvedTarget struct { + Index int `json:"index"` + Alias string `json:"alias,omitempty"` + Address string `json:"address"` + Port string `json:"port"` + User string `json:"user"` + KeyPath string `json:"key_path,omitempty"` + SSHPasswordKey string `json:"ssh_password_key,omitempty"` + SudoPasswordKey string `json:"sudo_password_key,omitempty"` + Groups []string `json:"groups,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + HostKeyFingerprint string `json:"host_key_fingerprint,omitempty"` + Literal bool `json:"literal,omitempty"` +} + +// SkippedTarget records a selector candidate that was not admitted. +type SkippedTarget struct { + Alias string `json:"alias,omitempty"` + Reason string `json:"reason"` +} + +// TargetSnapshot is the frozen, deterministic target set for one run. +type TargetSnapshot struct { + Targets []ResolvedTarget `json:"targets"` + Skipped []SkippedTarget `json:"skipped,omitempty"` + Count int `json:"count"` + SelectorDigest string `json:"selector_digest"` +} + +// ErrorInfo is the structured failure surface for one target or run. +type ErrorInfo struct { + Kind string `json:"kind"` + Message string `json:"message"` + Retryable bool `json:"retryable"` + RetrySafety string `json:"retry_safety"` +} + +// TargetResult is the finished-target document embedded in events and single-target results. +type TargetResult struct { + Target ResolvedTarget `json:"target"` + Action ActionSpec `json:"action"` + Status string `json:"status"` + Phase string `json:"phase"` + Completion string `json:"completion"` + ExitCode int `json:"exit_code"` + Error *ErrorInfo `json:"error,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + StdoutTruncated bool `json:"stdout_truncated,omitempty"` + StderrTruncated bool `json:"stderr_truncated,omitempty"` + DurationMs int64 `json:"duration_ms"` + AuthMethod string `json:"auth_method,omitempty"` +} + +// RunCounts summarizes a finished multi-target run. +type RunCounts struct { + Selected int `json:"selected"` + Started int `json:"started"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Uncertain int `json:"uncertain"` +} + +// Event is one JSONL stream record for multi-target runs. +type Event struct { + SchemaVersion string `json:"schema_version"` + RunID string `json:"run_id"` + RequestID string `json:"request_id,omitempty"` + Sequence int64 `json:"sequence"` + Kind string `json:"kind"` + Timestamp string `json:"timestamp"` + Target *ResolvedTarget `json:"target,omitempty"` + Result *TargetResult `json:"result,omitempty"` + Counts *RunCounts `json:"counts,omitempty"` + SelectorDigest string `json:"selector_digest,omitempty"` + Concurrency int `json:"concurrency,omitempty"` + FailureMode string `json:"failure_mode,omitempty"` + Action *ActionSpec `json:"action,omitempty"` + Error *ErrorInfo `json:"error,omitempty"` +} + +// Result is the single-target versioned document (and compatibility envelope). +type Result struct { + SchemaVersion string `json:"schema_version"` + RunID string `json:"run_id"` + RequestID string `json:"request_id,omitempty"` + Target ResolvedTarget `json:"target"` + Action ActionSpec `json:"action"` + Status string `json:"status"` + Phase string `json:"phase"` + Completion string `json:"completion"` + ExitCode int `json:"exit_code"` + Success bool `json:"success"` + Error *ErrorInfo `json:"error,omitempty"` + // Compatibility fields retained for current major version agents. + Host string `json:"host"` + Port string `json:"port"` + User string `json:"user"` + Command string `json:"command,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + StdoutTruncated bool `json:"stdout_truncated,omitempty"` + StderrTruncated bool `json:"stderr_truncated,omitempty"` + DurationMs int64 `json:"duration_ms"` + AuthMethod string `json:"auth_method,omitempty"` + // ErrorKind is a compatibility projection of Error.Kind for agents that + // still branch on the flat field from single-command JSON. + ErrorKind string `json:"error_kind,omitempty"` +} + +// DryRunPlan is the validated local plan for sshx run --dry-run. +type DryRunPlan struct { + SchemaVersion string `json:"schema_version"` + DryRun bool `json:"dry_run"` + Valid bool `json:"valid"` + RequestID string `json:"request_id,omitempty"` + Action ActionSpec `json:"action"` + Limits Limits `json:"limits"` + Policy PolicyPublic `json:"policy"` + Snapshot TargetSnapshot `json:"snapshot"` + WouldConnect bool `json:"would_connect"` + WouldExecute bool `json:"would_execute"` + WouldReadSecret bool `json:"would_read_secret"` + WouldWriteLocal bool `json:"would_write_local_state"` + WouldMutateRemote bool `json:"would_mutate_remote"` + MayMutateKnownHosts bool `json:"may_mutate_known_hosts"` + Notes []string `json:"notes,omitempty"` + Error *ErrorInfo `json:"error,omitempty"` +} + +// PolicyPublic is the audit/dry-run view of Policy without secret values. +type PolicyPublic struct { + FailureMode string `json:"failure_mode"` + SafetyCheckEnabled bool `json:"safety_check_enabled"` + SafetyBypass bool `json:"safety_bypass"` + BypassReason string `json:"bypass_reason,omitempty"` + AcceptUnknownHost bool `json:"accept_unknown_host"` + AllowInsecureHostKey bool `json:"allow_insecure_host_key"` + KnownHostsPath string `json:"known_hosts_path,omitempty"` + UseKeyAuth bool `json:"use_key_auth"` + KeyPath string `json:"key_path,omitempty"` + SSHPasswordKey string `json:"ssh_password_key,omitempty"` + SudoPasswordKey string `json:"sudo_password_key,omitempty"` + SSHPasswordProvided bool `json:"ssh_password_provided"` +} + +// PublicPolicy projects Policy without secret material. +func PublicPolicy(p Policy) PolicyPublic { + return PolicyPublic{ + FailureMode: p.FailureMode, + SafetyCheckEnabled: p.SafetyCheckEnabled, + SafetyBypass: p.SafetyBypass, + BypassReason: p.BypassReason, + AcceptUnknownHost: p.AcceptUnknownHost, + AllowInsecureHostKey: p.AllowInsecureHostKey, + KnownHostsPath: p.KnownHostsPath, + UseKeyAuth: p.UseKeyAuth, + KeyPath: p.KeyPath, + SSHPasswordKey: p.SSHPasswordKey, + SudoPasswordKey: p.SudoPasswordKey, + SSHPasswordProvided: p.SSHPassword != "", + } +} diff --git a/internal/execution/validate.go b/internal/execution/validate.go new file mode 100644 index 0000000..7c7a490 --- /dev/null +++ b/internal/execution/validate.go @@ -0,0 +1,212 @@ +package execution + +import ( + "fmt" + "strings" + + "github.com/talkincode/sshx/internal/sshclient" +) + +// NormalizeRequest fills defaults and validates the internal request shape. +// It does not resolve hosts or read secrets. +func NormalizeRequest(req *Request) error { + if req == nil { + return fmt.Errorf("%w: request is nil", ErrConfig) + } + if req.SchemaVersion == "" { + req.SchemaVersion = RequestSchemaVersion + } + if req.SchemaVersion != RequestSchemaVersion { + return fmt.Errorf("%w: unsupported request schema %q", ErrConfig, req.SchemaVersion) + } + + if req.Limits.Concurrency <= 0 { + req.Limits.Concurrency = DefaultConcurrency + } + if req.Limits.Concurrency > MaxConcurrency { + return fmt.Errorf("%w: concurrency %d exceeds hard maximum %d", ErrConfig, req.Limits.Concurrency, MaxConcurrency) + } + if req.Limits.MaxOutputBytesPerTarget <= 0 { + req.Limits.MaxOutputBytesPerTarget = DefaultMaxOutput + } + if req.Limits.MaxPayloadBytes <= 0 { + req.Limits.MaxPayloadBytes = DefaultMaxPayload + } + if req.Policy.FailureMode == "" { + req.Policy.FailureMode = FailureContinue + } + switch req.Policy.FailureMode { + case FailureContinue, FailureFailFast: + default: + return fmt.Errorf("%w: invalid failure mode %q", ErrConfig, req.Policy.FailureMode) + } + + if req.Action.Kind == "" { + return fmt.Errorf("%w: action kind is required", ErrConfig) + } + switch req.Action.Kind { + case ActionCommand, ActionScript, ActionInspect, ActionSFTP, ActionTransfer: + default: + return fmt.Errorf("%w: unsupported action kind %q", ErrConfig, req.Action.Kind) + } + + if req.Action.Intent == "" { + req.Action.Intent = IntentUnknown + } + switch req.Action.Intent { + case IntentRead, IntentChange, IntentUnknown: + default: + return fmt.Errorf("%w: invalid action intent %q", ErrConfig, req.Action.Intent) + } + + sources := 0 + if strings.TrimSpace(req.Action.Command) != "" { + sources++ + } + if req.Action.ScriptPath != "" { + sources++ + } + if req.Action.ScriptFromStdin { + sources++ + } + switch req.Action.Kind { + case ActionCommand: + if strings.TrimSpace(req.Action.Command) == "" { + return fmt.Errorf("%w: command action requires a command", ErrConfig) + } + if req.Action.ScriptPath != "" || req.Action.ScriptFromStdin { + return fmt.Errorf("%w: command action cannot combine with script input", ErrConfig) + } + case ActionScript: + if sources != 1 || strings.TrimSpace(req.Action.Command) != "" { + // script must have exactly one of file/stdin and no command text + n := 0 + if req.Action.ScriptPath != "" { + n++ + } + if req.Action.ScriptFromStdin { + n++ + } + if n != 1 { + return fmt.Errorf("%w: script action requires exactly one of --script-file or --script-stdin", ErrConfig) + } + if strings.TrimSpace(req.Action.Command) != "" { + return fmt.Errorf("%w: script action cannot combine with positional command input", ErrConfig) + } + } + if req.Action.ScriptRunner == "" { + req.Action.ScriptRunner = ScriptRunnerSH + } + if req.Action.ScriptRunner != ScriptRunnerSH { + return fmt.Errorf("%w: unsupported script runner %q (required: sh)", ErrConfig, req.Action.ScriptRunner) + } + } + + if req.Policy.SafetyBypass || !req.Policy.SafetyCheckEnabled { + if strings.TrimSpace(req.Policy.BypassReason) == "" { + return fmt.Errorf("%w: safety bypass requires a non-empty --bypass-reason", ErrConfig) + } + req.Policy.SafetyBypass = true + req.Policy.SafetyCheckEnabled = false + } + + return nil +} + +// SafetyCheck evaluates command/script safety without connecting. +func SafetyCheck(req *Request, payload []byte) error { + if req.Policy.SafetyBypass || !req.Policy.SafetyCheckEnabled { + return nil + } + switch req.Action.Kind { + case ActionCommand: + if err := sshclient.ValidateCommand(req.Action.Command); err != nil { + return fmt.Errorf("%w: %v", ErrBlocked, err) + } + case ActionScript: + // Best-effort scan of script text for the same destructive patterns. + if err := sshclient.ValidateCommand(string(payload)); err != nil { + return fmt.Errorf("%w: %v", ErrBlocked, err) + } + } + return nil +} + +// BuildDryRunPlan resolves selectors and reports effects without secrets/network. +func BuildDryRunPlan(req *Request, hosts []HostRecord, defaults HostRecord, payload *Payload) DryRunPlan { + plan := DryRunPlan{ + SchemaVersion: RequestSchemaVersion, + DryRun: true, + Valid: true, + RequestID: req.RequestID, + Action: req.Action, + Limits: req.Limits, + Policy: PublicPolicy(req.Policy), + Notes: []string{ + "dry-run does not connect, execute, read keyring secrets, mutate known_hosts, or write local/remote state", + }, + } + + if err := NormalizeRequest(req); err != nil { + plan.Valid = false + plan.Error = BuildError(err, ErrorKindConfig, req.Action.Intent, CompletionNotStarted) + return plan + } + plan.Action = req.Action + plan.Limits = req.Limits + plan.Policy = PublicPolicy(req.Policy) + + if payload != nil { + plan.Action.PayloadSHA256 = payload.SHA256 + plan.Action.PayloadBytes = payload.Size + } + + snap, err := ResolveTargets(hosts, req.Targets, defaults) + if err != nil { + plan.Valid = false + plan.Snapshot = snap + plan.Error = BuildError(err, ErrorKindConfig, req.Action.Intent, CompletionNotStarted) + return plan + } + plan.Snapshot = snap + + if err := SafetyCheck(req, payloadBytes(payload)); err != nil { + plan.Valid = false + plan.Error = BuildError(err, ErrorKindBlocked, req.Action.Intent, CompletionNotStarted) + // still report resolved snapshot for inspection + } + + plan.WouldConnect = plan.Valid && snap.Count > 0 + plan.WouldExecute = plan.WouldConnect + plan.WouldReadSecret = plan.WouldConnect && wouldReadSecret(req, snap) + plan.WouldMutateRemote = plan.WouldConnect && req.Action.Intent == IntentChange + plan.MayMutateKnownHosts = plan.WouldConnect && req.Policy.AcceptUnknownHost + plan.WouldWriteLocal = false + return plan +} + +func payloadBytes(p *Payload) []byte { + if p == nil { + return nil + } + return p.Bytes +} + +func wouldReadSecret(req *Request, snap TargetSnapshot) bool { + if req.Policy.SSHPasswordKey != "" || req.Policy.SSHPassword != "" { + return true + } + needSudo := req.Action.UseSudo || (req.Action.Kind == ActionCommand && sshclient.CommandUsesSudo(req.Action.Command)) + if !needSudo { + return false + } + if req.Policy.SudoPasswordKey != "" { + return true + } + for _, t := range snap.Targets { + if t.SudoPasswordKey != "" { + return true + } + } + return false +} diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index 11aa0f2..d7a9009 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -134,6 +134,26 @@ type Config struct { ArgumentError string ReportedErrorKind string ReportedError string + + // Run-mode execution contract fields (Mode == "run"). + RequestID string + RunTargets []string + RunGroups []string + RunTags map[string]string + RunAllHosts bool + RunAddress string + RunActionKind string + RunIntent string + RunUseSudo bool + RunConcurrency int + FailureMode string + BypassReason string + ScriptFile string + ScriptStdin bool + JSONLOutput bool + MaxOutputBytes int + MaxPayloadBytes int + SSHPasswordKey string } // SSHClient wraps one ssh.Client with execution and SFTP helpers. diff --git a/skills/sshx/SKILL.md b/skills/sshx/SKILL.md index 69bab4b..4b29cbb 100644 --- a/skills/sshx/SKILL.md +++ b/skills/sshx/SKILL.md @@ -14,6 +14,8 @@ its work, and exits — there is no daemon, shell, tunneling, or port forwarding ## When to use - Run a one-shot command on a remote host (optionally with `sudo`). +- Execute complex scripts byte-for-byte with `sshx run --script-file` / `--script-stdin`. +- Fan out one action to a bounded host set with `--group` / `--tag` / `--targets`. - Upload/download a file or list/make/remove remote paths over SFTP. - Manage frequently used hosts by short name (`~/.sshx/settings.json`). - Store/fetch SSH or sudo passwords in the OS keyring (never plaintext). @@ -62,29 +64,37 @@ only through the SSH session's stdin. Branch on observation `status` (`complete|partial|unsupported|failed`) and cache `hit/stale` fields. Never interpret `partial` or permission errors as application absence. -## Golden rule for agents: use `--json` +## Golden rule for agents: prefer `sshx run` + `--json`/`--jsonl` -For any non-interactive/programmatic use, **always pass `--json`** in command mode. -It emits exactly one JSON object on stdout; diagnostic logs go to stderr, so stdout -stays a clean machine-readable stream. `--json` cannot be combined with `--pty`. +For any non-interactive/programmatic use, prefer the canonical run contract: ```bash -sshx -h=prod-web --json "systemctl is-active nginx" +sshx run --target=prod-web --json -- "systemctl is-active nginx" +sshx run --group=prod-web --tag=env=prod --concurrency=4 --jsonl -- "uptime" +sshx run --target=prod-web --script-file=./check.sh --json ``` -JSON result fields: +Compatibility mode `sshx -h=prod-web --json "cmd"` still works and emits the +legacy single-object shape. `sshx run --json` adds versioned fields +(`schema_version`, `run_id`, `status`, `phase`, `completion`, structured +`error`). Multi-target runs stream JSONL events: +`run_started` → `target_started`/`target_finished` → `run_finished`. -```json -{ - "host": "...", "port": "22", "user": "...", "command": "...", - "exit_code": 0, "success": true, "stdout": "...", "stderr": "...", - "stdout_truncated": false, "stderr_truncated": false, - "duration_ms": 0, "auth_method": "key|password|...", - "error_kind": "", "error": "" -} -``` +Branch on `success` / `status` first; on failure read `error.kind` or +`error_kind` (do not parse free-form text). For change actions, inspect +`completion` before any retry (`not_started|partial|completed|completed_unconfirmed|unknown`). + +Selectors resolve only configured host aliases. Literal addresses require +`--address=` and cannot enter group/tag fan-out. Zero matches is exit `255` +with no network access. + +Safety/force/host-key relaxations require explicit CLI flags plus +`--bypass-reason=` on `sshx run`. Inherited `SSH_FORCE` / +`SSH_NO_SAFETY_CHECK` / `SSH_INSECURE_HOST_KEY` / `SSH_ACCEPT_UNKNOWN_HOST` +and working-directory `.env` files do not authorize bypasses. -Branch on `success` first; on failure read `error_kind` (do not parse free-form text). +SSH login secrets use `--ssh-password-key` / `ssh_password_key`. Sudo secrets +use `-pk` / `sudo_password_key` (legacy `password_key` is sudo-only). ## Preview before executing: use `--dry-run --json` diff --git a/tests/e2e/run_e2e_test.go b/tests/e2e/run_e2e_test.go new file mode 100644 index 0000000..e627471 --- /dev/null +++ b/tests/e2e/run_e2e_test.go @@ -0,0 +1,340 @@ +package e2e + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunScriptByteFidelity(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + // Use a quoted here-doc style body so remote sh prints literals without expanding them. + script := "cat <<'EOF'\na b\n$HOME\n$(literal)\n你好\n*.log\nback\\slash\nEOF\n" + scriptPath := filepath.Join(home, "payload.sh") + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o600)) + sum := sha256.Sum256([]byte(script)) + digest := hex.EncodeToString(sum[:]) + + // Dry-run exposes digest without connecting. + dry := runSSHX(t, home, []string{ + "run", + "--address=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--script-file=" + scriptPath, + "--dry-run", + "--json", + }, nil) + require.Equal(t, 0, dry.exitCode, dry.stderr) + var dryPlan map[string]any + require.NoError(t, json.Unmarshal([]byte(dry.stdout), &dryPlan)) + assert.Equal(t, true, dryPlan["valid"]) + assert.Equal(t, true, dryPlan["would_connect"]) + // Dry-run must not read secrets; without SSH_PASSWORD/key refs it reports no secret read. + assert.Equal(t, false, dryPlan["would_read_secret"]) + action, ok := dryPlan["action"].(map[string]any) + require.True(t, ok) + assert.Equal(t, digest, action["payload_sha256"]) + + result := runSSHX(t, home, []string{ + "run", + "--address=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--accept-unknown-host", + "--script-file=" + scriptPath, + "--json", + }, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 0, result.exitCode, "stderr=%s stdout=%s", result.stderr, result.stdout) + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(result.stdout), &payload)) + assert.Equal(t, true, payload["success"]) + stdout, ok := payload["stdout"].(string) + require.True(t, ok) + assert.Contains(t, stdout, "a b") + assert.Contains(t, stdout, "$HOME") + assert.Contains(t, stdout, "$(literal)") + assert.Contains(t, stdout, "你好") + assert.Contains(t, stdout, "*.log") + assert.Contains(t, stdout, `back\slash`) + action2, ok := payload["action"].(map[string]any) + require.True(t, ok) + assert.Equal(t, digest, action2["payload_sha256"]) +} + +func TestRunMultiHostJSONLAndSelectors(t *testing.T) { + const n = 8 + servers := make([]*testSSHServer, n) + home := t.TempDir() + hosts := make([]map[string]any, 0, n) + for i := 0; i < n; i++ { + servers[i] = startSSHServer(t, serverOptions{}) + name := fmt.Sprintf("node-%02d", i) + group := "fleet" + if i%2 == 0 { + group = "even" + } + hosts = append(hosts, map[string]any{ + "name": name, + "host": servers[i].host, + "port": servers[i].port, + "user": "operator", + "groups": []string{group, "fleet"}, + "tags": map[string]string{ + "env": "test", + "role": fmt.Sprintf("r%d", i%2), + }, + }) + } + settingsPath := filepath.Join(home, ".sshx", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0o700)) + raw, err := json.MarshalIndent(map[string]any{"hosts": hosts}, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(settingsPath, raw, 0o600)) + + // zero matches fails before connect + zero := runSSHX(t, home, []string{ + "run", "--group=missing", "--json", "--", "probe", + }, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 255, zero.exitCode, zero.stderr+zero.stdout) + + start := time.Now() + result := runSSHX(t, home, []string{ + "run", + "--group=fleet", + "--tag=env=test", + "--concurrency=4", + "--failure-mode=continue", + "--no-key", + "--accept-unknown-host", + "--jsonl", + "--", + "probe", + }, map[string]string{"SSH_PASSWORD": operatorPassword}) + elapsed := time.Since(start) + require.Equal(t, 0, result.exitCode, "stderr=%s stdout=%s", result.stderr, result.stdout) + + events := parseJSONL(t, result.stdout) + require.GreaterOrEqual(t, len(events), 2+n) + assert.Equal(t, "run_started", events[0]["kind"]) + assert.Equal(t, "run_finished", events[len(events)-1]["kind"]) + // Sequence numbers are monotonic, but events may complete out of target-index order. + seen := map[int64]bool{} + var prev int64 + for i, ev := range events { + s, ok := ev["sequence"].(float64) + require.True(t, ok) + seq := int64(s) + require.False(t, seen[seq], "duplicate sequence %d", seq) + seen[seq] = true + if i > 0 { + require.Greater(t, seq, prev, "sequence must increase in stream order") + } + prev = seq + } + finished := events[len(events)-1] + counts, ok := finished["counts"].(map[string]any) + require.True(t, ok) + assert.EqualValues(t, n, counts["selected"]) + assert.EqualValues(t, n, counts["succeeded"]) + + // Bounded fan-out should finish faster than serial sleep-equivalent; with + // near-instant probe this is a smoke check that the path completes. + assert.Less(t, elapsed, 15*time.Second) + + // tag AND filters to role=r0 (even indexes) + filtered := runSSHX(t, home, []string{ + "run", + "--group=fleet", + "--tag=env=test", + "--tag=role=r0", + "--concurrency=4", + "--no-key", + "--accept-unknown-host", + "--jsonl", + "--", + "probe", + }, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 0, filtered.exitCode, filtered.stderr+filtered.stdout) + fevents := parseJSONL(t, filtered.stdout) + fc, ok := fevents[len(fevents)-1]["counts"].(map[string]any) + require.True(t, ok) + assert.EqualValues(t, n/2, fc["selected"]) +} + +func TestRunBoundedFanOutThirtyTwoHosts(t *testing.T) { + const n = 32 + home := t.TempDir() + servers := make([]*testSSHServer, n) + hosts := make([]map[string]any, 0, n) + for i := 0; i < n; i++ { + servers[i] = startSSHServer(t, serverOptions{}) + hosts = append(hosts, map[string]any{ + "name": fmt.Sprintf("bulk-%02d", i), + "host": servers[i].host, + "port": servers[i].port, + "user": "operator", + "groups": []string{"bulk"}, + }) + } + writeSettings(t, home, map[string]any{"hosts": hosts}) + + for _, concurrency := range []int{1, 4, 8, 32} { + concurrency := concurrency + t.Run(fmt.Sprintf("c%d", concurrency), func(t *testing.T) { + start := time.Now() + result := runSSHX(t, home, []string{ + "run", + "--group=bulk", + fmt.Sprintf("--concurrency=%d", concurrency), + "--no-key", + "--accept-unknown-host", + "--jsonl", + "--", + "probe", + }, map[string]string{"SSH_PASSWORD": operatorPassword}) + elapsed := time.Since(start) + require.Equal(t, 0, result.exitCode, result.stderr+result.stdout) + events := parseJSONL(t, result.stdout) + counts, ok := events[len(events)-1]["counts"].(map[string]any) + require.True(t, ok) + assert.EqualValues(t, n, counts["selected"]) + assert.EqualValues(t, n, counts["succeeded"]) + t.Logf("concurrency=%d elapsed=%s", concurrency, elapsed) + if concurrency == 1 { + // Store baseline wall time via t.Log; higher concurrency should not be dramatically slower. + return + } + assert.Less(t, elapsed, 20*time.Second) + }) + } +} + +func TestRunFailFastAndPartialFailure(t *testing.T) { + okServer := startSSHServer(t, serverOptions{}) + badHome := t.TempDir() + // bad host points at closed port + settings := map[string]any{ + "hosts": []map[string]any{ + {"name": "ok", "host": okServer.host, "port": okServer.port, "user": "operator", "groups": []string{"g"}}, + {"name": "bad", "host": "127.0.0.1", "port": "1", "user": "operator", "groups": []string{"g"}}, + {"name": "ok2", "host": okServer.host, "port": okServer.port, "user": "operator", "groups": []string{"g"}}, + }, + } + writeSettings(t, badHome, settings) + + result := runSSHX(t, badHome, []string{ + "run", + "--group=g", + "--concurrency=1", + "--failure-mode=fail_fast", + "--no-key", + "--accept-unknown-host", + "--jsonl", + "--", + "probe", + }, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 1, result.exitCode, result.stderr+result.stdout) + events := parseJSONL(t, result.stdout) + finished := events[len(events)-1] + counts, ok := finished["counts"].(map[string]any) + require.True(t, ok) + assert.EqualValues(t, 3, counts["selected"]) + // At least one skipped or failed under fail_fast. + failed, ok := counts["failed"].(float64) + require.True(t, ok) + skipped, ok := counts["skipped"].(float64) + require.True(t, ok) + assert.Greater(t, failed+skipped, 0.0) +} + +func TestRunIgnoresHighRiskEnvAndDotenv(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + // Repository-local .env must not authorize force/safety bypass. + require.NoError(t, os.WriteFile(filepath.Join(home, ".env"), []byte("SSH_FORCE=true\nSSH_NO_SAFETY_CHECK=true\n"), 0o600)) + + blocked := runSSHX(t, home, []string{ + "run", + "--address=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--accept-unknown-host", + "--json", + "--", + "rm -rf /", + }, map[string]string{ + "SSH_PASSWORD": operatorPassword, + "SSH_FORCE": "true", + "SSH_NO_SAFETY_CHECK": "true", + "SSH_INSECURE_HOST_KEY": "true", + }) + // Request-level blocked/config failure. + require.NotEqual(t, 0, blocked.exitCode, blocked.stdout+blocked.stderr) + assert.True(t, + strings.Contains(blocked.stdout, "blocked") || + strings.Contains(blocked.stderr, "blocked") || + strings.Contains(blocked.stdout, "bypass"), + "stdout=%s stderr=%s", blocked.stdout, blocked.stderr, + ) +} + +func TestRunOversizedScriptFailsBeforeConnect(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + scriptPath := filepath.Join(home, "big.sh") + require.NoError(t, os.WriteFile(scriptPath, []byte(strings.Repeat("x", 64)), 0o600)) + before := server.connections.Load() + result := runSSHX(t, home, []string{ + "run", + "--address=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--script-file=" + scriptPath, + "--max-payload-bytes=16", + "--json", + }, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 255, result.exitCode, result.stdout+result.stderr) + assert.Equal(t, before, server.connections.Load(), "must not connect for oversized payload") +} + +func parseJSONL(t *testing.T, raw string) []map[string]any { + t.Helper() + var events []map[string]any + sc := bufio.NewScanner(strings.NewReader(raw)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var ev map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &ev), line) + events = append(events, ev) + } + require.NoError(t, sc.Err()) + return events +} + +func writeSettings(t *testing.T, home string, settings map[string]any) { + t.Helper() + path := filepath.Join(home, ".sshx", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + raw, err := json.MarshalIndent(settings, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, raw, 0o600)) +}