- 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.
256 lines
5.3 KiB
Go
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
|
|
}
|