feat: add configurable TLS/proxy installation options for the systemd service
build-and-release / release (push) Successful in 2m34s

This commit is contained in:
2026-06-23 19:13:34 +02:00
parent 411f7fd6d9
commit 63c9a272b5
3 changed files with 121 additions and 50 deletions
+2 -2
View File
@@ -81,7 +81,7 @@ func main() {
fmt.Printf("configuration file already exists at %s\n", configPath) fmt.Printf("configuration file already exists at %s\n", configPath)
os.Exit(0) os.Exit(0)
} }
fatalIf(saveDefaultConfig(configPath)) fatalIf(saveDefaultConfig(configPath, DefaultConfigContent(getUsername())))
os.Exit(0) os.Exit(0)
} }
@@ -96,7 +96,7 @@ func main() {
runCmd(args) runCmd(args)
case "install": case "install":
ensureRoot() ensureRoot()
fatalIf(installService()) fatalIf(installService(args))
case "uninstall": case "uninstall":
ensureRoot() ensureRoot()
fatalIf(uninstallService(slices.Contains(args, "--complete"))) fatalIf(uninstallService(slices.Contains(args, "--complete")))
+110 -47
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"flag"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -13,18 +14,18 @@ import (
"nadir/internal/config" "nadir/internal/config"
) )
const defaultConfigTemplate = `# Nadir configuration - config.yaml const configTemplateBase = `# Nadir configuration - config.yaml
# #
# Single source of truth for runtime settings. # Single source of truth for runtime settings.
# #
server: server:
secure_tls: true secure_tls: %s
# trust_proxy: false %s
tls_cert: /var/lib/nadir/tls/cert.pem %s
tls_key: /var/lib/nadir/tls/key.pem %s
hostname: 127.0.0.1 hostname: %s
port: 9999 port: %d
release_repo: https://tea.urania.dev/urania/nadir-agent release_repo: https://tea.urania.dev/urania/nadir-agent
roles: roles:
@@ -104,7 +105,38 @@ const (
// installService writes the systemd unit, enables it on boot, and starts it. // 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 // The unit pins the absolute executable and config paths captured now, so the
// service doesn't depend on the working directory at boot. // 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 // Provision the PAM service the server authenticates against, so it exists
// before the unit starts rather than appearing on first login. Idempotent: // before the unit starts rather than appearing on first login. Idempotent:
// EnsurePAMService leaves an existing /etc/pam.d/nadir untouched. runServer // 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) return fmt.Errorf("create data directory: %w", err)
} }
// Generate and save persistent self-signed TLS certificates // Generate and save persistent self-signed TLS certificates if TLS mode
tlsDir := "/var/lib/nadir/tls" certPath := filepath.Join("/var/lib/nadir/tls", "cert.pem")
if err := os.MkdirAll(tlsDir, 0700); err != nil { if isTLS {
return fmt.Errorf("create tls directory: %w", err) tlsDir := "/var/lib/nadir/tls"
} if err := os.MkdirAll(tlsDir, 0700); err != nil {
certPath := filepath.Join(tlsDir, "cert.pem") return fmt.Errorf("create tls directory: %w", err)
keyPath := filepath.Join(tlsDir, "key.pem") }
if _, err := os.Stat(certPath); os.IsNotExist(err) { keyPath := filepath.Join(tlsDir, "key.pem")
if err := generateAndSaveCert(certPath, keyPath); err != nil { if _, err := os.Stat(certPath); os.IsNotExist(err) {
return fmt.Errorf("generate certificates: %w", 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() cfgPath, err := resolveConfigPath()
@@ -152,9 +186,30 @@ func installService() error {
return err 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 // Ensure default config file exists
if _, err := os.Stat(cfgPath); os.IsNotExist(err) { if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
if err := saveDefaultConfig(cfgPath); err != nil { if err := saveDefaultConfig(cfgPath, configContent); err != nil {
return err return err
} }
} }
@@ -224,16 +279,18 @@ WantedBy=multi-user.target
} }
// Output credentials to copy to the frontend // Output credentials to copy to the frontend
certBytes, err := os.ReadFile(filepath.Join("/var/lib/nadir/tls", "cert.pem")) fmt.Println("\n======================================================================")
if err == nil { fmt.Println(" NADIR CLIENT CREDENTIALS (COPY THESE TO SVELTEKIT FRONTEND)")
fmt.Println("\n======================================================================") fmt.Println("======================================================================")
fmt.Println(" NADIR CLIENT CREDENTIALS (COPY THESE TO SVELTEKIT FRONTEND)") fmt.Printf("Token for \"dashboard\" (Bearer):\n %s\n", tokenStr)
fmt.Println("======================================================================") if isTLS {
fmt.Printf("Token for \"dashboard\" (Bearer):\n %s\n\n", tokenStr) certBytes, err := os.ReadFile(certPath)
fmt.Println("CA Certificate (Trust Store):") if err == nil {
fmt.Println(string(certBytes)) fmt.Println("\nCA Certificate (Trust Store):")
fmt.Println("======================================================================") fmt.Println(string(certBytes))
}
} }
fmt.Println("======================================================================")
fmt.Printf("installed and started %s; follow logs with: %s logs\n", serviceName, filepath.Base(exe)) fmt.Printf("installed and started %s; follow logs with: %s logs\n", serviceName, filepath.Base(exe))
return nil 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. // saveDefaultConfig writes the default configuration template to cfgPath.
func saveDefaultConfig(cfgPath string) error { func saveDefaultConfig(cfgPath, content string) error {
dir := filepath.Dir(cfgPath) dir := filepath.Dir(cfgPath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create config directory: %w", err) return fmt.Errorf("create config directory: %w", err)
} }
chownToSudoUser(dir) chownToSudoUser(dir)
username := getUsername() if err := os.WriteFile(cfgPath, []byte(content), 0600); err != nil {
configContent := fmt.Sprintf(defaultConfigTemplate, username)
if err := os.WriteFile(cfgPath, []byte(configContent), 0600); err != nil {
return fmt.Errorf("write default config: %w", err) return fmt.Errorf("write default config: %w", err)
} }
chownToSudoUser(cfgPath) 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 return nil
} }
@@ -468,19 +528,22 @@ func usage(w io.Writer) {
fmt.Fprint(w, `nadir - Linux system administration backend fmt.Fprint(w, `nadir - Linux system administration backend
Usage: Usage:
nadir [run] [-d] [-f <path>] Start the server (-d / --detach: run in background) 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 --save-config [-f <path>] Save default configuration to path and exit
nadir install [-f <path>] Install + enable the systemd service (starts on boot) nadir install [--tls|--unsecure|--trust-proxy] Install + enable the systemd service (starts on boot)
nadir uninstall [--complete] Remove the service (keeps data/config; --complete wipes all) (--tls: enable HTTPS with self-signed certificate, default)
nadir start|stop|restart|status Control the running service (--unsecure: serve HTTP directly)
nadir enable|disable Toggle start-on-boot without removing the unit (--trust-proxy: serve HTTP behind a reverse proxy)
nadir logs [clear] Follow (default) or clear server logs nadir uninstall [--complete] Remove the service (keeps data/config; --complete wipes all)
nadir token add <name> Mint a machine credential (Bearer token), shown once nadir start|stop|restart|status Control the running service
nadir token rm <name> Revoke a token (effective immediately, no restart) nadir enable|disable Toggle start-on-boot without removing the unit
nadir token ls List token names and when they were created nadir logs [clear] Follow (default) or clear server logs
nadir update [--check|--force] Fetch the latest release from server.release_repo and restart nadir token add <name> Mint a machine credential (Bearer token), shown once
(--check: report only; --force: re-download when already current) nadir token rm <name> Revoke a token (effective immediately, no restart)
nadir help Show this help 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). Most commands need root. Config path is specified via -f/--config or CONFIG_PATH (default ~/.config/config.yaml).
`) `)
+9 -1
View File
@@ -60,7 +60,7 @@ func generateSelfSignedCert() (tls.Certificate, error) {
// generateAndSaveCert generates a self-signed certificate and private key, // generateAndSaveCert generates a self-signed certificate and private key,
// and saves them as PEM files. // 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) priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil { if err != nil {
return err return err
@@ -83,6 +83,14 @@ func generateAndSaveCert(certPath, keyPath string) error {
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, 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 // 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). // correctly over the local network (e.g. Tailscale or Netbird).
if addrs, err := net.InterfaceAddrs(); err == nil { if addrs, err := net.InterfaceAddrs(); err == nil {