diff --git a/AGENT.md b/AGENT.md index 2e94b28..1c6b406 100644 --- a/AGENT.md +++ b/AGENT.md @@ -71,7 +71,8 @@ the project's mission: installed on managed hosts and do not turn sshx into a fleet control plane. - ❌ **Desired-state configuration / workflow orchestration** — bounded fan-out execution is in scope; playbooks, schedulers, reconciliation, and long-lived - workflow state are not. + workflow state are not. `sshx apply` may replace one file; it must not + validate-and-reload a service in the same invocation. - ❌ **GUI / TUI** — interaction is through flags and stdout/stderr only. - ❌ **Full OpenSSH replacement** — no interactive login shell multiplexing, port forwarding / tunneling, SOCKS proxy, X11 forwarding, or agent forwarding. @@ -87,10 +88,13 @@ actions, bounded multi-host execution, password/secret references, named host management, authentication UX, safety checks, auditing, and cross-platform correctness. Read-only host inspection, local plugin lifecycle, explicit plugin trust, and bounded observation reuse are also in scope. Guarded SQL execution -(`sshx sql`) is a deliberate scope expansion: statements run through the -database client already present on the remote host (psql or sqlite3), sshx -embeds no database driver, opens no tunnel, and keeps the one-shot -connect–execute–exit model. +(`sshx sql`) and guarded file apply (`sshx apply`) are deliberate scope +expansions: they absorb mutation risk (classify → precondition → backup → +atomic change → structured result) without becoming a workflow engine. + +**Convergence test:** every new sshx feature must remove an Agent judgment, not +add a command the Agent has to learn. Absorb remote tax (host, credential, +sudo, timeout, error class, backup). Do not wrap local Unix tools as new verbs. ## 4. Architecture @@ -113,6 +117,7 @@ internal/app/ → CLI surface (argument parsing, routing, sub-comman plugin.go → local plugin create/list/show/validate/test/trust/remove inspect.go → one-shot capability execution + observation caching sql.go → sshx sql: guarded SQL pipeline (classify → gate → explain → backup → execute) + apply.go → sshx apply: guarded single-file mutation (hash → backup → atomic write) internal/execution/ → versioned request/result model, selectors, executor internal/plugin/ → manifests, schemas, scaffolds, trust, built-ins internal/runtimepath/ → ~/.sshx / SSHX_HOME runtime-root resolution @@ -142,6 +147,7 @@ skills/ → canonical Agent skill plus its embedded asset packa | `plugin` | `sshx plugin ` | manage local inspection plugins | | `inspect` | `sshx inspect ... ` | collect/reuse one host observation | | `sql` | `sshx sql ... ""` | guarded SQL via remote psql or sqlite3 | +| `apply` | `sshx apply --path= --from=` | guarded single-file remote replace | ### State & storage diff --git a/CHANGELOG.md b/CHANGELOG.md index fff3ce2..d2ee21a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-08-16 + +### Added + +- Add guarded single-file apply through `sshx apply --path= --from=`. + The pipeline checks an optional `--expect-sha256` precondition, writes an + owner-only backup, atomically replaces a regular file while preserving mode + and owner, and returns `changed`, hashes, and `rollback_available`. `--sudo` + stages the payload over SFTP and installs with a privileged stdin script. + Reload/restart stays outside the command. + +### Security + +- Refuse apply to symlinks, directories, and critical identity files + (`/etc/passwd`, `/etc/shadow`, `/etc/sudoers`) unless `--force --bypass-reason=` + is explicit. `--no-backup` requires `--force`. + ## [0.5.0] - 2026-08-15 ### Added @@ -389,7 +406,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI/CD workflow and automated release process - Tag creation script -[Unreleased]: https://github.com/talkincode/sshx/compare/v0.4.1...HEAD +[Unreleased]: https://github.com/talkincode/sshx/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/talkincode/sshx/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/talkincode/sshx/compare/v0.4.1...v0.5.0 [0.4.1]: https://github.com/talkincode/sshx/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/talkincode/sshx/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/talkincode/sshx/compare/v0.2.0...v0.3.0 diff --git a/README.md b/README.md index a560e35..dbee154 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ It remains a single binary with one-shot invocations and no resident component o 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. +10. Guarded single-file apply: hash precondition, backup, and atomic replace. ## Installation diff --git a/README_CN.md b/README_CN.md index 7b42336..a103185 100644 --- a/README_CN.md +++ b/README_CN.md @@ -72,6 +72,7 @@ Agent 需要的不是另一个交互式 SSH shell,而是一份稳定、可组 8. 服务器到服务器直接文件传输,数据经本机流式中转而不落地。 9. 单次主机环境探测:内置系统/网络能力,应用级插件归 sshx 本地运行目录管理, 支持摘要信任和有有效期的观察快照。 +10. 受控单文件 apply:哈希前置条件、备份和原子替换。 ## 安装 diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 55d06b1..21f866d 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -4,6 +4,7 @@ - [Getting Started](getting-started.md) - [Host Management](host-management.md) - [SFTP Workflows](sftp.md) + - [Guarded File Apply](apply.md) - [Agent and Script Mode](agent-scripting.md) - [Inspection Capabilities and Local Plugins](inspection-plugins.md) - [Usage Scenarios](usage-scenarios.md) @@ -13,6 +14,7 @@ - [快速开始](zh/getting-started.md) - [主机管理](zh/host-management.md) - [SFTP 工作流](zh/sftp.md) + - [受控文件 Apply](zh/apply.md) - [Agent 与脚本模式](zh/agent-scripting.md) - [主机探测能力与本地插件](zh/inspection-plugins.md) - [使用场景](zh/usage-scenarios.md) diff --git a/docs/agent-scripting.md b/docs/agent-scripting.md index 93dc01b..670dc09 100644 --- a/docs/agent-scripting.md +++ b/docs/agent-scripting.md @@ -76,6 +76,19 @@ else fi ``` +## Guarded File Apply + +Prefer `sshx apply` when replacing one remote regular file. Branch on +`changed`, `completion`, and `error_kind`. A `precondition` failure means the +file was not written. + +```bash +sshx apply --target=prod-web --path=/etc/nginx/nginx.conf \ + --from=./nginx.conf --expect-sha256="$current" --sudo --json +``` + +Reload stays a separate `sshx run`. See [Guarded File Apply](apply.md). + ## Reusable Host Inspection Before repeating a chain of discovery commands, list and run a bounded diff --git a/docs/apply.md b/docs/apply.md new file mode 100644 index 0000000..8628c26 --- /dev/null +++ b/docs/apply.md @@ -0,0 +1,47 @@ +# Guarded File Apply + +`sshx apply` replaces one remote regular file. It is the file equivalent of `sshx sql`: classify the target, check a hash precondition, write a backup, then atomically replace the file. Reload and restart stay outside this command. + +```bash +sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf \ + --expect-sha256= --sudo --json +``` + +## What Apply Does + +1. Refuse anything that is not a clean absolute regular-file path. +2. Block `/etc/passwd`, `/etc/shadow`, and `/etc/sudoers` unless `--force --bypass-reason=` is explicit. +3. Read the current file (if it exists) and compare `--expect-sha256` when provided. +4. Copy the current file to `~/.sshx/file-backups/` unless `--no-backup --force` is set. +5. Write a same-directory temp file, preserve mode and owner, then rename over the target. +6. Return `changed`, `before_sha256`, `after_sha256`, `backup.path`, and `completion`. + +If the remote content already matches the payload, apply succeeds with `changed=false` and does not write a backup. + +## Privileged Paths + +SFTP runs as the SSH user. Use `--sudo` when the target is not writable by that user. sshx stages the payload under the remote home directory, then runs a privileged stdin script to install it. The script is never left on the host. + +```bash +sshx apply --target=prod-web --path=/etc/nginx/nginx.conf \ + --from=./nginx.conf --sudo --json +``` + +Validation and service reload are separate `sshx run` invocations: + +```bash +sshx run --target=prod-web --json -- "sudo nginx -t" +sshx run --target=prod-web --json -- "sudo systemctl reload nginx" +``` + +## Preview + +```bash +sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf --dry-run --json +``` + +Dry-run hashes the local file and prints the local plan. It does not connect or mutate the remote file. + +## When To Keep Using SFTP + +Use `--upload` / `--download` for moving bytes without a backup contract. Use `apply` when an existing remote file may be overwritten and the caller needs a hash, a backup, and a decidable `changed` result. diff --git a/docs/index.md b/docs/index.md index 377450b..cf8880f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,7 @@ The documentation starts in English by default. Use the language switch in the t - Save sudo passwords in the operating system keyring instead of plaintext files. - Use short host names from `~/.sshx/settings.json` instead of repeating IP, port, user, and key paths. - Perform small SFTP tasks without opening an interactive client. +- Replace one remote regular file with a hash, backup, and atomic write. - Produce JSON output that scripts and AI agents can branch on. - Preview local execution plans with `--dry-run` before connecting, reading secrets, mutating `known_hosts`, or writing host config. - Keep a local JSONL audit trail without recording plaintext passwords, private keys, stdout, or stderr. @@ -82,3 +83,4 @@ Read [Security Guidelines](security-guidelines.md) before using `sshx` in produc - [Agent and Script Mode](agent-scripting.md) explains JSON output, exit codes, timeouts, and audit logs. - [Inspection Capabilities and Local Plugins](inspection-plugins.md) explains built-ins, `plugin create`, trust, and observations. - [SFTP Workflows](sftp.md) covers upload, download, list, mkdir, and remove. +- [Guarded File Apply](apply.md) replaces one remote file with backup and hash checks. diff --git a/docs/roadmap.md b/docs/roadmap.md index e1fcbf9..e1c2891 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -121,6 +121,10 @@ Agent / 自动化 / 人类运维者 `sshx sql` 通过远端已有的 `psql` 或 `sqlite3` 执行恰好一条语句:本地 fail-closed 分类、策略门闩、变更前备份、结构化 JSON 与审计。PostgreSQL 另有 EXPLAIN 行数估计、表锁事务备份和容器凭据发现;SQLite 以绝对文件路径为身份,只读走 `file:?mode=ro`,变更在 `BEGIN IMMEDIATE` 下做表 CSV 或整文件 `.backup`。证据:`internal/app/sql.go`、`internal/sqlsafe/`、`tests/e2e/sql_sqlite_e2e_test.go`。 +- **受控文件 Apply** + + `sshx apply` 替换一个远程正则文件:绝对路径门闩、可选 `--expect-sha256` 前置条件、默认 owner-only 备份、同目录临时文件 + rename、保留权限/所有者。`--sudo` 先经 SFTP 暂存再特权安装。不包含 nginx -t 或 reload。证据:`internal/app/apply.go`、`internal/sshclient/apply.go`、`tests/e2e/apply_e2e_test.go`。 + - **本地结构化审计** 非 dry-run 调用默认写入本地 JSONL 审计事件,记录目标、动作、安全上下文、结果和耗时,排除 stdout/stderr,并对命令中的 secret-like 参数做尽力脱敏。证据:`internal/app/audit.go`、`internal/app/audit_test.go`。 @@ -245,5 +249,6 @@ Agent / 自动化 / 人类运维者 | 有界多主机执行 | 高 | 是 | 可能,多主机 | ✅ `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` | | 受控 SQL 执行(PostgreSQL / SQLite) | 高 | 是 | 是,远端库 | ✅ sqlite 只读查询与带备份 UPDATE | ✅ 直连客户端阻断、ATTACH 分类拒绝、缺路径 | ✅ operator 密码角色 | ✅ UPDATE 前 CSV 可还原旧值 | `tests/e2e/sql_sqlite_e2e_test.go`、`internal/sqlsafe/*_test.go`、`internal/app/sql_test.go` | +| 受控文件 Apply | 高 | 是 | 是,远端文件 | ✅ 创建/覆盖/幂等 | ✅ 哈希不匹配、符号链接、只读端 | ✅ operator/reader | ✅ 覆盖前备份可还原旧值 | `tests/e2e/apply_e2e_test.go`、`internal/app/apply_test.go`、`internal/sshclient/apply_test.go` | 当前已达到已实现一级能力的覆盖底线。表中的剩余红项属于尚未实现的方向能力,而不是用组件测试掩盖的既有质量债。未来任何一级能力不得只以参数解析或组件测试作为完成依据;必须沿用编译后二进制边界补充 E2E,并同步更新本矩阵。 diff --git a/docs/sftp.md b/docs/sftp.md index 378a02e..d08664b 100644 --- a/docs/sftp.md +++ b/docs/sftp.md @@ -8,16 +8,11 @@ sshx -h=prod-web --upload=./deploy/nginx.conf --to=/tmp/nginx.conf ``` -Safe production pattern: +To overwrite an existing remote file with a backup and hash precondition, use [Guarded File Apply](apply.md) instead of assembling upload + `install` yourself: ```bash -# Upload to a temporary path first -sshx -h=prod-web --upload=./deploy/nginx.conf --to=/tmp/nginx.conf - -# Inspect the uploaded file before moving it into place -sshx -h=prod-web "sudo install -m 0644 /tmp/nginx.conf /etc/nginx/nginx.conf" -sshx -h=prod-web "sudo nginx -t" -sshx -h=prod-web "sudo systemctl reload nginx" +sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./deploy/nginx.conf --sudo --json +sshx run --target=prod-web --json -- "sudo nginx -t" ``` ## Download A File diff --git a/docs/zh/agent-scripting.md b/docs/zh/agent-scripting.md index 02fd9b5..14f577a 100644 --- a/docs/zh/agent-scripting.md +++ b/docs/zh/agent-scripting.md @@ -74,6 +74,18 @@ else fi ``` +## 受控文件 Apply + +覆盖一个远程正则文件时优先用 `sshx apply`。根据 `changed`、`completion` 和 +`error_kind` 分支。`precondition` 表示文件没有被写入。 + +```bash +sshx apply --target=prod-web --path=/etc/nginx/nginx.conf \ + --from=./nginx.conf --expect-sha256="$current" --sudo --json +``` + +reload 仍是另一次 `sshx run`。详见 [受控文件 Apply](apply.md)。 + ## 可复用主机探测 在重复执行一串环境发现命令前,先列出并调用有界探测能力: diff --git a/docs/zh/apply.md b/docs/zh/apply.md new file mode 100644 index 0000000..a52fddb --- /dev/null +++ b/docs/zh/apply.md @@ -0,0 +1,42 @@ +# 受控文件 Apply + +`sshx apply` 替换一个远程正则文件。它是文件版的 `sshx sql`:判断目标、检查哈希前置条件、写备份,然后原子替换。服务校验和 reload 不属于这条命令。 + +```bash +sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf \ + --expect-sha256= --sudo --json +``` + +## Apply 做什么 + +1. 拒绝非干净绝对路径、目录、符号链接和设备节点。 +2. 默认阻断 `/etc/passwd`、`/etc/shadow`、`/etc/sudoers`,除非显式 `--force --bypass-reason=`。 +3. 读取现有文件,并在提供 `--expect-sha256` 时做前置校验。 +4. 除非 `--no-backup --force`,否则把原文复制到 `~/.sshx/file-backups/`。 +5. 在同目录写临时文件,保留权限和所有者,再 rename 覆盖目标。 +6. 返回 `changed`、`before_sha256`、`after_sha256`、`backup.path` 和 `completion`。 + +如果远程内容已经与 payload 一致,apply 以 `changed=false` 成功,且不写备份。 + +## 特权路径 + +SFTP 以 SSH 用户身份运行。目标对该用户不可写时使用 `--sudo`。sshx 先把 payload 暂存到远端 home,再通过 stdin 执行特权安装脚本;脚本不会留在主机上。 + +校验和 reload 用另一次 `sshx run`: + +```bash +sshx run --target=prod-web --json -- "sudo nginx -t" +sshx run --target=prod-web --json -- "sudo systemctl reload nginx" +``` + +## 预览 + +```bash +sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf --dry-run --json +``` + +dry-run 只哈希本地文件并打印本地计划,不连接、不改远程文件。 + +## 什么时候继续用 SFTP + +只搬字节、不需要备份合同时用 `--upload` / `--download`。会覆盖已有远程文件、需要哈希、备份和可判定的 `changed` 时用 `apply`。 diff --git a/docs/zh/index.md b/docs/zh/index.md index 9a5ac93..807a75b 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -14,6 +14,7 @@ - 把 sudo 密码保存到操作系统密钥链,而不是明文文件。 - 用 `~/.sshx/settings.json` 里的主机短名称代替重复输入 IP、端口、用户和 key 路径。 - 不打开交互式 SFTP 客户端,也能完成常见文件上传、下载和目录操作。 +- 用哈希前置条件、备份和原子替换安全地改一个远程文件。 - 输出适合脚本和 AI agent 判断分支的 JSON。 - 用 `--dry-run` 在连接、读取 secret、修改 `known_hosts` 或写配置前预览本地执行计划。 - 写入本地 JSONL 审计日志,同时不记录明文密码、私钥、stdout 或 stderr。 @@ -82,3 +83,4 @@ sshx inspect -h=prod-web system.baseline --json - [Agent 与脚本模式](agent-scripting.md)说明 JSON、退出码、timeout 和审计日志。 - [主机探测能力与本地插件](inspection-plugins.md)说明内置能力、`plugin create`、信任和观察快照。 - [SFTP 工作流](sftp.md)覆盖上传、下载、列目录、创建目录和删除。 +- [受控文件 Apply](apply.md)用备份和哈希检查替换一个远程文件。 diff --git a/docs/zh/sftp.md b/docs/zh/sftp.md index c5a623c..5621b25 100644 --- a/docs/zh/sftp.md +++ b/docs/zh/sftp.md @@ -8,16 +8,11 @@ sshx -h=prod-web --upload=./deploy/nginx.conf --to=/tmp/nginx.conf ``` -生产环境更安全的模式: +覆盖已有远程文件并需要备份/哈希前置条件时,用 [受控文件 Apply](apply.md),不要自己拼 upload + `install`: ```bash -# 先上传到临时路径 -sshx -h=prod-web --upload=./deploy/nginx.conf --to=/tmp/nginx.conf - -# 检查后再移动到正式位置 -sshx -h=prod-web "sudo install -m 0644 /tmp/nginx.conf /etc/nginx/nginx.conf" -sshx -h=prod-web "sudo nginx -t" -sshx -h=prod-web "sudo systemctl reload nginx" +sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./deploy/nginx.conf --sudo --json +sshx run --target=prod-web --json -- "sudo nginx -t" ``` ## 下载文件 diff --git a/internal/app/app.go b/internal/app/app.go index 991c59c..58e237e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -104,6 +104,13 @@ func Run(args []string) (err error) { return HandleSQL(config, audit) } + if config.Mode == "apply" { + if config.Timeout == 0 { + config.Timeout = 60 * time.Second + } + return HandleApply(config, audit) + } + // Handle password management mode if config.Mode == "password" { if pwdErr := HandlePasswordManagement(config); pwdErr != nil { diff --git a/internal/app/apply.go b/internal/app/apply.go new file mode 100644 index 0000000..2dfde03 --- /dev/null +++ b/internal/app/apply.go @@ -0,0 +1,314 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "path" + "strings" + "time" + + "github.com/talkincode/sshx/internal/execution" + "github.com/talkincode/sshx/internal/sshclient" + "github.com/talkincode/sshx/pkg/errutil" + "github.com/talkincode/sshx/pkg/logger" +) + +type applyBackupJSON struct { + Kind string `json:"kind"` + Path string `json:"path,omitempty"` + RestoreHint string `json:"restore_hint,omitempty"` +} + +type applyJSONResult struct { + SchemaVersion string `json:"schema_version"` + Host string `json:"host"` + Port string `json:"port"` + User string `json:"user"` + Action string `json:"action"` + Intent string `json:"intent"` + RemotePath string `json:"remote_path"` + LocalPath string `json:"local_path,omitempty"` + PayloadSHA256 string `json:"payload_sha256,omitempty"` + PayloadBytes int `json:"payload_bytes,omitempty"` + ExpectSHA256 string `json:"expect_sha256,omitempty"` + BeforeSHA256 string `json:"before_sha256,omitempty"` + AfterSHA256 string `json:"after_sha256,omitempty"` + Changed bool `json:"changed"` + Created bool `json:"created"` + RollbackAvail bool `json:"rollback_available"` + Mode string `json:"mode,omitempty"` + UseSudo bool `json:"use_sudo,omitempty"` + Backup *applyBackupJSON `json:"backup,omitempty"` + Status string `json:"status"` + Phase string `json:"phase"` + Completion string `json:"completion"` + ExitCode int `json:"exit_code"` + Success bool `json:"success"` + DurationMs int64 `json:"duration_ms"` + AuthMethod string `json:"auth_method,omitempty"` + ErrorKind string `json:"error_kind,omitempty"` + Error string `json:"error,omitempty"` + ErrorRetryable bool `json:"retryable,omitempty"` + RetrySafety string `json:"retry_safety,omitempty"` +} + +type applyRun struct { + config *sshclient.Config + audit *auditRecorder + start time.Time + phase string + client *sshclient.SSHClient + payload []byte + outcome *sshclient.ApplyOutcome +} + +func HandleApply(config *sshclient.Config, audit *auditRecorder) (err error) { + run := &applyRun{config: config, audit: audit, start: time.Now(), phase: "classify"} + if cfgErr := validateApplyConfig(config); cfgErr != nil { + return run.fail("config", cfgErr) + } + + run.phase = "policy" + if policyErr := applyPolicy(config); policyErr != nil { + kind := classifyError(policyErr) + if kind == "" || kind == "error" { + kind = "blocked" + } + return run.fail(kind, policyErr) + } + + payload, readErr := os.ReadFile(config.LocalPath) + if readErr != nil { + return run.fail("local_io", fmt.Errorf("read --from=%s: %w", config.LocalPath, readErr)) + } + if len(payload) > sshclient.MaxApplyBytes { + return run.fail("config", fmt.Errorf("payload exceeds %d-byte apply limit", sshclient.MaxApplyBytes)) + } + run.payload = payload + + if config.Host != "" && !isIPAddress(config.Host) { + if resolveErr := resolveHostFromSettings(config); resolveErr != nil { + logger.GetLogger().Info("Note: Could not find host '%s' in settings, using as hostname directly", config.Host) + } + } + + if config.ApplyUseSudo { + password, pwdErr := sshclient.GetSudoPassword(config.SudoKey) + if pwdErr != nil { + return run.fail("secret", fmt.Errorf("resolve sudo password key %q: %w", config.SudoKey, pwdErr)) + } + config.SudoPassword = password + } + + run.phase = "connect" + client, cliErr := sshclient.NewSSHClient(config) + if cliErr != nil { + return run.fail("config", fmt.Errorf("failed to create SSH client: %w", cliErr)) + } + defer errutil.HandleCloseError(&err, client) + if connErr := client.ConnectDirect(); connErr != nil { + return run.fail(classifyError(connErr), fmt.Errorf("failed to connect: %w", connErr)) + } + run.client = client + if audit != nil { + audit.event.AuthMethod = string(client.AuthMethodUsed()) + } + + run.phase = "apply" + outcome, applyErr := client.ApplyRegularFile(sshclient.ApplyRequest{ + RemotePath: config.RemotePath, + Payload: payload, + ExpectSHA256: config.ApplyExpectSHA256, + Backup: !config.ApplyNoBackup, + BackupDir: config.ApplyBackupDir, + Force: config.Force, + UseSudo: config.ApplyUseSudo, + }) + if applyErr != nil { + run.outcome = outcome + return run.fail(classifyApplyError(applyErr), applyErr) + } + run.outcome = outcome + return run.succeed() +} + +func validateApplyConfig(config *sshclient.Config) error { + if config.ArgumentError != "" { + return fmt.Errorf("%s", config.ArgumentError) + } + if config.Host == "" { + return fmt.Errorf("host is required (use -h= or --target=)") + } + if strings.TrimSpace(config.RemotePath) == "" { + return fmt.Errorf("--path= is required") + } + if strings.TrimSpace(config.LocalPath) == "" { + return fmt.Errorf("--from= is required") + } + if err := sshclient.ValidateApplyPath(config.RemotePath); err != nil { + return err + } + if config.ApplyBackupDir != "" { + dir := path.Clean(config.ApplyBackupDir) + if err := sshclient.ValidateApplyPath(dir + "/.keep"); err != nil { + return fmt.Errorf("invalid --backup-dir: %w", err) + } + config.ApplyBackupDir = dir + } + normalized, err := sshclient.NormalizeApplySHA256(config.ApplyExpectSHA256) + if err != nil { + return fmt.Errorf("invalid --expect-sha256: %w", err) + } + config.ApplyExpectSHA256 = normalized + if config.ApplyNoBackup && !config.Force { + return fmt.Errorf("--no-backup requires --force") + } + if config.Timeout < 0 { + return fmt.Errorf("invalid --timeout value (use e.g. 30s, 2m, or 30)") + } + return nil +} + +func applyPolicy(config *sshclient.Config) error { + if sshclient.ApplyPathBlocked(config.RemotePath) { + if !config.Force || strings.TrimSpace(config.BypassReason) == "" { + return fmt.Errorf("%w: %s is a critical identity file; pass --force --bypass-reason=", sshclient.ErrApplyBlocked, config.RemotePath) + } + } + return nil +} + +func (r *applyRun) succeed() error { + r.phase = "complete" + r.recordAudit(0, "", nil) + if r.config.JSONOutput { + emitApplyJSON(r.baseResult(true, 0, "", nil)) + return nil + } + if r.outcome != nil && !r.outcome.Changed { + logger.GetLogger().Success("Unchanged %s (already matches payload)", r.config.RemotePath) + return nil + } + logger.GetLogger().Success("Applied %s", r.config.RemotePath) + if r.outcome != nil && r.outcome.BackupPath != "" { + logger.GetLogger().Info("Backup: %s", r.outcome.BackupPath) + } + return nil +} + +func (r *applyRun) fail(kind string, failErr error) error { + completion := execution.CompletionNotStarted + switch r.phase { + case "apply": + completion = execution.CompletionUnknown + if kind == execution.ErrorKindPrecondition || kind == "blocked" || kind == "config" { + completion = execution.CompletionNotStarted + } + } + r.recordAudit(-1, kind, failErr) + if r.config.JSONOutput { + result := r.baseResult(false, -1, kind, failErr) + result.Completion = completion + if info := execution.BuildError(failErr, kind, execution.IntentChange, completion); info != nil { + result.ErrorRetryable = info.Retryable + result.RetrySafety = info.RetrySafety + } + emitApplyJSON(result) + return ErrReported + } + return failErr +} + +func (r *applyRun) baseResult(success bool, exitCode int, kind string, failErr error) applyJSONResult { + result := applyJSONResult{ + SchemaVersion: execution.ResultSchemaVersion, + Host: r.config.Host, + Port: r.config.Port, + User: r.config.User, + Action: execution.ActionApply, + Intent: execution.IntentChange, + RemotePath: r.config.RemotePath, + LocalPath: r.config.LocalPath, + ExpectSHA256: r.config.ApplyExpectSHA256, + UseSudo: r.config.ApplyUseSudo, + Status: execution.StatusFailed, + Phase: r.phase, + Completion: execution.CompletionNotStarted, + ExitCode: exitCode, + Success: success, + DurationMs: time.Since(r.start).Milliseconds(), + ErrorKind: kind, + } + if success { + result.Status = execution.StatusSucceeded + result.Completion = execution.CompletionCompleted + } + if len(r.payload) > 0 { + result.PayloadSHA256 = sshclient.SHA256Hex(r.payload) + result.PayloadBytes = len(r.payload) + } + if r.client != nil { + result.AuthMethod = string(r.client.AuthMethodUsed()) + } + if r.outcome != nil { + result.Changed = r.outcome.Changed + result.Created = r.outcome.Created + result.BeforeSHA256 = r.outcome.BeforeSHA256 + result.AfterSHA256 = r.outcome.AfterSHA256 + result.Mode = r.outcome.Mode + if r.outcome.BackupPath != "" { + result.RollbackAvail = true + result.Backup = &applyBackupJSON{ + Kind: "file", + Path: r.outcome.BackupPath, + RestoreHint: applyRestoreHint(r.config.RemotePath, r.outcome.BackupPath), + } + } else if r.config.ApplyNoBackup { + result.Backup = &applyBackupJSON{Kind: "none"} + } + } + if failErr != nil { + result.Error = redactError(failErr) + } + return result +} + +func (r *applyRun) recordAudit(exitCode int, kind string, failErr error) { + if r.audit == nil { + return + } + authMethod := sshclient.AuthMethodUnknown + if r.client != nil { + authMethod = r.client.AuthMethodUsed() + } + r.audit.recordApplyOutcome(r.config, authMethod, r.outcome, r.payload, r.phase, exitCode, kind, failErr) +} + +func applyRestoreHint(remotePath, backupPath string) string { + return fmt.Sprintf("copy %s over %s after verifying the backup contents", backupPath, remotePath) +} + +func classifyApplyError(err error) string { + kind := classifyError(err) + if kind != "" && kind != "error" { + return kind + } + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "precondition"): + return execution.ErrorKindPrecondition + case strings.Contains(msg, "blocked"): + return "blocked" + default: + return kind + } +} + +func emitApplyJSON(result applyJSONResult) { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + if err := enc.Encode(result); err != nil { + logger.GetLogger().Error("failed to encode JSON result: %v", err) + } +} diff --git a/internal/app/apply_test.go b/internal/app/apply_test.go new file mode 100644 index 0000000..1ef75f5 --- /dev/null +++ b/internal/app/apply_test.go @@ -0,0 +1,83 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseArgs_ApplyBasic(t *testing.T) { + config := ParseArgs([]string{ + "sshx", "apply", "--target=prod", "--path=/etc/nginx/nginx.conf", + "--from=./nginx.conf", "--expect-sha256=" + strings.Repeat("ab", 32), + "--sudo", "--json", + }) + if config.Mode != "apply" { + t.Fatalf("expected mode apply, got %s", config.Mode) + } + if config.Host != "prod" || config.RemotePath != "/etc/nginx/nginx.conf" || config.LocalPath != "./nginx.conf" { + t.Fatalf("unexpected apply routing: %#v", config) + } + if !config.ApplyUseSudo || !config.JSONOutput { + t.Fatal("sudo/json flags were not parsed") + } + if config.ApplyExpectSHA256 != strings.Repeat("ab", 32) { + t.Fatalf("unexpected expect hash: %s", config.ApplyExpectSHA256) + } +} + +func TestParseArgs_ApplyUnknownOption(t *testing.T) { + config := ParseArgs([]string{"sshx", "apply", "-h=prod", "--path=/tmp/a", "--from=./a", "--bogus"}) + if config.ArgumentError == "" { + t.Fatal("expected an argument error for unknown option") + } +} + +func TestValidateApplyConfig_NoBackupRequiresForce(t *testing.T) { + config := ParseArgs([]string{ + "sshx", "apply", "-h=prod", "--path=/tmp/app.conf", "--from=./app.conf", "--no-backup", + }) + if err := validateApplyConfig(config); err == nil { + t.Fatal("expected --no-backup without --force to fail") + } + config.Force = true + if err := validateApplyConfig(config); err != nil { + t.Fatalf("force should allow --no-backup: %v", err) + } +} + +func TestApplyPolicyBlocksPasswdWithoutBypass(t *testing.T) { + config := ParseArgs([]string{ + "sshx", "apply", "-h=prod", "--path=/etc/passwd", "--from=./passwd", + }) + if err := applyPolicy(config); err == nil { + t.Fatal("expected /etc/passwd apply to be blocked") + } + config.Force = true + config.BypassReason = "incident-restore" + if err := applyPolicy(config); err != nil { + t.Fatalf("force+bypass should allow the critical path: %v", err) + } +} + +func TestApplyDryRunDoesNotNeedHostConnection(t *testing.T) { + dir := t.TempDir() + local := filepath.Join(dir, "app.conf") + if err := os.WriteFile(local, []byte("payload\n"), 0o600); err != nil { + t.Fatal(err) + } + config := ParseArgs([]string{ + "sshx", "apply", "-h=prod", "--path=/tmp/app.conf", "--from=" + local, "--dry-run", "--json", + }) + plan := buildDryRunPlan(config) + if !plan.Valid { + t.Fatalf("expected valid dry-run plan: %+v", plan.ConfigCheck) + } + if !plan.WouldConnect || !plan.WouldMutateRemote { + t.Fatalf("unexpected effects: connect=%v mutate=%v", plan.WouldConnect, plan.WouldMutateRemote) + } + if plan.Apply == nil || plan.Apply.PayloadBytes != len("payload\n") || plan.Apply.Backup != "file" { + t.Fatalf("unexpected apply plan: %+v", plan.Apply) + } +} diff --git a/internal/app/audit.go b/internal/app/audit.go index 8c79b2f..ac0ae88 100644 --- a/internal/app/audit.go +++ b/internal/app/audit.go @@ -134,6 +134,14 @@ type auditEvent struct { SQLDocker string `json:"sql_docker,omitempty"` SQLCredSource string `json:"sql_cred_source,omitempty"` SQLCredCache string `json:"sql_cred_cache,omitempty"` + + ApplyExpectSHA256 string `json:"apply_expect_sha256,omitempty"` + ApplyPayloadHash string `json:"apply_payload_sha256,omitempty"` + ApplyBeforeHash string `json:"apply_before_sha256,omitempty"` + ApplyAfterHash string `json:"apply_after_sha256,omitempty"` + ApplyBackupPath string `json:"apply_backup_path,omitempty"` + ApplyChanged bool `json:"apply_changed,omitempty"` + ApplyCreated bool `json:"apply_created,omitempty"` } type auditRecorder struct { @@ -281,6 +289,41 @@ func (r *auditRecorder) recordSQLOutcome(config *sshclient.Config, authMethod ss r.completed = true } +func (r *auditRecorder) recordApplyOutcome(config *sshclient.Config, authMethod sshclient.AuthMethod, outcome *sshclient.ApplyOutcome, payload []byte, phase string, exitCode int, kind string, failErr error) { + if r == nil { + return + } + r.refresh(config) + r.event.AuthMethod = string(authMethod) + r.event.Phase = phase + r.event.ActionIntent = "change" + if len(payload) > 0 { + r.event.ApplyPayloadHash = sshclient.SHA256Hex(payload) + r.event.PayloadSHA256 = r.event.ApplyPayloadHash + } + r.event.ApplyExpectSHA256 = config.ApplyExpectSHA256 + if outcome != nil { + r.event.ApplyBeforeHash = outcome.BeforeSHA256 + r.event.ApplyAfterHash = outcome.AfterSHA256 + r.event.ApplyBackupPath = outcome.BackupPath + r.event.ApplyChanged = outcome.Changed + r.event.ApplyCreated = outcome.Created + } + r.event.WouldMutateRemote = failErr == nil && outcome != nil && outcome.Changed + r.event.DurationMs = time.Since(r.started).Milliseconds() + r.event.ExitCode = intPtr(exitCode) + if failErr != nil { + r.event.Outcome = auditStatus{ + Status: "failure", + ErrorKind: kind, + Message: redactError(failErr), + } + } else { + r.event.Outcome = auditStatus{Status: "success"} + } + r.completed = true +} + func (r *auditRecorder) finish(config *sshclient.Config, err error) error { if r == nil { return nil @@ -365,6 +408,9 @@ func (r *auditRecorder) refresh(config *sshclient.Config) { r.event.PasswordValueProvided = config.PasswordValue != "" r.event.PasswordKey = config.PasswordKey r.event.UsesSudo = sshclient.CommandUsesSudo(config.Command) + if config.Mode == "apply" { + r.event.UsesSudo = config.ApplyUseSudo + } if config.Mode == "inspect" { if resolved, resolveErr := pluginpkg.Resolve(config.InspectCapability); resolveErr == nil { if useSudo, _, privilegeErr := inspectionPrivilege(config, resolved.Manifest); privilegeErr == nil { @@ -471,6 +517,8 @@ func auditAction(config *sshclient.Config) string { return "inspect" case "sql": return "sql" + case "apply": + return "apply" default: return "" } @@ -499,6 +547,8 @@ func auditWouldReadSecret(config *sshclient.Config) bool { return config.InspectUseSudo && config.SudoKey != "" case "sql": return config.SQLPasswordKey != "" || config.SQLCredFrom != "" + case "apply": + return config.ApplyUseSudo && config.SudoKey != "" default: return false } @@ -535,6 +585,8 @@ func auditWouldMutateRemote(config *sshclient.Config) bool { // Conservative: refined by the sql handler once the statement class // is known (reads do not mutate). return true + case "apply": + return true default: return false } diff --git a/internal/app/config.go b/internal/app/config.go index 7f3dee2..bdcd1c5 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -114,6 +114,9 @@ func ParseArgs(args []string) *sshclient.Config { case "sql": parseSQLArgs(config, args[2:]) return config + case "apply": + parseApplyArgs(config, args[2:]) + return config } } @@ -652,6 +655,79 @@ func parseSQLArgs(config *sshclient.Config, args []string) { } } +// parseApplyArgs parses the `sshx apply` guarded file-mutation subcommand. +func parseApplyArgs(config *sshclient.Config, args []string) { + config.Mode = "apply" + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + config.ArgumentError = "apply does not take a positional command; use --from= and --path=" + return + } + switch { + case strings.HasPrefix(arg, "-h="), strings.HasPrefix(arg, "--host="), strings.HasPrefix(arg, "--target="): + config.Host = strings.SplitN(arg, "=", 2)[1] + 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="): + 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 == "--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 strings.HasPrefix(arg, "--path="): + config.RemotePath = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--from="): + config.LocalPath = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--expect-sha256="): + config.ApplyExpectSHA256 = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--backup-dir="): + config.ApplyBackupDir = strings.SplitN(arg, "=", 2)[1] + case arg == "--no-backup": + config.ApplyNoBackup = true + case arg == "--sudo": + config.ApplyUseSudo = true + case arg == "--force", arg == "-f": + config.Force = true + case strings.HasPrefix(arg, "--bypass-reason="): + config.BypassReason = strings.SplitN(arg, "=", 2)[1] + case arg == "--dry-run": + config.DryRun = true + case arg == "--json": + 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, "--audit-output="): + config.AuditOutput = strings.SplitN(arg, "=", 2)[1] + case arg == "--no-audit": + config.AuditEnabled = false + default: + config.ArgumentError = fmt.Sprintf("unknown apply option %q", arg) + } + } +} + // sqlCredCacheExplicit reports whether the operator explicitly set // --cred-cache (including --cred-cache=off, which must stay off). func sqlCredCacheExplicit(args []string) bool { diff --git a/internal/app/dryrun.go b/internal/app/dryrun.go index fe1f6f7..891a29e 100644 --- a/internal/app/dryrun.go +++ b/internal/app/dryrun.go @@ -79,10 +79,24 @@ type dryRunPlan struct { // SQL is the guarded-SQL local plan (Mode == "sql"). SQL *sqlDryRunPlan `json:"sql,omitempty"` + // Apply is the guarded file-apply local plan (Mode == "apply"). + Apply *applyDryRunPlan `json:"apply,omitempty"` hostTestReadsSecret bool } +type applyDryRunPlan struct { + RemotePath string `json:"remote_path"` + LocalPath string `json:"local_path"` + PayloadSHA256 string `json:"payload_sha256,omitempty"` + PayloadBytes int `json:"payload_bytes,omitempty"` + ExpectSHA256 string `json:"expect_sha256,omitempty"` + Backup string `json:"backup"` + BackupDir string `json:"backup_dir,omitempty"` + UseSudo bool `json:"use_sudo"` + Force bool `json:"force,omitempty"` +} + // sqlDryRunPlan is the local (connection-free) plan for one sql invocation. // The backup decision shown here uses no EXPLAIN estimate; a row-level backup // may still upgrade to a table dump at execution time. @@ -145,6 +159,7 @@ func buildDryRunPlan(config *sshclient.Config) dryRunPlan { fillDryRunSudo(config, &plan) fillDryRunValidation(config, &plan) fillDryRunSQL(config, &plan) + fillDryRunApply(config, &plan) fillDryRunEffects(config, &plan) return plan @@ -191,6 +206,10 @@ func fillDryRunAction(config *sshclient.Config, plan *dryRunPlan) { plan.CacheMode = config.InspectCacheMode case "sql": plan.Action = "sql" + case "apply": + plan.Action = "apply" + plan.LocalPath = config.LocalPath + plan.RemotePath = config.RemotePath } } @@ -204,7 +223,7 @@ func fillDryRunHost(config *sshclient.Config, plan *dryRunPlan) { plan.HostInput = config.Host } - if config.Mode == "ssh" || config.Mode == "sftp" || config.Mode == "inspect" || config.Mode == "sql" { + if config.Mode == "ssh" || config.Mode == "sftp" || config.Mode == "inspect" || config.Mode == "sql" || config.Mode == "apply" { resolveDryRunSSHHost(config, plan) return } @@ -335,6 +354,10 @@ func fillDryRunSudo(config *sshclient.Config, plan *dryRunPlan) { plan.UsesSudo = config.InspectUseSudo plan.SudoKey = config.SudoKey } + if config.Mode == "apply" { + plan.UsesSudo = config.ApplyUseSudo + plan.SudoKey = config.SudoKey + } if config.Mode == "host" && config.HostAction == "test" { return } @@ -509,6 +532,12 @@ func fillDryRunEffects(config *sshclient.Config, plan *dryRunPlan) { mutates := plan.SQL != nil && plan.SQL.Class != "" && plan.SQL.Class != string(sqlsafe.ClassRead) plan.WouldMutateRemote = plan.WouldExecute && mutates plan.WouldWriteRemoteState = plan.WouldExecute && plan.SQL != nil && plan.SQL.BackupKind != "" && plan.SQL.BackupKind != string(sqlsafe.BackupNone) + case "apply": + plan.WouldConnect = canProceed + plan.WouldExecute = canProceed + plan.WouldReadSecret = canProceed && config.ApplyUseSudo && config.SudoKey != "" + plan.WouldMutateRemote = canProceed + plan.WouldWriteRemoteState = canProceed && !config.ApplyNoBackup } plan.MayMutateKnownHosts = plan.WouldConnect && config.AcceptUnknownHost } @@ -619,8 +648,55 @@ func fillDryRunSQL(config *sshclient.Config, plan *dryRunPlan) { } } +func fillDryRunApply(config *sshclient.Config, plan *dryRunPlan) { + if config.Mode != "apply" { + return + } + if err := validateApplyConfig(config); err != nil { + plan.ConfigCheck = dryRunStatus{Status: "error", ErrorKind: classifyError(err), Message: err.Error()} + if plan.ConfigCheck.ErrorKind == "error" { + plan.ConfigCheck.ErrorKind = "config" + } + plan.Valid = false + return + } + if err := applyPolicy(config); err != nil { + plan.SafetyCheck = dryRunStatus{Status: "blocked", ErrorKind: "blocked", Message: err.Error()} + plan.Valid = false + return + } + applyPlan := &applyDryRunPlan{ + RemotePath: config.RemotePath, + LocalPath: config.LocalPath, + ExpectSHA256: config.ApplyExpectSHA256, + Backup: "file", + BackupDir: config.ApplyBackupDir, + UseSudo: config.ApplyUseSudo, + Force: config.Force, + } + if config.ApplyNoBackup { + applyPlan.Backup = "none" + } + if data, err := os.ReadFile(config.LocalPath); err == nil { + if len(data) > sshclient.MaxApplyBytes { + plan.ConfigCheck = dryRunStatus{Status: "error", ErrorKind: "config", Message: fmt.Sprintf("payload exceeds %d-byte apply limit", sshclient.MaxApplyBytes)} + plan.Valid = false + return + } + applyPlan.PayloadBytes = len(data) + applyPlan.PayloadSHA256 = sshclient.SHA256Hex(data) + } else { + plan.ConfigCheck = dryRunStatus{Status: "error", ErrorKind: "local_io", Message: fmt.Sprintf("read --from=%s: %v", config.LocalPath, err)} + plan.Valid = false + return + } + plan.Apply = applyPlan + plan.UsesSudo = config.ApplyUseSudo + plan.SafetyCheck = dryRunStatus{Status: "passed"} +} + func modeUsesSSHConnection(config *sshclient.Config) bool { - if config.Mode == "ssh" || config.Mode == "sftp" || config.Mode == "transfer" || config.Mode == "inspect" || config.Mode == "sql" { + if config.Mode == "ssh" || config.Mode == "sftp" || config.Mode == "transfer" || config.Mode == "inspect" || config.Mode == "sql" || config.Mode == "apply" { return true } return config.Mode == "host" && (config.HostAction == "test" || config.HostAction == "test-all") diff --git a/internal/app/usage.go b/internal/app/usage.go index 2c41bd6..0c41212 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -32,6 +32,7 @@ Usage: sshx plugin list [--json] # List built-in and local capabilities sshx inspect -h= [options] # Run one structured host inspection sshx sql -h= --db= [options] "SQL" # Guarded SQL via remote psql/sqlite3 + sshx apply -h= --path= --from= # Guarded remote file apply SSH Options: -h, --host=HOST Remote host address (required in compatibility mode) @@ -296,6 +297,35 @@ Guarded SQL Execution: sshx sql -h=app --engine=sqlite --db-file=/var/lib/app/app.db --json \ "UPDATE users SET active=0 WHERE id=42" +Guarded File Apply: + sshx apply -h= --path=/abs/remote.conf --from=./local.conf [options] + sshx apply --target= --path=/abs/remote.conf --from=./local.conf --json + + Replaces one remote regular file. sshx reads the current file, optionally + checks --expect-sha256, writes an owner-only backup, then atomically replaces + the target while preserving mode and owner. Reload/restart is not part of + this command — run a separate sshx run after apply succeeds. + + --path=PATH Absolute remote file path (required) + --from=PATH Local source file (required) + --expect-sha256=HEX Fail closed unless the current remote hash matches + --no-backup Skip the pre-change copy (requires --force) + --backup-dir=PATH Remote backup directory (default: ~/.sshx/file-backups) + --sudo Stage the payload over SFTP, then install with sudo + --force, -f Skip the hash precondition; required with --no-backup + --bypass-reason=TEXT Required with --force to overwrite /etc/passwd, + /etc/shadow, or /etc/sudoers + + JSON fields to branch on: success, changed, created, completion, error_kind + (precondition/blocked/remote_io/config/...), before_sha256, after_sha256, + backup.path, rollback_available. Identical content is success with + changed=false and does not write a backup. + + sshx apply -h=prod --path=/etc/nginx/nginx.conf --from=./nginx.conf \ + --expect-sha256= --sudo --json + sshx apply -h=prod --path=/etc/nginx/nginx.conf --from=./nginx.conf \ + --dry-run --json + Plugin Management: sshx plugin create [--runner=sh] [--platform=linux|darwin] [--privilege=never|optional|required] diff --git a/internal/app/usage_test.go b/internal/app/usage_test.go index 7af9c90..d35f033 100644 --- a/internal/app/usage_test.go +++ b/internal/app/usage_test.go @@ -46,6 +46,7 @@ func TestPrintUsage(t *testing.T) { "Password Management", "Inspection Capabilities:", "Guarded SQL Execution:", + "Guarded File Apply:", "Plugin Management:", "Agent Skill Installation:", "Environment Variables", @@ -73,6 +74,7 @@ func TestPrintUsage(t *testing.T) { "--no-safety-check", "--bypass-reason", "sshx inspect", + "sshx apply", "sshx plugin create", "sshx skill install", "SSHX_HOME", diff --git a/internal/execution/errors.go b/internal/execution/errors.go index 931e1a3..6cddca9 100644 --- a/internal/execution/errors.go +++ b/internal/execution/errors.go @@ -28,6 +28,10 @@ func Classify(err error) string { return ErrorKindRemoteIO case errors.Is(err, ErrBlocked): return ErrorKindBlocked + case errors.Is(err, sshclient.ErrPrecondition): + return ErrorKindPrecondition + case errors.Is(err, sshclient.ErrApplyBlocked): + return ErrorKindBlocked } var blocked *sshclient.CommandBlockedError if errors.As(err, &blocked) { @@ -81,7 +85,7 @@ func BuildError(err error, kind, intent, completion string) *ErrorInfo { case ErrorKindTimeout, ErrorKindConnect, ErrorKindProtocol: info.Retryable = true info.RetrySafety = RetryVerifyFirst - case ErrorKindAuth, ErrorKindHostKey, ErrorKindBlocked, ErrorKindConfig, ErrorKindLocalIO: + case ErrorKindAuth, ErrorKindHostKey, ErrorKindBlocked, ErrorKindConfig, ErrorKindLocalIO, ErrorKindPrecondition: info.Retryable = false info.RetrySafety = RetryUnsafe case ErrorKindRemoteExit: diff --git a/internal/execution/types.go b/internal/execution/types.go index 9fa1475..ca8025d 100644 --- a/internal/execution/types.go +++ b/internal/execution/types.go @@ -21,6 +21,7 @@ const ( ActionInspect = "inspect" ActionSFTP = "sftp" ActionTransfer = "transfer" + ActionApply = "apply" IntentRead = "read" IntentChange = "change" @@ -58,18 +59,19 @@ const ( 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" + 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" + ErrorKindPrecondition = "precondition" + ErrorKindUnknown = "unknown" ScriptRunnerSH = "sh" ) diff --git a/internal/sshclient/apply.go b/internal/sshclient/apply.go new file mode 100644 index 0000000..08cb14e --- /dev/null +++ b/internal/sshclient/apply.go @@ -0,0 +1,630 @@ +package sshclient + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "strings" + "time" + "unicode" + + "github.com/pkg/sftp" + "github.com/talkincode/sshx/pkg/errutil" +) + +const ( + // MaxApplyBytes bounds both the incoming payload and any existing remote + // file that apply will read for hashing or backup. + MaxApplyBytes = 10 << 20 + + defaultApplyBackupDir = ".sshx/file-backups" + applyStagingDir = ".sshx/apply-staging" +) + +var ( + // ErrPrecondition indicates the remote file hash did not match --expect-sha256. + ErrPrecondition = errors.New("apply precondition failed") + // ErrApplyBlocked indicates the target path is refused by apply policy. + ErrApplyBlocked = errors.New("apply target blocked") +) + +// ApplyRequest is one guarded regular-file replacement. +type ApplyRequest struct { + RemotePath string + Payload []byte + ExpectSHA256 string + Backup bool + BackupDir string + Force bool + UseSudo bool +} + +// ApplyOutcome is the observed result of one apply. +type ApplyOutcome struct { + Changed bool + Created bool + BeforeSHA256 string + AfterSHA256 string + BackupPath string + Mode string +} + +type applyScriptReport struct { + Status string `json:"status"` + Changed bool `json:"changed"` + Created bool `json:"created"` + Before string `json:"before"` + After string `json:"after"` + Backup string `json:"backup"` + Mode string `json:"mode"` + Error string `json:"error"` +} + +// ValidateApplyPath rejects anything that is not a clean POSIX absolute file path. +func ValidateApplyPath(remotePath string) error { + if err := validateAbsoluteRemotePath(remotePath); err != nil { + return fmt.Errorf("%w: %v", ErrApplyBlocked, err) + } + if remotePath == "/" { + return fmt.Errorf("%w: cannot apply to /", ErrApplyBlocked) + } + if strings.HasSuffix(remotePath, "/") { + return fmt.Errorf("%w: remote path must name a file", ErrApplyBlocked) + } + base := path.Base(remotePath) + if base == "." || base == ".." || strings.HasPrefix(base, ".") && strings.Contains(base, ".sshx.") { + return fmt.Errorf("%w: refused remote file name %q", ErrApplyBlocked, base) + } + return nil +} + +// ApplyPathBlocked reports whether the path is a critical identity file that +// requires an explicit force + bypass-reason pair. +func ApplyPathBlocked(remotePath string) bool { + cleaned := path.Clean(remotePath) + switch cleaned { + case "/etc/passwd", "/etc/shadow", "/etc/master.passwd", "/etc/sudoers": + return true + } + return cleaned == "/etc/sudoers.d" || strings.HasPrefix(cleaned, "/etc/sudoers.d/") +} + +// NormalizeApplySHA256 lowercases a hex digest and verifies it is SHA-256. +func NormalizeApplySHA256(value string) (string, error) { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return "", nil + } + if len(value) != 64 { + return "", fmt.Errorf("expected SHA-256 hex digest (64 chars)") + } + for _, r := range value { + if !unicode.Is(unicode.ASCII_Hex_Digit, r) { + return "", fmt.Errorf("expected SHA-256 hex digest") + } + } + return value, nil +} + +// SHA256Hex returns the lowercase hex SHA-256 of data. +func SHA256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +// ApplyRegularFile replaces one remote regular file. The SFTP path is used +// unless UseSudo is set, in which case the payload is staged over SFTP and a +// privileged stdin script performs backup + atomic install. +func (c *SSHClient) ApplyRegularFile(req ApplyRequest) (*ApplyOutcome, error) { + if err := ValidateApplyPath(req.RemotePath); err != nil { + return nil, err + } + if len(req.Payload) > MaxApplyBytes { + return nil, fmt.Errorf("payload exceeds %d-byte apply limit", MaxApplyBytes) + } + expect, err := NormalizeApplySHA256(req.ExpectSHA256) + if err != nil { + return nil, err + } + req.ExpectSHA256 = expect + if req.UseSudo { + return c.applyWithSudo(req) + } + return c.applyWithSFTP(req) +} + +func (c *SSHClient) applyWithSFTP(req ApplyRequest) (outcome *ApplyOutcome, err error) { + client, clientErr := sftp.NewClient(c.client) + if clientErr != nil { + return nil, fmt.Errorf("open SFTP session: %w", clientErr) + } + defer errutil.HandleCloseError(&err, client) + + info, statErr := client.Lstat(req.RemotePath) + created := false + var before []byte + var beforeMode os.FileMode + var beforeUID, beforeGID uint32 + var haveOwner bool + switch { + case statErr == nil: + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("%w: target must be a regular file", ErrApplyBlocked) + } + if info.Size() > MaxApplyBytes { + return nil, fmt.Errorf("existing file exceeds %d-byte apply limit", MaxApplyBytes) + } + before, err = readRemoteRegularFile(client, req.RemotePath, info.Size()) + if err != nil { + return nil, err + } + beforeMode = info.Mode().Perm() + if stat, ok := info.Sys().(*sftp.FileStat); ok { + beforeUID = stat.UID + beforeGID = stat.GID + haveOwner = true + } + case os.IsNotExist(statErr): + created = true + default: + return nil, fmt.Errorf("remote file inspect failed: %w", statErr) + } + + beforeHash := "" + if !created { + beforeHash = SHA256Hex(before) + } + payloadHash := SHA256Hex(req.Payload) + if preErr := checkApplyPrecondition(created, beforeHash, req); preErr != nil { + return nil, preErr + } + if !created && beforeHash == payloadHash { + return &ApplyOutcome{ + Changed: false, + Created: false, + BeforeSHA256: beforeHash, + AfterSHA256: beforeHash, + Mode: fmt.Sprintf("%04o", beforeMode), + }, nil + } + + backupPath := "" + if req.Backup && !created { + backupPath, err = writeApplyBackup(c, client, req, path.Base(req.RemotePath), before, beforeHash) + if err != nil { + return nil, err + } + } + + mode := os.FileMode(0o600) + if !created { + mode = beforeMode + } + if err := atomicReplaceFile(client, req.RemotePath, req.Payload, mode, haveOwner && !created, beforeUID, beforeGID); err != nil { + return nil, err + } + after, afterErr := readRemoteRegularFile(client, req.RemotePath, int64(len(req.Payload))) + if afterErr != nil { + return nil, afterErr + } + afterHash := SHA256Hex(after) + if afterHash != payloadHash { + return nil, fmt.Errorf("remote file post-apply hash mismatch") + } + return &ApplyOutcome{ + Changed: true, + Created: created, + BeforeSHA256: beforeHash, + AfterSHA256: afterHash, + BackupPath: backupPath, + Mode: fmt.Sprintf("%04o", mode), + }, nil +} + +func checkApplyPrecondition(created bool, beforeHash string, req ApplyRequest) error { + if req.Force { + return nil + } + if req.ExpectSHA256 == "" { + return nil + } + if created { + return fmt.Errorf("%w: target does not exist (expected %s)", ErrPrecondition, req.ExpectSHA256) + } + if beforeHash != req.ExpectSHA256 { + return fmt.Errorf("%w: have %s, expected %s", ErrPrecondition, beforeHash, req.ExpectSHA256) + } + return nil +} + +func writeApplyBackup(c *SSHClient, client *sftp.Client, req ApplyRequest, base string, data []byte, hash string) (string, error) { + dir := strings.TrimSpace(req.BackupDir) + if dir == "" { + home, err := c.RemoteHome() + if err != nil { + return "", fmt.Errorf("resolve backup directory: %w", err) + } + dir = path.Join(home, defaultApplyBackupDir) + } + if err := validateAbsoluteRemotePath(dir); err != nil { + return "", fmt.Errorf("backup directory: %w", err) + } + if err := mkdirAllPrivate(client, dir); err != nil { + return "", err + } + short := hash + if len(short) > 12 { + short = short[:12] + } + name := fmt.Sprintf("%s.%s.%s", base, time.Now().UTC().Format("20060102T150405Z"), short) + backupPath := path.Join(dir, name) + if err := writePrivateFile(client, backupPath, data); err != nil { + return "", fmt.Errorf("write backup: %w", err) + } + return backupPath, nil +} + +func atomicReplaceFile(client *sftp.Client, dest string, data []byte, mode os.FileMode, chown bool, uid, gid uint32) error { + dir := path.Dir(dest) + random := make([]byte, 12) + if _, err := rand.Read(random); err != nil { + return fmt.Errorf("generate apply temp name: %w", err) + } + tempPath := path.Join(dir, "."+path.Base(dest)+"."+hex.EncodeToString(random)+".sshx.tmp") + file, err := client.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL) + if err != nil { + return fmt.Errorf("remote file create apply temp file: %w", err) + } + cleanup := true + defer func() { + _ = file.Close() //nolint:errcheck // best-effort cleanup + if cleanup { + _ = client.Remove(tempPath) //nolint:errcheck // best-effort cleanup + } + }() + if _, err := file.Write(data); err != nil { + return fmt.Errorf("remote file write apply temp file: %w", err) + } + if err := file.Chmod(mode); err != nil { + return fmt.Errorf("remote file chmod apply temp file: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("remote file close apply temp file: %w", err) + } + if chown { + tempInfo, statErr := client.Lstat(tempPath) + if statErr != nil { + return fmt.Errorf("remote file inspect apply temp file: %w", statErr) + } + if stat, ok := tempInfo.Sys().(*sftp.FileStat); ok && (stat.UID != uid || stat.GID != gid) { + if chownErr := client.Chown(tempPath, int(uid), int(gid)); chownErr != nil { + return fmt.Errorf("remote file cannot preserve owner (retry with --sudo): %w", chownErr) + } + } + } + if err := posixOrRename(client, tempPath, dest); err != nil { + return fmt.Errorf("remote file atomically replace target: %w", err) + } + cleanup = false + return nil +} + +func posixOrRename(client *sftp.Client, oldpath, newpath string) error { + if err := client.PosixRename(oldpath, newpath); err == nil { + return nil + } + return client.Rename(oldpath, newpath) +} + +func readRemoteRegularFile(client *sftp.Client, remotePath string, size int64) ([]byte, error) { + file, err := client.Open(remotePath) + if err != nil { + return nil, fmt.Errorf("remote file open target: %w", err) + } + defer func() { _ = file.Close() }() //nolint:errcheck // best-effort close + limit := size + if limit <= 0 || limit > MaxApplyBytes { + limit = MaxApplyBytes + } + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil { + return nil, fmt.Errorf("remote file read target: %w", err) + } + if int64(len(data)) > MaxApplyBytes { + return nil, fmt.Errorf("existing file exceeds %d-byte apply limit", MaxApplyBytes) + } + return data, nil +} + +func writePrivateFile(client *sftp.Client, remotePath string, data []byte) error { + file, err := client.OpenFile(remotePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL) + if err != nil { + return err + } + defer func() { _ = file.Close() }() //nolint:errcheck // best-effort close + if err := file.Chmod(0o600); err != nil { + return err + } + if _, err := file.Write(data); err != nil { + return err + } + return file.Close() +} + +func mkdirAllPrivate(client *sftp.Client, dir string) error { + if err := validateAbsoluteRemotePath(dir); err != nil { + return err + } + var missing []string + current := dir + for { + info, err := client.Lstat(current) + if err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("backup path component is not a directory: %s", current) + } + break + } + if !os.IsNotExist(err) { + return fmt.Errorf("inspect backup directory %s: %w", current, err) + } + missing = append([]string{current}, missing...) + parent := path.Dir(current) + if parent == current { + return fmt.Errorf("cannot create backup directory %s", dir) + } + current = parent + } + for _, item := range missing { + if err := client.Mkdir(item); err != nil { + return fmt.Errorf("create backup directory %s: %w", item, err) + } + if err := client.Chmod(item, 0o700); err != nil { + return fmt.Errorf("secure backup directory %s: %w", item, err) + } + } + return nil +} + +func (c *SSHClient) applyWithSudo(req ApplyRequest) (*ApplyOutcome, error) { + if c.config.SudoPassword == "" { + return nil, fmt.Errorf("sudo apply requires a resolved sudo password") + } + home, err := c.RemoteHome() + if err != nil { + return nil, err + } + stagingDir := path.Join(home, applyStagingDir) + client, clientErr := sftp.NewClient(c.client) + if clientErr != nil { + return nil, fmt.Errorf("open SFTP session: %w", clientErr) + } + defer func() { _ = client.Close() }() //nolint:errcheck // best-effort close + + if mkdirErr := mkdirAllPrivate(client, stagingDir); mkdirErr != nil { + return nil, mkdirErr + } + random := make([]byte, 12) + if _, randErr := rand.Read(random); randErr != nil { + return nil, fmt.Errorf("generate staging name: %w", randErr) + } + staging := path.Join(stagingDir, hex.EncodeToString(random)+".new") + if stageErr := writePrivateFile(client, staging, req.Payload); stageErr != nil { + return nil, fmt.Errorf("stage payload: %w", stageErr) + } + defer func() { _ = client.Remove(staging) }() //nolint:errcheck // best-effort staging cleanup + + backupDir := strings.TrimSpace(req.BackupDir) + if backupDir == "" { + backupDir = path.Join(home, defaultApplyBackupDir) + } + script, err := buildApplySudoScript(req, staging, backupDir) + if err != nil { + return nil, err + } + result, runErr := c.RunScript(script, true) + if runErr != nil { + return nil, runErr + } + return parseApplyScriptReport(result) +} + +func buildApplySudoScript(req ApplyRequest, staging, backupDir string) ([]byte, error) { + for _, value := range []string{req.RemotePath, staging, backupDir, req.ExpectSHA256} { + if !shellSafeToken(value) { + return nil, fmt.Errorf("%w: value is not safe to embed in the apply script", ErrApplyBlocked) + } + } + wantBackup := "0" + if req.Backup { + wantBackup = "1" + } + force := "0" + if req.Force { + force = "1" + } + script := fmt.Sprintf(`#!/bin/sh +set -eu +TARGET='%s' +STAGING='%s' +BACKUP_DIR='%s' +EXPECT='%s' +WANT_BACKUP='%s' +FORCE='%s' +PAYLOAD_SHA='%s' + +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + openssl dgst -sha256 "$1" | awk '{print $NF}' + fi +} + +file_mode() { + if stat -c '%%a' "$1" >/dev/null 2>&1; then + stat -c '%%a' "$1" + else + stat -f '%%OLp' "$1" + fi +} + +file_uid() { + if stat -c '%%u' "$1" >/dev/null 2>&1; then + stat -c '%%u' "$1" + else + stat -f '%%u' "$1" + fi +} + +file_gid() { + if stat -c '%%g' "$1" >/dev/null 2>&1; then + stat -c '%%g' "$1" + else + stat -f '%%g' "$1" + fi +} + +emit() { + printf '%%s\n' "$1" +} + +if [ -L "$TARGET" ]; then + emit '{"status":"blocked","error":"target is a symlink"}' + exit 2 +fi +if [ -e "$TARGET" ] && [ ! -f "$TARGET" ]; then + emit '{"status":"blocked","error":"target is not a regular file"}' + exit 2 +fi +if [ ! -f "$STAGING" ]; then + emit '{"status":"remote_io","error":"staged payload is missing"}' + exit 4 +fi + +created=false +before="" +mode="0600" +if [ -f "$TARGET" ]; then + before=$(hash_file "$TARGET") + mode=$(file_mode "$TARGET") + if [ "$FORCE" != "1" ] && [ -n "$EXPECT" ] && [ "$before" != "$EXPECT" ]; then + emit "{\"status\":\"precondition\",\"before\":\"$before\",\"error\":\"hash mismatch\"}" + exit 3 + fi + if [ "$before" = "$PAYLOAD_SHA" ]; then + emit "{\"status\":\"ok\",\"changed\":false,\"created\":false,\"before\":\"$before\",\"after\":\"$before\",\"mode\":\"$mode\"}" + exit 0 + fi +else + created=true + if [ "$FORCE" != "1" ] && [ -n "$EXPECT" ]; then + emit '{"status":"precondition","error":"target does not exist"}' + exit 3 + fi +fi + +backup="" +if [ "$WANT_BACKUP" = "1" ] && [ "$created" = "false" ]; then + mkdir -p "$BACKUP_DIR" + chmod 700 "$BACKUP_DIR" || true + short=$(printf '%%s' "$before" | cut -c1-12) + stamp=$(date -u +%%Y%%m%%dT%%H%%M%%SZ) + backup="$BACKUP_DIR/$(basename "$TARGET").$stamp.$short" + cp -p "$TARGET" "$backup" + chmod 600 "$backup" || true +fi + +dir=$(dirname "$TARGET") +tmp="$dir/.$(basename "$TARGET").sshx.$$.tmp" +cp "$STAGING" "$tmp" +if [ "$created" = "false" ]; then + chmod "$mode" "$tmp" + chown "$(file_uid "$TARGET"):$(file_gid "$TARGET")" "$tmp" +else + chmod 600 "$tmp" +fi +mv -f "$tmp" "$TARGET" +after=$(hash_file "$TARGET") +if [ "$after" != "$PAYLOAD_SHA" ]; then + emit '{"status":"remote_io","error":"post-apply hash mismatch"}' + exit 4 +fi +emit "{\"status\":\"ok\",\"changed\":true,\"created\":$created,\"before\":\"$before\",\"after\":\"$after\",\"backup\":\"$backup\",\"mode\":\"$mode\"}" +`, req.RemotePath, staging, backupDir, req.ExpectSHA256, wantBackup, force, SHA256Hex(req.Payload)) + return []byte(script), nil +} + +func parseApplyScriptReport(result ExecResult) (*ApplyOutcome, error) { + if result.ExitCode == 0 { + report, err := decodeApplyScriptReport(result.Stdout) + if err != nil { + return nil, err + } + return &ApplyOutcome{ + Changed: report.Changed, + Created: report.Created, + BeforeSHA256: report.Before, + AfterSHA256: report.After, + BackupPath: report.Backup, + Mode: report.Mode, + }, nil + } + report, decodeErr := decodeApplyScriptReport(result.Stdout) + if decodeErr != nil { + return nil, fmt.Errorf("privileged apply failed with status %d: %s", result.ExitCode, strings.TrimSpace(result.Stderr)) + } + switch report.Status { + case "precondition": + if report.Before != "" { + return nil, fmt.Errorf("%w: have %s", ErrPrecondition, report.Before) + } + return nil, fmt.Errorf("%w: %s", ErrPrecondition, emptyFallback(report.Error, "target mismatch")) + case "blocked": + return nil, fmt.Errorf("%w: %s", ErrApplyBlocked, emptyFallback(report.Error, "target refused")) + default: + return nil, fmt.Errorf("remote file %s", emptyFallback(report.Error, "privileged apply failed")) + } +} + +func decodeApplyScriptReport(stdout string) (applyScriptReport, error) { + line := strings.TrimSpace(stdout) + if idx := strings.LastIndex(line, "{"); idx >= 0 { + line = line[idx:] + } + var report applyScriptReport + if err := json.Unmarshal([]byte(line), &report); err != nil { + return applyScriptReport{}, fmt.Errorf("parse privileged apply result: %w", err) + } + return report, nil +} + +func emptyFallback(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func shellSafeToken(value string) bool { + if value == "" { + return true + } + if strings.ContainsAny(value, "'\n\r\x00$`\\\"!") { + return false + } + for _, r := range value { + if r < 32 || r == 127 { + return false + } + } + return true +} diff --git a/internal/sshclient/apply_test.go b/internal/sshclient/apply_test.go new file mode 100644 index 0000000..3566088 --- /dev/null +++ b/internal/sshclient/apply_test.go @@ -0,0 +1,69 @@ +package sshclient + +import ( + "strings" + "testing" +) + +func TestValidateApplyPath(t *testing.T) { + t.Parallel() + if err := ValidateApplyPath("/etc/nginx/nginx.conf"); err != nil { + t.Fatalf("absolute file path should be accepted: %v", err) + } + for _, path := range []string{"", "relative.conf", "/etc/nginx/", "/", "/tmp/foo/../bar", "/tmp/foo/"} { + if err := ValidateApplyPath(path); err == nil { + t.Fatalf("expected rejection for %q", path) + } + } +} + +func TestApplyPathBlocked(t *testing.T) { + t.Parallel() + if !ApplyPathBlocked("/etc/passwd") || !ApplyPathBlocked("/etc/sudoers.d/app") { + t.Fatal("critical identity paths must be blocked") + } + if ApplyPathBlocked("/etc/nginx/nginx.conf") { + t.Fatal("ordinary config paths must not be blocked") + } +} + +func TestNormalizeApplySHA256(t *testing.T) { + t.Parallel() + got, err := NormalizeApplySHA256(" " + strings.Repeat("A", 64) + " ") + if err != nil { + t.Fatal(err) + } + if got != strings.Repeat("a", 64) { + t.Fatalf("expected lowercase digest, got %q", got) + } + if _, err := NormalizeApplySHA256("deadbeef"); err == nil { + t.Fatal("short digest must be rejected") + } +} + +func TestCheckApplyPrecondition(t *testing.T) { + t.Parallel() + req := ApplyRequest{ExpectSHA256: strings.Repeat("b", 64)} + if err := checkApplyPrecondition(true, "", req); err == nil { + t.Fatal("missing target with expect hash must fail") + } + if err := checkApplyPrecondition(false, strings.Repeat("a", 64), req); err == nil { + t.Fatal("hash mismatch must fail") + } + if err := checkApplyPrecondition(false, strings.Repeat("b", 64), req); err != nil { + t.Fatalf("matching hash should pass: %v", err) + } + if err := checkApplyPrecondition(false, strings.Repeat("a", 64), ApplyRequest{Force: true, ExpectSHA256: strings.Repeat("b", 64)}); err != nil { + t.Fatalf("force should skip precondition: %v", err) + } +} + +func TestShellSafeToken(t *testing.T) { + t.Parallel() + if !shellSafeToken("/etc/nginx/nginx.conf") { + t.Fatal("ordinary path should be safe") + } + if shellSafeToken("/tmp/foo'bar") || shellSafeToken("/tmp/foo$(x)") { + t.Fatal("metacharacters must be rejected") + } +} diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index 7f026c9..482dd99 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -195,6 +195,12 @@ type Config struct { SQLCredCacheTTL time.Duration // SQLCredRefresh forces re-resolution, replacing any cached entry. SQLCredRefresh bool + + // Guarded file apply fields (Mode == "apply"). + ApplyExpectSHA256 string + ApplyNoBackup bool + ApplyBackupDir string + ApplyUseSudo bool } // SSHClient wraps one ssh.Client with execution and SFTP helpers. diff --git a/skills/sshx/SKILL.md b/skills/sshx/SKILL.md index 6af9e45..f0ce3dd 100644 --- a/skills/sshx/SKILL.md +++ b/skills/sshx/SKILL.md @@ -1,6 +1,6 @@ --- name: sshx -description: Operate remote servers with the `sshx` CLI — inspect hosts with built-in or locally created plugins, run commands over SSH, transfer files over SFTP, manage named hosts, store SSH/sudo passwords in the OS keyring, and run guarded PostgreSQL or SQLite statements through the remote psql/sqlite3 client (with classification, backups, and strict auditing). Use when the user wants structured host discovery, custom application inspection, remote command execution, upload/download, service operations, host management, keyring-backed secrets, or safe production database queries and changes. Prefer `--json` for programmatic/agent use. +description: Operate remote servers with the `sshx` CLI — inspect hosts with built-in or locally created plugins, run commands over SSH, transfer files over SFTP, apply a single remote file with hash/backup/atomic replace, manage named hosts, store SSH/sudo passwords in the OS keyring, and run guarded PostgreSQL or SQLite statements through the remote psql/sqlite3 client (with classification, backups, and strict auditing). Use when the user wants structured host discovery, custom application inspection, remote command execution, safe config file changes, upload/download, service operations, host management, keyring-backed secrets, or safe production database queries and changes. Prefer `--json` for programmatic/agent use. --- # sshx @@ -17,6 +17,7 @@ its work, and exits — there is no daemon, shell, tunneling, or port forwarding - 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. +- Replace one remote regular file with `sshx apply` (hash precondition, backup, atomic write). - Manage frequently used hosts by short name (`~/.sshx/settings.json`). - Store/fetch SSH or sudo passwords in the OS keyring (never plaintext). - Run one guarded SQL statement against a remote PostgreSQL (plain or Dockerized) @@ -145,7 +146,8 @@ In `--json` mode an sshx-level failure has `exit_code: -1` and a non-empty `error_kind` values: `timeout`, `auth`, `host_key`, `connect`, `blocked`, `exit_missing`, `config`, `error`. SQL mode adds `explain_failed`, `impact_check_failed`, `remote_exit`, and -`cred_source_failed`. +`cred_source_failed`. Apply mode adds `precondition` when `--expect-sha256` +does not match the current remote file. ## Command execution @@ -336,6 +338,33 @@ sshx sql -h=app --engine=sqlite --db-file=/var/lib/app/app.db --json \ - `--db-user`, `--db-password-key`, `--docker`, and `--db-cred-from` are rejected. `--db=` may be used as an alias for the absolute file path. +## Guarded file apply + +Prefer `sshx apply` over `sed -i`, in-place editors, or upload-then-`install` +when replacing one remote regular file. The pipeline is fail-closed: +absolute path → optional hash precondition → owner-only backup → atomic +replace → structured result. Reload/restart is a separate `sshx run`. + +```bash +sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf \ + --expect-sha256= --sudo --json +sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf \ + --dry-run --json +``` + +- `--path` must be a clean absolute file path. Symlinks, directories, and + device nodes are blocked (`error_kind: "blocked"`). +- `--expect-sha256` is optional CAS. Mismatch is `error_kind: "precondition"` + with `completion: "not_started"` and no write. +- Backups default to `~/.sshx/file-backups/`. `--no-backup` requires `--force`. +- `--sudo` stages the payload over SFTP, then installs with a privileged + stdin script. Use it when the SSH user cannot write the target. +- `/etc/passwd`, `/etc/shadow`, and `/etc/sudoers` require + `--force --bypass-reason=`. +- JSON fields to branch on: `success`, `changed`, `created`, `completion`, + `error_kind`, `before_sha256`, `after_sha256`, `backup.path`, + `rollback_available`. Identical content is success with `changed=false`. + ## SFTP file operations ```bash @@ -434,3 +463,7 @@ sshx --help # full reference adds `--docker=` / `--db-cred-from=` for containerized DBs; SQLite uses `--engine=sqlite --db-file=/abs/path.db`. Direct `psql` / `sqlite3` invocations are blocked — rework as `sshx sql`, do not `--force`. +7. For remote file edits use `sshx apply`, never `sed -i` or upload-then-`install`. + Preview with `--dry-run --json`. Branch on `changed` and `error_kind` + (`precondition` means the file was not written). Validate or reload with a + separate `sshx run` after apply succeeds. diff --git a/tests/e2e/apply_e2e_test.go b/tests/e2e/apply_e2e_test.go new file mode 100644 index 0000000..bfc7d25 --- /dev/null +++ b/tests/e2e/apply_e2e_test.go @@ -0,0 +1,220 @@ +package e2e + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type applyResult struct { + Success bool `json:"success"` + Changed bool `json:"changed"` + Created bool `json:"created"` + Completion string `json:"completion"` + ErrorKind string `json:"error_kind"` + RemotePath string `json:"remote_path"` + BeforeSHA256 string `json:"before_sha256"` + AfterSHA256 string `json:"after_sha256"` + PayloadSHA256 string `json:"payload_sha256"` + RollbackAvailable bool `json:"rollback_available"` + Backup *struct { + Kind string `json:"kind"` + Path string `json:"path"` + RestoreHint string `json:"restore_hint"` + } `json:"backup"` +} + +func TestApplyCreatesOverwritesAndProtectsHash(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + remote := filepath.Join(server.root, "app.conf") + local := filepath.Join(home, "app.conf") + require.NoError(t, os.WriteFile(local, []byte("first\n"), 0o600)) + + base := []string{ + "apply", + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--json", + "--path=" + filepath.ToSlash(remote), + } + env := map[string]string{"SSH_PASSWORD": operatorPassword} + + created := runSSHX(t, home, append(append([]string{}, base...), + "--accept-unknown-host", "--from="+local), env) + require.Equal(t, 0, created.exitCode, created.stderr) + var createdResult applyResult + require.NoError(t, json.Unmarshal([]byte(created.stdout), &createdResult)) + assert.True(t, createdResult.Success) + assert.True(t, createdResult.Created) + assert.True(t, createdResult.Changed) + assert.Equal(t, "completed", createdResult.Completion) + got, err := os.ReadFile(remote) // #nosec G304 -- path is inside this test's temporary SSH root. + require.NoError(t, err) + assert.Equal(t, "first\n", string(got)) + + require.NoError(t, os.WriteFile(local, []byte("second\n"), 0o600)) + wrong := runSSHX(t, home, append(append([]string{}, base...), + "--from="+local, "--expect-sha256="+sha256Hex([]byte("nope\n"))), env) + require.Equal(t, 255, wrong.exitCode, wrong.stderr) + var wrongResult applyResult + require.NoError(t, json.Unmarshal([]byte(wrong.stdout), &wrongResult)) + assert.Equal(t, "precondition", wrongResult.ErrorKind) + assert.Equal(t, "not_started", wrongResult.Completion) + got, err = os.ReadFile(remote) // #nosec G304 -- path is inside this test's temporary SSH root. + require.NoError(t, err) + assert.Equal(t, "first\n", string(got), "hash mismatch must not change the target") + + updated := runSSHX(t, home, append(append([]string{}, base...), + "--from="+local, "--expect-sha256="+sha256Hex([]byte("first\n"))), env) + require.Equal(t, 0, updated.exitCode, updated.stderr) + var updatedResult applyResult + require.NoError(t, json.Unmarshal([]byte(updated.stdout), &updatedResult)) + assert.True(t, updatedResult.Changed) + assert.False(t, updatedResult.Created) + assert.True(t, updatedResult.RollbackAvailable) + require.NotNil(t, updatedResult.Backup) + assert.NotEmpty(t, updatedResult.Backup.Path) + got, err = os.ReadFile(remote) // #nosec G304 -- path is inside this test's temporary SSH root. + require.NoError(t, err) + assert.Equal(t, "second\n", string(got)) + backup := updatedResult.Backup.Path + if !filepath.IsAbs(backup) { + backup = filepath.Join(server.root, backup) + } + saved, err := os.ReadFile(backup) // #nosec G304 -- path is inside this test's temporary SSH root. + require.NoError(t, err) + assert.Equal(t, "first\n", string(saved)) + + same := runSSHX(t, home, append(append([]string{}, base...), "--from="+local), env) + require.Equal(t, 0, same.exitCode, same.stderr) + var sameResult applyResult + require.NoError(t, json.Unmarshal([]byte(same.stdout), &sameResult)) + assert.True(t, sameResult.Success) + assert.False(t, sameResult.Changed) +} + +func TestApplyDryRunDoesNotConnect(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + local := filepath.Join(home, "app.conf") + require.NoError(t, os.WriteFile(local, []byte("payload\n"), 0o600)) + before := server.connections.Load() + + result := runSSHX(t, home, []string{ + "apply", + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--path=/tmp/app.conf", + "--from=" + local, + "--dry-run", + "--json", + }, nil) + require.Equal(t, 0, result.exitCode, result.stderr) + assert.Equal(t, before, server.connections.Load()) + assert.Contains(t, result.stdout, `"mode": "apply"`) + assert.Contains(t, result.stdout, `"would_connect": true`) + assert.Contains(t, result.stdout, `"would_mutate_remote": true`) +} + +func TestApplyRejectsSymlinkAndReadOnlyTarget(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + local := filepath.Join(home, "app.conf") + require.NoError(t, os.WriteFile(local, []byte("payload\n"), 0o600)) + link := filepath.Join(server.root, "linked.conf") + require.NoError(t, os.WriteFile(filepath.Join(server.root, "real.conf"), []byte("orig\n"), 0o600)) + require.NoError(t, os.Symlink(filepath.Join(server.root, "real.conf"), link)) + + env := map[string]string{"SSH_PASSWORD": operatorPassword} + blocked := runSSHX(t, home, []string{ + "apply", + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--json", + "--accept-unknown-host", + "--path=" + filepath.ToSlash(link), + "--from=" + local, + }, env) + require.Equal(t, 255, blocked.exitCode, blocked.stderr) + var blockedResult applyResult + require.NoError(t, json.Unmarshal([]byte(blocked.stdout), &blockedResult)) + assert.Equal(t, "blocked", blockedResult.ErrorKind) + real, err := os.ReadFile(filepath.Join(server.root, "real.conf")) + require.NoError(t, err) + assert.Equal(t, "orig\n", string(real)) + + readerHome := t.TempDir() + readerLocal := filepath.Join(readerHome, "denied.conf") + require.NoError(t, os.WriteFile(readerLocal, []byte("nope\n"), 0o600)) + denied := runSSHX(t, readerHome, []string{ + "apply", + "-h=" + server.host, + "-p=" + server.port, + "-u=reader", + "--no-key", + "--json", + "--accept-unknown-host", + "--path=" + filepath.ToSlash(filepath.Join(server.root, "forbidden.conf")), + "--from=" + readerLocal, + }, map[string]string{"SSH_PASSWORD": readerPassword}) + require.Equal(t, 255, denied.exitCode, denied.stderr) + _, err = os.Stat(filepath.Join(server.root, "forbidden.conf")) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestApplySudoInstallsStagedPayload(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + keyringFile := filepath.Join(home, "keyring.json") + key := "apply-sudo" + env := map[string]string{ + "SSHX_E2E_KEYRING_FILE": keyringFile, + "SSH_PASSWORD": operatorPassword, + } + set := runSSHXWithTestKeyring(t, home, []string{"--password-set=" + key + ":" + operatorPassword, "--no-audit"}, env) + require.Equal(t, 0, set.exitCode, set.stderr) + + remote := filepath.Join(server.root, "sudo.conf") + require.NoError(t, os.WriteFile(remote, []byte("old\n"), 0o644)) // #nosec G306 -- fixture simulates a world-readable config file. + local := filepath.Join(home, "sudo.conf") + require.NoError(t, os.WriteFile(local, []byte("new\n"), 0o600)) + + result := runSSHXWithTestKeyring(t, home, []string{ + "apply", + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--json", + "--accept-unknown-host", + "--sudo", + "-pk=" + key, + "--path=" + filepath.ToSlash(remote), + "--from=" + local, + "--expect-sha256=" + sha256Hex([]byte("old\n")), + }, env) + require.Equal(t, 0, result.exitCode, result.stderr+result.stdout) + var decoded applyResult + require.NoError(t, json.Unmarshal([]byte(result.stdout), &decoded)) + assert.True(t, decoded.Changed) + got, err := os.ReadFile(remote) // #nosec G304 -- path is inside this test's temporary SSH root. + require.NoError(t, err) + assert.Equal(t, "new\n", string(got)) +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +}