summaryrefslogtreecommitdiff
path: root/database
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
parentfb653292612eddca5b088ed88466c546d1597107 (diff)
downloadpenguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.tar.gz
penguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.zip
STUFF
Diffstat (limited to 'database')
-rw-r--r--database/connections.go16
-rw-r--r--database/devices.go64
-rw-r--r--database/dns.go157
-rw-r--r--database/dns_test.go62
-rw-r--r--database/migrate.go44
-rw-r--r--database/users.go41
6 files changed, 384 insertions, 0 deletions
diff --git a/database/connections.go b/database/connections.go
index e16a17e..1af9de7 100644
--- a/database/connections.go
+++ b/database/connections.go
@@ -60,6 +60,22 @@ func MacForIP(db *sql.DB, ip string) (string, error) {
return mac, err
}
+// IPForMac returns the freshest live IP for a MAC, or "" if the device hasn't
+// been seen within the freshness window. This is what the DNS server answers a
+// device's A query with. A MAC can briefly hold more than one IP (a lease
+// change mid-window), so the most recently updated entry wins.
+func IPForMac(db *sql.DB, mac string) (string, error) {
+ var ip string
+ err := db.QueryRow(
+ "SELECT ip FROM connections WHERE mac = ? AND updated_at > ? ORDER BY updated_at DESC LIMIT 1",
+ mac, time.Now().Add(-connectionFreshness),
+ ).Scan(&ip)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", nil
+ }
+ return ip, err
+}
+
// NetworkUserIDs returns the set of users whose MAC was seen on the LAN within
// the freshness window, whether or not they're on the chat.
func NetworkUserIDs(db *sql.DB) (map[string]bool, error) {
diff --git a/database/devices.go b/database/devices.go
new file mode 100644
index 0000000..ab2a372
--- /dev/null
+++ b/database/devices.go
@@ -0,0 +1,64 @@
+package database
+
+import (
+ "database/sql"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+// UserDevice is one of a user's MACs joined to its current liveness from the
+// ARP table: IP and Online are populated only when the device was seen within
+// the freshness window.
+type UserDevice struct {
+ Mac string
+ IP string
+ Online bool
+ LastSeen time.Time
+}
+
+// MACOwnedByUsername reports whether a MAC is bound to the user with the given
+// username. Username matching is case-insensitive to suit DNS labels. This
+// gates the resolver's dynamic <mac>.<username> device records.
+func MACOwnedByUsername(db *sql.DB, mac, username string) (bool, error) {
+ var one int
+ err := db.QueryRow(`SELECT 1 FROM macs m JOIN users u ON u.id = m.user_id
+ WHERE m.mac = ? AND u.username = ? COLLATE NOCASE`, mac, username).Scan(&one)
+ if err == sql.ErrNoRows {
+ return false, nil
+ }
+ return err == nil, err
+}
+
+// ListUserMacs returns every MAC bound to a user, newest-seen first, annotated
+// with its current LAN IP when the device is live.
+func ListUserMacs(db *sql.DB, userID string) ([]UserDevice, error) {
+ fresh := time.Now().Add(-connectionFreshness)
+ rows, err := db.Query(`
+ SELECT m.mac, m.last_seen,
+ (SELECT c.ip FROM connections c
+ WHERE c.mac = m.mac AND c.updated_at > ?
+ ORDER BY c.updated_at DESC LIMIT 1) AS ip
+ FROM macs m
+ WHERE m.user_id = ?
+ ORDER BY m.last_seen DESC`,
+ fresh, userID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ devices := []UserDevice{}
+ for rows.Next() {
+ var d UserDevice
+ var ip sql.NullString
+ if err := rows.Scan(&d.Mac, &d.LastSeen, &ip); err != nil {
+ return nil, err
+ }
+ d.IP = ip.String
+ d.Online = ip.Valid
+ devices = append(devices, d)
+ }
+ return devices, rows.Err()
+}
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()
+}
diff --git a/database/dns_test.go b/database/dns_test.go
new file mode 100644
index 0000000..a893021
--- /dev/null
+++ b/database/dns_test.go
@@ -0,0 +1,62 @@
+package database
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+)
+
+func dnsTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "dns.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { db.Close() })
+ if _, err := Migrate(db); err != nil {
+ t.Fatal(err)
+ }
+ return db
+}
+
+func TestRenameUserDNSDomain(t *testing.T) {
+ db := dnsTestDB(t)
+ if _, err := CreateUser(db, "u1", "tux"); err != nil {
+ t.Fatal(err)
+ }
+
+ records := []*DNSRecord{
+ {ID: "a", UserID: "u1", Name: "tux.penguins.lan.", Type: "CNAME", Content: "aabbccddeeff.tux.penguins.lan", TTL: 300},
+ {ID: "b", UserID: "u1", Name: "www.tux.penguins.lan.", Type: "A", Content: "192.168.8.9", TTL: 60},
+ // content that merely contains the domain mid-string must NOT be rewritten
+ {ID: "c", UserID: "u1", Name: "note.tux.penguins.lan.", Type: "TXT", Content: "notmytux.penguins.lan", TTL: 60},
+ }
+ for _, r := range records {
+ if _, err := SaveDNSRecord(db, r); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if err := RenameUserDNSDomain(db, "u1", "tux.penguins.lan", "gentoo.penguins.lan"); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := GetUserDNSRecords(db, "u1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ byID := map[string]DNSRecord{}
+ for _, r := range got {
+ byID[r.ID] = r
+ }
+
+ if byID["a"].Name != "gentoo.penguins.lan." || byID["a"].Content != "aabbccddeeff.gentoo.penguins.lan" {
+ t.Errorf("apex CNAME = %q -> %q, want gentoo + device target", byID["a"].Name, byID["a"].Content)
+ }
+ if byID["b"].Name != "www.gentoo.penguins.lan." || byID["b"].Content != "192.168.8.9" {
+ t.Errorf("A record = %q -> %q, want www.gentoo + unchanged ip", byID["b"].Name, byID["b"].Content)
+ }
+ if byID["c"].Name != "note.gentoo.penguins.lan." || byID["c"].Content != "notmytux.penguins.lan" {
+ t.Errorf("TXT = %q -> %q, want name moved but content untouched", byID["c"].Name, byID["c"].Content)
+ }
+}
diff --git a/database/migrate.go b/database/migrate.go
index 24d47b6..13b2cb1 100644
--- a/database/migrate.go
+++ b/database/migrate.go
@@ -3,6 +3,7 @@ package database
import (
"database/sql"
"log"
+ "strings"
_ "github.com/mattn/go-sqlite3"
)
@@ -15,6 +16,7 @@ func MigrateUsers(dbConn *sql.DB) (*sql.DB, error) {
_, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
+ primary_mac TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`)
@@ -26,6 +28,46 @@ func MigrateUsers(dbConn *sql.DB) (*sql.DB, error) {
return dbConn, err
}
+// MigrateUserPrimaryMac adds primary_mac to pre-existing users tables. It's
+// idempotent: SQLite has no "ADD COLUMN IF NOT EXISTS", so the duplicate-column
+// error (column already present, e.g. on a fresh DB) is swallowed.
+func MigrateUserPrimaryMac(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating users.primary_mac column")
+
+ _, err := dbConn.Exec(`ALTER TABLE users ADD COLUMN primary_mac TEXT;`)
+ if err != nil && !strings.Contains(err.Error(), "duplicate column name") {
+ return dbConn, err
+ }
+ return dbConn, nil
+}
+
+// MigrateDNSRecords holds the user-editable DNS records served for the LAN zone.
+func MigrateDNSRecords(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating dns_records table")
+
+ _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS dns_records (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ type TEXT NOT NULL,
+ content TEXT NOT NULL,
+ ttl INTEGER NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );`)
+ if err != nil {
+ return dbConn, err
+ }
+
+ _, err = dbConn.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dns_records_name_content_type ON dns_records (name, type, content);`)
+ if err != nil {
+ return dbConn, err
+ }
+
+ _, err = dbConn.Exec(`CREATE INDEX IF NOT EXISTS idx_dns_records_name_type ON dns_records (name, type);`)
+ return dbConn, err
+}
+
// MigrateMacs maps hardware addresses to users. One user can own many MACs —
// that's how a privacy-rotated device "logs back in" to an existing account.
func MigrateMacs(dbConn *sql.DB) (*sql.DB, error) {
@@ -124,12 +166,14 @@ func Migrate(dbConn *sql.DB) (*sql.DB, error) {
migrations := []Migrator{
MigrateUsers,
+ MigrateUserPrimaryMac,
MigrateMacs,
MigrateSessions,
MigrateChat,
MigrateConnections,
MigrateMinecraft,
MigrateCanvas,
+ MigrateDNSRecords,
}
for _, migration := range migrations {
diff --git a/database/users.go b/database/users.go
index 169cce1..164a631 100644
--- a/database/users.go
+++ b/database/users.go
@@ -99,3 +99,44 @@ func AssociateMAC(db *sql.DB, mac, userID string) error {
mac, userID, now, now)
return err
}
+
+// MACBelongsToUser reports whether a MAC is currently bound to the given user.
+func MACBelongsToUser(db *sql.DB, mac, userID string) (bool, error) {
+ var owner string
+ err := db.QueryRow("SELECT user_id FROM macs WHERE mac = ?", mac).Scan(&owner)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return owner == userID, nil
+}
+
+// SetUserPrimaryMAC sets the user's primary device (the MAC its
+// <username>.<zone> name points at).
+func SetUserPrimaryMAC(db *sql.DB, userID, mac string) error {
+ _, err := db.Exec("UPDATE users SET primary_mac = ? WHERE id = ?", mac, userID)
+ return err
+}
+
+// SetPrimaryMACIfUnset atomically makes mac the primary device only when the
+// user has none yet. The conditional WHERE makes "first device wins" race-free:
+// concurrent associations can't both win, and there's no read-then-write gap.
+func SetPrimaryMACIfUnset(db *sql.DB, userID, mac string) error {
+ _, err := db.Exec("UPDATE users SET primary_mac = ? WHERE id = ? AND primary_mac IS NULL", mac, userID)
+ return err
+}
+
+// GetUserPrimaryMAC returns the user's primary MAC, or "" if unset.
+func GetUserPrimaryMAC(db *sql.DB, userID string) (string, error) {
+ var mac sql.NullString
+ err := db.QueryRow("SELECT primary_mac FROM users WHERE id = ?", userID).Scan(&mac)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", ErrNotFound
+ }
+ if err != nil {
+ return "", err
+ }
+ return mac.String, nil
+}