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, "") _, 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(`