445 lines
15 KiB
Go
445 lines
15 KiB
Go
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, "<!-- 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)
|
|
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 <script> + inline styles).
|
|
// unsafe-eval is removed Scalar does not need it. The CDN host is the
|
|
// supply-chain trust boundary; SRI pinning would close the remaining gap.
|
|
w.Header().Set("Content-Security-Policy",
|
|
"default-src 'self'; "+
|
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "+
|
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "+
|
|
"img-src 'self' data: https:; "+
|
|
"connect-src 'self'; "+
|
|
"font-src 'self' data: https://cdn.jsdelivr.net https://fonts.scalar.com")
|
|
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" crossorigin="anonymous"></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: 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"))
|
|
}
|
|
}
|