From 051ab6ca9d9df871423a8927d59dbf71d829dc48 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 16 Feb 2026 17:55:17 -0800 Subject: [PATCH 1/9] Remove legacy ssh --- main.go | 96 --------------------------------------------------------- 1 file changed, 96 deletions(-) diff --git a/main.go b/main.go index 4016ad3..cf02e27 100644 --- a/main.go +++ b/main.go @@ -58,10 +58,6 @@ type ExitNodeData struct { ExitNodes []ExitNode `json:"exitNodes"` } -type SSHPublicKeyData struct { - PublicKey string `json:"publicKey"` -} - // ExitNode represents an exit node with an ID, endpoint, and weight. type ExitNode struct { ID int `json:"exitNodeId"` @@ -279,10 +275,6 @@ func runNewtMain(ctx context.Context) { // load the prefer endpoint just as a flag flag.StringVar(&preferEndpoint, "prefer-endpoint", "", "Prefer this endpoint for the connection (if set, will override the endpoint from the server)") - // if authorizedKeysFile == "" { - // flag.StringVar(&authorizedKeysFile, "authorized-keys-file", "~/.ssh/authorized_keys", "Path to authorized keys file (if unset, no keys will be authorized)") - // } - // Add new mTLS flags if tlsClientCert == "" { flag.StringVar(&tlsClientCert, "tls-client-cert-file", "", "Path to client certificate file (PEM/DER format)") @@ -1168,94 +1160,6 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } }) - // EXPERIMENTAL: WHAT SHOULD WE DO ABOUT SECURITY? - client.RegisterHandler("newt/send/ssh/publicKey", func(msg websocket.WSMessage) { - logger.Debug("Received SSH public key request") - - var sshPublicKeyData SSHPublicKeyData - - jsonData, err := json.Marshal(msg.Data) - if err != nil { - logger.Info(fmtErrMarshaling, err) - return - } - if err := json.Unmarshal(jsonData, &sshPublicKeyData); err != nil { - logger.Info("Error unmarshaling SSH public key data: %v", err) - return - } - - sshPublicKey := sshPublicKeyData.PublicKey - - if authorizedKeysFile == "" { - logger.Debug("No authorized keys file set, skipping public key response") - return - } - - // Expand tilde to home directory if present - expandedPath := authorizedKeysFile - if strings.HasPrefix(authorizedKeysFile, "~/") { - homeDir, err := os.UserHomeDir() - if err != nil { - logger.Error("Failed to get user home directory: %v", err) - return - } - expandedPath = filepath.Join(homeDir, authorizedKeysFile[2:]) - } - - // if it is set but the file does not exist, create it - if _, err := os.Stat(expandedPath); os.IsNotExist(err) { - logger.Debug("Authorized keys file does not exist, creating it: %s", expandedPath) - if err := os.MkdirAll(filepath.Dir(expandedPath), 0755); err != nil { - logger.Error("Failed to create directory for authorized keys file: %v", err) - return - } - if _, err := os.Create(expandedPath); err != nil { - logger.Error("Failed to create authorized keys file: %v", err) - return - } - } - - // Check if the public key already exists in the file - fileContent, err := os.ReadFile(expandedPath) - if err != nil { - logger.Error("Failed to read authorized keys file: %v", err) - return - } - - // Check if the key already exists (trim whitespace for comparison) - existingKeys := strings.Split(string(fileContent), "\n") - keyAlreadyExists := false - trimmedNewKey := strings.TrimSpace(sshPublicKey) - - for _, existingKey := range existingKeys { - if strings.TrimSpace(existingKey) == trimmedNewKey && trimmedNewKey != "" { - keyAlreadyExists = true - break - } - } - - if keyAlreadyExists { - logger.Info("SSH public key already exists in authorized keys file, skipping") - return - } - - // append the public key to the authorized keys file - logger.Debug("Appending public key to authorized keys file: %s", sshPublicKey) - file, err := os.OpenFile(expandedPath, os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - logger.Error("Failed to open authorized keys file: %v", err) - return - } - defer file.Close() - - if _, err := file.WriteString(sshPublicKey + "\n"); err != nil { - logger.Error("Failed to write public key to authorized keys file: %v", err) - return - } - - logger.Info("SSH public key appended to authorized keys file") - }) - // Register handler for adding health check targets client.RegisterHandler("newt/healthcheck/add", func(msg websocket.WSMessage) { logger.Debug("Received health check add request: %+v", msg) From 9526768dfe996c6cf1dc3de005d1d74456a18ac4 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 16 Feb 2026 20:04:24 -0800 Subject: [PATCH 2/9] Add basic newt command relay to auth daemon --- main.go | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 145 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index cf02e27..ecf84eb 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "context" + "crypto/tls" "encoding/json" "errors" "flag" @@ -11,7 +13,6 @@ import ( "net/netip" "os" "os/signal" - "path/filepath" "strconv" "strings" "syscall" @@ -130,6 +131,7 @@ var ( preferEndpoint string healthMonitor *healthcheck.Monitor enforceHealthcheckCert bool + authDaemonKey string // Build/version (can be overridden via -ldflags "-X main.newtVersion=...") newtVersion = "version_replaceme" @@ -183,6 +185,7 @@ func runNewtMain(ctx context.Context) { updownScript = os.Getenv("UPDOWN_SCRIPT") interfaceName = os.Getenv("INTERFACE") portStr := os.Getenv("PORT") + authDaemonKey = os.Getenv("AUTH_DAEMON_KEY") // Metrics/observability env mirrors metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED") @@ -371,6 +374,11 @@ func runNewtMain(ctx context.Context) { region = regionEnv } + // Auth daemon key flag + if authDaemonKey == "" { + flag.StringVar(&authDaemonKey, "auth-daemon-key", "", "Preshared key for auth daemon authentication") + } + // do a --version check version := flag.Bool("version", false, "Print the version") @@ -686,8 +694,8 @@ func runNewtMain(ctx context.Context) { relayPort := wgData.RelayPort if relayPort == 0 { - relayPort = 21820 - } + relayPort = 21820 + } clientsHandleNewtConnection(wgData.PublicKey, endpoint, relayPort) @@ -1315,6 +1323,140 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } }) + // Register handler for SSH certificate issued events + client.RegisterHandler("newt/pam/connection", func(msg websocket.WSMessage) { + logger.Debug("Received SSH certificate issued message") + + // Define the structure of the incoming message + type SSHCertData struct { + TraceID string `json:"traceId"` + AgentPort int `json:"agentPort"` + AgentHost string `json:"agentHost"` + CACert string `json:"caCert"` + Username string `json:"username"` + NiceID string `json:"niceId"` + Metadata struct { + Sudo bool `json:"sudo"` + Homedir bool `json:"homedir"` + } `json:"metadata"` + } + + var certData SSHCertData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling SSH cert data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &certData); err != nil { + logger.Error("Error unmarshaling SSH cert data: %v", err) + return + } + + // Check if auth daemon key is configured + if authDaemonKey == "" { + logger.Error("Auth daemon key not configured, cannot process SSH certificate") + // Send failure response back to cloud + err := client.SendMessage("newt/pam/connection/response", map[string]interface{}{ + "traceId": certData.TraceID, + "success": false, + "error": "auth daemon key not configured", + }) + if err != nil { + logger.Error("Failed to send SSH cert failure response: %v", err) + } + return + } + + // Prepare the request body for the auth daemon + requestBody := map[string]interface{}{ + "caCert": certData.CACert, + "niceId": certData.NiceID, + "username": certData.Username, + "metadata": map[string]interface{}{ + "sudo": certData.Metadata.Sudo, + "homedir": certData.Metadata.Homedir, + }, + } + + requestJSON, err := json.Marshal(requestBody) + if err != nil { + logger.Error("Failed to marshal auth daemon request: %v", err) + // Send failure response + client.SendMessage("newt/pam/ssh-cert-response", map[string]interface{}{ + "traceId": certData.TraceID, + "success": false, + "error": fmt.Sprintf("failed to marshal request: %v", err), + }) + return + } + + // Create HTTPS client that skips certificate verification + // (auth daemon uses self-signed cert) + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + Timeout: 10 * time.Second, + } + + // Make the request to the auth daemon + url := fmt.Sprintf("https://%s:%d/connection", certData.AgentHost, certData.AgentPort) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON)) + if err != nil { + logger.Error("Failed to create auth daemon request: %v", err) + client.SendMessage("newt/pam/connection/response", map[string]interface{}{ + "traceId": certData.TraceID, + "success": false, + "error": fmt.Sprintf("failed to create request: %v", err), + }) + return + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+authDaemonKey) + + logger.Debug("Sending SSH cert to auth daemon at %s", url) + + // Send the request + resp, err := httpClient.Do(req) + if err != nil { + logger.Error("Failed to connect to auth daemon: %v", err) + client.SendMessage("newt/pam/connection/response", map[string]interface{}{ + "traceId": certData.TraceID, + "success": false, + "error": fmt.Sprintf("failed to connect to auth daemon: %v", err), + }) + return + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + logger.Error("Auth daemon returned non-OK status: %d", resp.StatusCode) + client.SendMessage("newt/pam/connection/response", map[string]interface{}{ + "traceId": certData.TraceID, + "success": false, + "error": fmt.Sprintf("auth daemon returned status %d", resp.StatusCode), + }) + return + } + + logger.Info("Successfully registered SSH certificate with auth daemon for user %s", certData.Username) + + // Send success response back to cloud + err = client.SendMessage("newt/pam/connection/response", map[string]interface{}{ + "traceId": certData.TraceID, + "success": true, + }) + if err != nil { + logger.Error("Failed to send SSH cert success response: %v", err) + } + }) + client.OnConnect(func() error { publicKey = privateKey.PublicKey() logger.Debug("Public key: %s", publicKey) From 0af6fb8fef53145f49c1f7f021d59cf5c149c56b Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 16 Feb 2026 20:29:19 -0800 Subject: [PATCH 3/9] Add round trip tracking for any message --- main.go | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/main.go b/main.go index ecf84eb..24fd8bb 100644 --- a/main.go +++ b/main.go @@ -1329,7 +1329,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // Define the structure of the incoming message type SSHCertData struct { - TraceID string `json:"traceId"` + MessageId string `json:"messageId"` AgentPort int `json:"agentPort"` AgentHost string `json:"agentHost"` CACert string `json:"caCert"` @@ -1357,9 +1357,9 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( if authDaemonKey == "" { logger.Error("Auth daemon key not configured, cannot process SSH certificate") // Send failure response back to cloud - err := client.SendMessage("newt/pam/connection/response", map[string]interface{}{ - "traceId": certData.TraceID, - "success": false, + err := client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, "error": "auth daemon key not configured", }) if err != nil { @@ -1383,9 +1383,9 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( if err != nil { logger.Error("Failed to marshal auth daemon request: %v", err) // Send failure response - client.SendMessage("newt/pam/ssh-cert-response", map[string]interface{}{ - "traceId": certData.TraceID, - "success": false, + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, "error": fmt.Sprintf("failed to marshal request: %v", err), }) return @@ -1407,9 +1407,9 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON)) if err != nil { logger.Error("Failed to create auth daemon request: %v", err) - client.SendMessage("newt/pam/connection/response", map[string]interface{}{ - "traceId": certData.TraceID, - "success": false, + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, "error": fmt.Sprintf("failed to create request: %v", err), }) return @@ -1425,9 +1425,9 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( resp, err := httpClient.Do(req) if err != nil { logger.Error("Failed to connect to auth daemon: %v", err) - client.SendMessage("newt/pam/connection/response", map[string]interface{}{ - "traceId": certData.TraceID, - "success": false, + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, "error": fmt.Sprintf("failed to connect to auth daemon: %v", err), }) return @@ -1437,9 +1437,9 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // Check response status if resp.StatusCode != http.StatusOK { logger.Error("Auth daemon returned non-OK status: %d", resp.StatusCode) - client.SendMessage("newt/pam/connection/response", map[string]interface{}{ - "traceId": certData.TraceID, - "success": false, + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, "error": fmt.Sprintf("auth daemon returned status %d", resp.StatusCode), }) return @@ -1448,9 +1448,9 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( logger.Info("Successfully registered SSH certificate with auth daemon for user %s", certData.Username) // Send success response back to cloud - err = client.SendMessage("newt/pam/connection/response", map[string]interface{}{ - "traceId": certData.TraceID, - "success": true, + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, }) if err != nil { logger.Error("Failed to send SSH cert success response: %v", err) From e06b8de0a74e5b90b65a4b5f1c5d6bf05a02b8f0 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 20:36:00 -0800 Subject: [PATCH 4/9] add auth daemon --- authdaemon/host_linux.go | 269 +++++++++++++++++++++++++++++++++++++++ authdaemon/host_stub.go | 32 +++++ authdaemon/principals.go | 28 ++++ authdaemon/routes.go | 92 +++++++++++++ authdaemon/server.go | 174 +++++++++++++++++++++++++ 5 files changed, 595 insertions(+) create mode 100644 authdaemon/host_linux.go create mode 100644 authdaemon/host_stub.go create mode 100644 authdaemon/principals.go create mode 100644 authdaemon/routes.go create mode 100644 authdaemon/server.go diff --git a/authdaemon/host_linux.go b/authdaemon/host_linux.go new file mode 100644 index 0000000..4416dd1 --- /dev/null +++ b/authdaemon/host_linux.go @@ -0,0 +1,269 @@ +//go:build linux + +package authdaemon + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + + "github.com/fosrl/newt/logger" +) + +// writeCACertIfNotExists writes contents to path only if the file does not exist. +func writeCACertIfNotExists(path, contents string) error { + if _, err := os.Stat(path); err == nil { + logger.Debug("auth-daemon: CA cert already exists at %s, skipping write", path) + return nil + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + contents = strings.TrimSpace(contents) + if contents != "" && !strings.HasSuffix(contents, "\n") { + contents += "\n" + } + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + return fmt.Errorf("write CA cert: %w", err) + } + logger.Info("auth-daemon: wrote CA cert to %s", path) + return nil +} + +// ensureSSHDTrustedUserCAKeys ensures sshd_config contains TrustedUserCAKeys caCertPath. +func ensureSSHDTrustedUserCAKeys(sshdConfigPath, caCertPath string) error { + if sshdConfigPath == "" { + sshdConfigPath = "/etc/ssh/sshd_config" + } + data, err := os.ReadFile(sshdConfigPath) + if err != nil { + return fmt.Errorf("read sshd_config: %w", err) + } + directive := "TrustedUserCAKeys " + caCertPath + lines := strings.Split(string(data), "\n") + found := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + // strip inline comment + if idx := strings.Index(trimmed, "#"); idx >= 0 { + trimmed = strings.TrimSpace(trimmed[:idx]) + } + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "TrustedUserCAKeys") { + if strings.TrimSpace(trimmed) == directive { + logger.Debug("auth-daemon: sshd_config already has TrustedUserCAKeys %s", caCertPath) + return nil + } + lines[i] = directive + found = true + break + } + } + if !found { + lines = append(lines, directive) + } + out := strings.Join(lines, "\n") + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + if err := os.WriteFile(sshdConfigPath, []byte(out), 0644); err != nil { + return fmt.Errorf("write sshd_config: %w", err) + } + logger.Info("auth-daemon: updated %s with TrustedUserCAKeys %s", sshdConfigPath, caCertPath) + return nil +} + +// reloadSSHD runs the given shell command to reload sshd (e.g. "systemctl reload sshd"). +func reloadSSHD(reloadCmd string) error { + if reloadCmd == "" { + return nil + } + cmd := exec.Command("sh", "-c", reloadCmd) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("reload sshd %q: %w (output: %s)", reloadCmd, err, string(out)) + } + logger.Info("auth-daemon: reloaded sshd") + return nil +} + +// writePrincipals updates the principals file at path: JSON object keyed by username, value is array of principals. Adds username and niceId to that user's list (deduped). +func writePrincipals(path, username, niceId string) error { + if path == "" { + return nil + } + username = strings.TrimSpace(username) + niceId = strings.TrimSpace(niceId) + if username == "" { + return nil + } + data := make(map[string][]string) + if raw, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(raw, &data) + } + list := data[username] + seen := make(map[string]struct{}, len(list)+2) + for _, p := range list { + seen[p] = struct{}{} + } + for _, p := range []string{username, niceId} { + if p == "" { + continue + } + if _, ok := seen[p]; !ok { + seen[p] = struct{}{} + list = append(list, p) + } + } + data[username] = list + body, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("marshal principals: %w", err) + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + if err := os.WriteFile(path, body, 0644); err != nil { + return fmt.Errorf("write principals: %w", err) + } + logger.Debug("auth-daemon: wrote principals to %s", path) + return nil +} + +// sudoGroup returns the name of the sudo group (wheel or sudo) that exists on the system. Prefers wheel. +func sudoGroup() string { + f, err := os.Open("/etc/group") + if err != nil { + return "sudo" + } + defer f.Close() + sc := bufio.NewScanner(f) + hasWheel := false + hasSudo := false + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, "wheel:") { + hasWheel = true + } + if strings.HasPrefix(line, "sudo:") { + hasSudo = true + } + } + if hasWheel { + return "wheel" + } + if hasSudo { + return "sudo" + } + return "sudo" +} + +// ensureUser creates the system user if missing, or reconciles sudo and homedir to match meta. +func ensureUser(username string, meta ConnectionMetadata) error { + if username == "" { + return nil + } + u, err := user.Lookup(username) + if err != nil { + if _, ok := err.(user.UnknownUserError); !ok { + return fmt.Errorf("lookup user %s: %w", username, err) + } + return createUser(username, meta) + } + return reconcileUser(u, meta) +} + +func createUser(username string, meta ConnectionMetadata) error { + args := []string{} + if meta.Homedir { + args = append(args, "-m") + } else { + args = append(args, "-M") + } + args = append(args, username) + cmd := exec.Command("useradd", args...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("useradd %s: %w (output: %s)", username, err, string(out)) + } + logger.Info("auth-daemon: created user %s (homedir=%v)", username, meta.Homedir) + if meta.Sudo { + group := sudoGroup() + cmd := exec.Command("usermod", "-aG", group, username) + if out, err := cmd.CombinedOutput(); err != nil { + logger.Warn("auth-daemon: usermod -aG %s %s: %v (output: %s)", group, username, err, string(out)) + } else { + logger.Info("auth-daemon: added %s to %s", username, group) + } + } + return nil +} + +func mustAtoi(s string) int { + n, _ := strconv.Atoi(s) + return n +} + +func reconcileUser(u *user.User, meta ConnectionMetadata) error { + group := sudoGroup() + inGroup, err := userInGroup(u.Username, group) + if err != nil { + logger.Warn("auth-daemon: check group %s: %v", group, err) + inGroup = false + } + if meta.Sudo && !inGroup { + cmd := exec.Command("usermod", "-aG", group, u.Username) + if out, err := cmd.CombinedOutput(); err != nil { + logger.Warn("auth-daemon: usermod -aG %s %s: %v (output: %s)", group, u.Username, err, string(out)) + } else { + logger.Info("auth-daemon: added %s to %s", u.Username, group) + } + } else if !meta.Sudo && inGroup { + cmd := exec.Command("gpasswd", "-d", u.Username, group) + if out, err := cmd.CombinedOutput(); err != nil { + logger.Warn("auth-daemon: gpasswd -d %s %s: %v (output: %s)", u.Username, group, err, string(out)) + } else { + logger.Info("auth-daemon: removed %s from %s", u.Username, group) + } + } + if meta.Homedir && u.HomeDir != "" { + if st, err := os.Stat(u.HomeDir); err != nil || !st.IsDir() { + if err := os.MkdirAll(u.HomeDir, 0755); err != nil { + logger.Warn("auth-daemon: mkdir %s: %v", u.HomeDir, err) + } else { + uid, gid := mustAtoi(u.Uid), mustAtoi(u.Gid) + _ = os.Chown(u.HomeDir, uid, gid) + logger.Info("auth-daemon: created home %s for %s", u.HomeDir, u.Username) + } + } + } + return nil +} + +func userInGroup(username, groupName string) (bool, error) { + // getent group wheel returns "wheel:x:10:user1,user2" + cmd := exec.Command("getent", "group", groupName) + out, err := cmd.Output() + if err != nil { + return false, err + } + parts := strings.SplitN(strings.TrimSpace(string(out)), ":", 4) + if len(parts) < 4 { + return false, nil + } + members := strings.Split(parts[3], ",") + for _, m := range members { + if strings.TrimSpace(m) == username { + return true, nil + } + } + return false, nil +} diff --git a/authdaemon/host_stub.go b/authdaemon/host_stub.go new file mode 100644 index 0000000..dfd09a5 --- /dev/null +++ b/authdaemon/host_stub.go @@ -0,0 +1,32 @@ +//go:build !linux + +package authdaemon + +import "fmt" + +var errLinuxOnly = fmt.Errorf("auth-daemon PAM agent is only supported on Linux") + +// writeCACertIfNotExists returns an error on non-Linux. +func writeCACertIfNotExists(path, contents string) error { + return errLinuxOnly +} + +// ensureSSHDTrustedUserCAKeys returns an error on non-Linux. +func ensureSSHDTrustedUserCAKeys(sshdConfigPath, caCertPath string) error { + return errLinuxOnly +} + +// reloadSSHD returns an error on non-Linux. +func reloadSSHD(reloadCmd string) error { + return errLinuxOnly +} + +// ensureUser returns an error on non-Linux. +func ensureUser(username string, meta ConnectionMetadata) error { + return errLinuxOnly +} + +// writePrincipals returns an error on non-Linux. +func writePrincipals(path, username, niceId string) error { + return errLinuxOnly +} diff --git a/authdaemon/principals.go b/authdaemon/principals.go new file mode 100644 index 0000000..cbfca80 --- /dev/null +++ b/authdaemon/principals.go @@ -0,0 +1,28 @@ +package authdaemon + +import ( + "encoding/json" + "fmt" + "os" +) + +// GetPrincipals reads the principals data file at path, looks up the given user, and returns that user's principals as a string slice. +// The file format is JSON: object with username keys and array-of-principals values, e.g. {"alice":["alice","usr-123"],"bob":["bob","usr-456"]}. +// If the user is not found or the file is missing, returns nil and nil. +func GetPrincipals(path, user string) ([]string, error) { + if path == "" { + return nil, fmt.Errorf("principals file path is required") + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read principals file: %w", err) + } + var m map[string][]string + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse principals file: %w", err) + } + return m[user], nil +} diff --git a/authdaemon/routes.go b/authdaemon/routes.go new file mode 100644 index 0000000..d7ce880 --- /dev/null +++ b/authdaemon/routes.go @@ -0,0 +1,92 @@ +package authdaemon + +import ( + "encoding/json" + "net/http" + + "github.com/fosrl/newt/logger" +) + +// registerRoutes registers all API routes. Add new endpoints here. +func (s *Server) registerRoutes() { + s.mux.HandleFunc("/health", s.handleHealth) + s.mux.HandleFunc("/connection", s.handleConnection) +} + +// ConnectionMetadata is the metadata object in POST /connection. +type ConnectionMetadata struct { + Sudo bool `json:"sudo"` + Homedir bool `json:"homedir"` +} + +// ConnectionRequest is the JSON body for POST /connection. +type ConnectionRequest struct { + CaCert string `json:"caCert"` + NiceId string `json:"niceId"` + Username string `json:"username"` + Metadata ConnectionMetadata `json:"metadata"` +} + +// healthResponse is the JSON body for GET /health. +type healthResponse struct { + Status string `json:"status"` +} + +// handleHealth responds with 200 and {"status":"ok"}. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(healthResponse{Status: "ok"}) +} + +// ProcessConnection runs the same logic as POST /connection: CA cert, sshd config, user create/reconcile, principals. +// Use this when DisableHTTPS is true (e.g. embedded in Newt) instead of calling the API. +func (s *Server) ProcessConnection(req ConnectionRequest) { + logger.Info("connection: niceId=%q username=%q metadata.sudo=%v metadata.homedir=%v", + req.NiceId, req.Username, req.Metadata.Sudo, req.Metadata.Homedir) + + cfg := &s.cfg + if cfg.CACertPath != "" { + if err := writeCACertIfNotExists(cfg.CACertPath, req.CaCert); err != nil { + logger.Warn("auth-daemon: write CA cert: %v", err) + } + sshdConfig := cfg.SSHDConfigPath + if sshdConfig == "" { + sshdConfig = "/etc/ssh/sshd_config" + } + if err := ensureSSHDTrustedUserCAKeys(sshdConfig, cfg.CACertPath); err != nil { + logger.Warn("auth-daemon: sshd_config: %v", err) + } + if cfg.ReloadSSHCommand != "" { + if err := reloadSSHD(cfg.ReloadSSHCommand); err != nil { + logger.Warn("auth-daemon: reload sshd: %v", err) + } + } + } + if err := ensureUser(req.Username, req.Metadata); err != nil { + logger.Warn("auth-daemon: ensure user: %v", err) + } + if cfg.PrincipalsFilePath != "" { + if err := writePrincipals(cfg.PrincipalsFilePath, req.Username, req.NiceId); err != nil { + logger.Warn("auth-daemon: write principals: %v", err) + } + } +} + +// handleConnection accepts POST with connection payload and delegates to ProcessConnection. +func (s *Server) handleConnection(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + var req ConnectionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + s.ProcessConnection(req) + w.WriteHeader(http.StatusOK) +} diff --git a/authdaemon/server.go b/authdaemon/server.go new file mode 100644 index 0000000..83cb480 --- /dev/null +++ b/authdaemon/server.go @@ -0,0 +1,174 @@ +package authdaemon + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net/http" + "os" + "runtime" + "strings" + "time" + + "github.com/fosrl/newt/logger" +) + +type Config struct { + // DisableHTTPS: when true, Run() does not start the HTTPS server (for embedded use inside Newt). Call ProcessConnection directly for connection events. + DisableHTTPS bool + Port int // Listen port for the HTTPS server. Required when DisableHTTPS is false. + PresharedKey string // Required when DisableHTTPS is false; used for HTTP auth (Authorization: Bearer or X-Preshared-Key: ). + CACertPath string // Where to write the CA cert (e.g. /etc/ssh/ca.pem). + SSHDConfigPath string // Path to sshd_config (e.g. /etc/ssh/sshd_config). Defaults to /etc/ssh/sshd_config when CACertPath is set. + ReloadSSHCommand string // Command to reload sshd after config change (e.g. "systemctl reload sshd"). Empty = no reload. + PrincipalsFilePath string // Path to the principals data file (JSON: username -> array of principals). Empty = do not store principals. +} + +type Server struct { + cfg Config + addr string + presharedKey string + mux *http.ServeMux + tlsCert tls.Certificate +} + +// generateTLSCert creates a self-signed certificate and key in memory (no disk). +func generateTLSCert() (tls.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, fmt.Errorf("generate key: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("serial: %w", err) + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "localhost", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{"localhost", "127.0.0.1"}, + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + return tls.Certificate{}, fmt.Errorf("create certificate: %w", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return tls.Certificate{}, fmt.Errorf("marshal key: %w", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return tls.Certificate{}, fmt.Errorf("x509 key pair: %w", err) + } + return cert, nil +} + +// authMiddleware wraps next and requires a valid preshared key on every request. +// Accepts Authorization: Bearer or X-Preshared-Key: . +func (s *Server) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := "" + if v := r.Header.Get("Authorization"); strings.HasPrefix(v, "Bearer ") { + key = strings.TrimSpace(strings.TrimPrefix(v, "Bearer ")) + } + if key == "" { + key = strings.TrimSpace(r.Header.Get("X-Preshared-Key")) + } + if key == "" || subtle.ConstantTimeCompare([]byte(key), []byte(s.presharedKey)) != 1 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// NewServer builds a new auth-daemon server from cfg. When DisableHTTPS is false, PresharedKey and Port are required. +func NewServer(cfg Config) (*Server, error) { + if runtime.GOOS != "linux" { + return nil, fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS) + } + if !cfg.DisableHTTPS { + if cfg.PresharedKey == "" { + return nil, fmt.Errorf("preshared key is required when HTTPS is enabled") + } + if cfg.Port <= 0 { + return nil, fmt.Errorf("port must be positive when HTTPS is enabled") + } + } + s := &Server{cfg: cfg} + if !cfg.DisableHTTPS { + cert, err := generateTLSCert() + if err != nil { + return nil, err + } + s.addr = fmt.Sprintf(":%d", cfg.Port) + s.presharedKey = cfg.PresharedKey + s.mux = http.NewServeMux() + s.tlsCert = cert + s.registerRoutes() + } + return s, nil +} + +// Run starts the HTTPS server (unless DisableHTTPS) and blocks until ctx is cancelled or the server errors. +// When DisableHTTPS is true, Run() blocks on ctx only and does not listen; use ProcessConnection for connection events. +func (s *Server) Run(ctx context.Context) error { + if s.cfg.DisableHTTPS { + logger.Info("auth-daemon running (HTTPS disabled)") + <-ctx.Done() + s.cleanupPrincipalsFile() + return nil + } + tcfg := &tls.Config{ + Certificates: []tls.Certificate{s.tlsCert}, + MinVersion: tls.VersionTLS12, + } + handler := s.authMiddleware(s.mux) + srv := &http.Server{ + Addr: s.addr, + Handler: handler, + TLSConfig: tcfg, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 60 * time.Second, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Warn("auth-daemon shutdown: %v", err) + } + }() + logger.Info("auth-daemon listening on https://127.0.0.1%s", s.addr) + if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + return err + } + s.cleanupPrincipalsFile() + return nil +} + +func (s *Server) cleanupPrincipalsFile() { + if s.cfg.PrincipalsFilePath != "" { + if err := os.Remove(s.cfg.PrincipalsFilePath); err != nil && !os.IsNotExist(err) { + logger.Warn("auth-daemon: remove principals file: %v", err) + } + } +} From 8609be130e62686c1a1506567e813ba811ef6697 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 20:50:13 -0800 Subject: [PATCH 5/9] remove defaults --- authdaemon/connection.go | 27 ++++++++++++ authdaemon/host_linux.go | 89 ++++++++++------------------------------ authdaemon/host_stub.go | 12 +----- authdaemon/routes.go | 36 ---------------- authdaemon/server.go | 27 +++++++----- 5 files changed, 66 insertions(+), 125 deletions(-) create mode 100644 authdaemon/connection.go diff --git a/authdaemon/connection.go b/authdaemon/connection.go new file mode 100644 index 0000000..9d36e27 --- /dev/null +++ b/authdaemon/connection.go @@ -0,0 +1,27 @@ +package authdaemon + +import ( + "github.com/fosrl/newt/logger" +) + +// ProcessConnection runs the same logic as POST /connection: CA cert, user create/reconcile, principals. +// Use this when DisableHTTPS is true (e.g. embedded in Newt) instead of calling the API. +func (s *Server) ProcessConnection(req ConnectionRequest) { + logger.Info("connection: niceId=%q username=%q metadata.sudo=%v metadata.homedir=%v", + req.NiceId, req.Username, req.Metadata.Sudo, req.Metadata.Homedir) + + cfg := &s.cfg + if cfg.CACertPath != "" { + if err := writeCACertIfNotExists(cfg.CACertPath, req.CaCert, cfg.Force); err != nil { + logger.Warn("auth-daemon: write CA cert: %v", err) + } + } + if err := ensureUser(req.Username, req.Metadata); err != nil { + logger.Warn("auth-daemon: ensure user: %v", err) + } + if cfg.PrincipalsFilePath != "" { + if err := writePrincipals(cfg.PrincipalsFilePath, req.Username, req.NiceId); err != nil { + logger.Warn("auth-daemon: write principals: %v", err) + } + } +} diff --git a/authdaemon/host_linux.go b/authdaemon/host_linux.go index 4416dd1..82834f3 100644 --- a/authdaemon/host_linux.go +++ b/authdaemon/host_linux.go @@ -16,20 +16,33 @@ import ( "github.com/fosrl/newt/logger" ) -// writeCACertIfNotExists writes contents to path only if the file does not exist. -func writeCACertIfNotExists(path, contents string) error { - if _, err := os.Stat(path); err == nil { - logger.Debug("auth-daemon: CA cert already exists at %s, skipping write", path) - return nil +// writeCACertIfNotExists writes contents to path. If the file already exists: when force is false, skip; when force is true, overwrite only if content differs. +func writeCACertIfNotExists(path, contents string, force bool) error { + contents = strings.TrimSpace(contents) + if contents != "" && !strings.HasSuffix(contents, "\n") { + contents += "\n" + } + existing, err := os.ReadFile(path) + if err == nil { + existingStr := strings.TrimSpace(string(existing)) + if existingStr != "" && !strings.HasSuffix(existingStr, "\n") { + existingStr += "\n" + } + if existingStr == contents { + logger.Debug("auth-daemon: CA cert unchanged at %s, skipping write", path) + return nil + } + if !force { + logger.Debug("auth-daemon: CA cert already exists at %s, skipping write (Force disabled)", path) + return nil + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", path, err) } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("mkdir %s: %w", dir, err) } - contents = strings.TrimSpace(contents) - if contents != "" && !strings.HasSuffix(contents, "\n") { - contents += "\n" - } if err := os.WriteFile(path, []byte(contents), 0644); err != nil { return fmt.Errorf("write CA cert: %w", err) } @@ -37,64 +50,6 @@ func writeCACertIfNotExists(path, contents string) error { return nil } -// ensureSSHDTrustedUserCAKeys ensures sshd_config contains TrustedUserCAKeys caCertPath. -func ensureSSHDTrustedUserCAKeys(sshdConfigPath, caCertPath string) error { - if sshdConfigPath == "" { - sshdConfigPath = "/etc/ssh/sshd_config" - } - data, err := os.ReadFile(sshdConfigPath) - if err != nil { - return fmt.Errorf("read sshd_config: %w", err) - } - directive := "TrustedUserCAKeys " + caCertPath - lines := strings.Split(string(data), "\n") - found := false - for i, line := range lines { - trimmed := strings.TrimSpace(line) - // strip inline comment - if idx := strings.Index(trimmed, "#"); idx >= 0 { - trimmed = strings.TrimSpace(trimmed[:idx]) - } - if trimmed == "" { - continue - } - if strings.HasPrefix(trimmed, "TrustedUserCAKeys") { - if strings.TrimSpace(trimmed) == directive { - logger.Debug("auth-daemon: sshd_config already has TrustedUserCAKeys %s", caCertPath) - return nil - } - lines[i] = directive - found = true - break - } - } - if !found { - lines = append(lines, directive) - } - out := strings.Join(lines, "\n") - if !strings.HasSuffix(out, "\n") { - out += "\n" - } - if err := os.WriteFile(sshdConfigPath, []byte(out), 0644); err != nil { - return fmt.Errorf("write sshd_config: %w", err) - } - logger.Info("auth-daemon: updated %s with TrustedUserCAKeys %s", sshdConfigPath, caCertPath) - return nil -} - -// reloadSSHD runs the given shell command to reload sshd (e.g. "systemctl reload sshd"). -func reloadSSHD(reloadCmd string) error { - if reloadCmd == "" { - return nil - } - cmd := exec.Command("sh", "-c", reloadCmd) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("reload sshd %q: %w (output: %s)", reloadCmd, err, string(out)) - } - logger.Info("auth-daemon: reloaded sshd") - return nil -} - // writePrincipals updates the principals file at path: JSON object keyed by username, value is array of principals. Adds username and niceId to that user's list (deduped). func writePrincipals(path, username, niceId string) error { if path == "" { diff --git a/authdaemon/host_stub.go b/authdaemon/host_stub.go index dfd09a5..2fb4c1a 100644 --- a/authdaemon/host_stub.go +++ b/authdaemon/host_stub.go @@ -7,17 +7,7 @@ import "fmt" var errLinuxOnly = fmt.Errorf("auth-daemon PAM agent is only supported on Linux") // writeCACertIfNotExists returns an error on non-Linux. -func writeCACertIfNotExists(path, contents string) error { - return errLinuxOnly -} - -// ensureSSHDTrustedUserCAKeys returns an error on non-Linux. -func ensureSSHDTrustedUserCAKeys(sshdConfigPath, caCertPath string) error { - return errLinuxOnly -} - -// reloadSSHD returns an error on non-Linux. -func reloadSSHD(reloadCmd string) error { +func writeCACertIfNotExists(path, contents string, force bool) error { return errLinuxOnly } diff --git a/authdaemon/routes.go b/authdaemon/routes.go index d7ce880..2cccc54 100644 --- a/authdaemon/routes.go +++ b/authdaemon/routes.go @@ -3,8 +3,6 @@ package authdaemon import ( "encoding/json" "net/http" - - "github.com/fosrl/newt/logger" ) // registerRoutes registers all API routes. Add new endpoints here. @@ -42,40 +40,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(healthResponse{Status: "ok"}) } -// ProcessConnection runs the same logic as POST /connection: CA cert, sshd config, user create/reconcile, principals. -// Use this when DisableHTTPS is true (e.g. embedded in Newt) instead of calling the API. -func (s *Server) ProcessConnection(req ConnectionRequest) { - logger.Info("connection: niceId=%q username=%q metadata.sudo=%v metadata.homedir=%v", - req.NiceId, req.Username, req.Metadata.Sudo, req.Metadata.Homedir) - - cfg := &s.cfg - if cfg.CACertPath != "" { - if err := writeCACertIfNotExists(cfg.CACertPath, req.CaCert); err != nil { - logger.Warn("auth-daemon: write CA cert: %v", err) - } - sshdConfig := cfg.SSHDConfigPath - if sshdConfig == "" { - sshdConfig = "/etc/ssh/sshd_config" - } - if err := ensureSSHDTrustedUserCAKeys(sshdConfig, cfg.CACertPath); err != nil { - logger.Warn("auth-daemon: sshd_config: %v", err) - } - if cfg.ReloadSSHCommand != "" { - if err := reloadSSHD(cfg.ReloadSSHCommand); err != nil { - logger.Warn("auth-daemon: reload sshd: %v", err) - } - } - } - if err := ensureUser(req.Username, req.Metadata); err != nil { - logger.Warn("auth-daemon: ensure user: %v", err) - } - if cfg.PrincipalsFilePath != "" { - if err := writePrincipals(cfg.PrincipalsFilePath, req.Username, req.NiceId); err != nil { - logger.Warn("auth-daemon: write principals: %v", err) - } - } -} - // handleConnection accepts POST with connection payload and delegates to ProcessConnection. func (s *Server) handleConnection(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/authdaemon/server.go b/authdaemon/server.go index 83cb480..78aa908 100644 --- a/authdaemon/server.go +++ b/authdaemon/server.go @@ -24,12 +24,11 @@ import ( type Config struct { // DisableHTTPS: when true, Run() does not start the HTTPS server (for embedded use inside Newt). Call ProcessConnection directly for connection events. DisableHTTPS bool - Port int // Listen port for the HTTPS server. Required when DisableHTTPS is false. - PresharedKey string // Required when DisableHTTPS is false; used for HTTP auth (Authorization: Bearer or X-Preshared-Key: ). - CACertPath string // Where to write the CA cert (e.g. /etc/ssh/ca.pem). - SSHDConfigPath string // Path to sshd_config (e.g. /etc/ssh/sshd_config). Defaults to /etc/ssh/sshd_config when CACertPath is set. - ReloadSSHCommand string // Command to reload sshd after config change (e.g. "systemctl reload sshd"). Empty = no reload. - PrincipalsFilePath string // Path to the principals data file (JSON: username -> array of principals). Empty = do not store principals. + Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default. + PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer or X-Preshared-Key: ). No default. + CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default. + Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false. + PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default. } type Server struct { @@ -98,18 +97,24 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { }) } -// NewServer builds a new auth-daemon server from cfg. When DisableHTTPS is false, PresharedKey and Port are required. +// NewServer builds a new auth-daemon server from cfg. Port, PresharedKey, CACertPath, and PrincipalsFilePath are required (no defaults). func NewServer(cfg Config) (*Server, error) { if runtime.GOOS != "linux" { return nil, fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS) } if !cfg.DisableHTTPS { - if cfg.PresharedKey == "" { - return nil, fmt.Errorf("preshared key is required when HTTPS is enabled") - } if cfg.Port <= 0 { - return nil, fmt.Errorf("port must be positive when HTTPS is enabled") + return nil, fmt.Errorf("port is required and must be positive") } + if cfg.PresharedKey == "" { + return nil, fmt.Errorf("preshared key is required") + } + } + if cfg.CACertPath == "" { + return nil, fmt.Errorf("CACertPath is required") + } + if cfg.PrincipalsFilePath == "" { + return nil, fmt.Errorf("PrincipalsFilePath is required") } s := &Server{cfg: cfg} if !cfg.DisableHTTPS { From 759e4c5bac7afa28420e85889edae282adee0cd5 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 17 Feb 2026 14:42:37 -0800 Subject: [PATCH 6/9] Add daemon into newt --- authdaemon.go | 151 +++++++++++++++++++++++ authdaemon/host_linux.go | 2 +- main.go | 257 +++++++++++++++++++++++++-------------- 3 files changed, 319 insertions(+), 91 deletions(-) create mode 100644 authdaemon.go diff --git a/authdaemon.go b/authdaemon.go new file mode 100644 index 0000000..5de92ad --- /dev/null +++ b/authdaemon.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + + "github.com/fosrl/newt/authdaemon" + "github.com/fosrl/newt/logger" +) + +const ( + defaultPrincipalsPath = "/var/run/auth-daemon/principals" + defaultCACertPath = "/etc/ssh/ca.pem" +) + +var ( + errPresharedKeyRequired = errors.New("auth-daemon-key is required when --auth-daemon is enabled") + errRootRequired = errors.New("auth-daemon must be run as root (use sudo)") + authDaemonServer *authdaemon.Server // Global auth daemon server instance +) + +// startAuthDaemon initializes and starts the auth daemon in the background. +// It validates requirements (Linux, root, preshared key) and starts the server +// in a goroutine so it runs alongside normal newt operation. +func startAuthDaemon(ctx context.Context) error { + // Validation + if runtime.GOOS != "linux" { + return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS) + } + if os.Geteuid() != 0 { + return errRootRequired + } + + // Use defaults if not set + principalsFile := authDaemonPrincipalsFile + if principalsFile == "" { + principalsFile = defaultPrincipalsPath + } + caCertPath := authDaemonCACertPath + if caCertPath == "" { + caCertPath = defaultCACertPath + } + + // Create auth daemon server + cfg := authdaemon.Config{ + DisableHTTPS: true, // We run without HTTP server in newt + PresharedKey: "this-key-is-not-used", // Not used in embedded mode, but set to non-empty to satisfy validation + PrincipalsFilePath: principalsFile, + CACertPath: caCertPath, + Force: true, + } + + srv, err := authdaemon.NewServer(cfg) + if err != nil { + return fmt.Errorf("create auth daemon server: %w", err) + } + + authDaemonServer = srv + + // Start the auth daemon in a goroutine so it runs alongside newt + go func() { + logger.Info("Auth daemon starting (native mode, no HTTP server)") + if err := srv.Run(ctx); err != nil { + logger.Error("Auth daemon error: %v", err) + } + logger.Info("Auth daemon stopped") + }() + + return nil +} + + + +// runPrincipalsCmd executes the principals subcommand logic +func runPrincipalsCmd(args []string) { + opts := struct { + PrincipalsFile string + Username string + }{ + PrincipalsFile: defaultPrincipalsPath, + } + + // Parse flags manually + for i := 0; i < len(args); i++ { + switch args[i] { + case "--principals-file": + if i+1 >= len(args) { + fmt.Fprintf(os.Stderr, "Error: --principals-file requires a value\n") + os.Exit(1) + } + opts.PrincipalsFile = args[i+1] + i++ + case "--username": + if i+1 >= len(args) { + fmt.Fprintf(os.Stderr, "Error: --username requires a value\n") + os.Exit(1) + } + opts.Username = args[i+1] + i++ + case "--help", "-h": + printPrincipalsHelp() + os.Exit(0) + default: + fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n", args[i]) + printPrincipalsHelp() + os.Exit(1) + } + } + + // Validation + if opts.Username == "" { + fmt.Fprintf(os.Stderr, "Error: username is required\n") + printPrincipalsHelp() + os.Exit(1) + } + + // Get principals + list, err := authdaemon.GetPrincipals(opts.PrincipalsFile, opts.Username) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if len(list) == 0 { + fmt.Println("") + return + } + for _, principal := range list { + fmt.Println(principal) + } +} + +func printPrincipalsHelp() { + fmt.Fprintf(os.Stderr, `Usage: newt principals [flags] + +Output principals for a username (for AuthorizedPrincipalsCommand in sshd_config). +Read the principals file and print principals that match the given username, one per line. +Configure in sshd_config with AuthorizedPrincipalsCommand and %%u for the username. + +Flags: + --principals-file string Path to the principals file (default "%s") + --username string Username to look up (required) + --help, -h Show this help message + +Example: + newt principals --username alice + +`, defaultPrincipalsPath) +} \ No newline at end of file diff --git a/authdaemon/host_linux.go b/authdaemon/host_linux.go index 82834f3..76f8712 100644 --- a/authdaemon/host_linux.go +++ b/authdaemon/host_linux.go @@ -138,7 +138,7 @@ func ensureUser(username string, meta ConnectionMetadata) error { } func createUser(username string, meta ConnectionMetadata) error { - args := []string{} + args := []string{"-s", "/bin/bash"} if meta.Homedir { args = append(args, "-m") } else { diff --git a/main.go b/main.go index 24fd8bb..3ea52d4 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "syscall" "time" + "github.com/fosrl/newt/authdaemon" "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" @@ -132,6 +133,9 @@ var ( healthMonitor *healthcheck.Monitor enforceHealthcheckCert bool authDaemonKey string + authDaemonPrincipalsFile string + authDaemonCACertPath string + authDaemonEnabled bool // Build/version (can be overridden via -ldflags "-X main.newtVersion=...") newtVersion = "version_replaceme" @@ -154,6 +158,28 @@ var ( ) func main() { + // Check for subcommands first (only principals exits early) + if len(os.Args) > 1 { + switch os.Args[1] { + case "auth-daemon": + // Run principals subcommand only if the next argument is "principals" + if len(os.Args) > 2 && os.Args[2] == "principals" { + runPrincipalsCmd(os.Args[3:]) + return + } + + // auth-daemon subcommand without "principals" - show help + fmt.Println("Error: auth-daemon subcommand requires 'principals' argument") + fmt.Println() + fmt.Println("Usage:") + fmt.Println(" newt auth-daemon principals [options]") + fmt.Println() + + // If not "principals", exit the switch to continue with normal execution + return + } + } + // Check if we're running as a Windows service if isWindowsService() { runService("NewtWireguardService", false, os.Args[1:]) @@ -185,7 +211,10 @@ func runNewtMain(ctx context.Context) { updownScript = os.Getenv("UPDOWN_SCRIPT") interfaceName = os.Getenv("INTERFACE") portStr := os.Getenv("PORT") - authDaemonKey = os.Getenv("AUTH_DAEMON_KEY") + authDaemonKey = os.Getenv("AD_KEY") + authDaemonPrincipalsFile = os.Getenv("AD_PRINCIPALS_FILE") + authDaemonCACertPath = os.Getenv("AD_CA_CERT_PATH") + authDaemonEnabledEnv := os.Getenv("AUTH_DAEMON_ENABLED") // Metrics/observability env mirrors metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED") @@ -374,9 +403,22 @@ func runNewtMain(ctx context.Context) { region = regionEnv } - // Auth daemon key flag + // Auth daemon flags if authDaemonKey == "" { - flag.StringVar(&authDaemonKey, "auth-daemon-key", "", "Preshared key for auth daemon authentication") + flag.StringVar(&authDaemonKey, "ad-preshared-key", "", "Preshared key for auth daemon authentication (required when --auth-daemon is true)") + } + if authDaemonPrincipalsFile == "" { + flag.StringVar(&authDaemonPrincipalsFile, "ad-principals-file", "/var/run/auth-daemon/principals", "Path to the principals file for auth daemon") + } + if authDaemonCACertPath == "" { + flag.StringVar(&authDaemonCACertPath, "ad-ca-cert-path", "/etc/ssh/ca.pem", "Path to the CA certificate file for auth daemon") + } + if authDaemonEnabledEnv == "" { + flag.BoolVar(&authDaemonEnabled, "auth-daemon", false, "Enable auth daemon mode (runs alongside normal newt operation)") + } else { + if v, err := strconv.ParseBool(authDaemonEnabledEnv); err == nil { + authDaemonEnabled = v + } } // do a --version check @@ -398,6 +440,13 @@ func runNewtMain(ctx context.Context) { logger.Init(nil) loggerLevel := util.ParseLogLevel(logLevel) + + // Start auth daemon if enabled + if authDaemonEnabled { + if err := startAuthDaemon(ctx); err != nil { + logger.Fatal("Failed to start auth daemon: %v", err) + } + } logger.GetLogger().SetLevel(loggerLevel) // Initialize telemetry after flags are parsed (so flags override env) @@ -1329,7 +1378,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // Define the structure of the incoming message type SSHCertData struct { - MessageId string `json:"messageId"` + MessageId int `json:"messageId"` AgentPort int `json:"agentPort"` AgentHost string `json:"agentHost"` CACert string `json:"caCert"` @@ -1348,109 +1397,137 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return } + // print the received data for debugging + logger.Debug("Received SSH cert data: %s", string(jsonData)) + if err := json.Unmarshal(jsonData, &certData); err != nil { logger.Error("Error unmarshaling SSH cert data: %v", err) return } - // Check if auth daemon key is configured - if authDaemonKey == "" { - logger.Error("Auth daemon key not configured, cannot process SSH certificate") - // Send failure response back to cloud - err := client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": "auth daemon key not configured", - }) - if err != nil { - logger.Error("Failed to send SSH cert failure response: %v", err) - } - return - } + // Check if we're running the auth daemon internally + if authDaemonServer != nil { + // Call ProcessConnection directly when running internally + logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) - // Prepare the request body for the auth daemon - requestBody := map[string]interface{}{ - "caCert": certData.CACert, - "niceId": certData.NiceID, - "username": certData.Username, - "metadata": map[string]interface{}{ - "sudo": certData.Metadata.Sudo, - "homedir": certData.Metadata.Homedir, - }, - } - - requestJSON, err := json.Marshal(requestBody) - if err != nil { - logger.Error("Failed to marshal auth daemon request: %v", err) - // Send failure response - client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": fmt.Sprintf("failed to marshal request: %v", err), - }) - return - } - - // Create HTTPS client that skips certificate verification - // (auth daemon uses self-signed cert) - httpClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, + authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ + CaCert: certData.CACert, + NiceId: certData.NiceID, + Username: certData.Username, + Metadata: authdaemon.ConnectionMetadata{ + Sudo: certData.Metadata.Sudo, + Homedir: certData.Metadata.Homedir, }, - }, - Timeout: 10 * time.Second, - } - - // Make the request to the auth daemon - url := fmt.Sprintf("https://%s:%d/connection", certData.AgentHost, certData.AgentPort) - req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON)) - if err != nil { - logger.Error("Failed to create auth daemon request: %v", err) - client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": fmt.Sprintf("failed to create request: %v", err), }) - return - } - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+authDaemonKey) - - logger.Debug("Sending SSH cert to auth daemon at %s", url) - - // Send the request - resp, err := httpClient.Do(req) - if err != nil { - logger.Error("Failed to connect to auth daemon: %v", err) - client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + // Send success response back to cloud + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ "messageId": certData.MessageId, - "complete": true, - "error": fmt.Sprintf("failed to connect to auth daemon: %v", err), + "complete": true, }) - return - } - defer resp.Body.Close() - // Check response status - if resp.StatusCode != http.StatusOK { - logger.Error("Auth daemon returned non-OK status: %d", resp.StatusCode) - client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - "error": fmt.Sprintf("auth daemon returned status %d", resp.StatusCode), - }) - return - } + logger.Info("Successfully processed connection via internal auth daemon for user %s", certData.Username) + } else { + // External auth daemon mode - make HTTP request + // Check if auth daemon key is configured + if authDaemonKey == "" { + logger.Error("Auth daemon key not configured, cannot communicate with daemon") + // Send failure response back to cloud + err := client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": "auth daemon key not configured", + }) + if err != nil { + logger.Error("Failed to send SSH cert failure response: %v", err) + } + return + } - logger.Info("Successfully registered SSH certificate with auth daemon for user %s", certData.Username) + // Prepare the request body for the auth daemon + requestBody := map[string]interface{}{ + "caCert": certData.CACert, + "niceId": certData.NiceID, + "username": certData.Username, + "metadata": map[string]interface{}{ + "sudo": certData.Metadata.Sudo, + "homedir": certData.Metadata.Homedir, + }, + } + + requestJSON, err := json.Marshal(requestBody) + if err != nil { + logger.Error("Failed to marshal auth daemon request: %v", err) + // Send failure response + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("failed to marshal request: %v", err), + }) + return + } + + // Create HTTPS client that skips certificate verification + // (auth daemon uses self-signed cert) + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + Timeout: 10 * time.Second, + } + + // Make the request to the auth daemon + url := fmt.Sprintf("https://%s:%d/connection", certData.AgentHost, certData.AgentPort) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON)) + if err != nil { + logger.Error("Failed to create auth daemon request: %v", err) + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("failed to create request: %v", err), + }) + return + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+authDaemonKey) + + logger.Debug("Sending SSH cert to auth daemon at %s", url) + + // Send the request + resp, err := httpClient.Do(req) + if err != nil { + logger.Error("Failed to connect to auth daemon: %v", err) + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("failed to connect to auth daemon: %v", err), + }) + return + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + logger.Error("Auth daemon returned non-OK status: %d", resp.StatusCode) + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("auth daemon returned status %d", resp.StatusCode), + }) + return + } + + logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) + } // Send success response back to cloud err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ "messageId": certData.MessageId, - "complete": true, + "complete": true, }) if err != nil { logger.Error("Failed to send SSH cert success response: %v", err) From 60dac985144b19f1f338934b2967988398eb207f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 17 Feb 2026 21:01:10 -0800 Subject: [PATCH 7/9] fix flag --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 3ea52d4..42dfb99 100644 --- a/main.go +++ b/main.go @@ -405,7 +405,7 @@ func runNewtMain(ctx context.Context) { // Auth daemon flags if authDaemonKey == "" { - flag.StringVar(&authDaemonKey, "ad-preshared-key", "", "Preshared key for auth daemon authentication (required when --auth-daemon is true)") + flag.StringVar(&authDaemonKey, "ad-pre-shared-key", "", "Pre-shared key for auth daemon authentication") } if authDaemonPrincipalsFile == "" { flag.StringVar(&authDaemonPrincipalsFile, "ad-principals-file", "/var/run/auth-daemon/principals", "Path to the principals file for auth daemon") From 5d04be92f79d5e4d66f2ce184efa538874914f37 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 17 Feb 2026 22:36:28 -0800 Subject: [PATCH 8/9] Allow sudo passwordless --- authdaemon/host_linux.go | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/authdaemon/host_linux.go b/authdaemon/host_linux.go index 76f8712..3fed489 100644 --- a/authdaemon/host_linux.go +++ b/authdaemon/host_linux.go @@ -158,10 +158,51 @@ func createUser(username string, meta ConnectionMetadata) error { } else { logger.Info("auth-daemon: added %s to %s", username, group) } + if err := configurePasswordlessSudo(username); err != nil { + logger.Warn("auth-daemon: configure passwordless sudo for %s: %v", username, err) + } } return nil } +// configurePasswordlessSudo creates a sudoers.d file to allow passwordless sudo for the user +func configurePasswordlessSudo(username string) error { + sudoersFile := filepath.Join("/etc/sudoers.d", fmt.Sprintf("90-pangolin-%s", username)) + content := fmt.Sprintf("# Created by newt auth-daemon\n%s ALL=(ALL) NOPASSWD:ALL\n", username) + + // Write to temp file first + tmpFile := sudoersFile + ".tmp" + if err := os.WriteFile(tmpFile, []byte(content), 0440); err != nil { + return fmt.Errorf("write temp sudoers file: %w", err) + } + + // Validate with visudo + cmd := exec.Command("visudo", "-c", "-f", tmpFile) + if out, err := cmd.CombinedOutput(); err != nil { + os.Remove(tmpFile) + return fmt.Errorf("visudo validation failed: %w (output: %s)", err, string(out)) + } + + // Move to final location + if err := os.Rename(tmpFile, sudoersFile); err != nil { + os.Remove(tmpFile) + return fmt.Errorf("move sudoers file: %w", err) + } + + logger.Info("auth-daemon: configured passwordless sudo for %s", username) + return nil +} + +// removePasswordlessSudo removes the sudoers.d file for the user +func removePasswordlessSudo(username string) { + sudoersFile := filepath.Join("/etc/sudoers.d", fmt.Sprintf("90-newt-%s", username)) + if err := os.Remove(sudoersFile); err != nil && !os.IsNotExist(err) { + logger.Warn("auth-daemon: remove passwordless sudo for %s: %v", username, err) + } else if err == nil { + logger.Info("auth-daemon: removed passwordless sudo for %s", username) + } +} + func mustAtoi(s string) int { n, _ := strconv.Atoi(s) return n @@ -189,6 +230,15 @@ func reconcileUser(u *user.User, meta ConnectionMetadata) error { logger.Info("auth-daemon: removed %s from %s", u.Username, group) } } + + // Configure passwordless sudo + if meta.Sudo { + if err := configurePasswordlessSudo(u.Username); err != nil { + logger.Warn("auth-daemon: configure passwordless sudo for %s: %v", u.Username, err) + } + } else { + removePasswordlessSudo(u.Username) + } if meta.Homedir && u.HomeDir != "" { if st, err := os.Stat(u.HomeDir); err != nil || !st.IsDir() { if err := os.MkdirAll(u.HomeDir, 0755); err != nil { From 556be90b7ed5a1ed871bda55c3ec2403f20aecae Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 20 Feb 2026 20:42:42 -0800 Subject: [PATCH 9/9] support sudo configuration and daemon mode --- authdaemon/connection.go | 4 +- authdaemon/host_linux.go | 237 +++++++++++++++++++++++++++------------ authdaemon/routes.go | 6 +- main.go | 35 +++--- 4 files changed, 194 insertions(+), 88 deletions(-) diff --git a/authdaemon/connection.go b/authdaemon/connection.go index 9d36e27..107d25d 100644 --- a/authdaemon/connection.go +++ b/authdaemon/connection.go @@ -7,8 +7,8 @@ import ( // ProcessConnection runs the same logic as POST /connection: CA cert, user create/reconcile, principals. // Use this when DisableHTTPS is true (e.g. embedded in Newt) instead of calling the API. func (s *Server) ProcessConnection(req ConnectionRequest) { - logger.Info("connection: niceId=%q username=%q metadata.sudo=%v metadata.homedir=%v", - req.NiceId, req.Username, req.Metadata.Sudo, req.Metadata.Homedir) + logger.Info("connection: niceId=%q username=%q metadata.sudoMode=%q metadata.sudoCommands=%v metadata.homedir=%v metadata.groups=%v", + req.NiceId, req.Username, req.Metadata.SudoMode, req.Metadata.SudoCommands, req.Metadata.Homedir, req.Metadata.Groups) cfg := &s.cfg if cfg.CACertPath != "" { diff --git a/authdaemon/host_linux.go b/authdaemon/host_linux.go index 3fed489..fef15b8 100644 --- a/authdaemon/host_linux.go +++ b/authdaemon/host_linux.go @@ -122,6 +122,55 @@ func sudoGroup() string { return "sudo" } +const skelDir = "/etc/skel" + +// copySkelInto copies files from srcDir (e.g. /etc/skel) into dstDir (e.g. user's home). +// Only creates files that don't already exist. All created paths are chowned to uid:gid. +func copySkelInto(srcDir, dstDir string, uid, gid int) { + entries, err := os.ReadDir(srcDir) + if err != nil { + if !os.IsNotExist(err) { + logger.Warn("auth-daemon: read %s: %v", srcDir, err) + } + return + } + for _, e := range entries { + name := e.Name() + src := filepath.Join(srcDir, name) + dst := filepath.Join(dstDir, name) + if e.IsDir() { + if st, err := os.Stat(dst); err == nil && st.IsDir() { + copySkelInto(src, dst, uid, gid) + continue + } + if err := os.MkdirAll(dst, 0755); err != nil { + logger.Warn("auth-daemon: mkdir %s: %v", dst, err) + continue + } + if err := os.Chown(dst, uid, gid); err != nil { + logger.Warn("auth-daemon: chown %s: %v", dst, err) + } + copySkelInto(src, dst, uid, gid) + continue + } + if _, err := os.Stat(dst); err == nil { + continue + } + data, err := os.ReadFile(src) + if err != nil { + logger.Warn("auth-daemon: read %s: %v", src, err) + continue + } + if err := os.WriteFile(dst, data, 0644); err != nil { + logger.Warn("auth-daemon: write %s: %v", dst, err) + continue + } + if err := os.Chown(dst, uid, gid); err != nil { + logger.Warn("auth-daemon: chown %s: %v", dst, err) + } + } +} + // ensureUser creates the system user if missing, or reconciles sudo and homedir to match meta. func ensureUser(username string, meta ConnectionMetadata) error { if username == "" { @@ -137,6 +186,43 @@ func ensureUser(username string, meta ConnectionMetadata) error { return reconcileUser(u, meta) } +// desiredGroups returns the exact list of supplementary groups the user should have: +// meta.Groups plus the sudo group when meta.SudoMode is "full" (deduped). +func desiredGroups(meta ConnectionMetadata) []string { + seen := make(map[string]struct{}) + var out []string + for _, g := range meta.Groups { + g = strings.TrimSpace(g) + if g == "" { + continue + } + if _, ok := seen[g]; ok { + continue + } + seen[g] = struct{}{} + out = append(out, g) + } + if meta.SudoMode == "full" { + sg := sudoGroup() + if _, ok := seen[sg]; !ok { + out = append(out, sg) + } + } + return out +} + +// setUserGroups sets the user's supplementary groups to exactly groups (local mirrors metadata). +// When groups is empty, clears all supplementary groups (usermod -G ""). +func setUserGroups(username string, groups []string) { + list := strings.Join(groups, ",") + cmd := exec.Command("usermod", "-G", list, username) + if out, err := cmd.CombinedOutput(); err != nil { + logger.Warn("auth-daemon: usermod -G %s: %v (output: %s)", list, err, string(out)) + } else { + logger.Info("auth-daemon: set %s supplementary groups to %s", username, list) + } +} + func createUser(username string, meta ConnectionMetadata) error { args := []string{"-s", "/bin/bash"} if meta.Homedir { @@ -150,56 +236,96 @@ func createUser(username string, meta ConnectionMetadata) error { return fmt.Errorf("useradd %s: %w (output: %s)", username, err, string(out)) } logger.Info("auth-daemon: created user %s (homedir=%v)", username, meta.Homedir) - if meta.Sudo { - group := sudoGroup() - cmd := exec.Command("usermod", "-aG", group, username) - if out, err := cmd.CombinedOutput(); err != nil { - logger.Warn("auth-daemon: usermod -aG %s %s: %v (output: %s)", group, username, err, string(out)) - } else { - logger.Info("auth-daemon: added %s to %s", username, group) + if meta.Homedir { + if u, err := user.Lookup(username); err == nil && u.HomeDir != "" { + uid, gid := mustAtoi(u.Uid), mustAtoi(u.Gid) + copySkelInto(skelDir, u.HomeDir, uid, gid) } + } + setUserGroups(username, desiredGroups(meta)) + switch meta.SudoMode { + case "full": if err := configurePasswordlessSudo(username); err != nil { logger.Warn("auth-daemon: configure passwordless sudo for %s: %v", username, err) } + case "commands": + if len(meta.SudoCommands) > 0 { + if err := configureSudoCommands(username, meta.SudoCommands); err != nil { + logger.Warn("auth-daemon: configure sudo commands for %s: %v", username, err) + } + } + default: + removeSudoers(username) } return nil } -// configurePasswordlessSudo creates a sudoers.d file to allow passwordless sudo for the user -func configurePasswordlessSudo(username string) error { - sudoersFile := filepath.Join("/etc/sudoers.d", fmt.Sprintf("90-pangolin-%s", username)) - content := fmt.Sprintf("# Created by newt auth-daemon\n%s ALL=(ALL) NOPASSWD:ALL\n", username) - - // Write to temp file first +const sudoersFilePrefix = "90-pangolin-" + +func sudoersPath(username string) string { + return filepath.Join("/etc/sudoers.d", sudoersFilePrefix+username) +} + +// writeSudoersFile writes content to the user's sudoers.d file and validates with visudo. +func writeSudoersFile(username, content string) error { + sudoersFile := sudoersPath(username) tmpFile := sudoersFile + ".tmp" if err := os.WriteFile(tmpFile, []byte(content), 0440); err != nil { return fmt.Errorf("write temp sudoers file: %w", err) } - - // Validate with visudo cmd := exec.Command("visudo", "-c", "-f", tmpFile) if out, err := cmd.CombinedOutput(); err != nil { os.Remove(tmpFile) return fmt.Errorf("visudo validation failed: %w (output: %s)", err, string(out)) } - - // Move to final location if err := os.Rename(tmpFile, sudoersFile); err != nil { os.Remove(tmpFile) return fmt.Errorf("move sudoers file: %w", err) } - + return nil +} + +// configurePasswordlessSudo creates a sudoers.d file to allow passwordless sudo for the user. +func configurePasswordlessSudo(username string) error { + content := fmt.Sprintf("# Created by Pangolin auth-daemon\n%s ALL=(ALL) NOPASSWD:ALL\n", username) + if err := writeSudoersFile(username, content); err != nil { + return err + } logger.Info("auth-daemon: configured passwordless sudo for %s", username) return nil } -// removePasswordlessSudo removes the sudoers.d file for the user -func removePasswordlessSudo(username string) { - sudoersFile := filepath.Join("/etc/sudoers.d", fmt.Sprintf("90-newt-%s", username)) +// configureSudoCommands creates a sudoers.d file allowing only the listed commands (NOPASSWD). +// Each command should be a full path (e.g. /usr/bin/systemctl). +func configureSudoCommands(username string, commands []string) error { + var b strings.Builder + b.WriteString("# Created by Pangolin auth-daemon (restricted commands)\n") + n := 0 + for _, c := range commands { + c = strings.TrimSpace(c) + if c == "" { + continue + } + fmt.Fprintf(&b, "%s ALL=(ALL) NOPASSWD: %s\n", username, c) + n++ + } + if n == 0 { + return fmt.Errorf("no valid sudo commands") + } + if err := writeSudoersFile(username, b.String()); err != nil { + return err + } + logger.Info("auth-daemon: configured restricted sudo for %s (%d commands)", username, len(commands)) + return nil +} + +// removeSudoers removes the sudoers.d file for the user. +func removeSudoers(username string) { + sudoersFile := sudoersPath(username) if err := os.Remove(sudoersFile); err != nil && !os.IsNotExist(err) { - logger.Warn("auth-daemon: remove passwordless sudo for %s: %v", username, err) + logger.Warn("auth-daemon: remove sudoers for %s: %v", username, err) } else if err == nil { - logger.Info("auth-daemon: removed passwordless sudo for %s", username) + logger.Info("auth-daemon: removed sudoers for %s", username) } } @@ -209,66 +335,37 @@ func mustAtoi(s string) int { } func reconcileUser(u *user.User, meta ConnectionMetadata) error { - group := sudoGroup() - inGroup, err := userInGroup(u.Username, group) - if err != nil { - logger.Warn("auth-daemon: check group %s: %v", group, err) - inGroup = false - } - if meta.Sudo && !inGroup { - cmd := exec.Command("usermod", "-aG", group, u.Username) - if out, err := cmd.CombinedOutput(); err != nil { - logger.Warn("auth-daemon: usermod -aG %s %s: %v (output: %s)", group, u.Username, err, string(out)) - } else { - logger.Info("auth-daemon: added %s to %s", u.Username, group) - } - } else if !meta.Sudo && inGroup { - cmd := exec.Command("gpasswd", "-d", u.Username, group) - if out, err := cmd.CombinedOutput(); err != nil { - logger.Warn("auth-daemon: gpasswd -d %s %s: %v (output: %s)", u.Username, group, err, string(out)) - } else { - logger.Info("auth-daemon: removed %s from %s", u.Username, group) - } - } - - // Configure passwordless sudo - if meta.Sudo { + setUserGroups(u.Username, desiredGroups(meta)) + switch meta.SudoMode { + case "full": if err := configurePasswordlessSudo(u.Username); err != nil { logger.Warn("auth-daemon: configure passwordless sudo for %s: %v", u.Username, err) } - } else { - removePasswordlessSudo(u.Username) + case "commands": + if len(meta.SudoCommands) > 0 { + if err := configureSudoCommands(u.Username, meta.SudoCommands); err != nil { + logger.Warn("auth-daemon: configure sudo commands for %s: %v", u.Username, err) + } + } else { + removeSudoers(u.Username) + } + default: + removeSudoers(u.Username) } if meta.Homedir && u.HomeDir != "" { + uid, gid := mustAtoi(u.Uid), mustAtoi(u.Gid) if st, err := os.Stat(u.HomeDir); err != nil || !st.IsDir() { if err := os.MkdirAll(u.HomeDir, 0755); err != nil { logger.Warn("auth-daemon: mkdir %s: %v", u.HomeDir, err) } else { - uid, gid := mustAtoi(u.Uid), mustAtoi(u.Gid) _ = os.Chown(u.HomeDir, uid, gid) + copySkelInto(skelDir, u.HomeDir, uid, gid) logger.Info("auth-daemon: created home %s for %s", u.HomeDir, u.Username) } + } else { + // Ensure .bashrc etc. exist (e.g. home existed but was empty or skel was minimal) + copySkelInto(skelDir, u.HomeDir, uid, gid) } } return nil } - -func userInGroup(username, groupName string) (bool, error) { - // getent group wheel returns "wheel:x:10:user1,user2" - cmd := exec.Command("getent", "group", groupName) - out, err := cmd.Output() - if err != nil { - return false, err - } - parts := strings.SplitN(strings.TrimSpace(string(out)), ":", 4) - if len(parts) < 4 { - return false, nil - } - members := strings.Split(parts[3], ",") - for _, m := range members { - if strings.TrimSpace(m) == username { - return true, nil - } - } - return false, nil -} diff --git a/authdaemon/routes.go b/authdaemon/routes.go index 2cccc54..8457c60 100644 --- a/authdaemon/routes.go +++ b/authdaemon/routes.go @@ -13,8 +13,10 @@ func (s *Server) registerRoutes() { // ConnectionMetadata is the metadata object in POST /connection. type ConnectionMetadata struct { - Sudo bool `json:"sudo"` - Homedir bool `json:"homedir"` + SudoMode string `json:"sudoMode"` // "none" | "full" | "commands" + SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands" + Homedir bool `json:"homedir"` + Groups []string `json:"groups"` // system groups to add the user to } // ConnectionRequest is the JSON body for POST /connection. diff --git a/main.go b/main.go index 42dfb99..5f5251f 100644 --- a/main.go +++ b/main.go @@ -1378,15 +1378,18 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // Define the structure of the incoming message type SSHCertData struct { - MessageId int `json:"messageId"` - AgentPort int `json:"agentPort"` - AgentHost string `json:"agentHost"` - CACert string `json:"caCert"` - Username string `json:"username"` - NiceID string `json:"niceId"` - Metadata struct { - Sudo bool `json:"sudo"` - Homedir bool `json:"homedir"` + MessageId int `json:"messageId"` + AgentPort int `json:"agentPort"` + AgentHost string `json:"agentHost"` + ExternalAuthDaemon bool `json:"externalAuthDaemon"` + CACert string `json:"caCert"` + Username string `json:"username"` + NiceID string `json:"niceId"` + Metadata struct { + SudoMode string `json:"sudoMode"` + SudoCommands []string `json:"sudoCommands"` + Homedir bool `json:"homedir"` + Groups []string `json:"groups"` } `json:"metadata"` } @@ -1406,7 +1409,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } // Check if we're running the auth daemon internally - if authDaemonServer != nil { + if authDaemonServer != nil && !certData.ExternalAuthDaemon { // if the auth daemon is running internally and the external auth daemon is not enabled // Call ProcessConnection directly when running internally logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) @@ -1415,8 +1418,10 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( NiceId: certData.NiceID, Username: certData.Username, Metadata: authdaemon.ConnectionMetadata{ - Sudo: certData.Metadata.Sudo, - Homedir: certData.Metadata.Homedir, + SudoMode: certData.Metadata.SudoMode, + SudoCommands: certData.Metadata.SudoCommands, + Homedir: certData.Metadata.Homedir, + Groups: certData.Metadata.Groups, }, }) @@ -1450,8 +1455,10 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( "niceId": certData.NiceID, "username": certData.Username, "metadata": map[string]interface{}{ - "sudo": certData.Metadata.Sudo, - "homedir": certData.Metadata.Homedir, + "sudoMode": certData.Metadata.SudoMode, + "sudoCommands": certData.Metadata.SudoCommands, + "homedir": certData.Metadata.Homedir, + "groups": certData.Metadata.Groups, }, }