2
Files
caddy-opnsense-blocker/internal/caddylog/parser.go

195 lines
4.9 KiB
Go

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)
}