Files
lpra/internal/agent/users.go
T

244 lines
6.5 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.TaskSysStat:
return sysStat()
case proto.TaskSysUserAdd:
var a proto.SysUserAddArgs
_ = json.Unmarshal(env.Args, &a)
return nil, addUser(a)
case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL:
return dbTask(env)
case proto.TaskTermCwd:
var a proto.TermCwdArgs
_ = json.Unmarshal(env.Args, &a)
return termCwd(a.Session)
}
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
}