summaryrefslogtreecommitdiff
path: root/database/dns.go
diff options
context:
space:
mode:
authorAlexander Hunt <me@liz.coffee>2026-06-21 12:43:31 -0700
committerAlexander Hunt <me@liz.coffee>2026-06-21 12:43:31 -0700
commitcfc0d34a584254a9ed76620951c810771aff6f3b (patch)
tree7fba034f7347ce0003e7a3a56adcaf862c834907 /database/dns.go
parentfb653292612eddca5b088ed88466c546d1597107 (diff)
downloadpenguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.tar.gz
penguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.zip
STUFF
Diffstat (limited to 'database/dns.go')
-rw-r--r--database/dns.go157
1 files changed, 157 insertions, 0 deletions
diff --git a/database/dns.go b/database/dns.go
new file mode 100644
index 0000000..8372936
--- /dev/null
+++ b/database/dns.go
@@ -0,0 +1,157 @@
+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 "<username>.<zone>", 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()
+}