package installer import ( "crypto/rand" "encoding/hex" "errors" "fmt" "io" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "unicode" "github.com/joho/godotenv" "rocketgit.ru/rsmon/worker/internal/distworker" ) var ( dockerImagePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:-]*@sha256:[a-f0-9]{64}$`) envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) instanceNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) ) const ( DefaultURL = "https://rsmon.ru" DefaultImage = "" defaultLogin = "admin" defaultPort = "27401" ) // installEnvKeys is the canonical, ordered set of worker environment // variables the installer understands and writes to the unit's env // file. Order matters: the rendered file is stable and readable. // // PUBLIC_URL is the canonical advertised origin. WORKER_URL stays in the // list for the bounded migration so legacy env files still resolve; it // is dropped from the written file whenever PUBLIC_URL is also present. var installEnvKeys = []string{ "RSMON_URL", "RSMON_TOKEN", "WORKER_HOST", "WORKER_PORT", "PUBLIC_URL", "WORKER_URL", "WORKER_LOGIN", "WORKER_PASSWORD", "WORKER_COMPOSE_ENABLED", "WORKER_CLUSTER_ENABLED", "WORKER_CLUSTER_ID", "WORKER_CLUSTER_PORT", "WORKER_CLUSTER_PEERS", "WORKER_CLUSTER_DATA_DIR", "WORKER_RELEASE_URL", } // InstallOptions captures the install/deploy command-line knobs. The // classic single-instance install leaves Name empty; a non-empty Name // installs a co-located worker under rsmon-worker- with its own // binary path, config dir, data dir, systemd unit, and port. type InstallOptions struct { Binary string EnvFile string Token string URL string PublicURL string Host string Port string Login string Password string Name string Docker bool Image string NoStart bool } // paths is the fully-resolved on-disk layout for an instance. Every // installer write targets these. The empty Name yields the classic // rsmon-worker paths; a named instance appends - everywhere. type paths struct { name string binary string // /usr/local/bin/rsmon-worker[-name] configDir string // /etc/rsmon-worker[-name] envFile string // /worker.env dataDir string // /var/lib/rsmon-worker[-name] unitName string // rsmon-worker[-name].service unitFile string // /etc/systemd/system/ container string // docker container name (rsmon-worker[-name]) volume string // docker volume name (rsmon-worker-data[-name]) } // resolvePaths derives the on-disk layout for an instance name. The // empty name reproduces the legacy single-instance paths so existing // deployments keep upgrading in place. func resolvePaths(name string) paths { p := paths{ name: name, binary: "/usr/local/bin/rsmon-worker", configDir: "/etc/rsmon-worker", dataDir: "/var/lib/rsmon-worker", container: "rsmon-worker", volume: "rsmon-worker-data", } if name == "" { p.unitName = "rsmon-worker.service" } else { suffix := "-" + name p.binary += suffix p.configDir += suffix p.dataDir += suffix p.container += suffix p.volume += suffix p.unitName = "rsmon-worker" + suffix + ".service" } p.envFile = filepath.Join(p.configDir, "worker.env") p.unitFile = "/etc/systemd/system/" + p.unitName return p } // validateInstanceName allows lowercase alphanumeric and hyphens, 1-32 // chars, starting and ending alphanumeric. The empty string (the // primary instance) is always valid. func validateInstanceName(name string) error { if name == "" { return nil } if len(name) > 32 || !instanceNamePattern.MatchString(name) { return errors.New("instance name must be 1-32 chars, lowercase alphanumeric and hyphens, starting and ending alphanumeric") } return nil } // Install copies the worker binary (or pulls the Docker image), writes // a hardened per-instance systemd unit and env file, then enables and // (unless --no-start) starts the service. func Install(opts InstallOptions) error { if os.Geteuid() != 0 { return errors.New("install must be run as root") } name := strings.TrimSpace(opts.Name) if err := validateInstanceName(name); err != nil { return err } p := resolvePaths(name) var fileEnv map[string]string if opts.EnvFile != "" { // ValidateEnvironmentFile is the strict systemd-safe gate; it // also confirms RSMON_URL/RSMON_TOKEN are present in the file. if err := ValidateEnvironmentFile(opts.EnvFile); err != nil { return fmt.Errorf("--env-file: %w", err) } fe, err := godotenv.Read(opts.EnvFile) if err != nil { return fmt.Errorf("read --env-file: %w", err) } fileEnv = fe } values, err := resolveInstallEnv(opts, name, fileEnv) if err != nil { return err } generatedPassword := "" if values["WORKER_LOGIN"] == "" && values["WORKER_PASSWORD"] == "" { gen, gerr := generatePassword(18) if gerr != nil { return gerr } values["WORKER_LOGIN"] = defaultLogin values["WORKER_PASSWORD"] = gen generatedPassword = gen } // Stage the binary (or pull the image) before touching config so a // download/build failure leaves the host untouched. if opts.Docker { if err := ValidateImage(opts.Image); err != nil { return err } if _, err := exec.LookPath("docker"); err != nil { return errors.New("docker is required for --docker installation") } if err := command("docker", "pull", opts.Image); err != nil { return err } } else { binary := opts.Binary if binary == "" { ex, err := os.Executable() if err != nil { return fmt.Errorf("locate worker executable: %w", err) } binary = ex } if err := copyAtomic(binary, p.binary, 0755); err != nil { return fmt.Errorf("install binary: %w", err) } } if err := os.MkdirAll(filepath.Join(p.dataDir, "webapp"), 0755); err != nil { return fmt.Errorf("create data dir: %w", err) } if err := os.MkdirAll(p.configDir, 0750); err != nil { return fmt.Errorf("create config dir: %w", err) } unit := systemdUnitFor(p) if opts.Docker { unit = dockerUnitFor(p, opts.Image) } if err := writeAtomic(p.unitFile, []byte(unit), 0644); err != nil { return fmt.Errorf("install systemd unit: %w", err) } if err := writeAtomic(p.envFile, renderEnvFile(values), 0600); err != nil { return fmt.Errorf("install environment: %w", err) } if err := command("systemctl", "daemon-reload"); err != nil { return err } if err := command("systemctl", "enable", p.unitName); err != nil { return err } if !opts.NoStart { if err := command("systemctl", "restart", p.unitName); err != nil { return err } if err := command("systemctl", "is-active", "--quiet", p.unitName); err != nil { return err } } // Operator-facing summary. Keep it on stdout so it composes with // scripts; the generated password is only printed once, here. fmt.Printf("rsmon-worker installed: %s\n", p.unitName) fmt.Printf(" binary: %s\n", p.binary) fmt.Printf(" env file: %s (mode 0600)\n", p.envFile) fmt.Printf(" data dir: %s\n", p.dataDir) fmt.Printf(" console: http://%s:%s\n", values["WORKER_HOST"], values["WORKER_PORT"]) if opts.NoStart { fmt.Printf(" status: enabled (not started, --no-start)\n") } else { fmt.Printf(" status: enabled and active\n") } if generatedPassword != "" { fmt.Printf(" generated operator console password (login=%s): %s\n", defaultLogin, generatedPassword) fmt.Println(" This password is stored in the env file above; record it now.") } return nil } // resolveInstallEnv applies the installer's precedence for every known // worker variable: explicit flag > --env-file > process environment > // built-in default. It fills defaults, enforces required values, and // validates every resolved value is systemd-safe. Basic-auth XOR is // enforced here; password generation happens in Install so the value // can be printed. func resolveInstallEnv(opts InstallOptions, name string, fileEnv map[string]string) (map[string]string, error) { flagVals := map[string]string{ "RSMON_URL": opts.URL, "RSMON_TOKEN": opts.Token, "PUBLIC_URL": opts.PublicURL, "WORKER_HOST": opts.Host, "WORKER_PORT": opts.Port, "WORKER_LOGIN": opts.Login, "WORKER_PASSWORD": opts.Password, } values := make(map[string]string, len(installEnvKeys)) for _, key := range installEnvKeys { if v, ok := flagVals[key]; ok && strings.TrimSpace(v) != "" { values[key] = v continue } if fileEnv != nil { if v, ok := fileEnv[key]; ok && strings.TrimSpace(v) != "" { values[key] = v continue } } if v := os.Getenv(key); strings.TrimSpace(v) != "" { values[key] = v } } if values["RSMON_URL"] == "" { values["RSMON_URL"] = DefaultURL } if values["WORKER_HOST"] == "" { values["WORKER_HOST"] = "127.0.0.1" } if values["WORKER_PORT"] == "" { if name == "" { values["WORKER_PORT"] = defaultPort } else { return nil, fmt.Errorf( "--name %q requires WORKER_PORT (the default %s belongs to the primary instance); "+ "set it via --port, WORKER_PORT, or the env file", name, defaultPort) } } if strings.TrimSpace(values["RSMON_TOKEN"]) == "" { return nil, errors.New("RSMON_TOKEN is required (set --token/--token-file, WORKER_TOKEN in --env-file, or RSMON_TOKEN in the environment/.env)") } if err := ValidateURL(values["RSMON_URL"]); err != nil { return nil, fmt.Errorf("RSMON_URL: %w", err) } if err := ValidateToken(values["RSMON_TOKEN"]); err != nil { return nil, fmt.Errorf("RSMON_TOKEN: %w", err) } // Basic auth: both set or both empty. Generation happens later. login, pass := values["WORKER_LOGIN"], values["WORKER_PASSWORD"] if (login == "") != (pass == "") { return nil, fmt.Errorf("WORKER_LOGIN and WORKER_PASSWORD must both be set or both be empty") } // PUBLIC_URL is the canonical advertised origin. When both the // canonical and the legacy WORKER_URL resolve, the legacy variable // is superseded and must not be written to a fresh env file. if values["PUBLIC_URL"] != "" { delete(values, "WORKER_URL") } // Canonical PUBLIC_URL is held to the strict origin shape; the // legacy WORKER_URL only to the tolerant absolute-URL check so env // files that previously installed keep working. if v := values["PUBLIC_URL"]; v != "" { if err := distworker.ValidatePublicURL(v); err != nil { return nil, fmt.Errorf("PUBLIC_URL: %w", err) } } if v := values["WORKER_URL"]; v != "" { if err := distworker.ValidateAdvertisedURL(v); err != nil { return nil, fmt.Errorf("WORKER_URL: %w", err) } } // Validate every value we will write is systemd/docker safe. for _, key := range installEnvKeys { v, ok := values[key] if !ok || v == "" { continue } if err := validateEnvValue(key, v); err != nil { return nil, err } } return values, nil } // renderEnvFile produces the canonical KEY=VALUE env file in a stable // order. Empty values are omitted. func renderEnvFile(values map[string]string) []byte { var b strings.Builder for _, key := range installEnvKeys { v := values[key] if v == "" { continue } fmt.Fprintf(&b, "%s=%s\n", key, v) } return []byte(b.String()) } func generatePassword(nBytes int) (string, error) { buf := make([]byte, nBytes) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } // systemdUnitFor renders the hardened binary systemd unit for an // instance. It mirrors the hardening the operator console expects // (CAP_NET_RAW for ping, docker.service ordering for Compose // discovery, a private writable data tree) while staying a plain // Type=simple root service. func systemdUnitFor(p paths) string { description := "RSMon distributed monitoring worker" if p.name != "" { description += " (" + p.name + ")" } return fmt.Sprintf(`[Unit] Description=%s Documentation=https://rocketgit.ru/rsmon/worker After=network-online.target docker.service Wants=network-online.target [Service] Type=simple User=root Group=root Environment=HOME=%[2]s Environment=RSMON_WEBAPP_DATA_DIR=%[2]s/webapp EnvironmentFile=%[3]s WorkingDirectory=%[2]s ExecStart=%[4]s Restart=always RestartSec=5s TimeoutStopSec=20s AmbientCapabilities=CAP_NET_RAW CapabilityBoundingSet=CAP_NET_RAW NoNewPrivileges=true PrivateTmp=true ProtectHome=true ProtectSystem=full ReadWritePaths=%[2]s [Install] WantedBy=multi-user.target `, description, p.dataDir, p.envFile, p.binary) } // dockerUnitFor renders the Docker-backed systemd unit. The container // always mounts its data volume at the in-image /var/lib/rsmon-worker; // only the volume (and container) name vary by instance. func dockerUnitFor(p paths, image string) string { description := "RSMon distributed monitoring worker (Docker)" if p.name != "" { description += " (" + p.name + ")" } return fmt.Sprintf(`[Unit] Description=%s Documentation=https://rocketgit.ru/rsmon/worker After=network-online.target docker.service Requires=docker.service [Service] Type=simple User=root ExecStartPre=-docker rm -f %[2]s ExecStart=docker run --rm --name %[2]s --network host --cap-add NET_RAW --env-file %[3]s -v %[4]s:/var/lib/rsmon-worker %[5]s ExecStop=docker stop %[2]s Restart=on-failure [Install] WantedBy=multi-user.target `, description, p.container, p.envFile, p.volume, image) } func ValidateURL(raw string) error { if strings.ContainsAny(raw, "\r\n") { return fmt.Errorf("server URL must be an absolute HTTP(S) URL") } u, err := url.Parse(raw) if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { return fmt.Errorf("server URL must be an absolute HTTP(S) URL") } return nil } // Environment renders the minimal legacy env file (URL/token/host). // Kept for callers and tests that depend on the classic shape; the // installer now writes the full resolved set via renderEnvFile. func Environment(serverURL, token string) []byte { return []byte("RSMON_URL=" + serverURL + "\nRSMON_TOKEN=" + token + "\nWORKER_HOST=127.0.0.1\n") } func ValidateToken(token string) error { if strings.TrimSpace(token) == "" || strings.ContainsAny(token, "\r\n") { return errors.New("worker token must be non-empty and contain no newlines") } return nil } // ValidateEnvironmentFile checks a worker credentials file before it // is used for installation. It enforces strict, systemd-safe // KEY=VALUE syntax (no quoting, interpolation, or whitespace in // values) and requires RSMON_URL and RSMON_TOKEN. func ValidateEnvironmentFile(path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read worker environment: %w", err) } _, err = parseEnvironmentContent(data) return err } // parseEnvironmentContent validates systemd-safe KEY=VALUE environment // content and returns the parsed map. Values must not use quoting, // interpolation, or whitespace; RSMON_URL and RSMON_TOKEN are required and // must not be duplicated. Operating on the already-read bytes (rather // than a path) keeps callers free of read-to-validate TOCTOU races. func parseEnvironmentContent(data []byte) (map[string]string, error) { values := make(map[string]string) seenRequired := make(map[string]bool) for number, line := range strings.Split(string(data), "\n") { lineNumber := number + 1 if line == "" || strings.HasPrefix(line, "#") { continue } if strings.ContainsRune(line, '\r') { return nil, fmt.Errorf("worker environment line %d contains a carriage return", lineNumber) } key, value, ok := strings.Cut(line, "=") if !ok || !envKeyPattern.MatchString(key) { return nil, fmt.Errorf("worker environment line %d must use KEY=VALUE syntax", lineNumber) } if err := validateEnvValue(key, value); err != nil { return nil, fmt.Errorf("worker environment line %d: %w", lineNumber, err) } if key == "RSMON_URL" || key == "RSMON_TOKEN" { if seenRequired[key] { return nil, fmt.Errorf("worker environment line %d duplicates %s", lineNumber, key) } seenRequired[key] = true } values[key] = value } if err := ValidateURL(values["RSMON_URL"]); err != nil { return nil, err } if err := ValidateToken(values["RSMON_TOKEN"]); err != nil { return nil, err } return values, nil } // validateEnvValue enforces the value rules shared by the strict env // file parser and the installer's resolved values: no whitespace, no // shell quoting, and no interpolation metacharacters. This keeps the // file unambiguous across systemd EnvironmentFile and docker --env-file. func validateEnvValue(key, value string) error { if strings.ContainsAny(value, "\r\n") { return fmt.Errorf("%s contains a newline", key) } if strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "$\\\"'") { return fmt.Errorf("%s uses unsupported quoting, interpolation, or whitespace", key) } return nil } func ValidateImage(image string) error { if !dockerImagePattern.MatchString(image) { return errors.New("Docker image must be an immutable repository@sha256:<64 lowercase hex characters> reference") } return nil } // DockerUnit renders the Docker-backed systemd unit for the classic // single instance. Kept for compatibility; named installs go through // dockerUnitFor. func DockerUnit(image string) string { return dockerUnitFor(resolvePaths(""), image) } func copyAtomic(src, dst string, mode os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() return atomicFile(dst, mode, func(out *os.File) error { _, err := io.Copy(out, in) return err }) } func writeAtomic(dst string, data []byte, mode os.FileMode) error { return atomicFile(dst, mode, func(out *os.File) error { _, err := out.Write(data) return err }) } func atomicFile(dst string, mode os.FileMode, write func(*os.File) error) error { if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(dst), ".rsmon-worker-*") if err != nil { return err } name := tmp.Name() defer os.Remove(name) if err := tmp.Chmod(mode); err != nil { tmp.Close() return err } if err := write(tmp); err != nil { tmp.Close() return err } if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(name, dst) } func command(name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("%s failed: %w", name, err) } return nil }