package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net" "net/http" "os/exec" "path/filepath" "regexp" "strings" "time" ) type AgentCommand struct { ID int64 `json:"id"` Action string `json:"action"` Target string `json:"target,omitempty"` } type AgentActionResult struct { ID int64 `json:"id"` Success bool `json:"success"` Message string `json:"message"` } type AgentAction struct { ID int64 `json:"id"` AgentID string `json:"agentId"` AgentName string `json:"agentName"` Action string `json:"action"` Target string `json:"target"` RequestedAt int64 `json:"requestedAt"` StartedAt *int64 `json:"startedAt,omitempty"` CompletedAt *int64 `json:"completedAt,omitempty"` Status string `json:"status"` Result string `json:"result"` } var safeUnitName = regexp.MustCompile(`^[A-Za-z0-9_.@:-]+\.service$`) var safeContainerName = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,128}$`) func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, error) { action, target = strings.TrimSpace(action), strings.TrimSpace(target) var raw, name string if err := s.db.QueryRow(`SELECT report_json,name FROM agents WHERE id=?`, agentID).Scan(&raw, &name); err != nil { return AgentAction{}, fmt.Errorf("агент не найден") } var report AgentReport _ = json.Unmarshal([]byte(raw), &report) valid := false switch action { case "systemd-restart", "systemd-reset-failed": if safeUnitName.MatchString(target) { for _, service := range report.Services { if service.Name == target { valid = true break } } } case "docker-restart", "docker-check", "docker-update", "docker-remove": if safeContainerName.MatchString(target) { for _, container := range report.Containers { if (container.ID == target || container.Name == target) && (action != "docker-remove" || container.State != "running") { valid = true break } } } case "updates-refresh", "system-update", "system-reboot": valid = target == "" } if !valid { return AgentAction{}, fmt.Errorf("действие или цель не входят в разрешённый список") } now := time.Now().Unix() result, err := s.db.Exec(`INSERT INTO agent_actions(agent_id,action,target,requested_at,status) VALUES(?,?,?,?, 'queued')`, agentID, action, target, now) if err != nil { return AgentAction{}, err } id, _ := result.LastInsertId() return AgentAction{ID: id, AgentID: agentID, AgentName: name, Action: action, Target: target, RequestedAt: now, Status: "queued"}, nil } func (s *Store) DispatchAgentActions(agentID string) ([]AgentCommand, error) { tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() // Обновление пакетов и Docker-образов может быть долгим. Не выдаём одну // команду повторно, пока предыдущий запуск ещё имеет шанс завершиться. _, _ = tx.Exec(`UPDATE agent_actions SET status='queued',started_at=NULL WHERE agent_id=? AND status='running' AND started_at 4000 { message = message[:4000] } _, _ = s.db.Exec(`UPDATE agent_actions SET status=?,result=?,completed_at=? WHERE id=? AND agent_id=? AND status='running'`, status, message, now, result.ID, agentID) } } func (s *Store) AgentActions(limit int) ([]AgentAction, error) { if limit < 1 || limit > 200 { limit = 100 } rows, err := s.db.Query(`SELECT a.id,a.agent_id,g.name,a.action,a.target,a.requested_at,a.started_at,a.completed_at,a.status,a.result FROM agent_actions a JOIN agents g ON g.id=a.agent_id ORDER BY a.id DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() var out []AgentAction for rows.Next() { var v AgentAction if err = rows.Scan(&v.ID, &v.AgentID, &v.AgentName, &v.Action, &v.Target, &v.RequestedAt, &v.StartedAt, &v.CompletedAt, &v.Status, &v.Result); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } func executeAgentCommand(command AgentCommand) AgentActionResult { result := AgentActionResult{ID: command.ID} var output string switch command.Action { case "systemd-restart": output = runAgentCombinedCommand(30*time.Second, "systemctl", "restart", command.Target) case "systemd-reset-failed": output = runAgentCombinedCommand(15*time.Second, "systemctl", "reset-failed", command.Target) case "docker-restart": output = restartAgentContainer(command.Target) case "docker-update": output = updateAgentContainer(command.Target, true) case "docker-check": output = updateAgentContainer(command.Target, false) case "docker-remove": output = removeAgentContainer(command.Target) case "system-update": output = updateAgentSystem() case "system-reboot": output = scheduleAgentReboot() case "updates-refresh": guestInventory.Lock() guestInventory.value.CollectedAt = time.Time{} guestInventory.Unlock() result.Success = true result.Message = "Проверка обновлений поставлена в очередь" return result default: result.Message = "Команда не разрешена" return result } result.Success = !strings.HasPrefix(output, "ERROR: ") result.Message = strings.TrimSpace(output) if result.Message == "" && result.Success { result.Message = "Выполнено успешно" } return result } func removeAgentContainer(target string) string { transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") }} defer transport.CloseIdleConnections() request, _ := http.NewRequest(http.MethodDelete, "http://docker/containers/"+target+"?v=false&force=false", nil) response, err := (&http.Client{Transport: transport, Timeout: 20 * time.Second}).Do(request) if err != nil { return "ERROR: " + err.Error() } defer response.Body.Close() if response.StatusCode != http.StatusNoContent { message, _ := io.ReadAll(io.LimitReader(response.Body, 2048)) return fmt.Sprintf("ERROR: Docker вернул %s: %s", response.Status, strings.TrimSpace(string(message))) } return "Остановленный контейнер удалён; volumes сохранены" } func updateAgentSystem() string { aptGet, err := exec.LookPath("apt-get") if err != nil { return "ERROR: apt-get не найден; обновление поддерживается для Debian/Ubuntu" } if output := runPrivilegedTransient(5*time.Minute, "update-lists", aptGet, "update"); strings.HasPrefix(output, "ERROR: ") { return explainSystemUpdateError(output) } message := runPrivilegedTransient(30*time.Minute, "upgrade", aptGet, "-y", "upgrade") if strings.HasPrefix(message, "ERROR: ") { return explainSystemUpdateError(message) } guestInventory.Lock() guestInventory.value.CollectedAt = time.Time{} guestInventory.Unlock() return "Обновления системы установлены; список пакетов будет обновлён в следующем отчёте агента" } func explainSystemUpdateError(message string) string { if strings.Contains(strings.ToLower(message), "read-only file system") { return "ERROR: файловая система агента доступна только для чтения. Обновите Dashboard и агент до v0.19.2 или новее, затем повторите операцию." } if len(message) > 1800 { message = message[len(message)-1800:] } return message } func runPrivilegedTransient(timeout time.Duration, suffix, name string, args ...string) string { systemdRun, err := exec.LookPath("systemd-run") if err != nil { return "ERROR: systemd-run не найден; невозможно безопасно выйти из read-only sandbox агента" } unit := fmt.Sprintf("proxmox-dashboard-%s-%d", suffix, time.Now().UnixNano()) commandArgs := []string{"--quiet", "--wait", "--collect", "--pipe", "--unit=" + unit, "--setenv=DEBIAN_FRONTEND=noninteractive", name} commandArgs = append(commandArgs, args...) return runAgentCombinedCommand(timeout, systemdRun, commandArgs...) } func scheduleAgentReboot() string { systemdRun, err := exec.LookPath("systemd-run") if err != nil { return "ERROR: systemd-run не найден" } systemctl, err := exec.LookPath("systemctl") if err != nil { return "ERROR: systemctl не найден" } unit := fmt.Sprintf("proxmox-dashboard-reboot-%d", time.Now().UnixNano()) output := runAgentCombinedCommand(15*time.Second, systemdRun, "--quiet", "--collect", "--unit="+unit, "--on-active=20s", systemctl, "reboot") if strings.HasPrefix(output, "ERROR: ") { return output } return "Перезагрузка запланирована через 20 секунд; кратковременный offline агента ожидаем" } func updateAgentContainer(target string, recreate bool) string { transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") }} defer transport.CloseIdleConnections() client := &http.Client{Transport: transport, Timeout: 10 * time.Second} response, err := client.Get("http://docker/containers/" + target + "/json") if err != nil { return "ERROR: " + err.Error() } defer response.Body.Close() var detail struct { Config struct { Labels map[string]string `json:"Labels"` } `json:"Config"` } if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&detail) != nil { return "ERROR: не удалось получить Compose-конфигурацию контейнера" } labels := detail.Config.Labels service := labels["com.docker.compose.service"] workdir := labels["com.docker.compose.project.working_dir"] files := strings.Split(labels["com.docker.compose.project.config_files"], ",") if service == "" || workdir == "" || len(files) == 0 || !filepath.IsAbs(workdir) || !filepath.IsAbs(files[0]) { return "ERROR: безопасное обновление доступно только для Docker Compose" } if _, err = exec.LookPath("docker"); err != nil { return "ERROR: Docker CLI не найден" } commands := [][]string{{"compose", "-f", files[0], "pull", service}} if recreate { commands = append(commands, []string{"compose", "-f", files[0], "up", "-d", "--no-deps", service}) } for _, args := range commands { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) command := exec.CommandContext(ctx, "docker", args...) command.Dir = workdir output, commandErr := command.CombinedOutput() cancel() if commandErr != nil { return "ERROR: " + commandErr.Error() + ": " + strings.TrimSpace(string(output)) } } if recreate { return "Образ загружен, Compose-контейнер пересоздан" } return "Проверка завершена: образ загружен; если digest изменился, карточка покажет доступное обновление" } func runAgentCombinedCommand(timeout time.Duration, name string, args ...string) string { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() output, err := exec.CommandContext(ctx, name, args...).CombinedOutput() message := strings.TrimSpace(string(output)) if err != nil { return "ERROR: " + err.Error() + ": " + message } return message } func restartAgentContainer(target string) string { transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") }} defer transport.CloseIdleConnections() client := &http.Client{Transport: transport, Timeout: 35 * time.Second} request, _ := http.NewRequest(http.MethodPost, "http://docker/containers/"+target+"/restart?t=10", bytes.NewReader(nil)) response, err := client.Do(request) if err != nil { return "ERROR: " + err.Error() } defer response.Body.Close() body, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) if response.StatusCode >= 300 { return "ERROR: Docker вернул " + response.Status + ": " + strings.TrimSpace(string(body)) } return "Контейнер перезапущен" }