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 }