package agent import ( "bytes" "encoding/json" "errors" "fmt" "os" "os/exec" "os/user" "regexp" "runtime" "sort" "strconv" "strings" "github.com/x-grk/longpolling-remote-access/internal/proto" ) // userNameRe is the login name shape accepted for existing users; it keeps a // name from ever being parsed as an option by su. newUserNameRe is the stricter // shape useradd accepts everywhere. var ( userNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]{0,31}$`) newUserNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`) ) func validUserName(name string) bool { return userNameRe.MatchString(name) } // dispatchTask runs one task: user management here, everything else is a // file-manager operation. func dispatchTask(env proto.TaskEnvelope) (any, error) { switch env.Type { case proto.TaskSysUsers: return sysUsers(), nil case proto.TaskSysUserAdd: var a proto.SysUserAddArgs _ = json.Unmarshal(env.Args, &a) return nil, addUser(a) } return dispatchFS(env) } // currentUser is the OS user the agent process runs as. func currentUser() string { if u, err := user.Current(); err == nil && u.Username != "" { return u.Username } if os.Geteuid() == 0 { return "root" } if n := os.Getenv("USER"); n != "" { return n } return strconv.Itoa(os.Geteuid()) } func userExists(name string) bool { if _, err := user.Lookup(name); err == nil { return true } // Without cgo, os/user only reads /etc/passwd, which macOS doesn't use. if runtime.GOOS == "darwin" { for _, n := range darwinUsers() { if n == name { return true } } } return false } func defaultShell() string { if _, err := os.Stat("/bin/bash"); err == nil { return "/bin/bash" } return "/bin/sh" } // sysUsers reports who the agent is and which users a terminal can be opened as. func sysUsers() proto.SysUsersResult { cur := currentUser() res := proto.SysUsersResult{Current: cur, CanSwitch: os.Geteuid() == 0, Users: []string{cur}} if !res.CanSwitch { return res } seen := map[string]bool{cur: true} for _, n := range loginUsers() { if !seen[n] && validUserName(n) { seen[n] = true res.Users = append(res.Users, n) } } sort.Strings(res.Users[1:]) return res } // loginUsers lists root plus the regular (non-system) accounts with a real shell. func loginUsers() []string { if runtime.GOOS == "darwin" { return darwinUsers() } b, err := os.ReadFile("/etc/passwd") if err != nil { return nil } var out []string for _, line := range strings.Split(string(b), "\n") { // name:passwd:uid:gid:gecos:home:shell f := strings.Split(line, ":") if len(f) < 7 || strings.HasPrefix(line, "#") { continue } uid, err := strconv.Atoi(f[2]) if err != nil || (uid != 0 && (uid < 1000 || uid == 65534)) { continue } if sh := f[6]; strings.HasSuffix(sh, "/nologin") || strings.HasSuffix(sh, "/false") { continue } out = append(out, f[0]) } return out } func darwinUsers() []string { b, err := exec.Command("dscl", ".", "-list", "/Users", "UniqueID").Output() if err != nil { return nil } var out []string for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line) if len(f) != 2 { continue } if uid, err := strconv.Atoi(f[1]); err == nil && (uid == 0 || uid >= 500) { out = append(out, f[0]) } } return out } // addUser creates an OS user with a home directory, optionally sets its // password and adds it to the sudo group. func addUser(a proto.SysUserAddArgs) error { if runtime.GOOS != "linux" { return errors.New("создание пользователей поддерживается только на Linux") } if os.Geteuid() != 0 { return errors.New("агент запущен не от root и не может создавать пользователей") } if !newUserNameRe.MatchString(a.Name) { return errors.New("недопустимое имя: строчные латинские буквы, цифры, '_' и '-', до 32 символов") } if strings.ContainsAny(a.Password, "\r\n") { return errors.New("пароль не должен содержать перевод строки") } if userExists(a.Name) { return fmt.Errorf("пользователь %s уже существует", a.Name) } // Resolve the sudo group up front so we don't leave a half-configured user. group := "" if a.Sudo { if group = sudoGroup(); group == "" { return errors.New("на машине нет группы sudo или wheel") } } switch { case hasCmd("useradd"): if err := run(nil, "useradd", "-m", "-s", defaultShell(), a.Name); err != nil { return err } case hasCmd("adduser"): // BusyBox / Alpine if err := run(nil, "adduser", "-D", "-s", defaultShell(), a.Name); err != nil { return err } default: return errors.New("не найдены useradd/adduser") } if a.Password != "" { if err := run([]byte(a.Name+":"+a.Password+"\n"), "chpasswd"); err != nil { return fmt.Errorf("пользователь создан, но пароль не задан: %w", err) } } if group != "" { var err error switch { case hasCmd("usermod"): err = run(nil, "usermod", "-aG", group, a.Name) case hasCmd("gpasswd"): err = run(nil, "gpasswd", "-a", a.Name, group) default: // BusyBox: adduser USER GROUP err = run(nil, "adduser", a.Name, group) } if err != nil { return fmt.Errorf("пользователь создан, но не добавлен в %s: %w", group, err) } } return nil } // sudoGroup returns the group that grants sudo on this distro, or "". func sudoGroup() string { for _, g := range []string{"sudo", "wheel"} { if _, err := user.LookupGroup(g); err == nil { return g } } return "" } func hasCmd(name string) bool { _, err := exec.LookPath(name) return err == nil } // run executes a command (optionally feeding stdin) and folds its output into // the error so the panel can show why it failed. func run(stdin []byte, name string, args ...string) error { cmd := exec.Command(name, args...) if stdin != nil { cmd.Stdin = bytes.NewReader(stdin) } out, err := cmd.CombinedOutput() if err != nil { if msg := strings.TrimSpace(string(out)); msg != "" { return fmt.Errorf("%s: %s", name, msg) } return fmt.Errorf("%s: %w", name, err) } return nil }