From b15e516c0c7307b73e223043389add53bc8b3594 Mon Sep 17 00:00:00 2001 From: jettwang Date: Tue, 11 Aug 2026 12:43:45 +0800 Subject: [PATCH] feat: define agent execution contract and E2E coverage --- .github/workflows/ci.yml | 75 ++++- AGENT.md | 89 +++-- CHANGELOG.md | 27 ++ Makefile | 6 +- README.md | 36 +- README_CN.md | 35 +- docs/SUMMARY.md | 1 + docs/index.md | 19 +- docs/roadmap.md | 256 +++++++++------ docs/zh/index.md | 19 +- internal/app/app.go | 2 +- internal/app/password.go | 28 +- internal/keyringstore/backend_e2e.go | 88 +++++ internal/keyringstore/backend_system.go | 20 ++ internal/sshclient/client.go | 8 +- internal/sshclient/client_test.go | 9 + internal/sshclient/validate.go | 7 +- tests/e2e/cli_e2e_test.go | 299 +++++++++++++++++ tests/e2e/harness_test.go | 418 ++++++++++++++++++++++++ tests/e2e/host_audit_e2e_test.go | 121 +++++++ tests/e2e/keyring_e2e_test.go | 99 ++++++ tests/e2e/sftp_e2e_test.go | 147 +++++++++ 22 files changed, 1619 insertions(+), 190 deletions(-) create mode 100644 internal/keyringstore/backend_e2e.go create mode 100644 internal/keyringstore/backend_system.go create mode 100644 tests/e2e/cli_e2e_test.go create mode 100644 tests/e2e/harness_test.go create mode 100644 tests/e2e/host_audit_e2e_test.go create mode 100644 tests/e2e/keyring_e2e_test.go create mode 100644 tests/e2e/sftp_e2e_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9cf3b5..83e1572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: go-version: ${{ matrix.go }} - name: Cache Go modules - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.cache/go-build @@ -38,12 +38,12 @@ jobs: run: go mod download - name: Run tests - run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + run: go test -v -short -race -coverprofile=coverage.out -covermode=atomic ./... shell: bash - name: Upload coverage to Codecov if: matrix.os == 'ubuntu-latest' - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: files: ./coverage.out flags: unittests @@ -52,6 +52,75 @@ jobs: - name: Build run: go build -v ./cmd/sshx + e2e: + name: E2E (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Download dependencies + run: go mod download + + - name: Prepare isolated macOS keychain + if: runner.os == 'macOS' + shell: bash + run: | + keychain_path="$RUNNER_TEMP/sshx-e2e.keychain-db" + keychain_password="sshx-e2e-ci" + original_keychain="$(security default-keychain -d user | sed 's/^[[:space:]]*\"//; s/\"[[:space:]]*$//')" + keychain_list_file="$RUNNER_TEMP/sshx-e2e-original-keychains.txt" + security list-keychains -d user | sed 's/^[[:space:]]*\"//; s/\"[[:space:]]*$//' > "$keychain_list_file" + original_keychains=() + while IFS= read -r item; do + if [ -n "$item" ]; then original_keychains+=("$item"); fi + done < "$keychain_list_file" + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 3600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security list-keychains -d user -s "$keychain_path" "${original_keychains[@]}" + security default-keychain -d user -s "$keychain_path" + { + echo "SSHX_E2E_KEYCHAIN=$keychain_path" + echo "SSHX_E2E_ORIGINAL_KEYCHAIN=$original_keychain" + echo "SSHX_E2E_ORIGINAL_KEYCHAIN_LIST=$keychain_list_file" + } >> "$GITHUB_ENV" + + - name: Run compiled-binary E2E tests + env: + SSHX_E2E_REAL_KEYRING: ${{ runner.os == 'macOS' && '1' || '0' }} + run: go test -v ./tests/e2e + + - name: Remove isolated macOS keychain + if: always() && runner.os == 'macOS' + shell: bash + run: | + if [ -n "${SSHX_E2E_ORIGINAL_KEYCHAIN:-}" ]; then + security default-keychain -d user -s "$SSHX_E2E_ORIGINAL_KEYCHAIN" + fi + if [ -f "${SSHX_E2E_ORIGINAL_KEYCHAIN_LIST:-}" ]; then + original_keychains=() + while IFS= read -r item; do + if [ -n "$item" ]; then original_keychains+=("$item"); fi + done < "$SSHX_E2E_ORIGINAL_KEYCHAIN_LIST" + if [ ${#original_keychains[@]} -gt 0 ]; then + security list-keychains -d user -s "${original_keychains[@]}" + fi + rm -f "$SSHX_E2E_ORIGINAL_KEYCHAIN_LIST" + fi + if [ -n "${SSHX_E2E_KEYCHAIN:-}" ]; then + security delete-keychain "$SSHX_E2E_KEYCHAIN" || true + fi + lint: name: Lint runs-on: ubuntu-latest diff --git a/AGENT.md b/AGENT.md index 45a849a..0f5bdb2 100644 --- a/AGENT.md +++ b/AGENT.md @@ -10,36 +10,45 @@ Module: `github.com/talkincode/sshx` · Language: Go 1.24 · License: MIT ## 1. Mission -`sshx` is a barrier-free, cross-platform **SSH/SFTP command-line client** with a -built-in **OS-keyring password manager** and **named host configuration**. It -exists to make ad-hoc operations across many remote servers fast and safe: +`sshx` is an **agent-native remote host execution tool over SSH**. SSH/SFTP is +the trusted transport; execution is the product. It turns an agent's intent +into a one-shot remote operation whose target and side effects are explicit, +whose result is machine-decidable, and whose security context is auditable: -> One command, multiple servers, zero password hassle. +> SSH is the channel. X is execution. The core value proposition: -- Run a command (or transfer a file) on a remote host in a single invocation. -- Never type or store passwords in plaintext — they live in the OS keyring and - sudo passwords are auto-filled. -- Address hosts by a short name instead of full connection details. -- Be secure by default (strict host-key verification, command safety guardrails). +- Give agents one stable process contract for target resolution, preview, + execution, structured results, failure classification, and audit evidence. +- Run a command or file action on an existing SSH host in one invocation, with + no resident remote agent and no long-running control plane. +- Reduce decision and retry cost through named hosts, JSON, exit codes, + `error_kind`, timeout, and dry-run plans. +- Protect credentials and trust boundaries through the OS keyring, strict + host-key verification, sudo over stdin, explicit bypasses, and audit redaction. + +Human operators use the same CLI, preview, safety, and audit semantics to +supervise agents and troubleshoot operations. ## 2. Goals -1. **Single self-contained binary** — no runtime dependencies, installable via +1. **Stable agent execution contract** — predictable stdout/stderr, exit codes, + JSON results, failure kinds, previews, and audit semantics. +2. **Single self-contained binary** — no runtime dependencies, installable via `go install`, an install script, or a downloaded release artifact. -2. **Cross-platform parity** — Linux, macOS, and Windows are all first-class. -3. **Secure by default** — strict `known_hosts` verification, keyring-backed +3. **Cross-platform parity** — Linux, macOS, and Windows are all first-class. +4. **Secure by default** — strict `known_hosts` verification, keyring-backed secrets, sudo password delivered over stdin (never interpolated), and command safety checks that block obviously destructive operations. -4. **Low cognitive load** — sensible defaults, named hosts, key-first auth with +5. **Low agent decision cost** — sensible defaults, named hosts, key-first auth with password fallback only when an SSH login password is already provided, and - helpful error messages. -5. **Multi-server ergonomics** — per-host SSH keys and per-host/per-server + classified failures rather than prose-only errors. +6. **Multi-server ergonomics** — per-host SSH keys and per-host/per-server password keys so one tool covers a whole fleet. -6. **Execution preview** — `--dry-run` explains the local execution plan without +7. **Execution preview** — `--dry-run` explains the local execution plan without connecting, executing, reading keyring secrets, or mutating state. -7. **Auditability** — non-dry-run invocations write structured JSONL audit +8. **Auditability** — non-dry-run invocations write structured JSONL audit events under `~/.sshx/audit` by default, with secrets and stdout/stderr excluded. @@ -55,6 +64,11 @@ the project's mission: CLI-only. Do not reintroduce an `mcp-stdio` mode or MCP tools. - ❌ **Daemons / long-running services / connection pools** — every command opens a connection, does its work, and exits. There is no background process. +- ❌ **Resident remote agent / control plane** — do not require a service to be + 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. - ❌ **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. @@ -63,9 +77,10 @@ the project's mission: - ❌ **Bespoke config formats** — configuration is `~/.sshx/settings.json`, environment variables, and CLI flags. Nothing else. -**In scope (welcome):** command execution, SFTP file ops, password/secret -management, named host management, authentication UX, safety checks, and -cross-platform correctness. +**In scope (welcome):** agent execution contracts, command execution, SFTP file +actions, bounded multi-host execution, password/secret references, named host +management, authentication UX, safety checks, auditing, and cross-platform +correctness. ## 4. Architecture @@ -245,9 +260,41 @@ verification for it: - Coverage is tracked (Codecov). Coverage is currently modest; **raising it is an ongoing goal** — prefer adding tests alongside any change you make. +### Capability coverage matrix (mandatory) + +The source of truth is the acceptance matrix in +[`docs/roadmap.md`](docs/roadmap.md). These requirements are **MUST-level**: + +1. Every top-level product capability MUST have at least one Happy Path E2E. +2. Every high-risk capability MUST cover at least one failure path. +3. Every permission-sensitive capability MUST verify at least two roles or + permission states. +4. Every state-changing operation MUST verify recovery or rollback after a + failure. +5. Adding a top-level capability MUST include its E2E and an updated matrix row; + otherwise the change is incomplete. + +Existing unit, component, or local-server tests do not count as CLI E2E unless +they exercise the compiled `sshx` process across the documented external +boundary. Record real evidence paths in the matrix and mark missing coverage as +a gap rather than inferring it. + +Canonical commands: + +```bash +make test-short # unit/component suite without compiled-binary E2E +make test-e2e # compiled sshx process across real SSH/SFTP protocol boundaries +``` + +The E2E source of evidence is `tests/e2e`. Native OS-keyring lifecycle tests are +opt-in locally and run against an ephemeral macOS Keychain in CI; never enable +them against a keyring that cannot be safely isolated and cleaned up. + ## 10. Roadmap -A living, maintainer-adjustable plan. Items must respect the boundaries in §3. +A living, maintainer-adjustable plan. The authoritative product profile, +directions, and acceptance matrix are in [`docs/roadmap.md`](docs/roadmap.md). +Items must respect the boundaries in §3. **Now / recently shipped** diff --git a/CHANGELOG.md b/CHANGELOG.md index 997574e..614f1a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add compiled-binary SSH/SFTP E2E coverage for command execution, structured + results, host trust, permissions, partial completion, dry-run, host import, + SFTP, server-to-server transfer, keyring-backed sudo, and audit recovery. +- Run the E2E suite on Linux and macOS CI, including production-binary checks + against an ephemeral macOS Keychain. + +### Changed + +- Upgrade the CI cache and Codecov actions to their supported major versions. + +### Fixed + +- Make public-key rejection correctly trigger an explicitly configured SSH + password fallback; the previous check matched a server-side error type that + the SSH client does not return. +- Keep local audit write failures visible at error log level without changing + a successfully completed remote command into a false execution failure. + +### Documentation + +- Reposition sshx as an agent-native remote host execution tool: SSH is the + trusted channel and X is execution. Add the efficiency and security model, + hard product boundaries, future directions, and an evidence-based capability + coverage matrix with mandatory E2E floors. + ## [0.0.14] - 2026-07-17 ### Added diff --git a/Makefile b/Makefile index 709b1e4..eae6198 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ GOMOD=$(GOCMD) mod GOFMT=$(GOCMD) fmt GOVET=$(GOCMD) vet -.PHONY: help build build-all test test-verbose test-coverage clean install uninstall run fmt vet lint deps version +.PHONY: help build build-all test test-short test-e2e test-verbose test-coverage clean install uninstall run fmt vet lint deps version version: ## Show the version string used for builds @echo "$(VERSION)" @@ -72,6 +72,10 @@ test-short: ## Run unit tests (skip integration tests) @echo "Running unit tests..." $(GOTEST) -v -short ./... +test-e2e: ## Run compiled-binary SSH/SFTP E2E tests (native keyring is opt-in) + @echo "Running compiled-binary E2E tests..." + $(GOTEST) -v ./tests/e2e + test-verbose: ## Run verbose tests @echo "Running verbose tests..." $(GOTEST) -v -race ./... diff --git a/README.md b/README.md index 74e85c3..03221dd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\ \______/ \______/ \__| \__|\__| \__| -Secure SSH & SFTP Client with Built-in Password Manager +Agent-Native Remote Execution over SSH ```
@@ -44,13 +44,15 @@ English | [简体中文](./README_CN.md) # SSHX -`sshx` is a barrier-free, cross-platform SSH/SFTP command-line client with a built-in system keyring password manager, making it easy to manage and operate multiple remote servers. +> **SSH is the channel. X is execution.** + +`sshx` is an **agent-native remote host execution tool**. It uses SSH/SFTP to reach existing hosts and brings target resolution, execution preview, safety checks, command and file actions, structured results, and audit evidence into one CLI invocation. ## Why You Need It? -Managing multiple servers means juggling different passwords and repeatedly entering sudo passwords. `sshx` securely stores passwords in your system keyring and auto-fills sudo passwords, so you can run commands across many servers without the password hassle. One command, multiple servers, zero password hassle. +Agents do not need another interactive SSH shell. They need a stable, composable remote execution contract with explicit side effects. `sshx` reduces argument assembly through named hosts, removes text guessing through JSON, exit codes, and error kinds, and lowers operational risk through dry-run plans, safety guardrails, the OS keyring, host-key verification, and local auditing. -**New!** Host Configuration Management - Store your frequently used host configurations in `~/.sshx/settings.json` and connect with just a name instead of typing full connection details every time. Each host can have its own SSH private key. Add hosts interactively! +It remains a single binary with one-shot invocations and no resident component on remote hosts: **efficient, secure, and auditable remote execution for agents over SSH.** Human operators use the same command, preview, and audit semantics for supervision and troubleshooting. ## Project Structure @@ -60,13 +62,13 @@ Managing multiple servers means juggling different passwords and repeatedly ente ## Key Features -1. Cross-platform SSH/SFTP operations (supports sudo auto-fill). -2. Direct server-to-server file transfer (`--transfer=: --to=:`), streamed through the local machine without touching local disk. -3. Password management (Keychain / Secret Service / Credential Manager). -4. Host configuration management with per-host SSH keys. -5. Dry-run execution plan preview for humans and agents. -6. Local structured audit trail with safe default redaction. -7. Script execution and command security validation. +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. ## Installation @@ -687,9 +689,14 @@ sudo chmod +x /usr/local/bin/sshx ## Development +The project's target state, hard non-goals, and capability coverage matrix live in the [Project Profile and Direction](docs/roadmap.md). Every new top-level capability must add a Happy Path E2E and update the matrix; high-risk, permission-sensitive, and state-changing capabilities must also meet the corresponding failure, permission-state, and recovery coverage floors. + ```bash -# Run tests -go test ./... +# Run fast unit/component tests +make test-short + +# Run compiled-binary SSH/SFTP E2E tests +make test-e2e # Format code gofmt -w . @@ -703,6 +710,9 @@ make lint > The lint target requires `golangci-lint` v2.6.1 or newer. Install it with `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.1`. +The normal E2E run uses an isolated, test-only keyring provider. CI additionally +checks the production binary against an ephemeral macOS Keychain. + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/README_CN.md b/README_CN.md index 9d3fcbf..ff08369 100644 --- a/README_CN.md +++ b/README_CN.md @@ -11,7 +11,7 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\ \______/ \______/ \__| \__|\__| \__| -内置密码管理器的安全 SSH 和 SFTP 客户端 +面向 Agent 的远程主机执行工具 ```
@@ -44,11 +44,15 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\ # SSHX -`sshx` 是一个无障碍、跨平台的 SSH/SFTP 命令行客户端,内置基于系统密钥链的密码管理器,让你轻松管理和操作多台远程服务器。 +> **SSH 是通道,X 代表执行。** + +`sshx` 是一个**面向 Agent 的远程主机执行工具**。它通过 SSH/SFTP 连接现有主机,把目标解析、执行预览、安全检查、命令与文件动作、结构化结果和审计留痕收敛到一次 CLI 调用中。 ## 为什么你需要它? -管理多台服务器时,记住不同的密码、反复输入 sudo 密码都很繁琐。`sshx` 将密码安全存储在系统密钥链中,自动填充 sudo 密码,让你在多台服务器上执行命令时不再为密码烦恼。一个命令,多台服务器,零密码困扰。 +Agent 需要的不是另一个交互式 SSH shell,而是一份稳定、可组合、能解释副作用的远程执行契约。`sshx` 用命名主机减少参数拼装,用 JSON、退出码和错误分类减少文本猜测,用 dry-run、安全护栏、系统密钥链、host-key 校验和本地审计降低误操作与凭据风险。 + +它保持单二进制、单次调用、无远端驻留组件:**让 Agent 通过 SSH,高效、安全、可审计地在远程主机上完成任务。** 人类运维者也使用同一套命令、预览与审计语义进行监督和排障。 ## 项目结构 @@ -58,13 +62,13 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\ ## 核心特性 -1. 跨平台 SSH/SFTP 操作(支持 sudo 自动填充)。 -2. 服务器到服务器直接文件传输(`--transfer=: --to=:`),数据经本机中转流式传输,不落本地磁盘。 -3. 密码管理(Keychain / Secret Service / Credential Manager)。 -4. 主机配置管理,支持为每台主机配置独立的 SSH 密钥。 -5. 面向人和 agent 的 dry-run 执行计划预览。 -6. 本地结构化审计日志,并默认做安全脱敏。 -7. 脚本执行和命令安全验证。 +1. Agent 友好的 JSON、稳定退出码、stdout/stderr 分离和错误分类。 +2. dry-run 执行计划预览,以及默认启用、自动脱敏的本地结构化审计。 +3. 命名主机管理和 OpenSSH config 选择性导入,支持每台主机独立 SSH key。 +4. 严格 host-key 校验、危险命令护栏和显式安全绕过语义。 +5. 系统密钥链密码管理和通过 stdin 完成的 sudo 自动填充。 +6. 跨平台 SSH/SFTP 命令与文件动作。 +7. 服务器到服务器直接文件传输,数据经本机流式中转而不落地。 ## 安装 @@ -603,9 +607,14 @@ sudo chmod +x /usr/local/bin/sshx ## 开发 +项目的目标状态、非目标铁律和业务能力覆盖矩阵维护在[项目画像与方向](docs/roadmap.md)。新增一级业务能力必须同步增加 Happy Path E2E 并更新矩阵;高风险、权限和状态修改能力还必须满足相应失败、权限差异与恢复覆盖底线。 + ```bash -# 运行测试 -go test ./... +# 运行快速单元/组件测试 +make test-short + +# 运行编译后二进制 SSH/SFTP E2E +make test-e2e # 格式化代码 gofmt -w . @@ -619,6 +628,8 @@ make lint > lint 目标需要 `golangci-lint` v2.6.1 或更高版本。使用 `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.1` 安装。 +常规 E2E 使用仅供测试的隔离 keyring 后端;CI 还会让生产构建连接临时 macOS Keychain,验证真实系统 keyring 生命周期。 + ## 许可证 本项目采用 MIT 许可证 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。 diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 3f3d041..d1f50a1 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -16,3 +16,4 @@ - [使用场景](zh/usage-scenarios.md) - [安全准则](zh/security-guidelines.md) - [故障排查](zh/troubleshooting.md) +- [Project Profile / 项目画像与方向](roadmap.md) diff --git a/docs/index.md b/docs/index.md index 1c179b3..033cec2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,10 @@ # SSHX Documentation -`sshx` is a cross-platform SSH and SFTP command-line client for people and automation agents who operate many remote servers. It keeps the tool shape simple: one command opens one SSH session, does the requested work, writes an optional local audit event, and exits. +> **SSH is the channel. X is execution.** + +`sshx` is an agent-native remote host execution tool. It uses SSH/SFTP to reach existing hosts and brings target resolution, execution preview, safety checks, command and file actions, structured results, and audit evidence into one CLI invocation. + +It keeps the operating model simple: one command opens one connection, performs one explicit action, returns a decidable result, writes a local audit event, and exits. Nothing needs to be installed on the remote host and no long-running control plane is introduced. The documentation starts in English by default. Use the language switch in the top navigation bar to open the matching Chinese page. @@ -16,22 +20,22 @@ The documentation starts in English by default. Use the language switch in the t ## Mental Model -Think of `sshx` as a safer one-shot remote operation helper, not a shell replacement and not a remote orchestration platform. +Think of `sshx` as a remote execution primitive in an agent's toolbox, not an interactive shell replacement and not a desired-state or workflow orchestration platform. ```text -human, script, or agent +agent, automation, or human operator | v -sshx CLI flags and optional .env +agent contract: CLI / JSON / exit code / dry-run | v -named host resolution and safety checks +X execution: target / safety / action / audit | v -SSH command or SFTP action +SSH channel: auth / host key / SSH exec / SFTP | v -structured result, exit code, optional audit event +remote host ``` ## Common First Commands @@ -67,6 +71,7 @@ Read [Security Guidelines](security-guidelines.md) before using `sshx` in produc ## Where To Go Next +- [Project Profile and Direction](roadmap.md) defines the product position, hard non-goals, and acceptance matrix (currently maintained in Chinese). - [Getting Started](getting-started.md) gets one host working. - [Host Management](host-management.md) explains named hosts and key selection. - [Usage Scenarios](usage-scenarios.md) gives practical examples for daily operations. diff --git a/docs/roadmap.md b/docs/roadmap.md index e02c742..8ae6f16 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,180 +1,222 @@ # sshx 项目画像与方向 -## 项目概述 - -`sshx` 是一个单二进制、跨平台的 SSH/SFTP 命令行工具,面向需要频繁操作多台远程服务器的人和自动化 agent。它的核心价值是:一次命令完成远程执行或文件操作,密码和 sudo 凭据交给系统密钥链保管,常用服务器可用短名称访问。 - -项目的运行方式保持简单:每次调用读取 CLI 参数、环境变量和可选的主机配置,建立一条直连 SSH 会话,完成命令或 SFTP 操作后退出。它不是后台服务,也不维护长期连接。 +> **SSH is the channel. X is execution.** +> +> **SSH 是通道,X 代表执行。** -```text -用户 / 脚本 / AI agent - | - v -cmd/sshx/main.go - | - v -internal/app - - 参数解析、命令分发 - - 主机配置管理 ~/.sshx/settings.json - - 密码管理与 keyring 交互 - | - v -internal/sshclient - - SSH 连接与认证 - - host-key 校验 - - 远程命令执行 / SFTP - - sudo stdin 注入与危险命令拦截 - | - +--------------------+ - | | - v v -远程 SSH/SFTP 服务 OS keyring / known_hosts -``` +## 项目定位 -## 项目画像(目标状态) - -`sshx` 做好之后,应当是一个可以被人类终端和自动化系统共同信任的远程操作工具:人类使用时命令短、反馈清楚、默认安全;agent 使用时输出稳定、退出码可判断、失败原因可机读;事后排查时能追溯关键操作的来源、目标、结果和安全上下文。 - -项目优先级是:安全默认值、数据正确性和可审计溯源优先于便利性;清晰的单次调用语义优先于复杂的长期会话能力;跨平台一致性优先于某个平台的深度特性。任何新能力都应服务于“一次调用、明确结果、低认知负担、事后可解释”的体验。 - -安全设计不是附加功能,而是产品边界。默认行为应保护 host-key、凭据和 sudo 密码路径;绕过安全检查必须显式、可见,并且不应让用户误以为 `sshx` 是不可信命令的沙箱。审计溯源也应遵守同一边界:记录足够解释操作链路的元数据,但不把 secret、sudo 密码、私钥内容或高敏 stdout/stderr 写进日志。 - -## 当前能力清单 +`sshx` 是一个**面向 Agent 的远程主机执行工具**。它以 SSH/SFTP 作为已有、成熟、普遍可达的可信通道,把 Agent 的执行意图转换为一次边界清楚、结果可判断、过程可审计的远程主机操作。 -- SSH 命令执行 +这一定义刻意把产品重心从“SSH 客户端”移到“远程执行”上: - 支持 `sshx -h= [options] `,默认直连远程主机并执行一次命令;远程命令退出码会透传为 `sshx` 自身退出码。证据:`cmd/sshx/main.go`、`internal/app/app.go`、`internal/sshclient/client.go`、`internal/app/usage.go`。 +- **SSH 是通道**:负责连接、认证、加密、主机身份校验和文件传输,但不是 sshx 的全部产品价值。 +- **X 是执行**:负责目标解析、执行预览、安全检查、命令或文件动作、结果结构化、失败分类和审计留痕。 +- **Agent 是首要调用者**:CLI 不是纯人类交互界面,而是一份稳定的进程级工具契约;人类运维者与 Agent 共用同一套目标、安全和审计语义。 -- Agent / 脚本模式 +一句话定位: - `--json` 会在 stdout 输出单个结构化 JSON 对象,包含 `exit_code`、`success`、`stdout`、`stderr`、`duration_ms`、`auth_method`、`error_kind` 等字段;stderr 仍用于诊断信息。证据:`internal/app/app.go`、`internal/app/usage.go`、`skills/sshx/SKILL.md`。 +> **让 Agent 通过 SSH,高效、安全、可审计地在远程主机上完成任务。** -- 命令超时与 PTY 选择 +英文定位: - `--timeout` 可限制远程命令运行时间;默认不使用 PTY,以保持 stdout/stderr 分离,`--pty` 仅在需要终端语义时显式启用,且不能与 `--json` 组合。证据:`internal/app/config.go`、`internal/app/app.go`、`internal/sshclient/client.go`。 +> **Agent-native remote execution over SSH.** -- SFTP 文件操作 +## 项目概述 - 支持上传、下载、列目录、创建目录、删除远程路径等单次 SFTP 操作。证据:`internal/app/config.go`、`internal/sshclient/client.go`、`internal/app/usage.go`。 +Agent 操作远程主机时,真正的困难通常不在“能否建立 SSH 连接”,而在于如何稳定地回答以下问题:目标是哪台主机、使用什么身份、将执行什么、是否越过安全边界、执行是否成功、失败发生在哪一层、事后能否解释这次操作。`sshx` 应把这些重复且高风险的细节收敛为一个短命令和一份稳定结果。 -- 系统密钥链密码管理 +项目保持单二进制、跨平台、无远端驻留组件的形态。每次调用解析目标和约束,建立 SSH/SFTP 连接,执行一个明确动作,返回结果并退出;本地配置、系统 keyring、`known_hosts` 和审计记录共同构成执行所需的最小信任环境。 - 密码存储在系统 keyring 服务 `sshx` 下,支持设置、检查、获取、删除和常见 key 探测;交互式终端上 `--password-get` 不直接打印明文密码,只有管道或重定向时输出原始值。证据:`internal/app/password.go`、`internal/sshclient/validate.go`、`CHANGELOG.md`。 +```text +Agent / 自动化 / 人类运维者 + | + v + Agent 契约层 + - CLI / JSON / 退出码 / error_kind + - dry-run / timeout / audit context + | + v + X 执行层 + - 目标发现与解析 + - 动作分类与安全检查 + - 命令、SFTP、主机间传输 + - 结果归一化与审计留痕 + | + v + SSH 通道层 + - 加密连接与认证 + - host-key 信任 + - SSH exec / SFTP + | + v + 远程主机 + +本地信任边界:settings.json / OS keyring / known_hosts / audit JSONL +``` -- 命名主机配置 +## 项目画像(目标状态) - `~/.sshx/settings.json` 保存主机短名称、地址、端口、用户、描述、系统类型、每主机 SSH key 和密码 key;支持添加、更新、列表、连接测试、全量连接测试和删除。配置写入采用临时文件加 rename 的方式,并设置为 `0600`。证据:`internal/app/settings.go`、`internal/app/host_manager.go`、`internal/app/usage.go`。 +`sshx` 做好之后,应成为 Agent 工具箱里的“远程执行基本件”:像调用本地进程一样容易组合,又明确承认远程操作具有凭据、权限、网络和破坏性副作用。Agent 不需要模拟交互式终端,不需要从自然语言日志猜测结果,也不需要在每个任务中重新拼装 SSH 参数、sudo 注入、安全检查和审计逻辑。 -- 认证路径 +### 效率画像 - 默认先尝试 SSH key;只有调用方已提供 SSH 登录密码时,服务端拒绝 key 后才会回退到密码认证。`--no-key` / `--password-only` 可强制密码模式;命名主机的 `password_key` 主要用于 sudo 自动填充。证据:`internal/sshclient/client.go`、`internal/app/host_manager.go`、`skills/sshx/SKILL.md`。 +效率不是单纯缩短 SSH 握手时间,而是减少 Agent 完成一次可靠远程操作所需的决策、调用和返工: -- host-key 校验 +- **意图表达短**:命名主机和可复用配置把地址、端口、用户、key 与 sudo key 收敛到稳定目标名。 +- **一次调用闭环**:发现或解析目标、执行动作、返回结构化结果、形成审计记录,不要求 Agent 维持交互会话。 +- **机器判断直接**:stdout、stderr、退出码、`success` 与 `error_kind` 各自职责稳定,Agent 不依赖脆弱文本匹配。 +- **执行前少返工**:dry-run 能在连接、读取 secret 或修改状态前暴露目标解析、sudo、安全绕过和副作用意图。 +- **失败后快恢复**:错误能区分配置、连接、认证、host-key、超时、命令退出和安全阻断,避免盲目重试。 +- **数据移动少落地**:远端到远端传输可以流式中转,不要求先写入本地磁盘。 - 默认使用 `known_hosts` 严格校验主机 key;未知主机或变更 key 会阻止连接。`--accept-unknown-host` 和 `--insecure-hostkey` 是显式 opt-in。证据:`internal/sshclient/client.go`、`AGENT.md`。 +### 安全画像 -- sudo 密码自动填充 +安全不是一句“危险命令检测”,而是一组贯穿执行生命周期的边界: - 识别以 `sudo` 开始的命令后,从 keyring 读取密码,并通过 stdin 传入 `sudo -S -p ''`,不把密码拼进命令字符串。证据:`internal/sshclient/client.go`、`internal/sshclient/validate.go`。 +- **凭据边界**:secret 默认进入 OS keyring,不进入配置、命令字符串、审计记录或普通终端回显。 +- **通道边界**:默认严格校验 host key,未知或变更的远端身份不会被静默接受。 +- **权限边界**:SSH 登录身份与 sudo secret 语义分离;特权执行必须可识别、可预览、可追溯。 +- **动作边界**:明显破坏性操作默认阻断;绕过必须显式且进入结果与审计上下文。 +- **副作用边界**:Agent 在执行前能知道动作是否会连接、读取 secret、修改本地状态或修改远端状态。 +- **责任边界**:记录足够解释“谁通过什么入口,对哪台主机,以何种安全上下文做了什么,结果如何”,同时默认不持久化敏感输出。 -- 危险命令防护 +安全、正确性与可审计性高于便利和吞吐;在发生冲突时,宁可要求调用者显式表达意图,也不静默降级信任边界。效率优化必须减少无意义摩擦,但不能用模糊目标、隐式凭据选择或不可解释的并发换速度。 - 默认拦截一组明显破坏性命令,例如删除根目录、格式化磁盘、fork bomb、`curl | sh`、关机重启和关键系统文件覆盖;`--force` 或 `--no-safety-check` 可显式绕过。证据:`internal/sshclient/validate.go`、`internal/app/app.go`、`internal/app/usage.go`。 +### 产品边界画像 -- 构建、发布和质量守护 +`sshx` 是执行工具,不是远程主机上的 Agent,也不是持续运行的控制平面。它应在现有 SSH 基础设施上提供一个清晰的 Agent 执行契约,而不是要求每台服务器安装新服务。它可以支持受控的批量执行和更强的执行描述,但不承担期望状态管理、工作流编排、资产治理或组织级审批系统的全部职责。 - Makefile 提供构建、测试、覆盖率、lint、跨平台编译和安装目标;CI 在 Ubuntu 和 macOS 上使用 Go 1.24 运行测试、race、覆盖率、lint 和安全扫描;release workflow 在 tag push 时构建 Linux、macOS、Windows 产物并生成 checksums,并在配置 `HOMEBREW_TAP_TOKEN` 后自动渲染、推送 Homebrew tap formula(`talkincode/homebrew-tap`)。证据:`Makefile`、`.github/workflows/ci.yml`、`.github/workflows/release.yml`、`RELEASE.md`。 +## 当前能力清单 -## 非目标(铁律) +- **单次远程命令执行** -- 不重新引入 MCP server、`mcp-stdio` 模式或 MCP tools。`sshx` 的产品形态是 CLI。 + 支持 `sshx -h= [options] `,默认不启用 PTY,保持 stdout/stderr 分离,并透传远程命令退出码。支持 timeout、显式 PTY 和 sudo stdin 注入。证据:`internal/app/app.go`、`internal/app/config.go`、`internal/sshclient/client.go`、`internal/sshclient/runcommand_test.go`。 -- 不引入守护进程、后台服务、连接池或长期会话管理。每次命令都应建立连接、执行、退出。 +- **Agent 结构化结果契约** -- 不做 GUI 或 TUI。交互面保持在 flags、stdout、stderr 和机器可读 JSON。 + `--json` 输出单个 JSON 对象,包含成功状态、退出码、输出、耗时、认证方式和 `error_kind`;sshx 自身失败与远程命令失败可以区分。证据:`internal/app/app.go`、`internal/app/agentmode_test.go`、`internal/app/usage.go`。 -- 不做完整 OpenSSH 替代品。不覆盖交互式登录 shell 复用、端口转发、隧道、SOCKS proxy、X11 forwarding 或 agent forwarding。 +- **执行计划预览** -- 不提供明文 secret 存储。凭据默认只进入 OS keyring;inline password 只能作为高风险便利入口存在,并应持续被明确提示。 + `--dry-run` 在建立连接、执行动作、读取 keyring secret、更新 `known_hosts` 或写配置前生成本地执行计划;可与 `--json` 组合供 Agent 判断。证据:`internal/app/dryrun.go`、`internal/app/agentmode_test.go`、`internal/app/transfer_test.go`。 -- 不扩展出新的私有配置格式。配置边界是 CLI flags、环境变量、`.env` 和 `~/.sshx/settings.json`。 +- **命名主机发现与管理** -- 不把危险命令防护宣传成安全沙箱。它只能拦截常见误操作,不能承诺执行不可信命令是安全的。 + `~/.sshx/settings.json` 保存命名主机、地址、端口、用户、key 和 password key;支持增删改查、单台或全量连接测试,并可从 `~/.ssh/config` 选择性、全有或全无地导入合格主机。配置写入使用私有权限和原子替换。证据:`internal/app/settings.go`、`internal/app/host_manager.go`、`internal/app/sshconfig.go`、`internal/app/settings_test.go`、`internal/app/sshconfig_test.go`。 -- 不把审计能力做成企业 SIEM、集中式合规平台或不可篡改账本。`sshx` 可以提供结构化溯源材料,但不承担组织级审计系统的全部职责。 +- **SFTP 与主机间文件执行动作** -- 不为了局部平台特性牺牲 Linux、macOS、Windows 的一等支持。 + 支持上传、下载、列表、建目录、删除,以及两台远端主机之间经本机流式中转的文件或目录传输。证据:`internal/sshclient/client.go`、`internal/sshclient/transfer.go`、`internal/app/transfer.go`、`internal/app/transfer_test.go`。 -## 方向与意图 +- **凭据与认证边界** -- 提升核心路径可信度 + 密码存放在系统 keyring;默认优先 SSH key,只有显式提供 SSH 登录密码时才回退密码认证;命名主机可独立选择 SSH key 和 sudo password key。证据:`internal/app/password.go`、`internal/sshclient/client.go`、`internal/sshclient/client_test.go`。 - 命令执行、认证回退、host-key 校验、sudo stdin、settings 原子写入、JSON 契约和 SFTP 操作是项目的负重路径。未来改动应让这些路径更容易被测试守护、更容易定位失败,而不是扩大不受控行为面。 +- **通道信任与动作护栏** -- 改善命名主机的规模化体验 + 默认通过 `known_hosts` 严格校验 host key;未知主机接受和不安全校验必须显式开启。明显破坏性命令默认被拦截,`--force` / `--no-safety-check` 是显式绕过。证据:`internal/sshclient/client.go`、`internal/sshclient/validate.go`、`internal/sshclient/client_test.go`、`internal/sshclient/validate_test.go`。 - 当用户管理的主机数量增加时,`--host-list`、连接测试和配置编辑需要更易扫描、更少出错。标签、分组、丰富列表输出或更顺手的编辑体验都属于这个方向,但必须保持 `settings.json` 简单、可审阅、可迁移。 +- **本地结构化审计** -- 让密码 key 发现和命名更一致 + 非 dry-run 调用默认写入本地 JSONL 审计事件,记录目标、动作、安全上下文、结果和耗时,排除 stdout/stderr,并对命令中的 secret-like 参数做尽力脱敏。证据:`internal/app/audit.go`、`internal/app/audit_test.go`。 - 现有 keyring API 限制导致 `--password-list` 只能探测常见 key。未来方向是减少“密码存在但用户不知道 key 名”的摩擦,同时不引入明文索引、不泄露基础设施命名、不破坏系统 keyring 作为信任根的边界。 +- **跨平台交付** -- 强化 agent 友好契约 + 项目以单二进制形式面向 Linux、macOS 和 Windows,支持 Go 安装、安装脚本、Release 产物和 Homebrew tap。证据:`Makefile`、`.github/workflows/ci.yml`、`.github/workflows/release.yml`、`install.sh`、`install.ps1`。 - `--json`、退出码、`error_kind`、stdout/stderr 分离和 timeout 是 agent 使用的核心契约。后续能力应尽量让程序可以分支处理失败,而不是解析自然语言日志。 +## 非目标(铁律) -- 扩展 SFTP 的实用范围 +- **不把 SSH 本身重新实现一遍。** 不追求交互式 shell 复用、通用端口转发、SOCKS、X11 或 agent forwarding;SSH 是底层通道,不是功能竞赛对象。 - 递归上传/下载、glob 等能力可以提升文件操作效率。扩展时应保持“一次调用完成一个明确操作”的语义,不把 SFTP 做成长期交互式文件管理器。 +- **不在远端安装驻留 Agent。** 不引入守护进程、后台服务、连接池或常驻控制面;一次调用建立连接、执行、返回并退出。 -- 支持多主机 fan-out 操作 +- **不成为 Ansible、Salt 或工作流引擎。** 可以提供有界的多主机执行,但不引入期望状态语言、playbook 生态、调度系统或长期任务编排。 - 在不引入 daemon 或连接池的前提下,允许对多个命名主机执行同一类检查或命令,并输出聚合报告。这是 `--host-test-all` 思路的自然延伸,目标是 fleet 级可观察结果,而不是长期编排系统。 +- **不在核心二进制内重新引入 MCP server。** CLI 和进程级结构化契约是稳定集成面;需要 MCP 或其他协议时,应由外部适配层调用 sshx,而不是扩张核心运行模型。 -- 支持受控的跳板访问 +- **不把危险命令防护宣传成沙箱。** sshx 降低误操作和凭据泄露风险,但不承诺安全执行恶意或不可信命令。 - 对私有网络主机,ProxyJump 风格能力可以降低运维摩擦。该方向必须保持 host-key 校验、认证路径和错误报告清晰,不能演变成通用隧道或代理产品。 +- **不成为 CMDB、企业 secret vault 或 SIEM。** sshx 可消费主机配置、使用本地 secret backend、生成审计证据,但不替代组织级资产、密钥和合规平台。 -- 建立可审计溯源能力 +- **不提供明文 secret 存储,也不静默放松 host-key 校验。** 便利性不能突破凭据与通道信任边界。 - 对执行过的操作形成结构化溯源材料,应成为重点方向。记录对象应优先覆盖时间、调用入口、人类/脚本/agent 可识别来源、目标主机、解析后的命名主机、远程用户、认证方式、host-key 决策、命令或 SFTP 意图、是否使用 sudo、是否绕过安全检查、退出状态、错误类别和耗时。审计应默认不记录 secret,不捕获敏感 stdout/stderr,且不改变一次调用即退出的模型。 +- **不做 GUI/TUI。** 核心交互面保持为 flags、stdin、stdout、stderr、退出码和结构化文件;图形化体验属于外部工具。 -- 让审计材料可被本地排查和自动化系统消费 +- **不为了局部平台能力牺牲跨平台一等支持。** Linux、macOS 和 Windows 的核心执行契约必须保持一致。 - 审计结果应兼顾人读和机器处理,能够与 `--json` 契约、退出码和 `error_kind` 对齐。未来能力应让用户可以回答“这次操作从哪里来、实际打到了哪台机器、用了什么凭据路径、为什么失败或成功”,而不是只能翻自然语言终端输出。 +## 方向与意图 -- 允许 secret backend 演进但不放松信任边界 +- **把“执行单元”变成稳定产品契约** - 如果未来支持可插拔 secret backend,默认仍应是 OS keyring,并且所有 backend 都必须遵守“不落明文、不经命令字符串传 sudo 密码、不静默降级”的原则。 + 每次执行都应能明确表达目标、动作、约束、副作用、安全上下文和结果。无论动作是命令、文件操作还是未来的批量执行,Agent 都能用同一套心智模型预览、执行、判断和审计,而不需要理解内部 SSH 细节。 -## 完成的样子 +- **降低复杂命令与脚本的传递损耗** -`sshx` 的路线图不是以功能数量衡量,而是以远程操作是否更可靠、更可判断、更不容易误伤来衡量。当人和 agent 都能在多数日常服务器操作中用一次命令得到明确结果,并且安全边界没有被便利性侵蚀,项目方向才算成立。 + 远程命令经本地 shell、参数解析和远端 shell 多层解释时容易发生引用、通配和变量展开损坏。sshx 应让 Agent 能可靠传递复杂执行内容,并保持“实际执行内容”在 dry-run、审计和结果中的语义一致;具体输入形态由实现阶段选择。 -- 核心执行契约稳定 +- **建立有界的多主机执行能力** - 人类模式下输出清楚,agent 模式下 stdout 可直接解析;远程命令失败和 `sshx` 自身失败可以稳定区分;timeout、认证失败、host-key 失败、危险命令阻断都有可观察、可分支的结果。 + Agent 应能对主机集合执行同一检查或动作,并得到逐主机、可聚合、可部分失败的结构化结果。并发必须有界,目标集合必须可预览,失败不能被总成功状态吞掉;该方向服务于执行效率,但不演变成持续编排平台。 -- 凭据和 host-key 路径没有绕路 +- **从命令黑名单走向可解释的执行治理** - 密码仍由系统 keyring 管理,sudo 密码仍只通过 stdin 传递,host-key 默认严格校验;任何绕过都必须是显式选择,且用户能从命令或输出上看出来。 + 在现有危险命令防护之外,逐步增强动作分类、只读与变更意图表达、安全绕过原因、调用来源或 run ID 等上下文,使人类审批层或上层 Agent 能基于明确证据决策。治理信息不得伪装成绝对安全保证。 -- 主机配置适合小团队和个人长期维护 +- **强化目标与身份的可发现性** - `settings.json` 可读、权限正确、写入安全;主机越多时,用户仍能快速知道每个主机使用的地址、用户、key、password key 和连接状态。 + 主机导入、列表、分组、标签、连接健康和凭据引用应让 Agent 快速找到正确目标,同时避免把基础设施秘密复制到更多位置。规模化体验仍以简单、可审阅、可迁移的本地配置为底线。 -- 自动化使用不会依赖脆弱文本解析 +- **提升失败恢复效率** - 重要状态应通过退出码、JSON 字段或稳定结构表达。自由文本日志可以辅助人读,但不能成为 agent 判断成败的唯一依据。 + 连接、认证、host-key、权限、超时、远程退出、部分传输和本地持久化失败应拥有稳定分类与足够上下文。对于会修改状态的动作,结果需要帮助调用者判断“未开始、部分完成、已完成但回执异常”,减少危险重试。 -- 关键操作可审计、可回看、可解释 +- **扩展文件与受控网络边界执行** - 对远程命令、SFTP 操作、host-key 信任变更、安全检查绕过和配置变更,应能形成结构化记录或等价的溯源材料。记录能支持本地排查和自动化汇总,同时不泄露 secret,不默认持久化高敏命令输出。 + 继续完善递归文件操作、传输完整性和失败恢复;在需要进入私有网络时,可考虑受控的 jump-host 能力,但不得扩展成通用隧道产品,也不得模糊每一跳的 host-key 与认证决策。 -- 新能力没有突破 CLI-only 边界 +- **保持 secret backend 可演进** - 即使支持更多主机、更强 SFTP 或跳板访问,项目仍保持单二进制、单次调用、无后台服务、无 GUI/TUI、无 MCP 的形态。 + 默认信任根仍是 OS keyring。未来若接入其他 secret backend,必须保持 secret 不落明文、不进入命令字符串、不静默降级、用途可区分,并让 Agent 只引用凭据而非读取凭据。 -- 质量守护跟得上风险 +## 完成的样子 - 安全相关逻辑、认证分支、配置写入、JSON 契约、SFTP 行为和跨平台差异应有自动化检查守护。具体测试形式可按代码实际选择,但关键回归应能在本地 `make check` 或 CI 中被挡下。 +`sshx` 的成功不以支持多少 SSH flag 衡量,而以 Agent 能否用更少步骤完成一次可信远程执行衡量。 + +- Agent 用一个稳定目标名和一个明确动作即可发起执行,不需要重复处理地址、端口、用户、key、sudo secret 与 host-key 细节。 +- dry-run 所展示的目标、动作、安全绕过和副作用,与真实执行及审计记录保持同一语义。 +- 人类输出清楚,机器输出稳定;所有一级失败都有可分支的类别,远程命令失败不会与 sshx 自身失败混淆。 +- 明显危险动作默认受阻,特权执行与安全绕过显式可见;secret 不出现在普通配置、命令拼接、审计记录或默认终端回显中。 +- 多主机执行即使部分失败,也能逐主机说明状态,并避免不受控并发和盲目重试。 +- 会修改远端状态的操作能够说明是否执行、是否部分完成以及下一步如何安全判断,而不是只返回一个模糊 EOF 或通用错误。 +- 项目继续保持单二进制、无远端驻留组件、无核心 MCP server、无长期控制面的轻量边界。 +- 每项一级能力都有覆盖真实 CLI 与真实 SSH/SFTP 边界的验收证据;安全与状态修改路径同时覆盖失败和恢复语义。 + +## 验收矩阵(业务能力覆盖矩阵) + +> 覆盖底线(硬性规定): +> +> 1. 每个一级功能至少有一条 Happy Path E2E。 +> 2. 每个高风险功能至少覆盖一条失败路径。 +> 3. 每个涉及权限的功能至少验证两种角色或权限状态。 +> 4. 每个会修改系统状态的操作至少验证一次失败后的恢复或回滚。 +> 5. 每次新增一级业务功能,必须同步新增对应 E2E 并更新本矩阵。 + +当前仓库已建立 `tests/e2e` 编译后二进制验收套件:测试进程通过真实 TCP SSH/SFTP 协议连接隔离服务端,并从进程退出码、stdout/stderr、JSON、远端文件/状态、`known_hosts`、settings、keyring 和审计 JSONL 观察结果。默认 keyring 场景使用仅在 `sshx_e2e` 构建标签下启用的隔离后端;macOS CI 还会创建临时系统 Keychain,验证生产二进制跨真实 OS keyring 的完整生命周期。组件测试不计作 CLI E2E,表内证据按实际边界标注。 + +| 一级功能 | 风险 | 权限 | 修改状态 | Happy Path E2E | 失败路径 | 权限状态覆盖 | 失败恢复/回滚 | 现有证据 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 单次远程命令执行 | 高 | 是 | 可能 | ✅ | ✅ 远端非零/超时/异常断连 | ✅ operator/reader | ✅ 部分完成后重读状态 | `tests/e2e/cli_e2e_test.go` | +| Agent JSON / 退出码契约 | 高 | 否 | 否 | ✅ | ✅ stdout/stderr、远端非零与分类失败 | 不适用:只描述结果 | 不适用:不修改状态 | `tests/e2e/cli_e2e_test.go` | +| dry-run 执行预览 | 中 | 否 | 否 | ✅ | ✅ 组件级无效计划 | 不适用:不获取远端权限 | ✅ 证明零连接、零信任/审计写入 | `tests/e2e/cli_e2e_test.go`、`internal/app/agentmode_test.go` | +| 命名主机管理与 SSH config 导入 | 中 | 否 | 是,本地 | ✅ 导入后按别名执行 | ✅ 选择项缺失 | 不适用:单用户本地配置 | ✅ 失败选择不部分写入 | `tests/e2e/host_audit_e2e_test.go` | +| SFTP 上传/下载/目录操作 | 高 | 是 | 是,远端 | ✅ | ✅ 只读端拒绝写入 | ✅ operator/reader | ✅ 失败上传无目标残留 | `tests/e2e/sftp_e2e_test.go` | +| 远端到远端传输 | 高 | 是,两端 | 是,两端 | ✅ | ✅ 目标端只读 | ✅ 可写/只读目标 | ✅ 失败无残留,改用可写端重试 | `tests/e2e/sftp_e2e_test.go` | +| keyring 凭据管理与认证回退 | 高 | 是 | 是,本地 secret | ✅ | ✅ 缺失 secret/公钥被拒 | ✅ key/password-fallback、stored/missing | ✅ 删除后缺失;可重新设置 | `tests/e2e/keyring_e2e_test.go`、`tests/e2e/cli_e2e_test.go` | +| host-key 校验 | 高 | 是,信任状态 | 可能修改 `known_hosts` | ✅ 显式信任后严格复用 | ✅ 未知/变更 key | ✅ strict/accept-unknown | ✅ 首次写入后重新严格连接 | `tests/e2e/cli_e2e_test.go` | +| 危险动作阻断与显式绕过 | 高 | 是 | 否,仅控制执行准入 | ✅ 显式 `--force` | ✅ 默认阻断且零连接 | ✅ 默认阻断/显式绕过 | 不适用:策略门本身不修改状态 | `tests/e2e/cli_e2e_test.go` | +| 本地结构化审计 | 高 | 否 | 是,本地 | ✅ | ✅ 不可写目标可观测 | 不适用:本地调用者同权 | ✅ 修复目标后单事件写入 | `tests/e2e/host_audit_e2e_test.go` | +| 有界多主机执行(方向) | 高 | 是 | 可能,多主机 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | `--host-test-all` 仅覆盖连接测试,不等同批量执行 | +| 可解释执行治理(方向) | 高 | 是 | 可能 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | 现有 `--dry-run`、安全检查与审计是基础,不构成完整能力 | + +当前已达到已实现一级能力的覆盖底线。表中的剩余红项属于尚未实现的方向能力,而不是用组件测试掩盖的既有质量债。未来任何一级能力不得只以参数解析或组件测试作为完成依据;必须沿用编译后二进制边界补充 E2E,并同步更新本矩阵。 diff --git a/docs/zh/index.md b/docs/zh/index.md index 3875238..1071ae4 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -1,6 +1,10 @@ # SSHX 文档 -`sshx` 是一个跨平台的 SSH/SFTP 命令行客户端,面向经常操作多台远程服务器的人和自动化 agent。它保持一个很简单的模型:一次命令建立一次 SSH 会话,完成指定操作,按需写入本地审计事件,然后退出。 +> **SSH 是通道,X 代表执行。** + +`sshx` 是一个面向 Agent 的远程主机执行工具。它通过 SSH/SFTP 连接现有主机,把目标解析、执行预览、安全检查、命令与文件动作、结构化结果和审计留痕收敛到一次 CLI 调用中。 + +它保持一个简单的模型:一次命令建立一次连接,完成一个明确动作,返回可判断的结果,写入本地审计事件,然后退出;无需在远端安装常驻 Agent,也不引入长期控制面。 文档默认首页是英文。可以使用顶部导航栏里的语言切换入口打开对应中文页面。 @@ -16,22 +20,22 @@ ## 心智模型 -把 `sshx` 理解成一个更安全的一次性远程操作助手,而不是交互式 shell 的替代品,也不是远程编排平台。 +把 `sshx` 理解成 Agent 工具箱里的远程执行基本件,而不是交互式 shell 的替代品,也不是期望状态或工作流编排平台。 ```text -人类、脚本或 agent +Agent、自动化或人类运维者 | v -sshx CLI 参数与可选 .env +Agent 契约:CLI / JSON / 退出码 / dry-run | v -命名主机解析与安全检查 +X 执行:目标解析 / 安全检查 / 动作 / 审计 | v -SSH 命令或 SFTP 操作 +SSH 通道:认证 / host-key / SSH exec / SFTP | v -结构化结果、退出码、可选审计事件 +远程主机 ``` ## 最常用的第一组命令 @@ -67,6 +71,7 @@ sshx -h=prod-web --json "systemctl is-active nginx" ## 下一步 +- [项目画像与方向](../roadmap.md)定义产品定位、非目标铁律和验收矩阵。 - [快速开始](getting-started.md)帮助你让第一台主机跑通。 - [主机管理](host-management.md)说明命名主机和密钥选择。 - [使用场景](usage-scenarios.md)提供大量日常运维例子。 diff --git a/internal/app/app.go b/internal/app/app.go index 0f2ac1d..8977abf 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -74,7 +74,7 @@ func Run(args []string) (err error) { audit := newAuditRecorder(config) defer func() { if auditErr := audit.finish(config, err); auditErr != nil { - logger.GetLogger().Warning("failed to write audit event: %v", auditErr) + logger.GetLogger().Error("failed to write audit event: %v", auditErr) } }() diff --git a/internal/app/password.go b/internal/app/password.go index a294e10..cc4694c 100644 --- a/internal/app/password.go +++ b/internal/app/password.go @@ -2,6 +2,7 @@ package app import ( "bufio" + "errors" "fmt" "io" "os" @@ -10,8 +11,7 @@ import ( "golang.org/x/term" - "github.com/zalando/go-keyring" - + "github.com/talkincode/sshx/internal/keyringstore" "github.com/talkincode/sshx/internal/sshclient" "github.com/talkincode/sshx/pkg/logger" ) @@ -47,7 +47,7 @@ func setPassword(serviceName, key, value string) error { value = password } - if err := keyring.Set(serviceName, key, value); err != nil { + if err := keyringstore.Set(serviceName, key, value); err != nil { return fmt.Errorf("failed to set password: %w", err) } @@ -72,9 +72,9 @@ func getPassword(serviceName, key string) error { return fmt.Errorf("password key is required") } - password, err := keyring.Get(serviceName, key) + password, err := keyringstore.Get(serviceName, key) if err != nil { - if err == keyring.ErrNotFound { + if errors.Is(err, keyringstore.ErrNotFound) { return fmt.Errorf("password not found for key: %s", key) } return fmt.Errorf("failed to get password: %w", err) @@ -104,16 +104,16 @@ func deletePassword(serviceName, key string) error { return fmt.Errorf("password key is required") } - _, err := keyring.Get(serviceName, key) + _, err := keyringstore.Get(serviceName, key) if err != nil { - if err == keyring.ErrNotFound { + if errors.Is(err, keyringstore.ErrNotFound) { logger.GetLogger().Warning("Password not found for key: %s (already deleted or never existed)", key) return nil } return fmt.Errorf("failed to check password: %w", err) } - if err := keyring.Delete(serviceName, key); err != nil { + if err := keyringstore.Delete(serviceName, key); err != nil { return fmt.Errorf("failed to delete password: %w", err) } @@ -129,7 +129,7 @@ func checkPassword(serviceName, key string) error { return fmt.Errorf("password key is required") } - _, err := keyring.Get(serviceName, key) + _, err := keyringstore.Get(serviceName, key) if err == nil { logger.GetLogger().Success("Password exists for key: %s", key) fmt.Printf("\nKey '%s' is stored in system keyring\n", key) @@ -137,7 +137,7 @@ func checkPassword(serviceName, key string) error { return nil } - if err == keyring.ErrNotFound { + if errors.Is(err, keyringstore.ErrNotFound) { logger.GetLogger().Warning("Password not found for key: %s", key) fmt.Printf("\nKey '%s' is NOT stored in system keyring\n", key) fmt.Printf("Use 'sshx --password-set=%s' to add it\n", key) @@ -163,12 +163,12 @@ func listPasswords() error { fmt.Println("Common keys:") found := false for _, key := range commonKeys { - _, err := keyring.Get(sshclient.KeyringServiceName, key) - switch err { - case nil: + _, err := keyringstore.Get(sshclient.KeyringServiceName, key) + switch { + case err == nil: fmt.Printf(" ✓ %s (exists)\n", key) found = true - case keyring.ErrNotFound: + case errors.Is(err, keyringstore.ErrNotFound): fmt.Printf(" %s (not set)\n", key) default: fmt.Printf(" ? %s (error: %v)\n", key, err) diff --git a/internal/keyringstore/backend_e2e.go b/internal/keyringstore/backend_e2e.go new file mode 100644 index 0000000..2270f14 --- /dev/null +++ b/internal/keyringstore/backend_e2e.go @@ -0,0 +1,88 @@ +//go:build sshx_e2e + +package keyringstore + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// ErrNotFound mirrors the public behavior of the OS keyring provider. +var ErrNotFound = errors.New("secret not found in isolated E2E keyring") + +type fileState map[string]map[string]string + +func Set(service, account, password string) error { + state, path, err := load() + if err != nil { + return err + } + if state[service] == nil { + state[service] = make(map[string]string) + } + state[service][account] = password + return save(path, state) +} + +func Get(service, account string) (string, error) { + state, _, err := load() + if err != nil { + return "", err + } + value, ok := state[service][account] + if !ok { + return "", ErrNotFound + } + return value, nil +} + +func Delete(service, account string) error { + state, path, err := load() + if err != nil { + return err + } + if _, ok := state[service][account]; !ok { + return ErrNotFound + } + delete(state[service], account) + return save(path, state) +} + +func load() (fileState, string, error) { + path := os.Getenv("SSHX_E2E_KEYRING_FILE") + if path == "" { + return nil, "", fmt.Errorf("SSHX_E2E_KEYRING_FILE is required by the sshx_e2e build") + } + data, err := os.ReadFile(path) // #nosec G304 -- path is an explicit E2E-only fixture location. + if os.IsNotExist(err) { + return make(fileState), path, nil + } + if err != nil { + return nil, "", err + } + state := make(fileState) + if err := json.Unmarshal(data, &state); err != nil { + return nil, "", fmt.Errorf("decode isolated E2E keyring: %w", err) + } + return state, path, nil +} + +func save(path string, state fileState) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create isolated E2E keyring directory: %w", err) + } + data, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("encode isolated E2E keyring: %w", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { // #nosec G306 -- E2E credential fixture must be owner-only. + return fmt.Errorf("write isolated E2E keyring: %w", err) + } + if err := os.Chmod(path, 0o600); err != nil { + return fmt.Errorf("secure isolated E2E keyring: %w", err) + } + return nil +} diff --git a/internal/keyringstore/backend_system.go b/internal/keyringstore/backend_system.go new file mode 100644 index 0000000..b9a1c8f --- /dev/null +++ b/internal/keyringstore/backend_system.go @@ -0,0 +1,20 @@ +//go:build !sshx_e2e + +package keyringstore + +import "github.com/zalando/go-keyring" + +// ErrNotFound reports that a key has no value in the operating-system keyring. +var ErrNotFound = keyring.ErrNotFound + +func Set(service, account, password string) error { + return keyring.Set(service, account, password) +} + +func Get(service, account string) (string, error) { + return keyring.Get(service, account) +} + +func Delete(service, account string) error { + return keyring.Delete(service, account) +} diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index 7f5aef6..5060357 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -438,7 +438,13 @@ func shouldFallbackToPassword(err error, hadKeyAuth bool, hasPassword bool) bool return false } var serverErr *ssh.ServerAuthError - return errors.As(err, &serverErr) + if errors.As(err, &serverErr) { + return true + } + // x/crypto/ssh does not expose a client-side authentication error type. + // It reports this stable RFC 4252 terminal condition after all offered key + // methods are rejected. Do not fall back on transport or host-key failures. + return strings.Contains(err.Error(), "ssh: unable to authenticate, attempted methods") } // RunCommand executes the configured command and returns a structured result. diff --git a/internal/sshclient/client_test.go b/internal/sshclient/client_test.go index ace4759..ada35c7 100644 --- a/internal/sshclient/client_test.go +++ b/internal/sshclient/client_test.go @@ -270,6 +270,7 @@ func TestConfig_MultipleHosts(t *testing.T) { func TestShouldFallbackToPassword(t *testing.T) { authErr := &ssh.ServerAuthError{Errors: []error{fmt.Errorf("publickey denied")}} + clientAuthErr := fmt.Errorf("ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain") t.Run("requires key auth present", func(t *testing.T) { assert.False(t, shouldFallbackToPassword(authErr, false, true)) @@ -283,6 +284,14 @@ func TestShouldFallbackToPassword(t *testing.T) { assert.True(t, shouldFallbackToPassword(authErr, true, true)) }) + t.Run("client auth exhaustion triggers fallback", func(t *testing.T) { + assert.True(t, shouldFallbackToPassword(clientAuthErr, true, true)) + }) + + t.Run("non-auth errors do not trigger fallback", func(t *testing.T) { + assert.False(t, shouldFallbackToPassword(fmt.Errorf("host key verification failed"), true, true)) + }) + t.Run("nil error", func(t *testing.T) { assert.False(t, shouldFallbackToPassword(nil, true, true)) }) diff --git a/internal/sshclient/validate.go b/internal/sshclient/validate.go index fb19c6d..d7c69dc 100644 --- a/internal/sshclient/validate.go +++ b/internal/sshclient/validate.go @@ -1,11 +1,12 @@ package sshclient import ( + "errors" "fmt" "strings" + "github.com/talkincode/sshx/internal/keyringstore" "github.com/talkincode/sshx/pkg/logger" - "github.com/zalando/go-keyring" ) const KeyringServiceName = "sshx" @@ -163,9 +164,9 @@ func isCommandWhitespace(ch byte) bool { func GetSudoPassword(key string) (string, error) { serviceName := KeyringServiceName - password, err := keyring.Get(serviceName, key) + password, err := keyringstore.Get(serviceName, key) if err != nil { - if err == keyring.ErrNotFound { + if errors.Is(err, keyringstore.ErrNotFound) { return "", fmt.Errorf("sudo password not found in keyring for key: %s\n"+ "Add it using one of:\n"+ " macOS: security add-generic-password -s %s -a %s -w \n"+ diff --git a/tests/e2e/cli_e2e_test.go b/tests/e2e/cli_e2e_test.go new file mode 100644 index 0000000..82b3c19 --- /dev/null +++ b/tests/e2e/cli_e2e_test.go @@ -0,0 +1,299 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh/knownhosts" +) + +func TestCLICommandReturnsStructuredResult(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + + result := runSSHX(t, home, []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--accept-unknown-host", + "--json", + "probe", + }, map[string]string{"SSH_PASSWORD": operatorPassword}) + + require.Equal(t, 0, result.exitCode, result.stderr) + var payload commandResult + require.NoError(t, json.Unmarshal([]byte(result.stdout), &payload)) + assert.True(t, payload.Success) + assert.Equal(t, 0, payload.ExitCode) + assert.Equal(t, "probe-ok\n", payload.Stdout) + assert.Empty(t, payload.Stderr) + assert.Equal(t, "password", payload.AuthMethod) +} + +func TestCLIKeyAuthenticationAndExplicitPasswordFallback(t *testing.T) { + authorizedSigner, authorizedKeyPath := newClientKey(t) + server := startSSHServer(t, serverOptions{authorizedKey: authorizedSigner.PublicKey()}) + home := t.TempDir() + base := []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--json", + } + + keyResult := runSSHX(t, home, append(append([]string{}, base...), + "-i="+authorizedKeyPath, + "--accept-unknown-host", + "probe", + ), nil) + require.Equal(t, 0, keyResult.exitCode, keyResult.stderr) + var keyPayload commandResult + require.NoError(t, json.Unmarshal([]byte(keyResult.stdout), &keyPayload)) + assert.Equal(t, "key", keyPayload.AuthMethod) + + _, rejectedKeyPath := newClientKey(t) + fallbackResult := runSSHX(t, home, append(append([]string{}, base...), + "-i="+rejectedKeyPath, + "probe", + ), map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 0, fallbackResult.exitCode, "stderr=%s stdout=%s", fallbackResult.stderr, fallbackResult.stdout) + var fallbackPayload commandResult + require.NoError(t, json.Unmarshal([]byte(fallbackResult.stdout), &fallbackPayload)) + assert.Equal(t, "password-fallback", fallbackPayload.AuthMethod) + assert.Equal(t, "probe-ok\n", fallbackPayload.Stdout) +} + +func TestCLIProcessDistinguishesRemoteExitAndStreams(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + base := []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--accept-unknown-host", + "--json", + } + + streams := runSSHX(t, home, append(append([]string{}, base...), "bothstreams"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + require.Equal(t, 0, streams.exitCode, streams.stderr) + var streamsPayload commandResult + require.NoError(t, json.Unmarshal([]byte(streams.stdout), &streamsPayload)) + assert.Equal(t, "to-out\n", streamsPayload.Stdout) + assert.Equal(t, "to-err\n", streamsPayload.Stderr) + + remoteExit := runSSHX(t, home, append(append([]string{}, base...), "exit7"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + require.Equal(t, 7, remoteExit.exitCode, remoteExit.stderr) + var exitPayload commandResult + require.NoError(t, json.Unmarshal([]byte(remoteExit.stdout), &exitPayload)) + assert.False(t, exitPayload.Success) + assert.Equal(t, 7, exitPayload.ExitCode) + assert.Equal(t, "partial\n", exitPayload.Stdout) + assert.Empty(t, exitPayload.ErrorKind) +} + +func TestCLIFailuresAreClassifiedAcrossTimeoutAuthAndHostTrust(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + base := []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--json", + } + + t.Run("unknown host is rejected until explicitly trusted", func(t *testing.T) { + home := t.TempDir() + unknown := runSSHX(t, home, append(append([]string{}, base...), "probe"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + assertSSHXFailure(t, unknown, "host_key") + + trusted := runSSHX(t, home, append(append([]string{}, base...), "--accept-unknown-host", "probe"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + require.Equal(t, 0, trusted.exitCode, trusted.stderr) + + strictAfterTrust := runSSHX(t, home, append(append([]string{}, base...), "probe"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + require.Equal(t, 0, strictAfterTrust.exitCode, strictAfterTrust.stderr) + }) + + t.Run("changed host key is rejected", func(t *testing.T) { + home := t.TempDir() + wrongSigner := newHostSigner(t) + knownHostsPath := filepath.Join(home, ".ssh", "known_hosts") + require.NoError(t, os.MkdirAll(filepath.Dir(knownHostsPath), 0o700)) + pattern := fmt.Sprintf("[%s]:%s", server.host, server.port) + require.NoError(t, os.WriteFile(knownHostsPath, []byte(knownhosts.Line([]string{pattern}, wrongSigner.PublicKey())+"\n"), 0o600)) + + changed := runSSHX(t, home, append(append([]string{}, base...), "probe"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + assertSSHXFailure(t, changed, "host_key") + }) + + t.Run("authentication failure is distinct", func(t *testing.T) { + home := t.TempDir() + trust := runSSHX(t, home, append(append([]string{}, base...), "--accept-unknown-host", "probe"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + require.Equal(t, 0, trust.exitCode, trust.stderr) + + denied := runSSHX(t, home, append(append([]string{}, base...), "probe"), map[string]string{ + "SSH_PASSWORD": "wrong-password", + }) + assertSSHXFailure(t, denied, "auth") + }) + + t.Run("command timeout is distinct", func(t *testing.T) { + home := t.TempDir() + timedOut := runSSHX(t, home, append(append([]string{}, base...), "--accept-unknown-host", "--timeout=100ms", "sleep"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + assertSSHXFailure(t, timedOut, "timeout") + }) +} + +func TestCLIPermissionsAndPartialCompletionAreObservable(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + operatorHome := t.TempDir() + operatorBase := []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--json", + } + + changed := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "--accept-unknown-host", "set-state ready"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + require.Equal(t, 0, changed.exitCode, changed.stderr) + + readerHome := t.TempDir() + readerBase := []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=reader", + "--no-key", + "--json", + } + denied := runSSHX(t, readerHome, append(append([]string{}, readerBase...), "--accept-unknown-host", "set-state forbidden"), map[string]string{ + "SSH_PASSWORD": readerPassword, + }) + require.Equal(t, 13, denied.exitCode, denied.stderr) + var deniedPayload commandResult + require.NoError(t, json.Unmarshal([]byte(denied.stdout), &deniedPayload)) + assert.False(t, deniedPayload.Success) + assert.Equal(t, 13, deniedPayload.ExitCode) + assert.Equal(t, "permission denied\n", deniedPayload.Stderr) + + uncertain := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "set-state-and-drop uncertain"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + assertSSHXFailure(t, uncertain, "exit_missing") + + observed := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "read-state"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + require.Equal(t, 0, observed.exitCode, observed.stderr) + var observedPayload commandResult + require.NoError(t, json.Unmarshal([]byte(observed.stdout), &observedPayload)) + assert.Equal(t, "uncertain\n", observedPayload.Stdout, "callers must be able to inspect state after an ambiguous teardown") +} + +func TestCLISafetyBlockPreventsConnectionAndForceIsExplicit(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + base := []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--json", + } + + connectionsBefore := server.connections.Load() + blocked := runSSHX(t, home, append(append([]string{}, base...), "rm -rf /"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + assertSSHXFailure(t, blocked, "blocked") + assert.Equal(t, connectionsBefore, server.connections.Load(), "blocked commands must not touch the network") + + forced := runSSHX(t, home, append(append([]string{}, base...), "--force", "--accept-unknown-host", "rm -rf /"), map[string]string{ + "SSH_PASSWORD": operatorPassword, + }) + require.Equal(t, 0, forced.exitCode, forced.stderr) + var forcedPayload commandResult + require.NoError(t, json.Unmarshal([]byte(forced.stdout), &forcedPayload)) + assert.Equal(t, "forced-ok\n", forcedPayload.Stdout) + assert.Greater(t, server.connections.Load(), connectionsBefore) +} + +func TestCLIDryRunDescribesEffectsWithoutCrossingBoundaries(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + connectionsBefore := server.connections.Load() + + result := runSSHX(t, home, []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--accept-unknown-host", + "--dry-run", + "--json", + "sudo whoami", + }, nil) + require.Equal(t, 0, result.exitCode, result.stderr) + var plan struct { + DryRun bool `json:"dry_run"` + Valid bool `json:"valid"` + Mode string `json:"mode"` + Action string `json:"action"` + UsesSudo bool `json:"uses_sudo"` + WouldConnect bool `json:"would_connect"` + WouldExecute bool `json:"would_execute"` + WouldReadSecret bool `json:"would_read_secret"` + WouldMutateRemote bool `json:"would_mutate_remote"` + MayMutateKnownHost bool `json:"may_mutate_known_hosts"` + } + require.NoError(t, json.Unmarshal([]byte(result.stdout), &plan)) + assert.True(t, plan.DryRun) + assert.True(t, plan.Valid) + assert.Equal(t, "ssh", plan.Mode) + assert.Equal(t, "command", plan.Action) + assert.True(t, plan.UsesSudo) + assert.True(t, plan.WouldConnect) + assert.True(t, plan.WouldExecute) + assert.True(t, plan.WouldReadSecret) + assert.True(t, plan.WouldMutateRemote) + assert.True(t, plan.MayMutateKnownHost) + assert.Equal(t, connectionsBefore, server.connections.Load()) + _, err := os.Stat(filepath.Join(home, ".ssh", "known_hosts")) + assert.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(home, ".sshx", "audit")) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func assertSSHXFailure(t *testing.T, result cliResult, kind string) { + t.Helper() + require.Equal(t, 255, result.exitCode, result.stderr) + var payload commandResult + require.NoError(t, json.Unmarshal([]byte(result.stdout), &payload)) + assert.False(t, payload.Success) + assert.Equal(t, -1, payload.ExitCode) + assert.Equal(t, kind, payload.ErrorKind) +} diff --git a/tests/e2e/harness_test.go b/tests/e2e/harness_test.go new file mode 100644 index 0000000..8e7c896 --- /dev/null +++ b/tests/e2e/harness_test.go @@ -0,0 +1,418 @@ +package e2e + +import ( + "bufio" + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/binary" + "encoding/pem" + "errors" + "flag" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/pkg/sftp" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +const ( + operatorPassword = "operator-pass" // #nosec G101 -- isolated E2E fixture credential. + readerPassword = "reader-pass" // #nosec G101 -- isolated E2E fixture credential. +) + +var ( + testBinary string + testKeyringBinary string + testRoot string +) + +type commandResult struct { + Host string `json:"host"` + Port string `json:"port"` + ExitCode int `json:"exit_code"` + Success bool `json:"success"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + AuthMethod string `json:"auth_method"` + ErrorKind string `json:"error_kind"` + Error string `json:"error"` +} + +type cliResult struct { + stdout string + stderr string + exitCode int +} + +type serverOptions struct { + root string + sftpReadOnly bool + authorizedKey ssh.PublicKey +} + +type testSSHServer struct { + host string + port string + listener net.Listener + root string + sftpReadOnly bool + connections atomic.Int64 + stateMu sync.Mutex + state string +} + +func TestMain(m *testing.M) { + flag.Parse() + if !testing.Short() { + var err error + testRoot, err = os.MkdirTemp("", "sshx-e2e-") + if err != nil { + fmt.Fprintf(os.Stderr, "create E2E temp directory: %v\n", err) + os.Exit(1) + } + testBinary = filepath.Join(testRoot, "sshx") + testKeyringBinary = filepath.Join(testRoot, "sshx-keyring-e2e") + if runtime.GOOS == "windows" { + testBinary += ".exe" + testKeyringBinary += ".exe" + } + if output, buildErr := buildTestBinary(testBinary); buildErr != nil { + fmt.Fprintf(os.Stderr, "build sshx E2E binary: %v\n%s", buildErr, output) + _ = os.RemoveAll(testRoot) //nolint:errcheck // best-effort TestMain cleanup + os.Exit(1) + } + if output, buildErr := buildTestBinary(testKeyringBinary, "sshx_e2e"); buildErr != nil { + fmt.Fprintf(os.Stderr, "build sshx keyring E2E binary: %v\n%s", buildErr, output) + _ = os.RemoveAll(testRoot) //nolint:errcheck // best-effort TestMain cleanup + os.Exit(1) + } + } + + code := m.Run() + if testRoot != "" { + _ = os.RemoveAll(testRoot) //nolint:errcheck // best-effort TestMain cleanup + } + os.Exit(code) +} + +func buildTestBinary(output string, tags ...string) ([]byte, error) { + args := []string{"build"} + if len(tags) > 0 { + args = append(args, "-tags", strings.Join(tags, ",")) + } + args = append(args, "-o", output, "./cmd/sshx") + build := exec.Command("go", args...) + build.Dir = repositoryRoot() + return build.CombinedOutput() +} + +func repositoryRoot() string { + _, filename, _, ok := runtime.Caller(0) + if !ok { + panic("cannot resolve E2E harness path") + } + return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) +} + +func startSSHServer(t *testing.T, options serverOptions) *testSSHServer { + t.Helper() + if testing.Short() { + t.Skip("skipping compiled-binary E2E in short mode") + } + + signer := newHostSigner(t) + + config := &ssh.ServerConfig{ + PasswordCallback: func(metadata ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + switch { + case metadata.User() == "operator" && string(password) == operatorPassword: + return &ssh.Permissions{Extensions: map[string]string{"role": "operator"}}, nil + case metadata.User() == "reader" && string(password) == readerPassword: + return &ssh.Permissions{Extensions: map[string]string{"role": "reader"}}, nil + } + return nil, fmt.Errorf("invalid E2E credentials") + }, + PublicKeyCallback: func(metadata ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if metadata.User() == "operator" && options.authorizedKey != nil && + bytes.Equal(key.Marshal(), options.authorizedKey.Marshal()) { + return &ssh.Permissions{Extensions: map[string]string{"role": "operator"}}, nil + } + return nil, fmt.Errorf("invalid E2E public key") + }, + } + config.AddHostKey(signer) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + host, port, err := net.SplitHostPort(listener.Addr().String()) + require.NoError(t, err) + if options.root == "" { + options.root = t.TempDir() + } + + server := &testSSHServer{ + host: host, port: port, listener: listener, + root: options.root, sftpReadOnly: options.sftpReadOnly, + } + t.Cleanup(func() { _ = listener.Close() }) //nolint:errcheck // best-effort E2E teardown + + go server.serve(config) + return server +} + +func newHostSigner(t *testing.T) ssh.Signer { + t.Helper() + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + signer, err := ssh.NewSignerFromKey(privateKey) + require.NoError(t, err) + return signer +} + +func newClientKey(t *testing.T) (ssh.Signer, string) { + t.Helper() + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + signer, err := ssh.NewSignerFromKey(privateKey) + require.NoError(t, err) + block, err := ssh.MarshalPrivateKey(privateKey, "sshx-e2e") + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "id_ed25519") + require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(block), 0o600)) + return signer, path +} + +func (s *testSSHServer) serve(config *ssh.ServerConfig) { + for { + conn, err := s.listener.Accept() + if err != nil { + return + } + s.connections.Add(1) + go handleSSHConnection(conn, config, s) + } +} + +func handleSSHConnection(conn net.Conn, config *ssh.ServerConfig, server *testSSHServer) { + serverConn, channels, requests, err := ssh.NewServerConn(conn, config) + if err != nil { + _ = conn.Close() //nolint:errcheck // handshake did not establish an SSH connection + return + } + defer func() { _ = serverConn.Close() }() //nolint:errcheck // best-effort E2E teardown + go ssh.DiscardRequests(requests) + + for newChannel := range channels { + if newChannel.ChannelType() != "session" { + _ = newChannel.Reject(ssh.UnknownChannelType, "session channels only") //nolint:errcheck // test protocol response + continue + } + channel, channelRequests, acceptErr := newChannel.Accept() + if acceptErr != nil { + continue + } + role := "" + if serverConn.Permissions != nil { + role = serverConn.Permissions.Extensions["role"] + } + go handleSSHSession(channel, channelRequests, server, role) + } +} + +func handleSSHSession(channel ssh.Channel, requests <-chan *ssh.Request, server *testSSHServer, role string) { + defer func() { _ = channel.Close() }() //nolint:errcheck // best-effort E2E teardown + for request := range requests { + if request.Type == "subsystem" { + var payload struct{ Name string } + if err := ssh.Unmarshal(request.Payload, &payload); err != nil || payload.Name != "sftp" { + _ = request.Reply(false, nil) //nolint:errcheck // unsupported fixture subsystem + return + } + _ = request.Reply(true, nil) //nolint:errcheck // test protocol response + options := []sftp.ServerOption{sftp.WithServerWorkingDirectory(server.root)} + if role == "reader" || server.sftpReadOnly { + options = append(options, sftp.ReadOnly()) + } + sftpServer, err := sftp.NewServer(channel, options...) + if err != nil { + return + } + _ = sftpServer.Serve() //nolint:errcheck // client disconnect ends the isolated fixture server + _ = sftpServer.Close() //nolint:errcheck // best-effort fixture teardown + return + } + if request.Type != "exec" { + if request.WantReply { + _ = request.Reply(false, nil) //nolint:errcheck // test protocol response + } + continue + } + + var payload struct{ Command string } + if err := ssh.Unmarshal(request.Payload, &payload); err != nil { + _ = request.Reply(false, nil) //nolint:errcheck // malformed test request + return + } + _ = request.Reply(true, nil) //nolint:errcheck // test protocol response + + exitCode := uint32(0) + switch { + case payload.Command == "probe" || strings.HasPrefix(payload.Command, "probe "): + _, _ = io.WriteString(channel, "probe-ok\n") //nolint:errcheck // fixture response + case payload.Command == "bothstreams": + _, _ = io.WriteString(channel, "to-out\n") //nolint:errcheck // fixture response + _, _ = io.WriteString(channel.Stderr(), "to-err\n") //nolint:errcheck // fixture response + case payload.Command == "exit7": + exitCode = 7 + _, _ = io.WriteString(channel, "partial\n") //nolint:errcheck // fixture response + case payload.Command == "sleep": + time.Sleep(2 * time.Second) + case payload.Command == "read-state": + _, _ = io.WriteString(channel, server.readState()+"\n") //nolint:errcheck // fixture response + case strings.HasPrefix(payload.Command, "set-state-and-drop "): + if role != "operator" { + _, _ = io.WriteString(channel.Stderr(), "permission denied\n") //nolint:errcheck // fixture response + sendExitStatus(channel, 13) + return + } + server.writeState(strings.TrimPrefix(payload.Command, "set-state-and-drop ")) + _, _ = io.WriteString(channel, "state-updated\n") //nolint:errcheck // fixture response + return + case strings.HasPrefix(payload.Command, "set-state "): + if role != "operator" { + exitCode = 13 + _, _ = io.WriteString(channel.Stderr(), "permission denied\n") //nolint:errcheck // fixture response + break + } + server.writeState(strings.TrimPrefix(payload.Command, "set-state ")) + _, _ = io.WriteString(channel, "state-updated\n") //nolint:errcheck // fixture response + case payload.Command == "rm -rf /": + _, _ = io.WriteString(channel, "forced-ok\n") //nolint:errcheck // fixture response + case payload.Command == "sudo -S -p '' whoami": + password, err := bufio.NewReader(channel).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + exitCode = 24 + _, _ = io.WriteString(channel.Stderr(), "sudo stdin read failed\n") //nolint:errcheck // fixture response + break + } + if strings.TrimSpace(password) != operatorPassword { + exitCode = 25 + _, _ = io.WriteString(channel.Stderr(), "sudo password mismatch\n") //nolint:errcheck // fixture response + break + } + _, _ = io.WriteString(channel, "sudo-ok\n") //nolint:errcheck // fixture response + default: + exitCode = 127 + _, _ = io.WriteString(channel.Stderr(), "unknown fixture command\n") //nolint:errcheck // fixture response + } + sendExitStatus(channel, exitCode) + return + } +} + +func (s *testSSHServer) readState() string { + s.stateMu.Lock() + defer s.stateMu.Unlock() + return s.state +} + +func (s *testSSHServer) writeState(value string) { + s.stateMu.Lock() + defer s.stateMu.Unlock() + s.state = value +} + +func sendExitStatus(channel ssh.Channel, status uint32) { + payload := make([]byte, 4) + binary.BigEndian.PutUint32(payload, status) + _, _ = channel.SendRequest("exit-status", false, payload) //nolint:errcheck // fixture response +} + +func runSSHX(t *testing.T, home string, args []string, extraEnv map[string]string) cliResult { + return runSSHXBinary(t, testBinary, home, args, extraEnv) +} + +func runSSHXWithTestKeyring(t *testing.T, home string, args []string, extraEnv map[string]string) cliResult { + return runSSHXBinary(t, testKeyringBinary, home, args, extraEnv) +} + +func runSSHXBinary(t *testing.T, binary, home string, args []string, extraEnv map[string]string) cliResult { + return runSSHXBinaryWithHome(t, binary, home, home, args, extraEnv) +} + +func runSSHXWithNativeKeyring(t *testing.T, workDir string, args []string, extraEnv map[string]string) cliResult { + t.Helper() + nativeHome := os.Getenv("HOME") + require.NotEmpty(t, nativeHome, "native keyring E2E requires the platform user HOME") + return runSSHXBinaryWithHome(t, testBinary, workDir, nativeHome, args, extraEnv) +} + +func runSSHXBinaryWithHome(t *testing.T, binary, workDir, environmentHome string, args []string, extraEnv map[string]string) cliResult { + t.Helper() + if testing.Short() { + t.Skip("skipping compiled-binary E2E in short mode") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + command := exec.CommandContext(ctx, binary, args...) // #nosec G204 -- binary is one of the harness-built sshx executables and args are isolated test inputs. + command.Dir = workDir + command.Env = isolatedEnvironment(environmentHome, extraEnv) + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + + err := command.Run() + exitCode := 0 + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } else { + require.NoError(t, err) + } + } + require.NoError(t, ctx.Err(), "sshx E2E process timed out") + return cliResult{stdout: stdout.String(), stderr: stderr.String(), exitCode: exitCode} +} + +func isolatedEnvironment(home string, extra map[string]string) []string { + blocked := map[string]struct{}{ + "HOME": {}, "SSH_PASSWORD": {}, "SSH_KEY_PATH": {}, "SSH_SUDO_KEY": {}, + "SSH_DISABLE_KEY": {}, "SSH_KNOWN_HOSTS": {}, "SSHX_AUDIT_OUTPUT": {}, + "SSHX_NO_AUDIT": {}, "SSH_ACCEPT_UNKNOWN_HOST": {}, "SSH_INSECURE_HOST_KEY": {}, + "SSH_NO_SAFETY_CHECK": {}, "SSH_FORCE": {}, "SSH_TIMEOUT": {}, "SSHX_LOG_LEVEL": {}, + } + env := make([]string, 0, len(os.Environ())+len(extra)+3) + for _, item := range os.Environ() { + name, _, _ := strings.Cut(item, "=") + if _, found := blocked[name]; !found { + env = append(env, item) + } + } + values := map[string]string{ + "HOME": home, + "SSHX_NO_AUDIT": "true", + "SSHX_LOG_LEVEL": "error", + } + for name, value := range extra { + values[name] = value + } + for name, value := range values { + env = append(env, name+"="+value) + } + return env +} diff --git a/tests/e2e/host_audit_e2e_test.go b/tests/e2e/host_audit_e2e_test.go new file mode 100644 index 0000000..7f3ca28 --- /dev/null +++ b/tests/e2e/host_audit_e2e_test.go @@ -0,0 +1,121 @@ +package e2e + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCLIHostImportIsUsableAndFailedSelectionIsAllOrNothing(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + sshConfig := filepath.Join(home, "ssh_config") + configText := "Host imported\n" + + " HostName " + server.host + "\n" + + " Port " + server.port + "\n" + + " User operator\n\n" + + "Host second\n" + + " HostName " + server.host + "\n" + + " Port 22\n" + + " User operator\n" + require.NoError(t, os.WriteFile(sshConfig, []byte(configText), 0o600)) + + imported := runSSHX(t, home, []string{ + "--host-import=imported", + "--ssh-config=" + sshConfig, + "--no-audit", + }, nil) + require.Equal(t, 0, imported.exitCode, imported.stderr) + settingsPath := filepath.Join(home, ".sshx", "settings.json") + settingsBeforeFailure, err := os.ReadFile(settingsPath) // #nosec G304 -- path is inside this test's temporary HOME. + require.NoError(t, err) + info, err := os.Stat(settingsPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + failed := runSSHX(t, home, []string{ + "--host-import=second,missing", + "--ssh-config=" + sshConfig, + "--no-audit", + }, nil) + require.Equal(t, 255, failed.exitCode) + settingsAfterFailure, err := os.ReadFile(settingsPath) // #nosec G304 -- path is inside this test's temporary HOME. + require.NoError(t, err) + assert.Equal(t, settingsBeforeFailure, settingsAfterFailure, "failed selection must not partially update settings") + + command := runSSHX(t, home, []string{ + "-h=imported", + "--no-key", + "--accept-unknown-host", + "--json", + "probe", + }, map[string]string{"SSH_PASSWORD": operatorPassword}) + require.Equal(t, 0, command.exitCode, command.stderr) + var payload commandResult + require.NoError(t, json.Unmarshal([]byte(command.stdout), &payload)) + assert.Equal(t, server.host, payload.Host) + assert.Equal(t, server.port, payload.Port) +} + +func TestCLIAuditRedactsSecretsAndRecoversAfterWriteFailure(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + knownHostArgs := []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--json", + } + env := map[string]string{ + "SSH_PASSWORD": operatorPassword, + "SSHX_NO_AUDIT": "false", + } + + invalidAuditTarget := filepath.Join(home, "audit-is-a-file") + require.NoError(t, os.WriteFile(invalidAuditTarget, []byte("not a directory"), 0o600)) + writeFailure := runSSHX(t, home, append(append([]string{}, knownHostArgs...), + "--accept-unknown-host", "--audit-output="+invalidAuditTarget, "probe"), env) + require.Equal(t, 0, writeFailure.exitCode, writeFailure.stderr) + assert.Contains(t, writeFailure.stderr, "failed to write audit event") + + auditDir := filepath.Join(home, "audit") + const fakeSecret = "fixture-secret-value" // #nosec G101 -- redaction fixture, not a credential. + audited := runSSHX(t, home, append(append([]string{}, knownHostArgs...), + "--audit-output="+auditDir, "probe --token="+fakeSecret), env) + require.Equal(t, 0, audited.exitCode, audited.stderr) + + entries, err := os.ReadDir(auditDir) + require.NoError(t, err) + require.Len(t, entries, 1) + file, err := os.Open(filepath.Join(auditDir, entries[0].Name())) // #nosec G304 -- isolated test directory entry. + require.NoError(t, err) + defer func() { _ = file.Close() }() //nolint:errcheck // best-effort fixture cleanup + scanner := bufio.NewScanner(file) + require.True(t, scanner.Scan()) + var event struct { + Mode string `json:"mode"` + Command string `json:"command"` + AuthMethod string `json:"auth_method"` + Outcome struct { + Status string `json:"status"` + } `json:"outcome"` + Redaction struct { + Secrets bool `json:"secrets_redacted"` + } `json:"redaction"` + } + require.NoError(t, json.Unmarshal(scanner.Bytes(), &event)) + assert.Equal(t, "ssh", event.Mode) + assert.Equal(t, "password", event.AuthMethod) + assert.Equal(t, "success", event.Outcome.Status) + assert.True(t, event.Redaction.Secrets) + assert.NotContains(t, event.Command, fakeSecret) + assert.Contains(t, strings.ToLower(event.Command), "redacted") + assert.False(t, scanner.Scan(), "one invocation must produce one audit event") +} diff --git a/tests/e2e/keyring_e2e_test.go b/tests/e2e/keyring_e2e_test.go new file mode 100644 index 0000000..f467d33 --- /dev/null +++ b/tests/e2e/keyring_e2e_test.go @@ -0,0 +1,99 @@ +package e2e + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsolatedKeyringProcessContractAndSudoExecution(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + home := t.TempDir() + keyringFile := filepath.Join(home, "keyring.json") + key := "isolated-key" + env := map[string]string{"SSHX_E2E_KEYRING_FILE": keyringFile} + + set := runSSHXWithTestKeyring(t, home, []string{"--password-set=" + key + ":" + operatorPassword, "--no-audit"}, env) + require.Equal(t, 0, set.exitCode, set.stderr) + info, err := os.Stat(keyringFile) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + get := runSSHXWithTestKeyring(t, home, []string{"--password-get=" + key, "--no-audit"}, env) + require.Equal(t, 0, get.exitCode, get.stderr) + assert.Equal(t, operatorPassword, get.stdout) + + sudo := runSSHXWithTestKeyring(t, home, []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--accept-unknown-host", + "--json", + "-pk=" + key, + "sudo whoami", + }, env) + require.Equal(t, 0, sudo.exitCode, sudo.stderr) + var payload commandResult + require.NoError(t, json.Unmarshal([]byte(sudo.stdout), &payload)) + assert.Equal(t, "sudo-ok\n", payload.Stdout) + assert.NotContains(t, sudo.stdout, operatorPassword) + assert.NotContains(t, sudo.stderr, operatorPassword) + + deleted := runSSHXWithTestKeyring(t, home, []string{"--password-delete=" + key, "--no-audit"}, env) + require.Equal(t, 0, deleted.exitCode, deleted.stderr) + missing := runSSHXWithTestKeyring(t, home, []string{"--password-get=" + key, "--no-audit"}, env) + assert.Equal(t, 255, missing.exitCode) +} + +func TestRealOSKeyringLifecycleAndSudoExecution(t *testing.T) { + if os.Getenv("SSHX_E2E_REAL_KEYRING") != "1" { + t.Skip("set SSHX_E2E_REAL_KEYRING=1 only in an isolated or ephemeral OS keyring session") + } + server := startSSHServer(t, serverOptions{}) + workDir := t.TempDir() + key := "sshx-e2e-" + randomHex(t, 8) + + set := runSSHXWithNativeKeyring(t, workDir, []string{"--password-set=" + key + ":" + operatorPassword, "--no-audit"}, nil) + require.Equal(t, 0, set.exitCode, set.stderr) + t.Cleanup(func() { + cleanup := runSSHXWithNativeKeyring(t, workDir, []string{"--password-delete=" + key, "--no-audit"}, nil) + assert.Equal(t, 0, cleanup.exitCode, cleanup.stderr) + }) + + get := runSSHXWithNativeKeyring(t, workDir, []string{"--password-get=" + key, "--no-audit"}, nil) + require.Equal(t, 0, get.exitCode, get.stderr) + assert.Equal(t, operatorPassword, get.stdout) + + sudo := runSSHXWithNativeKeyring(t, workDir, []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + "--accept-unknown-host", + "--known-hosts=" + filepath.Join(workDir, "known_hosts"), + "--json", + "-pk=" + key, + "sudo whoami", + }, nil) + require.Equal(t, 0, sudo.exitCode, sudo.stderr) + var payload commandResult + require.NoError(t, json.Unmarshal([]byte(sudo.stdout), &payload)) + assert.Equal(t, "sudo-ok\n", payload.Stdout) + assert.NotContains(t, sudo.stdout, operatorPassword) + assert.NotContains(t, sudo.stderr, operatorPassword) +} + +func randomHex(t *testing.T, bytesLen int) string { + t.Helper() + data := make([]byte, bytesLen) + _, err := rand.Read(data) + require.NoError(t, err) + return hex.EncodeToString(data) +} diff --git a/tests/e2e/sftp_e2e_test.go b/tests/e2e/sftp_e2e_test.go new file mode 100644 index 0000000..4427eba --- /dev/null +++ b/tests/e2e/sftp_e2e_test.go @@ -0,0 +1,147 @@ +package e2e + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCLISFTPWorkflowCoversWriteReadRemoveAndPermissionFailure(t *testing.T) { + server := startSSHServer(t, serverOptions{}) + operatorHome := t.TempDir() + localSource := filepath.Join(operatorHome, "local.txt") + require.NoError(t, os.WriteFile(localSource, []byte("sftp-payload\n"), 0o600)) + operatorBase := []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=operator", + "--no-key", + } + operatorEnv := map[string]string{"SSH_PASSWORD": operatorPassword} + + upload := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), + "--accept-unknown-host", "--upload="+localSource, "--to=uploaded.txt"), operatorEnv) + require.Equal(t, 0, upload.exitCode, upload.stderr) + remoteContent, err := os.ReadFile(filepath.Join(server.root, "uploaded.txt")) + require.NoError(t, err) + assert.Equal(t, "sftp-payload\n", string(remoteContent)) + + listing := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "--list=."), operatorEnv) + require.Equal(t, 0, listing.exitCode, listing.stderr) + assert.Contains(t, listing.stdout, "uploaded.txt") + + localDownload := filepath.Join(operatorHome, "downloaded.txt") + download := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), + "--download=uploaded.txt", "--to="+localDownload), operatorEnv) + require.Equal(t, 0, download.exitCode, download.stderr) + downloaded, err := os.ReadFile(localDownload) // #nosec G304 -- path is inside this test's temporary HOME. + require.NoError(t, err) + assert.Equal(t, "sftp-payload\n", string(downloaded)) + + mkdir := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "--mkdir=nested/dir"), operatorEnv) + require.Equal(t, 0, mkdir.exitCode, mkdir.stderr) + info, err := os.Stat(filepath.Join(server.root, "nested", "dir")) + require.NoError(t, err) + assert.True(t, info.IsDir()) + + removeDir := runSSHX(t, operatorHome, append(append([]string{}, operatorBase...), "--rm=nested"), operatorEnv) + require.Equal(t, 0, removeDir.exitCode, removeDir.stderr) + _, err = os.Stat(filepath.Join(server.root, "nested")) + assert.ErrorIs(t, err, os.ErrNotExist) + + readerHome := t.TempDir() + readerBase := []string{ + "-h=" + server.host, + "-p=" + server.port, + "-u=reader", + "--no-key", + } + readerEnv := map[string]string{"SSH_PASSWORD": readerPassword} + readerDownload := filepath.Join(readerHome, "reader-copy.txt") + readAllowed := runSSHX(t, readerHome, append(append([]string{}, readerBase...), + "--accept-unknown-host", "--download=uploaded.txt", "--to="+readerDownload), readerEnv) + require.Equal(t, 0, readAllowed.exitCode, readAllowed.stderr) + + readerSource := filepath.Join(readerHome, "forbidden.txt") + require.NoError(t, os.WriteFile(readerSource, []byte("must-not-land"), 0o600)) + writeDenied := runSSHX(t, readerHome, append(append([]string{}, readerBase...), + "--upload="+readerSource, "--to=forbidden.txt"), readerEnv) + require.Equal(t, 255, writeDenied.exitCode) + assert.Contains(t, writeDenied.stderr, "permission denied") + _, err = os.Stat(filepath.Join(server.root, "forbidden.txt")) + assert.ErrorIs(t, err, os.ErrNotExist, "failed upload must not leave a destination file") +} + +func TestCLIServerToServerTransferCoversSuccessFailureAndRecovery(t *testing.T) { + source := startSSHServer(t, serverOptions{}) + destination := startSSHServer(t, serverOptions{}) + locked := startSSHServer(t, serverOptions{sftpReadOnly: true}) + require.NoError(t, os.WriteFile(filepath.Join(source.root, "payload.txt"), []byte("streamed-between-hosts\n"), 0o600)) + + home := t.TempDir() + writeNamedHosts(t, home, map[string]*testSSHServer{ + "source": source, + "dest": destination, + "locked": locked, + }) + env := map[string]string{"SSH_PASSWORD": operatorPassword} + + success := runSSHX(t, home, []string{ + "--transfer=source:payload.txt", + "--to=dest:received.txt", + "--no-key", + "--accept-unknown-host", + }, env) + require.Equal(t, 0, success.exitCode, success.stderr) + content, err := os.ReadFile(filepath.Join(destination.root, "received.txt")) + require.NoError(t, err) + assert.Equal(t, "streamed-between-hosts\n", string(content)) + + failed := runSSHX(t, home, []string{ + "--transfer=source:payload.txt", + "--to=locked:partial.txt", + "--no-key", + "--accept-unknown-host", + }, env) + require.Equal(t, 255, failed.exitCode) + assert.Contains(t, failed.stderr, "permission denied") + _, err = os.Stat(filepath.Join(locked.root, "partial.txt")) + assert.ErrorIs(t, err, os.ErrNotExist, "failed transfer must not leave a destination file") + + recovered := runSSHX(t, home, []string{ + "--transfer=source:payload.txt", + "--to=dest:recovered.txt", + "--no-key", + }, env) + require.Equal(t, 0, recovered.exitCode, recovered.stderr) + recoveredContent, err := os.ReadFile(filepath.Join(destination.root, "recovered.txt")) + require.NoError(t, err) + assert.Equal(t, "streamed-between-hosts\n", string(recoveredContent)) +} + +func writeNamedHosts(t *testing.T, home string, servers map[string]*testSSHServer) { + t.Helper() + type host struct { + Name string `json:"name"` + Host string `json:"host"` + Port string `json:"port"` + User string `json:"user"` + } + type settings struct { + Hosts []host `json:"hosts"` + } + + fixture := settings{Hosts: make([]host, 0, len(servers))} + for name, server := range servers { + fixture.Hosts = append(fixture.Hosts, host{Name: name, Host: server.host, Port: server.port, User: "operator"}) + } + data, err := json.Marshal(fixture) + require.NoError(t, err) + dir := filepath.Join(home, ".sshx") + require.NoError(t, os.MkdirAll(dir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "settings.json"), data, 0o600)) +}