435 lines
14 KiB
Go
435 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"syscall"
|
|
|
|
"nadir/internal/auth"
|
|
"nadir/internal/config"
|
|
)
|
|
|
|
const defaultConfigTemplate = `# Nadir configuration - config.yaml
|
|
#
|
|
# Single source of truth for runtime settings.
|
|
#
|
|
|
|
server:
|
|
secure_tls: true
|
|
# trust_proxy: false
|
|
# tls_cert: /etc/nadir/tls/cert.pem
|
|
# tls_key: /etc/nadir/tls/key.pem
|
|
hostname: 127.0.0.1
|
|
port: 9999
|
|
release_repo: https://tea.urania.dev/urania/nadir-agent
|
|
|
|
roles:
|
|
admin:
|
|
"*": ["*"]
|
|
|
|
assignments:
|
|
%s: [admin]
|
|
`
|
|
|
|
// resolveConfigPath returns the config file path: CONFIG_PATH env (with ~ expanded)
|
|
// when set, otherwise the platform default. Used by run/install/uninstall.
|
|
func resolveConfigPath() (string, error) {
|
|
if p := os.Getenv("CONFIG_PATH"); p != "" {
|
|
return config.ExpandPath(p)
|
|
}
|
|
return config.DefaultPath()
|
|
}
|
|
|
|
func getUsername() string {
|
|
if u := os.Getenv("SUDO_USER"); u != "" {
|
|
return u
|
|
}
|
|
if u := os.Getenv("USER"); u != "" {
|
|
return u
|
|
}
|
|
return "admin"
|
|
}
|
|
|
|
func chownToSudoUser(path string) {
|
|
uidStr := os.Getenv("SUDO_UID")
|
|
gidStr := os.Getenv("SUDO_GID")
|
|
if uidStr != "" && gidStr != "" {
|
|
uid, err1 := strconv.Atoi(uidStr)
|
|
gid, err2 := strconv.Atoi(gidStr)
|
|
if err1 == nil && err2 == nil {
|
|
_ = os.Chown(path, uid, gid)
|
|
}
|
|
}
|
|
}
|
|
|
|
const (
|
|
serviceName = "nadir"
|
|
unitPath = "/etc/systemd/system/" + serviceName + ".service"
|
|
// symlinkPath puts nadir on PATH regardless of where the binary actually
|
|
// lives (built in place, installed via install.sh to /usr/local/bin
|
|
// directly, etc). installService creates it; uninstallService removes it -
|
|
// but only when it's actually a symlink we own, never a regular file that
|
|
// happens to sit there.
|
|
symlinkPath = "/usr/local/bin/nadir"
|
|
|
|
// daemonEnv marks a process that was re-exec'd by `run -d`, so it serves in
|
|
// the foreground of its new session instead of detaching again.
|
|
daemonEnv = "NADIR_DAEMON"
|
|
// detachLog is where a backgrounded (`run -d`) server's stdout/stderr go,
|
|
// alongside the SQLite stores. `logs` tails this when nadir isn't a service.
|
|
detachLog = "/var/lib/nadir/server.log"
|
|
|
|
// logrotatePath is the file path of the logrotate configuration for nadir.
|
|
logrotatePath = "/etc/logrotate.d/nadir"
|
|
// logrotateConfig defines logrotate rules for background server logs.
|
|
logrotateConfig = `/var/lib/nadir/server.log {
|
|
daily
|
|
rotate 5
|
|
compress
|
|
delaycompress
|
|
missingok
|
|
notifempty
|
|
copytruncate
|
|
}
|
|
`
|
|
)
|
|
|
|
// installService writes the systemd unit, enables it on boot, and starts it.
|
|
// The unit pins the absolute executable and config paths captured now, so the
|
|
// service doesn't depend on the working directory at boot.
|
|
func installService() error {
|
|
// Provision the PAM service the server authenticates against, so it exists
|
|
// before the unit starts rather than appearing on first login. Idempotent:
|
|
// EnsurePAMService leaves an existing /etc/pam.d/nadir untouched. runServer
|
|
// still calls it too, so `nadir run` standalone self-heals a fresh box.
|
|
if err := auth.EnsurePAMService(); err != nil {
|
|
return err
|
|
}
|
|
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Copy executable to /usr/local/bin/nadir so it runs from outside user home
|
|
// directories (e.g. /home/urania) which are restricted by systemd/AppArmor/SELinux and home permissions.
|
|
if exe != symlinkPath {
|
|
if err := copyFile(exe, symlinkPath); err != nil {
|
|
return fmt.Errorf("copy executable to %s: %w", symlinkPath, err)
|
|
}
|
|
exe = symlinkPath
|
|
fmt.Printf("installed binary to %s\n", symlinkPath)
|
|
}
|
|
|
|
// Ensure data directory /var/lib/nadir exists so systemd can write service logs there.
|
|
if err := os.MkdirAll("/var/lib/nadir", 0700); err != nil {
|
|
return fmt.Errorf("create data directory: %w", err)
|
|
}
|
|
|
|
cfgPath, err := resolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ensure default config file exists
|
|
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
|
if err := saveDefaultConfig(cfgPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
cfgAbs, err := filepath.Abs(cfgPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
unit := fmt.Sprintf(`[Unit]
|
|
Description=Nadir system administration backend
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStart=%s run
|
|
Environment=CONFIG_PATH=%s
|
|
StandardOutput=append:/var/lib/nadir/server.log
|
|
StandardError=append:/var/lib/nadir/server.log
|
|
Restart=on-failure
|
|
RestartSec=2
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`, exe, cfgAbs)
|
|
|
|
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
|
|
return fmt.Errorf("write %s: %w (need root)", unitPath, err)
|
|
}
|
|
if err := runForeground("systemctl", "daemon-reload"); err != nil {
|
|
return err
|
|
}
|
|
// enable --now: start now and on every boot.
|
|
if err := runForeground("systemctl", "enable", "--now", serviceName); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write logrotate configuration
|
|
if err := os.WriteFile(logrotatePath, []byte(logrotateConfig), 0644); err != nil {
|
|
fmt.Printf("warning: failed to write logrotate config %s: %v\n", logrotatePath, err)
|
|
} else {
|
|
fmt.Printf("created logrotate configuration %s\n", logrotatePath)
|
|
}
|
|
|
|
fmt.Printf("installed and started %s; follow logs with: %s logs\n", serviceName, filepath.Base(exe))
|
|
return nil
|
|
}
|
|
|
|
// copyFile copies src file to dst, overwriting if it exists and keeping executable permissions.
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
|
|
// If destination exists, remove it first to avoid ETXTBSY if running,
|
|
// or overwrite issues.
|
|
_ = os.Remove(dst)
|
|
|
|
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
}
|
|
|
|
// removeSymlink removes symlinkPath, either if it's a symlink or a regular file (e.g. from install.sh).
|
|
func removeSymlink() error {
|
|
info, err := os.Lstat(symlinkPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil // nothing there
|
|
}
|
|
return fmt.Errorf("stat %s: %w", symlinkPath, err)
|
|
}
|
|
if err := os.Remove(symlinkPath); err != nil {
|
|
return fmt.Errorf("remove %s: %w", symlinkPath, err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
fmt.Printf("removed symlink %s\n", symlinkPath)
|
|
} else {
|
|
fmt.Printf("removed binary %s\n", symlinkPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// uninstallService stops and disables the service and removes the install
|
|
// artifacts (systemd unit, PAM config, logrotate config, symlink/binary).
|
|
//
|
|
// User data - the data directory (/var/lib/nadir: audit log, token store,
|
|
// sessions, logs) and the config file - is KEPT by default so an uninstall
|
|
// doesn't silently destroy the audit trail or force re-issuing tokens. Pass
|
|
// complete=true to delete everything, leaving no trace.
|
|
func uninstallService(complete bool) error {
|
|
// Ignore errors: the service may already be stopped or never enabled.
|
|
_ = runForeground("systemctl", "disable", "--now", serviceName)
|
|
|
|
// Remove systemd unit file
|
|
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
|
|
fmt.Printf("warning: failed to remove systemd unit file %s: %v\n", unitPath, err)
|
|
} else if err == nil {
|
|
fmt.Printf("removed %s\n", unitPath)
|
|
}
|
|
|
|
// Remove PAM configuration
|
|
pamPath := "/etc/pam.d/nadir"
|
|
if err := os.Remove(pamPath); err != nil && !os.IsNotExist(err) {
|
|
fmt.Printf("warning: failed to remove PAM config %s: %v\n", pamPath, err)
|
|
} else if err == nil {
|
|
fmt.Printf("removed %s\n", pamPath)
|
|
}
|
|
|
|
// Remove logrotate configuration
|
|
if err := os.Remove(logrotatePath); err != nil && !os.IsNotExist(err) {
|
|
fmt.Printf("warning: failed to remove logrotate config %s: %v\n", logrotatePath, err)
|
|
} else if err == nil {
|
|
fmt.Printf("removed %s\n", logrotatePath)
|
|
}
|
|
|
|
// Remove binary or symlink
|
|
if err := removeSymlink(); err != nil {
|
|
fmt.Printf("warning: %v\n", err)
|
|
}
|
|
|
|
// /var/lib/nadir holds user data (audit log, token store, sessions, logs).
|
|
// Keep it unless --complete: a routine uninstall must not destroy the audit
|
|
// trail or force operators to re-mint every machine token.
|
|
varLibPath := "/var/lib/nadir"
|
|
if complete {
|
|
if err := os.RemoveAll(varLibPath); err != nil && !os.IsNotExist(err) {
|
|
fmt.Printf("warning: failed to remove data directory %s: %v\n", varLibPath, err)
|
|
} else if err == nil {
|
|
fmt.Printf("removed data directory %s\n", varLibPath)
|
|
}
|
|
} else if _, err := os.Stat(varLibPath); err == nil {
|
|
fmt.Printf("kept data directory %s (audit log, tokens, sessions); remove with: nadir uninstall --complete\n", varLibPath)
|
|
}
|
|
|
|
// Determine configuration path to inform the user. Under sudo, resolveConfigPath
|
|
// would hit root's HOME — check the invoking user's ~/.config first.
|
|
cfgPath, err := resolveConfigPath()
|
|
if err != nil {
|
|
cfgPath = "/root/.config/nadir/config.yaml"
|
|
}
|
|
if os.Getenv("CONFIG_PATH") == "" {
|
|
if u := os.Getenv("SUDO_USER"); u != "" && u != "root" {
|
|
candidate := filepath.Join("/home", u, ".config", "nadir", "config.yaml")
|
|
if _, statErr := os.Stat(candidate); statErr == nil {
|
|
cfgPath = candidate
|
|
}
|
|
}
|
|
}
|
|
|
|
if complete {
|
|
if err := os.Remove(cfgPath); err != nil && !os.IsNotExist(err) {
|
|
fmt.Printf("warning: failed to remove config file %s: %v\n", cfgPath, err)
|
|
} else if err == nil {
|
|
fmt.Printf("removed config file %s\n", cfgPath)
|
|
}
|
|
fmt.Println("uninstall completed (--complete): all nadir data removed.")
|
|
} else {
|
|
fmt.Printf("uninstall completed. Config kept at: %s (remove everything with: nadir uninstall --complete)\n", cfgPath)
|
|
}
|
|
|
|
return runForeground("systemctl", "daemon-reload")
|
|
}
|
|
|
|
// systemctl proxies a lifecycle action (start/stop/restart/status/enable/
|
|
// disable) to the nadir unit, inheriting stdio so output reaches the terminal.
|
|
func systemctl(action string) error {
|
|
return runForeground("systemctl", action, serviceName)
|
|
}
|
|
|
|
// logsCmd handles the logs subcommand: clear, or tail by default. Rotation is
|
|
// left to the installed logrotate config (see logrotateConfig), which runs daily
|
|
// for both service and `run -d` modes - no hand-rolled rotation here.
|
|
func logsCmd(args []string) error {
|
|
if len(args) == 0 {
|
|
return tailLogs()
|
|
}
|
|
if args[0] != "clear" {
|
|
return fmt.Errorf("unknown logs subcommand %q. Usage: nadir logs [clear]", args[0])
|
|
}
|
|
return clearLogs()
|
|
}
|
|
|
|
// clearLogs truncates the background log file to 0 bytes if it exists.
|
|
func clearLogs() error {
|
|
if _, err := os.Stat(detachLog); err == nil {
|
|
if err := os.Truncate(detachLog, 0); err != nil {
|
|
return fmt.Errorf("clear %s: %w", detachLog, err)
|
|
}
|
|
fmt.Printf("cleared log file %s\n", detachLog)
|
|
} else if os.IsNotExist(err) {
|
|
fmt.Printf("no log file found at %s\n", detachLog)
|
|
} else {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tailLogs follows nadir's logs from the unified log file, falling back to journald.
|
|
func tailLogs() error {
|
|
if _, err := os.Stat(detachLog); err == nil {
|
|
return runForeground("tail", "-n", "200", "-f", detachLog)
|
|
}
|
|
return runForeground("journalctl", "-u", serviceName, "-n", "200", "-f")
|
|
}
|
|
|
|
// daemonize re-execs this binary as a detached background `run`, redirecting its
|
|
// output to detachLog and starting a new session so it survives the terminal.
|
|
func daemonize() {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
fatalIf(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(detachLog), 0700); err != nil {
|
|
fatalIf(err)
|
|
}
|
|
logf, err := os.OpenFile(detachLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0640)
|
|
if err != nil {
|
|
fatalIf(err)
|
|
}
|
|
defer logf.Close()
|
|
|
|
cmd := exec.Command(exe, "run")
|
|
cmd.Env = append(os.Environ(), daemonEnv+"=1")
|
|
cmd.Stdout, cmd.Stderr = logf, logf
|
|
cmd.Stdin = nil
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // detach from controlling terminal
|
|
if err := cmd.Start(); err != nil {
|
|
fatalIf(fmt.Errorf("detach: %w", err))
|
|
}
|
|
fmt.Printf("nadir running in background (pid %d); logs: %s\n", cmd.Process.Pid, detachLog)
|
|
fmt.Printf("follow with: %s logs\n", filepath.Base(exe))
|
|
}
|
|
|
|
// runForeground runs a command with the terminal's stdio attached.
|
|
func runForeground(name string, args ...string) error {
|
|
c := exec.Command(name, args...)
|
|
c.Stdout, c.Stderr, c.Stdin = os.Stdout, os.Stderr, os.Stdin
|
|
return c.Run()
|
|
}
|
|
|
|
func fatalIf(err error) {
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// saveDefaultConfig writes the default configuration template to cfgPath.
|
|
func saveDefaultConfig(cfgPath string) error {
|
|
dir := filepath.Dir(cfgPath)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("create config directory: %w", err)
|
|
}
|
|
chownToSudoUser(dir)
|
|
|
|
username := getUsername()
|
|
configContent := fmt.Sprintf(defaultConfigTemplate, username)
|
|
if err := os.WriteFile(cfgPath, []byte(configContent), 0600); err != nil {
|
|
return fmt.Errorf("write default config: %w", err)
|
|
}
|
|
chownToSudoUser(cfgPath)
|
|
fmt.Printf("created default configuration file at %s (assigned admin role to user %q)\n", cfgPath, username)
|
|
return nil
|
|
}
|
|
|
|
func usage(w io.Writer) {
|
|
fmt.Fprint(w, `nadir - Linux system administration backend
|
|
|
|
Usage:
|
|
nadir [run] [-d] [-f <path>] Start the server (-d / --detach: run in background)
|
|
nadir --save-config [-f <path>] Save default configuration to path and exit
|
|
nadir install [-f <path>] Install + enable the systemd service (starts on boot)
|
|
nadir uninstall [--complete] Remove the service (keeps data/config; --complete wipes all)
|
|
nadir start|stop|restart|status Control the running service
|
|
nadir enable|disable Toggle start-on-boot without removing the unit
|
|
nadir logs [clear] Follow (default) or clear server logs
|
|
nadir token add <name> Mint a machine credential (Bearer token), shown once
|
|
nadir token rm <name> Revoke a token (effective immediately, no restart)
|
|
nadir token ls List token names and when they were created
|
|
nadir update [--check|--force] Fetch the latest release from server.release_repo and restart
|
|
(--check: report only; --force: re-download when already current)
|
|
nadir help Show this help
|
|
|
|
Most commands need root. Config path is specified via -f/--config or CONFIG_PATH (default ~/.config/config.yaml).
|
|
`)
|
|
}
|