fix: remove terminal module and implement concurrent log stream limiting
build-and-release / release (push) Successful in 2m39s

This commit is contained in:
2026-06-24 17:29:45 +02:00
parent 37e5b97507
commit 54108c263f
40 changed files with 851 additions and 806 deletions
+67 -20
View File
@@ -31,7 +31,6 @@ import (
"nadir/internal/modules/services"
"nadir/internal/modules/storage"
"nadir/internal/modules/system"
"nadir/internal/modules/terminal"
"nadir/internal/modules/users"
"nadir/internal/rbac"
@@ -206,13 +205,12 @@ func runServer() {
mods := []module.Module{
system.New(),
services.New(cfg.LogFiles),
users.New(),
users.New(sessions),
groups.New(),
packages.New(),
networking.New(),
storage.New(),
audit.New(auditStore),
terminal.New(sessions),
}
roles := rbac.New()
@@ -236,6 +234,8 @@ func runServer() {
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 {
@@ -277,18 +277,18 @@ func runServer() {
})
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 jsdelivr
// CDN plus inline (Scalar uses inline <script> + inline styles). The CDN
// host is the supply-chain trust boundary; pinning a version + SRI here
// is the follow-up (M5 partial).
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 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")
// /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">
@@ -303,7 +303,7 @@ func runServer() {
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: secHeaders(auth.WithClientIP(cfg.Server.TrustProxy, mux)),
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
@@ -313,14 +313,14 @@ func runServer() {
// 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).
// 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")
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)
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)
}
@@ -331,8 +331,19 @@ func runServer() {
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() {
@@ -360,6 +371,40 @@ func runServer() {
}
}
// 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
@@ -377,6 +422,8 @@ func secHeaders(next http.Handler) http.Handler {
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)
})
}
+6 -6
View File
@@ -132,9 +132,9 @@ func installService(args []string) error {
return fmt.Errorf("options --tls, --unsecure, and --trust-proxy are mutually exclusive")
}
// Default to tls if nothing is specified
isTLS := *tlsOpt || optCount == 0
isUnsecure := *unsecureOpt
// Default to unsecure (plain HTTP) if nothing is specified
isTLS := *tlsOpt
isUnsecure := *unsecureOpt || optCount == 0
isTrustProxy := *trustProxyOpt
// Provision the PAM service the server authenticates against, so it exists
@@ -505,7 +505,7 @@ func fatalIf(err error) {
// DefaultConfigContent returns the default configuration template filled with the username.
func DefaultConfigContent(username string) string {
return fmt.Sprintf(configTemplateBase, "true", "# trust_proxy: false", "# tls_cert: /var/lib/nadir/tls/cert.pem", "# tls_key: /var/lib/nadir/tls/key.pem", "127.0.0.1", 9999, username)
return fmt.Sprintf(configTemplateBase, "false", "# trust_proxy: false", "# tls_cert: /var/lib/nadir/tls/cert.pem", "# tls_key: /var/lib/nadir/tls/key.pem", "127.0.0.1", 9999, username)
}
// saveDefaultConfig writes the default configuration template to cfgPath.
@@ -531,8 +531,8 @@ 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 [--tls|--unsecure|--trust-proxy] Install + enable the systemd service (starts on boot)
(--tls: enable HTTPS with self-signed certificate, default)
(--unsecure: serve HTTP directly)
(--tls: enable HTTPS with self-signed certificate)
(--unsecure: serve HTTP directly, default)
(--trust-proxy: serve HTTP behind a reverse proxy)
nadir uninstall [--complete] Remove the service (keeps data/config; --complete wipes all)
nadir start|stop|restart|status Control the running service
+1 -46
View File
@@ -4,60 +4,15 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"log"
"math/big"
"net"
"os"
"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 := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
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{"urania-nadir"}},
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
}
// generateAndSaveCert generates a self-signed certificate and private key,
// and saves them as PEM files.
func generateAndSaveCert(certPath, keyPath string, hostname string) error {
@@ -75,7 +30,7 @@ func generateAndSaveCert(certPath, keyPath string, hostname string) error {
SerialNumber: serial,
Subject: pkix.Name{Organization: []string{"nadir-agent-tls"}},
NotBefore: time.Now(),
NotAfter: time.Now().Add(3650 * 24 * time.Hour), // 10 years
NotAfter: time.Now().Add(365 * 24 * time.Hour), // 1 year
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,