panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
// Package ca implements the Controller's internal Certificate Authority:
|
||||
// the root-of-trust that signs both the Controller's own server cert and
|
||||
// every Agent's client cert during the pairing flow.
|
||||
//
|
||||
// Files on disk under caDir:
|
||||
//
|
||||
// ca.crt — self-signed root CA certificate (PEM)
|
||||
// ca.key — CA private key (PEM, mode 0600)
|
||||
// controller.crt — Controller's server cert, signed by ca.key (PEM)
|
||||
// controller.key — Controller's server key (PEM, mode 0600)
|
||||
//
|
||||
// Bootstrapping is idempotent: on first start we generate both pairs; on
|
||||
// every subsequent start we load them. Rotate by deleting the specific
|
||||
// file(s) you want regenerated and restarting.
|
||||
package ca
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
rsaBits = 2048
|
||||
caValidity = 10 * 365 * 24 * time.Hour
|
||||
serverValidity = 365 * 24 * time.Hour
|
||||
agentValidity = 365 * 24 * time.Hour
|
||||
caCommonName = "panel Internal CA"
|
||||
serverCommonName = "panel controller"
|
||||
)
|
||||
|
||||
// CA wraps the loaded root cert/key plus the Controller's server cert/key.
|
||||
type CA struct {
|
||||
Root *x509.Certificate
|
||||
RootKey *rsa.PrivateKey
|
||||
RootPEM []byte // cached ca.crt bytes
|
||||
Server *x509.Certificate
|
||||
ServerKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
// Ensure loads (or initializes) the CA + server cert under dir. If
|
||||
// hostnames is non-empty, it's added as SANs on the server cert (e.g.
|
||||
// "mypanel.example.com"). localhost + 127.0.0.1 + the machine's
|
||||
// hostname are always included.
|
||||
func Ensure(dir string, extraHostnames []string) (*CA, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
rootCert, rootKey, rootPEM, err := ensureRoot(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serverCert, serverKey, err := ensureServer(dir, rootCert, rootKey, extraHostnames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CA{
|
||||
Root: rootCert, RootKey: rootKey, RootPEM: rootPEM,
|
||||
Server: serverCert, ServerKey: serverKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureRoot(dir string) (*x509.Certificate, *rsa.PrivateKey, []byte, error) {
|
||||
certPath := filepath.Join(dir, "ca.crt")
|
||||
keyPath := filepath.Join(dir, "ca.key")
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
cert, pemBytes, err := loadCert(certPath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("load ca.crt: %w", err)
|
||||
}
|
||||
key, err := loadKey(keyPath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("load ca.key: %w", err)
|
||||
}
|
||||
return cert, key, pemBytes, nil
|
||||
}
|
||||
|
||||
priv, err := rsa.GenerateKey(rand.Reader, rsaBits)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("rsa key: %w", err)
|
||||
}
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: randomSerial(),
|
||||
Subject: pkix.Name{CommonName: caCommonName, Organization: []string{"panel"}},
|
||||
NotBefore: time.Now().Add(-1 * time.Minute),
|
||||
NotAfter: time.Now().Add(caValidity),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLenZero: false,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("create ca cert: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("parse ca cert: %w", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return cert, priv, certPEM, nil
|
||||
}
|
||||
|
||||
func ensureServer(dir string, root *x509.Certificate, rootKey *rsa.PrivateKey, extraHostnames []string) (*x509.Certificate, *rsa.PrivateKey, error) {
|
||||
certPath := filepath.Join(dir, "controller.crt")
|
||||
keyPath := filepath.Join(dir, "controller.key")
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
cert, _, err := loadCert(certPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load controller.crt: %w", err)
|
||||
}
|
||||
key, err := loadKey(keyPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load controller.key: %w", err)
|
||||
}
|
||||
return cert, key, nil
|
||||
}
|
||||
priv, err := rsa.GenerateKey(rand.Reader, rsaBits)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
ips := []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}
|
||||
dns := []string{"localhost", host}
|
||||
// Split extraHostnames into DNS vs IP — TLS validates IP dials against
|
||||
// IPAddresses, not DNSNames, so any LAN IP an agent will dial must
|
||||
// land in the IP set.
|
||||
for _, h := range extraHostnames {
|
||||
if ip := net.ParseIP(h); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
} else {
|
||||
dns = append(dns, h)
|
||||
}
|
||||
}
|
||||
dns = uniq(dns)
|
||||
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: randomSerial(),
|
||||
Subject: pkix.Name{CommonName: serverCommonName, Organization: []string{"panel"}},
|
||||
NotBefore: time.Now().Add(-1 * time.Minute),
|
||||
NotAfter: time.Now().Add(serverValidity),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: dns,
|
||||
IPAddresses: ips,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, root, &priv.PublicKey, rootKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}), 0o600); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return cert, priv, nil
|
||||
}
|
||||
|
||||
// SignAgentCSR validates a CSR from a pairing agent and returns the
|
||||
// signed cert as PEM. The agent's CN becomes its identity. The cert
|
||||
// is marked ExtKeyUsageClientAuth (mTLS client only).
|
||||
func (c *CA) SignAgentCSR(csrPEM []byte, agentID string) (certPEM []byte, err error) {
|
||||
block, _ := pem.Decode(csrPEM)
|
||||
if block == nil || block.Type != "CERTIFICATE REQUEST" {
|
||||
return nil, errors.New("bad CSR PEM")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CSR: %w", err)
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, fmt.Errorf("CSR signature: %w", err)
|
||||
}
|
||||
// Force our agent_id into CN + DNS SANs regardless of what the CSR asked for.
|
||||
// (This is the panel's canonical identity for the cert; the agent can't
|
||||
// claim to be someone else.)
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: randomSerial(),
|
||||
Subject: pkix.Name{CommonName: agentID, Organization: []string{"panel-agent"}},
|
||||
NotBefore: time.Now().Add(-1 * time.Minute),
|
||||
NotAfter: time.Now().Add(agentValidity),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
DNSNames: []string{agentID},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, c.Root, csr.PublicKey, c.RootKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign: %w", err)
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), nil
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func randomSerial() *big.Int {
|
||||
max := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
n, _ := rand.Int(rand.Reader, max)
|
||||
return n
|
||||
}
|
||||
|
||||
func loadCert(path string) (*x509.Certificate, []byte, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
block, _ := pem.Decode(body)
|
||||
if block == nil {
|
||||
return nil, nil, errors.New("no PEM block")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return cert, body, nil
|
||||
}
|
||||
|
||||
func loadKey(path string) (*rsa.PrivateKey, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(body)
|
||||
if block == nil {
|
||||
return nil, errors.New("no PEM block")
|
||||
}
|
||||
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
}
|
||||
|
||||
func uniq(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentRow is the persisted form of an agent record.
|
||||
type AgentRow struct {
|
||||
AgentID string
|
||||
Version string
|
||||
HostOS string
|
||||
HostArch string
|
||||
Hostname string
|
||||
Label string
|
||||
Notes string
|
||||
// PortRangeStart/End define the inclusive host-port window the panel
|
||||
// allocates from when creating new instances on this agent. Both 0
|
||||
// (unset) means use the global default 7000-9999. Set via the agent
|
||||
// edit modal so operators can isolate per-agent port pools (e.g.
|
||||
// agent A: 7000-7999, agent B: 8000-8999) for firewall sanity.
|
||||
PortRangeStart int32
|
||||
PortRangeEnd int32
|
||||
// LANIP is the agent's RFC1918 LAN IP — used as the `target` field
|
||||
// when the opnfwd integration creates a port forward for an instance
|
||||
// on this agent. Empty disables the integration for this agent (loud
|
||||
// log, no-op). Operator-edited via the agent edit modal.
|
||||
LANIP string
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
// UpsertAgent inserts the agent or updates its metadata on reconnect,
|
||||
// bumping last_seen. first_seen is preserved on conflict.
|
||||
func (db *DB) UpsertAgent(ctx context.Context, a AgentRow) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO agents (agent_id, version, host_os, host_arch, hostname, first_seen, last_seen)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
ON CONFLICT (agent_id) DO UPDATE SET
|
||||
version = EXCLUDED.version,
|
||||
host_os = EXCLUDED.host_os,
|
||||
host_arch = EXCLUDED.host_arch,
|
||||
hostname = EXCLUDED.hostname,
|
||||
last_seen = NOW()
|
||||
`, a.AgentID, a.Version, a.HostOS, a.HostArch, a.Hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert agent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchAgent bumps last_seen for an agent. Used on heartbeat arrival.
|
||||
func (db *DB) TouchAgent(ctx context.Context, agentID string) error {
|
||||
_, err := db.pool.Exec(ctx, `UPDATE agents SET last_seen = NOW() WHERE agent_id = $1`, agentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("touch agent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAgentMeta sets operator-assigned display fields.
|
||||
func (db *DB) UpdateAgentMeta(ctx context.Context, agentID, label, notes string) error {
|
||||
_, err := db.pool.Exec(ctx, `UPDATE agents SET label = $2, notes = $3 WHERE agent_id = $1`, agentID, label, notes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update agent meta: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAgentPortRange persists the host-port window this agent allocates
|
||||
// new instances from. Pass 0,0 to clear (allocator falls back to global
|
||||
// default). Validates that start <= end and both fit in uint16 range.
|
||||
func (db *DB) SetAgentPortRange(ctx context.Context, agentID string, start, end int32) error {
|
||||
if start == 0 && end == 0 {
|
||||
_, err := db.pool.Exec(ctx, `UPDATE agents SET port_range_start = NULL, port_range_end = NULL WHERE agent_id = $1`, agentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear port range: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if start <= 0 || end <= 0 || start > 65535 || end > 65535 || start > end {
|
||||
return fmt.Errorf("invalid port range %d-%d (must be 1-65535 and start<=end)", start, end)
|
||||
}
|
||||
_, err := db.pool.Exec(ctx, `UPDATE agents SET port_range_start = $2, port_range_end = $3 WHERE agent_id = $1`, agentID, start, end)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set port range: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAgentPortRange returns the configured window or (0,0) if unset.
|
||||
// (0,0) means "use the global default" — allocator decides what.
|
||||
func (db *DB) GetAgentPortRange(ctx context.Context, agentID string) (start, end int32, err error) {
|
||||
var s, e *int32
|
||||
err = db.pool.QueryRow(ctx, `SELECT port_range_start, port_range_end FROM agents WHERE agent_id = $1`, agentID).Scan(&s, &e)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if s != nil {
|
||||
start = *s
|
||||
}
|
||||
if e != nil {
|
||||
end = *e
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetAgentMeta returns just the label + notes (avoids pulling the full row
|
||||
// when we already have everything else from the in-memory agent registry).
|
||||
func (db *DB) GetAgentMeta(ctx context.Context, agentID string) (label, notes string, err error) {
|
||||
err = db.pool.QueryRow(ctx, `SELECT COALESCE(label, ''), COALESCE(notes, '') FROM agents WHERE agent_id = $1`, agentID).Scan(&label, ¬es)
|
||||
return
|
||||
}
|
||||
|
||||
// GetAgentLANIP returns the operator-set LAN IP for this agent. Empty
|
||||
// string means the operator hasn't filled it in — opnfwd integration
|
||||
// no-ops for instances on this agent until they do.
|
||||
func (db *DB) GetAgentLANIP(ctx context.Context, agentID string) (string, error) {
|
||||
var v string
|
||||
err := db.pool.QueryRow(ctx, `SELECT COALESCE(lan_ip, '') FROM agents WHERE agent_id = $1`, agentID).Scan(&v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SetAgentLANIP persists the LAN IP. Empty string is allowed (clears).
|
||||
// Caller is responsible for sanity-checking the IP format.
|
||||
func (db *DB) SetAgentLANIP(ctx context.Context, agentID, ip string) error {
|
||||
_, err := db.pool.Exec(ctx, `UPDATE agents SET lan_ip = $2 WHERE agent_id = $1`, agentID, ip)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set agent lan_ip: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAgent removes an agent record. The agent's cert remains valid
|
||||
// until expiry — true revocation would require a CRL; for MVP this is a
|
||||
// forget-from-DB. If the agent is live it'll just re-register next beat.
|
||||
func (db *DB) DeleteAgent(ctx context.Context, agentID string) error {
|
||||
_, err := db.pool.Exec(ctx, `DELETE FROM agents WHERE agent_id = $1`, agentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete agent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAgents returns every row in the agents table. The in-memory registry
|
||||
// only knows about live-connected agents, but the dashboard needs to surface
|
||||
// offline ones too so instances owned by an offline agent don't silently
|
||||
// disappear from the UI. Caller merges live + DB and marks the DB-only ones
|
||||
// as offline.
|
||||
func (db *DB) ListAgents(ctx context.Context) ([]AgentRow, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
SELECT agent_id, version, host_os, host_arch, hostname,
|
||||
COALESCE(label, ''), COALESCE(notes, ''),
|
||||
COALESCE(port_range_start, 0), COALESCE(port_range_end, 0),
|
||||
COALESCE(lan_ip, ''),
|
||||
first_seen, last_seen
|
||||
FROM agents
|
||||
ORDER BY agent_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list agents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AgentRow
|
||||
for rows.Next() {
|
||||
var a AgentRow
|
||||
if err := rows.Scan(&a.AgentID, &a.Version, &a.HostOS, &a.HostArch, &a.Hostname,
|
||||
&a.Label, &a.Notes, &a.PortRangeStart, &a.PortRangeEnd, &a.LANIP,
|
||||
&a.FirstSeen, &a.LastSeen); err != nil {
|
||||
return nil, fmt.Errorf("scan agent row: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// BackupRow is the persisted form of a backup.
|
||||
type BackupRow struct {
|
||||
ID string
|
||||
InstanceID string
|
||||
AgentID string
|
||||
Path string
|
||||
SizeBytes int64
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) InsertBackup(ctx context.Context, r BackupRow) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO backups (id, instance_id, agent_id, path, size_bytes, description)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, r.ID, r.InstanceID, r.AgentID, r.Path, r.SizeBytes, r.Description)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert backup: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteBackup(ctx context.Context, id string) error {
|
||||
_, err := db.pool.Exec(ctx, `DELETE FROM backups WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete backup: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) GetBackup(ctx context.Context, id string) (*BackupRow, error) {
|
||||
var r BackupRow
|
||||
err := db.pool.QueryRow(ctx, `
|
||||
SELECT id, instance_id, agent_id, path, size_bytes, description, created_at
|
||||
FROM backups WHERE id = $1
|
||||
`, id).Scan(&r.ID, &r.InstanceID, &r.AgentID, &r.Path, &r.SizeBytes, &r.Description, &r.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("backup not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get backup: %w", err)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListBackupsByInstance(ctx context.Context, instanceID string) ([]BackupRow, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
SELECT id, instance_id, agent_id, path, size_bytes, description, created_at
|
||||
FROM backups WHERE instance_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, instanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list backups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []BackupRow
|
||||
for rows.Next() {
|
||||
var r BackupRow
|
||||
if err := rows.Scan(&r.ID, &r.InstanceID, &r.AgentID, &r.Path, &r.SizeBytes, &r.Description, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package db owns the Controller's durable state in Postgres. It exposes
|
||||
// a small typed surface (UpsertAgent, InsertInstance, ListInstances, …)
|
||||
// built on pgx/v5 with a connection pool. Migrations are embedded so the
|
||||
// binary is self-contained.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// DB wraps a pgx pool with our typed query methods.
|
||||
type DB struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Open connects to Postgres using dsn, pings once to fail fast on bad
|
||||
// credentials or unreachable server, and runs any pending migrations.
|
||||
func Open(ctx context.Context, dsn string) (*DB, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse db dsn: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 10
|
||||
cfg.MaxConnLifetime = 30 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new pool: %w", err)
|
||||
}
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := pool.Ping(pingCtx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping db: %w", err)
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, pool); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
return &DB{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close releases pool resources.
|
||||
func (db *DB) Close() {
|
||||
if db != nil && db.pool != nil {
|
||||
db.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Pool exposes the underlying pool for tests / advanced callers.
|
||||
func (db *DB) Pool() *pgxpool.Pool { return db.pool }
|
||||
@@ -0,0 +1,104 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InstanceForwardRow is one panel-owned port forward in opnfwd. Composite
|
||||
// key (instance_id, port_name) — the panel allocates one forward per
|
||||
// manifest port per instance.
|
||||
type InstanceForwardRow struct {
|
||||
InstanceID string
|
||||
PortName string
|
||||
NatUUID string
|
||||
FilterUUID string
|
||||
TargetIP string
|
||||
HostPort int32
|
||||
Proto string // "tcp" | "udp" | "tcp/udp"
|
||||
Descr string // "panel-<instance>-<port_name>" — matches the OPNsense rule's <descr>
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertInstanceForward persists a single forward, replacing any prior row
|
||||
// for the same (instance, port_name). The replace is intentional — if the
|
||||
// panel re-creates a forward for an existing port (e.g. operator clicked
|
||||
// Reconcile and we recreated), the new uuids supersede the old.
|
||||
func (db *DB) UpsertInstanceForward(ctx context.Context, r InstanceForwardRow) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO instance_forwards (instance_id, port_name, nat_uuid, filter_uuid, target_ip, host_port, proto, descr)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (instance_id, port_name) DO UPDATE SET
|
||||
nat_uuid = EXCLUDED.nat_uuid,
|
||||
filter_uuid = EXCLUDED.filter_uuid,
|
||||
target_ip = EXCLUDED.target_ip,
|
||||
host_port = EXCLUDED.host_port,
|
||||
proto = EXCLUDED.proto,
|
||||
descr = EXCLUDED.descr
|
||||
`, r.InstanceID, r.PortName, r.NatUUID, r.FilterUUID, r.TargetIP, r.HostPort, r.Proto, r.Descr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert instance_forward: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListInstanceForwards returns every forward for the given instance, used
|
||||
// at delete time + by the instance-modal "Forwards" pill.
|
||||
func (db *DB) ListInstanceForwards(ctx context.Context, instanceID string) ([]InstanceForwardRow, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
SELECT instance_id, port_name, nat_uuid, filter_uuid, target_ip, host_port, proto, descr, created_at
|
||||
FROM instance_forwards
|
||||
WHERE instance_id = $1
|
||||
ORDER BY port_name
|
||||
`, instanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list instance_forwards: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []InstanceForwardRow
|
||||
for rows.Next() {
|
||||
var r InstanceForwardRow
|
||||
if err := rows.Scan(&r.InstanceID, &r.PortName, &r.NatUUID, &r.FilterUUID,
|
||||
&r.TargetIP, &r.HostPort, &r.Proto, &r.Descr, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan instance_forward: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListAllInstanceForwards is for the reconcile path — compare every
|
||||
// panel-tracked forward against opnfwd's view of the world.
|
||||
func (db *DB) ListAllInstanceForwards(ctx context.Context) ([]InstanceForwardRow, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
SELECT instance_id, port_name, nat_uuid, filter_uuid, target_ip, host_port, proto, descr, created_at
|
||||
FROM instance_forwards
|
||||
ORDER BY instance_id, port_name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list all instance_forwards: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []InstanceForwardRow
|
||||
for rows.Next() {
|
||||
var r InstanceForwardRow
|
||||
if err := rows.Scan(&r.InstanceID, &r.PortName, &r.NatUUID, &r.FilterUUID,
|
||||
&r.TargetIP, &r.HostPort, &r.Proto, &r.Descr, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan instance_forward: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteInstanceForwards removes every panel-tracked forward for an
|
||||
// instance. Called from the delete path AFTER opnfwd's bulk-delete
|
||||
// returned successfully — this just drops the local mirror.
|
||||
func (db *DB) DeleteInstanceForwards(ctx context.Context, instanceID string) error {
|
||||
_, err := db.pool.Exec(ctx, `DELETE FROM instance_forwards WHERE instance_id = $1`, instanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete instance_forwards: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// InstanceRow is the persisted form of an instance record.
|
||||
type InstanceRow struct {
|
||||
InstanceID string
|
||||
AgentID string
|
||||
ModuleID string
|
||||
ModuleVersion string
|
||||
RunMode string
|
||||
DataPath string
|
||||
ConfigValues map[string]string
|
||||
// AssignedPorts maps manifest port name → host port the panel
|
||||
// allocated. Persisted so stopped instances still reserve their
|
||||
// slots — net.Listen probing can't see ports for instances that
|
||||
// aren't currently running, and would otherwise hand the same
|
||||
// port to a new instance.
|
||||
AssignedPorts map[string]uint32
|
||||
Status string // 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'updating'
|
||||
LastExitCode int32
|
||||
Detail string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertInstance inserts or updates an instance by instance_id. Used from
|
||||
// CreateInstance — it's idempotent so retry is safe.
|
||||
func (db *DB) UpsertInstance(ctx context.Context, r InstanceRow) error {
|
||||
cfgJSON, err := json.Marshal(r.ConfigValues)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config_values: %w", err)
|
||||
}
|
||||
if r.AssignedPorts == nil {
|
||||
r.AssignedPorts = map[string]uint32{}
|
||||
}
|
||||
portsJSON, err := json.Marshal(r.AssignedPorts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal assigned_ports: %w", err)
|
||||
}
|
||||
_, err = db.pool.Exec(ctx, `
|
||||
INSERT INTO instances
|
||||
(instance_id, agent_id, module_id, module_version, run_mode, data_path, config_values, assigned_ports, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (instance_id) DO UPDATE SET
|
||||
agent_id = EXCLUDED.agent_id,
|
||||
module_id = EXCLUDED.module_id,
|
||||
module_version = EXCLUDED.module_version,
|
||||
run_mode = EXCLUDED.run_mode,
|
||||
data_path = EXCLUDED.data_path,
|
||||
config_values = EXCLUDED.config_values,
|
||||
assigned_ports = EXCLUDED.assigned_ports,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = NOW()
|
||||
`, r.InstanceID, r.AgentID, r.ModuleID, r.ModuleVersion, r.RunMode, r.DataPath, cfgJSON, portsJSON, r.Status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert instance: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateInstanceConfigValues replaces ONLY the config_values column. Used by
|
||||
// the durable config-render path, which must not touch status / data_path /
|
||||
// assigned_ports the way the full UpsertInstance does (a config save racing
|
||||
// an instance start could otherwise stomp a live status transition).
|
||||
func (db *DB) UpdateInstanceConfigValues(ctx context.Context, instanceID string, values map[string]string) error {
|
||||
cfgJSON, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config_values: %w", err)
|
||||
}
|
||||
_, err = db.pool.Exec(ctx, `
|
||||
UPDATE instances SET config_values = $2, updated_at = NOW()
|
||||
WHERE instance_id = $1
|
||||
`, instanceID, cfgJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update instance config_values: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateInstanceStatus records a status transition from an InstanceStateUpdate.
|
||||
// updated_at is only bumped when the status actually changed — re-announces
|
||||
// from agent reconnects (e.g. controller restart triggering an announce loop)
|
||||
// would otherwise reset the timestamp every time, which breaks the frontend's
|
||||
// "instance has been running for >10 min" heuristic for clearing the
|
||||
// "starting" overlay on log-only modules.
|
||||
func (db *DB) UpdateInstanceStatus(ctx context.Context, instanceID, status string, exitCode int32, detail string) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
UPDATE instances
|
||||
SET status = $2,
|
||||
last_exit_code = $3,
|
||||
detail = $4,
|
||||
updated_at = CASE WHEN status IS DISTINCT FROM $2 THEN NOW() ELSE updated_at END
|
||||
WHERE instance_id = $1
|
||||
`, instanceID, status, exitCode, detail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update instance status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteInstance is not yet used; included so the schema doesn't grow a
|
||||
// parallel soft-delete pattern before we need one. Call when an operator
|
||||
// permanently removes an instance.
|
||||
func (db *DB) DeleteInstance(ctx context.Context, instanceID string) error {
|
||||
_, err := db.pool.Exec(ctx, `DELETE FROM instances WHERE instance_id = $1`, instanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete instance: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstanceRow returns one instance row by ID, or an error if missing.
|
||||
func (db *DB) GetInstanceRow(ctx context.Context, instanceID string) (InstanceRow, error) {
|
||||
const q = `
|
||||
SELECT instance_id, agent_id, module_id, COALESCE(module_version, ''),
|
||||
run_mode, COALESCE(data_path, ''), config_values, assigned_ports, status,
|
||||
COALESCE(last_exit_code, 0), COALESCE(detail, ''),
|
||||
created_at, updated_at
|
||||
FROM instances WHERE instance_id = $1
|
||||
`
|
||||
var r InstanceRow
|
||||
var cfgJSON, portsJSON []byte
|
||||
err := db.pool.QueryRow(ctx, q, instanceID).Scan(
|
||||
&r.InstanceID, &r.AgentID, &r.ModuleID, &r.ModuleVersion,
|
||||
&r.RunMode, &r.DataPath, &cfgJSON, &portsJSON, &r.Status,
|
||||
&r.LastExitCode, &r.Detail,
|
||||
&r.CreatedAt, &r.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return r, fmt.Errorf("get instance %q: %w", instanceID, err)
|
||||
}
|
||||
if len(cfgJSON) > 0 {
|
||||
_ = json.Unmarshal(cfgJSON, &r.ConfigValues)
|
||||
}
|
||||
if len(portsJSON) > 0 {
|
||||
_ = json.Unmarshal(portsJSON, &r.AssignedPorts)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ListInstances returns all instances, optionally filtered by agent_id.
|
||||
func (db *DB) ListInstances(ctx context.Context, agentFilter string) ([]InstanceRow, error) {
|
||||
var rows pgx.Rows
|
||||
var err error
|
||||
const selectAll = `
|
||||
SELECT instance_id, agent_id, module_id, COALESCE(module_version, ''),
|
||||
run_mode, COALESCE(data_path, ''), config_values, assigned_ports, status,
|
||||
COALESCE(last_exit_code, 0), COALESCE(detail, ''),
|
||||
created_at, updated_at
|
||||
FROM instances
|
||||
`
|
||||
if agentFilter != "" {
|
||||
rows, err = db.pool.Query(ctx, selectAll+` WHERE agent_id = $1 ORDER BY instance_id`, agentFilter)
|
||||
} else {
|
||||
rows, err = db.pool.Query(ctx, selectAll+` ORDER BY instance_id`)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list instances: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []InstanceRow
|
||||
for rows.Next() {
|
||||
var r InstanceRow
|
||||
var cfgJSON, portsJSON []byte
|
||||
if err := rows.Scan(
|
||||
&r.InstanceID, &r.AgentID, &r.ModuleID, &r.ModuleVersion,
|
||||
&r.RunMode, &r.DataPath, &cfgJSON, &portsJSON, &r.Status,
|
||||
&r.LastExitCode, &r.Detail,
|
||||
&r.CreatedAt, &r.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan instance: %w", err)
|
||||
}
|
||||
if len(cfgJSON) > 0 {
|
||||
_ = json.Unmarshal(cfgJSON, &r.ConfigValues)
|
||||
}
|
||||
if len(portsJSON) > 0 {
|
||||
_ = json.Unmarshal(portsJSON, &r.AssignedPorts)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// migrateLockKey is the pg advisory-lock key serializing Migrate across
|
||||
// controllers sharing one database. Arbitrary but fixed: fnv-ish of
|
||||
// "panel:schema_migrations". Session-scoped (not transaction-scoped) so it
|
||||
// spans the whole multi-transaction run and releases on disconnect if the
|
||||
// process dies mid-migrate.
|
||||
const migrateLockKey int64 = 0x70616e656c6d6967 // "panelmig"
|
||||
|
||||
// Migrate applies any pending SQL files in migrations/ in filename-sorted
|
||||
// order. Each file is wrapped in a transaction alongside its schema_migrations
|
||||
// insert so failure leaves the DB in a consistent state. A session-level
|
||||
// pg advisory lock serializes concurrent controllers: the second one blocks
|
||||
// here, then finds every version already recorded and no-ops.
|
||||
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
// Dedicated connection: session advisory locks belong to a connection,
|
||||
// and pool.Exec could lock on one conn and unlock on another.
|
||||
lockConn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire migrate lock conn: %w", err)
|
||||
}
|
||||
defer lockConn.Release()
|
||||
if _, err := lockConn.Exec(ctx, `SELECT pg_advisory_lock($1)`, migrateLockKey); err != nil {
|
||||
return fmt.Errorf("acquire migrate advisory lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = lockConn.Exec(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock($1)`, migrateLockKey)
|
||||
}()
|
||||
|
||||
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
entries, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read embedded migrations: %w", err)
|
||||
}
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, name := range files {
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, name,
|
||||
).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check %s: %w", name, err)
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx for %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(body)); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return fmt.Errorf("apply %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES ($1)`, name); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return fmt.Errorf("record %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TestMigrateConcurrent runs Migrate from two goroutines against a real
|
||||
// Postgres and asserts neither errors (the advisory lock serializes them;
|
||||
// the loser sees every version recorded and no-ops). Requires a live DB:
|
||||
// set PANEL_TEST_DSN to run, e.g.
|
||||
//
|
||||
// PANEL_TEST_DSN=postgres://user:pw@localhost:5432/panel_test go test ./controller/internal/db/
|
||||
//
|
||||
// Skipped otherwise — no Postgres is assumed on dev machines or CI.
|
||||
func TestMigrateConcurrent(t *testing.T) {
|
||||
dsn := os.Getenv("PANEL_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("PANEL_TEST_DSN not set; migration lock test needs a live Postgres")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
errs[i] = Migrate(ctx, pool)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, e := range errs {
|
||||
if e != nil {
|
||||
t.Errorf("concurrent Migrate %d: %v", i, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
-- 001_init.sql — initial schema
|
||||
--
|
||||
-- agents: one row per Target agent the controller has ever seen. Updated
|
||||
-- with each connection + heartbeat. Serves as audit trail — we
|
||||
-- never delete rows, so an agent disappearing from the network
|
||||
-- doesn't wipe its history.
|
||||
-- instances: one row per declared game server. The controller, not the
|
||||
-- agent, is the source of truth for "instance X exists". Status
|
||||
-- is updated from InstanceStateUpdate messages streamed up from
|
||||
-- the agent that owns the instance.
|
||||
|
||||
CREATE TABLE agents (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL,
|
||||
host_os TEXT NOT NULL,
|
||||
host_arch TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE instances (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
module_id TEXT NOT NULL,
|
||||
module_version TEXT,
|
||||
run_mode TEXT NOT NULL DEFAULT 'docker',
|
||||
data_path TEXT,
|
||||
config_values JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
status TEXT NOT NULL DEFAULT 'stopped',
|
||||
last_exit_code INTEGER,
|
||||
detail TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_instances_agent ON instances(agent_id);
|
||||
CREATE INDEX idx_instances_module ON instances(module_id);
|
||||
CREATE INDEX idx_instances_status ON instances(status);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 002_schedules.sql — timed / event-driven actions on instances.
|
||||
--
|
||||
-- For the MVP only cron triggers are supported; trigger_kind leaves room
|
||||
-- for future 'event' / 'composite' without a schema change.
|
||||
--
|
||||
-- action is JSONB so new action types (webhook, exec, etc.) can be added
|
||||
-- without migrations. The controller's scheduler engine validates shape
|
||||
-- at dispatch time.
|
||||
--
|
||||
-- last_fired_at / last_status let the UI show whether a schedule is
|
||||
-- actually doing what it says.
|
||||
|
||||
CREATE TABLE schedules (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
trigger_kind TEXT NOT NULL DEFAULT 'cron',
|
||||
cron_spec TEXT NOT NULL,
|
||||
action JSONB NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_fired_at TIMESTAMPTZ,
|
||||
last_status TEXT,
|
||||
last_detail TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_schedules_instance ON schedules(instance_id);
|
||||
CREATE INDEX idx_schedules_enabled ON schedules(enabled) WHERE enabled = TRUE;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 003_users.sql — local accounts + session cookies.
|
||||
--
|
||||
-- Password hashes are argon2id in PHC string format: all params live in the
|
||||
-- hash itself so we can bump time/memory costs without migrating.
|
||||
--
|
||||
-- Roles are coarse for now — admin (full control) / user (read-only). Fine-
|
||||
-- grained per-resource ACL is a later migration layered on top.
|
||||
|
||||
CREATE TABLE users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token TEXT PRIMARY KEY, -- 32 bytes hex; random via crypto/rand
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
last_seen_ip TEXT,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 004_event_triggers.sql — event-driven schedule triggers.
|
||||
--
|
||||
-- Until now schedules only supported cron. This migration adds:
|
||||
-- * trigger_kind = 'event' schedules, powered by a CEL expression over
|
||||
-- the event bus. Example: players_online == 0
|
||||
-- * optional sustained-state: fire only after the condition has been
|
||||
-- continuously true for N seconds. Enables "idle shutdown if 0 players
|
||||
-- for 30m" without firing every 60s when the polled player count is 0.
|
||||
--
|
||||
-- cron_spec becomes nullable so event triggers can exist with NULL there.
|
||||
-- Exactly one of cron_spec / event_spec is populated; the application
|
||||
-- enforces this.
|
||||
|
||||
ALTER TABLE schedules ALTER COLUMN cron_spec DROP NOT NULL;
|
||||
|
||||
ALTER TABLE schedules
|
||||
ADD COLUMN event_spec TEXT,
|
||||
ADD COLUMN event_sustained_seconds INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX idx_schedules_trigger_kind ON schedules(trigger_kind);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 005_backups.sql — instance backups (tar.gz of volumes).
|
||||
--
|
||||
-- The agent writes the archive to disk under its --backup-dir and
|
||||
-- reports back path + size. Controller persists the row so operators
|
||||
-- can list / restore / delete later.
|
||||
|
||||
CREATE TABLE backups (
|
||||
id TEXT PRIMARY KEY, -- bkp_<hex16>
|
||||
instance_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL, -- absolute path on the agent
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_backups_instance ON backups(instance_id);
|
||||
CREATE INDEX idx_backups_created ON backups(created_at DESC);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 006_pair_tokens.sql — pairing tokens for agent mTLS onboarding.
|
||||
--
|
||||
-- An operator generates a token, hands it to a new agent, the agent uses
|
||||
-- it exactly once to fetch a signed client cert. Tokens expire fast
|
||||
-- (default 15 min) to limit damage from leaked tokens. token_hash is
|
||||
-- sha256 of the raw token — we never store the raw value.
|
||||
|
||||
CREATE TABLE pair_tokens (
|
||||
id TEXT PRIMARY KEY, -- pt_<hex16>
|
||||
token_hash TEXT NOT NULL UNIQUE, -- sha256 hex of the raw token
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
used_by_agent TEXT, -- agent_id that consumed it
|
||||
issued_cert_fingerprint TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pair_tokens_hash ON pair_tokens(token_hash);
|
||||
CREATE INDEX idx_pair_tokens_expires ON pair_tokens(expires_at);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 007_agent_labels.sql — operator-set display metadata for agents.
|
||||
--
|
||||
-- agent_id is the cryptographic identity (fixed, used in cert CN). Label
|
||||
-- is a friendly name the operator assigns + can change. Notes is free-form.
|
||||
|
||||
ALTER TABLE agents
|
||||
ADD COLUMN label TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN notes TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 008_steam_credentials.sql — operator's cached Steam login for SteamCMD.
|
||||
--
|
||||
-- Some games (DayZ app 223350, Arma, a handful of others) require a Steam
|
||||
-- account that owns the game — anonymous SteamCMD login returns
|
||||
-- "No subscription". The panel stores one Steam credential per operator
|
||||
-- (for v1 we only support single-user mode, so a single row is enough).
|
||||
--
|
||||
-- password_ciphertext is AES-GCM encrypted using a key derived from the
|
||||
-- controller's CA private key via HKDF-SHA256; see auth.go's
|
||||
-- steamCredentialKey(). The plaintext password never touches disk or logs.
|
||||
--
|
||||
-- sentry_file is the SSFN-style device-auth blob SteamCMD writes after a
|
||||
-- successful Steam Guard challenge. Storing it lets subsequent logins skip
|
||||
-- the 2FA prompt. NULL until first successful login.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS steam_credentials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_ciphertext BYTEA NOT NULL,
|
||||
password_nonce BYTEA NOT NULL,
|
||||
sentry_file BYTEA, -- SSFN device-auth blob, filled after successful login
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 009_steam_login.sql — Steam OpenID sign-in support.
|
||||
--
|
||||
-- Adds a nullable `steam_id` column to `users` so an admin or operator
|
||||
-- can log in with their Steam account instead of (or in addition to)
|
||||
-- email + password.
|
||||
--
|
||||
-- steam_id stores the 17-digit SteamID64 as TEXT (not BIGINT) because
|
||||
-- while the value fits in uint64, using TEXT avoids any sign-extension
|
||||
-- surprises across the app / frontend / logs. Unique constraint so one
|
||||
-- Steam account can only be linked to one panel user.
|
||||
--
|
||||
-- password_hash is made nullable: a user may exist who only signs in via
|
||||
-- Steam with no local password. The email column stays NOT NULL — even
|
||||
-- Steam-only users need a panel identity, we auto-fill with the
|
||||
-- Steam-derived email "steam+<steamid>@panel.local" when there's nothing
|
||||
-- better.
|
||||
|
||||
ALTER TABLE users ADD COLUMN steam_id TEXT;
|
||||
ALTER TABLE users ADD CONSTRAINT users_steam_id_unique UNIQUE (steam_id);
|
||||
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
|
||||
|
||||
CREATE INDEX idx_users_steam_id ON users(steam_id) WHERE steam_id IS NOT NULL;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 010_port_allocation.sql — per-agent port range + per-instance assigned ports
|
||||
--
|
||||
-- Why: probing net.Listen for "is this port free?" only catches CURRENTLY
|
||||
-- bound ports. Two ARK servers, one running, one stopped — the running
|
||||
-- one's 7777 binding is detected, but the stopped one's intent isn't.
|
||||
-- Result: a fresh create assigns 7777 to a new server, then the stopped
|
||||
-- one fails to start because something else now owns its port.
|
||||
--
|
||||
-- Fix: persist each instance's assigned host ports in the DB, and let
|
||||
-- operators set a per-agent port range. Allocation now consults BOTH
|
||||
-- the persisted assignments AND the live probe — whichever rules out
|
||||
-- a port first wins.
|
||||
|
||||
ALTER TABLE agents
|
||||
ADD COLUMN port_range_start INTEGER,
|
||||
ADD COLUMN port_range_end INTEGER;
|
||||
|
||||
-- assigned_ports holds the actual host ports the panel allocated for
|
||||
-- this instance, keyed by manifest port name. Example:
|
||||
-- {"game": 7777, "raw": 7778, "rcon": 27020}
|
||||
-- Stays attached even when the instance is stopped — that's the whole
|
||||
-- point: stopped instances reserve their ports against the next create.
|
||||
ALTER TABLE instances
|
||||
ADD COLUMN assigned_ports JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 011_opnfwd.sql — wire panel into opnfwd's port-forward HTTP API.
|
||||
--
|
||||
-- Three changes:
|
||||
-- 1. agents.lan_ip — operator-set LAN IP for this agent. opnfwd's `target`
|
||||
-- field needs the LAN-side IP of the host running the game container.
|
||||
-- Defaulted blank; the dashboard's agent edit modal lets the operator
|
||||
-- fill it in. Without it, the opnfwd integration silently no-ops for
|
||||
-- instances on that agent (loud log line, no destructive failure).
|
||||
--
|
||||
-- 2. panel_settings — singleton key/value table for instance-wide knobs.
|
||||
-- Today: opnfwd_enabled / opnfwd_url / opnfwd_token. Future: anything
|
||||
-- else that's "global, operator-edited, doesn't fit elsewhere."
|
||||
--
|
||||
-- 3. instance_forwards — what the panel believes it owns in opnfwd. We
|
||||
-- persist the {nat_uuid, filter_uuid} returned by `POST /api/forwards`
|
||||
-- so we can target opnfwd's bulk-delete on instance delete. Composite
|
||||
-- PK on (instance_id, port_name) — every public port gets one row.
|
||||
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS lan_ip TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS panel_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO panel_settings (key, value) VALUES
|
||||
('opnfwd_enabled', 'false'),
|
||||
('opnfwd_url', ''),
|
||||
('opnfwd_token', '')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS instance_forwards (
|
||||
instance_id TEXT NOT NULL,
|
||||
port_name TEXT NOT NULL,
|
||||
nat_uuid TEXT NOT NULL DEFAULT '',
|
||||
filter_uuid TEXT NOT NULL DEFAULT '',
|
||||
target_ip TEXT NOT NULL,
|
||||
host_port INTEGER NOT NULL,
|
||||
proto TEXT NOT NULL,
|
||||
descr TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (instance_id, port_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS instance_forwards_instance_id_idx
|
||||
ON instance_forwards(instance_id);
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Empyrion EAH-style features. Three tables:
|
||||
--
|
||||
-- 1. empyrion_events — append-only log of unsolicited Event_* packages
|
||||
-- streamed from the bridge. Powers the player-history view, chat log,
|
||||
-- and the "what happened while I was away" overview.
|
||||
--
|
||||
-- 2. empyrion_inv_templates — operator-defined inventory kits (name +
|
||||
-- JSON list of {itemId, count}). Click "Apply" on a player row to
|
||||
-- drop the whole kit via Request_Player_AddItem fan-out.
|
||||
--
|
||||
-- 3. empyrion_known_players — projected from empyrion_events. Cached
|
||||
-- last-seen / first-seen / login count for the players-list view so
|
||||
-- we don't aggregate the events table on every refresh.
|
||||
--
|
||||
-- All tables are scoped by instance_id so two running empyrion instances
|
||||
-- on the same agent (rare but allowed) keep separate state.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS empyrion_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
cmd TEXT NOT NULL,
|
||||
player_id INTEGER,
|
||||
steam_id TEXT,
|
||||
player_name TEXT,
|
||||
playfield TEXT,
|
||||
msg TEXT,
|
||||
raw JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS empyrion_events_instance_ts
|
||||
ON empyrion_events (instance_id, ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS empyrion_events_player_ts
|
||||
ON empyrion_events (player_id, ts DESC) WHERE player_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS empyrion_events_steam_ts
|
||||
ON empyrion_events (steam_id, ts DESC) WHERE steam_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS empyrion_events_cmd
|
||||
ON empyrion_events (instance_id, cmd, ts DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS empyrion_inv_templates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
items JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
-- items shape: [{ "itemId": 4307, "count": 1, "ammo": 0, "decay": 0 }, ...]
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS empyrion_inv_templates_name
|
||||
ON empyrion_inv_templates (name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS empyrion_known_players (
|
||||
instance_id TEXT NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
steam_id TEXT NOT NULL DEFAULT '',
|
||||
player_name TEXT NOT NULL DEFAULT '',
|
||||
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
login_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_playfield TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (instance_id, player_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS empyrion_known_players_steam
|
||||
ON empyrion_known_players (steam_id) WHERE steam_id != '';
|
||||
CREATE INDEX IF NOT EXISTS empyrion_known_players_lastseen
|
||||
ON empyrion_known_players (instance_id, last_seen DESC);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE empyrion_blueprints (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
uploader TEXT,
|
||||
notes TEXT
|
||||
);
|
||||
CREATE INDEX idx_empyrion_blueprints_name ON empyrion_blueprints(name);
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Saved-coordinate book (wdcoordinates_edit equivalent).
|
||||
CREATE TABLE empyrion_coords (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
playfield TEXT NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL,
|
||||
y DOUBLE PRECISION NOT NULL,
|
||||
z DOUBLE PRECISION NOT NULL,
|
||||
notes TEXT,
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (instance_id, name)
|
||||
);
|
||||
CREATE INDEX idx_empyrion_coords_instance ON empyrion_coords(instance_id);
|
||||
|
||||
-- Player-warning log (wdwarnings + ctrlplayerwarnings equivalent).
|
||||
-- Append-only history of admin- or auto-issued warnings.
|
||||
CREATE TABLE empyrion_warnings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL,
|
||||
player_id INT NOT NULL,
|
||||
player_name TEXT,
|
||||
reason TEXT NOT NULL,
|
||||
given_by TEXT,
|
||||
auto_rule TEXT,
|
||||
given_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_empyrion_warnings_player ON empyrion_warnings(instance_id, player_id, given_at DESC);
|
||||
|
||||
-- Configurable warning rules (CEL-driven, matches scheduler_event.go's CEL env).
|
||||
CREATE TABLE empyrion_warning_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT, -- NULL = applies to all empyrion instances
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
cel TEXT NOT NULL,
|
||||
warn_message TEXT NOT NULL,
|
||||
threshold INT NOT NULL DEFAULT 3,
|
||||
threshold_action TEXT NOT NULL DEFAULT 'kick', -- kick | ban | none
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Chat-bot configuration: per-instance trigger registry
|
||||
CREATE TABLE empyrion_chatbot_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
instance_id TEXT, -- NULL = applies to all empyrion instances
|
||||
command TEXT NOT NULL, -- player types "!<command>" in chat
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
response TEXT NOT NULL, -- supports {playerName} {playerId} {factionId} {playfield} placeholders
|
||||
channel TEXT NOT NULL DEFAULT 'whisper', -- whisper | global | faction
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (instance_id, command)
|
||||
);
|
||||
|
||||
-- Seed three default rules (global, applies to all instances)
|
||||
INSERT INTO empyrion_chatbot_rules (id, instance_id, command, description, response, channel, enabled) VALUES
|
||||
('cb_help', NULL, 'help', 'List available commands', 'Bot commands: !help · !coords · !time', 'whisper', true),
|
||||
('cb_coords', NULL, 'coords', 'Tell player their coords', 'Your position: {playfield} ({x},{y},{z})', 'whisper', true),
|
||||
('cb_time', NULL, 'time', 'Server clock', 'Server time: {now}', 'whisper', true);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 016_update_checks.sql — WI-14 "update available" detection.
|
||||
--
|
||||
-- One row per instance: the result of the last manual update check
|
||||
-- (installed buildid from the Steam ACF vs the latest buildid for the
|
||||
-- instance's branch from steamcmd app_info). CHECK-ONLY bookkeeping —
|
||||
-- nothing in the panel acts on this table automatically.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS update_checks (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
app_id TEXT NOT NULL DEFAULT '',
|
||||
branch TEXT NOT NULL DEFAULT '',
|
||||
installed_buildid TEXT NOT NULL DEFAULT '',
|
||||
latest_buildid TEXT NOT NULL DEFAULT '',
|
||||
update_available BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- 017_pair_tokens_fk_cascade.sql — fix the self-contradictory FK on
|
||||
-- pair_tokens.created_by.
|
||||
--
|
||||
-- Migration 006 declared:
|
||||
-- created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE SET NULL
|
||||
-- which is impossible: ON DELETE SET NULL tries to write NULL into a NOT NULL
|
||||
-- column, so deleting an admin that had ever minted a pair token would fail
|
||||
-- with a not-null-violation instead of cleaning up the tokens.
|
||||
--
|
||||
-- Forward-safe fix: drop the old FK constraint and re-add it as ON DELETE
|
||||
-- CASCADE. Deleting a user now removes the (short-lived, mostly-expired) pair
|
||||
-- tokens they created, which is the sane behavior for onboarding tokens.
|
||||
--
|
||||
-- The constraint name from an inline column REFERENCES is auto-generated by
|
||||
-- Postgres as <table>_<column>_fkey. We drop it defensively with IF EXISTS so
|
||||
-- this migration is safe even if the name ever differed.
|
||||
|
||||
ALTER TABLE pair_tokens
|
||||
DROP CONSTRAINT IF EXISTS pair_tokens_created_by_fkey;
|
||||
|
||||
ALTER TABLE pair_tokens
|
||||
ADD CONSTRAINT pair_tokens_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE;
|
||||
@@ -0,0 +1,79 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type PairTokenRow struct {
|
||||
ID string
|
||||
TokenHash string
|
||||
Description string
|
||||
CreatedBy int64
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
UsedAt *time.Time
|
||||
UsedByAgent string
|
||||
}
|
||||
|
||||
var ErrPairTokenNotFound = errors.New("pairing token not found or already used")
|
||||
|
||||
func (db *DB) CreatePairToken(ctx context.Context, r PairTokenRow) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO pair_tokens (id, token_hash, description, created_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, r.ID, r.TokenHash, r.Description, r.CreatedBy, r.ExpiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert pair_token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConsumePairToken looks up a token by its hash, validates that it's
|
||||
// unused + unexpired, and atomically marks it consumed. Returns the row
|
||||
// on success. Returns ErrPairTokenNotFound if no such valid token.
|
||||
func (db *DB) ConsumePairToken(ctx context.Context, hash, agentID, fingerprint string) (*PairTokenRow, error) {
|
||||
var r PairTokenRow
|
||||
err := db.pool.QueryRow(ctx, `
|
||||
UPDATE pair_tokens
|
||||
SET used_at = NOW(), used_by_agent = $2, issued_cert_fingerprint = $3
|
||||
WHERE token_hash = $1
|
||||
AND used_at IS NULL
|
||||
AND expires_at > NOW()
|
||||
RETURNING id, token_hash, description, created_by, created_at, expires_at
|
||||
`, hash, agentID, fingerprint).Scan(
|
||||
&r.ID, &r.TokenHash, &r.Description, &r.CreatedBy, &r.CreatedAt, &r.ExpiresAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrPairTokenNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("consume pair_token: %w", err)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListPairTokens(ctx context.Context) ([]PairTokenRow, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
SELECT id, token_hash, description, created_by, created_at, expires_at, used_at, COALESCE(used_by_agent, '')
|
||||
FROM pair_tokens
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PairTokenRow
|
||||
for rows.Next() {
|
||||
var r PairTokenRow
|
||||
if err := rows.Scan(&r.ID, &r.TokenHash, &r.Description, &r.CreatedBy, &r.CreatedAt, &r.ExpiresAt, &r.UsedAt, &r.UsedByAgent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GetSetting returns the value of a panel_settings key, or empty string if
|
||||
// the key isn't present (caller treats empty as "unset / disabled / default").
|
||||
func (db *DB) GetSetting(ctx context.Context, key string) (string, error) {
|
||||
var v string
|
||||
err := db.pool.QueryRow(ctx, `SELECT value FROM panel_settings WHERE key = $1`, key).Scan(&v)
|
||||
if err != nil {
|
||||
// pgx returns ErrNoRows when key missing — surface as empty, not error.
|
||||
// Settings are always optional from the caller's POV.
|
||||
if err.Error() == "no rows in result set" {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("get setting %q: %w", key, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetSetting upserts a panel_settings row.
|
||||
func (db *DB) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO panel_settings (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`, key, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set setting %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSettings dumps every key→value, used by the admin settings page to
|
||||
// populate fields. Keep this small — it's not paginated.
|
||||
func (db *DB) ListSettings(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := db.pool.Query(ctx, `SELECT key, value FROM panel_settings`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list settings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, fmt.Errorf("scan setting: %w", err)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PruneEmpyrionEvents deletes empyrion_events rows older than keep.
|
||||
// empyrion_events is append-only (one row per bridge Event_* package) and
|
||||
// grows without bound on a busy server; the history/activity views only
|
||||
// ever page over recent windows. Returns the number of rows deleted.
|
||||
//
|
||||
// empyrion_known_players is NOT pruned here: it is already compact — one
|
||||
// upserted row per (instance_id, player_id) carrying login_count and
|
||||
// first/last_seen aggregates the players view depends on.
|
||||
func (db *DB) PruneEmpyrionEvents(ctx context.Context, keep time.Duration) (int64, error) {
|
||||
tag, err := db.pool.Exec(ctx,
|
||||
`DELETE FROM empyrion_events WHERE ts < NOW() - $1::interval`,
|
||||
fmt.Sprintf("%d seconds", int64(keep.Seconds())))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune empyrion_events: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ScheduleRow is the persisted form of a schedule.
|
||||
type ScheduleRow struct {
|
||||
ID string
|
||||
InstanceID string
|
||||
Description string
|
||||
TriggerKind string // "cron" | "event"
|
||||
CronSpec string // populated for TriggerKind == "cron"
|
||||
EventSpec string // populated for TriggerKind == "event" — CEL expression returning bool
|
||||
EventSustainedSeconds int32 // for event: fire only after condition true this many seconds
|
||||
Action json.RawMessage
|
||||
Enabled bool
|
||||
LastFiredAt *time.Time
|
||||
LastStatus string
|
||||
LastDetail string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) InsertSchedule(ctx context.Context, r ScheduleRow) error {
|
||||
var cronSpec, eventSpec *string
|
||||
if r.CronSpec != "" {
|
||||
cronSpec = &r.CronSpec
|
||||
}
|
||||
if r.EventSpec != "" {
|
||||
eventSpec = &r.EventSpec
|
||||
}
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO schedules (id, instance_id, description, trigger_kind, cron_spec, event_spec, event_sustained_seconds, action, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, r.ID, r.InstanceID, r.Description, r.TriggerKind, cronSpec, eventSpec, r.EventSustainedSeconds, []byte(r.Action), r.Enabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert schedule: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteSchedule(ctx context.Context, id string) error {
|
||||
_, err := db.pool.Exec(ctx, `DELETE FROM schedules WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete schedule: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) SetScheduleEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
_, err := db.pool.Exec(ctx, `UPDATE schedules SET enabled = $2, updated_at = NOW() WHERE id = $1`, id, enabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set schedule enabled: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSchedule persists changes to an existing schedule row.
|
||||
func (db *DB) UpdateSchedule(ctx context.Context, r ScheduleRow) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
UPDATE schedules
|
||||
SET description = $2,
|
||||
trigger_kind = $3,
|
||||
cron_spec = $4,
|
||||
event_spec = $5,
|
||||
event_sustained_seconds = $6,
|
||||
action = $7,
|
||||
enabled = $8,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
r.ID, r.Description, r.TriggerKind, r.CronSpec, r.EventSpec, r.EventSustainedSeconds, []byte(r.Action), r.Enabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update schedule: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateScheduleFireResult records the outcome of a trigger fire — used by
|
||||
// the engine to surface success/failure in the UI.
|
||||
func (db *DB) UpdateScheduleFireResult(ctx context.Context, id, status, detail string) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
UPDATE schedules
|
||||
SET last_fired_at = NOW(), last_status = $2, last_detail = $3, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, id, status, detail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update schedule result: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSchedules returns every schedule, optionally filtered by instance_id.
|
||||
func (db *DB) ListSchedules(ctx context.Context, instanceFilter string) ([]ScheduleRow, error) {
|
||||
const selectAll = `
|
||||
SELECT id, instance_id, description, trigger_kind,
|
||||
COALESCE(cron_spec, ''), COALESCE(event_spec, ''), event_sustained_seconds,
|
||||
action,
|
||||
enabled, last_fired_at, COALESCE(last_status, ''), COALESCE(last_detail, ''),
|
||||
created_at, updated_at
|
||||
FROM schedules
|
||||
`
|
||||
var rows pgx.Rows
|
||||
var err error
|
||||
if instanceFilter != "" {
|
||||
rows, err = db.pool.Query(ctx, selectAll+` WHERE instance_id = $1 ORDER BY id`, instanceFilter)
|
||||
} else {
|
||||
rows, err = db.pool.Query(ctx, selectAll+` ORDER BY instance_id, id`)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list schedules: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ScheduleRow
|
||||
for rows.Next() {
|
||||
var r ScheduleRow
|
||||
var action []byte
|
||||
var lastFired *time.Time
|
||||
if err := rows.Scan(
|
||||
&r.ID, &r.InstanceID, &r.Description, &r.TriggerKind,
|
||||
&r.CronSpec, &r.EventSpec, &r.EventSustainedSeconds,
|
||||
&action,
|
||||
&r.Enabled, &lastFired, &r.LastStatus, &r.LastDetail,
|
||||
&r.CreatedAt, &r.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan schedule: %w", err)
|
||||
}
|
||||
r.Action = action
|
||||
r.LastFiredAt = lastFired
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// SteamCredentialRow is a single stored Steam account for SteamCMD
|
||||
// non-anonymous login. v1 is single-operator, so most panels will have
|
||||
// at most one row.
|
||||
type SteamCredentialRow struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordCiphertext []byte // AES-GCM ciphertext of the password
|
||||
PasswordNonce []byte // AES-GCM nonce (12 bytes)
|
||||
SentryFile []byte // SSFN device-auth blob; NULL until first success
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// GetSteamCredentialByUsername returns the stored credential for the given
|
||||
// username or pgx.ErrNoRows if none exists.
|
||||
func (db *DB) GetSteamCredentialByUsername(ctx context.Context, username string) (*SteamCredentialRow, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
SELECT id, username, password_ciphertext, password_nonce, sentry_file, last_used_at, created_at, updated_at
|
||||
FROM steam_credentials
|
||||
WHERE username = $1`, username)
|
||||
var r SteamCredentialRow
|
||||
if err := row.Scan(&r.ID, &r.Username, &r.PasswordCiphertext, &r.PasswordNonce, &r.SentryFile, &r.LastUsedAt, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// GetAnySteamCredential returns the most-recently-used Steam credential, or
|
||||
// pgx.ErrNoRows if none exist. Used when the caller doesn't care which
|
||||
// account (single-operator panels typically have one).
|
||||
func (db *DB) GetAnySteamCredential(ctx context.Context) (*SteamCredentialRow, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
SELECT id, username, password_ciphertext, password_nonce, sentry_file, last_used_at, created_at, updated_at
|
||||
FROM steam_credentials
|
||||
ORDER BY COALESCE(last_used_at, created_at) DESC
|
||||
LIMIT 1`)
|
||||
var r SteamCredentialRow
|
||||
if err := row.Scan(&r.ID, &r.Username, &r.PasswordCiphertext, &r.PasswordNonce, &r.SentryFile, &r.LastUsedAt, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// UpsertSteamCredential stores (or replaces) a Steam credential. Leaves
|
||||
// sentry_file untouched on update — that's populated separately by
|
||||
// UpdateSteamSentryFile once a SteamCMD login succeeds.
|
||||
func (db *DB) UpsertSteamCredential(ctx context.Context, username string, cipher, nonce []byte) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO steam_credentials (username, password_ciphertext, password_nonce, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (username)
|
||||
DO UPDATE SET password_ciphertext = EXCLUDED.password_ciphertext,
|
||||
password_nonce = EXCLUDED.password_nonce,
|
||||
updated_at = NOW()`,
|
||||
username, cipher, nonce)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert steam credential: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSteamSentryFile stores the SSFN blob after a successful login so
|
||||
// subsequent SteamCMD invocations can skip the Steam Guard 2FA prompt.
|
||||
func (db *DB) UpdateSteamSentryFile(ctx context.Context, username string, sentry []byte) error {
|
||||
res, err := db.pool.Exec(ctx, `
|
||||
UPDATE steam_credentials
|
||||
SET sentry_file = $2, last_used_at = NOW(), updated_at = NOW()
|
||||
WHERE username = $1`,
|
||||
username, sentry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update sentry: %w", err)
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return errors.New("steam credential not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchSteamCredential updates last_used_at without writing a new sentry.
|
||||
func (db *DB) TouchSteamCredential(ctx context.Context, username string) error {
|
||||
_, err := db.pool.Exec(ctx, `UPDATE steam_credentials SET last_used_at = NOW() WHERE username = $1`, username)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSteamCredential removes a cached credential (operator action).
|
||||
func (db *DB) DeleteSteamCredential(ctx context.Context, username string) error {
|
||||
_, err := db.pool.Exec(ctx, `DELETE FROM steam_credentials WHERE username = $1`, username)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListSteamCredentialUsernames returns just the usernames (no password
|
||||
// material) so the UI can show "Steam accounts configured: X" without
|
||||
// touching the secrets.
|
||||
func (db *DB) ListSteamCredentialUsernames(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.pool.Query(ctx, `SELECT username FROM steam_credentials ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var u string
|
||||
if err := rows.Scan(&u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, u)
|
||||
}
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
// avoid unused-import removal
|
||||
var _ = pgx.ErrNoRows
|
||||
@@ -0,0 +1,92 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// UpdateCheckRow is the persisted result of the last update-available
|
||||
// check for an instance (WI-14). CHECK-ONLY bookkeeping.
|
||||
type UpdateCheckRow struct {
|
||||
InstanceID string
|
||||
AppID string
|
||||
Branch string
|
||||
InstalledBuildID string
|
||||
LatestBuildID string
|
||||
UpdateAvailable bool
|
||||
Error string
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertUpdateCheck records the latest check result for an instance.
|
||||
func (db *DB) UpsertUpdateCheck(ctx context.Context, r UpdateCheckRow) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO update_checks (instance_id, app_id, branch, installed_buildid, latest_buildid, update_available, error, checked_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT (instance_id) DO UPDATE SET
|
||||
app_id = EXCLUDED.app_id,
|
||||
branch = EXCLUDED.branch,
|
||||
installed_buildid = EXCLUDED.installed_buildid,
|
||||
latest_buildid = EXCLUDED.latest_buildid,
|
||||
update_available = EXCLUDED.update_available,
|
||||
error = EXCLUDED.error,
|
||||
checked_at = NOW()
|
||||
`, r.InstanceID, r.AppID, r.Branch, r.InstalledBuildID, r.LatestBuildID, r.UpdateAvailable, r.Error)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert update check: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUpdateCheck returns the last stored check for an instance, or
|
||||
// (nil, nil) when the instance has never been checked.
|
||||
func (db *DB) GetUpdateCheck(ctx context.Context, instanceID string) (*UpdateCheckRow, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
SELECT instance_id, app_id, branch, installed_buildid, latest_buildid, update_available, error, checked_at
|
||||
FROM update_checks WHERE instance_id = $1
|
||||
`, instanceID)
|
||||
var r UpdateCheckRow
|
||||
err := row.Scan(&r.InstanceID, &r.AppID, &r.Branch, &r.InstalledBuildID, &r.LatestBuildID, &r.UpdateAvailable, &r.Error, &r.CheckedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get update check: %w", err)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ListUpdateChecks returns every stored check, keyed for the dashboard's
|
||||
// "Update available" badges.
|
||||
func (db *DB) ListUpdateChecks(ctx context.Context) ([]UpdateCheckRow, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
SELECT instance_id, app_id, branch, installed_buildid, latest_buildid, update_available, error, checked_at
|
||||
FROM update_checks ORDER BY instance_id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list update checks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []UpdateCheckRow
|
||||
for rows.Next() {
|
||||
var r UpdateCheckRow
|
||||
if err := rows.Scan(&r.InstanceID, &r.AppID, &r.Branch, &r.InstalledBuildID, &r.LatestBuildID, &r.UpdateAvailable, &r.Error, &r.CheckedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan update check: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteUpdateCheck removes an instance's row (instance deleted).
|
||||
func (db *DB) DeleteUpdateCheck(ctx context.Context, instanceID string) error {
|
||||
_, err := db.pool.Exec(ctx, `DELETE FROM update_checks WHERE instance_id = $1`, instanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete update check: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// UserRow is a user record.
|
||||
type UserRow struct {
|
||||
ID int64
|
||||
Email string
|
||||
PasswordHash string // empty for Steam-only users
|
||||
SteamID string // SteamID64 (17-digit); empty if not linked
|
||||
Role string // "admin" | "user"
|
||||
Disabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// SessionRow is a valid (or expired) browser session.
|
||||
type SessionRow struct {
|
||||
Token string
|
||||
UserID int64
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
LastSeenIP string
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) CountUsers(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateUser(ctx context.Context, email, passwordHash, role string) (int64, error) {
|
||||
var id int64
|
||||
err := db.pool.QueryRow(ctx, `
|
||||
INSERT INTO users (email, password_hash, role)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, email, passwordHash, role).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// CreateSteamUser provisions a new panel user with only a SteamID
|
||||
// linked — no password, sign-in via Steam OpenID only. Used by the
|
||||
// admin-side "Add user by SteamID" flow so a teammate can be granted
|
||||
// access without giving them a panel email/password.
|
||||
func (db *DB) CreateSteamUser(ctx context.Context, email, steamID, role string) (int64, error) {
|
||||
var id int64
|
||||
err := db.pool.QueryRow(ctx, `
|
||||
INSERT INTO users (email, password_hash, steam_id, role)
|
||||
VALUES ($1, NULL, $2, $3)
|
||||
RETURNING id
|
||||
`, email, steamID, role).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create steam user: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ErrUserNotFound / ErrSessionNotFound are sentinel errors for the middleware
|
||||
// to distinguish "not logged in" from real DB errors.
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
)
|
||||
|
||||
// scanSteamAware is the generic row scan that handles the nullable
|
||||
// password_hash + steam_id columns the 009 migration introduced. Used by
|
||||
// every GetUser* helper so their SQL SELECT clauses stay aligned.
|
||||
func scanSteamAware(row pgx.Row, u *UserRow) error {
|
||||
var pwHash, steamID *string
|
||||
err := row.Scan(&u.ID, &u.Email, &pwHash, &steamID, &u.Role, &u.Disabled, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pwHash != nil {
|
||||
u.PasswordHash = *pwHash
|
||||
}
|
||||
if steamID != nil {
|
||||
u.SteamID = *steamID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
|
||||
var u UserRow
|
||||
err := scanSteamAware(db.pool.QueryRow(ctx, `
|
||||
SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
|
||||
FROM users WHERE email = $1
|
||||
`, email), &u)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByID(ctx context.Context, id int64) (*UserRow, error) {
|
||||
var u UserRow
|
||||
err := scanSteamAware(db.pool.QueryRow(ctx, `
|
||||
SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
|
||||
FROM users WHERE id = $1
|
||||
`, id), &u)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetUserBySteamID looks up a user by their linked SteamID64. Returns
|
||||
// ErrUserNotFound if nobody on this panel has that Steam account linked.
|
||||
func (db *DB) GetUserBySteamID(ctx context.Context, steamID string) (*UserRow, error) {
|
||||
var u UserRow
|
||||
err := scanSteamAware(db.pool.QueryRow(ctx, `
|
||||
SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
|
||||
FROM users WHERE steam_id = $1
|
||||
`, steamID), &u)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user by steam: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetFirstAdmin returns the earliest-created admin user, or ErrUserNotFound
|
||||
// if no admin exists. Used by --initial-admin-steam-id to locate the
|
||||
// account to link a Steam ID to when no explicit target is specified.
|
||||
func (db *DB) GetFirstAdmin(ctx context.Context) (*UserRow, error) {
|
||||
var u UserRow
|
||||
err := scanSteamAware(db.pool.QueryRow(ctx, `
|
||||
SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at
|
||||
FROM users WHERE role = 'admin' AND disabled = FALSE
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
`), &u)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get first admin: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// LinkSteamIDToUser sets/replaces the steam_id on an existing user.
|
||||
// If another user already owns that Steam ID, returns an error (the
|
||||
// unique constraint enforces one-user-per-Steam-account).
|
||||
func (db *DB) LinkSteamIDToUser(ctx context.Context, userID int64, steamID string) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
UPDATE users SET steam_id = $2, updated_at = NOW() WHERE id = $1
|
||||
`, userID, steamID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("link steam id: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlinkSteamID nulls the steam_id on a user. Operator pulled the
|
||||
// Steam link from the panel UI.
|
||||
func (db *DB) UnlinkSteamID(ctx context.Context, userID int64) error {
|
||||
_, err := db.pool.Exec(ctx, `UPDATE users SET steam_id = NULL, updated_at = NOW() WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unlink steam id: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUsers returns every user. Admin-only at the API surface; this
|
||||
// helper has no auth check itself.
|
||||
func (db *DB) ListUsers(ctx context.Context) ([]UserRow, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
SELECT id, email, COALESCE(password_hash, ''), steam_id, role, disabled, created_at, updated_at
|
||||
FROM users ORDER BY created_at ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []UserRow
|
||||
for rows.Next() {
|
||||
var u UserRow
|
||||
var sid *string
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.PasswordHash, &sid, &u.Role, &u.Disabled, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
if sid != nil {
|
||||
u.SteamID = *sid
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1
|
||||
`, id, passwordHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update user password: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateSession(ctx context.Context, token string, userID int64, expiresAt time.Time, ip string) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO sessions (token, user_id, expires_at, last_seen_ip)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, token, userID, expiresAt, ip)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSessionUser returns the user associated with a valid, non-expired
|
||||
// session token. It also bumps last_seen_at so we can show active sessions
|
||||
// in a future admin UI.
|
||||
func (db *DB) GetSessionUser(ctx context.Context, token, ip string) (*UserRow, error) {
|
||||
var u UserRow
|
||||
err := db.pool.QueryRow(ctx, `
|
||||
UPDATE sessions SET last_seen_at = NOW(), last_seen_ip = $2
|
||||
WHERE token = $1 AND expires_at > NOW()
|
||||
RETURNING user_id
|
||||
`, token, ip).Scan(&u.ID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get session: %w", err)
|
||||
}
|
||||
return db.GetUserByID(ctx, u.ID)
|
||||
}
|
||||
|
||||
func (db *DB) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := db.pool.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Package events is the Controller's in-memory pub-sub for live agent
|
||||
// events (instance state, app state, player, log). The Panel service's
|
||||
// StreamEvents RPC subscribes; agent message handlers publish. Subscribers
|
||||
// have bounded buffers and are dropped rather than back-pressuring the
|
||||
// whole pipeline if they can't keep up.
|
||||
package events
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
||||
)
|
||||
|
||||
// Filter narrows which events a subscriber receives. Empty string on a
|
||||
// field means "no filter on that field".
|
||||
type Filter struct {
|
||||
InstanceID string
|
||||
AgentID string
|
||||
// ExcludeLogs drops Event_Log payloads for this subscriber. The global
|
||||
// dashboard stream sets this: log lines are by far the bulk of event
|
||||
// volume (a 7DTD boot is thousands of lines in seconds), the dashboard
|
||||
// has no DOM target for them, and shipping them anyway is what
|
||||
// overflowed the subscriber buffer (dropped events → stale status) and
|
||||
// kept the browser main thread busy. Consoles + readiness scanners use
|
||||
// per-instance subscriptions, which keep their logs.
|
||||
ExcludeLogs bool
|
||||
}
|
||||
|
||||
// Subscription is the subscriber's handle. Call Close when done.
|
||||
type Subscription struct {
|
||||
bus *Bus
|
||||
id int64
|
||||
filter Filter
|
||||
ch chan *panelv1.Event
|
||||
// sch is non-nil only for SubscribeStamped subscribers; exactly one of
|
||||
// ch/sch carries events for a given subscription.
|
||||
sch chan Stamped
|
||||
dropped atomic.Uint64
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
// Events returns the receive channel. Consumer should range over it.
|
||||
func (s *Subscription) Events() <-chan *panelv1.Event { return s.ch }
|
||||
|
||||
// Stamped returns the id-stamped receive channel. Only valid on
|
||||
// subscriptions created via SubscribeStamped (nil otherwise).
|
||||
func (s *Subscription) Stamped() <-chan Stamped { return s.sch }
|
||||
|
||||
// Dropped returns the number of events that were dropped because the
|
||||
// subscriber's buffer was full.
|
||||
func (s *Subscription) Dropped() uint64 { return s.dropped.Load() }
|
||||
|
||||
// Close unsubscribes and closes the channel. Safe to call multiple times.
|
||||
func (s *Subscription) Close() {
|
||||
if !s.closed.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
s.bus.unsubscribe(s.id)
|
||||
if s.sch != nil {
|
||||
close(s.sch)
|
||||
} else {
|
||||
close(s.ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Bus is a concurrency-safe event fan-out with per-subscriber bounded
|
||||
// buffers. Subscribers register with optional filters; publishers deliver
|
||||
// each matching event non-blocking (drop on full buffer).
|
||||
type Bus struct {
|
||||
mu sync.RWMutex
|
||||
next int64
|
||||
subs map[int64]*Subscription
|
||||
bufCap int
|
||||
// ring retains the most recent events (with monotonic ids) so SSE
|
||||
// clients can resume via Last-Event-ID. See ring.go.
|
||||
ring *replayRing
|
||||
// droppedTotal counts every event dropped on a full subscriber buffer,
|
||||
// across all subscribers (including since-closed ones). Metrics surface.
|
||||
droppedTotal atomic.Uint64
|
||||
}
|
||||
|
||||
// New creates a Bus. bufCap is the per-subscriber channel buffer.
|
||||
//
|
||||
// The default is intentionally generous: a busy install/download (SteamCMD
|
||||
// preallocation, large Minecraft world seed, etc.) can emit 40+ log lines
|
||||
// per second per instance. At the old default of 256, a browser subscriber
|
||||
// that paused for half a second could lose dozens of intermediate events —
|
||||
// the user saw "0 → 88% → 7.11%" jumps because the progress-bar frames
|
||||
// between them hit the default: branch in Publish.
|
||||
func New(bufCap int) *Bus {
|
||||
if bufCap <= 0 {
|
||||
bufCap = 4096
|
||||
}
|
||||
return &Bus{subs: map[int64]*Subscription{}, bufCap: bufCap, ring: newReplayRing(DefaultRingCap)}
|
||||
}
|
||||
|
||||
// Subscribe returns a new Subscription. Call Sub.Close when done — typically
|
||||
// via defer from the stream handler.
|
||||
func (b *Bus) Subscribe(filter Filter) *Subscription {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.next++
|
||||
s := &Subscription{
|
||||
bus: b,
|
||||
id: b.next,
|
||||
filter: filter,
|
||||
ch: make(chan *panelv1.Event, b.bufCap),
|
||||
}
|
||||
b.subs[s.id] = s
|
||||
return s
|
||||
}
|
||||
|
||||
// SubscribeStamped is Subscribe, but the subscription's channel carries the
|
||||
// event's monotonic replay id alongside the event (Stamped()). Used by the
|
||||
// SSE handler to emit `id:` lines for Last-Event-ID resume.
|
||||
func (b *Bus) SubscribeStamped(filter Filter) *Subscription {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.next++
|
||||
s := &Subscription{
|
||||
bus: b,
|
||||
id: b.next,
|
||||
filter: filter,
|
||||
sch: make(chan Stamped, b.bufCap),
|
||||
}
|
||||
b.subs[s.id] = s
|
||||
return s
|
||||
}
|
||||
|
||||
// Replay returns the retained events with ID > afterID that match the
|
||||
// filter, oldest first. ok=false signals the resume point was overwritten
|
||||
// (a gap precedes the returned events) — callers should resync via snapshot.
|
||||
func (b *Bus) Replay(afterID uint64, f Filter) ([]Stamped, bool) {
|
||||
all, ok := b.ring.since(afterID)
|
||||
out := all[:0]
|
||||
for _, s := range all {
|
||||
if match(f, s.Event) {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, ok
|
||||
}
|
||||
|
||||
// LastID returns the id of the most recently published event (0 if none).
|
||||
func (b *Bus) LastID() uint64 { return b.ring.lastID() }
|
||||
|
||||
// DroppedTotal returns the process-lifetime count of events dropped on
|
||||
// full subscriber buffers.
|
||||
func (b *Bus) DroppedTotal() uint64 { return b.droppedTotal.Load() }
|
||||
|
||||
func (b *Bus) unsubscribe(id int64) {
|
||||
b.mu.Lock()
|
||||
delete(b.subs, id)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// Publish fans the event to every matching subscriber. Non-blocking —
|
||||
// slow subscribers lose events rather than back-pressuring publishers.
|
||||
func (b *Bus) Publish(e *panelv1.Event) {
|
||||
// Stamp + retain first so replay ids are assigned in publish order.
|
||||
id := b.ring.append(e)
|
||||
b.mu.RLock()
|
||||
subs := make([]*Subscription, 0, len(b.subs))
|
||||
for _, s := range b.subs {
|
||||
if match(s.filter, e) {
|
||||
subs = append(subs, s)
|
||||
}
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
for _, s := range subs {
|
||||
if s.sch != nil {
|
||||
select {
|
||||
case s.sch <- Stamped{ID: id, Event: e}:
|
||||
default:
|
||||
s.dropped.Add(1)
|
||||
b.droppedTotal.Add(1)
|
||||
}
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case s.ch <- e:
|
||||
default:
|
||||
s.dropped.Add(1)
|
||||
b.droppedTotal.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriberCount is useful for metrics / debug.
|
||||
func (b *Bus) SubscriberCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.subs)
|
||||
}
|
||||
|
||||
func match(f Filter, e *panelv1.Event) bool {
|
||||
if f.AgentID != "" && f.AgentID != e.AgentId {
|
||||
return false
|
||||
}
|
||||
if f.InstanceID != "" {
|
||||
if eventInstanceID(e) != f.InstanceID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.ExcludeLogs {
|
||||
if _, isLog := e.Payload.(*panelv1.Event_Log); isLog {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func eventInstanceID(e *panelv1.Event) string {
|
||||
switch p := e.Payload.(type) {
|
||||
case *panelv1.Event_InstanceState:
|
||||
return p.InstanceState.InstanceId
|
||||
case *panelv1.Event_AppState:
|
||||
return p.AppState.InstanceId
|
||||
case *panelv1.Event_Player:
|
||||
return p.Player.InstanceId
|
||||
case *panelv1.Event_Log:
|
||||
return p.Log.InstanceId
|
||||
case *panelv1.Event_InstanceStats:
|
||||
return p.InstanceStats.InstanceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
||||
)
|
||||
|
||||
func logEvent(agentID, instanceID string) *panelv1.Event {
|
||||
return &panelv1.Event{
|
||||
AgentId: agentID,
|
||||
Payload: &panelv1.Event_Log{Log: &panelv1.LogLine{InstanceId: instanceID, Line: "x"}},
|
||||
}
|
||||
}
|
||||
|
||||
func statsEvent(agentID, instanceID string) *panelv1.Event {
|
||||
return &panelv1.Event{
|
||||
AgentId: agentID,
|
||||
Payload: &panelv1.Event_InstanceStats{InstanceStats: &panelv1.InstanceStatsUpdate{InstanceId: instanceID}},
|
||||
}
|
||||
}
|
||||
|
||||
// drain returns however many events are immediately buffered.
|
||||
func drain(s *Subscription) int {
|
||||
n := 0
|
||||
for {
|
||||
select {
|
||||
case <-s.Events():
|
||||
n++
|
||||
default:
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterExcludeLogs(t *testing.T) {
|
||||
b := New(16)
|
||||
sub := b.Subscribe(Filter{ExcludeLogs: true})
|
||||
defer sub.Close()
|
||||
|
||||
b.Publish(logEvent("a1", "i1"))
|
||||
b.Publish(statsEvent("a1", "i1"))
|
||||
b.Publish(logEvent("a2", "i2"))
|
||||
|
||||
if got := drain(sub); got != 1 {
|
||||
t.Fatalf("ExcludeLogs subscriber got %d events, want 1 (stats only)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterInstanceAndAgent(t *testing.T) {
|
||||
b := New(16)
|
||||
byInst := b.Subscribe(Filter{InstanceID: "i1"})
|
||||
defer byInst.Close()
|
||||
byAgent := b.Subscribe(Filter{AgentID: "a2"})
|
||||
defer byAgent.Close()
|
||||
all := b.Subscribe(Filter{})
|
||||
defer all.Close()
|
||||
|
||||
b.Publish(logEvent("a1", "i1"))
|
||||
b.Publish(statsEvent("a2", "i2"))
|
||||
|
||||
if got := drain(byInst); got != 1 {
|
||||
t.Fatalf("InstanceID filter got %d, want 1", got)
|
||||
}
|
||||
if got := drain(byAgent); got != 1 {
|
||||
t.Fatalf("AgentID filter got %d, want 1", got)
|
||||
}
|
||||
if got := drain(all); got != 2 {
|
||||
t.Fatalf("unfiltered got %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropOnFullBuffer(t *testing.T) {
|
||||
b := New(2)
|
||||
sub := b.Subscribe(Filter{})
|
||||
defer sub.Close()
|
||||
for i := 0; i < 5; i++ {
|
||||
b.Publish(statsEvent("a1", "i1"))
|
||||
}
|
||||
if got := drain(sub); got != 2 {
|
||||
t.Fatalf("buffered %d, want 2", got)
|
||||
}
|
||||
if d := sub.Dropped(); d != 3 {
|
||||
t.Fatalf("dropped %d, want 3", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
||||
)
|
||||
|
||||
// LogBuffer is a bounded per-instance ring buffer of recent events. Its
|
||||
// purpose is UX: when the operator opens the Console tab for an instance,
|
||||
// we want them to see what's been going on — not just events that happen
|
||||
// *after* they subscribed. The buffer tails the event bus continuously so
|
||||
// a short history is always available.
|
||||
//
|
||||
// Events are stored verbatim (full *Event payload) so the caller can render
|
||||
// them exactly like the live SSE stream would. Only log, player, and
|
||||
// instance_state payloads are retained — stats and app_state churn too
|
||||
// fast and aren't what "Console history" should show.
|
||||
type LogBuffer struct {
|
||||
bus *Bus
|
||||
cap int
|
||||
mu sync.Mutex
|
||||
byInst map[string]*ringSlice
|
||||
stopped bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewLogBuffer creates the buffer and starts a background goroutine that
|
||||
// consumes the bus. Call Stop to shut down.
|
||||
//
|
||||
// capPerInstance is the maximum number of events kept per instance id. The
|
||||
// UI typically asks for the last 400, so 500 is a comfortable default.
|
||||
func NewLogBuffer(bus *Bus, capPerInstance int) *LogBuffer {
|
||||
if capPerInstance <= 0 {
|
||||
capPerInstance = 500
|
||||
}
|
||||
b := &LogBuffer{
|
||||
bus: bus,
|
||||
cap: capPerInstance,
|
||||
byInst: map[string]*ringSlice{},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go b.run()
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *LogBuffer) run() {
|
||||
defer close(b.done)
|
||||
sub := b.bus.Subscribe(Filter{})
|
||||
defer sub.Close()
|
||||
for ev := range sub.Events() {
|
||||
if !isRetained(ev) {
|
||||
continue
|
||||
}
|
||||
id := eventInstanceID(ev)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.stopped {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r := b.byInst[id]
|
||||
if r == nil {
|
||||
r = newRing(b.cap)
|
||||
b.byInst[id] = r
|
||||
}
|
||||
r.push(ev)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Recent returns up to `limit` most-recent retained events for an instance,
|
||||
// oldest first. Safe to call concurrently with Run.
|
||||
func (b *LogBuffer) Recent(instanceID string, limit int) []*panelv1.Event {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
r := b.byInst[instanceID]
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.tail(limit)
|
||||
}
|
||||
|
||||
// Forget drops the buffer for an instance. Called when an instance is
|
||||
// deleted so memory doesn't leak for dead ids.
|
||||
func (b *LogBuffer) Forget(instanceID string) {
|
||||
b.mu.Lock()
|
||||
delete(b.byInst, instanceID)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stop ends the background goroutine. Safe to call multiple times.
|
||||
func (b *LogBuffer) Stop() {
|
||||
b.mu.Lock()
|
||||
if b.stopped {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.stopped = true
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// isRetained decides whether an event belongs in the console history.
|
||||
//
|
||||
// - log lines: yes, that's the whole point.
|
||||
// - player events: yes — joins/leaves/chat/deaths belong on the console.
|
||||
// - instance_state: yes — "server started"-style transitions are context.
|
||||
// - stats / app_state: no — churn too fast; would push real content out
|
||||
// of the ring.
|
||||
func isRetained(e *panelv1.Event) bool {
|
||||
switch e.Payload.(type) {
|
||||
case *panelv1.Event_Log, *panelv1.Event_Player, *panelv1.Event_InstanceState:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ringSlice is a fixed-capacity FIFO. Implemented as a slice with head/len
|
||||
// tracking to avoid allocating on steady-state appends.
|
||||
type ringSlice struct {
|
||||
items []*panelv1.Event
|
||||
head int // index of the oldest entry (when len == cap)
|
||||
len int
|
||||
cap int
|
||||
}
|
||||
|
||||
func newRing(cap int) *ringSlice {
|
||||
return &ringSlice{items: make([]*panelv1.Event, cap), cap: cap}
|
||||
}
|
||||
|
||||
func (r *ringSlice) push(e *panelv1.Event) {
|
||||
if r.len < r.cap {
|
||||
r.items[(r.head+r.len)%r.cap] = e
|
||||
r.len++
|
||||
return
|
||||
}
|
||||
// At capacity: overwrite the oldest.
|
||||
r.items[r.head] = e
|
||||
r.head = (r.head + 1) % r.cap
|
||||
}
|
||||
|
||||
func (r *ringSlice) tail(limit int) []*panelv1.Event {
|
||||
if limit <= 0 || limit > r.len {
|
||||
limit = r.len
|
||||
}
|
||||
out := make([]*panelv1.Event, 0, limit)
|
||||
start := (r.head + r.len - limit + r.cap) % r.cap
|
||||
for i := 0; i < limit; i++ {
|
||||
out = append(out, r.items[(start+i)%r.cap])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package events
|
||||
|
||||
// Replay ring: every published event gets a monotonic id and lands in a
|
||||
// fixed-capacity ring buffer so an SSE client that reconnects with
|
||||
// Last-Event-ID can be caught up without a full poll. The ring is
|
||||
// best-effort — when a client's resume point has already been overwritten,
|
||||
// Replay reports the gap and the caller falls back to the state snapshot
|
||||
// it sends on every connect anyway.
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
||||
)
|
||||
|
||||
// Stamped pairs an event with its bus-assigned monotonic id. IDs start at 1
|
||||
// and never repeat for the lifetime of the process.
|
||||
type Stamped struct {
|
||||
ID uint64
|
||||
Event *panelv1.Event
|
||||
}
|
||||
|
||||
// DefaultRingCap is how many recent events the bus retains for replay.
|
||||
// State/app/player events are small and logs dominate volume; at a burst of
|
||||
// ~200 events/s this still covers ~20s of reconnect window, and the SSE
|
||||
// snapshot covers anything older.
|
||||
const DefaultRingCap = 4096
|
||||
|
||||
type replayRing struct {
|
||||
mu sync.Mutex
|
||||
buf []Stamped
|
||||
next uint64 // id to assign to the NEXT appended event (starts at 1)
|
||||
head int // index of oldest valid entry
|
||||
n int // number of valid entries (<= cap)
|
||||
}
|
||||
|
||||
func newReplayRing(capacity int) *replayRing {
|
||||
if capacity <= 0 {
|
||||
capacity = DefaultRingCap
|
||||
}
|
||||
return &replayRing{buf: make([]Stamped, capacity), next: 1}
|
||||
}
|
||||
|
||||
// append stamps e with the next monotonic id, stores it, and returns the id.
|
||||
func (r *replayRing) append(e *panelv1.Event) uint64 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
id := r.next
|
||||
r.next++
|
||||
idx := (r.head + r.n) % len(r.buf)
|
||||
if r.n < len(r.buf) {
|
||||
r.buf[idx] = Stamped{ID: id, Event: e}
|
||||
r.n++
|
||||
} else {
|
||||
r.buf[r.head] = Stamped{ID: id, Event: e}
|
||||
r.head = (r.head + 1) % len(r.buf)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// lastID returns the most recently assigned id (0 when nothing published).
|
||||
func (r *replayRing) lastID() uint64 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.next - 1
|
||||
}
|
||||
|
||||
// since returns every retained event with ID > afterID, oldest first.
|
||||
// ok=false means the resume point has been overwritten (afterID is older
|
||||
// than the oldest retained event), so the returned slice — while still the
|
||||
// full retained suffix — has a gap before it; callers should resync from a
|
||||
// snapshot. afterID >= lastID returns (nil, true).
|
||||
func (r *replayRing) since(afterID uint64) (out []Stamped, ok bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.n == 0 {
|
||||
// Nothing retained: only gapless if the caller is already current.
|
||||
return nil, afterID >= r.next-1
|
||||
}
|
||||
oldest := r.buf[r.head].ID
|
||||
ok = afterID >= oldest-1
|
||||
for i := 0; i < r.n; i++ {
|
||||
s := r.buf[(r.head+i)%len(r.buf)]
|
||||
if s.ID > afterID {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, ok
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
||||
)
|
||||
|
||||
func stateEvent(instanceID string) *panelv1.Event {
|
||||
return &panelv1.Event{Payload: &panelv1.Event_InstanceState{
|
||||
InstanceState: &panelv1.InstanceStateUpdate{InstanceId: instanceID},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestRingMonotonicIDsAndSince(t *testing.T) {
|
||||
r := newReplayRing(8)
|
||||
for i := 0; i < 5; i++ {
|
||||
id := r.append(stateEvent(fmt.Sprintf("i%d", i)))
|
||||
if want := uint64(i + 1); id != want {
|
||||
t.Fatalf("append id = %d, want %d", id, want)
|
||||
}
|
||||
}
|
||||
if r.lastID() != 5 {
|
||||
t.Fatalf("lastID = %d, want 5", r.lastID())
|
||||
}
|
||||
out, ok := r.since(2)
|
||||
if !ok {
|
||||
t.Fatalf("since(2) reported a gap on an unfilled ring")
|
||||
}
|
||||
if len(out) != 3 || out[0].ID != 3 || out[2].ID != 5 {
|
||||
t.Fatalf("since(2) = %+v, want ids 3..5", out)
|
||||
}
|
||||
// Fully caught up → empty, gapless.
|
||||
if out, ok := r.since(5); len(out) != 0 || !ok {
|
||||
t.Fatalf("since(lastID) = (%d events, ok=%v), want (0, true)", len(out), ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRingOverflowReportsGap(t *testing.T) {
|
||||
r := newReplayRing(4)
|
||||
for i := 0; i < 10; i++ { // ids 1..10; ring retains 7..10
|
||||
r.append(stateEvent("x"))
|
||||
}
|
||||
out, ok := r.since(3)
|
||||
if ok {
|
||||
t.Fatalf("since(3) after overflow should report a gap")
|
||||
}
|
||||
if len(out) != 4 || out[0].ID != 7 || out[3].ID != 10 {
|
||||
t.Fatalf("since(3) = %d events (first=%d), want retained suffix 7..10", len(out), out[0].ID)
|
||||
}
|
||||
// Resume point exactly at oldest-1 is gapless.
|
||||
if _, ok := r.since(6); !ok {
|
||||
t.Fatalf("since(oldest-1) should be gapless")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusReplayFiltersAndStampedSubscribe(t *testing.T) {
|
||||
b := New(16)
|
||||
sub := b.SubscribeStamped(Filter{InstanceID: "a"})
|
||||
defer sub.Close()
|
||||
|
||||
b.Publish(stateEvent("a")) // id 1
|
||||
b.Publish(stateEvent("b")) // id 2
|
||||
b.Publish(stateEvent("a")) // id 3
|
||||
|
||||
// Stamped subscription only sees matching events, with real ids.
|
||||
got1 := <-sub.Stamped()
|
||||
got2 := <-sub.Stamped()
|
||||
if got1.ID != 1 || got2.ID != 3 {
|
||||
t.Fatalf("stamped ids = %d,%d, want 1,3", got1.ID, got2.ID)
|
||||
}
|
||||
|
||||
// Replay applies the same filter.
|
||||
out, ok := b.Replay(0, Filter{InstanceID: "a"})
|
||||
if !ok || len(out) != 2 || out[0].ID != 1 || out[1].ID != 3 {
|
||||
t.Fatalf("Replay = %+v ok=%v, want ids 1,3 gapless", out, ok)
|
||||
}
|
||||
if b.LastID() != 3 {
|
||||
t.Fatalf("LastID = %d, want 3", b.LastID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusDroppedTotal(t *testing.T) {
|
||||
b := New(1)
|
||||
sub := b.Subscribe(Filter{})
|
||||
defer sub.Close()
|
||||
b.Publish(stateEvent("a")) // fills the 1-slot buffer
|
||||
b.Publish(stateEvent("a")) // dropped
|
||||
if b.DroppedTotal() != 1 {
|
||||
t.Fatalf("DroppedTotal = %d, want 1", b.DroppedTotal())
|
||||
}
|
||||
}
|
||||
@@ -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