diff --git a/README.md b/README.md index a22fffe..a63083e 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,8 @@ apcaccess status - `GET/POST/PUT/DELETE /api/services` — управление проверками сервисов. - `GET /api/services/history` — семидневная история доступности и отклика. - `GET/PUT/DELETE /api/settings/maintenance` — состояние, запуск и досрочное завершение плановых работ. +- `GET/DELETE /api/agents` — подключённые гостевые агенты. +- `POST /api/agents/enrollment-token` — одноразовый токен регистрации на 15 минут. ## Maintenance-режим @@ -332,6 +334,23 @@ apcaccess status с причиной плановых работ. После окончания окна обычный мониторинг включается автоматически. +## Агенты VM и LXC + +Read-only агент показывает состояние Linux внутри VM/LXC: load average, память, +корневой раздел, uptime, версию агента, Docker-контейнеры, health-check и число +перезапусков. Агент сам подключается к Dashboard каждые 10 секунд; открывать +входящий порт внутри гостевой системы не нужно. + +Откройте `Настройки → Агенты`, укажите адрес Dashboard, доступный из VM/LXC, и +VMID. Кнопка создаст готовую команду установки. Одноразовый токен действует 15 +минут и после регистрации заменяется индивидуальным секретом агента. + +В первой версии агент не выполняет команды и не перезапускает службы или +контейнеры. Для чтения Docker ему требуется доступ к `/var/run/docker.sock`, +поэтому служба устанавливается от root. Сам Dashboard пока не имеет +авторизации: используйте локальную сеть или HTTPS через доверенный reverse +proxy и не публикуйте агентские API напрямую в интернет. + ## Сборка ```bash diff --git a/agent.go b/agent.go new file mode 100644 index 0000000..44f584f --- /dev/null +++ b/agent.go @@ -0,0 +1,457 @@ +package main + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +const agentOfflineAfter = 45 * time.Second + +type agentOptions struct { + Server, EnrollmentToken, Name, ConfigPath string + VMID int +} + +type agentConfig struct { + Server string `json:"server"` + ID string `json:"id"` + Secret string `json:"secret"` + Name string `json:"name"` + VMID int `json:"vmid"` +} + +type AgentContainer struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` + Status string `json:"status"` + Health string `json:"health,omitempty"` + Restarts int64 `json:"restarts"` + CPUPercent float64 `json:"cpuPercent"` + MemoryUsage uint64 `json:"memoryUsage"` + Ports []string `json:"ports,omitempty"` +} + +type AgentReport struct { + Hostname string `json:"hostname"` + MachineID string `json:"machineId"` + Version string `json:"version"` + OS string `json:"os"` + Arch string `json:"arch"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Load1 float64 `json:"load1"` + CPUPercent float64 `json:"cpuPercent"` + MemoryTotal uint64 `json:"memoryTotal"` + MemoryUsed uint64 `json:"memoryUsed"` + RootTotal uint64 `json:"rootTotal"` + RootUsed uint64 `json:"rootUsed"` + DockerAvailable bool `json:"dockerAvailable"` + DockerError string `json:"dockerError,omitempty"` + Containers []AgentContainer `json:"containers"` + CollectedAt int64 `json:"collectedAt"` +} + +type ManagedAgent struct { + ID string `json:"id"` + Name string `json:"name"` + VMID int `json:"vmid"` + Hostname string `json:"hostname"` + Version string `json:"version"` + LastSeen int64 `json:"lastSeen"` + EnrolledAt int64 `json:"enrolledAt"` + Online bool `json:"online"` + RemoteAddress string `json:"remoteAddress"` + Report AgentReport `json:"report"` +} + +type agentEnrollmentRequest struct { + Token, Name, Hostname, MachineID string + VMID int +} + +func randomHex(bytesCount int) (string, error) { + b := make([]byte, bytesCount) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func tokenHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func (s *Store) CreateAgentEnrollmentToken() (string, error) { + token, err := randomHex(24) + if err != nil { + return "", err + } + _, err = s.db.Exec(`INSERT INTO agent_enrollment_tokens(token_hash,created_at,expires_at) VALUES(?,?,?)`, tokenHash(token), time.Now().Unix(), time.Now().Add(15*time.Minute).Unix()) + return token, err +} + +func (s *Store) EnrollAgent(req agentEnrollmentRequest) (agentConfig, error) { + tx, err := s.db.Begin() + if err != nil { + return agentConfig{}, err + } + defer tx.Rollback() + var expires int64 + if err = tx.QueryRow(`SELECT expires_at FROM agent_enrollment_tokens WHERE token_hash=? AND used_at IS NULL`, tokenHash(req.Token)).Scan(&expires); err != nil || expires < time.Now().Unix() { + return agentConfig{}, errors.New("одноразовый токен недействителен или истёк") + } + id, err := randomHex(12) + if err != nil { + return agentConfig{}, err + } + secret, err := randomHex(32) + if err != nil { + return agentConfig{}, err + } + name := strings.TrimSpace(req.Name) + if name == "" { + name = req.Hostname + } + now := time.Now().Unix() + if _, err = tx.Exec(`INSERT INTO agents(id,name,vmid,hostname,machine_id,secret_hash,enrolled_at,last_seen,report_json,remote_address) VALUES(?,?,?,?,?,?,?,?,?,?)`, id, name, req.VMID, req.Hostname, req.MachineID, tokenHash(secret), now, now, `{}`, ""); err != nil { + return agentConfig{}, err + } + if _, err = tx.Exec(`UPDATE agent_enrollment_tokens SET used_at=? WHERE token_hash=?`, now, tokenHash(req.Token)); err != nil { + return agentConfig{}, err + } + if err = tx.Commit(); err != nil { + return agentConfig{}, err + } + return agentConfig{ID: id, Secret: secret, Name: name, VMID: req.VMID}, nil +} + +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))) { + return errors.New("агент не авторизован") + } + data, _ := json.Marshal(report) + _, err := s.db.Exec(`UPDATE agents SET hostname=?,version=?,last_seen=?,report_json=?,remote_address=? WHERE id=?`, report.Hostname, report.Version, time.Now().Unix(), string(data), remote, id) + return err +} + +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 { + return nil, err + } + defer rows.Close() + result := []ManagedAgent{} + for rows.Next() { + var a ManagedAgent + var raw string + if err = rows.Scan(&a.ID, &a.Name, &a.VMID, &a.Hostname, &a.Version, &a.EnrolledAt, &a.LastSeen, &raw, &a.RemoteAddress); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(raw), &a.Report) + a.Online = time.Since(time.Unix(a.LastSeen, 0)) < agentOfflineAfter + result = append(result, a) + } + return result, rows.Err() +} + +func (s *Store) DeleteAgent(id string) error { + _, err := s.db.Exec(`DELETE FROM agents WHERE id=?`, id) + return err +} + +func runGuestAgent(opts agentOptions) error { + config, err := loadAgentConfig(opts.ConfigPath) + if err != nil && !os.IsNotExist(err) { + return err + } + if opts.Server != "" { + config.Server = strings.TrimRight(opts.Server, "/") + } + if opts.Name != "" { + config.Name = opts.Name + } + if opts.VMID > 0 { + config.VMID = opts.VMID + } + if config.Server == "" { + return errors.New("укажите --agent-server") + } + if config.ID == "" { + if opts.EnrollmentToken == "" { + return errors.New("для первого запуска нужен --agent-enroll-token") + } + config, err = enrollRemote(config, opts.EnrollmentToken) + if err != nil { + return err + } + if err = saveAgentConfig(opts.ConfigPath, config); err != nil { + return err + } + } + client := &http.Client{Timeout: 12 * time.Second} + for { + report := collectAgentReport() + data, _ := json.Marshal(report) + req, _ := http.NewRequest(http.MethodPost, config.Server+"/api/agent/report", bytes.NewReader(data)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+config.ID+"."+config.Secret) + response, requestErr := client.Do(req) + if requestErr == nil { + io.Copy(io.Discard, response.Body) + response.Body.Close() + if response.StatusCode >= 300 { + requestErr = fmt.Errorf("сервер вернул %s", response.Status) + } + } + if requestErr != nil { + fmt.Fprintf(os.Stderr, "agent: %v\n", requestErr) + } + time.Sleep(10 * time.Second) + } +} + +func enrollRemote(config agentConfig, token string) (agentConfig, error) { + hostname, _ := os.Hostname() + machineID, _ := os.ReadFile("/etc/machine-id") + payload := agentEnrollmentRequest{Token: token, Name: config.Name, VMID: config.VMID, Hostname: hostname, MachineID: strings.TrimSpace(string(machineID))} + data, _ := json.Marshal(payload) + response, err := http.Post(config.Server+"/api/agent/enroll", "application/json", bytes.NewReader(data)) + if err != nil { + return config, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + return config, fmt.Errorf("регистрация: %s", strings.TrimSpace(string(message))) + } + var credentials agentConfig + if err = json.NewDecoder(response.Body).Decode(&credentials); err != nil { + return config, err + } + credentials.Server = config.Server + return credentials, nil +} + +func loadAgentConfig(path string) (agentConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return agentConfig{}, err + } + var c agentConfig + err = json.Unmarshal(data, &c) + return c, err +} +func saveAgentConfig(path string, c agentConfig) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + data, _ := json.MarshalIndent(c, "", " ") + return os.WriteFile(path, data, 0600) +} + +func collectAgentReport() AgentReport { + hostname, _ := os.Hostname() + machineID, _ := os.ReadFile("/etc/machine-id") + report := AgentReport{Hostname: hostname, MachineID: strings.TrimSpace(string(machineID)), Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH, CollectedAt: time.Now().Unix()} + if data, err := os.ReadFile("/proc/uptime"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) > 0 { + value, _ := strconv.ParseFloat(fields[0], 64) + report.UptimeSeconds = int64(value) + } + } + if data, err := os.ReadFile("/proc/loadavg"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) > 0 { + report.Load1, _ = strconv.ParseFloat(fields[0], 64) + } + } + report.CPUPercent = sampleAgentCPU() + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + values := map[string]uint64{} + for _, line := range strings.Split(string(data), "\n") { + f := strings.Fields(line) + if len(f) >= 2 { + v, _ := strconv.ParseUint(f[1], 10, 64) + values[strings.TrimSuffix(f[0], ":")] = v * 1024 + } + } + report.MemoryTotal = values["MemTotal"] + available := values["MemAvailable"] + if report.MemoryTotal > available { + report.MemoryUsed = report.MemoryTotal - available + } + } + var stat syscall.Statfs_t + if syscall.Statfs("/", &stat) == nil { + report.RootTotal = stat.Blocks * uint64(stat.Bsize) + free := stat.Bavail * uint64(stat.Bsize) + if report.RootTotal > free { + report.RootUsed = report.RootTotal - free + } + } + report.Containers, report.DockerError = collectDockerContainers() + report.DockerAvailable = report.DockerError == "" + return report +} + +func sampleAgentCPU() float64 { + total1, idle1 := readAgentCPU() + time.Sleep(100 * time.Millisecond) + total2, idle2 := readAgentCPU() + if total2 <= total1 { + return 0 + } + totalDelta, idleDelta := total2-total1, idle2-idle1 + return float64(totalDelta-idleDelta) / float64(totalDelta) * 100 +} + +func readAgentCPU() (uint64, uint64) { + data, err := os.ReadFile("/proc/stat") + if err != nil { + return 0, 0 + } + line := strings.SplitN(string(data), "\n", 2)[0] + fields := strings.Fields(line) + if len(fields) < 5 { + return 0, 0 + } + var total uint64 + values := make([]uint64, 0, len(fields)-1) + for _, field := range fields[1:] { + v, _ := strconv.ParseUint(field, 10, 64) + values = append(values, v) + total += v + } + idle := values[3] + if len(values) > 4 { + idle += values[4] + } + return total, idle +} + +func collectDockerContainers() ([]AgentContainer, string) { + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + return []AgentContainer{}, "Docker socket недоступен" + } + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") + }} + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + response, err := client.Get("http://docker/containers/json?all=1") + if err != nil { + return nil, err.Error() + } + defer response.Body.Close() + if response.StatusCode != 200 { + return nil, response.Status + } + var raw []struct { + ID, Image, State, Status string + Names []string + Ports []struct { + IP string + PrivatePort, PublicPort int + Type string + } + } + if err = json.NewDecoder(response.Body).Decode(&raw); err != nil { + return nil, err.Error() + } + result := make([]AgentContainer, 0, len(raw)) + for _, v := range raw { + name := strings.TrimPrefix(firstString(v.Names), "/") + item := AgentContainer{ID: shortID(v.ID), Name: name, Image: v.Image, State: v.State, Status: v.Status} + for _, p := range v.Ports { + if p.PublicPort > 0 { + item.Ports = append(item.Ports, fmt.Sprintf("%d:%d/%s", p.PublicPort, p.PrivatePort, p.Type)) + } + } + inspect, inspectErr := client.Get("http://docker/containers/" + v.ID + "/json") + if inspectErr == nil { + var detail struct { + RestartCount int64 + State struct{ Health *struct{ Status string } } + } + if json.NewDecoder(inspect.Body).Decode(&detail) == nil { + item.Restarts = detail.RestartCount + if detail.State.Health != nil { + item.Health = detail.State.Health.Status + } + } + inspect.Body.Close() + } + statsResponse, statsErr := client.Get("http://docker/containers/" + v.ID + "/stats?stream=false") + if statsErr == nil { + var stats struct { + CPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemCPUUsage uint64 `json:"system_cpu_usage"` + OnlineCPUs uint64 `json:"online_cpus"` + } `json:"cpu_stats"` + PreCPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemCPUUsage uint64 `json:"system_cpu_usage"` + } `json:"precpu_stats"` + MemoryStats struct { + Usage uint64 `json:"usage"` + Stats map[string]uint64 `json:"stats"` + } `json:"memory_stats"` + } + if json.NewDecoder(statsResponse.Body).Decode(&stats) == nil { + cpuDelta := stats.CPUStats.CPUUsage.TotalUsage - stats.PreCPUStats.CPUUsage.TotalUsage + systemDelta := stats.CPUStats.SystemCPUUsage - stats.PreCPUStats.SystemCPUUsage + cpus := stats.CPUStats.OnlineCPUs + if cpus == 0 { + cpus = 1 + } + if systemDelta > 0 { + item.CPUPercent = float64(cpuDelta) / float64(systemDelta) * float64(cpus) * 100 + } + item.MemoryUsage = stats.MemoryStats.Usage + if cache := stats.MemoryStats.Stats["inactive_file"]; item.MemoryUsage > cache { + item.MemoryUsage -= cache + } + } + statsResponse.Body.Close() + } + result = append(result, item) + } + return result, "" +} +func firstString(v []string) string { + if len(v) > 0 { + return v[0] + } + return "" +} +func shortID(v string) string { + if len(v) > 12 { + return v[:12] + } + return v +} diff --git a/agent_test.go b/agent_test.go new file mode 100644 index 0000000..487713a --- /dev/null +++ b/agent_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "testing" + "time" +) + +func TestAgentEnrollmentTokenIsSingleUse(t *testing.T) { + store, err := openStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + token, err := store.CreateAgentEnrollmentToken() + if err != nil { + t.Fatal(err) + } + credentials, err := store.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "docker-01", Hostname: "vm105", VMID: 105}) + if err != nil { + t.Fatal(err) + } + if credentials.ID == "" || credentials.Secret == "" { + t.Fatal("credentials were not generated") + } + if _, err = store.EnrollAgent(agentEnrollmentRequest{Token: token, Hostname: "duplicate"}); err == nil { + t.Fatal("token was accepted twice") + } +} + +func TestAgentReportAuthenticationAndOfflineState(t *testing.T) { + store, err := openStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + token, _ := store.CreateAgentEnrollmentToken() + credentials, _ := store.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "docker", Hostname: "vm", VMID: 101}) + report := AgentReport{Hostname: "vm", Version: "test", DockerAvailable: true, Containers: []AgentContainer{{ID: "abc", Name: "web", State: "running"}}} + if err = store.SaveAgentReport(credentials.ID, "wrong", "127.0.0.1", report); err == nil { + t.Fatal("invalid secret accepted") + } + if err = store.SaveAgentReport(credentials.ID, credentials.Secret, "127.0.0.1", report); err != nil { + t.Fatal(err) + } + agents, err := store.Agents() + if err != nil { + t.Fatal(err) + } + if len(agents) != 1 || !agents[0].Online || len(agents[0].Report.Containers) != 1 { + t.Fatalf("unexpected agents: %#v", agents) + } + _, _ = store.db.Exec(`UPDATE agents SET last_seen=? WHERE id=?`, time.Now().Add(-time.Minute).Unix(), credentials.ID) + agents, _ = store.Agents() + if agents[0].Online { + t.Fatal("stale agent is online") + } +} diff --git a/alerts.go b/alerts.go index 28657ea..fcad311 100644 --- a/alerts.go +++ b/alerts.go @@ -197,6 +197,22 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler add("guests-stopped", "info", "VM/LXC", "Есть остановленные гости", fmt.Sprintf("Остановлено VM/LXC: %d. Это уведомление информационное.", stopped)) } } + for _, agent := range metrics.Agents { + vmSuffix := fmt.Sprintf("-%d", agent.VMID) + if !agent.Online { + add("agent-offline-"+agent.ID+vmSuffix, "critical", "Агенты", agent.Name+": агент не отвечает", fmt.Sprintf("Последний heartbeat: %s назад. VMID %d.", time.Since(time.Unix(agent.LastSeen, 0)).Round(time.Second), agent.VMID)) + continue + } + if agent.Report.DockerAvailable { + for _, container := range agent.Report.Containers { + if container.State != "running" { + add("agent-container-stopped-"+agent.ID+"-"+container.ID+vmSuffix, "warning", "Docker", agent.Name+": контейнер "+container.Name+" остановлен", fmt.Sprintf("%s · VMID %d.", container.Status, agent.VMID)) + } else if container.Health == "unhealthy" { + add("agent-container-health-"+agent.ID+"-"+container.ID+vmSuffix, "critical", "Docker", agent.Name+": контейнер "+container.Name+" unhealthy", fmt.Sprintf("Docker health-check не проходит · VMID %d.", agent.VMID)) + } + } + } + } for _, service := range metrics.Services.Services { for _, endpoint := range service.Endpoints { if !endpoint.Up { diff --git a/main.go b/main.go index c76be3f..352ef5b 100644 --- a/main.go +++ b/main.go @@ -20,11 +20,23 @@ var version = "dev" func main() { showVersion := flag.Bool("version", false, "show version") + agentMode := flag.Bool("agent", false, "run as a read-only guest agent") + agentServer := flag.String("agent-server", "", "dashboard URL used by the guest agent") + agentEnrollToken := flag.String("agent-enroll-token", "", "one-time enrollment token") + agentName := flag.String("agent-name", "", "agent display name") + agentVMID := flag.Int("agent-vmid", 0, "Proxmox VM/LXC ID") + agentConfig := flag.String("agent-config", "/var/lib/proxmox-dashboard-agent/agent.json", "agent configuration path") flag.Parse() if *showVersion { fmt.Println(version) return } + if *agentMode { + if err := runGuestAgent(agentOptions{Server: *agentServer, EnrollmentToken: *agentEnrollToken, Name: *agentName, VMID: *agentVMID, ConfigPath: *agentConfig}); err != nil { + log.Fatalf("Ошибка агента: %v", err) + } + return + } address := os.Getenv("DASHBOARD_ADDR") if address == "" { diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh new file mode 100755 index 0000000..ac82159 --- /dev/null +++ b/scripts/install-agent.sh @@ -0,0 +1,63 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "Запустите команду установки от root." >&2 + exit 1 +fi +if [ -z "${DASHBOARD_URL:-}" ] || [ -z "${ENROLL_TOKEN:-}" ]; then + echo "Не заданы DASHBOARD_URL и ENROLL_TOKEN. Создайте команду в настройках Dashboard." >&2 + exit 1 +fi + +GITEA_URL=${GITEA_URL:-https://git.myown.center} +GITEA_REPOSITORY=${GITEA_REPOSITORY:-maxim/ProxmoxDash} +VMID=${VMID:-0} +AGENT_NAME=${AGENT_NAME:-$(hostname)} + +release_json=$(curl -fsSL "$GITEA_URL/api/v1/repos/$GITEA_REPOSITORY/releases/latest") +download_url=$(printf '%s' "$release_json" | sed -n 's/.*"browser_download_url":"\([^"]*\/proxmox-dashboard-linux-amd64\)".*/\1/p' | head -n 1) +if [ -z "$download_url" ]; then + echo "В последнем релизе не найден Linux-бинарник." >&2 + exit 1 +fi + +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 + +umask 077 +{ + printf 'DASHBOARD_URL=%s\n' "$DASHBOARD_URL" + printf 'ENROLL_TOKEN=%s\n' "$ENROLL_TOKEN" + printf 'AGENT_NAME=%s\n' "$AGENT_NAME" + printf 'VMID=%s\n' "$VMID" +} > /etc/proxmox-dashboard-agent.env + +cat > /etc/systemd/system/proxmox-dashboard-agent.service <<'UNIT' +[Unit] +Description=Proxmox Dashboard read-only guest agent +After=network-online.target docker.service +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} +Restart=always +RestartSec=5 +NoNewPrivileges=true +ProtectHome=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/var/lib/proxmox-dashboard-agent + +[Install] +WantedBy=multi-user.target +UNIT + +systemctl daemon-reload +systemctl enable --now proxmox-dashboard-agent.service +echo "Агент установлен. Через несколько секунд он появится в Dashboard." diff --git a/store.go b/store.go index eec9a70..636ced4 100644 --- a/store.go +++ b/store.go @@ -138,6 +138,8 @@ func openStore(path string) (*Store, error) { `CREATE INDEX IF NOT EXISTS service_checks_service_ts_idx ON service_checks(service_id,ts)`, `CREATE TABLE IF NOT EXISTS entity_history (kind TEXT NOT NULL,name TEXT NOT NULL,ts INTEGER NOT NULL,value1 REAL,value2 REAL,PRIMARY KEY(kind,name,ts))`, `CREATE INDEX IF NOT EXISTS entity_history_kind_ts_idx ON entity_history(kind,ts)`, + `CREATE TABLE IF NOT EXISTS agent_enrollment_tokens (token_hash TEXT PRIMARY KEY, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER)`, + `CREATE TABLE IF NOT EXISTS agents (id TEXT PRIMARY KEY, name TEXT NOT NULL, vmid INTEGER NOT NULL DEFAULT 0, hostname TEXT NOT NULL DEFAULT '', machine_id TEXT NOT NULL DEFAULT '', secret_hash TEXT NOT NULL, enrolled_at INTEGER NOT NULL, last_seen INTEGER NOT NULL, version TEXT NOT NULL DEFAULT '', report_json TEXT NOT NULL DEFAULT '{}', remote_address TEXT NOT NULL DEFAULT '')`, } for _, statement := range statements { if _, err := db.Exec(statement); err != nil { @@ -233,7 +235,7 @@ func (s *Store) SaveThresholds(value AlertThresholds) error { } func defaultEmailSettings() EmailSettings { - return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Proxmox", "Dashboard", "Сервисы", "Система", "Сеть"}} + return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Proxmox", "Dashboard", "Сервисы", "Система", "Сеть", "Агенты", "Docker"}} } func (s *Store) EmailSettings(includePassword bool) EmailSettings { diff --git a/web.go b/web.go index 48dda2c..a94b644 100644 --- a/web.go +++ b/web.go @@ -31,6 +31,7 @@ type dashboardMetrics struct { Monitor MonitorHealth `json:"monitor"` UPSShutdown UPSShutdownSettings `json:"upsShutdown"` Trends []TrendAnomaly `json:"trends"` + Agents []ManagedAgent `json:"agents"` Timestamp time.Time `json:"timestamp"` } @@ -64,6 +65,7 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle metrics.Activity = activity.collect(metrics.Guests) attributeDiskIO(&metrics, ioAttribution.collect(metrics.Guests)) metrics.Trends = trends.collect(metrics, store) + metrics.Agents, _ = store.Agents() decorateNetworkInterfaces(&metrics.Network, metrics.Guests) metrics.MaintenanceWindow = store.MaintenanceWindow() allAlerts := correlateAlerts(metrics, evaluateAlerts(metrics, thresholds)) @@ -214,6 +216,69 @@ func routes(collector *cpuCollector, store *Store) http.Handler { w.WriteHeader(http.StatusMethodNotAllowed) } }) + mux.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + agents, err := store.Agents() + writeJSON(w, agents, err) + case http.MethodDelete: + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + http.Error(w, "Не указан агент", http.StatusBadRequest) + return + } + writeJSON(w, map[string]bool{"deleted": true}, store.DeleteAgent(id)) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/api/agents/enrollment-token", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + token, err := store.CreateAgentEnrollmentToken() + writeJSON(w, map[string]any{"token": token, "expiresInSeconds": 900}, err) + }) + mux.HandleFunc("/api/agent/enroll", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var request agentEnrollmentRequest + if json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&request) != nil || strings.TrimSpace(request.Token) == "" { + http.Error(w, "Некорректная регистрация", http.StatusBadRequest) + return + } + credentials, err := store.EnrollAgent(request) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + writeJSON(w, credentials, nil) + }) + mux.HandleFunc("/api/agent/report", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + credentials := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + id, secret, ok := strings.Cut(credentials, ".") + if !ok || id == "" || secret == "" { + http.Error(w, "Агент не авторизован", http.StatusUnauthorized) + return + } + var report AgentReport + if json.NewDecoder(io.LimitReader(r.Body, 2<<20)).Decode(&report) != nil { + http.Error(w, "Некорректный отчёт", http.StatusBadRequest) + return + } + if err := store.SaveAgentReport(id, secret, r.RemoteAddr, report); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + writeJSON(w, map[string]bool{"accepted": true}, nil) + }) mux.HandleFunc("/api/alerts/history", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) @@ -419,6 +484,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} .snapshots .fill{background:linear-gradient(90deg,#9d8cff,#ff78b8)}.tasks .fill{background:linear-gradient(90deg,#43c6d9,#6ba8ff)}.homelab-map .fill{background:linear-gradient(90deg,#55d58a,#9d8cff)}.map-board{display:grid;grid-template-columns:170px 1fr 1fr 1fr;gap:14px;padding:18px;min-width:680px}.map-column{display:grid;align-content:start;gap:9px}.map-column-title{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.1em}.map-node{padding:12px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.map-node.good{border-color:#2d6245}.map-node.bad{border-color:#71362f}.map-node.warn{border-color:#6d4c2c}.map-node strong{display:block}.map-node small{display:block;color:var(--muted);margin-top:3px}.map-scroll{overflow-x:auto}.backup-check{margin-top:9px;color:var(--muted);font-size:12px}.task-filter{display:flex;gap:6px;padding:12px 16px;overflow-x:auto} .topology-board{padding:18px;min-width:620px}.topology-host{margin-bottom:14px}.topology-grid{display:grid;grid-template-columns:repeat(2,minmax(270px,1fr));gap:12px}.topology-group{padding:14px;border:1px solid var(--line);border-radius:15px;background:rgba(100,130,150,.04)}.topology-group-head{display:flex;justify-content:space-between;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--line)}.topology-section{margin-top:11px}.topology-section-title{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px}.topology-items{display:flex;flex-wrap:wrap;gap:6px}.topology-chip{padding:6px 9px;border:1px solid var(--line);border-radius:9px;background:var(--panel);font-size:12px}.topology-chip.good{border-color:#2d6245}.topology-chip.bad{border-color:#71362f}.topology-unassigned{margin-top:14px}@media(max-width:700px){.topology-grid{grid-template-columns:1fr}} .monitor .fill{background:linear-gradient(90deg,#55d58a,#43c6d9)}.shutdown-plan{display:grid;gap:8px;margin-top:12px}.shutdown-step{padding:10px 12px;border:1px solid var(--line);border-radius:10px}.infra-status{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:14px 0}.infra-status>div{padding:11px;border:1px solid var(--line);border-radius:10px}.infra-status b{display:block;margin-top:4px}@media(max-width:700px){.infra-status{grid-template-columns:1fr 1fr}} +.agents .fill{background:linear-gradient(90deg,#55d58a,#6ba8ff)}.agent-command{display:block;width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--text);font:12px/1.5 ui-monospace,monospace;overflow-wrap:anywhere;white-space:pre-wrap}.agent-containers{display:flex;flex-wrap:wrap;gap:7px;margin-top:13px}