@@ -10,6 +10,5 @@ config.yml
|
||||
|
||||
# Editor
|
||||
*.swp
|
||||
server
|
||||
|
||||
CLAUDE.md
|
||||
@@ -0,0 +1,338 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"nadir"
|
||||
"nadir/internal/auditlog"
|
||||
"nadir/internal/auth"
|
||||
"nadir/internal/config"
|
||||
"nadir/internal/meta"
|
||||
"nadir/internal/module"
|
||||
"nadir/internal/modules/audit"
|
||||
"nadir/internal/modules/groups"
|
||||
"nadir/internal/modules/networking"
|
||||
"nadir/internal/modules/packages"
|
||||
"nadir/internal/modules/services"
|
||||
"nadir/internal/modules/storage"
|
||||
"nadir/internal/modules/system"
|
||||
"nadir/internal/modules/terminal"
|
||||
"nadir/internal/modules/users"
|
||||
"nadir/internal/rbac"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||
)
|
||||
|
||||
// main is a thin command dispatcher. With no subcommand (or "run") it starts the
|
||||
// server; the rest manage nadir as a systemd service or tail its logs. Service
|
||||
// plumbing lives in service.go, TLS in tls.go.
|
||||
func main() {
|
||||
// Global flags can appear before or after the subcommand (`nadir install -f
|
||||
// path` and `nadir -f path install` both work), so we re-parse until only
|
||||
// positional args remain.
|
||||
fs := flag.NewFlagSet("nadir", flag.ExitOnError)
|
||||
configFlag := fs.String("f", "", "config file path")
|
||||
fs.StringVar(configFlag, "config", "", "alias for -f")
|
||||
saveConfig := fs.Bool("save-config", false, "write default config and exit")
|
||||
|
||||
rest := os.Args[1:]
|
||||
var args []string
|
||||
for len(rest) > 0 {
|
||||
fatalIf(fs.Parse(rest))
|
||||
rest = fs.Args()
|
||||
if len(rest) == 0 {
|
||||
break
|
||||
}
|
||||
args = append(args, rest[0])
|
||||
rest = rest[1:]
|
||||
}
|
||||
|
||||
if *configFlag != "" {
|
||||
os.Setenv("CONFIG_PATH", *configFlag)
|
||||
}
|
||||
|
||||
if *saveConfig {
|
||||
configPath, err := resolveConfigPath()
|
||||
fatalIf(err)
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Printf("configuration file already exists at %s\n", configPath)
|
||||
os.Exit(0)
|
||||
}
|
||||
fatalIf(saveDefaultConfig(configPath))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
sub := "run"
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
sub, args = args[0], args[1:]
|
||||
}
|
||||
|
||||
switch sub {
|
||||
case "run":
|
||||
ensureRoot()
|
||||
runCmd(args)
|
||||
case "install":
|
||||
ensureRoot()
|
||||
fatalIf(installService())
|
||||
case "uninstall":
|
||||
ensureRoot()
|
||||
fatalIf(uninstallService(slices.Contains(args, "--complete")))
|
||||
case "start", "stop", "restart", "status", "enable", "disable":
|
||||
ensureRoot()
|
||||
fatalIf(systemctl(sub))
|
||||
case "logs":
|
||||
ensureRoot()
|
||||
fatalIf(logsCmd(args))
|
||||
case "token":
|
||||
ensureRoot()
|
||||
fatalIf(tokenCmd(args))
|
||||
case "help", "-h", "--help":
|
||||
usage(os.Stdout)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", sub)
|
||||
usage(os.Stderr)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
// runCmd parses run-specific flags and either detaches into the background or
|
||||
// serves in the foreground.
|
||||
func runCmd(args []string) {
|
||||
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
||||
detach := fs.Bool("d", false, "run in the background, detached from the terminal")
|
||||
fs.BoolVar(detach, "detach", false, "alias for -d")
|
||||
fs.Parse(args)
|
||||
|
||||
// A detached child is re-exec'd as plain "run" with daemonEnv set, so this
|
||||
// branch fires only for the parent the user launched.
|
||||
if *detach && os.Getenv(daemonEnv) == "" {
|
||||
daemonize()
|
||||
return
|
||||
}
|
||||
runServer()
|
||||
}
|
||||
|
||||
func runServer() {
|
||||
// Set up logging to /var/lib/nadir/server.log if running outside of systemd.
|
||||
// (Under systemd, systemd handles standard output/error redirection to the log file natively.)
|
||||
if os.Getenv("INVOCATION_ID") == "" {
|
||||
if err := os.MkdirAll(filepath.Dir(detachLog), 0700); err == nil {
|
||||
logFile, err := os.OpenFile(detachLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0640)
|
||||
if err == nil {
|
||||
log.SetOutput(io.MultiWriter(os.Stderr, logFile))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Never leak internal command stderr to clients. For 5xx, log the wrapped
|
||||
// errors server-side and strip them from the response body; sub-500 errors
|
||||
// (400/404/409 …) keep their messages, which are user-facing by design.
|
||||
defaultNewError := huma.NewError
|
||||
huma.NewError = func(status int, msg string, errs ...error) huma.StatusError {
|
||||
if status >= 500 {
|
||||
for _, e := range errs {
|
||||
log.Printf("internal error %d: %s: %v", status, msg, e)
|
||||
}
|
||||
errs = nil
|
||||
}
|
||||
return defaultNewError(status, msg, errs...)
|
||||
}
|
||||
|
||||
if err := auth.EnsurePAMService(); err != nil {
|
||||
log.Fatalf("pam: %v", err)
|
||||
}
|
||||
|
||||
configPath, err := resolveConfigPath()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Fatalf("config: %v (please run 'nadir install' or 'nadir --save-config' to install the app)", err)
|
||||
}
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionStore("/var/lib/nadir/sessions.db")
|
||||
if err != nil {
|
||||
log.Fatalf("sessions: %v", err)
|
||||
}
|
||||
|
||||
auditStore, err := auditlog.New("/var/lib/nadir/audit.db")
|
||||
if err != nil {
|
||||
log.Fatalf("audit: %v", err)
|
||||
}
|
||||
|
||||
tokenStore, err := auth.NewTokenStore(tokenDBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("tokens: %v", err)
|
||||
}
|
||||
tokenAuth := auth.NewTokenAuth(tokenStore)
|
||||
|
||||
mods := []module.Module{
|
||||
system.New(),
|
||||
services.New(cfg.LogFiles),
|
||||
users.New(),
|
||||
groups.New(),
|
||||
packages.New(),
|
||||
networking.New(),
|
||||
storage.New(),
|
||||
audit.New(auditStore),
|
||||
terminal.New(sessions),
|
||||
}
|
||||
|
||||
roles := rbac.New()
|
||||
if err := config.Apply(cfg, roles, mods); err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
humaConfig := huma.DefaultConfig("Nadir API", nadir.Version)
|
||||
// Reuse the README overview (everything before the api-desc-end marker) as the
|
||||
// OpenAPI description, so /docs and GitHub stay in sync from one source.
|
||||
apiDesc, _, _ := strings.Cut(nadir.README, "<!-- api-desc-end -->")
|
||||
_, apiDesc, _ = strings.Cut(apiDesc, "\n") // drop the "# Nadir" title; Huma sets its own
|
||||
humaConfig.Info.Description = strings.TrimSpace(apiDesc)
|
||||
humaConfig.Tags = []*huma.Tag{{Name: "Authentication"}}
|
||||
for _, m := range mods {
|
||||
humaConfig.Tags = append(humaConfig.Tags, &huma.Tag{Name: module.Title(m.ID())})
|
||||
}
|
||||
humaConfig.Tags = append(humaConfig.Tags, &huma.Tag{Name: "Meta"})
|
||||
humaConfig.DocsPath = ""
|
||||
|
||||
api := humago.New(mux, humaConfig)
|
||||
api.UseMiddleware(rbac.RbacMiddleware(api, sessions, tokenAuth, roles, auditStore))
|
||||
|
||||
for _, m := range mods {
|
||||
m.Register(api)
|
||||
}
|
||||
|
||||
meta.Register(api, mods)
|
||||
meta.RegisterHealth(api, sessions)
|
||||
meta.RegisterWhoami(api, sessions, roles, mods)
|
||||
|
||||
auth.RegisterLogin(api, sessions, auditStore, cfg.SecureCookie())
|
||||
auth.RegisterLogout(api, sessions, cfg.SecureCookie())
|
||||
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Title", "Nadir")
|
||||
year := time.Now().Year()
|
||||
fmt.Fprintf(w, "%d MIT | NADIR | made with love in urania\n", year)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /favicon.svg", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Write([]byte(nadir.Favicon))
|
||||
})
|
||||
|
||||
// /install.sh is the curl|sh bootstrap. It points the installer at the Gitea
|
||||
// release repo (configured via server.release_repo) and the installer picks
|
||||
// the asset matching the target host's architecture, so the latest tagged
|
||||
// build is always what gets installed.
|
||||
mux.HandleFunc("GET /install.sh", func(w http.ResponseWriter, r *http.Request) {
|
||||
if cfg.Server.ReleaseRepo == "" {
|
||||
http.Error(w, "install.sh is disabled: set server.release_repo in config.yaml", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
script := strings.ReplaceAll(nadir.InstallScriptTemplate, "__NADIR_RELEASE_REPO__", cfg.Server.ReleaseRepo)
|
||||
w.Header().Set("Content-Type", "text/x-shellscript")
|
||||
w.Write([]byte(script))
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<!doctype html><html><head><title>API</title>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg"></head>
|
||||
<body><script id="api-reference" data-url="/openapi.json" data-configuration='{"layout":"classic"}'></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script></body></html>`))
|
||||
})
|
||||
|
||||
addr := cfg.Server.Hostname + ":" + cfg.Server.Port
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
// WithClientIP records the source IP for the login throttle (H1); behind a
|
||||
// trusted proxy it reads X-Forwarded-For instead of the proxy's address.
|
||||
Handler: auth.WithClientIP(cfg.Server.TrustProxy, mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 0, // unset: SSE endpoints stream indefinitely
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// Pick how the connection is secured (see config.Server doc):
|
||||
// 1. trust_proxy - a reverse proxy terminates TLS; we serve plaintext HTTP.
|
||||
// 2. tls_cert/tls_key - we terminate TLS with the admin's PEM pair.
|
||||
// 3. neither - a fresh in-memory self-signed cert (dev only).
|
||||
var serve func() error
|
||||
switch {
|
||||
case cfg.Server.TrustProxy:
|
||||
log.Printf("tls: trust_proxy set - serving plaintext HTTP, TLS terminated upstream; bind to localhost so X-Forwarded-For can't be spoofed")
|
||||
serve = srv.ListenAndServe
|
||||
default:
|
||||
cert, err := serverCert(cfg.Server.TLSCert, cfg.Server.TLSKey)
|
||||
if err != nil {
|
||||
log.Fatalf("tls cert: %v", err)
|
||||
}
|
||||
if x509Cert, perr := x509.ParseCertificate(cert.Certificate[0]); perr == nil {
|
||||
log.Printf("tls: subject=%s dns=%v ip=%v valid_to=%s",
|
||||
x509Cert.Subject, x509Cert.DNSNames, x509Cert.IPAddresses, x509Cert.NotAfter)
|
||||
}
|
||||
srv.TLSConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
serve = func() error { return srv.ListenAndServeTLS("", "") }
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Println("listening on " + addr)
|
||||
if err := serve(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
log.Println("shutting down…")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("http shutdown: %v", err)
|
||||
}
|
||||
|
||||
if err := auditStore.Close(); err != nil {
|
||||
log.Printf("audit close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureRoot() {
|
||||
if os.Getuid() != 0 {
|
||||
fatalIf(fmt.Errorf("nadir must run as root"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
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 help Show this help
|
||||
|
||||
Most commands need root. Config path is specified via -f/--config or CONFIG_PATH (default ~/.config/config.yaml).
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// serverCert returns the TLS certificate to serve: the admin-supplied PEM pair
|
||||
// when both paths are set, otherwise a freshly generated in-memory self-signed
|
||||
// cert for local development.
|
||||
func serverCert(certPath, keyPath string) (tls.Certificate, error) {
|
||||
if certPath != "" && keyPath != "" {
|
||||
return tls.LoadX509KeyPair(certPath, keyPath)
|
||||
}
|
||||
log.Printf("tls: no tls_cert/tls_key configured - generating a self-signed certificate (dev only)")
|
||||
return generateSelfSignedCert()
|
||||
}
|
||||
|
||||
func generateSelfSignedCert() (tls.Certificate, error) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
// Random serial: a fixed serial (1) makes every generated cert collide in a
|
||||
// browser/OS trust store, so a previously-accepted cert can't be replaced.
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{Organization: []string{"nadir-dev-local"}},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
return tls.Certificate{Certificate: [][]byte{derBytes}, PrivateKey: priv}, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"nadir/internal/auth"
|
||||
)
|
||||
|
||||
// tokenDBPath holds machine credentials, alongside the other SQLite stores. The
|
||||
// running server and this CLI both open it (WAL handles the sharing).
|
||||
const tokenDBPath = "/var/lib/nadir/tokens.db"
|
||||
|
||||
// tokenCmd implements `nadir token add|rm|ls`, the mint/revoke surface for
|
||||
// machine credentials. Minting and revoking take effect immediately - the
|
||||
// server reads tokenDBPath live, so no restart is needed. Granting access is a
|
||||
// separate step: assign the token name a role in config.yaml (see `add` output).
|
||||
func tokenCmd(args []string) error {
|
||||
store, err := auth.NewTokenStore(tokenDBPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: nadir token add <name> | rm <name> | ls")
|
||||
}
|
||||
switch args[0] {
|
||||
case "add":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("usage: nadir token add <name>")
|
||||
}
|
||||
name := args[1]
|
||||
raw, err := store.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Token created for %q. Copy it now - it is not stored or shown again:\n\n %s\n\n", name, raw)
|
||||
fmt.Printf("Use it as a header: Authorization: Bearer nad_***\n")
|
||||
fmt.Printf("Grant it access by assigning a role in config.yaml, then restart nadir:\n\n assignments:\n %s: [auditor]\n", name)
|
||||
return nil
|
||||
case "rm":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("usage: nadir token rm <name>")
|
||||
}
|
||||
if err := store.Delete(args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Revoked token %q (effective immediately).\n", args[1])
|
||||
return nil
|
||||
case "ls":
|
||||
infos, err := store.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(infos) == 0 {
|
||||
fmt.Println("no tokens")
|
||||
return nil
|
||||
}
|
||||
for _, t := range infos {
|
||||
fmt.Printf("%-30s created %s\n", t.Name, t.Created.Format("2006-01-02 15:04:05 UTC"))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown token subcommand %q (want add|rm|ls)", args[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user