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/groups" "nadir/internal/modules/networking" "nadir/internal/modules/packages" "nadir/internal/modules/services" "nadir/internal/modules/storage" "nadir/internal/modules/system" "nadir/internal/modules/users" "nadir/internal/rbac" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" ) // auditModule is a synthetic module so the config validator knows the "audit" // permission vocabulary. The actual endpoint is registered by meta.RegisterAudit — // a full module for one GET is too shallow. type auditModule struct{} func (auditModule) ID() string { return "audit" } func (auditModule) Permissions() []rbac.Permission { return []rbac.Permission{rbac.Read} } func (auditModule) Register(huma.API) {} // 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") showVersion := fs.Bool("v", false, "print version and exit") fs.BoolVar(showVersion, "version", false, "alias for -v") 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 len(args) > 0 { // Once we have identified the subcommand, the remaining arguments // belong to it (including its own flags), so we stop parsing global flags. args = append(args, rest...) break } } if *showVersion { fmt.Println(nadir.Version) os.Exit(0) } 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, DefaultConfigContent(getUsername()))) 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(args)) 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 "update": ensureRoot() fatalIf(updateCmd(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(sessions), groups.New(), packages.New(), networking.New(), storage.New(), auditModule{}, } 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) rateLimiter := auth.NewRateLimiter(100, time.Minute) api.UseMiddleware(auth.RateLimitMiddleware(api, rateLimiter)) 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, tokenAuth, roles, mods) meta.RegisterUpdate(api, configPath) meta.RegisterAudit(api, auditStore) 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) { // /docs needs to execute the Scalar bundle, so loosen the strict CSP set // by secHeaders for this one page: allow scripts/styles from the pinned // jsdelivr CDN version + inline (Scalar uses inline `)) }) 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: bodySizeLimit(requestTimeout(secHeaders(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 - plain HTTP (localhost-only default). 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 case cfg.Server.TLSCert != "" && cfg.Server.TLSKey != "": cert, err := tls.LoadX509KeyPair(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, CipherSuites: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, }, } serve = func() error { return srv.ListenAndServeTLS("", "") } default: log.Printf("tls: no TLS configured — serving plain HTTP on %s", addr) serve = srv.ListenAndServe } 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) } } // bodySizeLimit rejects requests with a body larger than 1 MB, preventing // OOM from arbitrarily large JSON payloads. Wraps the entire mux so it // covers both Huma-registered routes and raw mux handlers (/docs, /install.sh). func bodySizeLimit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) next.ServeHTTP(w, r) }) } // requestTimeout cancels slow requests via context deadline. SSE endpoints // (package streams, log following) are exempt because they keep the // connection open indefinitely. func requestTimeout(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if isSSEEndpoint(r) { next.ServeHTTP(w, r) return } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() next.ServeHTTP(w, r.WithContext(ctx)) }) } // isSSEEndpoint identifies routes that stream data indefinitely and must // not have a context deadline. func isSSEEndpoint(r *http.Request) bool { if strings.HasPrefix(r.URL.Path, "/api/packages") { return r.Method == "POST" || r.Method == "DELETE" } return r.Method == "GET" && strings.HasSuffix(r.URL.Path, "/logs/stream") } // secHeaders sets defensive response headers on every HTTP response. The // default Content-Security-Policy denies everything (`default-src 'none'`) — // correct for the JSON API and the tiny landing/favicon endpoints. /docs // overrides it with a CDN-permissive policy because the Scalar bundle needs // to execute. // // HSTS is set unconditionally: nadir always serves TLS directly or sits behind // a TLS-terminating proxy (config rejects any other shape), so a browser // receiving this header is on an HTTPS connection. func secHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") h.Set("Cache-Control", "no-store, no-cache, must-revalidate, private") h.Set("Pragma", "no-cache") next.ServeHTTP(w, r) }) } func ensureRoot() { if os.Getuid() != 0 { fatalIf(fmt.Errorf("nadir must run as root")) } }