294 lines
7.7 KiB
Go
294 lines
7.7 KiB
Go
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
|
|
}
|