fix: agent update runs as root when it can (sudo without a password otherwise), checks the download, restarts reliably and reports its result; one-colour file icons; zip and delete on one line on phones; PIN buttons alike; no key fingerprint in the SSH status; corner plus clears split panes

This commit is contained in:
redtea committed 2026-10-14 23:54:24 +03:00
1 parent 467bd3cb4f
commit 5eca9ca215
8 files changed
+181 -37

No files matched your search

+1 -1
View File
@@ -244,7 +244,7 @@ func (a *Agent) runSSH(parent context.Context, sid string, t proto.SSHTarget) {
}
}()
log.Printf("ssh %s: %s@%s up (host key %s)", sid, t.User, addr, fp)
status(proto.SSHOK, t.User+"@"+addr+" · "+fp)
status(proto.SSHOK, t.User+"@"+addr)
a.bridgeStream(parent, sid, sh, sh.Resize)
log.Printf("ssh %s: ended", sid)
}
+116 -23
View File
@@ -3,31 +3,40 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// doUpdate downloads the latest agent binary for this platform, atomically
// replaces the running executable, and schedules a service restart. It never
// uninstalls - only updates.
// doUpdate downloads the latest agent binary for this platform, replaces the
// running executable and restarts the service. It runs as root when it can:
// the agent's own user if that is root, otherwise sudo when it needs no
// password. It never uninstalls - only updates.
func (a *Agent) doUpdate() proto.TaskResult {
exe, err := os.Executable()
if err != nil {
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
}
exe, _ = filepath.EvalSymlinks(exe)
sudo := ""
if os.Geteuid() != 0 {
sudo = sudoPath()
}
url := fmt.Sprintf("%s/download/agent-%s-%s", a.cfg.Host, runtime.GOOS, runtime.GOARCH)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
resp, err := a.http.Do(req)
resp, err := a.stream.Do(req)
if err != nil {
return proto.TaskResult{Error: "скачивание: " + err.Error()}
}
@@ -36,41 +45,125 @@ func (a *Agent) doUpdate() proto.TaskResult {
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
}
tmp := exe + ".new"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return proto.TaskResult{Error: "создание файла: " + err.Error()}
// The new binary lands next to the old one, or, when root's hand is
// needed to put it there, in a temporary folder first.
dir := filepath.Dir(exe)
if sudo != "" {
dir = ""
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(tmp)
f, err := os.CreateTemp(dir, ".lpagent-new-*")
if err != nil {
return proto.TaskResult{Error: "создание файла: " + err.Error() + userNote()}
}
tmp := f.Name()
defer os.Remove(tmp)
n, err := io.Copy(f, io.LimitReader(resp.Body, 256<<20))
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return proto.TaskResult{Error: "запись: " + err.Error()}
}
f.Close()
if err := checkBinary(tmp, n); err != nil {
return proto.TaskResult{Error: err.Error()}
}
if err := os.Chmod(tmp, 0o755); err != nil {
return proto.TaskResult{Error: err.Error()}
}
// Atomic replace: renaming over a running executable is allowed on Linux;
// the live process keeps the old inode until it restarts.
if err := os.Rename(tmp, exe); err != nil {
os.Remove(tmp)
return proto.TaskResult{Error: "замена бинарника: " + err.Error()}
how := "от " + currentUser()
if sudo != "" {
// install copies it in place under a new inode, as a rename would.
if out, err := exec.Command(sudo, "-n", "install", "-m", "0755", tmp, exe).CombinedOutput(); err != nil {
return proto.TaskResult{Error: "замена бинарника через sudo: " + strings.TrimSpace(string(out)+" "+err.Error())}
}
how = "через sudo"
} else if err := os.Rename(tmp, exe); err != nil {
// Renaming over a running executable is allowed; the live process
// keeps the old inode until it restarts.
return proto.TaskResult{Error: "замена бинарника: " + err.Error() + userNote()}
}
// Restart shortly, after the result has been reported.
go func() {
time.Sleep(1500 * time.Millisecond)
restartSelf()
restartSelf(exe, sudo)
}()
data, _ := json.Marshal(map[string]string{"status": "обновлено, перезапуск"})
data, _ := json.Marshal(map[string]string{"status": "обновлено " + how + ", перезапуск"})
return proto.TaskResult{OK: true, Data: data}
}
// restartSelf restarts the agent so the new binary takes effect.
func restartSelf() {
// sudoPath is sudo when it lets this user run commands without a password.
func sudoPath() string {
p, err := exec.LookPath("sudo")
if err != nil {
return ""
}
if exec.Command(p, "-n", "true").Run() != nil {
return ""
}
return p
}
func userNote() string {
if os.Geteuid() == 0 {
return ""
}
return " (агент запущен от " + currentUser() + ", без root и sudo без пароля - обновите установщиком)"
}
// checkBinary makes sure what came is a program for this system, not an
// error page: a binary of a sensible size with the right header.
func checkBinary(path string, size int64) error {
if size < 1<<20 {
return fmt.Errorf("скачанный файл слишком мал (%d байт) - не похоже на агента", size)
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
var magic [4]byte
if _, err := io.ReadFull(f, magic[:]); err != nil {
return err
}
ok := false
switch runtime.GOOS {
case "linux":
ok = string(magic[:]) == "\x7fELF"
case "darwin":
ok = magic == [4]byte{0xcf, 0xfa, 0xed, 0xfe} || magic == [4]byte{0xca, 0xfe, 0xba, 0xbe}
default:
ok = true
}
if !ok {
return errors.New("скачанный файл - не программа для " + runtime.GOOS)
}
return nil
}
// restartSelf starts the new binary. Under systemd the service is restarted
// (queued, so it goes on after this process is stopped); otherwise the
// process replaces itself with the new binary.
func restartSelf(exe, sudo string) {
if os.Getenv("INVOCATION_ID") != "" {
if path, err := exec.LookPath("systemctl"); err == nil {
_ = exec.Command(path, "restart", "lpagent").Start()
args := []string{"--no-block", "restart", "lpagent"}
cmd := exec.Command(path, args...)
if sudo != "" {
cmd = exec.Command(sudo, append([]string{"-n", path}, args...)...)
}
if cmd.Run() == nil {
return
}
// No systemd (e.g. launchd/manual run): exit so the supervisor restarts us.
}
// systemd brings a service that stopped back up (Restart=always).
os.Exit(0)
}
CloseTerminals()
if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil {
log.Printf("update: restart: %v", err)
os.Exit(0)
}
}
+20 -7
View File
@@ -211,6 +211,8 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
"ServerVersion": Version,
"Outdated": a.Version != "" && a.Version != Version,
"Updated": r.URL.Query().Get("updated") == "1",
"UpdateMsg": r.URL.Query().Get("umsg"),
"UpdateErr": r.URL.Query().Get("uperr"),
"UserOK": r.URL.Query().Get("uok"),
"UserErr": r.URL.Query().Get("uerr"),
"Transport": firstNonEmpty(a.Transport, proto.TransportAuto),
@@ -366,17 +368,28 @@ func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
back := func(q url.Values) { http.Redirect(w, r, "/agents/"+id+"/edit?"+q.Encode(), http.StatusSeeOther) }
if !s.online(a) {
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
back(url.Values{"uperr": {"машина не в сети"}})
return
}
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUpdate, nil)
defer s.hub.DiscardTask(taskID)
select {
case <-ch:
case <-time.After(30 * time.Second):
if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, id, "update:"+a.Version+" -> "+Version)
}
// The download can take a while on a slow link; under a proxy's timeout.
res, err := s.waitTask(r.Context(), id, proto.TaskSysUpdate, nil, 80*time.Second)
switch {
case err != nil:
back(url.Values{"uperr": {"агент не ответил: " + err.Error()}})
case !res.OK:
back(url.Values{"uperr": {res.Error}})
default:
var d struct {
Status string `json:"status"`
}
_ = json.Unmarshal(res.Data, &d)
back(url.Values{"updated": {"1"}, "umsg": {d.Status}})
}
http.Redirect(w, r, "/agents/"+id+"/edit?updated=1", http.StatusSeeOther)
}
func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -53,7 +53,7 @@
' <input type="password" name="current" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="текущий PIN" autocomplete="off">' +
' <input type="password" name="pin" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый PIN, 4 цифры" autocomplete="off">' +
' <input type="password" name="again" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый ещё раз" autocomplete="off">' +
' <div class="row"><button type="button" class="btn danger small acct-pindel">Убрать PIN</button><button type="submit" class="acct-pinsave">Сохранить</button></div>' +
' <div class="row"><button type="button" class="btn danger acct-pindel">Убрать PIN</button><button type="submit" class="acct-pinsave">Сохранить</button></div>' +
' </form>' +
' <label class="acct-pingrace"><span>На этом устройстве не спрашивать после ввода</span><select>' +
' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' +
+6
View File
@@ -287,6 +287,7 @@
t.el.querySelector('.dot').className = 'dot ' + (st.includes('err') ? 'err' : st.includes('wait') ? 'wait' : st.includes('ok') ? 'ok' : 'off');
t.el.classList.toggle('on', t === active);
t.view.classList.toggle('split', t.panes.length > 1);
syncFloat();
}
function activate(t) {
if (!t) return;
@@ -310,8 +311,13 @@
statusEl.title = activePane.text;
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
}
// The corner "+" moves down out of the way of a split tab's title bars.
function syncFloat() {
document.body.classList.toggle('split1', tabs.length === 1 && tabs[0].panes.length > 1);
}
// One tab: a plain terminal, the strip folded into the corner "+".
function updateMode() {
syncFloat();
const multi = tabs.length > 1;
if (document.body.classList.contains('multi') === multi) return;
document.body.classList.toggle('multi', multi);
+2 -1
View File
@@ -94,7 +94,8 @@
<div class="card">
<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">Клиент {{if .UpdateMsg}}{{.UpdateMsg}}{{else}}обновлён и перезапускается{{end}}. Через несколько секунд он вернётся с новой версией.</div>{{end}}
{{if .UpdateErr}}<div class="err">Обновление не вышло: {{.UpdateErr}}</div>{{end}}
<p class="muted">
Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code>
· Сервер: <code>{{.ServerVersion}}</code>
+32 -3
View File
@@ -16,6 +16,7 @@
}
/* A button with an icon before its text. */
.fm-bar .btn.ico { gap:6px; }
.fm-bar .spacer { flex:1; }
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
@@ -58,7 +59,9 @@
table.fm td, table.fm th { padding:7px 8px; }
.fm .nm { cursor:pointer; }
.fm .nm:hover { color:var(--accent); }
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
.ic { display:inline-flex; vertical-align:-3px; width:18px; justify-content:center; margin-right:6px; color:var(--muted); }
.ic.dir { color:#7aa7d9; }
.ic.dir svg { fill:rgba(122,167,217,.16); }
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
.act { display:flex; gap:6px; justify-content:flex-end; }
/* Sortable headers: a click sorts by the column, a second one turns it round. */
@@ -76,6 +79,8 @@
table.fm td, table.fm th { padding:7px 4px; }
.ic { margin-right:4px; }
.fm-bar .fm-pathbox { flex:1 1 100%; order:-1; }
/* The selection's buttons go together on a line of their own. */
.fm-bar .spacer { flex:1 1 100%; height:0; }
}
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
border-radius:12px; padding:0; max-width:900px; width:92vw; }
@@ -304,7 +309,7 @@
<button class="btn small ico" id="mkdir"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M12 11v6M9 14h6"/></svg>Папка</button>
<button class="btn small ico" id="newfile"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M12 12v6M9 15h6"/></svg>Файл</button>
<label class="btn small ico" style="cursor:pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 10l6 6 6-6M5 21h14"/></svg>Загрузить<input type="file" id="upl" multiple hidden></label>
<span class="spacer" style="flex:1"></span>
<span class="spacer"></span>
<span id="selinfo" class="muted" style="font-size:13px"></span>
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
<button class="btn danger small" id="delsel" disabled>Удалить выбранное</button>
@@ -482,6 +487,30 @@
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>';
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
// File icons: one colour, the line style of the buttons; a few kinds by
// the name's ending.
const FI = '<svg class="fi" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">';
const FICONS = {
dir: FI + '<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
file: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>',
text: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
code: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M10 12l-2 2.5 2 2.5M14 12l2 2.5-2 2.5"/></svg>',
img: FI + '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 9"/></svg>',
arc: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M11 4v2M11 8v2M11 12v2"/><rect x="9.5" y="15" width="3" height="3" rx=".6"/></svg>',
media:FI + '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg>',
link: FI + '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>'
};
function fileIcon(e) {
let k = 'file';
if (e.is_dir) k = 'dir';
else if (/^L/.test(e.mode || '')) k = 'link';
else if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|heic)$/i.test(e.name)) k = 'img';
else if (/\.(zip|tar|gz|tgz|bz2|xz|zst|7z|rar|deb|rpm)$/i.test(e.name)) k = 'arc';
else if (/\.(mp4|m4v|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(e.name)) k = 'media';
else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|conf|xml)$/i.test(e.name)) k = 'code';
else if (/\.(txt|md|log|csv|rst|cfg|env)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
return '<span class="ic' + (k === 'dir' ? ' dir' : '') + '">' + FICONS[k] + '</span>';
}
let cwd = '/';
let entries = [];
const selected = new Set();
@@ -564,7 +593,7 @@
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
tr.innerHTML =
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
'<td class="nm">'+fileIcon(e)+esc(e.name)+'</td>' +
'<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
'<td><div class="act">' +
+3 -1
View File
@@ -82,7 +82,9 @@
body.multi .area { margin-top:0; border-top-left-radius:0; }
.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex;
align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text);
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease; }
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease, top .25s ease; }
/* One tab split in panes: below the panes' title bars, clear of their close buttons. */
body.split1 .float-add { top:42px; }
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
body.multi .float-add { opacity:0; pointer-events:none; }
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);