- agent reports the OS user it runs as (enroll + poll header) - per-machine terminal user on the edit page; defaults to the agent's own user (root on Linux) - create a new user from the panel: name, password, optional sudo group - show the reason in the terminal when a shell fails to start - bump version to 0.0.2
236 lines
6.2 KiB
Go
236 lines
6.2 KiB
Go
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
|
|
}
|