From 3dde07f4034ae3500da3e539947e52f6ebafe05e Mon Sep 17 00:00:00 2001 From: iRedTea Date: Tue, 13 Oct 2026 01:28:57 +0300 Subject: [PATCH] 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 --- README.md | 10 +- VERSION.txt | 2 +- internal/agent/client.go | 8 +- internal/agent/session.go | 9 +- internal/agent/task.go | 5 +- internal/agent/terminal.go | 31 ++- internal/agent/users.go | 235 ++++++++++++++++++++ internal/proto/proto.go | 20 +- internal/server/agents.go | 3 +- internal/server/db.go | 41 +++- internal/server/handlers_agent.go | 17 +- internal/server/handlers_panel.go | 128 ++++++++++- internal/server/handlers_panel_term.go | 4 +- internal/server/hub.go | 16 +- internal/server/server.go | 2 + internal/server/web/static/app.css | 2 +- internal/server/web/templates/edit.html | 40 ++++ internal/server/web/templates/terminal.html | 2 +- 18 files changed, 530 insertions(+), 45 deletions(-) create mode 100644 internal/agent/users.go diff --git a/README.md b/README.md index 61af6a0..67a19c4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT). Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ -в веб-панели видит список машин и открывает по каждой **root-терминал в браузере**. +в веб-панели видит список машин и открывает по каждой **терминал в браузере** (по умолчанию от root, пользователя можно сменить). ``` браузер ──WS──> [nginx :8445 TLS] ──> [Go server 127.0.0.1:8091] <──long-poll── агент (root, за NAT) @@ -42,10 +42,16 @@ curl -fsSL https://localhost:8445/install.sh | sudo HOST=https://localhost:8445 curl -fsSL https://localhost:8445/install.sh | sudo sh ``` +## Пользователь терминала +По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux — root). +В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового +(имя, пароль, опционально — добавить в sudo). Смена и создание работают, только если агент запущен от root. +Файловый менеджер по-прежнему работает от пользователя агента. + ## Безопасность - Пароли админов — bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина. - Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time). -- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ). +- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ), как и смена/создание пользователя. ## Скриншоты image diff --git a/VERSION.txt b/VERSION.txt index 8acdd82..4e379d2 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.0.1 +0.0.2 diff --git a/internal/agent/client.go b/internal/agent/client.go index eb9ea07..894adec 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -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: diff --git a/internal/agent/session.go b/internal/agent/session.go index 101343f..5b9d07e 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -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) } diff --git a/internal/agent/task.go b/internal/agent/task.go index c7f67fc..d5b9de6 100644 --- a/internal/agent/task.go +++ b/internal/agent/task.go @@ -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()} } diff --git a/internal/agent/terminal.go b/internal/agent/terminal.go index b6aefd8..a43cbca 100644 --- a/internal/agent/terminal.go +++ b/internal/agent/terminal.go @@ -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 { diff --git a/internal/agent/users.go b/internal/agent/users.go new file mode 100644 index 0000000..c233b81 --- /dev/null +++ b/internal/agent/users.go @@ -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 +} diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 88aaf66..d8662a1 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -27,6 +27,7 @@ type EnrollRequest struct { OS string `json:"os"` Arch string `json:"arch"` Version string `json:"version"` + User string `json:"user,omitempty"` // OS user the agent runs as } // EnrollResponse carries the identity the agent persists and uses from then on. @@ -41,6 +42,7 @@ type ControlMessage struct { SessionID string `json:"session_id,omitempty"` Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial TaskID string `json:"task_id,omitempty"` // CtrlTask + User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own) } // Terminal / stream frame types on the session "in" channel (server -> agent). @@ -84,7 +86,9 @@ const ( TaskFSMove = "fs.move" // move/rename TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download - TaskSysUpdate = "sys.update" // download the latest agent binary and restart + TaskSysUpdate = "sys.update" // download the latest agent binary and restart + TaskSysUsers = "sys.users" // report the agent's user and the login-capable users + TaskSysUserAdd = "sys.useradd" // create a new OS user ) // TaskEnvelope is what the agent fetches for a task id. @@ -144,3 +148,17 @@ type FSReadResult struct { Truncated bool `json:"truncated"` Binary bool `json:"binary"` } + +// SysUsersResult is returned for TaskSysUsers. +type SysUsersResult struct { + Current string `json:"current"` // user the agent runs as + CanSwitch bool `json:"can_switch"` // agent is root, so it can open shells as (and create) other users + Users []string `json:"users"` +} + +// SysUserAddArgs are the arguments of TaskSysUserAdd. +type SysUserAddArgs struct { + Name string `json:"name"` + Password string `json:"password,omitempty"` // empty leaves the account without a password + Sudo bool `json:"sudo"` // add to the sudo/wheel group +} diff --git a/internal/server/agents.go b/internal/server/agents.go index 44a7427..697ed23 100644 --- a/internal/server/agents.go +++ b/internal/server/agents.go @@ -34,7 +34,7 @@ func bearerToken(r *http.Request) string { // enroll creates a new agent record for a valid enroll secret and returns the // generated id plus a one-time plaintext token (only the hash is stored). -func (s *Server) enroll(name, hostname, os, arch, version string) (id, token string, err error) { +func (s *Server) enroll(name, hostname, os, arch, version, runUser string) (id, token string, err error) { id = randToken()[:16] token = randToken() a := Agent{ @@ -44,6 +44,7 @@ func (s *Server) enroll(name, hostname, os, arch, version string) (id, token str OS: os, Arch: arch, Version: version, + RunUser: runUser, TokenHash: hashToken(token), } if err = s.db.CreateAgent(a); err != nil { diff --git a/internal/server/db.go b/internal/server/db.go index 3e46c03..aec39db 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -59,6 +59,8 @@ CREATE TABLE IF NOT EXISTS agents ( arch TEXT NOT NULL, token_hash TEXT NOT NULL, version TEXT NOT NULL DEFAULT '', + run_user TEXT NOT NULL DEFAULT '', + login_user TEXT NOT NULL DEFAULT '', enrolled_at INTEGER NOT NULL, last_seen INTEGER NOT NULL ); @@ -101,6 +103,8 @@ CREATE TABLE IF NOT EXISTS forward_rules ( `ALTER TABLE forward_rules ADD COLUMN proto TEXT NOT NULL DEFAULT 'http'`, `ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`, `ALTER TABLE ufw_rules ADD COLUMN rule_id INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`, } { if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") { return err @@ -222,9 +226,18 @@ type Agent struct { Arch string TokenHash string Version string + RunUser string // OS user the agent process runs as (reported by the agent) + LoginUser string // OS user terminals are opened as; "" = RunUser LastSeen time.Time } +// TerminalUser is the OS user a terminal on this agent runs as. Agents are +// installed as a root systemd service, so that is the fallback for agents too +// old to report their user. +func (a Agent) TerminalUser() string { + return firstNonEmpty(a.LoginUser, a.RunUser, "root") +} + // Online reports whether the agent polled recently enough to be considered up. func (a Agent) Online() bool { return time.Since(a.LastSeen) < 45*time.Second @@ -233,9 +246,9 @@ func (a Agent) Online() bool { func (db *DB) CreateAgent(a Agent) error { now := time.Now().Unix() _, err := db.sql.Exec( - `INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, enrolled_at, last_seen) - VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, - a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, now, now) + `INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, run_user, enrolled_at, last_seen) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, a.RunUser, now, now) return err } @@ -243,8 +256,8 @@ func (db *DB) GetAgent(id string) (*Agent, error) { a := &Agent{} var last int64 err := db.sql.QueryRow( - `SELECT id, name, hostname, os, arch, token_hash, version, last_seen FROM agents WHERE id = ?`, id). - Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &last) + `SELECT id, name, hostname, os, arch, token_hash, version, run_user, login_user, last_seen FROM agents WHERE id = ?`, id). + Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &last) if err == sql.ErrNoRows { return nil, nil } @@ -257,7 +270,7 @@ func (db *DB) GetAgent(id string) (*Agent, error) { func (db *DB) ListAgents() ([]Agent, error) { rows, err := db.sql.Query( - `SELECT id, name, hostname, os, arch, version, last_seen FROM agents ORDER BY name`) + `SELECT id, name, hostname, os, arch, version, run_user, login_user, last_seen FROM agents ORDER BY name`) if err != nil { return nil, err } @@ -266,7 +279,7 @@ func (db *DB) ListAgents() ([]Agent, error) { for rows.Next() { var a Agent var last int64 - if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.Version, &last); err != nil { + if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.Version, &a.RunUser, &a.LoginUser, &last); err != nil { return nil, err } a.LastSeen = time.Unix(last, 0) @@ -280,10 +293,16 @@ func (db *DB) TouchAgent(id string) error { return err } -// TouchAgentVersion updates last_seen and the reported version together. -func (db *DB) TouchAgentVersion(id, version string) error { - _, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ? WHERE id = ?`, - time.Now().Unix(), version, id) +// TouchAgentInfo updates last_seen together with the reported version and OS user. +func (db *DB) TouchAgentInfo(id, version, runUser string) error { + _, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ?, run_user = ? WHERE id = ?`, + time.Now().Unix(), version, runUser, id) + return err +} + +// SetAgentLoginUser sets the OS user terminals are opened as ("" = the agent's own). +func (db *DB) SetAgentLoginUser(id, user string) error { + _, err := db.sql.Exec(`UPDATE agents SET login_user = ? WHERE id = ?`, user, id) return err } diff --git a/internal/server/handlers_agent.go b/internal/server/handlers_agent.go index c38dbba..25cd4ff 100644 --- a/internal/server/handlers_agent.go +++ b/internal/server/handlers_agent.go @@ -28,7 +28,7 @@ func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) return } - id, token, err := s.enroll(req.Name, req.Hostname, req.OS, req.Arch, req.Version) + id, token, err := s.enroll(req.Name, req.Hostname, req.OS, req.Arch, req.Version, req.User) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return @@ -43,9 +43,12 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - // The agent sends its version occasionally; record it when present. - if v := r.Header.Get("X-Agent-Version"); v != "" && v != a.Version { - _ = s.db.TouchAgentVersion(a.ID, v) + // The agent sends its version and OS user occasionally; record them when + // present. Agents older than 0.0.2 send no user, so keep what we have. + v := r.Header.Get("X-Agent-Version") + u := firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser) + if v != "" && (v != a.Version || u != a.RunUser) { + _ = s.db.TouchAgentInfo(a.ID, v, u) } else { _ = s.db.TouchAgent(a.ID) } @@ -121,7 +124,11 @@ func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) { return } if msg.Exit { - s.hub.CloseSession(sess.ID) + // End the stream now, but keep the session reachable for a moment so + // the panel can still collect the last output (e.g. why the shell + // failed to start). + sess.close() + time.AfterFunc(pollHold, func() { s.hub.CloseSession(sess.ID) }) } w.WriteHeader(http.StatusNoContent) } diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index 989b72c..e6869de 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -1,9 +1,13 @@ package server import ( + "encoding/json" + "errors" "fmt" "log" "net/http" + "net/url" + "slices" "strconv" "strings" "time" @@ -152,13 +156,135 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - s.render(w, "edit.html", map[string]any{ + data := map[string]any{ "Agent": a, "CSRF": csrfToken(r), "ServerVersion": Version, "Outdated": a.Version != "" && a.Version != Version, "Updated": r.URL.Query().Get("updated") == "1", + "UserOK": r.URL.Query().Get("uok"), + "UserErr": r.URL.Query().Get("uerr"), + } + // The user list lives on the machine, so it is only known while it is online. + if a.Online() { + if users, err := s.agentUsers(id); err == nil { + data["Users"] = users + } else { + data["UsersErr"] = err.Error() + } + } + s.render(w, "edit.html", data) +} + +// usersTaskTimeout bounds how long the edit page waits for the agent. +const usersTaskTimeout = 8 * time.Second + +// agentUsers asks the agent who it runs as and which users it can log in as. +func (s *Server) agentUsers(agentID string) (*proto.SysUsersResult, error) { + taskID, ch := s.hub.CreateTask(agentID, proto.TaskSysUsers, nil) + defer s.hub.DiscardTask(taskID) + select { + case res := <-ch: + if !res.OK { + // Agents older than 0.0.2 don't know this task. + return nil, errors.New("клиент не поддерживает выбор пользователя — обновите его") + } + var out proto.SysUsersResult + if err := json.Unmarshal(res.Data, &out); err != nil { + return nil, errors.New("некорректный ответ клиента") + } + return &out, nil + case <-time.After(usersTaskTimeout): + return nil, errors.New("таймаут ожидания клиента") + } +} + +// editUserRedirect returns to the edit page with a result banner. +func editUserRedirect(w http.ResponseWriter, r *http.Request, id, key, msg string) { + http.Redirect(w, r, "/agents/"+id+"/edit?"+key+"="+url.QueryEscape(msg), http.StatusSeeOther) +} + +// handleAgentSetUser picks the OS user terminals on this machine run as. +func (s *Server) handleAgentSetUser(w http.ResponseWriter, r *http.Request) { + if !checkCSRF(r) { + http.Error(w, "bad csrf", http.StatusForbidden) + return + } + id := r.PathValue("id") + a, err := s.db.GetAgent(id) + if err != nil || a == nil { + http.NotFound(w, r) + return + } + name := strings.TrimSpace(r.FormValue("user")) + if !a.Online() { + editUserRedirect(w, r, id, "uerr", "Машина офлайн — сменить пользователя нельзя.") + return + } + // Only accept a user the machine itself reports, so a typo (or an agent too + // old to honour the choice) can't silently leave the terminal as root. + users, err := s.agentUsers(id) + if err != nil { + editUserRedirect(w, r, id, "uerr", err.Error()) + return + } + if !slices.Contains(users.Users, name) { + editUserRedirect(w, r, id, "uerr", "Пользователя "+name+" нет на машине.") + return + } + if name == users.Current { + name = "" // follow the agent's own user + } + if err := s.db.SetAgentLoginUser(id, name); err != nil { + editUserRedirect(w, r, id, "uerr", "Не удалось сохранить.") + return + } + if admin := s.currentAdmin(r); admin != nil { + s.db.Audit(admin.ID, id, "set_user:"+firstNonEmpty(name, users.Current)) + } + editUserRedirect(w, r, id, "uok", "Терминал будет открываться от "+firstNonEmpty(name, users.Current)+".") +} + +// handleAgentAddUser creates a new OS user on the machine and switches the +// terminal to it. +func (s *Server) handleAgentAddUser(w http.ResponseWriter, r *http.Request) { + if !checkCSRF(r) { + http.Error(w, "bad csrf", http.StatusForbidden) + return + } + id := r.PathValue("id") + a, err := s.db.GetAgent(id) + if err != nil || a == nil { + http.NotFound(w, r) + return + } + if !a.Online() { + editUserRedirect(w, r, id, "uerr", "Машина офлайн — создать пользователя нельзя.") + return + } + name := strings.TrimSpace(r.FormValue("name")) + args, _ := json.Marshal(proto.SysUserAddArgs{ + Name: name, + Password: r.FormValue("password"), + Sudo: r.FormValue("sudo") == "1", }) + taskID, ch := s.hub.CreateTask(id, proto.TaskSysUserAdd, args) + defer s.hub.DiscardTask(taskID) + select { + case res := <-ch: + if !res.OK { + editUserRedirect(w, r, id, "uerr", "Не удалось создать пользователя: "+res.Error) + return + } + case <-time.After(30 * time.Second): + editUserRedirect(w, r, id, "uerr", "Таймаут ожидания клиента.") + return + } + _ = s.db.SetAgentLoginUser(id, name) + if admin := s.currentAdmin(r); admin != nil { + s.db.Audit(admin.ID, id, "create_user:"+name) + } + editUserRedirect(w, r, id, "uok", "Пользователь "+name+" создан и выбран для терминала.") } // handleAgentUpdate asks the agent to download the latest binary and restart. diff --git a/internal/server/handlers_panel_term.go b/internal/server/handlers_panel_term.go index 559f808..7a8128b 100644 --- a/internal/server/handlers_panel_term.go +++ b/internal/server/handlers_panel_term.go @@ -21,9 +21,9 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - sess := s.hub.CreateSession(agentID) + sess := s.hub.CreateSession(agentID, agent.LoginUser) if admin := s.currentAdmin(r); admin != nil { - s.db.Audit(admin.ID, agentID, "open_terminal") + s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser()) } writeJSON(w, map[string]string{"session_id": sess.ID}) } diff --git a/internal/server/hub.go b/internal/server/hub.go index 0ae1cb4..ff4c7cf 100644 --- a/internal/server/hub.go +++ b/internal/server/hub.go @@ -82,10 +82,11 @@ func (h *Hub) newSession(agentID string) *Session { return s } -// CreateSession registers a terminal session and queues it for the agent. -func (h *Hub) CreateSession(agentID string) *Session { +// CreateSession registers a terminal session and queues it for the agent. user +// is the OS user to run the shell as ("" = the agent's own user). +func (h *Hub) CreateSession(agentID, user string) *Session { s := h.newSession(agentID) - h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID}) + h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID, User: user}) return s } @@ -245,7 +246,14 @@ func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) { case <-time.After(timeout): return nil, true case <-s.closed: - return nil, false + // Hand over output queued before the close (e.g. the reason a shell + // failed to start) instead of dropping it. + select { + case m := <-s.out: + batch = append(batch, m) + default: + return nil, false + } } for { select { diff --git a/internal/server/server.go b/internal/server/server.go index 30ad1f0..2074e25 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -129,6 +129,8 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit)) mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename)) mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate)) + mux.Handle("POST /agents/{id}/user", s.requireAdmin(s.handleAgentSetUser)) + mux.Handle("POST /agents/{id}/users/add", s.requireAdmin(s.handleAgentAddUser)) mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete)) mux.Handle("GET /settings", s.requireAdmin(s.handleSettings)) mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost)) diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 06830e4..4040de0 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -34,7 +34,7 @@ header.topbar nav a:hover { color: var(--text); } h1 { font-size: 22px; margin: 0 0 18px; } h2 { font-size: 17px; margin: 0 0 14px; } label { display: block; margin: 12px 0 4px; color: var(--muted); font-size: 13px; } -input[type=text], input[type=password] { +input[type=text], input[type=password], select { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px; } diff --git a/internal/server/web/templates/edit.html b/internal/server/web/templates/edit.html index bddc391..62a1513 100644 --- a/internal/server/web/templates/edit.html +++ b/internal/server/web/templates/edit.html @@ -28,6 +28,46 @@

+
+

Пользователь

+ {{if .UserOK}}
{{.UserOK}}
{{end}} + {{if .UserErr}}
{{.UserErr}}
{{end}} +

+ Клиент запущен от: {{if .Users}}{{.Users.Current}}{{else if .Agent.RunUser}}{{.Agent.RunUser}}{{else}}неизвестно{{end}} + · Терминал открывается от: {{.Agent.TerminalUser}} +

+ {{if .Users}} +
+ + + + {{if .Users.CanSwitch}}{{end}} +
+ {{if .Users.CanSwitch}} + + {{else}} +

Клиент запущен не от root, поэтому сменить или создать пользователя нельзя.

+ {{end}} + {{else if .UsersErr}} +

Список пользователей недоступен: {{.UsersErr}}.

+ {{else}} +

Машина офлайн — сменить пользователя можно, когда она на связи.

+ {{end}} +
+

Версия клиента

{{if .Updated}}
Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.
{{end}} diff --git a/internal/server/web/templates/terminal.html b/internal/server/web/templates/terminal.html index ec2d3ec..8acf3e7 100644 --- a/internal/server/web/templates/terminal.html +++ b/internal/server/web/templates/terminal.html @@ -13,7 +13,7 @@
-
LPRA · Терминал — {{.Agent.Name}} ({{.Agent.Hostname}})
+
LPRA · Терминал — {{.Agent.Name}} ({{.Agent.TerminalUser}}@{{.Agent.Hostname}})