diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..f8ec9dc --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,34 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download +COPY ./*.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -o clamdproxy . + +FROM alpine:latest + +# Define build arguments with default values +ARG LISTEN_ADDR="0.0.0.0:3315" +ARG BACKEND_ADDR="clamav:3310" +ARG LOG_LEVEL="debug" +ARG METRICS_ADDR="0.0.0.0:2112" + +# Create environment variables from the ARGs +ENV LISTEN_ADDR=${LISTEN_ADDR} +ENV BACKEND_ADDR=${BACKEND_ADDR} +ENV LOG_LEVEL=${LOG_LEVEL} +ENV METRICS_ADDR=${METRICS_ADDR} + +RUN apk --no-cache add ca-certificates +WORKDIR /app +COPY --from=builder /app/clamdproxy /app/ + +# Create a startup script +RUN echo '#!/bin/sh' > /app/start.sh && \ + echo 'exec /app/clamdproxy --listen "$LISTEN_ADDR" --backend "$BACKEND_ADDR" --log-level "$LOG_LEVEL" --metrics "$METRICS_ADDR"' >> /app/start.sh && \ + chmod +x /app/start.sh + +EXPOSE 3315 2112 +ENTRYPOINT ["/app/start.sh"] \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..46e0e35 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,71 @@ +version: '3.8' + +services: + clamav: + image: mkodockx/docker-clamav:alpine + container_name: clamav-docker + ports: + - "3310:3310" + volumes: + - ./testfiles:/scan + - clamav_data:/var/lib/clamav + restart: unless-stopped + healthcheck: + test: ["CMD", "./check.sh"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + networks: + - clam-net + clamdproxy: + build: + context: ../ + dockerfile: docker/Dockerfile + container_name: clamdproxy + ports: + - "3315:3315" + - "2112:2112" + depends_on: + clamav: + condition: service_healthy + restart: unless-stopped + networks: + - clam-net + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=15d' + networks: + - clam-net + + grafana: + image: grafana/grafana:latest + ports: + - "3001:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_SECURITY_ADMIN_USER=admin + volumes: + - grafana_data:/var/lib/grafana + depends_on: + - prometheus + networks: + - clam-net + +volumes: + clamav_data: + prometheus_data: + grafana_data: + +networks: + clam-net: + driver: bridge \ No newline at end of file diff --git a/docker/docker.md b/docker/docker.md new file mode 100644 index 0000000..6ddde69 --- /dev/null +++ b/docker/docker.md @@ -0,0 +1,62 @@ +# Docker Build and Run Guide + +This guide explains how to build and run the ClamdProxy Docker container with custom configuration. + +## Building the Docker Image + +You can build the ClamdProxy Docker image using the following command: + +```bash +docker build -t clamdproxy \ + --build-arg LISTEN_ADDR=0.0.0.0:3315 \ + --build-arg BACKEND_ADDR=docker.for.mac.localhost:3310 \ + --build-arg LOG_LEVEL=debug \ + --build-arg METRICS_ADDR=0.0.0.0:2112 \ + -f docker/Dockerfile . +``` + +### Build Arguments + +The Dockerfile supports the following build arguments: + +| Argument | Description | Default Value | +|----------|-------------|---------------| +| `LISTEN_ADDR` | Address and port for ClamdProxy to listen on | `0.0.0.0:3315` | +| `BACKEND_ADDR` | Address and port of the clamd backend | `clamav:3310` | +| `LOG_LEVEL` | Logging level (debug, info, warn, error) | `debug` | +| `METRICS_ADDR` | Address and port for exposing Prometheus metrics | `0.0.0.0:2112` | + +## Running the Container + +After building the image, you can run the container with: + +```bash +docker run -p 3315:3315 -p 2112:2112 \ + -e LISTEN_ADDR=0.0.0.0:3315 \ + -e BACKEND_ADDR=docker.for.mac.localhost:3310 \ + -e LOG_LEVEL=debug \ + -e METRICS_ADDR=0.0.0.0:2112 \ + clamdproxy +``` + +### Environment Variables + +The container uses the following environment variables: + +| Variable | Description | Default Value | +|----------|-------------|---------------| +| `LISTEN_ADDR` | Address and port for ClamdProxy to listen on | Value from build arg | +| `BACKEND_ADDR` | Address and port of the clamd backend | Value from build arg | +| `LOG_LEVEL` | Logging level (debug, info, warn, error) | Value from build arg | +| `METRICS_ADDR` | Address and port for exposing Prometheus metrics | Value from build arg | + +## Notes for macOS Users + +When running Docker on macOS, use `docker.for.mac.localhost` to connect to clamD services running on your host machine. This is why the example uses `docker.for.mac.localhost:3310` as the backend address. + +## Ports + +- The proxy service listens on port 3315 (or as configured) +- Metrics are exposed on port 2112 (or as configured) + +Make sure to map these ports correctly when running the container. \ No newline at end of file diff --git a/docker/grafana.json b/docker/grafana.json new file mode 100644 index 0000000..03e387c --- /dev/null +++ b/docker/grafana.json @@ -0,0 +1,813 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "ClamAV Dashboard", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 12, + "panels": [], + "title": "ClamD Dashboard", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "del6mcwp74xz4e" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "percentage", + "steps": [ + { + "color": "green" + }, + { + "color": "orange", + "value": 70 + }, + { + "color": "red", + "value": 85 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 3, + "x": 0, + "y": 1 + }, + "id": 20, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": false, + "sizing": "auto" + }, + "pluginVersion": "12.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "ael6f69gclslca" + }, + "editorMode": "code", + "exemplar": false, + "expr": "max by(virus_detected) (clamdproxy_files_scanned_total{virus_detected=\"true\"})", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Virus Detected", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "del6mcwp74xz4e" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "percentage", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 3, + "x": 3, + "y": 1 + }, + "id": 22, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": false, + "sizing": "auto" + }, + "pluginVersion": "12.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "ael6f69gclslca" + }, + "editorMode": "code", + "exemplar": false, + "expr": "max by(virus_detected) (clamdproxy_files_scanned_total{virus_detected=\"false\"})", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Files Scanned", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "del6mcwp74xz4e" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 3, + "x": 6, + "y": 1 + }, + "id": 13, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "12.0.0", + "targets": [ + { + "datasource": { + "uid": "Prometheus" + }, + "editorMode": "code", + "expr": "sum by(code) (\n promhttp_metric_handler_requests_total{code=\"200\"}\n)\n", + "legendFormat": "status {{code}}", + "range": true, + "refId": "A" + } + ], + "title": "http status 200", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "del6mcwp74xz4e" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange" + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 3, + "x": 9, + "y": 1 + }, + "id": 21, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "12.0.0", + "targets": [ + { + "datasource": { + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum(clamdproxy_files_size_bytes_total)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "status {{code}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Total File Scanned in size", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "del6mcwp74xz4e" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-red" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 3, + "x": 12, + "y": 1 + }, + "id": 14, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "12.0.0", + "targets": [ + { + "datasource": { + "uid": "Prometheus" + }, + "editorMode": "code", + "expr": "sum by(code) (\n promhttp_metric_handler_requests_total{code=\"500\"}\n)\n", + "legendFormat": "status {{code}}", + "range": true, + "refId": "A" + } + ], + "title": "http status 500", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "del6mcwp74xz4e" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-red" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 3, + "x": 15, + "y": 1 + }, + "id": 15, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "12.0.0", + "targets": [ + { + "datasource": { + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(job) (clamdproxy_backend_errors_total{job=\"clamdproxy\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "status {{code}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Backend Error Total", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "del6mcwp74xz4e" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 3, + "x": 0, + "y": 7 + }, + "id": 16, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "12.0.0", + "targets": [ + { + "datasource": { + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(job) (clamdproxy_connections_total{job=\"clamdproxy\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "status {{code}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Total Connections", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "del6mcwp74xz4e" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 3, + "x": 3, + "y": 7 + }, + "id": 17, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "12.0.0", + "targets": [ + { + "datasource": { + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(job) (clamdproxy_active_connections{job=\"clamdproxy\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "status {{code}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Active Connections", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "del6mcwp74xz4e" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 6, + "y": 7 + }, + "id": 11, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "text": { + "titleSize": 15 + }, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.0", + "targets": [ + { + "datasource": { + "uid": "Prometheus" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(command) (clamdproxy_commands_total{allowed=\"true\"})", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{command}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Commands Total", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "del6mcwp74xz4e" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80.0001 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 13, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "fel1813ec2cjkd" + }, + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(clamdproxy_files_scanned_total{job=\"clamdproxy\"}[1m]))", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{command}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Command Rate", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "auto", + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [ + { + "allValue": ".*", + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": "fel1813ec2cjkd", + "includeAll": true, + "multi": true, + "name": "namespace", + "options": [], + "query": "label_values(go_memstats_alloc_bytes, namespace)", + "refresh": 2, + "regex": "", + "type": "query" + }, + { + "allValue": ".*", + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": "fel1813ec2cjkd", + "includeAll": true, + "multi": true, + "name": "pod", + "options": [], + "query": "label_values(process_resident_memory_bytes, pod)", + "refresh": 2, + "regex": "", + "type": "query" + }, + { + "auto": false, + "auto_count": 30, + "auto_min": "10s", + "current": { + "text": "5m", + "value": "5m" + }, + "name": "interval", + "options": [ + { + "selected": false, + "text": "1m", + "value": "1m" + }, + { + "selected": true, + "text": "5m", + "value": "5m" + }, + { + "selected": false, + "text": "10m", + "value": "10m" + }, + { + "selected": false, + "text": "30m", + "value": "30m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + } + ], + "query": "1m,5m,10m,30m,1h", + "refresh": 2, + "type": "interval" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "ClamAV Dashboard", + "uid": "ypFZFgvmz", + "version": 20 +} \ No newline at end of file diff --git a/docker/prometheus.yml b/docker/prometheus.yml new file mode 100644 index 0000000..5654dc7 --- /dev/null +++ b/docker/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'clamdproxy' + static_configs: + - targets: ['clamdproxy:2112'] \ No newline at end of file diff --git a/go.mod b/go.mod index 3977fde..0de8734 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,35 @@ module github.com/miklosn/clamdproxy -go 1.22 +go 1.22.0 -require github.com/alecthomas/kong v1.9.0 +toolchain go1.22.4 + +require ( + github.com/alecthomas/kong v1.9.0 + github.com/prometheus/client_golang v1.22.0 + github.com/stretchr/testify v1.10.0 + go.opentelemetry.io/otel v1.35.0 + go.opentelemetry.io/otel/exporters/prometheus v0.57.0 + go.opentelemetry.io/otel/metric v1.35.0 + go.opentelemetry.io/otel/sdk/metric v1.35.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/sdk v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 17c1dfb..d779a6b 100644 --- a/go.sum +++ b/go.sum @@ -4,5 +4,67 @@ github.com/alecthomas/kong v1.9.0 h1:Wgg0ll5Ys7xDnpgYBuBn/wPeLGAuK0NvYmEcisJgrIs github.com/alecthomas/kong v1.9.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/prometheus v0.57.0 h1:AHh/lAP1BHrY5gBwk8ncc25FXWm/gmmY3BX258z5nuk= +go.opentelemetry.io/otel/exporters/prometheus v0.57.0/go.mod h1:QpFWz1QxqevfjwzYdbMb4Y1NnlJvqSGwyuU0B4iuc9c= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 9d01f42..7cf7c2a 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ var cli struct { Backend string `name:"backend" help:"Address of the backend clamd server" default:"127.0.0.1:3311"` LogLevel string `name:"log-level" help:"Log level (debug, info, warn, error)" default:"warn" enum:"debug,info,warn,error"` PprofAddr string `name:"pprof" help:"Address for pprof HTTP server (disabled if empty)" default:""` + MetricsAddr string `name:"metrics" help:"Address for OpenTelemetry metrics HTTP server (disabled if empty)" default:""` AllowedCommands []string `name:"allow-command" help:"ClamAV commands to allow (can be specified multiple times)"` } @@ -49,6 +50,14 @@ func getLogger(logLevel string) *slog.Logger { } func main() { + // Add panic recovery + defer func() { + if r := recover(); r != nil { + logger.Error("Panic recovered", "error", r) + os.Exit(1) + } + }() + // Parse command line arguments with Kong ctx := kong.Parse(&cli) _ = ctx // You can use ctx for subcommands if needed in the future @@ -60,6 +69,22 @@ func main() { // Initialize allowed commands initAllowedCommands() + // Initialize metrics if enabled + if cli.MetricsAddr != "" { + logger.Debug("Initializing metrics", "addr", cli.MetricsAddr) // Add debug log + metrics, err := InitMetrics(cli.MetricsAddr) + if err != nil { + logger.Error("Failed to initialize metrics", "error", err) + os.Exit(1) + } + defer func() { + if err := metrics.Close(); err != nil { + logger.Error("Failed to close metrics server", "error", err) + } + }() + logger.Info("Metrics enabled", "addr", cli.MetricsAddr) + } + logger.Warn("Starting clamdproxy", "listen", cli.Listen, "backend", cli.Backend) @@ -104,17 +129,30 @@ func handleConnection(clientConn net.Conn) { if err := clientConn.Close(); err != nil { logger.Error("Failed to close client connection", "error", err) } + // Record connection closed in metrics + if proxyMetrics != nil { + proxyMetrics.RecordConnectionClosed() + } }() clientAddr := clientConn.RemoteAddr() logger.Info("Connection established", "client", clientAddr) + // Record connection in metrics + if proxyMetrics != nil { + proxyMetrics.RecordConnection(clientAddr.String()) + } + backendConn, err := net.Dial("tcp", cli.Backend) if err != nil { logger.Error("Failed to connect to backend", "backend", &cli.Backend, "client", &clientAddr, "error", err) + // Record backend error in metrics + if proxyMetrics != nil { + proxyMetrics.RecordBackendError() + } return } defer func() { diff --git a/metrics.go b/metrics.go new file mode 100644 index 0000000..58ee701 --- /dev/null +++ b/metrics.go @@ -0,0 +1,253 @@ +// Package main implements metrics collection for clamdproxy using OpenTelemetry +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/metric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" +) + +// Metrics holds all the metrics instruments used by the proxy +type Metrics struct { + ConnectionsTotal metric.Int64Counter + CommandsTotal metric.Int64Counter + CommandDuration metric.Float64Histogram + ActiveConnections metric.Int64UpDownCounter + BackendErrors metric.Int64Counter + CommandErrors metric.Int64Counter + FilesScanned metric.Int64Counter + FilesSizeBytes metric.Int64Counter + FilesWithVirus metric.Int64Counter + metricServer *http.Server +} + +// Global metrics instance +var proxyMetrics *Metrics + +// InitMetrics initializes the OpenTelemetry metrics system and creates all metric instruments +func InitMetrics(metricsAddr string) (*Metrics, error) { + // Create a new Prometheus exporter + exporter, err := prometheus.New() + if err != nil { + return nil, fmt.Errorf("failed to create Prometheus exporter: %w", err) + } + + // Create a new MeterProvider with the Prometheus exporter + provider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(exporter), + ) + + // Set the global MeterProvider + otel.SetMeterProvider(provider) + + // Create a meter for our application + meter := provider.Meter("clamdproxy") + + // Create metrics + m := &Metrics{} + + // Initialize counters + m.ConnectionsTotal, err = meter.Int64Counter( + "clamdproxy_connections_total", + metric.WithDescription("Total number of client connections"), + metric.WithUnit("{connections}"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create connections counter: %w", err) + } + + m.CommandsTotal, err = meter.Int64Counter( + "clamdproxy_commands_total", + metric.WithDescription("Total number of commands processed"), + metric.WithUnit("{commands}"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create commands counter: %w", err) + } + + m.CommandDuration, err = meter.Float64Histogram( + "clamdproxy_command_duration_seconds", + metric.WithDescription("Duration of command processing in seconds"), + metric.WithUnit("s"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create command duration histogram: %w", err) + } + + m.ActiveConnections, err = meter.Int64UpDownCounter( + "clamdproxy_active_connections", + metric.WithDescription("Current number of active connections"), + metric.WithUnit("{connections}"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create active connections counter: %w", err) + } + + m.BackendErrors, err = meter.Int64Counter( + "clamdproxy_backend_errors_total", + metric.WithDescription("Total number of backend connection errors"), + metric.WithUnit("{errors}"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create backend errors counter: %w", err) + } + + m.CommandErrors, err = meter.Int64Counter( + "clamdproxy_command_errors_total", + metric.WithDescription("Total number of command processing errors"), + metric.WithUnit("{errors}"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create command errors counter: %w", err) + } + + // Initialize file scanning metrics + m.FilesScanned, err = meter.Int64Counter( + "clamdproxy_files_scanned_total", + metric.WithDescription("Total number of files scanned"), + metric.WithUnit("{files}"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create files scanned counter: %w", err) + } + + m.FilesSizeBytes, err = meter.Int64Counter( + "clamdproxy_files_size_bytes_total", + metric.WithDescription("Total size of files scanned in bytes"), + metric.WithUnit("By"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create file size counter: %w", err) + } + + m.FilesWithVirus, err = meter.Int64Counter( + "clamdproxy_files_with_virus_total", + metric.WithDescription("Total number of files with viruses detected"), + metric.WithUnit("{files}"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create files with virus counter: %w", err) + } + + // Start metrics HTTP server if address is provided + if metricsAddr != "" { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + + m.metricServer = &http.Server{ + Addr: metricsAddr, + Handler: mux, + } + + go func() { + logger.Info("Starting metrics server", "addr", metricsAddr) + if err := m.metricServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("Metrics server failed", "error", err) + } + }() + } + + proxyMetrics = m + return m, nil +} + +// RecordConnection records a new client connection +func (m *Metrics) RecordConnection(clientAddr string) { + if m == nil { + return + } + m.ConnectionsTotal.Add(context.Background(), 1) + m.ActiveConnections.Add(context.Background(), 1) +} + +// RecordConnectionClosed records a closed client connection +func (m *Metrics) RecordConnectionClosed() { + if m == nil { + return + } + m.ActiveConnections.Add(context.Background(), -1) +} + +// RecordCommand records a command being processed +func (m *Metrics) RecordCommand(cmd string, allowed bool) { + if m == nil { + return + } + m.CommandsTotal.Add(context.Background(), 1, metric.WithAttributes( + attribute.String("command", cmd), + attribute.Bool("allowed", allowed), + )) +} + +// RecordCommandDuration records the duration of a command +func (m *Metrics) RecordCommandDuration(cmd string, duration time.Duration) { + if m == nil { + return + } + m.CommandDuration.Record(context.Background(), duration.Seconds(), metric.WithAttributes( + attribute.String("command", cmd), + )) +} + +// RecordBackendError records a backend connection error +func (m *Metrics) RecordBackendError() { + if m == nil { + return + } + m.BackendErrors.Add(context.Background(), 1) +} + +// RecordCommandError records a command processing error +func (m *Metrics) RecordCommandError(cmd string, errMsg string) { + if m == nil { + return + } + m.CommandErrors.Add(context.Background(), 1, metric.WithAttributes( + attribute.String("command", cmd), + attribute.String("error", errMsg), + )) +} + +// RecordFileScan records metrics for a scanned file +func (m *Metrics) RecordFileScan(filename string, sizeBytes int64, virusFound bool, virusName string) { + if m == nil { + return + } + + attrs := []attribute.KeyValue{ + attribute.Bool("virus_detected", virusFound), + } + + m.FilesScanned.Add(context.Background(), 1, metric.WithAttributes(attrs...)) + m.FilesSizeBytes.Add(context.Background(), sizeBytes, metric.WithAttributes(attrs...)) + + if virusFound { + m.FilesWithVirus.Add(context.Background(), 1, metric.WithAttributes(attrs...)) + + // Log the detailed information instead of adding it as a label + logger.Info("Virus detected", + "filename", filename, + "virus_name", virusName, + "size_bytes", sizeBytes) + } +} + +// Close shuts down the metrics server if it's running +func (m *Metrics) Close() error { + if m == nil || m.metricServer == nil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return m.metricServer.Shutdown(ctx) +} diff --git a/metrics_test.go b/metrics_test.go new file mode 100644 index 0000000..c6aeec6 --- /dev/null +++ b/metrics_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMetrics(t *testing.T) { + // Initialize metrics with a test server + metrics, err := InitMetrics("localhost:0") + require.NoError(t, err) + require.NotNil(t, metrics) + defer func() { + err := metrics.Close() + require.NoError(t, err) + }() + + t.Run("RecordConnection", func(t *testing.T) { + metrics.RecordConnection("127.0.0.1:1234") + resp := getMetrics(t, metrics) + + // Check for the counter without the client label + assert.Contains(t, resp, "clamdproxy_connections_total") + + // Check for active connections + assert.Contains(t, resp, "clamdproxy_active_connections") + }) + + t.Run("RecordConnectionClosed", func(t *testing.T) { + metrics.RecordConnection("127.0.0.1:1234") + metrics.RecordConnectionClosed() + resp := getMetrics(t, metrics) + assert.Contains(t, resp, `clamdproxy_active_connections`) + }) + + t.Run("RecordCommand", func(t *testing.T) { + metrics.RecordCommand("PING", true) + resp := getMetrics(t, metrics) + assert.Contains(t, resp, `clamdproxy_commands_total{allowed="true",command="PING",otel_scope_name="clamdproxy"`) + }) + + t.Run("RecordCommandError", func(t *testing.T) { + metrics.RecordCommandError("SCAN", "connection refused") + resp := getMetrics(t, metrics) + assert.Contains(t, resp, `clamdproxy_command_errors_total{command="SCAN",error="connection refused",otel_scope_name="clamdproxy"`) + }) +} + +// Helper function to get metrics from the HTTP endpoint +func getMetrics(t *testing.T, m *Metrics) string { + req := httptest.NewRequest("GET", "/metrics", nil) + w := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.metricServer.Handler.ServeHTTP(w, r) + }) + + handler.ServeHTTP(w, req) + resp := w.Result() + defer func() { + err := resp.Body.Close() + require.NoError(t, err) + }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected status code 200, got %d", resp.StatusCode) + } + + body := new(strings.Builder) + _, err := io.Copy(body, resp.Body) + require.NoError(t, err) + + return body.String() +} + +func TestNilMetrics(t *testing.T) { + var m *Metrics + + // Test that nil metrics don't panic + t.Run("NilMetricsOperations", func(t *testing.T) { + assert.NotPanics(t, func() { + m.RecordConnection("test") + m.RecordConnectionClosed() + m.RecordCommand("test", true) + m.RecordCommandDuration("test", time.Second) + m.RecordBackendError() + m.RecordCommandError("test", "error") + _ = m.Close() + }) + }) +} diff --git a/proxy.go b/proxy.go index bd71e72..5cd49cf 100644 --- a/proxy.go +++ b/proxy.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "syscall" + "time" ) // Buffer pools to reduce GC pressure @@ -62,15 +63,17 @@ var allowedCommands = map[string]bool{ "INSTREAM": true, "VERSION": true, "VERSIONCOMMANDS": true, + "": true, // Allow empty commands } // ClamdProxy handles bidirectional proxying between client and backend clamd server. // It filters commands to prevent unsafe operations from reaching the backend. type ClamdProxy struct { - client net.Conn // Connection to the client - backend net.Conn // Connection to the backend clamd server - backendBuf *bufio.Writer // Buffered writer for backend - clientBuf *bufio.Writer // Buffered writer for client + client net.Conn // Connection to the client + backend net.Conn // Connection to the backend clamd server + backendBuf *bufio.Writer // Buffered writer for backend + clientBuf *bufio.Writer // Buffered writer for client + lastStreamSize int64 // Size of the last streamed file } // NewClamdProxy creates a new proxy instance with the given client and backend connections @@ -99,24 +102,46 @@ func (p *ClamdProxy) Start() { bytesWritten := int64(0) var err error + // Buffer to accumulate response for parsing + responseBuf := make([]byte, 0, 1024) + for { nr, er := p.backend.Read(buf) if nr > 0 { + // Add to response buffer for parsing + responseBuf = append(responseBuf, buf[:nr]...) + + // Log the raw response for debugging + logger.Debug("Received response from backend", + "bytes", nr, + "preview", truncateString(string(buf[:nr]), 100)) + + // Check for virus detection in the response + p.parseResponseForVirus(responseBuf) + + // Clear the response buffer if it gets too large or contains a complete response + if len(responseBuf) > 4096 || strings.Contains(string(responseBuf), "\n") { + responseBuf = make([]byte, 0, 1024) + } + nw, ew := p.clientBuf.Write(buf[0:nr]) if nw > 0 { bytesWritten += int64(nw) } if ew != nil { + logger.Debug("Error writing to client buffer", "error", ew) err = ew break } if nr != nw { + logger.Debug("Short write to client buffer", "expected", nr, "written", nw) err = io.ErrShortWrite break } } if er != nil { if er != io.EOF { + logger.Debug("Error reading from backend", "error", er) err = er } break @@ -137,18 +162,12 @@ func (p *ClamdProxy) Start() { if err != nil { if isConnectionClosed(err) { - logger.Debug("Backend connection closed", - "client", clientAddr, - "error", err) + logger.Debug("Backend connection closed", "client", clientAddr, "error", err) } else { - logger.Debug("Error copying from backend to client", - "client", clientAddr, - "error", err) + logger.Debug("Error copying from backend to client", "client", clientAddr, "error", err) } } else { - logger.Info("Proxy completed", - "client", clientAddr, - "bytesTransferred", bytesWritten) + logger.Info("Proxy completed", "client", clientAddr, "bytesTransferred", bytesWritten) } } @@ -171,6 +190,10 @@ func (p *ClamdProxy) handleClientToBackend() { logger.Debug("Client connection closed", "client", clientAddr, "error", err) } else { logger.Debug("Error reading command", "client", clientAddr, "error", err) + // Record command error in metrics + if proxyMetrics != nil { + proxyMetrics.RecordCommandError("unknown", err.Error()) + } } } // Close the backend connection to signal we're done @@ -180,14 +203,31 @@ func (p *ClamdProxy) handleClientToBackend() { break } + // Skip empty commands + if cmd == "" { + continue + } + // Only log commands at appropriate levels logger.Debug("Command received", "client", clientAddr, "command", cmd) - // Check if command is allowed - if isCommandAllowed(cmd) { + // Check if command is allowed and record start time + allowed := isCommandAllowed(cmd) + commandStart := time.Now() + + // Record command metrics + if proxyMetrics != nil { + proxyMetrics.RecordCommand(cmd, allowed) + } + + if allowed { // Forward the command to backend using buffered writer if _, err := p.backendBuf.Write(append([]byte(cmd), delim)); err != nil { logger.Debug("Error forwarding command", "error", err) + // Record command error in metrics + if proxyMetrics != nil { + proxyMetrics.RecordCommandError(cmd, err.Error()) + } break } // Flush after each command to ensure it's sent immediately @@ -196,25 +236,45 @@ func (p *ClamdProxy) handleClientToBackend() { break } + // Record command duration + if proxyMetrics != nil && !isInstreamCommand(cmd) { + proxyMetrics.RecordCommandDuration(cmd, time.Since(commandStart)) + } + // Handle special case for INSTREAM command (file streaming) if isInstreamCommand(cmd) { logger.Debug("Processing INSTREAM data", "client", clientAddr) + instreamStart := time.Now() if err := p.handleInstream(reader); err != nil { logger.Debug("Error handling INSTREAM data", "client", clientAddr, "error", err) + // Record command error in metrics + if proxyMetrics != nil { + proxyMetrics.RecordCommandError(cmd, err.Error()) + } break } + + // Record INSTREAM command duration after completion + if proxyMetrics != nil { + proxyMetrics.RecordCommandDuration(cmd, time.Since(instreamStart)) + } } } else { - logger.Info("Blocked command", "client", clientAddr, "command", cmd) + logger.Debug("Blocked command", "client", clientAddr, "command", cmd) // Changed from Info to Debug // Send error response to client using buffered writer response := "ERROR: Command not allowed\n" if _, err := p.clientBuf.WriteString(response); err != nil { logger.Debug("Error sending error response", "error", err) break } + + // Record command duration for blocked commands too + if proxyMetrics != nil { + proxyMetrics.RecordCommandDuration(cmd, time.Since(commandStart)) + } if err := p.clientBuf.Flush(); err != nil { logger.Debug("Error flushing error response", "error", err) break @@ -373,27 +433,104 @@ func (p *ClamdProxy) handleInstream(reader *bufio.Reader) error { totalBytes += size chunks++ + } - // Only log chunk details at the most verbose level and only occasionally - if chunks%100 == 0 { - logger.Debug("INSTREAM progress", - "client", clientAddr, - "chunks", chunks, - "totalBytes", totalBytes) + // After the INSTREAM command completes successfully, store the final file size + p.lastStreamSize = int64(totalBytes) + + // Flush the backend buffer to ensure all data is sent + if err := p.backendBuf.Flush(); err != nil { + return fmt.Errorf("failed to flush backend buffer: %w", err) + } + + return nil +} + +// parseResponseForVirus checks if the response contains virus detection information +// and records metrics if a virus is found +func (p *ClamdProxy) parseResponseForVirus(response []byte) { + // Convert to string for easier parsing + respStr := string(response) + + // Add debug logging to see what's in the response + logger.Debug("Parsing response", "length", len(respStr), "preview", truncateString(respStr, 100)) + + // Check if this is an INSTREAM response (either clean or with virus) + if strings.Contains(respStr, "stream") { + // Default to clean file (no virus) + virusFound := false + virusName := "" + filename := "stream" + + // Check for virus detection + if strings.Contains(respStr, " FOUND") { + logger.Debug("Found virus detection pattern in response") + lines := strings.Split(respStr, "\n") + for _, line := range lines { + if strings.Contains(line, " FOUND") { + logger.Debug("Processing virus line", "line", line) + parts := strings.Split(line, ": ") + if len(parts) >= 2 { + fileInfo := parts[0] + detectionInfo := strings.TrimSuffix(parts[1], " FOUND") + + // Extract filename from fileInfo + // Format: "instream(172.18.0.5@35478)" + filename = fileInfo + if strings.Contains(fileInfo, "(") && strings.Contains(fileInfo, ")") { + // Extract the part between parentheses + start := strings.Index(fileInfo, "(") + 1 + end := strings.Index(fileInfo, ")") + if start > 0 && end > start { + clientInfo := fileInfo[start:end] + // Use client info as part of the filename + filename = fileInfo[:start-1] + "_" + clientInfo + } + } + + virusFound = true + virusName = detectionInfo + + logger.Info("Virus detected", + "file", filename, + "virus", detectionInfo, + "size", p.lastStreamSize) + } + } + } + } else if strings.Contains(respStr, "OK") { + // This is a clean file + logger.Debug("Clean file detected", "size", p.lastStreamSize) } - // Flush periodically to balance between batching and responsiveness - if chunks%10 == 0 { - if err := p.backendBuf.Flush(); err != nil { - return fmt.Errorf("failed to flush data: %w", err) + // Record metrics for all scanned files, whether clean or infected + if proxyMetrics != nil { + // Use the actual file size if available + fileSize := p.lastStreamSize + if fileSize == 0 { + fileSize = int64(1024) // Default 1KB if size unknown + } + + proxyMetrics.RecordFileScan(filename, fileSize, virusFound, virusName) + + if virusFound { + logger.Debug("Recorded virus metrics", + "file", filename, + "size", fileSize, + "virus", virusName) + } else { + logger.Debug("Recorded clean file metrics", + "file", filename, + "size", fileSize) } } } +} - // Final flush to ensure all data is sent - if err := p.backendBuf.Flush(); err != nil { - return fmt.Errorf("failed to flush final data: %w", err) +// Helper function to truncate long strings for logging +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s } - - return nil + return s[:maxLen] + "..." } diff --git a/proxy_test.go b/proxy_test.go index 3ebf165..bd4addb 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -90,12 +90,12 @@ func TestIsCommandAllowed(t *testing.T) { for k, v := range allowedCommands { originalAllowed[k] = v } - + // Restore after test defer func() { allowedCommands = originalAllowed }() - + // Set test allowed commands allowedCommands = map[string]bool{ "PING": true, @@ -103,7 +103,7 @@ func TestIsCommandAllowed(t *testing.T) { "VERSIONCOMMANDS": true, "INSTREAM": true, } - + allowedCmds := []string{ "PING", "VERSION", "VERSIONCOMMANDS", "INSTREAM", "zPING", "zVERSION", "zVERSIONCOMMANDS", "zINSTREAM",