package hostsetup import ( "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" ) type AppSpec struct { Name string Description string DefaultConfig string DefaultDataDir string DefaultUnit string ConfigTemplate string } type SetupOptions struct { BinaryPath string ConfigPath string DataDir string UnitPath string User string Group string Enable bool Start bool Restart bool DryRun bool OverwriteConfig bool Runner CommandRunner Getuid func() int } type CommandRunner interface { Run(ctx context.Context, name string, args ...string) ([]byte, error) } type execRunner struct{} func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { return exec.CommandContext(ctx, name, args...).CombinedOutput() } const defaultUser = "iop" func Run(ctx context.Context, spec AppSpec, opts SetupOptions, out io.Writer) error { if out == nil { out = io.Discard } if err := applyDefaults(spec, &opts); err != nil { return err } if err := validateDataDir(opts.DataDir); err != nil { return err } plan := buildPlan(spec, opts) fmt.Fprintln(out, plan) unit := renderUnit(spec, opts) if opts.DryRun { fmt.Fprintln(out, "--- unit preview ---") fmt.Fprintln(out, unit) fmt.Fprintln(out, "--- config preview ---") fmt.Fprintln(out, spec.ConfigTemplate) return nil } if needsRoot(opts) && opts.Getuid() != 0 { return fmt.Errorf("hostsetup: root required to write %s and %s", opts.UnitPath, opts.ConfigPath) } if err := ensureUserGroup(ctx, opts, out); err != nil { return err } if err := ensureDirs(opts); err != nil { return err } if err := ensureConfig(spec, opts, out); err != nil { return err } if err := writeUnit(opts, unit); err != nil { return err } if err := chownServicePaths(ctx, opts); err != nil { return err } if _, err := opts.Runner.Run(ctx, "systemctl", "daemon-reload"); err != nil { return fmt.Errorf("systemctl daemon-reload: %w", err) } if opts.Enable { if _, err := opts.Runner.Run(ctx, "systemctl", "enable", spec.Name); err != nil { return fmt.Errorf("systemctl enable: %w", err) } } if opts.Restart { if _, err := opts.Runner.Run(ctx, "systemctl", "restart", spec.Name); err != nil { return fmt.Errorf("systemctl restart: %w", err) } } else if opts.Start { if _, err := opts.Runner.Run(ctx, "systemctl", "start", spec.Name); err != nil { return fmt.Errorf("systemctl start: %w", err) } } return nil } func applyDefaults(spec AppSpec, opts *SetupOptions) error { if spec.Name == "" { return errors.New("hostsetup: spec.Name required") } if opts.BinaryPath == "" { bin, err := os.Executable() if err != nil { return fmt.Errorf("resolve binary: %w", err) } opts.BinaryPath = bin } if opts.ConfigPath == "" { opts.ConfigPath = spec.DefaultConfig } if opts.DataDir == "" { opts.DataDir = spec.DefaultDataDir } if opts.UnitPath == "" { opts.UnitPath = spec.DefaultUnit } if opts.User == "" { opts.User = defaultUser } if opts.Group == "" { opts.Group = opts.User } if opts.Runner == nil { opts.Runner = execRunner{} } if opts.Getuid == nil { opts.Getuid = os.Geteuid } return nil } func needsRoot(opts SetupOptions) bool { return isSystemPath(opts.UnitPath) || isSystemPath(opts.ConfigPath) || isSystemPath(opts.DataDir) } func isSystemPath(p string) bool { return strings.HasPrefix(p, "/etc/") || strings.HasPrefix(p, "/var/lib/") || strings.HasPrefix(p, "/usr/") } func buildPlan(spec AppSpec, opts SetupOptions) string { var b strings.Builder fmt.Fprintf(&b, "hostsetup plan for %s\n", spec.Name) fmt.Fprintf(&b, " binary : %s\n", opts.BinaryPath) fmt.Fprintf(&b, " config : %s\n", opts.ConfigPath) fmt.Fprintf(&b, " data : %s\n", opts.DataDir) fmt.Fprintf(&b, " unit : %s\n", opts.UnitPath) fmt.Fprintf(&b, " user : %s:%s\n", opts.User, opts.Group) fmt.Fprintf(&b, " enable=%t start=%t restart=%t dry-run=%t overwrite-config=%t", opts.Enable, opts.Start, opts.Restart, opts.DryRun, opts.OverwriteConfig) return b.String() } func ensureUserGroup(ctx context.Context, opts SetupOptions, out io.Writer) error { if _, err := opts.Runner.Run(ctx, "getent", "group", opts.Group); err != nil { if _, err := opts.Runner.Run(ctx, "groupadd", "--system", opts.Group); err != nil { return fmt.Errorf("groupadd %s: %w", opts.Group, err) } fmt.Fprintf(out, "created group %s\n", opts.Group) } if _, err := opts.Runner.Run(ctx, "id", "-u", opts.User); err != nil { if _, err := opts.Runner.Run(ctx, "useradd", "--system", "--gid", opts.Group, "--home-dir", opts.DataDir, "--shell", "/usr/sbin/nologin", opts.User); err != nil { return fmt.Errorf("useradd %s: %w", opts.User, err) } fmt.Fprintf(out, "created user %s\n", opts.User) } return nil } var unsafeDataDirs = map[string]bool{ "/": true, "/etc": true, "/home": true, "/opt": true, "/root": true, "/srv": true, "/tmp": true, "/usr": true, "/usr/lib": true, "/usr/local": true, "/var": true, "/var/lib": true, "/var/log": true, "/var/run": true, } func ensureTraversable(path string) error { info, err := os.Stat(path) if err != nil { return fmt.Errorf("stat %s: %w", path, err) } cur := info.Mode().Perm() wanted := cur | 0o011 // g+x, o+x — minimum to traverse for service user if wanted == cur { return nil } if err := os.Chmod(path, wanted); err != nil { return fmt.Errorf("chmod %s: %w", path, err) } return nil } func validateDataDir(p string) error { if p == "" { return nil } cleaned := filepath.Clean(p) if unsafeDataDirs[cleaned] { return fmt.Errorf("hostsetup: data dir %q is not an app-specific path; refuse to recursively chown", cleaned) } return nil } func ensureDirs(opts SetupOptions) error { if dir := filepath.Dir(opts.ConfigPath); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("mkdir %s: %w", dir, err) } } if opts.DataDir != "" { if parent := filepath.Dir(opts.DataDir); parent != "" && parent != "." && parent != "/" { if err := os.MkdirAll(parent, 0o755); err != nil { return fmt.Errorf("mkdir %s: %w", parent, err) } if err := ensureTraversable(parent); err != nil { return err } } if err := os.MkdirAll(opts.DataDir, 0o750); err != nil { return fmt.Errorf("mkdir %s: %w", opts.DataDir, err) } } if dir := filepath.Dir(opts.UnitPath); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("mkdir %s: %w", dir, err) } } return nil } func ensureConfig(spec AppSpec, opts SetupOptions, out io.Writer) error { _, err := os.Stat(opts.ConfigPath) switch { case err == nil && !opts.OverwriteConfig: fmt.Fprintf(out, "config %s already exists, skipping\n", opts.ConfigPath) return nil case err == nil && opts.OverwriteConfig: fmt.Fprintf(out, "overwriting config %s\n", opts.ConfigPath) case errors.Is(err, os.ErrNotExist): fmt.Fprintf(out, "writing config %s\n", opts.ConfigPath) default: return fmt.Errorf("stat config: %w", err) } return os.WriteFile(opts.ConfigPath, []byte(spec.ConfigTemplate), 0o600) } func writeUnit(opts SetupOptions, unit string) error { return os.WriteFile(opts.UnitPath, []byte(unit), 0o644) } func chownServicePaths(ctx context.Context, opts SetupOptions) error { owner := opts.User + ":" + opts.Group if opts.DataDir != "" { if _, err := opts.Runner.Run(ctx, "chown", "-R", owner, opts.DataDir); err != nil { return fmt.Errorf("chown %s %s: %w", owner, opts.DataDir, err) } } if opts.ConfigPath != "" { if _, err := opts.Runner.Run(ctx, "chown", owner, opts.ConfigPath); err != nil { return fmt.Errorf("chown %s %s: %w", owner, opts.ConfigPath, err) } } return nil }