Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93673d3570 | ||
|
|
3dde07f403 |
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
|
Лёгкий self-hosted сервис для доступа к Linux-машинам **без белого IP** (за NAT).
|
||||||
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
|
Агент на клиенте сам исходяще подключается к серверу по HTTP long-polling; админ
|
||||||
в веб-панели видит список машин и открывает по каждой **root-терминал в браузере**.
|
в веб-панели видит список машин и открывает по каждой **терминал в браузере** (по умолчанию от root, пользователя можно сменить).
|
||||||
|
|
||||||
```
|
```
|
||||||
браузер ──WS──> [nginx :8445 TLS] ──> [Go server 127.0.0.1:8091] <──long-poll── агент (root, за NAT)
|
браузер ──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
|
curl -fsSL https://localhost:8445/install.sh | sudo sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Пользователь терминала
|
||||||
|
По умолчанию терминал открывается от пользователя, под которым запущен агент (на Linux — root).
|
||||||
|
В окне «Изменить машину» можно выбрать другого пользователя машины или создать нового
|
||||||
|
(имя, пароль, опционально — добавить в sudo). Смена и создание работают, только если агент запущен от root.
|
||||||
|
Файловый менеджер по-прежнему работает от пользователя агента.
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
- Пароли админов — bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина.
|
- Пароли админов — bcrypt; сессии в HttpOnly/Secure/SameSite куках; CSRF на формах; rate-limit логина.
|
||||||
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time).
|
- Агенты: общий enroll-секрет → индивидуальный токен (хранится хешем, сверка constant-time).
|
||||||
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ).
|
- Открытие терминала пишется в таблицу `audit` (терминал даёт root-доступ), как и смена/создание пользователя.
|
||||||
|
|
||||||
## Скриншоты
|
## Скриншоты
|
||||||
<img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" />
|
<img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" />
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.0.1
|
0.0.2
|
||||||
@@ -120,6 +120,7 @@ func (a *Agent) enroll(ctx context.Context) error {
|
|||||||
OS: runtime.GOOS,
|
OS: runtime.GOOS,
|
||||||
Arch: runtime.GOARCH,
|
Arch: runtime.GOARCH,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
|
User: currentUser(),
|
||||||
})
|
})
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.Host+"/api/agent/enroll", bytes.NewReader(body))
|
||||||
req.Header.Set("X-Enroll-Secret", a.cfg.EnrollSecret)
|
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 {
|
func (a *Agent) pollOnce(ctx context.Context) error {
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.Host+"/api/agent/poll", nil)
|
||||||
a.authHeaders(req)
|
a.authHeaders(req)
|
||||||
// Report the version occasionally (not every poll), so the panel can flag
|
// Report the version and our OS user occasionally (not every poll), so the
|
||||||
// outdated agents without extra traffic.
|
// panel can flag outdated agents without extra traffic.
|
||||||
a.pollCount++
|
a.pollCount++
|
||||||
if a.pollCount%20 == 1 {
|
if a.pollCount%20 == 1 {
|
||||||
req.Header.Set("X-Agent-Version", Version)
|
req.Header.Set("X-Agent-Version", Version)
|
||||||
|
req.Header.Set("X-Agent-User", currentUser())
|
||||||
}
|
}
|
||||||
resp, err := a.http.Do(req)
|
resp, err := a.http.Do(req)
|
||||||
if err != nil {
|
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.
|
// Dispatch in the background so we keep polling for more work.
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case proto.CtrlOpenTerminal:
|
case proto.CtrlOpenTerminal:
|
||||||
go a.runTerminal(ctx, msg.SessionID)
|
go a.runTerminal(ctx, msg.SessionID, msg.User)
|
||||||
case proto.CtrlOpenForward:
|
case proto.CtrlOpenForward:
|
||||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||||
case proto.CtrlTask:
|
case proto.CtrlTask:
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// runTerminal spawns a PTY and bridges it to the session.
|
// runTerminal spawns a PTY and bridges it to the session.
|
||||||
func (a *Agent) runTerminal(parent context.Context, sid string) {
|
// user is the OS user to run the shell as ("" = the agent's own user).
|
||||||
term, err := StartPTY()
|
func (a *Agent) runTerminal(parent context.Context, sid, user string) {
|
||||||
|
term, err := StartPTY(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("term %s: start pty: %v", sid, err)
|
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})
|
a.postOut(context.Background(), sid, proto.OutMessage{Exit: true})
|
||||||
return
|
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)
|
a.bridgeStream(parent, sid, term, term.Resize)
|
||||||
log.Printf("term %s: ended", sid)
|
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()
|
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 {
|
func (a *Agent) execTask(env proto.TaskEnvelope) proto.TaskResult {
|
||||||
data, err := dispatchFS(env)
|
data, err := dispatchTask(env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return proto.TaskResult{Error: err.Error()}
|
return proto.TaskResult{Error: err.Error()}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
@@ -13,14 +14,30 @@ type PTY struct {
|
|||||||
f *os.File
|
f *os.File
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartPTY launches the shell attached to a new PTY. The agent runs as root
|
// StartPTY launches a login shell attached to a new PTY. With user empty (or
|
||||||
// (systemd User=root), so this is a root shell by design.
|
// equal to the agent's own user) it is the agent's shell — root by default,
|
||||||
func StartPTY() (*PTY, error) {
|
// since the agent runs as a root systemd service. Any other user is entered via
|
||||||
shell := "/bin/bash"
|
// `su -l`, which only a root agent can do without a password.
|
||||||
if _, err := os.Stat(shell); err != nil {
|
func StartPTY(user string) (*PTY, error) {
|
||||||
shell = "/bin/sh"
|
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")
|
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||||
f, err := pty.Start(cmd)
|
f, err := pty.Start(cmd)
|
||||||
if err != nil {
|
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
|
||||||
|
}
|
||||||
+19
-1
@@ -27,6 +27,7 @@ type EnrollRequest struct {
|
|||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
Version string `json:"version"`
|
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.
|
// 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"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
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).
|
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
||||||
@@ -84,7 +86,9 @@ const (
|
|||||||
TaskFSMove = "fs.move" // move/rename
|
TaskFSMove = "fs.move" // move/rename
|
||||||
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
|
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.
|
// TaskEnvelope is what the agent fetches for a task id.
|
||||||
@@ -144,3 +148,17 @@ type FSReadResult struct {
|
|||||||
Truncated bool `json:"truncated"`
|
Truncated bool `json:"truncated"`
|
||||||
Binary bool `json:"binary"`
|
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
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ func bearerToken(r *http.Request) string {
|
|||||||
|
|
||||||
// enroll creates a new agent record for a valid enroll secret and returns the
|
// 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).
|
// 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]
|
id = randToken()[:16]
|
||||||
token = randToken()
|
token = randToken()
|
||||||
a := Agent{
|
a := Agent{
|
||||||
@@ -44,6 +44,7 @@ func (s *Server) enroll(name, hostname, os, arch, version string) (id, token str
|
|||||||
OS: os,
|
OS: os,
|
||||||
Arch: arch,
|
Arch: arch,
|
||||||
Version: version,
|
Version: version,
|
||||||
|
RunUser: runUser,
|
||||||
TokenHash: hashToken(token),
|
TokenHash: hashToken(token),
|
||||||
}
|
}
|
||||||
if err = s.db.CreateAgent(a); err != nil {
|
if err = s.db.CreateAgent(a); err != nil {
|
||||||
|
|||||||
+30
-11
@@ -59,6 +59,8 @@ CREATE TABLE IF NOT EXISTS agents (
|
|||||||
arch TEXT NOT NULL,
|
arch TEXT NOT NULL,
|
||||||
token_hash TEXT NOT NULL,
|
token_hash TEXT NOT NULL,
|
||||||
version TEXT NOT NULL DEFAULT '',
|
version TEXT NOT NULL DEFAULT '',
|
||||||
|
run_user TEXT NOT NULL DEFAULT '',
|
||||||
|
login_user TEXT NOT NULL DEFAULT '',
|
||||||
enrolled_at INTEGER NOT NULL,
|
enrolled_at INTEGER NOT NULL,
|
||||||
last_seen 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 forward_rules ADD COLUMN proto TEXT NOT NULL DEFAULT 'http'`,
|
||||||
`ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`,
|
`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 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") {
|
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||||
return err
|
return err
|
||||||
@@ -222,9 +226,18 @@ type Agent struct {
|
|||||||
Arch string
|
Arch string
|
||||||
TokenHash string
|
TokenHash string
|
||||||
Version 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
|
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.
|
// Online reports whether the agent polled recently enough to be considered up.
|
||||||
func (a Agent) Online() bool {
|
func (a Agent) Online() bool {
|
||||||
return time.Since(a.LastSeen) < 45*time.Second
|
return time.Since(a.LastSeen) < 45*time.Second
|
||||||
@@ -233,9 +246,9 @@ func (a Agent) Online() bool {
|
|||||||
func (db *DB) CreateAgent(a Agent) error {
|
func (db *DB) CreateAgent(a Agent) error {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
_, err := db.sql.Exec(
|
_, err := db.sql.Exec(
|
||||||
`INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, enrolled_at, last_seen)
|
`INSERT INTO agents(id, name, hostname, os, arch, token_hash, version, run_user, enrolled_at, last_seen)
|
||||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, now, now)
|
a.ID, a.Name, a.Hostname, a.OS, a.Arch, a.TokenHash, a.Version, a.RunUser, now, now)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,8 +256,8 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
|
|||||||
a := &Agent{}
|
a := &Agent{}
|
||||||
var last int64
|
var last int64
|
||||||
err := db.sql.QueryRow(
|
err := db.sql.QueryRow(
|
||||||
`SELECT id, name, hostname, os, arch, token_hash, version, last_seen FROM agents WHERE id = ?`, id).
|
`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, &last)
|
Scan(&a.ID, &a.Name, &a.Hostname, &a.OS, &a.Arch, &a.TokenHash, &a.Version, &a.RunUser, &a.LoginUser, &last)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -257,7 +270,7 @@ func (db *DB) GetAgent(id string) (*Agent, error) {
|
|||||||
|
|
||||||
func (db *DB) ListAgents() ([]Agent, error) {
|
func (db *DB) ListAgents() ([]Agent, error) {
|
||||||
rows, err := db.sql.Query(
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -266,7 +279,7 @@ func (db *DB) ListAgents() ([]Agent, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a Agent
|
var a Agent
|
||||||
var last int64
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
a.LastSeen = time.Unix(last, 0)
|
a.LastSeen = time.Unix(last, 0)
|
||||||
@@ -280,10 +293,16 @@ func (db *DB) TouchAgent(id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// TouchAgentVersion updates last_seen and the reported version together.
|
// TouchAgentInfo updates last_seen together with the reported version and OS user.
|
||||||
func (db *DB) TouchAgentVersion(id, version string) error {
|
func (db *DB) TouchAgentInfo(id, version, runUser string) error {
|
||||||
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ? WHERE id = ?`,
|
_, err := db.sql.Exec(`UPDATE agents SET last_seen = ?, version = ?, run_user = ? WHERE id = ?`,
|
||||||
time.Now().Unix(), version, 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -43,9 +43,12 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The agent sends its version occasionally; record it when present.
|
// The agent sends its version and OS user occasionally; record them when
|
||||||
if v := r.Header.Get("X-Agent-Version"); v != "" && v != a.Version {
|
// present. Agents older than 0.0.2 send no user, so keep what we have.
|
||||||
_ = s.db.TouchAgentVersion(a.ID, v)
|
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 {
|
} else {
|
||||||
_ = s.db.TouchAgent(a.ID)
|
_ = s.db.TouchAgent(a.ID)
|
||||||
}
|
}
|
||||||
@@ -121,7 +124,11 @@ func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if msg.Exit {
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -152,13 +156,135 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, "edit.html", map[string]any{
|
data := map[string]any{
|
||||||
"Agent": a,
|
"Agent": a,
|
||||||
"CSRF": csrfToken(r),
|
"CSRF": csrfToken(r),
|
||||||
"ServerVersion": Version,
|
"ServerVersion": Version,
|
||||||
"Outdated": a.Version != "" && a.Version != Version,
|
"Outdated": a.Version != "" && a.Version != Version,
|
||||||
"Updated": r.URL.Query().Get("updated") == "1",
|
"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.
|
// handleAgentUpdate asks the agent to download the latest binary and restart.
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sess := s.hub.CreateSession(agentID)
|
sess := s.hub.CreateSession(agentID, agent.LoginUser)
|
||||||
if admin := s.currentAdmin(r); admin != nil {
|
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})
|
writeJSON(w, map[string]string{"session_id": sess.ID})
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-4
@@ -82,10 +82,11 @@ func (h *Hub) newSession(agentID string) *Session {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession registers a terminal session and queues it for the agent.
|
// CreateSession registers a terminal session and queues it for the agent. user
|
||||||
func (h *Hub) CreateSession(agentID string) *Session {
|
// 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)
|
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +246,14 @@ func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) {
|
|||||||
case <-time.After(timeout):
|
case <-time.After(timeout):
|
||||||
return nil, true
|
return nil, true
|
||||||
case <-s.closed:
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit))
|
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}/rename", s.requireAdmin(s.handleAgentRename))
|
||||||
mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate))
|
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("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete))
|
||||||
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
|
mux.Handle("GET /settings", s.requireAdmin(s.handleSettings))
|
||||||
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost))
|
mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost))
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ input[type=text], input[type=password] {
|
|||||||
width: 100%; padding: 10px 12px; border-radius: 8px;
|
width: 100%; padding: 10px 12px; border-radius: 8px;
|
||||||
border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px;
|
border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px;
|
||||||
}
|
}
|
||||||
|
/* Sized and shaped like .btn.ghost so it lines up with the buttons next to it. */
|
||||||
|
select {
|
||||||
|
-webkit-appearance: none; appearance: none; cursor: pointer;
|
||||||
|
min-width: 180px; padding: 10px 38px 10px 14px; border-radius: 8px;
|
||||||
|
border: 1px solid var(--border); color: var(--text);
|
||||||
|
font: inherit; font-size: 14px; font-weight: 500; line-height: 1.5;
|
||||||
|
background: #0c0f13 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b95a5' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 14px center;
|
||||||
|
}
|
||||||
|
select:hover, select:focus { border-color: var(--accent); outline: none; }
|
||||||
|
select:disabled, button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
button, .btn {
|
button, .btn {
|
||||||
display: inline-block; cursor: pointer; border: 0; border-radius: 8px;
|
display: inline-block; cursor: pointer; border: 0; border-radius: 8px;
|
||||||
padding: 10px 16px; font-size: 14px; font-weight: 500;
|
padding: 10px 16px; font-size: 14px; font-weight: 500;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Изменить машину — LPRA</title>
|
<title>Изменить машину — LPRA</title>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css?v={{.ServerVersion}}-2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -28,6 +28,46 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Пользователь</h2>
|
||||||
|
{{if .UserOK}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">{{.UserOK}}</div>{{end}}
|
||||||
|
{{if .UserErr}}<div class="err">{{.UserErr}}</div>{{end}}
|
||||||
|
<p class="muted">
|
||||||
|
Клиент запущен от: <code>{{if .Users}}{{.Users.Current}}{{else if .Agent.RunUser}}{{.Agent.RunUser}}{{else}}неизвестно{{end}}</code>
|
||||||
|
· Терминал открывается от: <code>{{.Agent.TerminalUser}}</code>
|
||||||
|
</p>
|
||||||
|
{{if .Users}}
|
||||||
|
<form method="post" action="/agents/{{.Agent.ID}}/user" class="row">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<select name="user" {{if not .Users.CanSwitch}}disabled{{end}}>
|
||||||
|
{{range .Users.Users}}<option value="{{.}}" {{if eq . $.Agent.TerminalUser}}selected{{end}}>{{.}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
<button type="submit" {{if not .Users.CanSwitch}}disabled{{end}}>Сохранить</button>
|
||||||
|
{{if .Users.CanSwitch}}<button type="button" class="btn ghost" onclick="document.getElementById('newuser').hidden = !document.getElementById('newuser').hidden">Создать нового</button>{{end}}
|
||||||
|
</form>
|
||||||
|
{{if .Users.CanSwitch}}
|
||||||
|
<form id="newuser" method="post" action="/agents/{{.Agent.ID}}/users/add" style="max-width:360px; margin-top:16px" hidden>
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<label for="nu-name">Имя нового пользователя</label>
|
||||||
|
<input type="text" id="nu-name" name="name" pattern="[a-z_][a-z0-9_\-]{0,31}" autocomplete="off" required>
|
||||||
|
<label for="nu-pass">Пароль</label>
|
||||||
|
<input type="password" id="nu-pass" name="password" autocomplete="new-password">
|
||||||
|
<label style="color:var(--text); font-size:14px">
|
||||||
|
<input type="checkbox" name="sudo" value="1"> Добавить в sudo
|
||||||
|
</label>
|
||||||
|
<p class="muted" style="font-size:13px">Без пароля вход по паролю (и sudo) для него работать не будет — только терминал из панели.</p>
|
||||||
|
<button type="submit">Создать пользователя</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted" style="font-size:13px; margin-top:12px">Клиент запущен не от root, поэтому сменить или создать пользователя нельзя.</p>
|
||||||
|
{{end}}
|
||||||
|
{{else if .UsersErr}}
|
||||||
|
<p class="muted" style="font-size:13px">Список пользователей недоступен: {{.UsersErr}}.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted" style="font-size:13px">Машина офлайн — сменить пользователя можно, когда она на связи.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Версия клиента</h2>
|
<h2>Версия клиента</h2>
|
||||||
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
|
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">LPRA · Терминал — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
<div class="brand">LPRA · Терминал — {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">← Машины</a>
|
<a href="/">← Машины</a>
|
||||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||||
|
|||||||
Reference in new issue
Block a user