package installer import ( "errors" "fmt" "net" "path/filepath" "regexp" "strconv" "strings" "time" "golang.org/x/crypto/ssh" "rocketgit.ru/rsmon/worker/internal/sshinstall" ) // SourceInstallOptions drives the remote source installation (work // package 3 of docs/source-installation.md). It reuses the deploy // command's SSHOptions for authentication and host-key verification and // adds the source-build knobs. It never carries a worker token or // control-plane credential: the built worker is staged, not configured // or started, so no secret is ever sent to the remote host. type SourceInstallOptions struct { SSHOptions // Repo is the public worker repository to clone or update. Empty // uses the sshinstall default. Only https URLs without userinfo are // accepted. Repo string // Branch pins the branch to build. Empty resolves the remote's // default branch (the public repo currently publishes "master"); // the resolved branch and commit are recorded in the build dir. Branch string // GoVersion defaults to the pinned sshinstall toolchain (1.26.0). // Non-default versions have no baked checksum yet and are rejected. GoVersion string // GoArch optionally pins the Go download archive suffix (e.g. // "amd64"); empty derives it from the remote `uname -m`. GoArch string // BuildDir is the remote clone/build directory. BuildDir string // GoModuleProxy overrides GOPROXY for the remote build. GoModuleProxy string // ToolchainDir is where the verified Go toolchain is installed. // It must be an absolute path ending in /go (default // /usr/local/go). Replacement is atomic: the new toolchain is // downloaded, verified, and staged before the prior one is moved // aside, and the prior one is restored if the swap fails. ToolchainDir string // StageBinary is where the built worker binary is written. It must // be absolute and defaults to /rsmon-worker. The running // service and its config are NOT touched until Activation runs. StageBinary string // SessionTimeout bounds each remote command. 0 uses the default // (30 minutes); the build step can legitimately run for minutes. SessionTimeout time.Duration // Activation drives work package 4: after the staging build, the // staged binary, validated environment, data dir, and the detected // init's service definition are installed atomically and the worker // is started and verified (process + /healthz). Any failure rolls // back to the prior working install. Empty keeps SourceInstall at the // staging boundary and touches no service configuration. Activation ActivationOptions // activationEnv is the once-rendered activation environment, computed // during option normalization so the env file is read and rendered // exactly once per run (no TOCTOU between preflight and activation). activationEnv []byte } // SourceInstallResult is what a source installation resolved to. The // resolved branch and commit are recorded on the remote host in // RecordFile, and the built binary is left at StageBinary for the next // (service-activation) work package to install atomically. type SourceInstallResult struct { Detection sshinstall.Detection Plan sshinstall.SourcePlan GoArch string // resolved archive suffix, e.g. "linux-amd64" ToolchainDir string ResolvedBranch string ResolvedCommit string RecordFile string StageBinary string // Activation is set when the staged binary was atomically installed // and the worker started/verified. It records the installed layout // and the supervisor used. Nil when Activation was not requested. Activation *ActivationResult } // defaultToolchainDir is the standard Go installation prefix. const defaultToolchainDir = "/usr/local/go" // defaultSessionTimeout bounds each remote command when the operator // does not configure one. The staging build and first-time module // downloads can run for minutes, so this is generous. const defaultSessionTimeout = 30 * time.Minute // commitRecordName is the file (inside BuildDir) that records the // resolved branch and commit the build was produced from. const commitRecordName = "rsmon-worker.commit" var ( commitPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) branchNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`) ) // sourceExecutor bundles the SSH client, privilege options, and per // command timeout used by every remote source-install step. type sourceExecutor struct { client *ssh.Client ssh SSHOptions timeout time.Duration } // runPrivileged executes a command through the SSHOptions privilege path // (root, passwordless sudo, or sudo -S) and returns bounded stdout. func (e *sourceExecutor) runPrivileged(command string) ([]byte, error) { cmd, stdin := sudoWrap(e.ssh.User, e.ssh.SudoPassword, command) return runRemoteOutput(e.client, cmd, stdin, e.timeout) } // runPlain executes a command as the SSH user and returns bounded // stdout. func (e *sourceExecutor) runPlain(command string) ([]byte, error) { return runRemoteOutput(e.client, command, nil, e.timeout) } // fileProber builds the sshinstall.FileProber used for init-system // detection over a live SSH session. func (e *sourceExecutor) fileProber() sshinstall.FileProber { return func(paths ...string) map[string]bool { quoted := make([]string, len(paths)) for i, p := range paths { quoted[i] = shellQuote(p) } expr := "for p in " + strings.Join(quoted, " ") + "; do [ -e \"$p\" ] && printf '%s\\n' \"$p\"; done; true" out, err := e.runPlain(expr) if err != nil { return nil } present := make(map[string]bool, len(paths)) for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line = strings.TrimSpace(line); line != "" { present[line] = true } } return present } } // SourceInstall executes the source-install flow over SSH: it reuses the // deploy command's SSH authentication and host-key verification, detects // the remote host, plans the pinned toolchain and package prerequisites, // then installs packages, downloads and verifies the Go toolchain, // clones/updates the public repository (verifying an existing checkout's // origin matches the configured repository), checks out the resolved // branch, builds the worker to a staging path, and only then records the // resolved branch and commit. // // When Activation.Activate is set, the staged binary is then atomically // installed together with the validated environment, data directory, and // the detected init's service definition, and the worker is started and // verified (process + /healthz); any activation/start/health failure // rolls back to the prior working install. Without activation the // running service, its configuration, and its data directory are // deliberately untouched (the staging boundary of work package 3). // // Every remote step runs with the same privilege path as `deploy` (root, // passwordless sudo, or sudo -S), every interpolated value is // single-quoted, every step script fails closed (`set -eu` or explicit // `&&`/retry), and every failure returns a bounded, actionable error. // Each remote command is capped by SessionTimeout and its stdout is // size-bounded. func SourceInstall(opts SourceInstallOptions) (*SourceInstallResult, error) { opts, err := normalizeSourceOptions(opts) if err != nil { return nil, err } auth, err := sshAuth(opts.SSHOptions) if err != nil { return nil, err } hostKey, err := hostKeyCallback(opts.SSHOptions) if err != nil { return nil, err } client, err := ssh.Dial("tcp", net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port)), &ssh.ClientConfig{ User: opts.User, Auth: auth, HostKeyCallback: hostKey, Timeout: 15 * time.Second, }) if err != nil { return nil, fmt.Errorf("connect to %s: %w", opts.Host, err) } defer client.Close() //nolint:errcheck executor := &sourceExecutor{client: client, ssh: opts.SSHOptions, timeout: opts.SessionTimeout} osRelease, err := executor.runPlain("cat /etc/os-release") if err != nil { return nil, fmt.Errorf("read remote /etc/os-release: %w", err) } uname, err := executor.runPlain("uname -m") if err != nil { return nil, fmt.Errorf("read remote machine architecture: %w", err) } detection := sshinstall.Detect(string(osRelease), executor.fileProber()) plan, err := sshinstall.PlanSource(detection, sshinstall.SourceOptions{ Repo: opts.Repo, Branch: opts.Branch, GoVersion: opts.GoVersion, GoArch: opts.GoArch, UnameM: strings.TrimSpace(string(uname)), BuildDir: opts.BuildDir, GoModuleProxy: opts.GoModuleProxy, }) if err != nil { return nil, err } stage := opts.StageBinary if stage == "" { stage = filepath.Join(plan.BuildDir, "rsmon-worker") } result := &SourceInstallResult{ Detection: detection, Plan: plan, GoArch: plan.Toolchain.Arch, ToolchainDir: opts.ToolchainDir, StageBinary: stage, } if len(plan.Packages) > 0 { script := "sh -c " + shellQuote(packageScript(detection.PackageManager, plan.Packages)) if _, err := executor.runPrivileged(script); err != nil { return nil, fmt.Errorf("install prerequisites via %s: %w", detection.PackageManager, err) } } script := "sh -c " + shellQuote(toolchainScript(plan.Toolchain, opts.ToolchainDir)) if _, err := executor.runPrivileged(script); err != nil { return nil, fmt.Errorf("install Go %s toolchain: %w", plan.Toolchain.Version, err) } // Clone or update. An existing checkout must point at the configured // repository, or the install fails before fetching or building. script = "sh -c " + shellQuote(cloneUpdateScript(plan.Repo, plan.BuildDir)) if _, err := executor.runPrivileged(script); err != nil { return nil, fmt.Errorf("clone/update source repository: %w", err) } out, err := executor.runPrivileged("sh -c " + shellQuote(resolveBranchScript(plan.BuildDir))) if err != nil { return nil, fmt.Errorf("resolve remote default branch: %w", err) } branch, err := parseResolvedBranch(string(out)) if err != nil { return nil, err } if plan.Branch != "" && plan.Branch != branch { ref := "refs/remotes/origin/" + plan.Branch if _, err := executor.runPrivileged("sh -c " + shellQuote(refExistsScript(plan.BuildDir, ref))); err != nil { return nil, fmt.Errorf("branch %q does not exist on the remote repository: %w", plan.Branch, err) } branch = plan.Branch } result.ResolvedBranch = branch // Checkout fails closed: a dirty tree or missing branch aborts before // the build, so the previous staging binary is left untouched. out, err = executor.runPrivileged("sh -c " + shellQuote(checkoutScript(plan.BuildDir, branch))) if err != nil { return nil, fmt.Errorf("check out branch %q: %w", branch, err) } commit, err := parseResolvedCommit(string(out)) if err != nil { return nil, fmt.Errorf("resolve commit: %w", err) } result.ResolvedCommit = commit // Build with the repository's own flags (same -ldflags shape the // worker Makefile uses), resolving COMMIT from the checkout and // BUILD_DATE at install time. The binary is built to a temp path, // verified, and atomically swapped into the staging path; a failed // build leaves the previous staging binary in place. buildDate := time.Now().UTC().Format("2006-01-02T15:04:05Z") ldflags := fmt.Sprintf("-s -w -X main.version=dev -X main.commit=%s -X main.buildDate=%s", commit[:12], buildDate) script = "sh -c " + shellQuote(buildScript(opts.ToolchainDir, plan.BuildDir, plan.GoModuleProxy, stage, ldflags)) if _, err := executor.runPrivileged(script); err != nil { return nil, fmt.Errorf("build worker binary: %w", err) } // The commit record is written only after a successful build, so the // record and the staged binary always correspond to the same commit. result.RecordFile = filepath.Join(plan.BuildDir, commitRecordName) if _, err := executor.runPrivileged("sh -c " + shellQuote(commitRecordScript(plan.BuildDir, branch, commit))); err != nil { return nil, fmt.Errorf("record resolved commit: %w", err) } // Work package 4: atomically install the staged build and activate // the worker. On any failure the remote script restores the previous // working install and this step returns a bounded error. if opts.Activation.Activate { if err := executor.activateWorker(opts, result); err != nil { return nil, err } } return result, nil } // normalizeSourceOptions validates operator input before any remote // connection or mutation. Every value that later reaches a remote shell // is constrained here. func normalizeSourceOptions(o SourceInstallOptions) (SourceInstallOptions, error) { if o.Host == "" || o.User == "" { return o, errors.New("--host and --user are required for source install") } if o.Port == 0 { o.Port = 22 } if o.Port < 1 || o.Port > 65535 { return o, errors.New("SSH port must be between 1 and 65535") } if o.Branch != "" && !validBranchName(o.Branch) { return o, fmt.Errorf("invalid branch %q: only A-Za-z0-9, dots, underscores, slashes, and hyphens are allowed", o.Branch) } if o.GoVersion != "" && !sshinstall.ValidGoVersion(o.GoVersion) { return o, fmt.Errorf("invalid Go version %q: only digits, letters, dots, dashes, and underscores are allowed", o.GoVersion) } if o.GoArch != "" && !sshinstall.ValidGoArch(o.GoArch) { return o, fmt.Errorf("invalid Go architecture %q: only letters, digits, dashes, and underscores are allowed", o.GoArch) } if err := sshinstall.ValidateRepoURL(o.Repo); err != nil { return o, fmt.Errorf("invalid repository: %w", err) } if o.ToolchainDir == "" { o.ToolchainDir = defaultToolchainDir } if !strings.HasPrefix(o.ToolchainDir, "/") || filepath.Base(o.ToolchainDir) != "go" { return o, fmt.Errorf("toolchain directory must be an absolute path ending in /go, got %q", o.ToolchainDir) } if o.StageBinary != "" && !strings.HasPrefix(o.StageBinary, "/") { return o, fmt.Errorf("staging binary path must be absolute, got %q", o.StageBinary) } if o.SessionTimeout <= 0 { o.SessionTimeout = defaultSessionTimeout } if o.Activation.Activate { // Render (and validate) the activation environment exactly once // here. The rendered bytes are reused at activation time, so the // env file is read a single time and cannot change between the // preflight and the remote install (env-file TOCTOU). env, err := o.Activation.renderEnv() if err != nil { return o, fmt.Errorf("activation: %w", err) } if err := validateRenderedEnv(env); err != nil { return o, fmt.Errorf("activation: %w", err) } o.activationEnv = env } return o, nil } // validBranchName reports whether a branch is a safe git branch name // that can be interpolated into remote commands. The charset check is // the command-injection boundary; the extra rules reject git-invalid or // ambiguous refname patterns. func validBranchName(branch string) bool { if !branchNamePattern.MatchString(branch) { return false } if strings.HasPrefix(branch, "-") || strings.HasPrefix(branch, "/") || strings.Contains(branch, "..") || strings.Contains(branch, "@{") || strings.Contains(branch, "//") || strings.HasSuffix(branch, ".") || strings.HasSuffix(branch, "/") { return false } return true } // parseResolvedBranch turns the `git symbolic-ref` output // ("origin/master\n") into the short branch name, validating it so // remote-controlled output can never inject a command. func parseResolvedBranch(raw string) (string, error) { branch := strings.TrimSpace(raw) branch = strings.TrimPrefix(branch, "origin/") if !validBranchName(branch) { return "", fmt.Errorf("remote reported an invalid default branch %q", strings.TrimSpace(raw)) } return branch, nil } // parseResolvedCommit extracts the 40-hex commit from `git rev-parse // HEAD` output, taking the last whitespace-separated token so unrelated // stdout cannot satisfy the parse. func parseResolvedCommit(raw string) (string, error) { fields := strings.Fields(strings.TrimSpace(raw)) if len(fields) == 0 { return "", errors.New("remote reported no commit") } commit := fields[len(fields)-1] if !commitPattern.MatchString(commit) { return "", fmt.Errorf("remote reported an invalid resolved commit %q", commit) } return commit, nil } // quoteList renders each item as a single shell-quoted word. func quoteList(items []string) string { quoted := make([]string, len(items)) for i, item := range items { quoted[i] = shellQuote(item) } return strings.Join(quoted, " ") } // packageScript installs the minimal build prerequisites with the // distro's package manager. It is idempotent on every supported manager // and never installs a C compiler (the worker builds with CGO disabled). func packageScript(pkg sshinstall.PackageManager, pkgs []string) string { quoted := quoteList(pkgs) switch pkg { case sshinstall.PkgApk: return "apk add --no-cache " + quoted case sshinstall.PkgApt: // Ubuntu/Debian need a fresh package index before installing. return "export DEBIAN_FRONTEND=noninteractive\napt-get update\napt-get install -y --no-install-recommends " + quoted case sshinstall.PkgPacman: return "pacman -Sy --noconfirm --needed " + quoted case sshinstall.PkgDnf: return "dnf install -y --setopt=install_weak_deps=False " + quoted case sshinstall.PkgYum: return "yum install -y " + quoted default: return "" } } // toolchainScript downloads the pinned Go toolchain, verifies its // published SHA-256 before extraction, stages the extract on the same // filesystem as the target, verifies the staged toolchain reports the // target version, and only then swaps it into place. The prior toolchain // (when present) is moved to a sibling backup first and is restored if // the swap fails, so a failed download/verify/extract/swap always leaves // the prior Go untouched. A present toolchain already reporting the // target version is reused (idempotent rerun). Temp and staging // directories are removed on success and failure. func toolchainScript(tc sshinstall.Toolchain, toolchainDir string) string { parent := filepath.Dir(toolchainDir) want := "go" + tc.Version var b strings.Builder b.WriteString("set -eu\n") b.WriteString("parent=" + shellQuote(parent) + "\n") b.WriteString("toolchain=" + shellQuote(toolchainDir) + "\n") b.WriteString("want=" + shellQuote(want) + "\n") b.WriteString("if [ -x \"$toolchain/bin/go\" ]; then\n") b.WriteString(" have=\"$($toolchain/bin/go version 2>/dev/null | awk '{print $3}')\"\n") b.WriteString(" if [ \"$have\" = \"$want\" ]; then\n") b.WriteString(" echo \"go toolchain already present: $have\"\n") b.WriteString(" exit 0\n") b.WriteString(" fi\n") b.WriteString("fi\n") b.WriteString("work=\"$(mktemp -d /tmp/rsmon-toolchain-XXXXXX)\"\n") b.WriteString("staging=\"$(mktemp -d \"$parent/.go-staging-XXXXXX\")\"\n") b.WriteString("trap 'rm -rf \"$work\" \"$staging\"' EXIT HUP INT TERM\n") b.WriteString("archive=\"$work/go" + tc.Version + "." + tc.Arch + ".tar.gz\"\n") b.WriteString("curl -fsSL --retry 3 --retry-delay 2 -o \"$archive\" " + shellQuote(tc.URL) + "\n") b.WriteString("printf '%s %s\\n' " + shellQuote(tc.SHA256) + " \"$archive\" | sha256sum -c -\n") b.WriteString("tar -C \"$staging\" -xzf \"$archive\"\n") b.WriteString("staged=\"$($staging/go/bin/go version | awk '{print $3}')\"\n") b.WriteString("if [ \"$staged\" != \"$want\" ]; then\n") b.WriteString(" printf 'staged toolchain failed verification: %s\\n' \"$staged\" >&2\n") b.WriteString(" exit 1\n") b.WriteString("fi\n") // Swap atomically on the same filesystem, preserving the prior // toolchain in a sibling backup with rollback on failure. b.WriteString("backup=\"\"\n") b.WriteString("if [ -e \"$toolchain\" ]; then\n") b.WriteString(" backup=\"$parent/.go-backup\"\n") b.WriteString(" rm -rf \"$backup\"\n") b.WriteString(" mv \"$toolchain\" \"$backup\"\n") b.WriteString("fi\n") b.WriteString("if ! mv \"$staging/go\" \"$toolchain\"; then\n") b.WriteString(" if [ -n \"$backup\" ]; then\n") b.WriteString(" mv \"$backup\" \"$toolchain\"\n") b.WriteString(" fi\n") b.WriteString(" exit 1\n") b.WriteString("fi\n") b.WriteString("if [ -n \"$backup\" ]; then\n") b.WriteString(" rm -rf \"$backup\"\n") b.WriteString("fi\n") b.WriteString("\"$toolchain/bin/go\" version\n") return b.String() } // cloneUpdateScript clones the repository when missing and otherwise // fetches the latest refs, so a rerun updates in place. Before fetching // an existing checkout, it verifies the configured repository matches // the checkout's `remote.origin.url` exactly, so the installer can never // fetch or build an unconfigured repository. The clone/fetch is retried // up to three times (2s apart) because real repositories can be // transiently unreachable (DNS, TLS, or proxy hiccups). The script fails // closed: any exhausted retry exits non-zero. func cloneUpdateScript(repo, buildDir string) string { return "set -u\n" + "repo=" + shellQuote(repo) + "\n" + "dir=" + shellQuote(buildDir) + "\n" + "if [ ! -d \"$dir/.git\" ]; then\n" + " attempt=0\n" + " while [ \"$attempt\" -lt 3 ]; do\n" + " if git clone \"$repo\" \"$dir\"; then\n" + " exit 0\n" + " fi\n" + " attempt=$((attempt + 1))\n" + " sleep 2\n" + " done\n" + " exit 1\n" + "fi\n" + "origin=\"$(git -C \"$dir\" config --get remote.origin.url || true)\"\n" + "if [ \"$origin\" != \"$repo\" ]; then\n" + " printf 'existing checkout origin does not match configured repository\\nconfigured: %s\\nfound: %s\\n' \"$repo\" \"$origin\" >&2\n" + " exit 1\n" + "fi\n" + "attempt=0\n" + "while [ \"$attempt\" -lt 3 ]; do\n" + " if git -C \"$dir\" fetch --prune origin; then\n" + " exit 0\n" + " fi\n" + " attempt=$((attempt + 1))\n" + " sleep 2\n" + "done\n" + "exit 1\n" } // resolveBranchScript prints the remote's default branch short name // (with an "origin/" prefix) via origin/HEAD. It fails closed so a // set-head failure aborts rather than resolving a stale default. func resolveBranchScript(buildDir string) string { return "set -eu\n" + "git -C " + shellQuote(buildDir) + " remote set-head origin --auto >/dev/null\n" + "git -C " + shellQuote(buildDir) + " symbolic-ref --short refs/remotes/origin/HEAD\n" } // refExistsScript verifies a remote-tracking ref exists (exit 0) without // emitting output. func refExistsScript(buildDir, ref string) string { return "git -C " + shellQuote(buildDir) + " show-ref --verify --quiet " + shellQuote(ref) } // checkoutScript moves the local branch to the resolved remote branch // and prints the resolved commit. It fails closed (`set -eu`): before the // destructive `checkout -B` (which would silently discard local changes) // it refuses when the tracked working tree is dirty, so a checkout // failure can never be masked by a stale `rev-parse` from the previous // checkout, and a failed checkout aborts before the build. func checkoutScript(buildDir, branch string) string { return "set -eu\n" + "git -C " + shellQuote(buildDir) + " diff --quiet || { echo 'working tree has uncommitted changes; refusing to overwrite' >&2; exit 1; }\n" + "git -C " + shellQuote(buildDir) + " diff --cached --quiet || { echo 'working tree has staged changes; refusing to overwrite' >&2; exit 1; }\n" + "git -C " + shellQuote(buildDir) + " checkout -q -B " + shellQuote(branch) + " " + shellQuote("origin/"+branch) + "\n" + "git -C " + shellQuote(buildDir) + " rev-parse HEAD\n" } // commitRecordScript writes the resolved branch and commit to the build // dir record file with a bounded, greppable format. It runs only after a // successful build, so the record always matches the staged binary. func commitRecordScript(buildDir, branch, commit string) string { path := shellQuote(filepath.Join(buildDir, commitRecordName)) format := shellQuote("branch=%s\\ncommit=%s\\n") return "umask 022; printf " + format + " " + shellQuote(branch) + " " + shellQuote(commit) + " > " + path + " && chmod 0644 " + path } // buildScript builds the worker with the repository's own flags into the // staging path and verifies the resulting binary runs. CGO is disabled, // trimpath keeps the build reproducible, and both caches (GOCACHE and // GOMODCACHE) live inside the build dir so reruns reuse them. The binary // is built to a sibling temp path, verified, and only then atomically // swapped over the previous staging binary, so a failed build never // replaces it. func buildScript(toolchainDir, buildDir, goproxy, stage, ldflags string) string { var b strings.Builder b.WriteString("set -eu\n") b.WriteString("cd " + shellQuote(buildDir) + "\n") b.WriteString("export PATH=" + shellQuote(toolchainDir+"/bin") + ":$PATH\n") b.WriteString("export GOCACHE=" + shellQuote(buildDir+"/.gocache") + "\n") b.WriteString("export GOMODCACHE=" + shellQuote(buildDir+"/.gomodcache") + "\n") if goproxy != "" { b.WriteString("export GOPROXY=" + shellQuote(goproxy) + "\n") } b.WriteString("stage=" + shellQuote(stage) + "\n") b.WriteString("tmp=\"$stage.new\"\n") b.WriteString("trap 'rm -f \"$tmp\"' EXIT HUP INT TERM\n") b.WriteString("CGO_ENABLED=0 ") b.WriteString(shellQuote(toolchainDir + "/bin/go")) b.WriteString(" build -trimpath -ldflags=" + shellQuote(ldflags) + " -o \"$tmp\" ./cmd/rsmon-worker\n") b.WriteString("\"$tmp\" --version\n") b.WriteString("mv -f \"$tmp\" \"$stage\"\n") return b.String() }