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
@@ -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 {
|
||||
|
||||
+30
-11
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
+12
-4
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,46 @@
|
||||
</p>
|
||||
</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" style="max-width:360px" {{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">
|
||||
<h2>Версия клиента</h2>
|
||||
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
|
||||
Reference in new issue
Block a user