Files
ProxmoxDash/agent_traffic_discovery.go

210 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}