Files
dyn/internal/database/db.go
Tanishq Dubey 01694f66a8 Add comprehensive logging and debug endpoints for production troubleshooting
- Add request logging middleware to main.go
- Add debug handler with health, config, stats, and test-dns endpoints
- Add detailed logging to DynDNS handler
- Add logging to Technitium DNS client
- Add database Ping() and GetStats() methods
- New endpoints:
  - /health - detailed health status with database and DNS checks
  - /debug/config - sanitized configuration
  - /debug/stats - database statistics
  - /debug/test-dns - live DNS test endpoint

This will help diagnose production issues with DNS updates.
2026-02-02 22:14:24 -05:00

256 lines
5.3 KiB
Go

package database
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"time"
"git.dws.rip/DWS/dyn/internal/models"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
conn *sql.DB
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := conn.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
db := &DB{conn: conn}
if err := db.migrate(); err != nil {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
return db, nil
}
func (db *DB) migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS spaces (
token TEXT PRIMARY KEY,
subdomain TEXT UNIQUE NOT NULL,
last_ip TEXT,
updated_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_subdomain ON spaces(subdomain);
`
_, err := db.conn.Exec(schema)
return err
}
func (db *DB) Close() error {
return db.conn.Close()
}
// Ping checks if the database connection is alive
func (db *DB) Ping() error {
return db.conn.Ping()
}
// GetStats returns database statistics
func (db *DB) GetStats() (map[string]interface{}, error) {
stats := make(map[string]interface{})
// Get total spaces count
var totalSpaces int
err := db.conn.QueryRow("SELECT COUNT(*) FROM spaces").Scan(&totalSpaces)
if err != nil {
return nil, err
}
stats["total_spaces"] = totalSpaces
// Get spaces with IPs (active)
var activeSpaces int
err = db.conn.QueryRow("SELECT COUNT(*) FROM spaces WHERE last_ip IS NOT NULL").Scan(&activeSpaces)
if err != nil {
return nil, err
}
stats["active_spaces"] = activeSpaces
// Get recently updated (last 24 hours)
var recentlyUpdated int
err = db.conn.QueryRow("SELECT COUNT(*) FROM spaces WHERE updated_at > datetime('now', '-1 day')").Scan(&recentlyUpdated)
if err != nil {
return nil, err
}
stats["recently_updated_24h"] = recentlyUpdated
return stats, nil
}
func (db *DB) CreateSpace(ctx context.Context, subdomain string) (*models.Space, error) {
token, err := generateToken()
if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
space := &models.Space{
Token: token,
Subdomain: subdomain,
CreatedAt: time.Now(),
}
query := `
INSERT INTO spaces (token, subdomain, last_ip, updated_at, created_at)
VALUES (?, ?, ?, ?, ?)
`
_, err = db.conn.ExecContext(ctx, query,
space.Token,
space.Subdomain,
space.LastIP,
space.UpdatedAt,
space.CreatedAt,
)
if err != nil {
if isUniqueConstraintError(err) {
return nil, fmt.Errorf("subdomain already taken")
}
return nil, fmt.Errorf("failed to create space: %w", err)
}
return space, nil
}
func (db *DB) GetSpaceByToken(ctx context.Context, token string) (*models.Space, error) {
query := `
SELECT token, subdomain, last_ip, updated_at, created_at
FROM spaces
WHERE token = ?
`
row := db.conn.QueryRowContext(ctx, query, token)
space := &models.Space{}
var updatedAt sql.NullTime
err := row.Scan(
&space.Token,
&space.Subdomain,
&space.LastIP,
&updatedAt,
&space.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get space: %w", err)
}
if updatedAt.Valid {
space.UpdatedAt = updatedAt.Time
}
return space, nil
}
func (db *DB) GetSpaceBySubdomain(ctx context.Context, subdomain string) (*models.Space, error) {
query := `
SELECT token, subdomain, last_ip, updated_at, created_at
FROM spaces
WHERE subdomain = ?
`
row := db.conn.QueryRowContext(ctx, query, subdomain)
space := &models.Space{}
var updatedAt sql.NullTime
err := row.Scan(
&space.Token,
&space.Subdomain,
&space.LastIP,
&updatedAt,
&space.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get space: %w", err)
}
if updatedAt.Valid {
space.UpdatedAt = updatedAt.Time
}
return space, nil
}
func (db *DB) UpdateSpaceIP(ctx context.Context, token string, ip string) error {
query := `
UPDATE spaces
SET last_ip = ?, updated_at = ?
WHERE token = ?
`
_, err := db.conn.ExecContext(ctx, query, ip, time.Now(), token)
if err != nil {
return fmt.Errorf("failed to update space IP: %w", err)
}
return nil
}
func (db *DB) SubdomainExists(ctx context.Context, subdomain string) (bool, error) {
query := `SELECT 1 FROM spaces WHERE subdomain = ?`
row := db.conn.QueryRowContext(ctx, query, subdomain)
var exists int
err := row.Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check subdomain: %w", err)
}
return true, nil
}
func generateToken() (string, error) {
bytes := make([]byte, 24)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return contains(errStr, "UNIQUE constraint failed") ||
contains(errStr, "duplicate key value")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || containsInternal(s, substr))
}
func containsInternal(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}