package database import ( "database/sql" "strings" "time" _ "github.com/mattn/go-sqlite3" ) // DNSRecord is a user-owned record in the LAN zone. Names are stored fully // qualified with a trailing dot (e.g. "tux.penguins.lan.") to match the form // the resolver queries with. type DNSRecord struct { ID string UserID string Name string Type string Content string TTL int CreatedAt time.Time } const dnsColumns = "id, user_id, name, type, content, ttl, created_at" func scanDNSRecord(s interface{ Scan(...any) error }) (*DNSRecord, error) { var r DNSRecord if err := s.Scan(&r.ID, &r.UserID, &r.Name, &r.Type, &r.Content, &r.TTL, &r.CreatedAt); err != nil { return nil, err } return &r, nil } func CountUserDNSRecords(db *sql.DB, userID string) (int, error) { var count int err := db.QueryRow("SELECT COUNT(*) FROM dns_records WHERE user_id = ?", userID).Scan(&count) return count, err } func GetUserDNSRecords(db *sql.DB, userID string) ([]DNSRecord, error) { rows, err := db.Query("SELECT "+dnsColumns+" FROM dns_records WHERE user_id = ? ORDER BY name, type", userID) if err != nil { return nil, err } defer rows.Close() records := []DNSRecord{} for rows.Next() { r, err := scanDNSRecord(rows) if err != nil { return nil, err } records = append(records, *r) } return records, rows.Err() } // SaveDNSRecord upserts a record. The unique (name, type, content) index means // re-saving the same record is a no-op rather than a duplicate. func SaveDNSRecord(db *sql.DB, record *DNSRecord) (*DNSRecord, error) { if record.CreatedAt.IsZero() { record.CreatedAt = time.Now() } _, err := db.Exec( "INSERT OR REPLACE INTO dns_records ("+dnsColumns+") VALUES (?, ?, ?, ?, ?, ?, ?)", record.ID, record.UserID, record.Name, record.Type, record.Content, record.TTL, record.CreatedAt, ) if err != nil { return nil, err } return record, nil } func GetDNSRecord(db *sql.DB, recordID string) (*DNSRecord, error) { r, err := scanDNSRecord(db.QueryRow("SELECT "+dnsColumns+" FROM dns_records WHERE id = ?", recordID)) if err == sql.ErrNoRows { return nil, ErrNotFound } return r, err } func DeleteDNSRecord(db *sql.DB, recordID string) error { _, err := db.Exec("DELETE FROM dns_records WHERE id = ?", recordID) return err } // DeleteUserRecordsByNameType removes a user's records at a name of a given // type. Used to keep a single CNAME per name (so re-running the wizard or // repointing the primary replaces rather than duplicates). func DeleteUserRecordsByNameType(db *sql.DB, userID, name, qtype string) error { _, err := db.Exec("DELETE FROM dns_records WHERE user_id = ? AND name = ? AND type = ?", userID, name, qtype) return err } // RenameUserDNSDomain moves a user's records from oldDomain to newDomain (both // of the form ".", no trailing dot) when their handle changes, // rewriting the suffix in both record names and contents so their setup keeps // working under the new name. Records that don't reference the domain are left // alone. func RenameUserDNSDomain(db *sql.DB, userID, oldDomain, newDomain string) error { records, err := GetUserDNSRecords(db, userID) if err != nil { return err } for _, rec := range records { newName := rewriteDomainSuffix(rec.Name, oldDomain, newDomain) newContent := rewriteDomainSuffix(rec.Content, oldDomain, newDomain) if newName == rec.Name && newContent == rec.Content { continue } rec.Name, rec.Content = newName, newContent if _, err := SaveDNSRecord(db, &rec); err != nil { return err } } return nil } // rewriteDomainSuffix replaces a trailing oldDomain (the whole string or a // dot-separated suffix) with newDomain, preserving any trailing dot. A value // that merely contains oldDomain mid-string is left untouched. func rewriteDomainSuffix(s, oldDomain, newDomain string) string { hadDot := strings.HasSuffix(s, ".") bare := strings.TrimSuffix(s, ".") switch { case bare == oldDomain: bare = newDomain case strings.HasSuffix(bare, "."+oldDomain): bare = strings.TrimSuffix(bare, "."+oldDomain) + "." + newDomain default: return s } if hadDot { return bare + "." } return bare } // FindDNSRecords returns every record matching a fully-qualified name and type. // This is the resolver's lookup path. func FindDNSRecords(db *sql.DB, name, qtype string) ([]DNSRecord, error) { rows, err := db.Query("SELECT "+dnsColumns+" FROM dns_records WHERE name = ? AND type = ?", name, qtype) if err != nil { return nil, err } defer rows.Close() records := []DNSRecord{} for rows.Next() { r, err := scanDNSRecord(rows) if err != nil { return nil, err } records = append(records, *r) } return records, rows.Err() }