fix(worker): harden control-plane lifecycle
Все проверки выполнены успешно
CI / test (push) Successful in 2m32s
Docker / Build and publish worker image (push) Successful in 18m17s
Все проверки выполнены успешно
CI / test (push) Successful in 2m32s
Docker / Build and publish worker image (push) Successful in 18m17s
- reconnect safely after token rotation and retry leased results - reject malformed tasks and remove production cluster debug mutation - validate environment files and require immutable container images BREAKING CHANGE: Docker install, deploy, and Compose now require an immutable repository@sha256 image reference.
Этот коммит содержится в:
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -39,3 +40,16 @@ func TestKnownHostsMissingFile(t *testing.T) {
|
||||
t.Fatal("missing known_hosts file accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployRejectsMutableDockerImageBeforeConnecting(t *testing.T) {
|
||||
err := Deploy(DeployOptions{
|
||||
Host: "unreachable.example.test",
|
||||
User: "deploy",
|
||||
Token: "token",
|
||||
Docker: true,
|
||||
Image: "reg.rsxx.ru/rsmon/rsmon-worker:latest",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "immutable") {
|
||||
t.Fatalf("Deploy() error = %v, want immutable image error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,17 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var dockerImagePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:@-]*$`)
|
||||
var (
|
||||
dockerImagePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:-]*@sha256:[a-f0-9]{64}$`)
|
||||
envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultURL = "https://rsmon.ru"
|
||||
DefaultImage = "reg.rsxx.ru/rsmon/rsmon-worker:latest"
|
||||
DefaultImage = ""
|
||||
binaryPath = "/usr/local/bin/rsmon-worker"
|
||||
envPath = "/etc/rsmon-worker/worker.env"
|
||||
unitPath = "/etc/systemd/system/rsmon-worker.service"
|
||||
@@ -74,7 +78,11 @@ func Install(opts InstallOptions) error {
|
||||
if err := ValidateURL(opts.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.EnvFile == "" {
|
||||
if opts.EnvFile != "" {
|
||||
if err := ValidateEnvironmentFile(opts.EnvFile); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := ValidateToken(opts.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -156,9 +164,50 @@ func ValidateToken(token string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEnvironmentFile checks the worker credentials before installation
|
||||
// changes the binary, systemd unit, or Docker image on the host.
|
||||
func ValidateEnvironmentFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read worker environment: %w", err)
|
||||
}
|
||||
values := make(map[string]string)
|
||||
seenRequired := make(map[string]bool)
|
||||
for number, line := range strings.Split(string(data), "\n") {
|
||||
lineNumber := number + 1
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsRune(line, '\r') {
|
||||
return fmt.Errorf("worker environment line %d contains a carriage return", lineNumber)
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok || !envKeyPattern.MatchString(key) {
|
||||
return fmt.Errorf("worker environment line %d must use KEY=VALUE syntax", lineNumber)
|
||||
}
|
||||
if strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "$\\\"'") {
|
||||
return fmt.Errorf("worker environment line %d uses unsupported quoting, interpolation, or whitespace", lineNumber)
|
||||
}
|
||||
if key == "RSMON_URL" || key == "RSMON_TOKEN" {
|
||||
if seenRequired[key] {
|
||||
return fmt.Errorf("worker environment line %d duplicates %s", lineNumber, key)
|
||||
}
|
||||
seenRequired[key] = true
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
if err := ValidateURL(values["RSMON_URL"]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateToken(values["RSMON_TOKEN"]); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateImage(image string) error {
|
||||
if !dockerImagePattern.MatchString(image) {
|
||||
return errors.New("Docker image must be one non-option argument")
|
||||
return errors.New("Docker image must be an immutable repository@sha256:<64 lowercase hex characters> reference")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -29,6 +31,65 @@ func TestValidateToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEnvironmentFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contents string
|
||||
valid bool
|
||||
}{
|
||||
{name: "valid", contents: "# Worker credentials\nRSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\n\n", valid: true},
|
||||
{name: "missing URL", contents: "RSMON_TOKEN=secret\n"},
|
||||
{name: "missing token", contents: "RSMON_URL=https://rsmon.ru\n"},
|
||||
{name: "invalid URL", contents: "RSMON_URL=file:///tmp/worker\nRSMON_TOKEN=secret\n"},
|
||||
{name: "additional settings", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\nWORKER_HOST=0.0.0.0\nWORKER_URL=\n", valid: true},
|
||||
{name: "dotenv interpolation", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=${TOKEN}\n"},
|
||||
{name: "dotenv export", contents: "export RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\n"},
|
||||
{name: "YAML assignment", contents: "RSMON_URL: https://rsmon.ru\nRSMON_TOKEN=secret\n"},
|
||||
{name: "malformed key", contents: "RSMON-URL=https://rsmon.ru\nRSMON_TOKEN=secret\n"},
|
||||
{name: "missing assignment", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN\n"},
|
||||
{name: "quoted value", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=\"secret\"\n"},
|
||||
{name: "whitespace", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret value\n"},
|
||||
{name: "duplicate URL", contents: "RSMON_URL=https://rsmon.ru\nRSMON_URL=https://evil.test\nRSMON_TOKEN=secret\n"},
|
||||
{name: "duplicate empty token", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=\nRSMON_TOKEN=secret\n"},
|
||||
{name: "carriage return", contents: "RSMON_URL=https://rsmon.ru\r\nRSMON_TOKEN=secret\r\n"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "worker.env")
|
||||
if err := os.WriteFile(path, []byte(tt.contents), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := ValidateEnvironmentFile(path)
|
||||
if tt.valid && err != nil {
|
||||
t.Fatalf("ValidateEnvironmentFile() error = %v", err)
|
||||
}
|
||||
if !tt.valid && err == nil {
|
||||
t.Fatal("ValidateEnvironmentFile() succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEnvironmentFileInputErrors(t *testing.T) {
|
||||
if err := ValidateEnvironmentFile(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing environment file accepted")
|
||||
}
|
||||
if err := ValidateEnvironmentFile(t.TempDir()); err == nil {
|
||||
t.Fatal("directory accepted as an environment file")
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root can read mode-000 files")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "unreadable")
|
||||
if err := os.WriteFile(path, []byte("RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\n"), 0000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateEnvironmentFile(path); err == nil {
|
||||
t.Fatal("unreadable environment file accepted")
|
||||
}
|
||||
}
|
||||
|
||||
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"} {
|
||||
@@ -45,10 +106,11 @@ func TestShellQuote(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateImage(t *testing.T) {
|
||||
if err := ValidateImage(DefaultImage); err != nil {
|
||||
const image = "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
if err := ValidateImage(image); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, image := range []string{"", "-bad", "image name", "image%stest", `image"test`} {
|
||||
for _, image := range []string{"", "-bad", "image name", "image%stest", `image"test`, "reg.rsxx.ru/rsmon/rsmon-worker:latest", "reg.rsxx.ru/rsmon/rsmon-worker@sha256:short", "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef"} {
|
||||
if err := ValidateImage(image); err == nil {
|
||||
t.Fatalf("ValidateImage(%q) succeeded", image)
|
||||
}
|
||||
@@ -59,8 +121,9 @@ 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} {
|
||||
const image = "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
unit := DockerUnit(image)
|
||||
for _, want := range []string{"ExecStartPre=-docker rm -f rsmon-worker", "docker run --rm", image} {
|
||||
if !strings.Contains(unit, want) {
|
||||
t.Fatalf("Docker systemd unit missing %q", want)
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user