diff --git a/cli b/cli deleted file mode 100755 index c6c8605..0000000 Binary files a/cli and /dev/null differ diff --git a/gateway b/gateway deleted file mode 100755 index 313a6ce..0000000 Binary files a/gateway and /dev/null differ diff --git a/migrations/009_dns_records_multi.sql b/migrations/009_dns_records_multi.sql new file mode 100644 index 0000000..17b8f0b --- /dev/null +++ b/migrations/009_dns_records_multi.sql @@ -0,0 +1,45 @@ +-- Migration 009: Update DNS Records to Support Multiple Records per FQDN +-- This allows round-robin A records and multiple NS records for the same domain + +BEGIN; + +-- SQLite doesn't support DROP CONSTRAINT, so we recreate the table +-- First, create the new table structure +CREATE TABLE IF NOT EXISTS dns_records_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fqdn TEXT NOT NULL, -- Fully qualified domain name (e.g., myapp.node-7prvNa.orama.network) + record_type TEXT NOT NULL DEFAULT 'A',-- DNS record type: A, AAAA, CNAME, TXT, NS, SOA + value TEXT NOT NULL, -- IP address or target value + ttl INTEGER NOT NULL DEFAULT 300, -- Time to live in seconds + priority INTEGER DEFAULT 0, -- Priority for MX/SRV records, or weight for round-robin + namespace TEXT NOT NULL DEFAULT 'system', -- Namespace that owns this record + deployment_id TEXT, -- Optional: deployment that created this record + node_id TEXT, -- Optional: specific node ID for node-specific routing + is_active BOOLEAN NOT NULL DEFAULT TRUE,-- Enable/disable without deleting + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by TEXT NOT NULL DEFAULT 'system', -- Wallet address or 'system' for auto-created records + UNIQUE(fqdn, record_type, value) -- Allow multiple records of same type for same FQDN, but not duplicates +); + +-- Copy existing data if the old table exists +INSERT OR IGNORE INTO dns_records_new (id, fqdn, record_type, value, ttl, namespace, deployment_id, node_id, is_active, created_at, updated_at, created_by) +SELECT id, fqdn, record_type, value, ttl, namespace, deployment_id, node_id, is_active, created_at, updated_at, created_by +FROM dns_records WHERE 1=1; + +-- Drop old table and rename new one +DROP TABLE IF EXISTS dns_records; +ALTER TABLE dns_records_new RENAME TO dns_records; + +-- Recreate indexes +CREATE INDEX IF NOT EXISTS idx_dns_records_fqdn ON dns_records(fqdn); +CREATE INDEX IF NOT EXISTS idx_dns_records_fqdn_type ON dns_records(fqdn, record_type); +CREATE INDEX IF NOT EXISTS idx_dns_records_namespace ON dns_records(namespace); +CREATE INDEX IF NOT EXISTS idx_dns_records_deployment ON dns_records(deployment_id); +CREATE INDEX IF NOT EXISTS idx_dns_records_node_id ON dns_records(node_id); +CREATE INDEX IF NOT EXISTS idx_dns_records_active ON dns_records(is_active); + +-- Mark migration as applied +INSERT OR IGNORE INTO schema_migrations(version) VALUES (9); + +COMMIT; diff --git a/pkg/cli/production/upgrade/orchestrator.go b/pkg/cli/production/upgrade/orchestrator.go index d12c27e..ecc4fa6 100644 --- a/pkg/cli/production/upgrade/orchestrator.go +++ b/pkg/cli/production/upgrade/orchestrator.go @@ -341,6 +341,18 @@ func (o *Orchestrator) restartServices() error { // Restart services to apply changes - use getProductionServices to only restart existing services services := utils.GetProductionServices() + + // If this is a nameserver, also restart CoreDNS and Caddy + if o.setup.IsNameserver() { + nameserverServices := []string{"coredns", "caddy"} + for _, svc := range nameserverServices { + unitPath := filepath.Join("/etc/systemd/system", svc+".service") + if _, err := os.Stat(unitPath); err == nil { + services = append(services, svc) + } + } + } + if len(services) == 0 { fmt.Printf(" ⚠️ No services found to restart\n") } else { diff --git a/pkg/coredns/rqlite/backend.go b/pkg/coredns/rqlite/backend.go index 75041c4..54696e5 100644 --- a/pkg/coredns/rqlite/backend.go +++ b/pkg/coredns/rqlite/backend.go @@ -142,11 +142,66 @@ func (b *Backend) parseValue(recordType, value string) (interface{}, error) { case "TXT": return []string{value}, nil + case "NS": + return dns.Fqdn(value), nil + + case "SOA": + // SOA format: "mname rname serial refresh retry expire minimum" + // Example: "ns1.dbrs.space. admin.dbrs.space. 2026012401 3600 1800 604800 300" + return b.parseSOA(value) + default: return nil, fmt.Errorf("unsupported record type: %s", recordType) } } +// parseSOA parses a SOA record value string +// Format: "mname rname serial refresh retry expire minimum" +func (b *Backend) parseSOA(value string) (*dns.SOA, error) { + parts := strings.Fields(value) + if len(parts) < 7 { + return nil, fmt.Errorf("invalid SOA format, expected 7 fields: %s", value) + } + + serial, err := parseUint32(parts[2]) + if err != nil { + return nil, fmt.Errorf("invalid SOA serial: %w", err) + } + refresh, err := parseUint32(parts[3]) + if err != nil { + return nil, fmt.Errorf("invalid SOA refresh: %w", err) + } + retry, err := parseUint32(parts[4]) + if err != nil { + return nil, fmt.Errorf("invalid SOA retry: %w", err) + } + expire, err := parseUint32(parts[5]) + if err != nil { + return nil, fmt.Errorf("invalid SOA expire: %w", err) + } + minttl, err := parseUint32(parts[6]) + if err != nil { + return nil, fmt.Errorf("invalid SOA minimum: %w", err) + } + + return &dns.SOA{ + Ns: dns.Fqdn(parts[0]), + Mbox: dns.Fqdn(parts[1]), + Serial: serial, + Refresh: refresh, + Retry: retry, + Expire: expire, + Minttl: minttl, + }, nil +} + +// parseUint32 parses a string to uint32 +func parseUint32(s string) (uint32, error) { + var val uint32 + _, err := fmt.Sscanf(s, "%d", &val) + return val, err +} + // ping tests the RQLite connection func (b *Backend) ping() error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -205,6 +260,10 @@ func qTypeToString(qtype uint16) string { return "CNAME" case dns.TypeTXT: return "TXT" + case dns.TypeNS: + return "NS" + case dns.TypeSOA: + return "SOA" default: return dns.TypeToString[qtype] } @@ -221,6 +280,10 @@ func stringToQType(s string) uint16 { return dns.TypeCNAME case "TXT": return dns.TypeTXT + case "NS": + return dns.TypeNS + case "SOA": + return dns.TypeSOA default: return 0 } diff --git a/pkg/coredns/rqlite/plugin.go b/pkg/coredns/rqlite/plugin.go index e302aa7..f4f8a11 100644 --- a/pkg/coredns/rqlite/plugin.go +++ b/pkg/coredns/rqlite/plugin.go @@ -150,6 +150,15 @@ func (p *RQLitePlugin) buildRR(qname string, record *DNSRecord) dns.RR { Hdr: header, Txt: record.ParsedValue.([]string), } + case dns.TypeNS: + return &dns.NS{ + Hdr: header, + Ns: record.ParsedValue.(string), + } + case dns.TypeSOA: + soa := record.ParsedValue.(*dns.SOA) + soa.Hdr = header + return soa default: p.logger.Warn("Unsupported record type", zap.Uint16("type", record.Type), diff --git a/pkg/environments/production/installers/coredns.go b/pkg/environments/production/installers/coredns.go index 5ed50d9..6cedb5e 100644 --- a/pkg/environments/production/installers/coredns.go +++ b/pkg/environments/production/installers/coredns.go @@ -1,11 +1,15 @@ package installers import ( + "bytes" + "encoding/json" "fmt" "io" + "net/http" "os" "os/exec" "path/filepath" + "time" ) const ( @@ -191,23 +195,26 @@ func (ci *CoreDNSInstaller) Install() error { return nil } -// Configure creates CoreDNS configuration files +// Configure creates CoreDNS configuration files and seeds static DNS records into RQLite func (ci *CoreDNSInstaller) Configure(domain string, rqliteDSN string, ns1IP, ns2IP, ns3IP string) error { configDir := "/etc/coredns" if err := os.MkdirAll(configDir, 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } - // Create Corefile - corefile := ci.generateCorefile(domain, rqliteDSN, configDir) + // Create Corefile (uses only RQLite plugin) + corefile := ci.generateCorefile(domain, rqliteDSN) if err := os.WriteFile(filepath.Join(configDir, "Corefile"), []byte(corefile), 0644); err != nil { return fmt.Errorf("failed to write Corefile: %w", err) } - // Create zone file - zonefile := ci.generateZoneFile(domain, ns1IP, ns2IP, ns3IP) - if err := os.WriteFile(filepath.Join(configDir, "db."+domain), []byte(zonefile), 0644); err != nil { - return fmt.Errorf("failed to write zone file: %w", err) + // Seed static DNS records into RQLite + fmt.Fprintf(ci.logWriter, " Seeding static DNS records into RQLite...\n") + if err := ci.seedStaticRecords(domain, rqliteDSN, ns1IP, ns2IP, ns3IP); err != nil { + // Don't fail on seed errors - RQLite might not be up yet + fmt.Fprintf(ci.logWriter, " ⚠️ Could not seed DNS records (RQLite may not be ready): %v\n", err) + } else { + fmt.Fprintf(ci.logWriter, " ✓ Static DNS records seeded\n") } return nil @@ -263,14 +270,14 @@ rqlite:rqlite ` } -// generateCorefile creates the CoreDNS configuration -func (ci *CoreDNSInstaller) generateCorefile(domain, rqliteDSN, configDir string) string { +// generateCorefile creates the CoreDNS configuration (RQLite only) +func (ci *CoreDNSInstaller) generateCorefile(domain, rqliteDSN string) string { return fmt.Sprintf(`# CoreDNS configuration for %s -# Uses RQLite for dynamic DNS records (deployments, ACME challenges) -# Falls back to static zone file for base records (SOA, NS) +# Uses RQLite for ALL DNS records (static + dynamic) +# Static records (SOA, NS, A) are seeded into RQLite during installation %s { - # First try RQLite for dynamic records (TXT for ACME, A for deployments) + # RQLite handles all records: SOA, NS, A, TXT (ACME), etc. rqlite { dsn %s refresh 5s @@ -278,9 +285,6 @@ func (ci *CoreDNSInstaller) generateCorefile(domain, rqliteDSN, configDir string cache_size 10000 } - # Fall back to static zone file for SOA/NS records - file %s/db.%s - # Enable logging and error reporting log errors @@ -293,44 +297,95 @@ func (ci *CoreDNSInstaller) generateCorefile(domain, rqliteDSN, configDir string cache 300 errors } -`, domain, domain, rqliteDSN, configDir, domain) +`, domain, domain, rqliteDSN) } -// generateZoneFile creates the static DNS zone file -func (ci *CoreDNSInstaller) generateZoneFile(domain, ns1IP, ns2IP, ns3IP string) string { - return fmt.Sprintf(`$ORIGIN %s. -$TTL 300 +// seedStaticRecords inserts static zone records into RQLite +func (ci *CoreDNSInstaller) seedStaticRecords(domain, rqliteDSN, ns1IP, ns2IP, ns3IP string) error { + // Generate serial based on current date + serial := fmt.Sprintf("%d", time.Now().Unix()) -@ IN SOA ns1.%s. admin.%s. ( - 2024012401 ; Serial - 3600 ; Refresh - 1800 ; Retry - 604800 ; Expire - 300 ) ; Negative TTL + // SOA record format: "mname rname serial refresh retry expire minimum" + soaValue := fmt.Sprintf("ns1.%s. admin.%s. %s 3600 1800 604800 300", domain, domain, serial) -; Nameservers -@ IN NS ns1.%s. -@ IN NS ns2.%s. -@ IN NS ns3.%s. + // Define all static records + records := []struct { + fqdn string + recordType string + value string + ttl int + }{ + // SOA record + {domain + ".", "SOA", soaValue, 300}, -; Nameserver A records -ns1 IN A %s -ns2 IN A %s -ns3 IN A %s + // NS records + {domain + ".", "NS", "ns1." + domain + ".", 300}, + {domain + ".", "NS", "ns2." + domain + ".", 300}, + {domain + ".", "NS", "ns3." + domain + ".", 300}, -; Root domain points to all nodes (round-robin) -@ IN A %s -@ IN A %s -@ IN A %s + // Nameserver A records (glue) + {"ns1." + domain + ".", "A", ns1IP, 300}, + {"ns2." + domain + ".", "A", ns2IP, 300}, + {"ns3." + domain + ".", "A", ns3IP, 300}, -; Wildcard fallback (RQLite records take precedence for specific subdomains) -* IN A %s -* IN A %s -* IN A %s -`, domain, domain, domain, domain, domain, domain, - ns1IP, ns2IP, ns3IP, - ns1IP, ns2IP, ns3IP, - ns1IP, ns2IP, ns3IP) + // Root domain A records (round-robin) + {domain + ".", "A", ns1IP, 300}, + {domain + ".", "A", ns2IP, 300}, + {domain + ".", "A", ns3IP, 300}, + + // Wildcard A records (round-robin) + {"*." + domain + ".", "A", ns1IP, 300}, + {"*." + domain + ".", "A", ns2IP, 300}, + {"*." + domain + ".", "A", ns3IP, 300}, + } + + // Build SQL statements + var statements []string + for _, r := range records { + // Use INSERT OR REPLACE to handle updates + stmt := fmt.Sprintf( + `INSERT OR REPLACE INTO dns_records (fqdn, record_type, value, ttl, namespace, created_by) VALUES ('%s', '%s', '%s', %d, 'system', 'system')`, + r.fqdn, r.recordType, r.value, r.ttl, + ) + statements = append(statements, stmt) + } + + // Execute via RQLite HTTP API + return ci.executeRQLiteStatements(rqliteDSN, statements) +} + +// executeRQLiteStatements executes SQL statements via RQLite HTTP API +func (ci *CoreDNSInstaller) executeRQLiteStatements(rqliteDSN string, statements []string) error { + // RQLite execute endpoint + executeURL := rqliteDSN + "/db/execute?pretty&timings" + + // Build request body + body, err := json.Marshal(statements) + if err != nil { + return fmt.Errorf("failed to marshal statements: %w", err) + } + + // Create request + req, err := http.NewRequest("POST", executeURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // Execute with timeout + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("RQLite returned status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil } // containsLine checks if a string contains a specific line diff --git a/pkg/gateway/handlers/deployments/domain_handler.go b/pkg/gateway/handlers/deployments/domain_handler.go index f542261..3c8c080 100644 --- a/pkg/gateway/handlers/deployments/domain_handler.go +++ b/pkg/gateway/handlers/deployments/domain_handler.go @@ -457,15 +457,18 @@ func (h *DomainHandler) createDNSRecord(ctx context.Context, domain, deploymentI // Create DNS A record dnsQuery := ` - INSERT INTO dns_records (fqdn, record_type, value, ttl, namespace, deployment_id, node_id, created_by, created_at) - VALUES (?, 'A', ?, 300, ?, ?, ?, 'system', ?) - ON CONFLICT(fqdn) DO UPDATE SET value = ?, updated_at = ? + INSERT INTO dns_records (fqdn, record_type, value, ttl, namespace, deployment_id, node_id, created_by, created_at, updated_at) + VALUES (?, 'A', ?, 300, ?, ?, ?, 'system', ?, ?) + ON CONFLICT(fqdn, record_type, value) DO UPDATE SET + deployment_id = excluded.deployment_id, + node_id = excluded.node_id, + updated_at = excluded.updated_at ` fqdn := domain + "." now := time.Now() - _, err = h.service.db.Exec(ctx, dnsQuery, fqdn, nodeIP, "", deploymentID, homeNodeID, now, nodeIP, now) + _, err = h.service.db.Exec(ctx, dnsQuery, fqdn, nodeIP, "", deploymentID, homeNodeID, now, now) if err != nil { h.logger.Error("Failed to create DNS record", zap.Error(err)) return diff --git a/pkg/gateway/handlers/deployments/service.go b/pkg/gateway/handlers/deployments/service.go index 27aa7c1..ac7cbb4 100644 --- a/pkg/gateway/handlers/deployments/service.go +++ b/pkg/gateway/handlers/deployments/service.go @@ -324,7 +324,10 @@ func (s *DeploymentService) createDNSRecord(ctx context.Context, fqdn, recordTyp query := ` INSERT INTO dns_records (fqdn, record_type, value, ttl, namespace, deployment_id, is_active, created_at, updated_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(fqdn) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + ON CONFLICT(fqdn, record_type, value) DO UPDATE SET + deployment_id = excluded.deployment_id, + updated_at = excluded.updated_at, + is_active = TRUE ` now := time.Now() diff --git a/scripts/generate-source-archive.sh b/scripts/generate-source-archive.sh new file mode 100755 index 0000000..d716a28 --- /dev/null +++ b/scripts/generate-source-archive.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Generates a tarball of the current codebase for deployment +# Output: /tmp/network-source.tar.gz +# +# Usage: ./scripts/generate-source-archive.sh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +OUTPUT="/tmp/network-source.tar.gz" + +echo "Generating source archive..." + +cd "$PROJECT_ROOT" + +# Remove root-level binaries before archiving (they'll be rebuilt on VPS) +rm -f gateway cli node orama-cli-linux 2>/dev/null + +tar czf "$OUTPUT" \ + --exclude='.git' \ + --exclude='node_modules' \ + --exclude='*.log' \ + --exclude='.DS_Store' \ + --exclude='bin/' \ + --exclude='dist/' \ + --exclude='coverage/' \ + --exclude='.claude/' \ + --exclude='testdata/' \ + --exclude='examples/' \ + --exclude='*.tar.gz' \ + . + +echo "Archive created: $OUTPUT" +echo "Size: $(du -h $OUTPUT | cut -f1)"