// Package opnfwd is a thin HTTP client wrapping an opnfwd service (an // OPNsense port-forward automation helper) running on a LAN host. Used by // the panel to auto-create / auto-delete OPNsense port forwards when a // game-server instance is created or deleted, so operators don't have to // click through OPNsense's two-page rule-editor by hand for every server. // // This integration is OPTIONAL — it only activates when an opnfwd URL + // token are configured in panel settings. Auth is via a Bearer token the // panel persists in panel_settings.opnfwd_token. // // The client never retries on failure — opnfwd writes a backup of // `/conf/config.xml` BEFORE every mutation, so a partial failure is // recoverable from opnfwd's History modal. Silent retries would risk // duplicate rule creation. package opnfwd import ( "bytes" "context" "crypto/tls" "encoding/json" "errors" "fmt" "io" "net/http" "github.com/dbledeez/panel/pkg/version" "strings" "time" ) // Client points at an opnfwd HTTP endpoint and signs every request with the // Bearer token. Zero-value Client is unusable — callers must populate at // least BaseURL and Token. type Client struct { BaseURL string // e.g. "http://:14995" or "https://" Token string // matches opnfwd's [api] token in secrets/config.toml Timeout time.Duration // default 30s if zero // Optional: when calling the public reverse-proxy URL with a self-signed // cert chain, set true to skip TLS verify. Leave false for the LAN URL. InsecureTLSSkipVerify bool } // AddRequest mirrors opnfwd's POST /api/forwards body. type AddRequest struct { Name string `json:"name"` // 1-64 chars, used as the rule's Port string `json:"port"` // single port like "7021" or range "10000-10005" Target string `json:"target"` // RFC1918 IPv4 destination (the LAN IP of the panel agent) Proto string `json:"proto"` // "tcp" | "udp" | "tcp/udp" } // AddResult mirrors opnfwd's success response — see opnfwd/app/opnsense.py // `add_forward` return dict. The `pairs` array has one entry per port (a // range expansion creates N pairs); single-port adds always return len 1. type AddResult struct { Output string `json:"output"` Pairs []ForwardPair `json:"pairs"` Count int `json:"count"` } // ForwardPair is what we persist in panel's instance_forwards table. type ForwardPair struct { Port string `json:"port"` NatUUID string `json:"nat_uuid"` FilterUUID string `json:"filter_uuid"` } // DeleteRequest is the body of POST /api/forwards/bulk-delete. type DeleteRequest struct { Items []DeleteItem `json:"items"` } // DeleteItem is one forward to remove. Either uuid alone is fine — opnfwd // drops whichever side it can find. Passing both is the recommended form // (the panel always has both because it persisted them at create time). type DeleteItem struct { NatUUID string `json:"nat_uuid,omitempty"` FilterUUID string `json:"filter_uuid,omitempty"` } // ForwardOut is one row from GET /api/forwards. Used by the reconcile path // to compare what opnfwd has against what panel believes it has. type ForwardOut struct { Name string `json:"name"` Port string `json:"port"` Target string `json:"target"` Proto string `json:"proto"` NatUUID string `json:"nat_uuid,omitempty"` FilterUUID string `json:"filter_uuid,omitempty"` } // ---- public API ---- // Health calls /api/me — round-trips the token and confirms the service is // reachable. Use this from the panel's settings page to surface a green/red // dot before the operator flips the toggle. func (c *Client) Health(ctx context.Context) error { if c.BaseURL == "" { return errors.New("opnfwd client: BaseURL is empty") } body, status, err := c.do(ctx, http.MethodGet, "/api/me", nil) if err != nil { return err } if status != http.StatusOK { return fmt.Errorf("health: HTTP %d: %s", status, truncate(body, 200)) } return nil } // Add creates ONE forward (single port). The panel always calls Add with a // single port — it tracks each manifest port individually so a deleted // instance can have its forwards removed precisely. opnfwd's range support // is a UX nicety for humans; we don't use it. func (c *Client) Add(ctx context.Context, req AddRequest) (AddResult, error) { if req.Name == "" || req.Port == "" || req.Target == "" { return AddResult{}, errors.New("opnfwd Add: name, port, target are required") } if req.Proto == "" { req.Proto = "tcp/udp" } body, status, err := c.do(ctx, http.MethodPost, "/api/forwards", req) if err != nil { return AddResult{}, err } if status != http.StatusOK { return AddResult{}, fmt.Errorf("add forward: HTTP %d: %s", status, truncate(body, 400)) } var out AddResult if err := json.Unmarshal(body, &out); err != nil { return AddResult{}, fmt.Errorf("decode add response: %w (body: %s)", err, truncate(body, 200)) } return out, nil } // BulkDelete removes N forwards in one atomic mutation. opnfwd writes a // /conf/config.xml backup before this fires. func (c *Client) BulkDelete(ctx context.Context, items []DeleteItem) error { if len(items) == 0 { return nil } body, status, err := c.do(ctx, http.MethodPost, "/api/forwards/bulk-delete", DeleteRequest{Items: items}) if err != nil { return err } if status != http.StatusOK { return fmt.Errorf("bulk delete: HTTP %d: %s", status, truncate(body, 400)) } return nil } // List returns every forward opnfwd knows about. The reconcile button // compares this to what the panel persisted in instance_forwards. func (c *Client) List(ctx context.Context) ([]ForwardOut, error) { body, status, err := c.do(ctx, http.MethodGet, "/api/forwards", nil) if err != nil { return nil, err } if status != http.StatusOK { return nil, fmt.Errorf("list forwards: HTTP %d: %s", status, truncate(body, 200)) } var out []ForwardOut if err := json.Unmarshal(body, &out); err != nil { return nil, fmt.Errorf("decode list response: %w", err) } return out, nil } // ---- internals ---- func (c *Client) do(ctx context.Context, method, path string, body any) ([]byte, int, error) { var bodyReader io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return nil, 0, fmt.Errorf("marshal body: %w", err) } bodyReader = bytes.NewReader(b) } url := strings.TrimRight(c.BaseURL, "/") + path req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) if err != nil { return nil, 0, fmt.Errorf("new request: %w", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("User-Agent", "panel-controller/"+version.Version+" (+opnfwd integration)") timeout := c.Timeout if timeout == 0 { timeout = 30 * time.Second } transport := &http.Transport{} if c.InsecureTLSSkipVerify { transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} } httpClient := &http.Client{Timeout: timeout, Transport: transport} resp, err := httpClient.Do(req) if err != nil { return nil, 0, fmt.Errorf("http: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, resp.StatusCode, fmt.Errorf("read body: %w", err) } return respBody, resp.StatusCode, nil } func truncate(b []byte, n int) string { if len(b) <= n { return string(b) } return string(b[:n]) + "…" }