Files
newt/authdaemon/host_linux.go
2026-02-17 22:10:01 -08:00

225 lines
6.1 KiB
Go

//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. 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)
}
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
}
// 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{"-s", "/bin/bash"}
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
}