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 ] Start the server (-d / --detach: run in background) nadir --save-config [-f ] Save default configuration to path and exit nadir install [-f ] 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 Mint a machine credential (Bearer token), shown once nadir token rm 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). `) }