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() }