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:
redtea committed 2026-10-13 01:28:57 +03:00
1 parent be7fbabd9e
commit 3dde07f403
18 files changed
+530 -45

No files matched your search

+5 -3
View File
@@ -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:
+6 -3
View File
@@ -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)
}
+3 -2
View File
@@ -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()}
}
+24 -7
View File
@@ -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 {
+235
View File
@@ -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
}