From 12aba383f1748759e3af481414ef022e30e02fe4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 6 Aug 2026 21:01:54 +0300 Subject: [PATCH] Add secure automatic agent updates --- README.md | 12 +++ agent.go | 165 ++++++++++++++++++++++++++++++++++++- agent_test.go | 26 ++++++ scripts/install-agent.sh | 8 +- scripts/uninstall-agent.sh | 16 ++++ web.go | 38 ++++++++- 6 files changed, 255 insertions(+), 10 deletions(-) create mode 100755 scripts/uninstall-agent.sh diff --git a/README.md b/README.md index a977129..8215313 100644 --- a/README.md +++ b/README.md @@ -346,12 +346,24 @@ health-check, число перезапусков, CPU и память. Аген VMID. Кнопка создаст готовую команду установки. Одноразовый токен действует 15 минут и после регистрации заменяется индивидуальным секретом агента. +После однократной установки агент обновляется автоматически вслед за Dashboard. +Центральный сервер отдаёт агенту собственный бинарник и SHA-256; агент проверяет +контрольную сумму, атомарно заменяет файл и перезапускается через systemd. Если +агент был удалён из Dashboard, повторная команда с новым токеном автоматически +выполнит новую регистрацию. + В первой версии агент не выполняет команды и не перезапускает службы или контейнеры. Для чтения Docker ему требуется доступ к `/var/run/docker.sock`, поэтому служба устанавливается от root. Сам Dashboard пока не имеет авторизации: используйте локальную сеть или HTTPS через доверенный reverse proxy и не публикуйте агентские API напрямую в интернет. +Полностью удалить агент из VM/LXC: + +```bash +curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/uninstall-agent.sh | sh +``` + Оформление, пороги, почта и обновления Dashboard находятся в обычных настройках. Рабочие инструменты — сервисы, агенты, плановые работы и UPS-сценарий — вынесены в отдельный раздел `Управление` в шапке Dashboard. diff --git a/agent.go b/agent.go index a096846..5cb1c97 100644 --- a/agent.go +++ b/agent.go @@ -99,11 +99,51 @@ type agentInventory struct { CollectedAt time.Time } +type AgentUpdateManifest struct { + Version string `json:"version"` + SHA256 string `json:"sha256"` + URL string `json:"url"` +} + +type agentReportResponse struct { + Accepted bool `json:"accepted"` + Update *AgentUpdateManifest `json:"update,omitempty"` +} + var guestInventory struct { sync.Mutex value agentInventory } +var agentBinaryInfo struct { + sync.Once + manifest AgentUpdateManifest + path string + err error +} + +func currentAgentUpdate() (AgentUpdateManifest, string, error) { + agentBinaryInfo.Do(func() { + agentBinaryInfo.path, agentBinaryInfo.err = os.Executable() + if agentBinaryInfo.err != nil { + return + } + file, err := os.Open(agentBinaryInfo.path) + if err != nil { + agentBinaryInfo.err = err + return + } + defer file.Close() + hash := sha256.New() + if _, err = io.Copy(hash, file); err != nil { + agentBinaryInfo.err = err + return + } + agentBinaryInfo.manifest = AgentUpdateManifest{Version: version, SHA256: hex.EncodeToString(hash.Sum(nil)), URL: "/api/agent/binary"} + }) + return agentBinaryInfo.manifest, agentBinaryInfo.path, agentBinaryInfo.err +} + type ManagedAgent struct { ID string `json:"id"` Name string `json:"name"` @@ -180,8 +220,7 @@ func (s *Store) EnrollAgent(req agentEnrollmentRequest) (agentConfig, error) { } func (s *Store) SaveAgentReport(id, secret, remote string, report AgentReport) error { - var expected string - if s.db.QueryRow(`SELECT secret_hash FROM agents WHERE id=?`, id).Scan(&expected) != nil || !hmac.Equal([]byte(expected), []byte(tokenHash(secret))) { + if !s.AuthenticateAgent(id, secret) { return errors.New("агент не авторизован") } data, _ := json.Marshal(report) @@ -189,6 +228,11 @@ func (s *Store) SaveAgentReport(id, secret, remote string, report AgentReport) e return err } +func (s *Store) AuthenticateAgent(id, secret string) bool { + var expected string + return s.db.QueryRow(`SELECT secret_hash FROM agents WHERE id=?`, id).Scan(&expected) == nil && hmac.Equal([]byte(expected), []byte(tokenHash(secret))) +} + func (s *Store) Agents() ([]ManagedAgent, error) { rows, err := s.db.Query(`SELECT id,name,vmid,hostname,version,enrolled_at,last_seen,report_json,remote_address FROM agents ORDER BY name`) if err != nil { @@ -252,10 +296,33 @@ func runGuestAgent(opts agentOptions) error { req.Header.Set("Authorization", "Bearer "+config.ID+"."+config.Secret) response, requestErr := client.Do(req) if requestErr == nil { - io.Copy(io.Discard, response.Body) + responseData, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10)) response.Body.Close() + if response.StatusCode == http.StatusUnauthorized && opts.EnrollmentToken != "" { + fresh, enrollmentErr := enrollRemote(config, opts.EnrollmentToken) + if enrollmentErr == nil { + config = fresh + if saveErr := saveAgentConfig(opts.ConfigPath, config); saveErr != nil { + return saveErr + } + continue + } + requestErr = fmt.Errorf("повторная регистрация: %v", enrollmentErr) + } if response.StatusCode >= 300 { - requestErr = fmt.Errorf("сервер вернул %s", response.Status) + if requestErr == nil { + requestErr = fmt.Errorf("сервер вернул %s", response.Status) + } + } + if response.StatusCode == http.StatusOK { + var result agentReportResponse + if json.Unmarshal(responseData, &result) == nil && result.Update != nil && newerAgentVersion(result.Update.Version, version) { + if updateErr := applyAgentUpdate(client, config, *result.Update); updateErr != nil { + requestErr = fmt.Errorf("автообновление: %v", updateErr) + } else { + return nil + } + } } } if requestErr != nil { @@ -265,6 +332,96 @@ func runGuestAgent(opts agentOptions) error { } } +func newerAgentVersion(candidate, current string) bool { + parse := func(value string) [3]int { + value = strings.TrimPrefix(strings.TrimSpace(value), "v") + value = strings.SplitN(value, "-", 2)[0] + parts := strings.Split(value, ".") + var result [3]int + for i := 0; i < len(parts) && i < 3; i++ { + result[i], _ = strconv.Atoi(parts[i]) + } + return result + } + if candidate == "" || current == "" || current == "dev" { + return false + } + next, installed := parse(candidate), parse(current) + for i := 0; i < 3; i++ { + if next[i] > installed[i] { + return true + } + if next[i] < installed[i] { + return false + } + } + return false +} + +func applyAgentUpdate(client *http.Client, config agentConfig, update AgentUpdateManifest) error { + if !strings.HasPrefix(update.URL, "/api/agent/") || len(update.SHA256) != 64 { + return errors.New("сервер вернул некорректный манифест") + } + request, err := http.NewRequest(http.MethodGet, config.Server+update.URL, nil) + if err != nil { + return err + } + request.Header.Set("Authorization", "Bearer "+config.ID+"."+config.Secret) + updateClient := *client + updateClient.Timeout = 2 * time.Minute + response, err := updateClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("загрузка вернула %s", response.Status) + } + executable, err := os.Executable() + if err != nil { + return err + } + temporary := executable + ".update" + _ = os.Remove(temporary) + file, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0755) + if err != nil { + return err + } + keep := false + defer func() { + file.Close() + if !keep { + _ = os.Remove(temporary) + } + }() + hash := sha256.New() + written, err := io.Copy(io.MultiWriter(file, hash), io.LimitReader(response.Body, (64<<20)+1)) + if err != nil { + return err + } + if written > 64<<20 { + return errors.New("бинарник превышает допустимый размер") + } + if err = file.Sync(); err != nil { + return err + } + if err = file.Close(); err != nil { + return err + } + actual := hex.EncodeToString(hash.Sum(nil)) + if !hmac.Equal([]byte(actual), []byte(strings.ToLower(update.SHA256))) { + return errors.New("SHA-256 обновления не совпал") + } + if err = os.Chmod(temporary, 0755); err != nil { + return err + } + if err = os.Rename(temporary, executable); err != nil { + return err + } + keep = true + return nil +} + func enrollRemote(config agentConfig, token string) (agentConfig, error) { hostname, _ := os.Hostname() machineID, _ := os.ReadFile("/etc/machine-id") diff --git a/agent_test.go b/agent_test.go index ba31a85..fc0ae23 100644 --- a/agent_test.go +++ b/agent_test.go @@ -1,6 +1,7 @@ package main import ( + "os" "testing" "time" ) @@ -27,6 +28,31 @@ func TestAgentEnrollmentTokenIsSingleUse(t *testing.T) { } } +func TestCurrentAgentUpdateManifestMatchesExecutable(t *testing.T) { + manifest, path, err := currentAgentUpdate() + if err != nil { + t.Fatal(err) + } + if manifest.URL != "/api/agent/binary" || len(manifest.SHA256) != 64 { + t.Fatalf("unexpected manifest: %#v", manifest) + } + if _, err = os.Stat(path); err != nil { + t.Fatal(err) + } +} + +func TestAgentOnlyInstallsNewerVersion(t *testing.T) { + if !newerAgentVersion("v0.14.0", "v0.13.0") { + t.Fatal("new version was not detected") + } + if newerAgentVersion("v0.12.0", "v0.13.0") { + t.Fatal("downgrade was allowed") + } + if newerAgentVersion("v0.13.0", "v0.13.0") { + t.Fatal("same version was offered") + } +} + func TestAgentReportAuthenticationAndOfflineState(t *testing.T) { store, err := openStore(":memory:") if err != nil { diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh index ac82159..22e5b96 100755 --- a/scripts/install-agent.sh +++ b/scripts/install-agent.sh @@ -26,7 +26,7 @@ install -d -m 0700 /var/lib/proxmox-dashboard-agent temporary_binary=$(mktemp /tmp/proxmox-dashboard-agent.XXXXXX) trap 'rm -f "$temporary_binary"' EXIT curl -fsSL "$download_url" -o "$temporary_binary" -install -m 0755 "$temporary_binary" /usr/local/bin/proxmox-dashboard-agent +install -m 0755 "$temporary_binary" /var/lib/proxmox-dashboard-agent/proxmox-dashboard-agent umask 077 { @@ -45,7 +45,7 @@ Wants=network-online.target [Service] Type=simple EnvironmentFile=/etc/proxmox-dashboard-agent.env -ExecStart=/usr/local/bin/proxmox-dashboard-agent --agent --agent-server ${DASHBOARD_URL} --agent-enroll-token ${ENROLL_TOKEN} --agent-name ${AGENT_NAME} --agent-vmid ${VMID} +ExecStart=/var/lib/proxmox-dashboard-agent/proxmox-dashboard-agent --agent --agent-server ${DASHBOARD_URL} --agent-enroll-token ${ENROLL_TOKEN} --agent-name ${AGENT_NAME} --agent-vmid ${VMID} Restart=always RestartSec=5 NoNewPrivileges=true @@ -59,5 +59,7 @@ WantedBy=multi-user.target UNIT systemctl daemon-reload -systemctl enable --now proxmox-dashboard-agent.service +systemctl enable proxmox-dashboard-agent.service +systemctl restart proxmox-dashboard-agent.service +rm -f /usr/local/bin/proxmox-dashboard-agent echo "Агент установлен. Через несколько секунд он появится в Dashboard." diff --git a/scripts/uninstall-agent.sh b/scripts/uninstall-agent.sh new file mode 100755 index 0000000..a85a7ae --- /dev/null +++ b/scripts/uninstall-agent.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "Запустите удаление от root." >&2 + exit 1 +fi + +systemctl disable --now proxmox-dashboard-agent.service 2>/dev/null || true +rm -f /etc/systemd/system/proxmox-dashboard-agent.service +rm -f /etc/proxmox-dashboard-agent.env +rm -f /usr/local/bin/proxmox-dashboard-agent +rm -rf /var/lib/proxmox-dashboard-agent +systemctl daemon-reload +systemctl reset-failed proxmox-dashboard-agent.service 2>/dev/null || true +echo "Агент и его локальные учётные данные удалены." diff --git a/web.go b/web.go index 8fad35f..567ae6d 100644 --- a/web.go +++ b/web.go @@ -262,8 +262,7 @@ func routes(collector *cpuCollector, store *Store) http.Handler { w.WriteHeader(http.StatusMethodNotAllowed) return } - credentials := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - id, secret, ok := strings.Cut(credentials, ".") + id, secret, ok := agentCredentials(r) if !ok || id == "" || secret == "" { http.Error(w, "Агент не авторизован", http.StatusUnauthorized) return @@ -277,7 +276,34 @@ func routes(collector *cpuCollector, store *Store) http.Handler { http.Error(w, err.Error(), http.StatusUnauthorized) return } - writeJSON(w, map[string]bool{"accepted": true}, nil) + result := agentReportResponse{Accepted: true} + if report.Version != version && version != "dev" { + manifest, _, manifestErr := currentAgentUpdate() + if manifestErr == nil { + result.Update = &manifest + } + } + writeJSON(w, result, nil) + }) + mux.HandleFunc("/api/agent/binary", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + id, secret, ok := agentCredentials(r) + if !ok || !store.AuthenticateAgent(id, secret) { + http.Error(w, "Агент не авторизован", http.StatusUnauthorized) + return + } + manifest, path, err := currentAgentUpdate() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("X-Checksum-SHA256", manifest.SHA256) + w.Header().Set("Content-Disposition", `attachment; filename="proxmox-dashboard-agent"`) + http.ServeFile(w, r, path) }) mux.HandleFunc("/api/alerts/history", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -449,6 +475,12 @@ func writeJSON(w http.ResponseWriter, value any, err error) { _ = json.NewEncoder(w).Encode(value) } +func agentCredentials(r *http.Request) (string, string, bool) { + credentials := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + id, secret, ok := strings.Cut(credentials, ".") + return id, secret, ok && id != "" && secret != "" +} + const dashboardHTML = `