feat: terminal as a chosen OS user, create users from the panel (0.0.2)
- 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
This commit is contained in:
1 parent
be7fbabd9e
commit
3dde07f403
18 files changed
+530
-45
No files matched your search
@@ -120,6 +120,7 @@ func (a *Agent) enroll(ctx context.Context) error {
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
Version: Version,
|
||||
User: currentUser(),
|
||||
})
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
|
||||
req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret)
|
||||
@@ -145,11 +146,12 @@ func (a *Agent) enroll(ctx context.Context) error {
|
||||
func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
||||
a.authHeaders(req)
|
||||
// Report the version occasionally (not every poll), so the panel can flag
|
||||
// outdated agents without extra traffic.
|
||||
// Report the version and our OS user occasionally (not every poll), so the
|
||||
// panel can flag outdated agents without extra traffic.
|
||||
a.pollCount++
|
||||
if a.pollCount%20 == 1 {
|
||||
req.Header.Set("X-Agent-Version", Version)
|
||||
req.Header.Set("X-Agent-User", currentUser())
|
||||
}
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -169,7 +171,7 @@ func (a *Agent) pollOnce(ctx context.Context) error {
|
||||
// Dispatch in the background so we keep polling for more work.
|
||||
switch msg.Type {
|
||||
case proto.CtrlOpenTerminal:
|
||||
go a.runTerminal(ctx, msg.SessionID)
|
||||
go a.runTerminal(ctx, msg.SessionID, msg.User)
|
||||
case proto.CtrlOpenForward:
|
||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||
case proto.CtrlTask:
|
||||
|
||||
@@ -15,14 +15,17 @@ import (
|
||||
)
|
||||
|
||||
// runTerminal spawns a PTY and bridges it to the session.
|
||||
func (a *Agent) runTerminal(parent context.Context, sid string) {
|
||||
term, err := StartPTY()
|
||||
// user is the OS user to run the shell as ("" = the agent's own user).
|
||||
func (a *Agent) runTerminal(parent context.Context, sid, user string) {
|
||||
term, err := StartPTY(user)
|
||||
if err != nil {
|
||||
log.Printf("term %s: start pty: %v", sid, err)
|
||||
// Show the reason in the browser terminal before closing it.
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Data: b64.EncodeToString([]byte("lpra: " + err.Error() + "\r\n"))})
|
||||
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
|
||||
return
|
||||
}
|
||||
log.Printf("term %s: shell started", sid)
|
||||
log.Printf("term %s: shell started (user %q)", sid, user)
|
||||
a.bridgeStream(parent, sid, term, term.Resize)
|
||||
log.Printf("term %s: ended", sid)
|
||||
}
|
||||
|
||||
@@ -54,9 +54,10 @@ func (a *Agent) postResult(ctx context.Context, taskID string, res proto.TaskRes
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// execTask runs a single file-manager operation natively and returns a result.
|
||||
// execTask runs a single file-manager or user-management operation natively
|
||||
// and returns a result.
|
||||
func (a *Agent) execTask(env proto.TaskEnvelope) proto.TaskResult {
|
||||
data, err := dispatchFS(env)
|
||||
data, err := dispatchTask(env)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
@@ -13,14 +14,30 @@ type PTY struct {
|
||||
f *os.File
|
||||
}
|
||||
|
||||
// StartPTY launches the shell attached to a new PTY. The agent runs as root
|
||||
// (systemd User=root), so this is a root shell by design.
|
||||
func StartPTY() (*PTY, error) {
|
||||
shell := "/bin/bash"
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
shell = "/bin/sh"
|
||||
// StartPTY launches a login shell attached to a new PTY. With user empty (or
|
||||
// equal to the agent's own user) it is the agent's shell — root by default,
|
||||
// since the agent runs as a root systemd service. Any other user is entered via
|
||||
// `su -l`, which only a root agent can do without a password.
|
||||
func StartPTY(user string) (*PTY, error) {
|
||||
var cmd *exec.Cmd
|
||||
if user == "" || user == currentUser() {
|
||||
cmd = exec.Command(defaultShell(), "-l")
|
||||
} else {
|
||||
if !validUserName(user) {
|
||||
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return nil, fmt.Errorf("агент запущен от %s и не может открыть сессию от %s", currentUser(), user)
|
||||
}
|
||||
if !userExists(user) {
|
||||
return nil, fmt.Errorf("пользователь %s не найден", user)
|
||||
}
|
||||
su, err := exec.LookPath("su")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("su не найден: %w", err)
|
||||
}
|
||||
cmd = exec.Command(su, "-l", user)
|
||||
}
|
||||
cmd := exec.Command(shell, "-l")
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
f, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user