panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
// 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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user