From 3256dcdc12c56554a2a64d9155e64990fc3133b5 Mon Sep 17 00:00:00 2001 From: Gleb Tv Date: Sun, 19 Jul 2026 13:21:11 +0300 Subject: [PATCH] feat: add worker install and deploy --- Dockerfile | 6 +- Makefile | 1 + README.md | 74 ++++++- checks/cbssl/README.md | 10 +- checks/cbssl/cbssl.go | 2 - cmd/rsmon-worker/main.go | 7 + cmd/rsmon-worker/main_test.go | 21 ++ cmd/rsmon-worker/management.go | 145 ++++++++++++++ docs/private-workers.md | 21 +- go.sum | 2 + internal/installer/deploy.go | 260 +++++++++++++++++++++++++ internal/installer/deploy_test.go | 41 ++++ internal/installer/install.go | 225 +++++++++++++++++++++ internal/installer/install_test.go | 68 +++++++ packaging/systemd/rsmon-worker.service | 19 +- scripts/install-systemd.sh | 48 +---- 16 files changed, 870 insertions(+), 80 deletions(-) create mode 100644 cmd/rsmon-worker/management.go create mode 100644 internal/installer/deploy.go create mode 100644 internal/installer/deploy_test.go create mode 100644 internal/installer/install.go create mode 100644 internal/installer/install_test.go diff --git a/Dockerfile b/Dockerfile index 93f4184..5a06660 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM reg.rsxx.ru/library/golang:1-trixie AS builder +FROM golang:1-trixie AS builder WORKDIR /src @@ -14,9 +14,9 @@ RUN CGO_ENABLED=0 go build -trimpath \ -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildDate=${BUILD_DATE}" \ -o /out/rsmon-worker ./cmd/rsmon-worker -FROM reg.rsxx.ru/library/debian:13-slim +FROM debian:13-slim -RUN apt-get update \ +RUN DEBIAN_FRONTEND=noninteractive apt-get update \ && apt-get install -y --no-install-recommends ca-certificates chromium tzdata \ && rm -rf /var/lib/apt/lists/* \ && groupadd --gid 10001 rsmon-worker \ diff --git a/Makefile b/Makefile index c5273f1..7789196 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ test: RSMON_ENV=test CWD=$(CURDIR) go test \ ./cmd/rsmon-worker \ ./internal/distworker \ + ./internal/installer \ ./internal/webapp \ ./internal/workercluster \ ./internal/wire \ diff --git a/README.md b/README.md index a9c369a..386b01e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ docker compose up -d docker compose logs -f worker ``` +Compose pulls `reg.rsxx.ru/rsmon/rsmon-worker:latest` by default. Set +`RSMON_WORKER_IMAGE` to use another published tag. + The operator console is bound to `127.0.0.1:27401` by default. Set `WORKER_BIND_IP` only when a firewall or TLS reverse proxy protects the port. Persistent web and cluster state is stored in the `worker-data` volume. @@ -44,13 +47,13 @@ Persistent web and cluster state is stored in the `worker-data` volume. ## Docker ```bash -docker build -t rsmon-worker:local . +docker pull reg.rsxx.ru/rsmon/rsmon-worker:latest docker run --rm \ --cap-add NET_RAW \ --env-file .env \ -p 127.0.0.1:27401:27401 \ -v rsmon-worker-data:/var/lib/rsmon-worker \ - rsmon-worker:local + reg.rsxx.ru/rsmon/rsmon-worker:latest ``` Published images use these tags: @@ -73,16 +76,36 @@ sudo apt-get update sudo apt-get install -y ca-certificates chromium libcap2-bin tzdata ``` -Build and install: +Build and install with a token file so the token does not enter shell history or +the process list: ```bash make build -cp packaging/systemd/worker.env.example worker.env -# Edit worker.env. -sudo ./scripts/install-systemd.sh --env ./worker.env +printf '%s\n' 'WORKER_TOKEN' > worker-token +chmod 600 worker-token +sudo ./bin/rsmon-worker install --token-file worker-token +rm worker-token ``` -To install an already downloaded release binary: +`install` copies the running binary to `/usr/local/bin/rsmon-worker`, writes the +mode-0600 configuration at `/etc/rsmon-worker/worker.env`, installs a simple +root-run systemd unit, and enables and starts it. Use `--url` to override +`https://rsmon.ru`, `--binary` to install another binary, or `--no-start` to +configure without starting. + +The Docker alternative pulls the prebuilt image and installs a systemd unit +that runs it: + +```bash +sudo ./bin/rsmon-worker install --docker --token-file worker-token +``` + +The image defaults to `reg.rsxx.ru/rsmon/rsmon-worker:latest`; override it with +`--image`. + +The token can also be passed as `--token` or `--api-key`, but that can expose it +through shell history and process inspection. The legacy repository-based +installer remains available: ```bash sudo ./scripts/install-systemd.sh --binary ./rsmon-worker --env ./worker.env @@ -96,9 +119,40 @@ journalctl -u rsmon-worker -f sudo systemctl restart rsmon-worker ``` -The service runs as the dedicated `rsmon-worker` user, stores state below -`/var/lib/rsmon-worker`, reads secrets from `/etc/rsmon-worker/worker.env`, and -has only `CAP_NET_RAW` for ICMP checks. +The service runs as root, reads secrets from +`/etc/rsmon-worker/worker.env`, and can execute ICMP checks without additional +capability setup. + +## SSH deployment + +`deploy` uploads the selected worker binary and a temporary mode-0600 +configuration over SSH, then runs the binary's `install` command through root or +`sudo`. The remote host needs Linux, systemd, `base64`, and either root SSH or +sudo access. + +```bash +./bin/rsmon-worker deploy \ + --host worker.example.com \ + --user deploy \ + --identity-file ~/.ssh/id_ed25519 \ + --token-file worker-token +``` + +Add `--docker` to install remotely by uploading only the configuration and +systemd unit, then running `docker pull` on the target. This mode does not upload +or execute the local worker binary, so the local and remote architectures may +differ. + +The default SSH port is 22 and the default RSMon URL is `https://rsmon.ru`. +Encrypted keys use `--key-passphrase-file`; password authentication uses +`--password-file`; password-protected sudo uses `--sudo-password-file`. Direct +secret flags are supported for interactive convenience but file options are +safer for automation. + +SSH host keys are checked against `~/.ssh/known_hosts` by default. Use +`--known-hosts PATH` or pin `--host-key-fingerprint SHA256:...`. The explicit +`--insecure-host-key` option disables host authentication and should only be +used in a trusted disposable environment. ## Configuration diff --git a/checks/cbssl/README.md b/checks/cbssl/README.md index be959df..6d4b838 100644 --- a/checks/cbssl/README.md +++ b/checks/cbssl/README.md @@ -30,7 +30,6 @@ The checker looks for CA certificates in the following locations (in order): 2. `/etc/ssl/cert.pem` - macOS system certificates 3. `/etc/pki/tls/certs/ca-bundle.crt` - RHEL/CentOS system certificates 4. `/usr/local/share/ca-certificates/` - Custom certificate directory -5. `/data/rsmon/docker/cert-bundles/output/` - Project-specific certificate bundles ### Mozilla CA Bundle @@ -38,7 +37,7 @@ The project includes the Mozilla CA certificate bundle which contains the same C ```bash # Downloaded from: https://curl.se/ca/cacert.pem -# Location: docker/cert-bundles/output/mozilla-ca-bundle.crt +# Install additional certificates in the operating system trust store. # Certificate count: 144 CA certificates ``` @@ -125,12 +124,11 @@ To update the CA certificate bundles: ```bash # Download latest Mozilla CA bundle -cd /data/rsmon -curl -fsSL -o docker/cert-bundles/output/mozilla-ca-bundle.crt \ +curl -fsSL -o /usr/local/share/ca-certificates/mozilla-ca-bundle.crt \ https://curl.se/ca/cacert.pem # Verify -grep -c "BEGIN CERTIFICATE" docker/cert-bundles/output/mozilla-ca-bundle.crt +grep -c "BEGIN CERTIFICATE" /usr/local/share/ca-certificates/mozilla-ca-bundle.crt ``` ## Troubleshooting @@ -141,7 +139,7 @@ This error occurs when no CA certificates can be found. Solutions: 1. Ensure the system has `ca-certificates` package installed 2. Place custom CA certificates in `/usr/local/share/ca-certificates/` -3. Add certificates to the project bundle at `docker/cert-bundles/output/` +3. Add certificates to the operating system trust store ### Certificate validation failures diff --git a/checks/cbssl/cbssl.go b/checks/cbssl/cbssl.go index b19c6a5..5d11bfd 100644 --- a/checks/cbssl/cbssl.go +++ b/checks/cbssl/cbssl.go @@ -313,7 +313,6 @@ func LoadBrowserCARoots() (chrome, firefox *x509.CertPool, err error) { "/etc/ssl/cert.pem", // macOS "/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS "/usr/local/share/ca-certificates/", // Custom certs - "/data/rsmon/docker/cert-bundles/output/", // Our custom bundled certs } for _, certPath := range certPaths { @@ -389,7 +388,6 @@ func loadFirefoxCARoots(pool *x509.CertPool) bool { paths := []string{ "/usr/lib/x86_64-linux-gnu/libnssckbi.so", // Debian/Ubuntu NSS module "/usr/lib/libnssckbi.so", // Generic path - "/data/rsmon/docker/cert-bundles/output/mozilla/", // Our bundled Mozilla certs } for _, path := range paths { diff --git a/cmd/rsmon-worker/main.go b/cmd/rsmon-worker/main.go index 8d39a22..e71c074 100644 --- a/cmd/rsmon-worker/main.go +++ b/cmd/rsmon-worker/main.go @@ -50,6 +50,9 @@ var ( func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) + if handled, code := dispatchManagementCommand(os.Args[1:]); handled { + os.Exit(code) + } loadDotEnv() versionFlag := flag.Bool("version", false, "Print version and exit") @@ -71,6 +74,10 @@ func main() { if len(flag.Args()) > 0 && flag.Arg(0) == "liveness" { os.Exit(livenessCheck()) } + if len(flag.Args()) > 0 { + fmt.Fprintf(os.Stderr, "unknown command %q\n", flag.Arg(0)) + os.Exit(2) + } log.Println("rsmon-worker starting...") diff --git a/cmd/rsmon-worker/main_test.go b/cmd/rsmon-worker/main_test.go index 63e18c5..00325b0 100644 --- a/cmd/rsmon-worker/main_test.go +++ b/cmd/rsmon-worker/main_test.go @@ -3,9 +3,30 @@ package main import ( "net/http" "net/http/httptest" + "os" "testing" ) +func TestDispatchManagementCommand(t *testing.T) { + if handled, _ := dispatchManagementCommand(nil); handled { + t.Fatal("empty arguments were handled") + } + if handled, code := dispatchManagementCommand([]string{"install", "--help"}); !handled || code != 0 { + t.Fatalf("install help = handled %t code %d", handled, code) + } +} + +func TestSecretValue(t *testing.T) { + path := t.TempDir() + "/secret" + if err := os.WriteFile(path, []byte("value\n"), 0600); err != nil { + t.Fatal(err) + } + got, err := secretValue("", path) + if err != nil || got != "value" { + t.Fatalf("secretValue() = %q, %v", got, err) + } +} + func TestProbeLiveness(t *testing.T) { t.Parallel() diff --git a/cmd/rsmon-worker/management.go b/cmd/rsmon-worker/management.go new file mode 100644 index 0000000..6a9ce27 --- /dev/null +++ b/cmd/rsmon-worker/management.go @@ -0,0 +1,145 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "strings" + + "rocketgit.ru/rsmon/worker/internal/installer" +) + +func dispatchManagementCommand(args []string) (bool, int) { + if len(args) == 0 { + return false, 0 + } + switch args[0] { + case "install": + return true, installCommand(args[1:]) + case "deploy": + return true, deployCommand(args[1:]) + default: + return false, 0 + } +} + +func installCommand(args []string) int { + fs := flag.NewFlagSet("install", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var opts installer.InstallOptions + var tokenFile 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.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.BoolVar(&opts.Docker, "docker", false, "run the prebuilt Docker image instead of the binary") + fs.StringVar(&opts.Image, "image", installer.DefaultImage, "Docker image used 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]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if fs.NArg() != 0 { + fs.Usage() + return 2 + } + var err error + if opts.Token, err = secretValue(opts.Token, tokenFile); 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 +} + +func deployCommand(args []string) int { + fs := flag.NewFlagSet("deploy", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var opts installer.DeployOptions + var tokenFile, passwordFile, passphraseFile, sudoPasswordFile string + fs.StringVar(&opts.Host, "host", "", "SSH server hostname or address") + fs.IntVar(&opts.Port, "port", 22, "SSH server port") + fs.StringVar(&opts.User, "user", "", "SSH username") + fs.StringVar(&opts.IdentityFile, "identity-file", "", "SSH private key path") + fs.StringVar(&opts.KeyPassphrase, "key-passphrase", "", "SSH private key passphrase") + fs.StringVar(&passphraseFile, "key-passphrase-file", "", "file containing the private key passphrase") + fs.StringVar(&opts.Password, "password", "", "SSH login password") + fs.StringVar(&passwordFile, "password-file", "", "file containing the SSH login password") + fs.StringVar(&opts.SudoPassword, "sudo-password", "", "remote sudo password") + fs.StringVar(&sudoPasswordFile, "sudo-password-file", "", "file containing the remote sudo password") + fs.StringVar(&opts.KnownHostsFile, "known-hosts", "", "known_hosts path (default: ~/.ssh/known_hosts)") + fs.StringVar(&opts.HostKeyFingerprint, "host-key-fingerprint", "", "expected SHA256 SSH host-key fingerprint") + fs.BoolVar(&opts.InsecureHostKey, "insecure-host-key", false, "disable SSH host-key verification (unsafe)") + fs.StringVar(&opts.Binary, "binary", "", "worker binary to upload (default: this executable)") + fs.StringVar(&opts.Token, "token", "", "worker API 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.BoolVar(&opts.Docker, "docker", false, "install the prebuilt Docker image remotely") + fs.StringVar(&opts.Image, "image", installer.DefaultImage, "Docker image used with --docker") + fs.BoolVar(&opts.NoStart, "no-start", false, "install and enable without starting") + fs.Usage = func() { + fmt.Fprintln(fs.Output(), "Usage: rsmon-worker deploy --host HOST --user USER --token TOKEN [SSH options]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if fs.NArg() != 0 { + fs.Usage() + return 2 + } + var err error + if opts.Token, err = secretValue(opts.Token, tokenFile); err != nil { + fmt.Fprintf(os.Stderr, "deploy failed: %v\n", err) + return 1 + } + if opts.Password, err = secretValue(opts.Password, passwordFile); err != nil { + fmt.Fprintf(os.Stderr, "deploy failed: %v\n", err) + return 1 + } + if opts.KeyPassphrase, err = secretValue(opts.KeyPassphrase, passphraseFile); err != nil { + fmt.Fprintf(os.Stderr, "deploy failed: %v\n", err) + return 1 + } + if opts.SudoPassword, err = secretValue(opts.SudoPassword, sudoPasswordFile); err != nil { + fmt.Fprintf(os.Stderr, "deploy failed: %v\n", err) + return 1 + } + if err := installer.Deploy(opts); err != nil { + fmt.Fprintf(os.Stderr, "deploy failed: %v\n", err) + return 1 + } + fmt.Fprintln(os.Stdout, "rsmon-worker deployed") + return 0 +} + +func secretValue(direct, path string) (string, error) { + if direct != "" && path != "" { + return "", fmt.Errorf("a secret and its file option cannot both be set") + } + if path == "" { + return direct, nil + } + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read secret file: %w", err) + } + return strings.TrimRight(string(b), "\r\n"), nil +} diff --git a/docs/private-workers.md b/docs/private-workers.md index c63c704..9497f15 100644 --- a/docs/private-workers.md +++ b/docs/private-workers.md @@ -113,8 +113,25 @@ type, control host access: - Compose mutation: separate high-risk capability, disabled by default; - Raft voter: durable fsync-capable cluster data directory and mTLS transport. -`CAP_NET_RAW` is granted only for ping/traceroute features. The worker remains -unprivileged otherwise. +The simple systemd installer currently runs the worker as root, matching the +minimal host-install model. Docker runs with the image's unprivileged user and +adds `NET_RAW` for ping/traceroute. A future hardened systemd profile can use a +dedicated user and narrow capabilities when host inventory requirements are +finalized. + +The standalone binary provides two systemd installation paths: + +- `rsmon-worker install` installs the current binary locally and writes the + worker URL/token to a root-owned mode-0600 environment file; `--docker` + installs a systemd-managed prebuilt image instead; +- `rsmon-worker deploy` verifies an SSH host key, uploads the binary and a + temporary mode-0600 environment file, and invokes `install` remotely; + `--docker` uploads only the environment and unit, then pulls on the target. + +Both default to `https://rsmon.ru` and accept `--token-file` for automation. +Direct secret flags are supported but can be visible in process listings; file +options are preferred. SSH login credentials, sudo credentials, and the worker +token remain separate. ## Worker Self-Monitoring diff --git a/go.sum b/go.sum index ce47547..6245253 100644 --- a/go.sum +++ b/go.sum @@ -413,6 +413,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/internal/installer/deploy.go b/internal/installer/deploy.go new file mode 100644 index 0000000..c7c0d1e --- /dev/null +++ b/internal/installer/deploy.go @@ -0,0 +1,260 @@ +package installer + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +type DeployOptions struct { + Host string + Port int + User string + IdentityFile string + KeyPassphrase string + Password string + SudoPassword string + KnownHostsFile string + HostKeyFingerprint string + InsecureHostKey bool + Binary string + Token string + URL string + Docker bool + Image string + NoStart bool +} + +func Deploy(opts DeployOptions) error { + if opts.Host == "" || opts.User == "" { + return errors.New("--host and --user are required") + } + if opts.Port == 0 { + opts.Port = 22 + } + if opts.Port < 1 || opts.Port > 65535 { + return errors.New("SSH port must be between 1 and 65535") + } + if err := ValidateToken(opts.Token); err != nil { + return err + } + if opts.URL == "" { + opts.URL = DefaultURL + } + if opts.Image == "" { + opts.Image = DefaultImage + } + if opts.Docker { + if err := ValidateImage(opts.Image); err != nil { + return err + } + } + if err := ValidateURL(opts.URL); err != nil { + return err + } + if !opts.Docker && opts.Binary == "" { + var err error + opts.Binary, err = os.Executable() + if err != nil { + return err + } + } + auth, err := sshAuth(opts) + if err != nil { + return err + } + hostKey, err := hostKeyCallback(opts) + if err != nil { + return err + } + client, err := ssh.Dial("tcp", net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port)), &ssh.ClientConfig{ + User: opts.User, + Auth: auth, + HostKeyCallback: hostKey, + Timeout: 15 * time.Second, + }) + if err != nil { + return fmt.Errorf("connect to %s: %w", opts.Host, err) + } + defer client.Close() + + suffix := strconv.FormatInt(time.Now().UnixNano(), 36) + remoteBinary := "/tmp/rsmon-worker-" + suffix + remoteEnv := remoteBinary + ".env" + remoteUnit := remoteBinary + ".service" + if err := uploadBytes(client, Environment(opts.URL, opts.Token), remoteEnv, 0600); err != nil { + return fmt.Errorf("upload configuration: %w", err) + } + defer runRemote(client, "rm -f -- "+shellQuote(remoteBinary)+" "+shellQuote(remoteEnv)+" "+shellQuote(remoteUnit), nil) //nolint:errcheck + if opts.Docker { + if err := uploadBytes(client, []byte(DockerUnit(opts.Image)), remoteUnit, 0600); err != nil { + return fmt.Errorf("upload systemd unit: %w", err) + } + return deployDocker(client, opts, remoteEnv, remoteUnit) + } + if err := upload(client, opts.Binary, remoteBinary, 0700); err != nil { + return fmt.Errorf("upload worker: %w", err) + } + args := shellQuote(remoteBinary) + " install --binary " + shellQuote(remoteBinary) + " --env-file " + shellQuote(remoteEnv) + if opts.NoStart { + args += " --no-start" + } + var command string + var stdin []byte + if opts.User == "root" { + command = args + } else if opts.SudoPassword != "" { + command = "sudo -S -p '' -- " + args + stdin = []byte(opts.SudoPassword + "\n") + } else { + command = "sudo -n -- " + args + } + if err := runRemote(client, command, stdin); err != nil { + return fmt.Errorf("remote install: %w", err) + } + return nil +} + +func deployDocker(client *ssh.Client, opts DeployOptions, remoteEnv, remoteUnit string) error { + script := "install -d -m 0755 /etc/rsmon-worker" + + " && install -m 0600 " + shellQuote(remoteEnv) + " /etc/rsmon-worker/worker.env" + + " && install -m 0644 " + shellQuote(remoteUnit) + " /etc/systemd/system/rsmon-worker.service" + + " && docker pull " + shellQuote(opts.Image) + + " && systemctl daemon-reload" + + " && systemctl enable rsmon-worker.service" + if !opts.NoStart { + script += " && systemctl restart rsmon-worker.service && systemctl is-active --quiet rsmon-worker.service" + } + command := "sh -c " + shellQuote(script) + var stdin []byte + if opts.User != "root" { + if opts.SudoPassword != "" { + command = "sudo -S -p '' -- " + command + stdin = []byte(opts.SudoPassword + "\n") + } else { + command = "sudo -n -- " + command + } + } + if err := runRemote(client, command, stdin); err != nil { + return fmt.Errorf("remote Docker install: %w", err) + } + return nil +} + +func sshAuth(opts DeployOptions) ([]ssh.AuthMethod, error) { + var methods []ssh.AuthMethod + if opts.IdentityFile != "" { + key, err := os.ReadFile(opts.IdentityFile) + if err != nil { + return nil, fmt.Errorf("read identity file: %w", err) + } + var signer ssh.Signer + if opts.KeyPassphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(opts.KeyPassphrase)) + } else { + signer, err = ssh.ParsePrivateKey(key) + } + if err != nil { + return nil, fmt.Errorf("parse identity file: %w", err) + } + methods = append(methods, ssh.PublicKeys(signer)) + } + if opts.Password != "" { + methods = append(methods, ssh.Password(opts.Password)) + } + if len(methods) == 0 { + return nil, errors.New("provide --identity-file or --password") + } + return methods, nil +} + +func hostKeyCallback(opts DeployOptions) (ssh.HostKeyCallback, error) { + if opts.HostKeyFingerprint != "" { + want := opts.HostKeyFingerprint + return func(_ string, _ net.Addr, key ssh.PublicKey) error { + if got := ssh.FingerprintSHA256(key); got != want { + return fmt.Errorf("host key fingerprint mismatch: got %s", got) + } + return nil + }, nil + } + if opts.InsecureHostKey { + return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec // explicit operator opt-in + } + path := opts.KnownHostsFile + if path == "" { + u, err := user.Current() + if err != nil { + return nil, err + } + path = filepath.Join(u.HomeDir, ".ssh", "known_hosts") + } + return knownhosts.New(path) +} + +func upload(client *ssh.Client, localPath, remotePath string, mode os.FileMode) error { + f, err := os.Open(localPath) + if err != nil { + return err + } + defer f.Close() + return uploadReader(client, f, remotePath, mode) +} + +func uploadBytes(client *ssh.Client, data []byte, remotePath string, mode os.FileMode) error { + return uploadReader(client, bytes.NewReader(data), remotePath, mode) +} + +func uploadReader(client *ssh.Client, src io.Reader, remotePath string, mode os.FileMode) error { + session, err := client.NewSession() + if err != nil { + return err + } + defer session.Close() + stdin, err := session.StdinPipe() + if err != nil { + return err + } + session.Stdout = os.Stdout + session.Stderr = os.Stderr + command := "umask 077; base64 -d > " + shellQuote(remotePath) + " && chmod " + fmt.Sprintf("%#o", mode.Perm()) + " " + shellQuote(remotePath) + if err := session.Start(command); err != nil { + return err + } + encoder := base64.NewEncoder(base64.StdEncoding, stdin) + _, copyErr := io.Copy(encoder, src) + closeErr := encoder.Close() + pipeErr := stdin.Close() + waitErr := session.Wait() + return errors.Join(copyErr, closeErr, pipeErr, waitErr) +} + +func runRemote(client *ssh.Client, command string, stdin []byte) error { + session, err := client.NewSession() + if err != nil { + return err + } + defer session.Close() + if stdin != nil { + session.Stdin = bytes.NewReader(stdin) + } + session.Stdout = os.Stdout + session.Stderr = os.Stderr + return session.Run(command) +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} diff --git a/internal/installer/deploy_test.go b/internal/installer/deploy_test.go new file mode 100644 index 0000000..0e79ac7 --- /dev/null +++ b/internal/installer/deploy_test.go @@ -0,0 +1,41 @@ +package installer + +import ( + "crypto/ed25519" + "crypto/rand" + "net" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestFingerprintHostKeyCallback(t *testing.T) { + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + publicKey, err := ssh.NewPublicKey(privateKey.Public()) + if err != nil { + t.Fatal(err) + } + callback, err := hostKeyCallback(DeployOptions{HostKeyFingerprint: ssh.FingerprintSHA256(publicKey)}) + if err != nil { + t.Fatal(err) + } + if err := callback("host", &net.TCPAddr{}, publicKey); err != nil { + t.Fatalf("matching fingerprint rejected: %v", err) + } + callback, err = hostKeyCallback(DeployOptions{HostKeyFingerprint: "SHA256:wrong"}) + if err != nil { + t.Fatal(err) + } + if err := callback("host", &net.TCPAddr{}, publicKey); err == nil { + t.Fatal("mismatched fingerprint accepted") + } +} + +func TestKnownHostsMissingFile(t *testing.T) { + if _, err := hostKeyCallback(DeployOptions{KnownHostsFile: t.TempDir() + "/missing"}); err == nil { + t.Fatal("missing known_hosts file accepted") + } +} diff --git a/internal/installer/install.go b/internal/installer/install.go new file mode 100644 index 0000000..e766913 --- /dev/null +++ b/internal/installer/install.go @@ -0,0 +1,225 @@ +package installer + +import ( + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +var dockerImagePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:@-]*$`) + +const ( + DefaultURL = "https://rsmon.ru" + DefaultImage = "reg.rsxx.ru/rsmon/rsmon-worker:latest" + binaryPath = "/usr/local/bin/rsmon-worker" + envPath = "/etc/rsmon-worker/worker.env" + unitPath = "/etc/systemd/system/rsmon-worker.service" +) + +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 +} + +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 { + return err + } + if opts.EnvFile == "" { + if err := ValidateToken(opts.Token); err != nil { + return err + } + } + if opts.Image == "" { + opts.Image = DefaultImage + } + 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) + } + if err := writeAtomic(unitPath, []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 := command("systemctl", "daemon-reload"); err != nil { + return err + } + if err := command("systemctl", "enable", "rsmon-worker.service"); err != nil { + return err + } + if !opts.NoStart { + if err := command("systemctl", "restart", "rsmon-worker.service"); err != nil { + return err + } + if err := command("systemctl", "is-active", "--quiet", "rsmon-worker.service"); err != nil { + return err + } + } + return nil +} + +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 +} + +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 +} + +func ValidateImage(image string) error { + if !dockerImagePattern.MatchString(image) { + return errors.New("Docker image must be one non-option argument") + } + return nil +} + +func DockerUnit(image string) string { + return fmt.Sprintf(dockerSystemdUnit, 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 +} diff --git a/internal/installer/install_test.go b/internal/installer/install_test.go new file mode 100644 index 0000000..5ff2934 --- /dev/null +++ b/internal/installer/install_test.go @@ -0,0 +1,68 @@ +package installer + +import ( + "strings" + "testing" +) + +func TestValidateURL(t *testing.T) { + for _, raw := range []string{"https://rsmon.ru", "http://localhost:7401"} { + if err := ValidateURL(raw); err != nil { + t.Fatalf("ValidateURL(%q): %v", raw, err) + } + } + for _, raw := range []string{"", "rsmon.ru", "file:///tmp/x"} { + if err := ValidateURL(raw); err == nil { + t.Fatalf("ValidateURL(%q) succeeded", raw) + } + } +} + +func TestValidateToken(t *testing.T) { + if err := ValidateToken("token"); err != nil { + t.Fatal(err) + } + for _, token := range []string{"", " ", "token\nRSMON_URL=https://evil.test"} { + if err := ValidateToken(token); err == nil { + t.Fatalf("ValidateToken(%q) succeeded", token) + } + } +} + +func TestEnvironment(t *testing.T) { + got := string(Environment("https://example.test", "secret")) + for _, want := range []string{"RSMON_URL=https://example.test\n", "RSMON_TOKEN=secret\n", "WORKER_HOST=127.0.0.1\n"} { + if !strings.Contains(got, want) { + t.Fatalf("environment missing %q", want) + } + } +} + +func TestShellQuote(t *testing.T) { + if got, want := shellQuote("a'b"), `'a'\''b'`; got != want { + t.Fatalf("shellQuote() = %q, want %q", got, want) + } +} + +func TestValidateImage(t *testing.T) { + if err := ValidateImage(DefaultImage); err != nil { + t.Fatal(err) + } + for _, image := range []string{"", "-bad", "image name", "image%stest", `image"test`} { + if err := ValidateImage(image); err == nil { + t.Fatalf("ValidateImage(%q) succeeded", image) + } + } +} + +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") + } + unit := DockerUnit(DefaultImage) + for _, want := range []string{"ExecStartPre=-docker rm -f rsmon-worker", "docker run --rm", DefaultImage} { + if !strings.Contains(unit, want) { + t.Fatalf("Docker systemd unit missing %q", want) + } + } +} diff --git a/packaging/systemd/rsmon-worker.service b/packaging/systemd/rsmon-worker.service index 559d910..37bb5d1 100644 --- a/packaging/systemd/rsmon-worker.service +++ b/packaging/systemd/rsmon-worker.service @@ -1,28 +1,13 @@ [Unit] Description=RSMon distributed monitoring worker -Documentation=https://rocketgit.ru/rsmon/worker After=network-online.target -Wants=network-online.target [Service] Type=simple -User=rsmon-worker -Group=rsmon-worker -Environment=HOME=/var/lib/rsmon-worker -Environment=RSMON_WEBAPP_DATA_DIR=/var/lib/rsmon-worker/webapp +User=root EnvironmentFile=/etc/rsmon-worker/worker.env -WorkingDirectory=/var/lib/rsmon-worker ExecStart=/usr/local/bin/rsmon-worker -Restart=always -RestartSec=5s -TimeoutStopSec=20s -AmbientCapabilities=CAP_NET_RAW -CapabilityBoundingSet=CAP_NET_RAW -NoNewPrivileges=true -PrivateTmp=true -ProtectHome=true -ProtectSystem=full -ReadWritePaths=/var/lib/rsmon-worker +Restart=on-failure [Install] WantedBy=multi-user.target diff --git a/scripts/install-systemd.sh b/scripts/install-systemd.sh index 708f7dd..0013433 100755 --- a/scripts/install-systemd.sh +++ b/scripts/install-systemd.sh @@ -11,8 +11,7 @@ usage() { Usage: sudo ./scripts/install-systemd.sh [--binary PATH] [--env PATH] [--no-start] Installs rsmon-worker as /usr/local/bin/rsmon-worker and configures systemd. -If --env is omitted on a first install, an example file is installed and the -service is not started until its token and password are configured. +--env is required and must contain RSMON_URL and RSMON_TOKEN. USAGE } @@ -56,44 +55,13 @@ if [ ! -x "$BINARY" ]; then fi fi -if ! command -v chromium >/dev/null 2>&1; then - if command -v apt-get >/dev/null 2>&1; then - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates chromium libcap2-bin tzdata - else - printf 'Warning: Chromium is not installed; browser-backed HTTP checks will fail.\n' >&2 - fi +if [ -z "$ENV_FILE" ]; then + printf '%s\n' '--env is required; use a mode-0600 file containing RSMON_URL and RSMON_TOKEN.' >&2 + exit 2 fi -if ! getent group rsmon-worker >/dev/null; then - groupadd --system rsmon-worker -fi -if ! id rsmon-worker >/dev/null 2>&1; then - useradd --system --gid rsmon-worker --home-dir /var/lib/rsmon-worker --create-home --shell /usr/sbin/nologin rsmon-worker -fi - -install -d -m 0755 /etc/rsmon-worker -install -d -o rsmon-worker -g rsmon-worker -m 0750 /var/lib/rsmon-worker /var/lib/rsmon-worker/webapp /var/lib/rsmon-worker/cluster -install -m 0755 "$BINARY" /usr/local/bin/rsmon-worker -install -m 0644 "$ROOT_DIR/packaging/systemd/rsmon-worker.service" /etc/systemd/system/rsmon-worker.service - -if [ -n "$ENV_FILE" ]; then - install -m 0600 "$ENV_FILE" /etc/rsmon-worker/worker.env -elif [ ! -f /etc/rsmon-worker/worker.env ]; then - install -m 0600 "$ROOT_DIR/packaging/systemd/worker.env.example" /etc/rsmon-worker/worker.env - START_SERVICE=0 - printf 'Installed /etc/rsmon-worker/worker.env; configure it before starting the service.\n' -fi - -if command -v setcap >/dev/null 2>&1; then - setcap cap_net_raw=+ep /usr/local/bin/rsmon-worker || true -fi - -systemctl daemon-reload -systemctl enable rsmon-worker.service -if [ "$START_SERVICE" -eq 1 ]; then - systemctl restart rsmon-worker.service - systemctl --no-pager --full status rsmon-worker.service -else - printf 'Start after configuration with: systemctl start rsmon-worker\n' +ARGS=(install --binary "$BINARY" --env-file "$ENV_FILE") +if [ "$START_SERVICE" -eq 0 ]; then + ARGS+=(--no-start) fi +exec "$BINARY" "${ARGS[@]}"