210 lines
6.7 KiB
Go
210 lines
6.7 KiB
Go
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
|
||
}
|