package sshinstall import ( "fmt" "net/url" "regexp" "strings" ) // DefaultGoVersion is the pinned Go toolchain version the source // installer plans by default. It is overridable through install options // when a host needs a different toolchain. const DefaultGoVersion = "1.26.0" // DefaultRepo is the publicly readable worker repository. It requires no // source credential and is the plan's default clone URL. const DefaultRepo = "https://rocketgit.ru/rsmon/worker.git" var ( // goVersionPattern bounds Go toolchain version strings that are // interpolated into remote shell commands and download URLs. goVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z._-]*$`) // goArchPattern bounds Go download archive suffixes (e.g. "amd64") // that are interpolated into remote shell commands and URLs. goArchPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z_-]*$`) ) // ValidGoVersion reports whether a Go toolchain version uses only safe // characters (digits, letters, dots, dashes, underscores) and starts // with a digit. Versions are embedded in remote shell commands and // download URLs, so the charset is the injection boundary. func ValidGoVersion(v string) bool { return goVersionPattern.MatchString(v) } // ValidGoArch reports whether a Go download archive suffix uses only // safe characters. Suffixes are embedded in remote shell commands and // download URLs, so the charset is the injection boundary. func ValidGoArch(a string) bool { return goArchPattern.MatchString(a) } // Toolchain is a pinned, checksum-verified Go toolchain download for a // remote Linux architecture. The SHA-256 is baked for the default // toolchain version so planning never needs the network. type Toolchain struct { Version string // e.g. "1.26.0" Arch string // download archive suffix, e.g. "linux-amd64" URL string // direct download URL SHA256 string // published SHA-256 (64 lowercase hex) } // toolchainSHA pins the official go.dev SHA-256 checksums for the // default Go version per Linux archive. Sources: https://go.dev/dl/ // (?mode=json) published sums for DefaultGoVersion. Keep this in sync // with DefaultGoVersion. var toolchainSHA = map[string]string{ "linux-386": "35e2ec7a7ae6905a1fae5459197b70e3fcbc5e0a786a7d6ba8e49bcd38ad2e26", "linux-amd64": "aac1b08a0fb0c4e0a7c1555beb7b59180b05dfc5a3d62e40e9de90cd42f88235", "linux-arm64": "bd03b743eb6eb4193ea3c3fd3956546bf0e3ca5b7076c8226334afe6b75704cd", "linux-armv6l": "3f6b48d96f0d8dff77e4625aa179e0449f6bbe79b6986bfa711c2cfc1257ebd8", "linux-loong64": "33947cd7686f1cd5f097d2a5a30427a4ade114ea00b7570c85a2abf1af3d0507", "linux-mips": "a4ece61d4bac43b6983fde2c6b9cfc1af7f0d5d6a073219583d4e93b11559c25", "linux-mips64": "197c2e97fa9ec1ad05998e0982d1a1ae761980df154424e5f29f3912e9ea4e5e", "linux-mips64le": "61c52b4ab0dceae29f10df29045483596c3f06810c9b511e8336a97428a95a1b", "linux-mipsle": "b3a13cc5a5f9250b02cf4ba19914c90c7034e68a5ccb9affa5198aadbcedac9a", "linux-ppc64": "ef7232a49101d163a93bac34d03bfbc4fb18f75d7526d77ac307e16d9d83c300", "linux-ppc64le": "3066b2284b554da76cf664d217490792ba6f292ec0fc20bf9615e173cc0d2800", "linux-riscv64": "ab9226ecddda0f682365c949114b653a66c2e9330e7b8d3edea80858437d2ff2", "linux-s390x": "d62137f11530b97f3503453ad7d9e570af070770599fb8054f4e8cd0e905a453", } // GoArch maps a remote `uname -m` value to the Go download archive // suffix used by go.dev. Every suffix it can return must have a pinned // checksum in toolchainSHA; the table-consistency test enforces that. // Unknown values error. func GoArch(unameM string) (string, error) { switch strings.ToLower(strings.TrimSpace(unameM)) { case "x86_64", "amd64": return "amd64", nil case "aarch64", "arm64": return "arm64", nil case "armv6l", "armv7l": return "armv6l", nil case "i386", "i486", "i586", "i686", "386": return "386", nil case "loongarch64": return "loong64", nil case "mips": return "mips", nil case "mipsel": return "mipsle", nil case "mips64": return "mips64", nil case "mips64el": return "mips64le", nil case "ppc64": return "ppc64", nil case "ppc64le": return "ppc64le", nil case "riscv64": return "riscv64", nil case "s390x": return "s390x", nil default: return "", fmt.Errorf("unsupported machine architecture %q", unameM) } } // ToolchainFor returns the pinned, checksum-verified Go toolchain for a // remote Linux architecture. A non-default version has no baked // checksum yet and must be resolved through a checksum source by the // executor work package. func ToolchainFor(goarch, version string) (Toolchain, error) { if version == "" { version = DefaultGoVersion } suffix := "linux-" + goarch t := Toolchain{ Version: version, Arch: suffix, URL: "https://go.dev/dl/go" + version + "." + suffix + ".tar.gz", } if version != DefaultGoVersion { return t, fmt.Errorf("no baked checksum for Go %s; only %s is pinned (resolve %s via the checksum source)", version, DefaultGoVersion, suffix) } sha, ok := toolchainSHA[suffix] if !ok { return Toolchain{}, fmt.Errorf("no pinned Go %s toolchain for %s", version, suffix) } t.SHA256 = sha return t, nil } // SourceOptions are the operator-configurable knobs that the source // plan is resolved against. Every field is optional; empty values fall // back to the pinned defaults. type SourceOptions struct { Repo string // clone URL; default DefaultRepo Branch string // branch to build; empty means "the remote default branch" GoVersion string // default DefaultGoVersion GoArch string // go archive suffix; when empty, derived from UnameM UnameM string // remote `uname -m` output; required unless GoArch set BuildDir string // remote clone/build directory GoModuleProxy string // GOPROXY override; empty keeps the Go default } // SourcePlan is the pure, resolved source-install plan. Producing it // never touches the network or the remote host; the executor work // package turns it into remote commands. type SourcePlan struct { Repo string Branch string BuildDir string GoModuleProxy string Toolchain Toolchain Packages []string // packages to install via the package manager InitSystem InitSystem } // PlanSource resolves a source-install plan for a detected host. It // returns an error before any remote mutation could happen when the // target cannot be planned (unknown distro, unsupported architecture, // malformed repository). func PlanSource(d Detection, opts SourceOptions) (SourcePlan, error) { if d.PackageManager == PkgUnknown { return SourcePlan{}, fmt.Errorf("unsupported distro %q: no package manager", d.ID) } goarch := strings.TrimSpace(opts.GoArch) if goarch != "" && !ValidGoArch(goarch) { return SourcePlan{}, fmt.Errorf("invalid Go architecture %q: only letters, digits, dashes, and underscores are allowed", goarch) } if goarch == "" { var err error goarch, err = GoArch(opts.UnameM) if err != nil { return SourcePlan{}, err } } if version := strings.TrimSpace(opts.GoVersion); version != "" && !ValidGoVersion(version) { return SourcePlan{}, fmt.Errorf("invalid Go version %q: only digits, letters, dots, dashes, and underscores are allowed", version) } toolchain, err := ToolchainFor(goarch, opts.GoVersion) if err != nil { return SourcePlan{}, err } repo := strings.TrimSpace(opts.Repo) if repo == "" { repo = DefaultRepo } if err := validateRepoURL(repo); err != nil { return SourcePlan{}, err } branch := strings.TrimSpace(opts.Branch) // An empty branch means "build the remote's default branch" (the // public repo currently publishes master). The executor resolves and // records the remote default; a non-empty branch is pinned and must // exist on the remote. buildDir := strings.TrimSpace(opts.BuildDir) if buildDir == "" { buildDir = "/opt/rsmon-worker-src" } if !strings.HasPrefix(buildDir, "/") { return SourcePlan{}, fmt.Errorf("build directory must be absolute, got %q", buildDir) } return SourcePlan{ Repo: repo, Branch: branch, BuildDir: buildDir, GoModuleProxy: strings.TrimSpace(opts.GoModuleProxy), Toolchain: toolchain, Packages: packagePrereqs(d.PackageManager), InitSystem: d.InitSystem, }, nil } // packagePrereqs returns the minimal package set the source installer // plans to install for a package manager: git, CA certificates, and // download/archive tools. It never plans a C compiler or build-essential // because the worker builds with CGO disabled. func packagePrereqs(pkg PackageManager) []string { switch pkg { case PkgApk: return []string{"git", "ca-certificates", "curl", "tar", "gzip"} case PkgApt: return []string{"git", "ca-certificates", "curl", "tar", "gzip"} case PkgPacman: return []string{"git", "ca-certificates", "curl", "tar", "gzip"} case PkgDnf, PkgYum: return []string{"git", "ca-certificates", "curl", "tar", "gzip"} default: return nil } } // ValidateRepoURL rejects repository references that could smuggle a // command or a non-remote scheme into the clone step. Only https is // accepted (the default repository is https), and userinfo such as // `user:pass@host` is rejected so credentials can never reach the remote // clone command or the clone's config. An empty value is accepted here // (it falls back to the default repository when planning). func ValidateRepoURL(repo string) error { if repo == "" { return nil } return validateRepoURL(repo) } func validateRepoURL(repo string) error { if strings.ContainsAny(repo, "\r\n\t ") { return fmt.Errorf("repository URL %q contains whitespace", repo) } u, err := url.Parse(repo) if err != nil || u.Host == "" { return fmt.Errorf("repository URL %q is not an absolute clone URL", repo) } if u.User != nil { return fmt.Errorf("repository URL %q must not contain userinfo", repo) } if u.Scheme != "https" { return fmt.Errorf("repository URL %q must use the https scheme", repo) } return nil } // StepKind identifies one ordered remote step the executor will run. type StepKind string const ( StepInstallPackages StepKind = "install-packages" StepInstallToolchain StepKind = "install-toolchain" StepCloneSource StepKind = "clone-source" StepCheckoutBranch StepKind = "checkout-branch" StepBuildWorker StepKind = "build-worker" StepInstallService StepKind = "install-service" ) // Step is one ordered, pure planning step. The executor maps each step // to remote commands; planning does not execute anything. type Step struct { Kind StepKind Detail string Packages []string // only for StepInstallPackages } // Steps returns the ordered source-install plan as stable, reviewable // steps. It is the contract the executor work package implements. func (p SourcePlan) Steps() []Step { checkoutDetail := "check out branch " + p.Branch + " and record the resolved commit" if p.Branch == "" { checkoutDetail = "check out the remote default branch and record the resolved commit" } return []Step{ {Kind: StepInstallPackages, Detail: "install minimal build prerequisites", Packages: p.Packages}, {Kind: StepInstallToolchain, Detail: "install pinned Go " + p.Toolchain.Version + " (" + p.Toolchain.Arch + ") and verify SHA-256"}, {Kind: StepCloneSource, Detail: "clone " + p.Repo + " into " + p.BuildDir}, {Kind: StepCheckoutBranch, Detail: checkoutDetail}, {Kind: StepBuildWorker, Detail: "build the worker binary with CGO_ENABLED=0 and trimpath"}, {Kind: StepInstallService, Detail: "atomically install the binary, env, data dir, and " + string(p.InitSystem) + " service definition"}, } }