Support Alpine OpenRC agents and discover Docker NPMplus and CrowdSec
This commit is contained in:
45
README.md
45
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.
|
||||
Как и раньше, первый запуск чтения начинается с конца существующих логов.
|
||||
|
||||
82
agent_installer_test.go
Normal file
82
agent_installer_test.go
Normal file
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 вручную")
|
||||
|
||||
@@ -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,12 +55,24 @@ 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*"))
|
||||
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
|
||||
budget := int64(1 << 20)
|
||||
@@ -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()
|
||||
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 alerts list недоступен; проверьте права агента и локальную LAPI")
|
||||
return nil, false, errors.New("cscli недоступен на хосте; контейнер CrowdSec не обнаружен")
|
||||
}
|
||||
events, err := parseCrowdTraffic(output.Bytes())
|
||||
data = 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 {
|
||||
|
||||
209
agent_traffic_discovery.go
Normal file
209
agent_traffic_discovery.go
Normal file
@@ -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
|
||||
}
|
||||
134
agent_traffic_discovery_test.go
Normal file
134
agent_traffic_discovery_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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 "Агент и его локальные учётные данные удалены."
|
||||
|
||||
2
web.go
2
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=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.agentName)+' · '+safe(v.action)+'</div><div class="disk-meta">'+safe(v.target||'без цели')+' · '+new Date(v.requestedAt*1000).toLocaleString('ru-RU')+'</div></div><span class="badge '+(v.status==='failed'?'bad':v.status==='completed'?'':'warn')+'">'+safe(v.status)+'</span></div>'+(v.result?'<div class="settings-note">'+safe(v.result)+'</div>':'')+'</article>').join(''):'<span class="empty">Действий пока не было.</span>'}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]??'')}
|
||||
|
||||
Reference in New Issue
Block a user