feat: add worker install and deploy
Все проверки выполнены успешно
CI / test (push) Successful in 2m24s
Docker / Build and publish worker image (push) Successful in 13m24s

Этот коммит содержится в:
Gleb Tv
2026-07-19 13:21:11 +03:00
родитель 579a14b403
Коммит 3256dcdc12
16 изменённых файлов: 870 добавлений и 80 удалений

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

@@ -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 \

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

@@ -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 \

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

@@ -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

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

@@ -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

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

@@ -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 {

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

@@ -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...")

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

@@ -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()

145
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
}

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

@@ -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

2
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=

260
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, "'", "'\\''") + "'"
}

41
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")
}
}

225
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
}

68
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)
}
}
}

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

@@ -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

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

@@ -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[@]}"