// main.go
package main
import (
"bytes"
"encoding/json"
"log"
"net"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/gofiber/fiber/v2"
)
type Anomaly struct {
Source string `json:"source"`
IP string `json:"ip"`
Confidence float64 `json:"confidence"`
Notes string `json:"notes"`
Tags string `json:"tags"`
}
// --- Config (env) ---
var (
oneFirewallEndpoint = getenv("ONEFIREWALL_END_POINT", "https://app.onefirewall.com/api/v2/ips")
oneFirewallAPIKey = getenv("ONEFIREWALL_API_KEY", "<your-api-key>")
oneFirewallBulk = mustAtoi(getenv("ONEFIREWALL_BULK", "100"))
oneFirewallTag = getenv("ONEFIREWALL_TAG", "report-example")
httpTimeout = time.Second * 10
)
// --- Queue & Submission State ---
var (
queueMu sync.Mutex
feedQueue []Anomaly
submitMu sync.Mutex
submitting bool
)
// Helper: get env with default
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func mustAtoi(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
return 100
}
return n
}
// --- Client IP extraction (WAF/CDN aware) ---
var (
forwardedForRe = regexp.MustCompile(`(?i)for=([^;,\s]+)`)
)
// Normalize IPv4-mapped IPv6 like "::ffff:198.51.100.23"
func normalizeIP(ip string) string {
ip = strings.Trim(ip, `"`)
ip = strings.TrimSpace(ip)
ip = strings.TrimPrefix(ip, "::ffff:")
// If it's an IP:port, strip port
host, _, err := net.SplitHostPort(ip)
if err == nil && host != "" {
return host
}
return ip
}
func getClientIP(c *fiber.Ctx) string {
// 1) Prefer headers set by known proxies/CDNs
if v := c.Get("X-Forwarded-For"); v != "" {
parts := strings.Split(v, ",")
if len(parts) > 0 {
return normalizeIP(strings.TrimSpace(parts[0]))
}
}
if v := c.Get("CF-Connecting-IP"); v != "" {
return normalizeIP(v)
}
if v := c.Get("True-Client-IP"); v != "" {
return normalizeIP(v)
}
if v := c.Get("X-Real-IP"); v != "" {
return normalizeIP(v)
}
if v := c.Get("Forwarded"); v != "" {
if m := forwardedForRe.FindStringSubmatch(v); len(m) == 2 {
return normalizeIP(m[1])
}
}
// 2) Fiber’s derived IP (may use proxy headers depending on deployment)
if ip := c.IP(); ip != "" && ip != "::1" {
return normalizeIP(ip)
}
// 3) Remote address fallback
if ra := c.Context().RemoteAddr().String(); ra != "" {
return normalizeIP(ra)
}
return "unknown"
}
// --- Submit to OneFirewall ---
func submitToOneFirewall() {
// prevent concurrent submissions
submitMu.Lock()
if submitting {
submitMu.Unlock()
return
}
submitting = true
submitMu.Unlock()
// snapshot queue
queueMu.Lock()
if len(feedQueue) == 0 {
queueMu.Unlock()
submitMu.Lock()
submitting = false
submitMu.Unlock()
return
}
payload := make([]Anomaly, len(feedQueue))
copy(payload, feedQueue)
queueMu.Unlock()
// marshal outside lock
body, err := json.Marshal(payload)
if err != nil {
log.Printf("marshal error: %v", err)
submitMu.Lock()
submitting = false
submitMu.Unlock()
return
}
req, err := http.NewRequest(http.MethodPost, oneFirewallEndpoint, bytes.NewReader(body))
if err != nil {
log.Printf("request build error: %v", err)
submitMu.Lock()
submitting = false
submitMu.Unlock()
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+oneFirewallAPIKey)
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(req)
if err != nil {
log.Printf("submit error: %v", err)
submitMu.Lock()
submitting = false
submitMu.Unlock()
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
// success -> drop only the submitted items
queueMu.Lock()
if len(feedQueue) >= len(payload) {
feedQueue = feedQueue[len(payload):]
} else {
// shouldn't happen, but be safe
feedQueue = nil
}
queueMu.Unlock()
log.Printf("Submitted %d anomalies to OneFirewall: %s", len(payload), resp.Status)
} else {
log.Printf("submit failed: %s (queue retained)", resp.Status)
}
submitMu.Lock()
submitting = false
submitMu.Unlock()
}
func main() {
app := fiber.New(fiber.Config{
// Optionally set a proxy header Fiber should trust (if you fully trust your proxy layer)
// ProxyHeader: fiber.HeaderXForwardedFor,
// EnableTrustedProxyCheck: true,
// TrustedProxies: []string{"YOUR.WAF.CIDR/24"},
})
// Middleware: run AFTER handlers to inspect final status
app.Use(func(c *fiber.Ctx) error {
err := c.Next()
status := c.Response().StatusCode()
if status == 0 {
status = http.StatusOK
}
if status == http.StatusUnauthorized || status == http.StatusForbidden || status == http.StatusNotFound {
clientIP := getClientIP(c)
host := c.Hostname()
if host == "" {
host = c.Get("Host")
if host == "" {
host = "unknown"
}
}
anomaly := Anomaly{
Source: host,
IP: clientIP,
Confidence: 0.2,
Notes: c.OriginalURL(),
Tags: oneFirewallTag,
}
// enqueue safely
queueMu.Lock()
feedQueue = append(feedQueue, anomaly)
queueLen := len(feedQueue)
queueMu.Unlock()
log.Printf("Captured anomaly: %+v (queue=%d)", anomaly, queueLen)
// flush if bulk reached
if queueLen >= oneFirewallBulk {
go submitToOneFirewall()
}
}
return err
})
// --- Example routes ---
app.Get("/", func(c *fiber.Ctx) error {
return c.SendString("Hello, Fiber!")
})
app.Get("/secret", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusForbidden) // 403
})
app.Get("/missing", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusNotFound) // 404
})
// Optional: periodic flush so you don't wait for bulk threshold
flushSec := mustAtoi(getenv("ONEFIREWALL_FLUSH_SEC", "60"))
if flushSec > 0 {
ticker := time.NewTicker(time.Duration(flushSec) * time.Second)
go func() {
for range ticker.C {
queueMu.Lock()
hasItems := len(feedQueue) > 0
queueMu.Unlock()
if hasItems {
submitToOneFirewall()
}
}
}()
}
port := getenv("PORT", "3000")
log.Printf("App running on :%s", port)
if err := app.Listen(":" + port); err != nil {
log.Fatal(err)
}
}