Panel — public source drop (v0.9.0)
Self-hostable game-server control panel: controller + agent + 26 game modules. One-line install (prebuilt release, no Go required): curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user