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 76f8712..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,75 +236,136 @@ 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 } +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) + } + 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)) + } + 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 +} + +// 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 sudoers for %s: %v", username, err) + } else if err == nil { + logger.Info("auth-daemon: removed sudoers for %s", username) + } +} + 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) + 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 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)) + 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 { - logger.Info("auth-daemon: removed %s from %s", u.Username, group) + 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, }, }