package caddylog import ( "encoding/json" "errors" "fmt" "net" "net/url" "strconv" "strings" "time" "git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model" ) var ErrEmptyLine = errors.New("empty log line") type accessLogEntry struct { Timestamp json.RawMessage `json:"ts"` Status int `json:"status"` Request accessLogRequest `json:"request"` } type accessLogRequest struct { RemoteIP string `json:"remote_ip"` ClientIP string `json:"client_ip"` Host string `json:"host"` Method string `json:"method"` URI string `json:"uri"` Headers map[string][]string `json:"headers"` } func ParseLine(line string) (model.AccessLogRecord, error) { trimmed := strings.TrimSpace(line) if trimmed == "" { return model.AccessLogRecord{}, ErrEmptyLine } var entry accessLogEntry if err := json.Unmarshal([]byte(trimmed), &entry); err != nil { return model.AccessLogRecord{}, fmt.Errorf("decode caddy log line: %w", err) } if entry.Status == 0 { return model.AccessLogRecord{}, errors.New("missing caddy status") } remoteIP, err := normalizeIP(entry.Request.RemoteIP) if err != nil && strings.TrimSpace(entry.Request.RemoteIP) != "" { return model.AccessLogRecord{}, fmt.Errorf("normalize remote ip: %w", err) } clientCandidate := entry.Request.ClientIP if strings.TrimSpace(clientCandidate) == "" { clientCandidate = entry.Request.RemoteIP } clientIP, err := normalizeIP(clientCandidate) if err != nil { return model.AccessLogRecord{}, fmt.Errorf("normalize client ip: %w", err) } occurredAt, err := parseTimestamp(entry.Timestamp) if err != nil { return model.AccessLogRecord{}, fmt.Errorf("parse timestamp: %w", err) } uri := entry.Request.URI if strings.TrimSpace(uri) == "" { uri = "/" } return model.AccessLogRecord{ OccurredAt: occurredAt, RemoteIP: remoteIP, ClientIP: clientIP, Host: strings.TrimSpace(entry.Request.Host), Method: strings.ToUpper(strings.TrimSpace(entry.Request.Method)), URI: uri, Path: pathFromURI(uri), Status: entry.Status, UserAgent: firstUserAgent(entry.Request.Headers), RawJSON: trimmed, }, nil } func ParseLines(lines []string) ([]model.AccessLogRecord, error) { records := make([]model.AccessLogRecord, 0, len(lines)) for _, line := range lines { record, err := ParseLine(line) if err != nil { if errors.Is(err, ErrEmptyLine) { continue } return nil, err } records = append(records, record) } return records, nil } func firstUserAgent(headers map[string][]string) string { if len(headers) == 0 { return "" } for _, key := range []string{"User-Agent", "user-agent", "USER-AGENT"} { if values, ok := headers[key]; ok && len(values) > 0 { return strings.TrimSpace(values[0]) } } for key, values := range headers { if strings.EqualFold(key, "user-agent") && len(values) > 0 { return strings.TrimSpace(values[0]) } } return "" } func parseTimestamp(raw json.RawMessage) (time.Time, error) { if len(raw) == 0 { return time.Time{}, errors.New("missing timestamp") } var numeric float64 if err := json.Unmarshal(raw, &numeric); err == nil { seconds := int64(numeric) nanos := int64((numeric - float64(seconds)) * float64(time.Second)) return time.Unix(seconds, nanos).UTC(), nil } var text string if err := json.Unmarshal(raw, &text); err == nil { text = strings.TrimSpace(text) if text == "" { return time.Time{}, errors.New("empty timestamp") } if numeric, err := strconv.ParseFloat(text, 64); err == nil { seconds := int64(numeric) nanos := int64((numeric - float64(seconds)) * float64(time.Second)) return time.Unix(seconds, nanos).UTC(), nil } for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { parsed, err := time.Parse(layout, text) if err == nil { return parsed.UTC(), nil } } } return time.Time{}, fmt.Errorf("unsupported timestamp payload %s", string(raw)) } func normalizeIP(value string) (string, error) { trimmed := strings.TrimSpace(value) if trimmed == "" { return "", errors.New("missing ip address") } parsed := net.ParseIP(trimmed) if parsed == nil { return "", fmt.Errorf("invalid ip address %q", value) } return parsed.String(), nil } func pathFromURI(rawURI string) string { trimmed := strings.TrimSpace(rawURI) if trimmed == "" { return "/" } parsed, err := url.ParseRequestURI(trimmed) if err == nil { if parsed.Path == "" { return "/" } return normalizePath(parsed.Path) } value := strings.SplitN(trimmed, "?", 2)[0] value = strings.SplitN(value, "#", 2)[0] return normalizePath(value) } func normalizePath(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { return "/" } if !strings.HasPrefix(trimmed, "/") { trimmed = "/" + trimmed } if trimmed != "/" { trimmed = strings.TrimRight(trimmed, "/") } return strings.ToLower(trimmed) }