diff --git a/README.md b/README.md index a290914..ae651a0 100644 --- a/README.md +++ b/README.md @@ -586,3 +586,48 @@ HTTP-проверки остаются в истории и возвращают новые правила сразу применяются и к старым записям. Срабатывания и решения CrowdSec, а также общие счётчики firewall, никогда не скрываются этим фильтром. Сетевые потоки по-прежнему хранятся только последним снимком, без истории. + +## Alpine LXC и Docker NPM/NPM+ (v0.23.0) + +Установщик агента поддерживает Linux **x86_64/amd64** с systemd или OpenRC +(Alpine LXC). Для Alpine команда из настроек сначала устанавливает curl и +CA-сертификаты, если curl отсутствует. Бинарник статический, без glibc; +скачивание проверяется по SHA-256. OpenRC-служба включается в default runlevel +и использует supervise-daemon: перезапускает агент после ошибки и обновления. +Команды: `rc-service proxmox-dashboard-agent status/restart` (выберите одно +действие). Журнал: `/var/log/proxmox-dashboard-agent.log`; его ротацию настройте +средствами ОС. Удаление — штатным `scripts/uninstall-agent.sh`. + +Обновление пакетов ОС и управление systemd-службами из интерфейса остаются +функциями Debian/Ubuntu/systemd; этот релиз добавляет установку и работу агента +на OpenRC, а не эмуляцию systemd. Включённый сетевой сбор на Alpine ставит +`conntrack-tools` через `apk add --no-cache`. Ограничения CAP_NET_ADMIN и +сетевого namespace LXC сохраняются; никаких изменений конфигурации Proxmox, +privileged/nesting, firewall или sysctl агент не делает. + +После выбора агента Docker-хоста и включения карты трафика агент автоматически: + +- проверяет стандартные каталоги NPM и NPM+; +- находит запущенные контейнеры с nginx-proxy-manager/npmplus в имени образа + и определяет каталоги логов по mounts `/data`, включая именованные volumes + и вложенные bind mounts; +- читает обычный формат NPM и формат NPM+ `access.log`, включая ротацию; +- находит единственный контейнер образа `crowdsecurity/crowdsec` и выполняет + только `cscli alerts list` через локальный Docker socket. Docker CLI, новый + bouncer key и опубликованный LAPI не нужны. Если контейнера нет, используется + прежний локальный `cscli`. + +Поиск повторяется раз в минуту, поэтому пересоздание контейнеров подхватывается +автоматически. При нескольких CrowdSec задайте точный контейнер вручную: +`DASHBOARD_CROWDSEC_CONTAINER=имя` в `/etc/proxmox-dashboard-agent.env` и +перезапустите агент. Каталог логов можно переопределить переменной +`DASHBOARD_NPM_LOG_DIR=/путь/к/логам`; `off` выключает сбор трафика этого агента. +Используется локальный `/var/run/docker.sock`; rootless/удалённые Docker engines +и логи только в stdout этим обнаружением не поддерживаются. + +NPM+ должен действительно писать access-логи: включите `LOGROTATE=true` в его +Compose и пересоздайте контейнер, если логирование выключено. Агент показывает +подсказку при отсутствии логов, но не меняет Compose приложений. Это настройка +[NPM+](https://github.com/ZoeyVid/NPMplus/blob/develop/README.md), а не Dashboard. +Stream-логи NPM+ не считаются HTTP-запросами; сетевые потоки собираются conntrack. +Как и раньше, первый запуск чтения начинается с конца существующих логов. diff --git a/agent_installer_test.go b/agent_installer_test.go new file mode 100644 index 0000000..4a2eaec --- /dev/null +++ b/agent_installer_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Run the real installer in a temporary tree with package/service/download +// commands stubbed. This must never touch the host's init system or packages. +func TestAgentInstallerOpenRC(t *testing.T) { + for _, init := range []string{"openrc", "systemd"} { + t.Run(init, func(t *testing.T) { + root := t.TempDir() + bin := filepath.Join(root, "bin") + os.MkdirAll(bin, 0755) + write := func(name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nset -eu\n"+body), 0755); err != nil { + t.Fatal(err) + } + } + write("id", "echo 0\n") + write("uname", "echo x86_64\n") + hash := strings.Repeat("a", 64) + write("curl", `case "$1" in -fsSL) ;; *) exit 4 ;; esac +if [ "$#" = 2 ]; then printf '%s' '{"browser_download_url":"https://example.test/proxmox-dashboard-linux-amd64"}'; exit; fi +case "$2" in *.sha256) printf '%s\n' '`+hash+` proxmox-dashboard-linux-amd64' > "$4" ;; *) printf '#!/bin/sh\nprintf "%%s\\n" "$@" > "$CAPTURE"\n' > "$4" ;; esac +`) + write("sha256sum", "printf '%s\\n' '"+hash+" binary'\n") + for _, name := range []string{"systemctl", "rc-service", "rc-update", "supervise-daemon"} { + write(name, `printf '%s %s\n' "$(basename "$0")" "$*" >> "$CALLS"`+"\n") + } + raw, err := os.ReadFile("scripts/install-agent.sh") + if err != nil { + t.Fatal(err) + } + // Redirect every persistent absolute path, including those in generated scripts. + s := string(raw) + for _, prefix := range []string{"/etc/", "/var/lib/", "/usr/local/", "/run/", "/var/log/"} { + s = strings.ReplaceAll(s, prefix, root+prefix) + } + for _, dir := range []string{"etc/systemd/system", "etc/init.d", "var/log"} { + os.MkdirAll(filepath.Join(root, dir), 0755) + } + if init == "systemd" { + os.MkdirAll(filepath.Join(root, "run/systemd/system"), 0755) + } + script := filepath.Join(root, "install.sh") + os.WriteFile(script, []byte(s), 0700) + env := append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "DASHBOARD_URL=https://dashboard.test", "ENROLL_TOKEN=token", "AGENT_NAME=Alpine $(touch should-not-exist)", "VMID=102", "CALLS="+root+"/calls", "CAPTURE="+root+"/args") + cmd := exec.Command("sh", script) + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %s", err, out) + } + calls, _ := os.ReadFile(root + "/calls") + if init == "openrc" { + if strings.Contains(string(calls), "systemctl") || !strings.Contains(string(calls), "rc-update add proxmox-dashboard-agent default") || !strings.Contains(string(calls), "rc-service proxmox-dashboard-agent restart") { + t.Fatalf("%s", calls) + } + cmd = exec.Command("sh", root+"/usr/local/sbin/proxmox-dashboard-agent-run") + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("launcher: %v %s", err, out) + } + args, _ := os.ReadFile(root + "/args") + if !strings.Contains(string(args), "Alpine $(touch should-not-exist)\n") { + t.Fatalf("name was not passed literally: %s", args) + } + } else if !strings.Contains(string(calls), "systemctl restart proxmox-dashboard-agent.service") { + t.Fatalf("%s", calls) + } + info, _ := os.Stat(root + "/etc/proxmox-dashboard-agent.env") + if info.Mode().Perm() != 0600 { + t.Fatalf("credentials permissions: %v", info.Mode()) + } + }) + } +} diff --git a/agent_network_setup.go b/agent_network_setup.go index e431480..b0d2655 100644 --- a/agent_network_setup.go +++ b/agent_network_setup.go @@ -60,6 +60,16 @@ func installNetworkDependency() error { if os.Geteuid() != 0 { return fmt.Errorf("для автоустановки нужны права root") } + if apk, err := exec.LookPath("apk"); err == nil { + out := runAgentCombinedCommand(10*time.Minute, apk, "add", "--no-cache", "conntrack-tools") + if strings.HasPrefix(out, "ERROR: ") { + return fmt.Errorf("%s", out) + } + if _, err := exec.LookPath("conntrack"); err != nil { + return fmt.Errorf("apk завершился, но conntrack не найден") + } + return nil + } apt, err := exec.LookPath("apt-get") if err != nil { return fmt.Errorf("автоустановка поддерживается на Debian/Ubuntu с apt-get; установите conntrack вручную") diff --git a/agent_traffic.go b/agent_traffic.go index 624e46a..2d05578 100644 --- a/agent_traffic.go +++ b/agent_traffic.go @@ -26,7 +26,7 @@ var npmTrafficPattern = regexp.MustCompile(`^\[([^]]+)\] .*? (\d{3}) - ([A-Z]+) func parseNPMTraffic(line string) (TrafficEvent, bool) { m := npmTrafficPattern.FindStringSubmatch(line) if m == nil { - return TrafficEvent{}, false + return parseNPMPlusTraffic(line) } ip := net.ParseIP(m[6]) if ip == nil { @@ -55,11 +55,23 @@ type trafficAgentState struct { CrowdSeen map[string]int64 `json:"crowdSeen"` } -func (state *trafficAgentState) readLogs(dir string) (int, error) { +func (state *trafficAgentState) readLogs(dirs ...string) (int, error) { state.BacklogBytes = 0 - paths, err := filepath.Glob(filepath.Join(dir, "*_access.log*")) - if err != nil { - return 0, err + var paths []string + seenPaths := map[string]bool{} + for _, dir := range dirs { + for _, pattern := range []string{"*_access.log*", "access.log*"} { + matches, err := filepath.Glob(filepath.Join(dir, pattern)) + if err != nil { + return 0, err + } + for _, path := range matches { + if !seenPaths[path] { + paths = append(paths, path) + seenPaths[path] = true + } + } + } } sort.Strings(paths) files := 0 @@ -85,7 +97,7 @@ func (state *trafficAgentState) readLogs(dir string) (int, error) { f.Close() continue } - active := strings.HasSuffix(path, "_access.log") + active := strings.HasSuffix(path, "_access.log") || filepath.Base(path) == "access.log" if active { files++ } @@ -229,21 +241,31 @@ func parseCrowdTraffic(data []byte) ([]TrafficEvent, error) { // cscli uses the existing local machine credentials. No new bouncer key or // public LAPI port is needed, and only the read-only alerts list is executed. -func collectCrowdTraffic(ctx context.Context) ([]TrafficEvent, bool, error) { +func collectCrowdTraffic(ctx context.Context, container ...string) ([]TrafficEvent, bool, error) { ctx, cancel := context.WithTimeout(ctx, 6*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "cscli", "alerts", "list", "--since", "24h", "--limit", "50", "-o", "json") - var output limitedTrafficOutput - cmd.Stdout = &output - if err := cmd.Run(); err != nil { - return nil, false, errors.New("cscli alerts list недоступен; проверьте права агента и локальную LAPI") + var data []byte + if len(container) > 0 && container[0] != "" { + var err error + data, err = dockerCrowdOutput(ctx, container[0]) + if err != nil { + return nil, false, errors.New("CrowdSec в Docker недоступен; проверьте контейнер и Docker socket") + } + } else { + cmd := exec.CommandContext(ctx, "cscli", "alerts", "list", "--since", "24h", "--limit", "50", "-o", "json") + var output limitedTrafficOutput + cmd.Stdout = &output + if err := cmd.Run(); err != nil { + return nil, false, errors.New("cscli недоступен на хосте; контейнер CrowdSec не обнаружен") + } + data = output.Bytes() } - events, err := parseCrowdTraffic(output.Bytes()) + events, err := parseCrowdTraffic(data) if err != nil { return nil, false, errors.New("Не удалось разобрать JSON CrowdSec") } var raw []json.RawMessage - json.Unmarshal(output.Bytes(), &raw) + json.Unmarshal(data, &raw) return events, len(raw) >= 50, nil } @@ -287,9 +309,6 @@ func runAgentTraffic(ctx context.Context, config agentConfig, stateDir string) { if dir == "off" { return } - if dir == "" { - dir = "/opt/npm/data/logs" - } statePath := filepath.Join(stateDir, "traffic-state.json") state := trafficAgentState{StartedAt: time.Now().Unix(), Cursors: map[string]trafficCursor{}, CrowdSeen: map[string]int64{}} @@ -310,6 +329,8 @@ func runAgentTraffic(ctx context.Context, config agentConfig, stateDir string) { } client := &http.Client{Timeout: 10 * time.Second} lastCrowd := time.Time{} + lastDiscovery := time.Time{} + discovery := trafficDiscovery{} crowdAt := int64(0) crowdError := "" crowdLimited := false @@ -321,9 +342,21 @@ func runAgentTraffic(ctx context.Context, config agentConfig, stateDir string) { return default: } - files, logErr := state.readLogs(dir) + if time.Since(lastDiscovery) >= time.Minute { + discovery = discoverTraffic(ctx) + lastDiscovery = time.Now() + } + files, logErr := state.readLogs(discovery.Dirs...) + if logErr != nil { + logErr = fmt.Errorf("%s; проверьте access-логи и LOGROTATE=true у NPM+", logErr) + } if len(state.Pending) < 400 && time.Since(lastCrowd) >= 15*time.Second { - events, limited, err := collectCrowdTraffic(ctx) + events, limited, err := collectCrowdTraffic(ctx, discovery.CrowdContainer) + if discovery.Error != "" { + if _, localErr := exec.LookPath("cscli"); localErr != nil { + err = errors.New(discovery.Error) + } + } crowdLimited = limited lastCrowd = time.Now() if err != nil { diff --git a/agent_traffic_discovery.go b/agent_traffic_discovery.go new file mode 100644 index 0000000..8b234da --- /dev/null +++ b/agent_traffic_discovery.go @@ -0,0 +1,209 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +type trafficDockerContainer struct { + ID string `json:"Id"` + Image string + Names []string + Mounts []struct{ Source, Destination string } +} +type trafficDiscovery struct { + Dirs []string + CrowdContainer string + Error string +} + +func trafficDockerClient() *http.Client { + return &http.Client{Timeout: 8 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") + }}} +} +func discoverTraffic(ctx context.Context) trafficDiscovery { + d := trafficDiscovery{Dirs: []string{"/opt/npm/data/logs", "/opt/npmplus/nginx/logs", "/opt/npmplus/nginx"}} + client := trafficDockerClient() + defer client.CloseIdleConnections() + req, _ := http.NewRequestWithContext(ctx, "GET", "http://docker/containers/json", nil) + res, err := client.Do(req) + if err == nil { + defer res.Body.Close() + var containers []trafficDockerContainer + if res.StatusCode == 200 && json.NewDecoder(io.LimitReader(res.Body, 2<<20)).Decode(&containers) == nil { + d = trafficSources(containers, d.Dirs) + } else { + d.Error = "Не удалось прочитать список Docker-контейнеров" + } + } else if _, e := os.Stat("/var/run/docker.sock"); e == nil { + d.Error = "Docker socket недоступен агенту" + } + if dir := os.Getenv("DASHBOARD_NPM_LOG_DIR"); dir != "" { + d.Dirs = []string{dir} + } + if container := os.Getenv("DASHBOARD_CROWDSEC_CONTAINER"); container != "" { + d.CrowdContainer = container + d.Error = "" + } + return d +} +func trafficSources(containers []trafficDockerContainer, defaults []string) trafficDiscovery { + d := trafficDiscovery{Dirs: append([]string{}, defaults...)} + var crowd []string + for _, c := range containers { + img := strings.ToLower(c.Image) + if strings.Contains(img, "crowdsecurity/crowdsec:") || strings.HasSuffix(img, "crowdsecurity/crowdsec") || strings.Contains(img, "crowdsecurity/crowdsec@") { + crowd = append(crowd, c.ID) + } + if !strings.Contains(img, "nginx-proxy-manager") && !strings.Contains(img, "npmplus") { + continue + } + for _, target := range []string{"/data/logs", "/data/nginx/logs", "/data/nginx"} { + // Use the most specific mount, including a nested bind or named volume. + best, source := "", "" + for _, m := range c.Mounts { + dst := filepath.Clean(m.Destination) + if filepath.IsAbs(m.Source) && (target == dst || strings.HasPrefix(target, dst+"/")) && len(dst) > len(best) { + best = dst + source = filepath.Join(m.Source, strings.TrimPrefix(target, dst)) + } + } + if source != "" { + d.Dirs = append(d.Dirs, source) + } + } + } + if len(crowd) == 1 { + d.CrowdContainer = crowd[0] + } else if len(crowd) > 1 { + d.Error = "Найдено несколько CrowdSec: задайте DASHBOARD_CROWDSEC_CONTAINER" + } + unique := map[string]bool{} + dirs := []string{} + for _, dir := range d.Dirs { + dir = filepath.Clean(dir) + if !unique[dir] { + unique[dir] = true + dirs = append(dirs, dir) + } + } + d.Dirs = dirs + return d +} + +// Docker exec uses a fixed read-only command; no shell, credential extraction or published LAPI. +func dockerCrowdOutput(ctx context.Context, container string) ([]byte, error) { + client := trafficDockerClient() + defer client.CloseIdleConnections() + return dockerCrowdWithClient(ctx, container, client) +} +func dockerCrowdWithClient(ctx context.Context, container string, client *http.Client) ([]byte, error) { + if !safeContainerName.MatchString(container) || strings.HasPrefix(container, "-") { + return nil, fmt.Errorf("некорректный контейнер CrowdSec") + } + call := func(path string, payload any) (*http.Response, error) { + raw, _ := json.Marshal(payload) + req, _ := http.NewRequestWithContext(ctx, "POST", "http://docker"+path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + return client.Do(req) + } + res, err := call("/containers/"+container+"/exec", map[string]any{"AttachStdout": true, "AttachStderr": true, "Tty": false, "Cmd": []string{"cscli", "alerts", "list", "--since", "24h", "--limit", "50", "-o", "json"}}) + if err != nil { + return nil, err + } + var created struct{ ID string } + err = json.NewDecoder(io.LimitReader(res.Body, 4096)).Decode(&created) + res.Body.Close() + if err != nil || res.StatusCode != 201 || !safeContainerName.MatchString(created.ID) { + return nil, fmt.Errorf("Docker не создал cscli exec") + } + res, err = call("/exec/"+created.ID+"/start", map[string]any{"Detach": false, "Tty": false}) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != 200 { + return nil, fmt.Errorf("Docker cscli exec: %s", res.Status) + } + output, err := readDockerTrafficOutput(res.Body) + if err != nil { + return nil, err + } + req, _ := http.NewRequestWithContext(ctx, "GET", "http://docker/exec/"+created.ID+"/json", nil) + check, err := client.Do(req) + if err != nil { + return nil, err + } + defer check.Body.Close() + var status struct { + Running bool + ExitCode int + } + if check.StatusCode != 200 || json.NewDecoder(io.LimitReader(check.Body, 4096)).Decode(&status) != nil || status.Running || status.ExitCode != 0 { + return nil, fmt.Errorf("cscli внутри Docker завершился с ошибкой") + } + return output, nil +} +func readDockerTrafficOutput(r io.Reader) ([]byte, error) { + var out bytes.Buffer + total := uint32(0) + for { + var header [8]byte + _, err := io.ReadFull(r, header[:]) + if err == io.EOF { + return out.Bytes(), nil + } + if err != nil { + return nil, err + } + n := binary.BigEndian.Uint32(header[4:]) + if n > 4<<20 || total+n > 4<<20 { + return nil, fmt.Errorf("Docker output too large") + } + total += n + if header[0] != 1 && header[0] != 2 { + return nil, fmt.Errorf("invalid Docker stream") + } + if header[0] == 1 { + _, err = io.CopyN(&out, r, int64(n)) + } else { + _, err = io.CopyN(io.Discard, r, int64(n)) + } + if err != nil { + return nil, err + } + } +} + +var npmPlusTrafficPattern = regexp.MustCompile(`^\[([^]]+)\] (\S+) (\S+) [0-9.]+ "([A-Z]+) ([^" ]+) HTTP/[0-9.]+" (\d{3}) `) + +func parseNPMPlusTraffic(line string) (TrafficEvent, bool) { + m := npmPlusTrafficPattern.FindStringSubmatch(line) + if m == nil { + return TrafficEvent{}, false + } + ip := net.ParseIP(m[3]) + ts, err := time.Parse("02/Jan/2006:15:04:05 -0700", m[1]) + if ip == nil || err != nil { + return TrafficEvent{}, false + } + host := m[2] + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + status, _ := strconv.Atoi(m[6]) + return TrafficEvent{Time: ts.Unix(), Kind: "request", IP: ip.String(), Domain: trafficText(host, 253), Method: m[4], Path: trafficPath(m[5]), Status: status}, true +} diff --git a/agent_traffic_discovery_test.go b/agent_traffic_discovery_test.go new file mode 100644 index 0000000..d470831 --- /dev/null +++ b/agent_traffic_discovery_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNPMPlusTraffic(t *testing.T) { + for _, ip := range []string{"8.8.8.8", "2001:4860:4860::8888"} { + line := `[14/Oct/2026:16:24:17 +0000] example.test:8443 ` + ip + ` 0.001 "GET /health?token=secret HTTP/3.0" 200 123 "-" "test" "-" "text/html" TLSv1.3 - - -` + e, ok := parseNPMTraffic(line) + if !ok || e.IP != ip || e.Domain != "example.test" || e.Path != "/health" || e.Status != 200 { + t.Fatalf("%+v %v", e, ok) + } + } + if _, ok := parseNPMTraffic(`[14/Oct/2026:16:24:17 +0000] 443 8.8.8.8 UDP - 200 10 20 0.001`); ok { + t.Fatal("stream counted as HTTP") + } +} +func TestDockerTrafficDiscovery(t *testing.T) { + var containers []trafficDockerContainer + json.Unmarshal([]byte(`[{"Id":"aaa","Image":"ghcr.io/zoeyvid/npmplus:latest","Mounts":[{"Source":"/var/lib/docker/volumes/npm/_data","Destination":"/data"},{"Source":"/srv/logs","Destination":"/data/nginx/logs"}]},{"Id":"bbb","Image":"crowdsecurity/crowdsec:latest"}]`), &containers) + d := trafficSources(containers, nil) + if d.CrowdContainer != "bbb" || d.Error != "" || strings.Join(d.Dirs, ",") != "/var/lib/docker/volumes/npm/_data/logs,/srv/logs,/var/lib/docker/volumes/npm/_data/nginx" { + t.Fatalf("%+v", d) + } + containers = append(containers, containers[1]) + d = trafficSources(containers, nil) + if d.CrowdContainer != "" || d.Error == "" { + t.Fatal("ambiguous CrowdSec silently chosen") + } +} +func TestDockerTrafficDemux(t *testing.T) { + var b bytes.Buffer + for _, frame := range []struct { + stream byte + text string + }{{2, "diagnostic"}, {1, "["}, {1, "]"}} { + h := []byte{frame.stream, 0, 0, 0, 0, 0, 0, 0} + binary.BigEndian.PutUint32(h[4:], uint32(len(frame.text))) + b.Write(h) + b.WriteString(frame.text) + } + out, err := readDockerTrafficOutput(&b) + if err != nil || string(out) != "[]" { + t.Fatalf("%q %v", out, err) + } + if _, err := readDockerTrafficOutput(bytes.NewReader([]byte{1, 0, 0})); err == nil { + t.Fatal("truncation accepted") + } + if _, err := readDockerTrafficOutput(bytes.NewReader([]byte{1, 0, 0, 0, 255, 255, 255, 255})); err == nil { + t.Fatal("oversized frame accepted") + } +} +func TestTrafficMultipleDirectoriesAndNPMPlusRotation(t *testing.T) { + a, b := t.TempDir(), t.TempDir() + p := filepath.Join(b, "access.log") + os.WriteFile(filepath.Join(a, "proxy-host-1_access.log"), nil, 0600) + os.WriteFile(p, nil, 0600) + s := trafficAgentState{Cursors: map[string]trafficCursor{}} + if n, err := s.readLogs(a, b, a); err != nil || n != 2 { + t.Fatalf("files %d: %v", n, err) + } + line := "[" + time.Now().UTC().Format("02/Jan/2006:15:04:05 -0700") + `] example.test 8.8.8.8 0.1 "GET / HTTP/2.0" 200 123 "-"` + "\n" + os.WriteFile(p, []byte(line), 0600) + s.readLogs(a, b) + if len(s.Pending) != 1 { + t.Fatal("NPM+ event missing") + } + s.Pending = nil + os.Rename(p, p+".1") + os.WriteFile(p, []byte(line), 0600) + s.readLogs(a, b) + if len(s.Pending) != 1 { + t.Fatalf("rotation duplicated/lost events: %d", len(s.Pending)) + } +} + +type trafficRoundTrip func(*http.Request) (*http.Response, error) + +func (f trafficRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func TestDockerCrowdExec(t *testing.T) { + for _, exitCode := range []int{0, 1} { + calls := 0 + client := &http.Client{Transport: trafficRoundTrip(func(r *http.Request) (*http.Response, error) { + calls++ + body := "" + status := 200 + switch calls { + case 1: + if r.Method != "POST" || r.URL.Path != "/containers/crowdsec/exec" { + t.Fatalf("unexpected %s %s", r.Method, r.URL) + } + var payload struct { + Cmd []string + Tty bool + } + json.NewDecoder(r.Body).Decode(&payload) + if strings.Join(payload.Cmd, " ") != "cscli alerts list --since 24h --limit 50 -o json" || payload.Tty { + t.Fatalf("unexpected command %+v", payload) + } + status = 201 + body = `{"Id":"exec123"}` + case 2: + if r.URL.Path != "/exec/exec123/start" { + t.Fatal(r.URL) + } + body = string([]byte{1, 0, 0, 0, 0, 0, 0, 2}) + "[]" + case 3: + if r.Method != "GET" || r.URL.Path != "/exec/exec123/json" { + t.Fatal(r.URL) + } + body = fmt.Sprintf(`{"Running":false,"ExitCode":%d}`, exitCode) + default: + t.Fatal("extra Docker request") + } + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })} + out, err := dockerCrowdWithClient(context.Background(), "crowdsec", client) + if calls != 3 || (exitCode == 0 && (err != nil || string(out) != "[]")) || (exitCode != 0 && err == nil) { + t.Fatalf("calls=%d output=%s err=%v", calls, out, err) + } + } +} diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh index 22e5b96..1c4bdaa 100755 --- a/scripts/install-agent.sh +++ b/scripts/install-agent.sh @@ -10,11 +10,28 @@ if [ -z "${DASHBOARD_URL:-}" ] || [ -z "${ENROLL_TOKEN:-}" ]; then exit 1 fi +case "$(uname -m)" in x86_64|amd64) ;; *) echo "Поддерживается Linux amd64 (x86_64)." >&2; exit 1 ;; esac +if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + agent_init=systemd +elif command -v rc-service >/dev/null 2>&1 && command -v supervise-daemon >/dev/null 2>&1; then + agent_init=openrc +else + echo "Нужен работающий systemd или OpenRC с supervise-daemon." >&2; exit 1 +fi +if ! command -v curl >/dev/null 2>&1; then + if command -v apk >/dev/null 2>&1; then apk add --no-cache curl ca-certificates; else echo "Установите curl и ca-certificates." >&2; exit 1; fi +fi + GITEA_URL=${GITEA_URL:-https://git.myown.center} GITEA_REPOSITORY=${GITEA_REPOSITORY:-maxim/ProxmoxDash} VMID=${VMID:-0} AGENT_NAME=${AGENT_NAME:-$(hostname)} +case "$DASHBOARD_URL$ENROLL_TOKEN$AGENT_NAME$VMID" in + *' +'*) echo "Параметры установки не должны содержать переносы строк." >&2; exit 1 ;; +esac + 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 @@ -24,8 +41,12 @@ fi install -d -m 0700 /var/lib/proxmox-dashboard-agent temporary_binary=$(mktemp /tmp/proxmox-dashboard-agent.XXXXXX) -trap 'rm -f "$temporary_binary"' EXIT +trap 'rm -f "$temporary_binary" "$temporary_binary.sha256"' EXIT curl -fsSL "$download_url" -o "$temporary_binary" +curl -fsSL "$download_url.sha256" -o "$temporary_binary.sha256" +expected_hash=$(awk 'NR==1 {print $1}' "$temporary_binary.sha256") +actual_hash=$(sha256sum "$temporary_binary" | awk '{print $1}') +if [ "${#expected_hash}" -ne 64 ] || [ "$expected_hash" != "$actual_hash" ]; then echo "SHA-256 загрузки не совпал." >&2; exit 1; fi install -m 0755 "$temporary_binary" /var/lib/proxmox-dashboard-agent/proxmox-dashboard-agent umask 077 @@ -36,6 +57,7 @@ umask 077 printf 'VMID=%s\n' "$VMID" } > /etc/proxmox-dashboard-agent.env +if [ "$agent_init" = systemd ]; then cat > /etc/systemd/system/proxmox-dashboard-agent.service <<'UNIT' [Unit] Description=Proxmox Dashboard read-only guest agent @@ -61,5 +83,38 @@ UNIT systemctl daemon-reload systemctl enable proxmox-dashboard-agent.service systemctl restart proxmox-dashboard-agent.service +else + mkdir -p /usr/local/sbin /etc/init.d + cat > /usr/local/sbin/proxmox-dashboard-agent-run <<'RUN' +#!/bin/sh +set -eu +# Parse literal key=value records, never evaluate configuration as shell code. +while IFS='=' read -r key value; do + case "$key" in DASHBOARD_URL|ENROLL_TOKEN|AGENT_NAME|VMID|DASHBOARD_NPM_LOG_DIR|DASHBOARD_CROWDSEC_CONTAINER) export "$key=$value" ;; esac +done < /etc/proxmox-dashboard-agent.env +exec /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" +RUN + chmod 0755 /usr/local/sbin/proxmox-dashboard-agent-run + cat > /etc/init.d/proxmox-dashboard-agent <<'OPENRC' +#!/sbin/openrc-run +description="Proxmox Dashboard guest agent" +command="/usr/local/sbin/proxmox-dashboard-agent-run" +supervisor="supervise-daemon" +respawn_delay=5 +respawn_max=0 +output_log="/var/log/proxmox-dashboard-agent.log" +error_log="/var/log/proxmox-dashboard-agent.log" +depend() { + need net + after docker +} +start_pre() { + checkpath --file --mode 0600 /var/log/proxmox-dashboard-agent.log +} +OPENRC + chmod 0755 /etc/init.d/proxmox-dashboard-agent + rc-update add proxmox-dashboard-agent default + rc-service proxmox-dashboard-agent restart +fi rm -f /usr/local/bin/proxmox-dashboard-agent echo "Агент установлен. Через несколько секунд он появится в Dashboard." diff --git a/scripts/uninstall-agent.sh b/scripts/uninstall-agent.sh index a85a7ae..125370f 100755 --- a/scripts/uninstall-agent.sh +++ b/scripts/uninstall-agent.sh @@ -6,11 +6,16 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi +if command -v rc-service >/dev/null 2>&1 && [ -f /etc/init.d/proxmox-dashboard-agent ]; then + rc-service proxmox-dashboard-agent stop || true + rc-update del proxmox-dashboard-agent default || true +fi systemctl disable --now proxmox-dashboard-agent.service 2>/dev/null || true +rm -f /etc/init.d/proxmox-dashboard-agent /usr/local/sbin/proxmox-dashboard-agent-run 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 daemon-reload 2>/dev/null || true systemctl reset-failed proxmox-dashboard-agent.service 2>/dev/null || true echo "Агент и его локальные учётные данные удалены." diff --git a/web.go b/web.go index e0052b4..5ab83d1 100644 --- a/web.go +++ b/web.go @@ -1041,7 +1041,7 @@ async function loadTrafficSettings(){try{ el('saveTrafficSettings').onclick=async()=>{const button=el('saveTrafficSettings');button.disabled=true;try{const response=await fetch('/api/settings/traffic',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({monitoringRules:el('monitoringRules').value.split('\n').filter(line=>line.trim()).map(line=>{const parts=line.split('|').map(v=>v.trim());if(parts.length>3)throw Error('В правиле допускается три поля: IP | домен | путь');return {ip:parts[0],domain:parts[1]||'',pathPrefix:parts[2]||''}}),enabled:el('trafficEnabled').checked,networkEnabled:el('networkEnabled').checked,agentId:el('trafficAgent').value,serverCountry:el('trafficCountry').value})});if(!response.ok)throw Error(await response.text());el('trafficLink').hidden=!el('trafficEnabled').checked;el('trafficSettingsStatus').textContent='Сохранено. Фильтры применяются сразу, настройки сбора — при следующем отчёте агента.';}catch(e){el('trafficSettingsStatus').textContent=e.message||'Ошибка сохранения';}finally{button.disabled=false;}}; if(location.hash==='#traffic-settings'){el('settingsDialog').showModal();el('trafficSettingsTab').click();} el('agentServerURL').value=location.origin; -el('createAgentToken').onclick=async()=>{const button=el('createAgentToken'),server=el('agentServerURL').value.trim().replace(/\/$/,''),vmid=Number(el('agentVMID').value)||0;if(!/^https?:\/\//.test(server)){alert('Укажите полный адрес Dashboard, доступный из VM/LXC.');return}button.disabled=true;button.textContent='Создаём токен…';try{const response=await fetch('/api/agents/enrollment-token',{method:'POST'});if(!response.ok)throw new Error(await response.text());const value=await response.json(),quote=v=>"'"+String(v).replaceAll("'","'\"'\"'")+"'";el('agentInstallCommand').textContent='curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/install-agent.sh | DASHBOARD_URL='+quote(server)+' ENROLL_TOKEN='+quote(value.token)+' VMID='+quote(vmid)+' sh';el('agentInstallBlock').hidden=false}catch(e){alert('Не удалось создать токен: '+e.message)}finally{button.disabled=false;button.textContent='Создать команду установки или обновления'}}; +el('createAgentToken').onclick=async()=>{const button=el('createAgentToken'),server=el('agentServerURL').value.trim().replace(/\/$/,''),vmid=Number(el('agentVMID').value)||0;if(!/^https?:\/\//.test(server)){alert('Укажите полный адрес Dashboard, доступный из VM/LXC.');return}button.disabled=true;button.textContent='Создаём токен…';try{const response=await fetch('/api/agents/enrollment-token',{method:'POST'});if(!response.ok)throw new Error(await response.text());const value=await response.json(),quote=v=>"'"+String(v).replaceAll("'","'\"'\"'")+"'";el('agentInstallCommand').textContent='(command -v curl >/dev/null 2>&1 || apk add --no-cache curl ca-certificates) && curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/install-agent.sh | DASHBOARD_URL='+quote(server)+' ENROLL_TOKEN='+quote(value.token)+' VMID='+quote(vmid)+' sh';el('agentInstallBlock').hidden=false}catch(e){alert('Не удалось создать токен: '+e.message)}finally{button.disabled=false;button.textContent='Создать команду установки или обновления'}}; el('agentSettingsList').onclick=async event=>{const detail=event.target.closest('[data-agent-detail]');if(detail){const agent=currentAgents.find(v=>v.id===detail.dataset.agentDetail);if(agent)openAgentDetail(agent);return}const button=event.target.closest('[data-agent-delete]');if(!button||!confirm('Удалить агент? Для повторного подключения потребуется новый токен.'))return;button.disabled=true;const response=await fetch('/api/agents?id='+encodeURIComponent(button.dataset.agentDelete),{method:'DELETE'});if(response.ok)button.closest('.disk-item').remove();else{button.disabled=false;alert(await response.text())}}; async function loadAgentActions(){const response=await fetch('/api/agent-actions');if(!response.ok)return;const items=await response.json()||[];el('agentActionLog').innerHTML=items.length?items.map(v=>'
'+safe(v.agentName)+' · '+safe(v.action)+'
'+safe(v.target||'без цели')+' · '+new Date(v.requestedAt*1000).toLocaleString('ru-RU')+'
'+safe(v.status)+'
'+(v.result?'
'+safe(v.result)+'
':'')+'
').join(''):'Действий пока не было.'}loadAgentActions().catch(()=>{}); async function loadThresholds(){const response=await fetch('/api/settings/alerts');if(!response.ok)return;alertSettings=await response.json();document.querySelectorAll('[data-threshold]').forEach(input=>input.value=alertSettings[input.dataset.threshold]??'')}