From 63c9a272b5459f8c1941dc58162595b894a2721b Mon Sep 17 00:00:00 2001 From: urania Date: Tue, 23 Jun 2026 19:13:34 +0200 Subject: [PATCH] feat: add configurable TLS/proxy installation options for the systemd service --- cmd/server/server.go | 4 +- cmd/server/service.go | 157 +++++++++++++++++++++++++++++------------- cmd/server/tls.go | 10 ++- 3 files changed, 121 insertions(+), 50 deletions(-) diff --git a/cmd/server/server.go b/cmd/server/server.go index 6b3cc06..8628ad2 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -81,7 +81,7 @@ func main() { fmt.Printf("configuration file already exists at %s\n", configPath) os.Exit(0) } - fatalIf(saveDefaultConfig(configPath)) + fatalIf(saveDefaultConfig(configPath, DefaultConfigContent(getUsername()))) os.Exit(0) } @@ -96,7 +96,7 @@ func main() { runCmd(args) case "install": ensureRoot() - fatalIf(installService()) + fatalIf(installService(args)) case "uninstall": ensureRoot() fatalIf(uninstallService(slices.Contains(args, "--complete"))) diff --git a/cmd/server/service.go b/cmd/server/service.go index 8953cbc..82ab434 100644 --- a/cmd/server/service.go +++ b/cmd/server/service.go @@ -1,6 +1,7 @@ package main import ( + "flag" "fmt" "io" "os" @@ -13,18 +14,18 @@ import ( "nadir/internal/config" ) -const defaultConfigTemplate = `# Nadir configuration - config.yaml +const configTemplateBase = `# Nadir configuration - config.yaml # # Single source of truth for runtime settings. # server: - secure_tls: true - # trust_proxy: false - tls_cert: /var/lib/nadir/tls/cert.pem - tls_key: /var/lib/nadir/tls/key.pem - hostname: 127.0.0.1 - port: 9999 + secure_tls: %s + %s + %s + %s + hostname: %s + port: %d release_repo: https://tea.urania.dev/urania/nadir-agent roles: @@ -104,7 +105,38 @@ const ( // 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 { +func installService(args []string) error { + // Parse options + fs := flag.NewFlagSet("install", flag.ContinueOnError) + tlsOpt := fs.Bool("tls", false, "Generate persistent self-signed TLS cert/key and enable HTTPS") + unsecureOpt := fs.Bool("unsecure", false, "Serve plaintext HTTP directly") + trustProxyOpt := fs.Bool("trust-proxy", false, "Serve plaintext HTTP behind a trusted TLS-terminating reverse proxy") + hostnameOpt := fs.String("hostname", "127.0.0.1", "Hostname to bind to") + portOpt := fs.Int("port", 9999, "Port to bind to") + if err := fs.Parse(args); err != nil { + return err + } + + optCount := 0 + if *tlsOpt { + optCount++ + } + if *unsecureOpt { + optCount++ + } + if *trustProxyOpt { + optCount++ + } + + if optCount > 1 { + 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 + isTrustProxy := *trustProxyOpt + // 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 @@ -133,18 +165,20 @@ func installService() error { return fmt.Errorf("create data directory: %w", err) } - // Generate and save persistent self-signed TLS certificates - tlsDir := "/var/lib/nadir/tls" - if err := os.MkdirAll(tlsDir, 0700); err != nil { - return fmt.Errorf("create tls directory: %w", err) - } - certPath := filepath.Join(tlsDir, "cert.pem") - keyPath := filepath.Join(tlsDir, "key.pem") - if _, err := os.Stat(certPath); os.IsNotExist(err) { - if err := generateAndSaveCert(certPath, keyPath); err != nil { - return fmt.Errorf("generate certificates: %w", err) + // Generate and save persistent self-signed TLS certificates if TLS mode + certPath := filepath.Join("/var/lib/nadir/tls", "cert.pem") + if isTLS { + tlsDir := "/var/lib/nadir/tls" + if err := os.MkdirAll(tlsDir, 0700); err != nil { + return fmt.Errorf("create tls directory: %w", err) + } + keyPath := filepath.Join(tlsDir, "key.pem") + if _, err := os.Stat(certPath); os.IsNotExist(err) { + if err := generateAndSaveCert(certPath, keyPath, *hostnameOpt); err != nil { + return fmt.Errorf("generate certificates: %w", err) + } + fmt.Printf("generated persistent self-signed TLS certificate at %s\n", certPath) } - fmt.Printf("generated persistent self-signed TLS certificate at %s\n", certPath) } cfgPath, err := resolveConfigPath() @@ -152,9 +186,30 @@ func installService() error { return err } + // Construct configuration template content based on installation options + secureTLSVal := "true" + trustProxyLine := "# trust_proxy: false" + certLine := "tls_cert: /var/lib/nadir/tls/cert.pem" + keyLine := "tls_key: /var/lib/nadir/tls/key.pem" + + if isUnsecure { + secureTLSVal = "false" + trustProxyLine = "# trust_proxy: false" + certLine = "# tls_cert: /var/lib/nadir/tls/cert.pem" + keyLine = "# tls_key: /var/lib/nadir/tls/key.pem" + } else if isTrustProxy { + secureTLSVal = "false" + trustProxyLine = "trust_proxy: true" + certLine = "# tls_cert: /var/lib/nadir/tls/cert.pem" + keyLine = "# tls_key: /var/lib/nadir/tls/key.pem" + } + + username := getUsername() + configContent := fmt.Sprintf(configTemplateBase, secureTLSVal, trustProxyLine, certLine, keyLine, *hostnameOpt, *portOpt, username) + // Ensure default config file exists if _, err := os.Stat(cfgPath); os.IsNotExist(err) { - if err := saveDefaultConfig(cfgPath); err != nil { + if err := saveDefaultConfig(cfgPath, configContent); err != nil { return err } } @@ -224,16 +279,18 @@ WantedBy=multi-user.target } // Output credentials to copy to the frontend - certBytes, err := os.ReadFile(filepath.Join("/var/lib/nadir/tls", "cert.pem")) - if err == nil { - fmt.Println("\n======================================================================") - fmt.Println(" NADIR CLIENT CREDENTIALS (COPY THESE TO SVELTEKIT FRONTEND)") - fmt.Println("======================================================================") - fmt.Printf("Token for \"dashboard\" (Bearer):\n %s\n\n", tokenStr) - fmt.Println("CA Certificate (Trust Store):") - fmt.Println(string(certBytes)) - fmt.Println("======================================================================") + fmt.Println("\n======================================================================") + fmt.Println(" NADIR CLIENT CREDENTIALS (COPY THESE TO SVELTEKIT FRONTEND)") + fmt.Println("======================================================================") + fmt.Printf("Token for \"dashboard\" (Bearer):\n %s\n", tokenStr) + if isTLS { + certBytes, err := os.ReadFile(certPath) + if err == nil { + fmt.Println("\nCA Certificate (Trust Store):") + fmt.Println(string(certBytes)) + } } + fmt.Println("======================================================================") fmt.Printf("installed and started %s; follow logs with: %s logs\n", serviceName, filepath.Base(exe)) return nil @@ -446,21 +503,24 @@ 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) +} + // saveDefaultConfig writes the default configuration template to cfgPath. -func saveDefaultConfig(cfgPath string) error { +func saveDefaultConfig(cfgPath, content 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 { + if err := os.WriteFile(cfgPath, []byte(content), 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) + fmt.Printf("created default configuration file at %s\n", cfgPath) return nil } @@ -468,19 +528,22 @@ func usage(w io.Writer) { fmt.Fprint(w, `nadir - Linux system administration backend Usage: - nadir [run] [-d] [-f ] Start the server (-d / --detach: run in background) - nadir --save-config [-f ] Save default configuration to path and exit - nadir install [-f ] 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 Mint a machine credential (Bearer token), shown once - nadir token rm Revoke a token (effective immediately, no restart) - nadir token ls List token names and when they were created - nadir update [--check|--force] Fetch the latest release from server.release_repo and restart - (--check: report only; --force: re-download when already current) - nadir help Show this help + nadir [run] [-d] [-f ] Start the server (-d / --detach: run in background) + nadir --save-config [-f ] 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) + (--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 + nadir enable|disable Toggle start-on-boot without removing the unit + nadir logs [clear] Follow (default) or clear server logs + nadir token add Mint a machine credential (Bearer token), shown once + nadir token rm Revoke a token (effective immediately, no restart) + nadir token ls List token names and when they were created + nadir update [--check|--force] Fetch the latest release from server.release_repo and restart + (--check: report only; --force: re-download when already current) + nadir help Show this help Most commands need root. Config path is specified via -f/--config or CONFIG_PATH (default ~/.config/config.yaml). `) diff --git a/cmd/server/tls.go b/cmd/server/tls.go index aeb69f1..75ea92b 100644 --- a/cmd/server/tls.go +++ b/cmd/server/tls.go @@ -60,7 +60,7 @@ func generateSelfSignedCert() (tls.Certificate, error) { // generateAndSaveCert generates a self-signed certificate and private key, // and saves them as PEM files. -func generateAndSaveCert(certPath, keyPath string) error { +func generateAndSaveCert(certPath, keyPath string, hostname string) error { priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return err @@ -83,6 +83,14 @@ func generateAndSaveCert(certPath, keyPath string) error { IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, } + if hostname != "" && hostname != "localhost" && hostname != "127.0.0.1" && hostname != "::1" { + if ip := net.ParseIP(hostname); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, hostname) + } + } + // Automatically add the machine's external IP addresses to SANs so it can be verified // correctly over the local network (e.g. Tailscale or Netbird). if addrs, err := net.InterfaceAddrs(); err == nil {