// SPDX-License-Identifier: AGPL-3.0-only package deploy import ( "context" "errors" "fmt" "io" "net/http" "net/url" "sort" "strings" "time" "gamertan.com/tend/internal/process" ) type Operator interface { Restart(context.Context, string) error Stop(context.Context, string) error IsActive(context.Context, string) (bool, error) StartCandidate(context.Context, string, string, map[string]string) error ValidateCaddy(context.Context, string) error ReloadCaddy(context.Context) error Probe(context.Context, string, string, string, string) error } type SystemOperator struct { Runner process.Runner Timeout time.Duration } func (o SystemOperator) Restart(ctx context.Context, unit string) error { _, err := o.Runner.Run(ctx, "/", nil, "systemctl", "restart", unit) return err } func (o SystemOperator) Stop(ctx context.Context, unit string) error { _, err := o.Runner.Run(ctx, "/", nil, "systemctl", "stop", unit) return err } func (o SystemOperator) IsActive(ctx context.Context, unit string) (bool, error) { out, err := o.Runner.Run(ctx, "/", nil, "systemctl", "is-active", unit) if err != nil { if strings.TrimSpace(string(out)) == "inactive" || strings.TrimSpace(string(out)) == "failed" { return false, nil } return false, err } return strings.TrimSpace(string(out)) == "active", nil } func (o SystemOperator) StartCandidate(ctx context.Context, unit, binary string, env map[string]string) error { args := []string{ "--unit", unit, "--collect", "--property=DynamicUser=yes", "--property=NoNewPrivileges=yes", "--property=PrivateDevices=yes", "--property=PrivateTmp=yes", "--property=ProtectClock=yes", "--property=ProtectControlGroups=yes", "--property=ProtectHome=yes", "--property=ProtectHostname=yes", "--property=ProtectKernelLogs=yes", "--property=ProtectKernelModules=yes", "--property=ProtectKernelTunables=yes", "--property=ProtectSystem=strict", "--property=RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX", "--property=RestrictNamespaces=yes", "--property=RestrictRealtime=yes", "--property=RestrictSUIDSGID=yes", "--property=LockPersonality=yes", "--property=MemoryDenyWriteExecute=yes", "--property=CapabilityBoundingSet=", "--property=AmbientCapabilities=", } keys := make([]string, 0, len(env)) for key := range env { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { args = append(args, "--setenv", key+"="+env[key]) } args = append(args, "--", binary) _, err := o.Runner.Run(ctx, "/", nil, "systemd-run", args...) return err } func (o SystemOperator) ValidateCaddy(ctx context.Context, path string) error { _, err := o.Runner.Run(ctx, "/", nil, "caddy", "validate", "--config", path, "--adapter", "caddyfile") return err } func (o SystemOperator) ReloadCaddy(ctx context.Context) error { _, err := o.Runner.Run(ctx, "/", nil, "systemctl", "reload", "caddy.service") return err } func (o SystemOperator) Probe(ctx context.Context, address, host, path, contains string) error { u := url.URL{Scheme: "http", Host: address, Path: path} req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return err } req.Host = host client := &http.Client{Timeout: o.Timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect refused") }} response, err := client.Do(req) if err != nil { return err } defer response.Body.Close() if response.StatusCode != http.StatusOK { return fmt.Errorf("probe returned HTTP %d", response.StatusCode) } body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if err != nil { return err } if contains != "" && !strings.Contains(string(body), contains) { return errors.New("probe response omitted required marker") } return nil }