# SSH Source Installation Plan ## Status In progress. Work package 1 (reusable Docker/OpenSSH harness and distro fixtures), the pure detection/planning foundation (work package 2 core), and work package 3 (remote execution through the existing SSH transport) are implemented: - `internal/installer/harness` builds and runs real OpenSSH containers for Alpine, Ubuntu, and Arch, waits for real network readiness, captures the server host key into a temp `known_hosts` file, and tears the container, network, per-instance fixture image tag, and temp dir down reliably. It never mocks SSH and connects with the `golang.org/x/crypto/ssh` library and known_hosts verification semantics the installer's `deploy` path relies on. - `internal/sshinstall` resolves a remote host's distro, package manager, and init system from `/etc/os-release`, plans the pinned Go 1.26 toolchain (published SHA-256) for the remote architecture, and produces a pure source-install plan. It executes nothing. - `installer.SourceInstall` (work package 3) executes the plan through the same SSH transport, authentication, and host-key verification the `deploy` command uses. It installs the minimal package prerequisites, downloads and SHA-256-verifies the pinned Go toolchain before extraction, clones/updates the public repository, checks out the resolved branch, records the resolved branch and commit, and builds the worker to a staging path. It deliberately does not install or replace the running service, configuration, or data directory: atomic activation and rollback are the next work package. The current Go installer can still only upload a binary or deploy an immutable Docker image over SSH. Source installs now build remotely to a staging path, but service activation over SSH (work package 4) is not implemented yet; the acceptance test stops after install-to-staging and idempotent-rerun assertions. Existing tests are unit tests plus the harness tests that run against live OpenSSH containers when explicitly enabled. ## Initial Platform Scope The first source installer supports Linux and is validated on Alpine, Ubuntu, and Arch Linux containers. CentOS-family support follows after its package and service differences are implemented. Windows and macOS remain later platform work despite the worker being written in Go. Use `reg.rsxx.ru` image mirrors where available. Tests must not depend on Docker Hub when a local mirror exists. ## Source Install Flow The Go CLI connects through `golang.org/x/crypto/ssh` using existing key, passphrase, password, sudo-password, known-hosts, and pinned-fingerprint support. It then: 1. detects supported OS, architecture, package manager, init system, and privilege path; 2. installs only required packages (`git`, CA certificates, download/archive tools); it does not install `build-essential` or a C compiler unless a detected dependency requires CGO; 3. downloads the pinned Go 1.26 toolchain for the detected architecture and verifies the published SHA-256; 4. clones `https://rocketgit.ru/rsmon/worker.git` or updates an existing clone; 5. checks out the resolved branch (the pinned branch when one is configured, otherwise the remote's default branch) and records the resolved commit; 6. builds a reproducible worker binary with the repository build flags; 7. atomically installs the binary, validated environment, data directory, and service definition; 8. starts the service and verifies process status and `/healthz`. Repository, branch, Go version, checksum source, build directory, and Go module proxy may be configurable, but production output records their resolved values. The default repository is publicly readable and requires no source credential. ## Work Package 3: Remote Execution To A Staging Path Work package 3 is `installer.SourceInstall` in `internal/installer/sourceinstall.go`. It reuses the `deploy` command's `SSHOptions` (authentication, sudo password, known-hosts and fingerprint verification) and runs every remote step with the same privilege path (root, passwordless sudo, or `sudo -S -p ''` with the password delivered only over stdin). Steps 1-6 of the flow above are implemented; step 7 (atomic install) is deliberately the next work package. Per step: - **Prerequisite install.** `packageScript` renders the distro's idempotent command (`apk add --no-cache`, `apt-get update` + `apt-get install -y --no-install-recommends`, `pacman -Sy --noconfirm --needed`, `dnf install -y`) for the minimal plan packages (`git`, `ca-certificates`, `curl`, `tar`, `gzip`). No compiler is ever planned or installed. - **Toolchain.** `toolchainScript` downloads the pinned Go tarball into a `mktemp` temp dir, verifies it with `sha256sum -c -` *before* extraction, extracts into a staging dir on the same filesystem as `ToolchainDir` (which must end in `/go`, default `/usr/local/go`), verifies the staged toolchain reports the target version, and only then swaps it into place. The prior toolchain is moved to a sibling `.go-backup` and is restored if the swap fails, so a failed download/verify/extract/swap always leaves the prior Go untouched. A present toolchain that already reports the target version is reused, so reruns do not re-download. Temp, staging, and backup directories are removed on success and failure. - **Clone/update.** `cloneUpdateScript` clones the repository when `BuildDir` has no `.git` and otherwise fetches with `--prune`, so a rerun updates in place. An existing checkout's `remote.origin.url` must exactly match the configured repository before anything is fetched or built, so the installer can never fetch or build an unconfigured repository. The clone/fetch retries up to three times (2s apart) because real repositories can be transiently unreachable (DNS, TLS, or proxy hiccups); three bounded attempts keep a momentary outage from failing a full source install. - **Resolved branch.** The installer resolves the remote default branch via `git remote set-head origin --auto` + `git symbolic-ref --short refs/remotes/origin/HEAD`. When no branch is pinned it builds the remote default (the public repo currently publishes `master`); a pinned branch must exist remotely or the install fails before the build. The resolved branch and the `git rev-parse HEAD` commit (validated as 40 lowercase hex) are returned by `SourceInstall`. - **Staging build.** `buildScript` builds with `CGO_ENABLED=0`, `-trimpath`, the repository's own `-ldflags` shape (version `dev`, resolved commit short form, UTC build date), and both `GOCACHE` and `GOMODCACHE` inside the build dir (so reruns reuse them), plus an optional `GOPROXY`. The binary is built to a sibling `.new`, verified with `.new --version`, and only then atomically swapped over `/rsmon-worker` (or `StageBinary`), so a failed build never replaces the previous staging binary. It is not written to `/usr/local/bin`. - **Commit record.** The `rsmon-worker.commit` record (a `branch=...` / `commit=...` format in the build dir) is written only *after* a successful build, so the record and the staged binary always correspond to the same commit. Security properties of work package 3: - Every interpolated value (repository, branch, build dir, URLs, SHA-256, package names, paths) is single-quoted; repository, branch, commit, Go version, and Go architecture values are additionally validated with strict patterns. No worker token or control-plane credential is ever sent: the install stages a binary and touches no service configuration. - The repository must be an `https://` URL without userinfo, so source credentials cannot reach the remote clone command or the clone's config. - Sudo passwords are delivered over the session's stdin only, never in a command string (the same `sudoWrap` path the `deploy` command uses). Direct `--password`/`--sudo-password`/`--key-passphrase` flags remain available but expose the value through the process list and shell history; the CLI docs strongly prefer the `-file` variants. The source installer sends no worker token at all. - Every step script fails closed: `set -eu` (or an explicit retry that exits non-zero) is used, so a failed checkout or fetch can never be masked by a stale subsequent command. The checkout step refuses before the destructive `checkout -B` when the tracked working tree is dirty (`git diff --quiet` / `--cached --quiet`), because `checkout -B` would silently discard local changes; a dirty-tree rerun fails at checkout and leaves the previous staging binary and commit record untouched. - Remote errors are bounded: each step returns a step-labelled error, stderr and captured stdout are size-bounded in `runRemoteOutput`, and each remote command is capped by `--session-timeout` (default 30m). - Toolchain temp, staging, and backup directories are removed on success and failure, and the acceptance test asserts no `/tmp/rsmon-toolchain-*` or `/usr/local/.go-staging-*`/`.go-backup` leaks after the rerun. - Failed builds and failed checkouts leave the previous staging binary untouched (the binary is only overwritten by an atomically-swapped successful build, and a checkout failure aborts before the build); there is no running service or configuration to preserve yet. ## Docker OpenSSH Test Harness Adapt the real-network pattern from `/data/_swap/sshkeymanager`: start an OpenSSH container, wait for SSH readiness, connect with the Go installer, and tear the environment down reliably. Do not mock SSH command execution in the acceptance test. Provide images/fixtures for: - Ubuntu with apt and systemd-compatible service testing where practical; - Alpine with apk/OpenRC or a clearly separated no-service build/install gate; - Arch with pacman and its service behavior. Each clean target begins without Go or the worker source. The test asserts package installation, verified Go version, clone branch/resolved commit, build, atomic config permissions, running service where supported, and HTTP liveness. ### Implementation (`internal/installer/harness`) The harness starts one container per distro fixture (`testdata/fixtures/{alpine,ubuntu,arch}/Dockerfile`), waits for a real TCP + SSH handshake, captures the server host key into a temp `known_hosts` file, and connects with the `golang.org/x/crypto/ssh` library and known_hosts verification semantics the installer's `deploy` uses. The harness owns its connection code rather than calling into the installer package, so the tests stay independent; only the library and the verification semantics are shared. Host-key handling is trust-on-first-use (TOFU): the fresh temp `known_hosts` file accepts the first key the server presents. What the harness proves is that a known_hosts entry carrying a *different* key is rejected before any command runs (the `TestHarnessHostKeyMismatch` test), not that a fingerprint is pinned. The fixtures install over the public internet, so environments with flaky local resolvers can pin a reliable upstream via the comma-separated `RSMON_TEST_DOCKER_DNS` variable (applied as `docker run --dns ...`); it is empty by default, keeping Docker's embedded DNS. Teardown (`docker rm -f` + `docker network rm` + per-instance `docker image rm` + temp-dir removal) is idempotent, runs on every `Start` error path, and is verified by a dedicated test. Each harness instance builds its own uniquely-tagged fixture image (`rsmon-worker-test/-:local`), so removing one never deletes a shared base image or another instance's image. Base images, mirror-first: | Fixture | Default image | Note | | --- | --- | --- | | alpine | `reg.rsxx.ru/library/alpine:3` | reg.rsxx.ru mirror exists | | ubuntu | `ubuntu:24.04` | no mirror yet; override `RSMON_TEST_IMAGE_UBUNTU` | | arch | `archlinux:latest` | no mirror yet; override `RSMON_TEST_IMAGE_ARCH` | Any `RSMON_TEST_IMAGE_` environment variable overrides the fixture image, so a mirror or local cache can be used when available. > **Mutable test images.** The fixture base tags above are deliberately > mutable (a major-tag mirror ref and Docker Hub rolling tags) so the > fixtures track current distro releases. Fixture builds are therefore > not byte-reproducible; the worker's *source-install* production output > pins immutable artifacts (a Go toolchain SHA-256, a branch's resolved > commit) and this harness's image override is the escape hatch for > reproducing a specific distro snapshot. The fixtures authenticate with the bundled test key (`testdata/keys/rsmon_test_ed25519`); password auth is disabled and `PermitRootLogin` is `prohibit-password`. `openrc` is installed in the Alpine fixture so init detection has a stable marker; Ubuntu and Arch carry systemd markers. > **Test-only key.** The bundled keypair is strictly a test fixture: it > grants root SSH access only to the disposable containers that bake its > public key. It must never be used for real hosts, added to production > images, or treated as a credential outside the harness. ### Opt-in integration test controls Ordinary unit runs never pull or start Docker. The Docker/OpenSSH tests are gated behind the `RSMON_TEST_DOCKER` environment variable: - `make test` (default CI unit run) pins `RSMON_TEST_DOCKER=0` and skips every harness Docker test, even when the flag is exported in the developer's environment. - `make test-ssh` sets `RSMON_TEST_DOCKER=1` and runs the full fixture matrix (`TestHarnessFixtures` for alpine/ubuntu/arch, host-key mismatch, stable host key, failed-start cleanup, and teardown assertions). - The harness `Start` itself refuses to run without the opt-in flag. Run them locally with: ```bash make test-ssh ``` or, equivalently: ```bash RSMON_TEST_DOCKER=1 go test -v -count=1 -timeout 30m ./internal/installer/harness ``` ## Idempotency And Security - A second run updates/fetches safely: the toolchain is reused when the version matches, the clone's origin is verified against the configured repository and then fetched in place, the resolved branch/commit record is rewritten after the new build succeeds, and the staging build atomically swaps over the previous staging binary. The "one active service" guarantee is a work package 4 property (activation); work package 3 leaves no running service to duplicate and no leaked toolchain temp files. - Wrong host fingerprints fail before remote mutation. The harness's fresh known_hosts file is trust-on-first-use; its dedicated mismatch test dials against a known_hosts entry carrying a different server key and proves the dial fails before any command runs. - Tokens/passwords should come from files: `--key-passphrase-file`, `--password-file`, and `--sudo-password-file` keep secrets out of argv and shell history, while the equivalent direct flags expose them through the process list. The source install itself sends no worker token or control-plane credential at all, and sudo passwords travel only over the session's stdin. - Remote temporary files are removed on success and failure. - Failed builds, failed checkouts, and failed toolchain swaps do not replace a working binary or service definition. - Package-manager and download failures return bounded actionable errors. - The installer verifies Go tarball checksum before extraction. ## Implementation Work Packages - [x] 1. Add reusable Docker/OpenSSH harness and distro fixtures. - [x] 2. Add pure distro/toolchain/source-install script planning and unit tests (detection + planning foundation; remote execution is work package 3). - [x] 3. Execute source installation through the existing SSH transport (prerequisites, verified Go toolchain, clone/update, resolved branch and commit record, and a build to a staging path; no service activation). - [ ] 4. Add atomic build/install, idempotency, and failure rollback. - [ ] 5. Add Alpine, Ubuntu, and Arch network E2E tests to CI. - [ ] 6. Add CentOS-family support. - [ ] 7. Plan native Windows service and macOS launchd installers separately. ## Acceptance Gates - [ ] All three initial Linux images install from a clean state through OpenSSH. Work package 3 covers the install-to-staging half (package install, verified Go 1.26, clone/update, resolved commit, staging build); the service-start half is the work package 4 gate. - [ ] The built worker reports the expected version/commit and serves `/healthz`. - [ ] Re-running the installer succeeds without duplicate services or leaked files. - [ ] Host-key, checksum, clone, build, and service-start failure tests preserve the previous installation. - [ ] CI uses approved registry mirrors and cleans every test container/network. ## Verified Test Evidence (work packages 1-3) Recorded 2026-08-12 from `make test-ssh` (Docker Engine 29.7.1): - Alpine `3.24.1` (mirror `reg.rsxx.ru/library/alpine:3`): distro alpine, pkg apk, init openrc. - Ubuntu `24.04` (`VERSION_ID=24.04`): distro ubuntu, pkg apt, init systemd. - Arch rolling image `archlinux:latest` (`VERSION_ID=20260809.0.570793`): distro arch, pkg pacman, init systemd. - Host-key mismatch, host-key stability, failed-start cleanup, and complete teardown (container, network, fixture image tag, and temp dir gone) tests pass; no test container, network, or image tag is left behind. - `TestSourceInstallFixtures` runs the full work-package-3 flow on each fixture from a clean state over the harness's real OpenSSH transport: prerequisite install, verified Go 1.26 toolchain download/extraction, clone of the public repository, resolution of its default branch, resolved-commit record file, and a staging build that reports the resolved commit via `--version`. The public repo's `HEAD` was `master` at `4651deb2...` during the run, and all three fixtures resolved to that branch and commit (the installer records whatever the remote publishes). A rerun succeeds, keeps the same branch, reuses the toolchain, and leaves no `/tmp/rsmon-toolchain-*` temp dirs. The running service and its configuration are not touched (work package 4 boundary). - The unit suite covers the per-step remote scripts, option validation, branch/commit parsing, sudo wrapping (password never in the command), the secrets-absent contract, and an in-process real-SSH orchestration flow with failure paths for missing pinned branches, build failures, and detection failures.