diff --git a/README.md b/README.md index 1a5592e..54fa670 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,13 @@ Docker image references. The Harbor project is appended separately as `rsmon`. ## systemd +The `install` subcommand turns a built binary into an enabled systemd service: +it writes a hardened unit, a mode-0600 env file, the data directory, then +enables and starts the worker. It reads configuration from flags, `--env-file`, +a `.env` in the working directory, or the process environment (in that order), +and supports several co-located workers per host via `--name`. The full +reference is in [`docs/install.md`](docs/install.md). + Install host dependencies first. On Debian or Ubuntu: ```bash diff --git a/cmd/rsmon-worker/main.go b/cmd/rsmon-worker/main.go index a9bc786..f43bc53 100644 --- a/cmd/rsmon-worker/main.go +++ b/cmd/rsmon-worker/main.go @@ -36,10 +36,14 @@ var ( func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) + // Load .env before any subcommand dispatch so management commands + // (install/deploy) read the same environment as the worker runtime. + // godotenv.Load only fills variables not already present in the + // process environment, so an explicit export or sudo -E still wins. + loadDotEnv() if handled, code := dispatchManagementCommand(os.Args[1:]); handled { os.Exit(code) } - loadDotEnv() versionFlag := flag.Bool("version", false, "Print version and exit") noWeb := flag.Bool("no-web", false, "Disable the local web UI (Phase 1 ships with it on)") diff --git a/cmd/rsmon-worker/management.go b/cmd/rsmon-worker/management.go index e8a079f..24a4e1d 100644 --- a/cmd/rsmon-worker/management.go +++ b/cmd/rsmon-worker/management.go @@ -28,18 +28,24 @@ func installCommand(args []string) int { fs := flag.NewFlagSet("install", flag.ContinueOnError) fs.SetOutput(os.Stderr) var opts installer.InstallOptions - var tokenFile string + var tokenFile, passwordFile string fs.StringVar(&opts.Binary, "binary", "", "worker binary to install (default: this executable)") - fs.StringVar(&opts.EnvFile, "env-file", "", "existing worker environment file") - fs.StringVar(&opts.Token, "token", "", "worker API token") + fs.StringVar(&opts.EnvFile, "env-file", "", "worker environment file (systemd-safe KEY=VALUE; overrides .env/process env)") + fs.StringVar(&opts.Token, "token", "", "worker API token (RSMON_TOKEN)") fs.StringVar(&opts.Token, "api-key", "", "worker API token (alias for --token)") fs.StringVar(&tokenFile, "token-file", "", "file containing the worker API token") - fs.StringVar(&opts.URL, "url", installer.DefaultURL, "RSMon server URL") + fs.StringVar(&opts.URL, "url", "", "RSMon server URL (RSMON_URL; default https://rsmon.ru)") + fs.StringVar(&opts.Host, "host", "", "operator console bind address (WORKER_HOST; default 127.0.0.1)") + fs.StringVar(&opts.Port, "port", "", "operator console port (WORKER_PORT; required with --name)") + fs.StringVar(&opts.Login, "login", "", "operator console login (WORKER_LOGIN; default admin with a generated password)") + fs.StringVar(&opts.Password, "password", "", "operator console password (WORKER_PASSWORD)") + fs.StringVar(&passwordFile, "password-file", "", "file containing the operator console password") + fs.StringVar(&opts.Name, "name", "", "instance name: installs a co-located worker (rsmon-worker-) with its own config/data/unit/port") fs.BoolVar(&opts.Docker, "docker", false, "run the prebuilt Docker image instead of the binary") fs.StringVar(&opts.Image, "image", installer.DefaultImage, "immutable Docker repository@sha256 digest required with --docker") fs.BoolVar(&opts.NoStart, "no-start", false, "install and enable without starting") fs.Usage = func() { - fmt.Fprintln(fs.Output(), "Usage: rsmon-worker install --token TOKEN [--url URL] [--no-start]") + fmt.Fprintln(fs.Output(), "Usage: rsmon-worker install [--token TOKEN|--env-file FILE] [--name NAME] [--port PORT] [options]") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { @@ -57,11 +63,14 @@ func installCommand(args []string) int { fmt.Fprintf(os.Stderr, "install failed: %v\n", err) return 1 } + if opts.Password, err = secretValue(opts.Password, passwordFile); err != nil { + fmt.Fprintf(os.Stderr, "install failed: %v\n", err) + return 1 + } if err := installer.Install(opts); err != nil { fmt.Fprintf(os.Stderr, "install failed: %v\n", err) return 1 } - fmt.Fprintln(os.Stdout, "rsmon-worker installed") return 0 } diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..8ca8a12 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,251 @@ +# Installation + +The `rsmon-worker install` subcommand turns a built binary (or a published +Docker image) into a running, enabled systemd service on a Linux host. It is +the supported way to deploy the worker: it writes the configuration, the +systemd unit, and the data directory, then starts the service. + +> A shell installer that downloads a pre-built binary is planned. Today +> `install` copies the binary you invoke it from (or pulls the `--image` +> digest), so build first with `make build` and run the resulting +> `./bin/rsmon-worker`. + +## Requirements + +- Linux with systemd. +- Root (`install must be run as root`). The worker runs as `root` so it can + reach the Docker socket for Compose discovery and use `CAP_NET_RAW` for ping + checks without extra setup. +- For the binary install: the worker binary you want to install. +- For the Docker install (`--docker`): the `docker` CLI and an immutable + `repository@sha256:<64 lowercase hex>` image digest. + +Host dependencies for browser-backed HTTP checks (Debian/Ubuntu): + +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates chromium libcap2-bin tzdata +``` + +## Configuration sources + +The installer reads the same environment variables the worker runtime reads. +Each variable is resolved with this precedence (highest first): + +1. **Explicit flags** (`--url`, `--token`, `--host`, `--port`, `--login`, + `--password`, `--name`). +2. **`--env-file`** — a strict, systemd-safe `KEY=VALUE` file (validated before + anything is written to disk). +3. **Process environment**, including a `.env` file in the working directory + (loaded automatically at startup via `godotenv`). +4. **Built-in defaults** (`RSMON_URL=https://rsmon.ru`, `WORKER_HOST=127.0.0.1`, + `WORKER_PORT=27401` for the primary instance). + +`.env` files are loaded before the install command runs, so +`sudo rsmon-worker install` from a directory containing a `.env` picks up those +values automatically. To override a value, pass the matching flag. + +### Variables + +| Variable | Required | Default | Purpose | +| --- | --- | --- | --- | +| `RSMON_URL` | yes | `https://rsmon.ru` | Control-plane base URL. | +| `RSMON_TOKEN` | yes | none | Worker bearer token. | +| `WORKER_HOST` | no | `127.0.0.1` | Operator-console bind address. | +| `WORKER_PORT` | no | `27401` (primary) | Operator-console port. **Required** for named instances. | +| `WORKER_URL` | no | none | Public URL advertised to the control plane. | +| `WORKER_LOGIN` | no | `admin` (generated) | Operator-console basic-auth login. | +| `WORKER_PASSWORD` | no | generated | Operator-console basic-auth password. | +| `WORKER_COMPOSE_ENABLED` | no | feature default (on) | Enable Docker Compose discovery/management. | +| `WORKER_CLUSTER_*` | no | none | Optional Raft cluster (`ID`, `PORT`, `PEERS`, `DATA_DIR`, `ENABLED`). | +| `WORKER_RELEASE_URL` | no | none | Self-update release feed URL. | + +`WORKER_LOGIN` and `WORKER_PASSWORD` must both be set or both be left empty. If +both are empty, the installer generates a random password (login `admin`), +writes it to the env file, and prints it once. Record it; the operator console +requires it for both the browser login and the `/web/api/*` HTTP basic-auth +endpoints. + +Values must be systemd-safe: no whitespace, quotes, backslashes, or `$` +interpolation inside a value. This keeps the file unambiguous across systemd +`EnvironmentFile` and `docker --env-file`. + +## Single-instance install (one worker per host) + +The classic install uses the canonical paths and the default port 27401. + +```bash +make build +printf '%s\n' 'YOUR_WORKER_TOKEN' > worker-token +chmod 600 worker-token +sudo ./bin/rsmon-worker install \ + --token-file worker-token \ + --port 27401 \ + --password 'choose-a-console-password' +rm worker-token +``` + +If you keep configuration in a `.env` (recommended for repeatability): + +```bash +# .env +RSMON_URL=https://rsmon.ru +RSMON_TOKEN=... +WORKER_PORT=27401 +WORKER_LOGIN=admin +WORKER_PASSWORD=... +``` + +```bash +sudo ./bin/rsmon-worker install +``` + +Installed layout: + +| Path | Contents | +| --- | --- | +| `/usr/local/bin/rsmon-worker` | Worker binary. | +| `/etc/rsmon-worker/worker.env` | Configuration, mode `0600`. | +| `/var/lib/rsmon-worker/` | Data directory (SQLite, webapp state). | +| `/etc/systemd/system/rsmon-worker.service` | systemd unit. | + +## Multi-instance install (several workers per host) + +`--name` installs a co-located worker under `rsmon-worker-` with its own +binary, config, data, systemd unit, and port. This is how you run a staging +build next to production, or isolate tenants on one host. + +```bash +sudo ./bin/rsmon-worker install --name edge --port 27403 --password '...' +``` + +A named instance must have an explicit `WORKER_PORT` (the default 27401 belongs +to the primary). The name must be 1–32 chars, lowercase alphanumeric and +hyphens, starting and ending alphanumeric. + +Named layout (for `--name edge`): + +| Path | Contents | +| --- | --- | +| `/usr/local/bin/rsmon-worker-edge` | Binary. | +| `/etc/rsmon-worker-edge/worker.env` | Configuration, mode `0600`. | +| `/var/lib/rsmon-worker-edge/` | Data directory. | +| `/etc/systemd/system/rsmon-worker-edge.service` | systemd unit. | + +Each instance is an independent service (`rsmon-worker.service`, +`rsmon-worker-edge.service`, …) and can be managed separately. + +## What the installer does + +For a binary install, `rsmon-worker install` performs these actions in order: + +1. **Validates** the instance name, the `--env-file` (if any), and the resolved + `RSMON_URL`/`RSMON_TOKEN`. Nothing on the host changes before validation + passes. +2. **Resolves** every supported variable with the precedence above and, when + `WORKER_LOGIN`/`WORKER_PASSWORD` are both unset, generates a random console + password. +3. **Copies the binary** from the running executable (or `--binary`) to + `/usr/local/bin/rsmon-worker[-]` with an atomic rename. +4. **Creates** the data directory (`/var/lib/rsmon-worker[-]/webapp`) and + the config directory (`/etc/rsmon-worker[-]`, mode `0750`). +5. **Writes the env file** to `/worker.env` (mode `0600`), in a + stable canonical order. +6. **Writes the systemd unit** to + `/etc/systemd/system/rsmon-worker[-].service`. +7. Runs `systemctl daemon-reload`, `systemctl enable`, and, unless `--no-start`, + `systemctl restart` followed by `systemctl is-active --quiet` to confirm the + service came up. +8. **Prints a summary**: unit name, binary, env file, data dir, console URL, + status, and the generated password if one was created. + +### The generated systemd unit + +The unit is a hardened `Type=simple` root service ordered after +`network-online.target` and `docker.service`: + +- `ExecStart=/usr/local/bin/rsmon-worker[-]` +- `EnvironmentFile=/etc/rsmon-worker[-]/worker.env` +- `Environment=HOME=/var/lib/rsmon-worker[-]` +- `Environment=RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker[-]/webapp` +- `AmbientCapabilities=CAP_NET_RAW` / `CapabilityBoundingSet=CAP_NET_RAW` for + ICMP checks. +- `PrivateTmp`, `ProtectHome`, `ProtectSystem=full`, and + `ReadWritePaths=` to constrain writes. +- `Restart=always`, `RestartSec=5s`. + +## Docker install + +Installs a systemd unit that runs the published image instead of a local +binary. Requires an immutable digest; mutable tags are rejected. + +```bash +sudo ./bin/rsmon-worker install --docker \ + --image 'reg.rsxx.ru/rsmon/rsmon-worker@sha256:<64-hex-digest>' \ + --token-file worker-token +``` + +`--name` works with `--docker` too; the container and volume are namespaced by +instance (`rsmon-worker-edge`, `rsmon-worker-data-edge`). The container always +mounts its data volume at the in-image `/var/lib/rsmon-worker`. + +## Options reference + +``` +rsmon-worker install [--token TOKEN|--token-file FILE|--env-file FILE] + [--name NAME] [--port PORT] [options] +``` + +| Flag | Purpose | +| --- | --- | +| `--token`, `--api-key` | Worker API token (`RSMON_TOKEN`). | +| `--token-file` | File containing the worker token (avoids shell history). | +| `--env-file` | Strict worker env file; validated then used as the config source. | +| `--url` | Control-plane URL (`RSMON_URL`). | +| `--host` | Console bind address (`WORKER_HOST`). | +| `--port` | Console port (`WORKER_PORT`; required with `--name`). | +| `--login` | Console login (`WORKER_LOGIN`). | +| `--password`, `--password-file` | Console password, or a file containing it. | +| `--name` | Instance name for a co-located worker. | +| `--binary` | Binary to install (default: this executable). | +| `--docker` | Install the prebuilt Docker image (requires `--image`). | +| `--image` | Immutable `repository@sha256:<64 hex>` digest. | +| `--no-start` | Enable without starting. | + +`--token`/`--token-file` and `--password`/`--password-file` are mutually +exclusive within each pair; combining a direct secret with its file form is an +error. + +## Post-install operations + +```bash +systemctl status rsmon-worker[-] +journalctl -u rsmon-worker[-] -f +sudo systemctl restart rsmon-worker[-] +``` + +The public liveness endpoint is `GET /healthz`; probe it with +`rsmon-worker liveness`. The operator console is at `http://127.0.0.1:` +(loopback by default) and exposes the JSON API under `/web/api/*` using HTTP +basic auth. + +### Uninstall + +There is no `uninstall` subcommand yet. To remove an instance manually: + +```bash +sudo systemctl disable --now rsmon-worker- +sudo rm /etc/systemd/system/rsmon-worker-.service +sudo rm -rf /etc/rsmon-worker- /var/lib/rsmon-worker- /usr/local/bin/rsmon-worker- +sudo systemctl daemon-reload +``` + +## Security notes + +- Keep `/etc/rsmon-worker[-]/worker.env` mode `0600`; the installer writes + it that way. +- Prefer `--token-file`/`--password-file` or a `.env` over passing secrets as + flags, which can leak through shell history and process inspection. +- Bind the console to loopback (`WORKER_HOST=127.0.0.1`, the default) or front + it with an authenticated TLS reverse proxy. +- Each worker should use its own control-plane token. diff --git a/internal/installer/install.go b/internal/installer/install.go index 571a0cf..d855b13 100644 --- a/internal/installer/install.go +++ b/internal/installer/install.go @@ -1,6 +1,8 @@ package installer import ( + "crypto/rand" + "encoding/hex" "errors" "fmt" "io" @@ -11,137 +13,408 @@ import ( "regexp" "strings" "unicode" + + "github.com/joho/godotenv" ) 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_]*$`) + 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 = "" - binaryPath = "/usr/local/bin/rsmon-worker" - envPath = "/etc/rsmon-worker/worker.env" - unitPath = "/etc/systemd/system/rsmon-worker.service" + + defaultLogin = "admin" + defaultPort = "27401" ) -const systemdUnit = `[Unit] -Description=RSMon distributed monitoring worker -After=network-online.target - -[Service] -Type=simple -User=root -EnvironmentFile=/etc/rsmon-worker/worker.env -ExecStart=/usr/local/bin/rsmon-worker -Restart=on-failure - -[Install] -WantedBy=multi-user.target -` - -const dockerSystemdUnit = `[Unit] -Description=RSMon distributed monitoring worker (Docker) -After=network-online.target docker.service -Requires=docker.service - -[Service] -Type=simple -User=root -ExecStartPre=-docker rm -f rsmon-worker -ExecStart=docker run --rm --name rsmon-worker --network host --cap-add NET_RAW --env-file /etc/rsmon-worker/worker.env -v rsmon-worker-data:/var/lib/rsmon-worker %s -ExecStop=docker stop rsmon-worker -Restart=on-failure - -[Install] -WantedBy=multi-user.target -` - -type InstallOptions struct { - Binary string - EnvFile string - Token string - URL string - Docker bool - Image string - NoStart bool +// 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. +var installEnvKeys = []string{ + "RSMON_URL", + "RSMON_TOKEN", + "WORKER_HOST", + "WORKER_PORT", + "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 + 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") } - if opts.URL == "" { - opts.URL = DefaultURL - } - if err := ValidateURL(opts.URL); err != nil { + 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 err + return fmt.Errorf("--env-file: %w", err) } - } else { - if err := ValidateToken(opts.Token); err != nil { - return err + fe, err := godotenv.Read(opts.EnvFile) + if err != nil { + return fmt.Errorf("read --env-file: %w", err) } + fileEnv = fe } - if opts.Image == "" { - opts.Image = DefaultImage + + 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 } - } else if opts.Binary == "" { - var err error - opts.Binary, err = os.Executable() - if err != nil { - return fmt.Errorf("locate worker executable: %w", err) - } - } - unit := systemdUnit - if opts.Docker { 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 } - unit = DockerUnit(opts.Image) - } else if err := copyAtomic(opts.Binary, binaryPath, 0755); err != nil { - return fmt.Errorf("install binary: %w", 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 := writeAtomic(unitPath, []byte(unit), 0644); err != nil { + + 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 opts.EnvFile != "" { - if err := copyAtomic(opts.EnvFile, envPath, 0600); err != nil { - return fmt.Errorf("install environment: %w", err) - } - } else { - if err := writeAtomic(envPath, Environment(opts.URL, opts.Token), 0600); err != nil { - return fmt.Errorf("install environment: %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", "rsmon-worker.service"); err != nil { + if err := command("systemctl", "enable", p.unitName); err != nil { return err } if !opts.NoStart { - if err := command("systemctl", "restart", "rsmon-worker.service"); err != nil { + if err := command("systemctl", "restart", p.unitName); err != nil { return err } - if err := command("systemctl", "is-active", "--quiet", "rsmon-worker.service"); err != nil { + 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, + "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") + } + + // 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") @@ -153,6 +426,9 @@ func ValidateURL(raw string) error { 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") } @@ -164,8 +440,10 @@ func ValidateToken(token string) error { return nil } -// ValidateEnvironmentFile checks the worker credentials before installation -// changes the binary, systemd unit, or Docker image on the host. +// 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 { @@ -185,8 +463,8 @@ func ValidateEnvironmentFile(path string) error { if !ok || !envKeyPattern.MatchString(key) { return fmt.Errorf("worker environment line %d must use KEY=VALUE syntax", lineNumber) } - if strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "$\\\"'") { - return fmt.Errorf("worker environment line %d uses unsupported quoting, interpolation, or whitespace", lineNumber) + if err := validateEnvValue(key, value); err != nil { + return fmt.Errorf("worker environment line %d: %w", lineNumber, err) } if key == "RSMON_URL" || key == "RSMON_TOKEN" { if seenRequired[key] { @@ -205,6 +483,20 @@ func ValidateEnvironmentFile(path string) error { return 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") @@ -212,8 +504,11 @@ func ValidateImage(image string) error { 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 fmt.Sprintf(dockerSystemdUnit, image) + return dockerUnitFor(resolvePaths(""), image) } func copyAtomic(src, dst string, mode os.FileMode) error { diff --git a/internal/installer/install_test.go b/internal/installer/install_test.go index 93afeb9..3645f6c 100644 --- a/internal/installer/install_test.go +++ b/internal/installer/install_test.go @@ -118,9 +118,29 @@ func TestValidateImage(t *testing.T) { } func TestSystemdUnits(t *testing.T) { - if !strings.Contains(systemdUnit, "Type=simple\nUser=root\n") || !strings.Contains(systemdUnit, "ExecStart=/usr/local/bin/rsmon-worker\n") { - t.Fatal("binary systemd unit is not the simple root service") + primary := systemdUnitFor(resolvePaths("")) + if !strings.Contains(primary, "Type=simple\nUser=root\n") || + !strings.Contains(primary, "ExecStart=/usr/local/bin/rsmon-worker\n") || + !strings.Contains(primary, "EnvironmentFile=/etc/rsmon-worker/worker.env\n") { + t.Fatal("binary systemd unit is not the simple root service at the classic paths") } + if !strings.Contains(primary, "After=network-online.target docker.service") || + !strings.Contains(primary, "CAP_NET_RAW") || !strings.Contains(primary, "ProtectSystem=full") { + t.Fatal("binary systemd unit must order after docker and harden for ping/compose") + } + + named := systemdUnitFor(resolvePaths("edge")) + for _, want := range []string{ + "ExecStart=/usr/local/bin/rsmon-worker-edge", + "EnvironmentFile=/etc/rsmon-worker-edge/worker.env", + "RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker-edge/webapp", + "(edge)", + } { + if !strings.Contains(named, want) { + t.Fatalf("named binary unit missing %q", want) + } + } + const image = "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" unit := DockerUnit(image) for _, want := range []string{"ExecStartPre=-docker rm -f rsmon-worker", "docker run --rm", image} { @@ -128,4 +148,137 @@ func TestSystemdUnits(t *testing.T) { t.Fatalf("Docker systemd unit missing %q", want) } } + namedDocker := dockerUnitFor(resolvePaths("edge"), image) + for _, want := range []string{"docker rm -f rsmon-worker-edge", "--name rsmon-worker-edge ", "-v rsmon-worker-data-edge:/var/lib/rsmon-worker"} { + if !strings.Contains(namedDocker, want) { + t.Fatalf("named Docker unit missing %q", want) + } + } +} + +func TestResolvePaths(t *testing.T) { + primary := resolvePaths("") + if primary.binary != "/usr/local/bin/rsmon-worker" || + primary.configDir != "/etc/rsmon-worker" || + primary.envFile != "/etc/rsmon-worker/worker.env" || + primary.dataDir != "/var/lib/rsmon-worker" || + primary.unitName != "rsmon-worker.service" || + primary.unitFile != "/etc/systemd/system/rsmon-worker.service" || + primary.container != "rsmon-worker" || primary.volume != "rsmon-worker-data" { + t.Fatalf("primary paths wrong: %+v", primary) + } + edge := resolvePaths("edge") + if edge.binary != "/usr/local/bin/rsmon-worker-edge" || + edge.configDir != "/etc/rsmon-worker-edge" || + edge.envFile != "/etc/rsmon-worker-edge/worker.env" || + edge.dataDir != "/var/lib/rsmon-worker-edge" || + edge.unitName != "rsmon-worker-edge.service" || + edge.unitFile != "/etc/systemd/system/rsmon-worker-edge.service" || + edge.container != "rsmon-worker-edge" || edge.volume != "rsmon-worker-data-edge" { + t.Fatalf("named paths wrong: %+v", edge) + } +} + +func TestValidateInstanceName(t *testing.T) { + for _, n := range []string{"", "dev", "edge-1", "a", "ab"} { + if err := validateInstanceName(n); err != nil { + t.Fatalf("validateInstanceName(%q): %v", n, err) + } + } + for _, n := range []string{"Dev", "dev_", "-dev", "dev-", "a.b", strings.Repeat("a", 33), "dev zone"} { + if err := validateInstanceName(n); err == nil { + t.Fatalf("validateInstanceName(%q) succeeded", n) + } + } +} + +func TestResolveInstallEnv(t *testing.T) { + // Force a deterministic process environment so precedence is exact. + t.Setenv("RSMON_URL", "https://proc.test") + t.Setenv("RSMON_TOKEN", "proc-token") + t.Setenv("WORKER_PORT", "29999") + t.Setenv("WORKER_LOGIN", "") + t.Setenv("WORKER_PASSWORD", "") + + t.Run("flag beats process env", func(t *testing.T) { + v, err := resolveInstallEnv(InstallOptions{ + URL: "https://flag.test", Token: "flag-token", Port: "28080", + }, "", nil) + if err != nil { + t.Fatal(err) + } + if v["RSMON_URL"] != "https://flag.test" || v["RSMON_TOKEN"] != "flag-token" || v["WORKER_PORT"] != "28080" { + t.Fatalf("flag did not win: %+v", v) + } + }) + + t.Run("process env fills when flags empty", func(t *testing.T) { + v, err := resolveInstallEnv(InstallOptions{}, "", nil) + if err != nil { + t.Fatal(err) + } + if v["RSMON_URL"] != "https://proc.test" || v["RSMON_TOKEN"] != "proc-token" || v["WORKER_PORT"] != "29999" { + t.Fatalf("process env not used: %+v", v) + } + }) + + t.Run("env file beats process env", func(t *testing.T) { + v, err := resolveInstallEnv(InstallOptions{}, "", map[string]string{ + "RSMON_URL": "https://file.test", "RSMON_TOKEN": "file-token", + }) + if err != nil { + t.Fatal(err) + } + if v["RSMON_URL"] != "https://file.test" || v["RSMON_TOKEN"] != "file-token" { + t.Fatalf("env file did not beat process: %+v", v) + } + }) + + t.Run("token required", func(t *testing.T) { + t.Setenv("RSMON_TOKEN", "") + if _, err := resolveInstallEnv(InstallOptions{}, "", nil); err == nil { + t.Fatal("missing token accepted") + } + }) + + t.Run("named instance requires port", func(t *testing.T) { + t.Setenv("WORKER_PORT", "") + if _, err := resolveInstallEnv(InstallOptions{}, "edge", nil); err == nil { + t.Fatal("named instance without port accepted") + } + }) + + t.Run("basic auth XOR rejected", func(t *testing.T) { + _, err := resolveInstallEnv(InstallOptions{Login: "admin"}, "", nil) + if err == nil { + t.Fatal("login-only accepted") + } + }) +} + +func TestRenderEnvFileOrderAndOmission(t *testing.T) { + got := string(renderEnvFile(map[string]string{ + "WORKER_PORT": "27402", + "RSMON_TOKEN": "secret", + "RSMON_URL": "https://rsmon.ru", + "WORKER_LOGIN": "", // omitted + "WORKER_PASSWORD": "", + })) + want := "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\nWORKER_PORT=27402\n" + if got != want { + t.Fatalf("renderEnvFile = %q, want %q", got, want) + } +} + +func TestValidateEnvValue(t *testing.T) { + for _, v := range []string{"secret", "abc-123", "https://rsmon.ru", "27402"} { + if err := validateEnvValue("KEY", v); err != nil { + t.Fatalf("validateEnvValue(%q): %v", v, err) + } + } + for _, v := range []string{"a b", `"q"`, `'q'`, "a$b", `a\b`, "a\nb"} { + if err := validateEnvValue("KEY", v); err == nil { + t.Fatalf("validateEnvValue(%q) succeeded", v) + } + } }