The Vedetta Firewall Connector framework allows community contributors to add support for any firewall platform. This guide explains how to implement a new connector.
Setting up a UniFi gateway? For end-user setup (enabling syslog/CEF export, verification, optional REST connector, and the supported-vs-experimental status ladder) see the UniFi Connector Setup Guide. This document is for contributors implementing a new connector platform.
Vedetta normalizes firewall events from multiple sources into a unified schema. The connector architecture enables:
- Multi-platform support: UniFi, OpenWrt, pfSense, Mikrotik, etc.
- Pluggable design: Add a new firewall type without modifying core code
- Normalized events: All firewall logs converted to a common schema
- Automatic polling: Background task fetches events on a schedule
- Health monitoring: Track connector status and event flow
All firewall connectors implement the firewall.Connector interface:
type Connector interface {
// Name returns the connector type identifier (e.g. "unifi", "openwrt").
Name() string
// Discover attempts to auto-detect the firewall and return its info.
// Returns nil if the firewall is not reachable or not the expected type.
Discover(ctx context.Context) (*FirewallInfo, error)
// Connect establishes a session with the firewall.
Connect(ctx context.Context) error
// Disconnect gracefully closes the session.
Disconnect() error
// Poll fetches new firewall events since the last poll.
// Returns events normalized to the Vedetta schema.
Poll(ctx context.Context) ([]FirewallEvent, error)
// Health returns the current health status of the connector.
Health() ConnectorHealth
}The normalized event representation:
type FirewallEvent struct {
Timestamp time.Time
Action string // allow | block | drop | reject
Protocol string // tcp | udp | icmp
SrcIP string
SrcPort int
DstIP string
DstPort int
Interface string // wan | lan | iot | guest
Direction string // in | out
Rule string // firewall rule name/ID that matched
BytesSent int64
BytesRecv int64
Application string // DPI app name if available
RawLog string // original log line for debugging
}
// NOTE on current collector behavior (as of the ingest pipeline implementation):
// The Fluent Bit config for firewall/syslog (parsers.conf + fluent-bit.conf modify filter)
// only emits event_type=firewall_log, source_hash, and raw_log (the original syslog/filterlog line).
// Detailed fields (action, protocol, src_ip, dst_ip, src_port, dst_port, interface, direction, rule)
// remain inside the raw_log CSV string and are NOT automatically extracted to top-level JSON keys
// or to metadata by the collector (unlike the Pi-hole path which has a dedicated Lua transform).
// The /ingest handler (after the metadata-lift fix) will preserve any top-level fields the collector
// does emit into the Event.Metadata JSON so that json_extract-based filters (e.g. ?action=block)
// can work if/when the collector or a connector layer populates them.
// Per-vendor parsing of raw_log (or equivalent export) into the structured FirewallEvent fields
// is a connector-layer responsibility (see research/deep-dive-firewall-connectors.md and UniFi/pfSense plans).
// This is explicitly tracked as in-scope for the firewall/UniFi connector work (Stage 5).
// The faithful test uses a synthetic payload with top-level fields to verify the handler's
// preservation logic (the drop-bug fix); it does not reflect current collector output shape.Configuration passed to your connector:
type ConnectorConfig struct {
Name string // human-readable name (e.g. "My UniFi Gateway")
Type string // unifi | openwrt | pfsense | mikrotik
Host string // hostname or IP of the firewall
Port int // API/management port
Username string // API username
Password string // API password
APIKey string // API key (alternative to username/password)
TLSSkipVerify bool // skip TLS cert verification
PollInterval time.Duration // polling frequency
Enabled bool
}Status information returned during polling:
type ConnectorHealth struct {
Connected bool
LastPoll time.Time
LastError string
EventCount int64
Uptime time.Duration
}Create a new file in backend/internal/firewall/ named after your firewall platform, e.g., openwrt.go:
package firewall
import (
"context"
"sync"
"time"
)
type OpenWrtConnector struct {
cfg ConnectorConfig
client *http.Client
connected bool
lastPoll time.Time
lastError string
eventCount int64
connectTime time.Time
mu sync.RWMutex
}
func NewOpenWrtConnector(cfg ConnectorConfig) *OpenWrtConnector {
return &OpenWrtConnector{
cfg: cfg,
// Initialize HTTP client, etc.
}
}Implement all required methods:
func (oc *OpenWrtConnector) Name() string {
return "openwrt"
}
func (oc *OpenWrtConnector) Discover(ctx context.Context) (*FirewallInfo, error) {
// Connect and fetch firewall model, firmware version, etc.
// Return nil if this isn't an OpenWrt device
}
func (oc *OpenWrtConnector) Connect(ctx context.Context) error {
// Authenticate and establish a session
// Set oc.connected = true on success
}
func (oc *OpenWrtConnector) Disconnect() error {
// Gracefully close the session
// Set oc.connected = false
}
func (oc *OpenWrtConnector) Poll(ctx context.Context) ([]FirewallEvent, error) {
// Fetch firewall events and convert to FirewallEvent slice
// Update oc.lastPoll, oc.eventCount, oc.lastError
}
func (oc *OpenWrtConnector) Health() ConnectorHealth {
// Return current health status
}Depending on your firewall, you may use:
- REST API: JSON over HTTP/HTTPS (recommended)
- SSH: Execute commands, parse output
- Log files: Poll syslog or other logs
- SNMP: Query MIBs
Example: Querying a REST API and parsing JSON:
func (oc *OpenWrtConnector) Poll(ctx context.Context) ([]FirewallEvent, error) {
req, _ := http.NewRequestWithContext(ctx, "GET",
"https://"+oc.cfg.Host+"/api/firewall/logs", nil)
req.Header.Set("Authorization", "Bearer "+oc.cfg.APIKey)
resp, err := oc.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var logs []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&logs); err != nil {
return nil, err
}
var events []FirewallEvent
for _, log := range logs {
event := oc.parseLog(log)
if event != nil {
events = append(events, *event)
}
}
oc.mu.Lock()
oc.lastPoll = time.Now()
oc.eventCount += int64(len(events))
oc.mu.Unlock()
return events, nil
}Convert firewall-specific events to the standard FirewallEvent schema:
func (oc *OpenWrtConnector) parseLog(log map[string]interface{}) *FirewallEvent {
// Extract required fields
srcIP, ok := log["src_addr"].(string)
if !ok {
return nil
}
event := &FirewallEvent{
Timestamp: parseTime(log["timestamp"].(string)),
Action: normalizeAction(log["action"].(string)),
Protocol: log["protocol"].(string),
SrcIP: srcIP,
SrcPort: int(log["src_port"].(float64)),
DstIP: log["dst_addr"].(string),
DstPort: int(log["dst_port"].(float64)),
Interface: log["interface"].(string),
Direction: log["direction"].(string),
Rule: log["rule_name"].(string),
}
return event
}
func normalizeAction(action string) string {
switch action {
case "ACCEPT", "allow":
return "allow"
case "DROP", "discard":
return "drop"
case "REJECT", "deny":
return "reject"
default:
return action
}
}Register your connector in the main application initialization:
// In main.go or initialization code
import "github.com/vedetta-network/vedetta/backend/internal/firewall"
// Create manager with event sink
manager := firewall.NewManager(func(events []models.Event) error {
return db.InsertEvents(events)
})
// Register UniFi connector
unifiCfg := firewall.ConnectorConfig{
Name: "my_unifi",
Type: "unifi",
Host: "192.168.1.1",
Port: 443,
Username: "admin",
Password: "password",
TLSSkipVerify: true,
PollInterval: 60 * time.Second,
Enabled: true,
}
unifiConn := firewall.NewUniFiConnector(unifiCfg)
manager.Register(unifiCfg, unifiConn)
// Register your OpenWrt connector
openWrtCfg := firewall.ConnectorConfig{
Name: "my_openwrt",
Type: "openwrt",
Host: "192.168.2.1",
Port: 8080,
APIKey: "your-api-key",
PollInterval: 30 * time.Second,
Enabled: true,
}
openWrtConn := firewall.NewOpenWrtConnector(openWrtCfg)
manager.Register(openWrtCfg, openWrtConn)
// Start all connectors
if err := manager.Start(); err != nil {
log.Fatal(err)
}
// Graceful shutdown
defer manager.Stop()Create backend/internal/firewall/openwrt_test.go:
package firewall
import (
"context"
"testing"
)
func TestOpenWrtConnectorName(t *testing.T) {
cfg := ConnectorConfig{Name: "test"}
conn := NewOpenWrtConnector(cfg)
if conn.Name() != "openwrt" {
t.Errorf("expected 'openwrt', got %q", conn.Name())
}
}
func TestOpenWrtConnect(t *testing.T) {
cfg := ConnectorConfig{
Host: "192.168.1.1", // Mock or real test device
Port: 8080,
APIKey: "test-key",
}
conn := NewOpenWrtConnector(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := conn.Connect(ctx)
if err != nil {
t.Fatalf("Connect failed: %v", err)
}
defer conn.Disconnect()
// Verify connection
health := conn.Health()
if !health.Connected {
t.Error("expected connected=true")
}
}
func TestOpenWrtPoll(t *testing.T) {
cfg := ConnectorConfig{
Host: "192.168.1.1",
Port: 8080,
APIKey: "test-key",
}
conn := NewOpenWrtConnector(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := conn.Connect(ctx); err != nil {
t.Fatalf("Connect failed: %v", err)
}
defer conn.Disconnect()
events, err := conn.Poll(ctx)
if err != nil {
t.Fatalf("Poll failed: %v", err)
}
// Verify event structure
for _, event := range events {
if event.Timestamp.IsZero() {
t.Error("event.Timestamp is zero")
}
if event.SrcIP == "" {
t.Error("event.SrcIP is empty")
}
}
}Test against a real firewall in your lab environment:
# Run tests with firewall details
FIREWALL_HOST=192.168.1.1 \
FIREWALL_USER=admin \
FIREWALL_PASS=password \
go test -v ./backend/internal/firewall/- Thread Safety: Use
sync.RWMutexto protect shared state (as shown in UniFi connector) - Error Handling: Track errors in
lastErrorand log appropriately - Timeouts: Always use context timeouts for network calls
- TLS Verification: Respect the
TLSSkipVerifyconfig flag for self-signed certs - Metadata: Store raw logs in
FirewallEvent.RawLogfor debugging - Graceful Shutdown: Implement proper cleanup in
Disconnect() - Rate Limiting: Respect the
PollIntervalto avoid overwhelming the firewall - Logging: Use
log.Printf()for important events (connect, disconnect, errors)
Here's a minimal but complete connector for a hypothetical firewall:
package firewall
import (
"context"
"fmt"
"net/http"
"sync"
"time"
)
type MinimalConnector struct {
cfg ConnectorConfig
client *http.Client
connected bool
lastPoll time.Time
lastError string
eventCount int64
connectTime time.Time
mu sync.RWMutex
}
func NewMinimalConnector(cfg ConnectorConfig) *MinimalConnector {
return &MinimalConnector{
cfg: cfg,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (mc *MinimalConnector) Name() string {
return "minimal"
}
func (mc *MinimalConnector) Discover(ctx context.Context) (*FirewallInfo, error) {
return &FirewallInfo{
Model: "TestModel",
Firmware: "1.0",
Features: []string{"firewall"},
}, nil
}
func (mc *MinimalConnector) Connect(ctx context.Context) error {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.connected = true
mc.connectTime = time.Now()
return nil
}
func (mc *MinimalConnector) Disconnect() error {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.connected = false
return nil
}
func (mc *MinimalConnector) Poll(ctx context.Context) ([]FirewallEvent, error) {
// Return empty for now
return []FirewallEvent{}, nil
}
func (mc *MinimalConnector) Health() ConnectorHealth {
mc.mu.RLock()
defer mc.mu.RUnlock()
uptime := time.Duration(0)
if !mc.connectTime.IsZero() {
uptime = time.Since(mc.connectTime)
}
return ConnectorHealth{
Connected: mc.connected,
LastPoll: mc.lastPoll,
LastError: mc.lastError,
EventCount: mc.eventCount,
Uptime: uptime,
}
}Purpose: Auto-detect and validate that the connected device is the expected firewall type.
Returns:
*FirewallInfo: Device metadata (model, firmware, IP config)error: If the firewall is not reachable or not the expected type
Typical implementation checks API endpoints or queries system info.
Purpose: Establish and authenticate a session.
Should:
- Validate credentials
- Establish connection (login, API token, etc.)
- Set
uc.connected = true - Set
uc.connectTime = time.Now()
Purpose: Graceful cleanup.
Should:
- Logout or revoke tokens
- Close connections
- Set
uc.connected = false
Purpose: Fetch new firewall events.
Returns:
[]FirewallEvent: Normalized eventserror: If fetch fails
Must update:
uc.lastPoll = time.Now()uc.eventCount += int64(len(events))uc.lastError(set to "" on success)
Purpose: Return current status for monitoring dashboards.
Returns ConnectorHealth with connection state, event count, last error, and uptime.
When submitting a new connector:
- Place code in
backend/internal/firewall/{type}.go - Add tests in
{type}_test.go - Document the firewall API in code comments
- Update this guide with platform-specific notes
- Test with real hardware if possible
- Submit a pull request with clear commit messages
Questions? Open an issue on the Vedetta GitHub repository.