feat(installer): multi-instance install with full env resolution
Все проверки выполнены успешно
CI / test (push) Successful in 1m0s
Docker / Build and publish worker image (push) Successful in 16m19s

Rework `rsmon-worker install` so one host can run several isolated
workers and so the installer consumes the full worker env-var set.

- main.go now loads .env before dispatching management commands, so
  install/deploy read the same environment as the runtime.
- New --name flag installs a co-located worker as rsmon-worker-<name>
  with its own binary (/usr/local/bin/rsmon-worker-<name>), config
  (/etc/rsmon-worker-<name>), data dir (/var/lib/rsmon-worker-<name>),
  and systemd unit. Named instances require an explicit WORKER_PORT.
- Configuration is resolved flags > --env-file > process env/.env
  (godotenv) > defaults; the resolved set is written as a stable,
  systemd-safe 0600 env file.
- WORKER_LOGIN/WORKER_PASSWORD default to a generated admin password
  (printed once) when both are unset; XOR is rejected.
- The generated unit is now hardened (After=docker.service, CAP_NET_RAW,
  ProtectSystem=full, ReadWritePaths=data dir) and parameterized by
  instance; the Docker unit is namespaced by instance too.
- install creates the data + config directories and prints a summary
  (unit, binary, env file, data dir, console URL, generated password).
- New flags: --name, --host, --port, --login, --password/--password-file.
- Tests: resolvePaths, validateInstanceName, resolveInstallEnv
  precedence/XOR/port-required, renderEnvFile, validateEnvValue, plus
  named-instance unit assertions. End-to-end verified by installing and
  removing a throwaway --name instance.
- docs/install.md documents the tool, config sources/precedence,
  single- and multi-instance flows, the exact actions performed, the
  generated unit, options, and uninstall.
Этот коммит содержится в:
root
2026-08-03 12:15:29 +03:00
родитель dde720eb44
Коммит 9b82c9f82f
6 изменённых файлов: 812 добавлений и 93 удалений

Просмотреть файл

@@ -77,6 +77,13 @@ Docker image references. The Harbor project is appended separately as `rsmon`.
## systemd ## 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: Install host dependencies first. On Debian or Ubuntu:
```bash ```bash

Просмотреть файл

@@ -36,10 +36,14 @@ var (
func main() { func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) 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 { if handled, code := dispatchManagementCommand(os.Args[1:]); handled {
os.Exit(code) os.Exit(code)
} }
loadDotEnv()
versionFlag := flag.Bool("version", false, "Print version and exit") 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)") noWeb := flag.Bool("no-web", false, "Disable the local web UI (Phase 1 ships with it on)")

Просмотреть файл

@@ -28,18 +28,24 @@ func installCommand(args []string) int {
fs := flag.NewFlagSet("install", flag.ContinueOnError) fs := flag.NewFlagSet("install", flag.ContinueOnError)
fs.SetOutput(os.Stderr) fs.SetOutput(os.Stderr)
var opts installer.InstallOptions 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.Binary, "binary", "", "worker binary to install (default: this executable)")
fs.StringVar(&opts.EnvFile, "env-file", "", "existing worker environment file") 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") fs.StringVar(&opts.Token, "token", "", "worker API token (RSMON_TOKEN)")
fs.StringVar(&opts.Token, "api-key", "", "worker API token (alias for --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(&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-<name>) with its own config/data/unit/port")
fs.BoolVar(&opts.Docker, "docker", false, "run the prebuilt Docker image instead of the binary") 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.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.BoolVar(&opts.NoStart, "no-start", false, "install and enable without starting")
fs.Usage = func() { 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() fs.PrintDefaults()
} }
if err := fs.Parse(args); err != nil { 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) fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
return 1 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 { if err := installer.Install(opts); err != nil {
fmt.Fprintf(os.Stderr, "install failed: %v\n", err) fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
return 1 return 1
} }
fmt.Fprintln(os.Stdout, "rsmon-worker installed")
return 0 return 0
} }

251
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-<name>` 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[-<name>]` with an atomic rename.
4. **Creates** the data directory (`/var/lib/rsmon-worker[-<name>]/webapp`) and
the config directory (`/etc/rsmon-worker[-<name>]`, mode `0750`).
5. **Writes the env file** to `<config dir>/worker.env` (mode `0600`), in a
stable canonical order.
6. **Writes the systemd unit** to
`/etc/systemd/system/rsmon-worker[-<name>].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[-<name>]`
- `EnvironmentFile=/etc/rsmon-worker[-<name>]/worker.env`
- `Environment=HOME=/var/lib/rsmon-worker[-<name>]`
- `Environment=RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker[-<name>]/webapp`
- `AmbientCapabilities=CAP_NET_RAW` / `CapabilityBoundingSet=CAP_NET_RAW` for
ICMP checks.
- `PrivateTmp`, `ProtectHome`, `ProtectSystem=full`, and
`ReadWritePaths=<data dir>` 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[-<name>]
journalctl -u rsmon-worker[-<name>] -f
sudo systemctl restart rsmon-worker[-<name>]
```
The public liveness endpoint is `GET /healthz`; probe it with
`rsmon-worker liveness`. The operator console is at `http://127.0.0.1:<port>`
(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-<name>
sudo rm /etc/systemd/system/rsmon-worker-<name>.service
sudo rm -rf /etc/rsmon-worker-<name> /var/lib/rsmon-worker-<name> /usr/local/bin/rsmon-worker-<name>
sudo systemctl daemon-reload
```
## Security notes
- Keep `/etc/rsmon-worker[-<name>]/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.

Просмотреть файл

@@ -1,6 +1,8 @@
package installer package installer
import ( import (
"crypto/rand"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -11,137 +13,408 @@ import (
"regexp" "regexp"
"strings" "strings"
"unicode" "unicode"
"github.com/joho/godotenv"
) )
var ( var (
dockerImagePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:-]*@sha256:[a-f0-9]{64}$`) 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_]*$`) envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
instanceNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
) )
const ( const (
DefaultURL = "https://rsmon.ru" DefaultURL = "https://rsmon.ru"
DefaultImage = "" DefaultImage = ""
binaryPath = "/usr/local/bin/rsmon-worker"
envPath = "/etc/rsmon-worker/worker.env" defaultLogin = "admin"
unitPath = "/etc/systemd/system/rsmon-worker.service" defaultPort = "27401"
) )
const systemdUnit = `[Unit] // installEnvKeys is the canonical, ordered set of worker environment
Description=RSMon distributed monitoring worker // variables the installer understands and writes to the unit's env
After=network-online.target // file. Order matters: the rendered file is stable and readable.
var installEnvKeys = []string{
[Service] "RSMON_URL",
Type=simple "RSMON_TOKEN",
User=root "WORKER_HOST",
EnvironmentFile=/etc/rsmon-worker/worker.env "WORKER_PORT",
ExecStart=/usr/local/bin/rsmon-worker "WORKER_URL",
Restart=on-failure "WORKER_LOGIN",
"WORKER_PASSWORD",
[Install] "WORKER_COMPOSE_ENABLED",
WantedBy=multi-user.target "WORKER_CLUSTER_ENABLED",
` "WORKER_CLUSTER_ID",
"WORKER_CLUSTER_PORT",
const dockerSystemdUnit = `[Unit] "WORKER_CLUSTER_PEERS",
Description=RSMon distributed monitoring worker (Docker) "WORKER_CLUSTER_DATA_DIR",
After=network-online.target docker.service "WORKER_RELEASE_URL",
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
`
// 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-<name> with its own
// binary path, config dir, data dir, systemd unit, and port.
type InstallOptions struct { type InstallOptions struct {
Binary string Binary string
EnvFile string EnvFile string
Token string Token string
URL string URL string
Host string
Port string
Login string
Password string
Name string
Docker bool Docker bool
Image string Image string
NoStart bool 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 -<name> everywhere.
type paths struct {
name string
binary string // /usr/local/bin/rsmon-worker[-name]
configDir string // /etc/rsmon-worker[-name]
envFile string // <configDir>/worker.env
dataDir string // /var/lib/rsmon-worker[-name]
unitName string // rsmon-worker[-name].service
unitFile string // /etc/systemd/system/<unitName>
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 { func Install(opts InstallOptions) error {
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
return errors.New("install must be run as root") return errors.New("install must be run as root")
} }
if opts.URL == "" { name := strings.TrimSpace(opts.Name)
opts.URL = DefaultURL if err := validateInstanceName(name); err != nil {
}
if err := ValidateURL(opts.URL); err != nil {
return err return err
} }
p := resolvePaths(name)
var fileEnv map[string]string
if opts.EnvFile != "" { 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 { 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 return err
} }
} else {
if err := ValidateToken(opts.Token); err != nil { generatedPassword := ""
return err 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
} }
if opts.Image == "" {
opts.Image = DefaultImage // Stage the binary (or pull the image) before touching config so a
} // download/build failure leaves the host untouched.
if opts.Docker { if opts.Docker {
if err := ValidateImage(opts.Image); err != nil { if err := ValidateImage(opts.Image); err != nil {
return err 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 { if _, err := exec.LookPath("docker"); err != nil {
return errors.New("docker is required for --docker installation") return errors.New("docker is required for --docker installation")
} }
if err := command("docker", "pull", opts.Image); err != nil { if err := command("docker", "pull", opts.Image); err != nil {
return err return err
} }
unit = DockerUnit(opts.Image) } else {
} else if err := copyAtomic(opts.Binary, binaryPath, 0755); err != nil { 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) 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) return fmt.Errorf("install systemd unit: %w", err)
} }
if opts.EnvFile != "" { if err := writeAtomic(p.envFile, renderEnvFile(values), 0600); err != nil {
if err := copyAtomic(opts.EnvFile, envPath, 0600); err != nil {
return fmt.Errorf("install environment: %w", err) 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 := command("systemctl", "daemon-reload"); err != nil { if err := command("systemctl", "daemon-reload"); err != nil {
return err return err
} }
if err := command("systemctl", "enable", "rsmon-worker.service"); err != nil { if err := command("systemctl", "enable", p.unitName); err != nil {
return err return err
} }
if !opts.NoStart { if !opts.NoStart {
if err := command("systemctl", "restart", "rsmon-worker.service"); err != nil { if err := command("systemctl", "restart", p.unitName); err != nil {
return err 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 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 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 { func ValidateURL(raw string) error {
if strings.ContainsAny(raw, "\r\n") { if strings.ContainsAny(raw, "\r\n") {
return fmt.Errorf("server URL must be an absolute HTTP(S) URL") return fmt.Errorf("server URL must be an absolute HTTP(S) URL")
@@ -153,6 +426,9 @@ func ValidateURL(raw string) error {
return nil 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 { func Environment(serverURL, token string) []byte {
return []byte("RSMON_URL=" + serverURL + "\nRSMON_TOKEN=" + token + "\nWORKER_HOST=127.0.0.1\n") 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 return nil
} }
// ValidateEnvironmentFile checks the worker credentials before installation // ValidateEnvironmentFile checks a worker credentials file before it
// changes the binary, systemd unit, or Docker image on the host. // 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 { func ValidateEnvironmentFile(path string) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -185,8 +463,8 @@ func ValidateEnvironmentFile(path string) error {
if !ok || !envKeyPattern.MatchString(key) { if !ok || !envKeyPattern.MatchString(key) {
return fmt.Errorf("worker environment line %d must use KEY=VALUE syntax", lineNumber) return fmt.Errorf("worker environment line %d must use KEY=VALUE syntax", lineNumber)
} }
if strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "$\\\"'") { if err := validateEnvValue(key, value); err != nil {
return fmt.Errorf("worker environment line %d uses unsupported quoting, interpolation, or whitespace", lineNumber) return fmt.Errorf("worker environment line %d: %w", lineNumber, err)
} }
if key == "RSMON_URL" || key == "RSMON_TOKEN" { if key == "RSMON_URL" || key == "RSMON_TOKEN" {
if seenRequired[key] { if seenRequired[key] {
@@ -205,6 +483,20 @@ func ValidateEnvironmentFile(path string) error {
return nil 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 { func ValidateImage(image string) error {
if !dockerImagePattern.MatchString(image) { if !dockerImagePattern.MatchString(image) {
return errors.New("Docker image must be an immutable repository@sha256:<64 lowercase hex characters> reference") 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 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 { func DockerUnit(image string) string {
return fmt.Sprintf(dockerSystemdUnit, image) return dockerUnitFor(resolvePaths(""), image)
} }
func copyAtomic(src, dst string, mode os.FileMode) error { func copyAtomic(src, dst string, mode os.FileMode) error {

Просмотреть файл

@@ -118,9 +118,29 @@ func TestValidateImage(t *testing.T) {
} }
func TestSystemdUnits(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") { primary := systemdUnitFor(resolvePaths(""))
t.Fatal("binary systemd unit is not the simple root service") 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" const image = "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
unit := DockerUnit(image) unit := DockerUnit(image)
for _, want := range []string{"ExecStartPre=-docker rm -f rsmon-worker", "docker run --rm", 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) 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)
}
}
} }