Initial commit: DDNS service with NIC V2 protocol support
Features: - Token-based subdomain claiming - NIC V2 (DynDNS2) protocol implementation - Technitium DNS integration - Rate limiting (10 req/min IP, 1 req/min token) - Web UI for space claiming - Docker/Docker Compose support - Compatible with UniFi, pfSense, EdgeRouter Module: git.dws.rip/DWS/dyn
This commit is contained in:
219
internal/database/db.go
Normal file
219
internal/database/db.go
Normal file
@@ -0,0 +1,219 @@
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user