diff --git a/internal/agent/ssh.go b/internal/agent/ssh.go index 06150cf..ccb6dfa 100644 --- a/internal/agent/ssh.go +++ b/internal/agent/ssh.go @@ -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) } diff --git a/internal/agent/update.go b/internal/agent/update.go index c1c3a91..d7bc1d6 100644 --- a/internal/agent/update.go +++ b/internal/agent/update.go @@ -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() { - if path, err := exec.LookPath("systemctl"); err == nil { - _ = exec.Command(path, "restart", "lpagent").Start() - return +// 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 { + 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 + } + } + // 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) } - // No systemd (e.g. launchd/manual run): exit so the supervisor restarts us. - os.Exit(0) } diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index d81f3e9..70bf5a6 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -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) { diff --git a/internal/server/web/static/account.js b/internal/server/web/static/account.js index 056741b..7cc43e5 100644 --- a/internal/server/web/static/account.js +++ b/internal/server/web/static/account.js @@ -53,7 +53,7 @@ ' ' + ' ' + ' ' + - '
' + + '
' + ' ' + ' - + @@ -482,6 +487,30 @@ const ICON_MV = SVG + ''; const ICON_UNZIP = SVG + ''; const ICON_DEL = SVG + ''; + // File icons: one colour, the line style of the buttons; a few kinds by + // the name's ending. + const FI = ''; + const FICONS = { + dir: FI + '', + file: FI + '', + text: FI + '', + code: FI + '', + img: FI + '', + arc: FI + '', + media:FI + '', + link: FI + '' + }; + 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 '' + FICONS[k] + ''; + } 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 = '' + - ''+(e.is_dir?'📁':'📄')+''+esc(e.name)+'' + + ''+fileIcon(e)+esc(e.name)+'' + ''+(e.is_dir?'':fmtSize(e.size))+'' + ''+fmtTime(e.mtime)+'' + '
' + diff --git a/internal/server/web/templates/terminal.html b/internal/server/web/templates/terminal.html index 659f85f..b84bd31 100644 --- a/internal/server/web/templates/terminal.html +++ b/internal/server/web/templates/terminal.html @@ -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);