package opnsense import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "sync" "git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config" ) type AliasClient interface { AddIPIfMissing(ctx context.Context, ip string) (string, error) RemoveIPIfPresent(ctx context.Context, ip string) (string, error) IsIPPresent(ctx context.Context, ip string) (bool, error) } type Client struct { cfg config.OPNsenseConfig httpClient *http.Client mu sync.Mutex aliasUUID string knownAliasIPs map[string]struct{} } func NewClient(cfg config.OPNsenseConfig) *Client { transport := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}, } return &Client{ cfg: cfg, httpClient: &http.Client{ Timeout: cfg.Timeout.Duration, Transport: transport, }, } } func (c *Client) AddIPIfMissing(ctx context.Context, ip string) (string, error) { normalized, err := normalizeIP(ip) if err != nil { return "", err } c.mu.Lock() defer c.mu.Unlock() snapshot, err := c.ensureAliasSnapshotLocked(ctx) if err != nil { return "", err } if _, ok := snapshot[normalized]; ok { return "already_present", nil } payload, err := c.requestJSON(ctx, http.MethodPost, c.cfg.APIPaths.AliasUtilAdd, map[string]string{"alias": c.cfg.Alias.Name}, map[string]string{"address": normalized}) if err != nil { return "", err } if status := strings.ToLower(strings.TrimSpace(asString(payload["status"]))); status != "done" { return "", fmt.Errorf("opnsense alias add failed: %v", payload) } snapshot[normalized] = struct{}{} return "added", nil } func (c *Client) RemoveIPIfPresent(ctx context.Context, ip string) (string, error) { normalized, err := normalizeIP(ip) if err != nil { return "", err } c.mu.Lock() defer c.mu.Unlock() snapshot, err := c.ensureAliasSnapshotLocked(ctx) if err != nil { return "", err } if _, ok := snapshot[normalized]; !ok { return "already_absent", nil } payload, err := c.requestJSON(ctx, http.MethodPost, c.cfg.APIPaths.AliasUtilDelete, map[string]string{"alias": c.cfg.Alias.Name}, map[string]string{"address": normalized}) if err != nil { return "", err } if status := strings.ToLower(strings.TrimSpace(asString(payload["status"]))); status != "done" { return "", fmt.Errorf("opnsense alias delete failed: %v", payload) } delete(snapshot, normalized) return "removed", nil } func (c *Client) IsIPPresent(ctx context.Context, ip string) (bool, error) { normalized, err := normalizeIP(ip) if err != nil { return false, err } c.mu.Lock() defer c.mu.Unlock() snapshot, err := c.ensureAliasSnapshotLocked(ctx) if err != nil { return false, err } _, ok := snapshot[normalized] return ok, nil } func (c *Client) ensureAliasSnapshotLocked(ctx context.Context) (map[string]struct{}, error) { if c.knownAliasIPs != nil { return c.knownAliasIPs, nil } if err := c.ensureAliasExistsLocked(ctx); err != nil { return nil, err } payload, err := c.requestJSON(ctx, http.MethodGet, c.cfg.APIPaths.AliasUtilList, map[string]string{"alias": c.cfg.Alias.Name}, nil) if err != nil { return nil, err } rows, ok := payload["rows"].([]any) if !ok { return nil, fmt.Errorf("unexpected opnsense alias listing payload: %v", payload) } snapshot := make(map[string]struct{}, len(rows)) for _, row := range rows { rowMap, ok := row.(map[string]any) if !ok { return nil, fmt.Errorf("unexpected opnsense alias row payload: %T", row) } candidate := asString(rowMap["ip"]) if candidate == "" { candidate = asString(rowMap["address"]) } if candidate == "" { candidate = asString(rowMap["item"]) } if candidate == "" { continue } normalized, err := normalizeIP(candidate) if err != nil { continue } snapshot[normalized] = struct{}{} } c.knownAliasIPs = snapshot return snapshot, nil } func (c *Client) ensureAliasExistsLocked(ctx context.Context) error { if c.aliasUUID != "" { return nil } uuid, err := c.getAliasUUIDLocked(ctx) if err != nil { return err } if uuid == "" { if !c.cfg.EnsureAlias { return fmt.Errorf("opnsense alias %q does not exist and ensure_alias is disabled", c.cfg.Alias.Name) } if _, err := c.requestJSON(ctx, http.MethodPost, c.cfg.APIPaths.AliasAddItem, nil, map[string]any{ "alias": map[string]string{ "enabled": "1", "name": c.cfg.Alias.Name, "type": c.cfg.Alias.Type, "content": "", "description": c.cfg.Alias.Description, }, }); err != nil { return err } uuid, err = c.getAliasUUIDLocked(ctx) if err != nil { return err } if uuid == "" { return fmt.Errorf("unable to create opnsense alias %q", c.cfg.Alias.Name) } if _, err := c.requestJSON(ctx, http.MethodPost, c.cfg.APIPaths.AliasSetItem, map[string]string{"uuid": uuid}, map[string]any{ "alias": map[string]string{ "enabled": "1", "name": c.cfg.Alias.Name, "type": c.cfg.Alias.Type, "content": "", "description": c.cfg.Alias.Description, }, }); err != nil { return err } if err := c.reconfigureLocked(ctx); err != nil { return err } } c.aliasUUID = uuid return nil } func (c *Client) getAliasUUIDLocked(ctx context.Context) (string, error) { payload, err := c.requestJSON(ctx, http.MethodGet, c.cfg.APIPaths.AliasGetUUID, map[string]string{"alias": c.cfg.Alias.Name}, nil) if err != nil { return "", err } return strings.TrimSpace(asString(payload["uuid"])), nil } func (c *Client) reconfigureLocked(ctx context.Context) error { payload, err := c.requestJSON(ctx, http.MethodPost, c.cfg.APIPaths.AliasReconfig, nil, nil) if err != nil { return err } status := strings.ToLower(strings.TrimSpace(asString(payload["status"]))) if status != "ok" && status != "done" { return fmt.Errorf("opnsense alias reconfigure failed: %v", payload) } return nil } func (c *Client) requestJSON(ctx context.Context, method, pathTemplate string, pathValues map[string]string, body any) (map[string]any, error) { requestURL, err := c.buildURL(pathTemplate, pathValues) if err != nil { return nil, err } var payload io.Reader if body != nil { encoded, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("encode request body: %w", err) } payload = bytes.NewReader(encoded) } req, err := http.NewRequestWithContext(ctx, method, requestURL, payload) if err != nil { return nil, fmt.Errorf("build request: %w", err) } req.SetBasicAuth(c.cfg.APIKey, c.cfg.APISecret) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("perform request: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { payload, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) return nil, fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(payload))) } var decoded map[string]any if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { return nil, fmt.Errorf("decode response: %w", err) } return decoded, nil } func (c *Client) buildURL(pathTemplate string, values map[string]string) (string, error) { baseURL := strings.TrimRight(c.cfg.BaseURL, "/") if baseURL == "" { return "", fmt.Errorf("missing opnsense base url") } path := pathTemplate for key, value := range values { path = strings.ReplaceAll(path, "{"+key+"}", url.PathEscape(value)) } return baseURL + path, nil } func normalizeIP(ip string) (string, error) { parsed := net.ParseIP(strings.TrimSpace(ip)) if parsed == nil { return "", fmt.Errorf("invalid ip address %q", ip) } return parsed.String(), nil } func asString(value any) string { switch typed := value.(type) { case string: return typed case fmt.Stringer: return typed.String() case nil: return "" default: return fmt.Sprintf("%v", typed) } }