Status page per machine: CPU load, RAM and disk of the client, sampled by the agent only while the page is open and visible (one reading serves every open tab), plus a button that has the agent measure its link to the host: ping, down and up. Online/offline now comes from the control polls the hub holds open, so a dropped agent shows offline at once instead of 45 s later, and the dashboard keeps the status column live. A forward rule can be created "without ufw": the panel opens nothing in ufw for it and so revokes nothing when it is deleted. The checkbox is shown only while the ufw integration is on. Dashboard row actions are icons; a status action is added.
238 lines
6.2 KiB
Go
238 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.TaskSysStat:
|
|
return sysStat()
|
|
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
|
|
}
|