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 @@ ' ' + ' ' + ' ' + - '
' + + ' ' + ' ' + '