// Package store provides database access for the poller service. package store import ( "context" "fmt" "github.com/jackc/pgx/v5/pgxpool" ) // Device represents a device row fetched from the devices table. // The poller reads ALL devices across all tenants (no RLS applied to poller_user). type Device struct { ID string TenantID string IPAddress string APIPort int APISSLPort int EncryptedCredentials []byte // legacy AES-256-GCM BYTEA EncryptedCredentialsTransit *string // OpenBao Transit ciphertext (TEXT, nullable) RouterOSVersion *string MajorVersion *int TLSMode string // "insecure" or "portal_ca" CACertPEM *string // PEM-encoded CA cert (only populated when TLSMode = "portal_ca") SSHPort int // SSH port for config backup (default 22) SSHHostKeyFingerprint *string // TOFU SSH host key fingerprint (SHA256:base64) } // DeviceStore manages PostgreSQL connections for device data access. type DeviceStore struct { pool *pgxpool.Pool } // NewDeviceStore creates a pgx connection pool and returns a DeviceStore. // // The databaseURL should use the poller_user role which has SELECT-only access // to the devices table and is not subject to RLS policies. func NewDeviceStore(ctx context.Context, databaseURL string) (*DeviceStore, error) { pool, err := pgxpool.New(ctx, databaseURL) if err != nil { return nil, fmt.Errorf("creating pgx pool: %w", err) } // Verify connectivity immediately. if err := pool.Ping(ctx); err != nil { pool.Close() return nil, fmt.Errorf("pinging database: %w", err) } return &DeviceStore{pool: pool}, nil } // FetchDevices returns all devices from the database. // // The query reads across all tenants intentionally — the poller_user role has // SELECT-only access without RLS so it can poll all devices. func (s *DeviceStore) FetchDevices(ctx context.Context) ([]Device, error) { const query = ` SELECT d.id::text, d.tenant_id::text, d.ip_address, d.api_port, d.api_ssl_port, d.encrypted_credentials, d.encrypted_credentials_transit, d.routeros_version, d.routeros_major_version, d.tls_mode, ca.cert_pem, COALESCE(d.ssh_port, 22), d.ssh_host_key_fingerprint FROM devices d LEFT JOIN certificate_authorities ca ON d.tenant_id = ca.tenant_id AND d.tls_mode = 'portal_ca' WHERE d.encrypted_credentials IS NOT NULL OR d.encrypted_credentials_transit IS NOT NULL ` rows, err := s.pool.Query(ctx, query) if err != nil { return nil, fmt.Errorf("querying devices: %w", err) } defer rows.Close() var devices []Device for rows.Next() { var d Device if err := rows.Scan( &d.ID, &d.TenantID, &d.IPAddress, &d.APIPort, &d.APISSLPort, &d.EncryptedCredentials, &d.EncryptedCredentialsTransit, &d.RouterOSVersion, &d.MajorVersion, &d.TLSMode, &d.CACertPEM, &d.SSHPort, &d.SSHHostKeyFingerprint, ); err != nil { return nil, fmt.Errorf("scanning device row: %w", err) } devices = append(devices, d) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterating device rows: %w", err) } return devices, nil } // GetDevice returns a single device by ID for interactive command execution. func (s *DeviceStore) GetDevice(ctx context.Context, deviceID string) (Device, error) { const query = ` SELECT d.id::text, d.tenant_id::text, d.ip_address, d.api_port, d.api_ssl_port, d.encrypted_credentials, d.encrypted_credentials_transit, d.routeros_version, d.routeros_major_version, d.tls_mode, ca.cert_pem, COALESCE(d.ssh_port, 22), d.ssh_host_key_fingerprint FROM devices d LEFT JOIN certificate_authorities ca ON d.tenant_id = ca.tenant_id AND d.tls_mode = 'portal_ca' WHERE d.id = $1 ` var d Device err := s.pool.QueryRow(ctx, query, deviceID).Scan( &d.ID, &d.TenantID, &d.IPAddress, &d.APIPort, &d.APISSLPort, &d.EncryptedCredentials, &d.EncryptedCredentialsTransit, &d.RouterOSVersion, &d.MajorVersion, &d.TLSMode, &d.CACertPEM, &d.SSHPort, &d.SSHHostKeyFingerprint, ) if err != nil { return Device{}, fmt.Errorf("querying device %s: %w", deviceID, err) } return d, nil } // UpdateSSHHostKey stores the SSH host key fingerprint for TOFU verification. // Called after a successful first-connect to persist the observed fingerprint. func (s *DeviceStore) UpdateSSHHostKey(ctx context.Context, deviceID string, fingerprint string) error { const query = `UPDATE devices SET ssh_host_key_fingerprint = $1, ssh_host_key_first_seen = COALESCE(ssh_host_key_first_seen, NOW()), ssh_host_key_last_verified = NOW() WHERE id = $2` _, err := s.pool.Exec(ctx, query, fingerprint, deviceID) if err != nil { return fmt.Errorf("updating SSH host key for device %s: %w", deviceID, err) } return nil } // Pool returns the underlying pgxpool.Pool for shared use by other subsystems // (e.g., credential cache key_access_log inserts). func (s *DeviceStore) Pool() *pgxpool.Pool { return s.pool } // Close closes the pgx connection pool. func (s *DeviceStore) Close() { s.pool.Close() }