Files
panel/controller/internal/opnfwd/client.go
T
2026-07-14 19:22:07 -07:00

218 lines
7.3 KiB
Go

// 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://<opnfwd-host>:14995" or "https://<your-reverse-proxy>"
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 <descr>
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]) + "…"
}