package tui import ( "encoding/json" "fmt" "net/http" "os/exec" "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // ── Rendering ─────────────────────────────────────────────────────── func (a App) renderDNSMenu() string { var b strings.Builder b.WriteString(styleTitle.Render("DNS SERVER")) b.WriteString("\n") b.WriteString(a.renderHR()) b.WriteString("\n") // Check DNS server status _, dnsRunning := getServiceStatus("autarch-dns") if dnsRunning { b.WriteString(" " + styleStatusOK.Render("● DNS Server is running")) } else { b.WriteString(" " + styleStatusBad.Render("○ DNS Server is stopped")) } b.WriteString("\n") // Check if binary exists dir := findAutarchDir() binaryPath := dir + "/services/dns-server/autarch-dns" if fileExists(binaryPath) { b.WriteString(" " + styleSuccess.Render("✔ Binary found: ") + styleDim.Render(binaryPath)) } else { b.WriteString(" " + styleWarning.Render("⚠ Binary not found — build required")) } b.WriteString("\n") // Check if source exists sourcePath := dir + "/services/dns-server/main.go" if fileExists(sourcePath) { b.WriteString(" " + styleSuccess.Render("✔ Source code present")) } else { b.WriteString(" " + styleError.Render("✘ Source not found at " + dir + "/services/dns-server/")) } b.WriteString("\n\n") b.WriteString(a.renderHR()) b.WriteString("\n") b.WriteString(styleKey.Render(" [b]") + " Build DNS server from source\n") b.WriteString(styleKey.Render(" [s]") + " Start / Stop DNS server\n") b.WriteString(styleKey.Render(" [m]") + " Manage DNS (zones, records, hosts, blocklist)\n") b.WriteString(styleKey.Render(" [c]") + " Edit DNS config\n") b.WriteString(styleKey.Render(" [t]") + " Test DNS resolution\n") b.WriteString(styleKey.Render(" [l]") + " View DNS logs\n") b.WriteString(styleKey.Render(" [i]") + " Install systemd service unit\n") b.WriteString("\n") b.WriteString(styleDim.Render(" esc back")) b.WriteString("\n") return b.String() } func (a App) renderDNSManageMenu() string { var b strings.Builder b.WriteString(styleTitle.Render("DNS MANAGEMENT")) b.WriteString("\n") b.WriteString(a.renderHR()) b.WriteString("\n") // Try to get status from API status := getDNSAPIStatus() if status != nil { b.WriteString(" " + styleKey.Render("Queries: ") + lipgloss.NewStyle().Foreground(colorWhite).Render(fmt.Sprintf("%v", status["total_queries"]))) b.WriteString("\n") b.WriteString(" " + styleKey.Render("Cache: ") + lipgloss.NewStyle().Foreground(colorWhite).Render(fmt.Sprintf("hits=%v misses=%v", status["cache_hits"], status["cache_misses"]))) b.WriteString("\n") b.WriteString(" " + styleKey.Render("Blocked: ") + lipgloss.NewStyle().Foreground(colorWhite).Render(fmt.Sprintf("%v", status["blocked_queries"]))) b.WriteString("\n\n") } else { b.WriteString(styleWarning.Render(" ⚠ Cannot reach DNS API — is the server running?")) b.WriteString("\n\n") } b.WriteString(a.renderHR()) b.WriteString("\n") b.WriteString(styleKey.Render(" [z]") + " Manage zones\n") b.WriteString(styleKey.Render(" [h]") + " Manage hosts file\n") b.WriteString(styleKey.Render(" [b]") + " Manage blocklist\n") b.WriteString(styleKey.Render(" [f]") + " Manage forwarding rules\n") b.WriteString(styleKey.Render(" [c]") + " Flush cache\n") b.WriteString(styleKey.Render(" [q]") + " Query log\n") b.WriteString(styleKey.Render(" [t]") + " Top domains\n") b.WriteString(styleKey.Render(" [e]") + " Encryption settings (DoT/DoH)\n") b.WriteString(styleKey.Render(" [r]") + " Root server check\n") b.WriteString("\n") b.WriteString(styleDim.Render(" esc back")) b.WriteString("\n") return b.String() } func (a App) renderDNSZones() string { var b strings.Builder b.WriteString(styleTitle.Render("DNS ZONES")) b.WriteString("\n") b.WriteString(a.renderHR()) b.WriteString("\n") if len(a.listItems) == 0 { b.WriteString(styleDim.Render(" No zones configured (or API unreachable).")) b.WriteString("\n\n") } else { for i, item := range a.listItems { cursor := " " if i == a.cursor { cursor = styleSelected.Render(" ▸") + " " } b.WriteString(fmt.Sprintf("%s%s %s\n", cursor, lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(item.Name), styleDim.Render(item.Status), )) } b.WriteString("\n") } b.WriteString(a.renderHR()) b.WriteString(styleKey.Render(" [n]") + " New zone ") b.WriteString(styleKey.Render("[enter]") + " View records ") b.WriteString(styleKey.Render("[d]") + " Delete zone\n") b.WriteString(styleDim.Render(" esc back")) b.WriteString("\n") return b.String() } // ── Key Handling ──────────────────────────────────────────────────── func (a App) handleDNSMenu(key string) (tea.Model, tea.Cmd) { switch key { case "b": return a.buildDNSServer() case "s": return a.toggleDNSService() case "m": a.pushView(ViewDNSManage) return a, nil case "c": return a.editDNSConfig() case "t": return a.testDNSResolution() case "l": return a.viewDNSLogs() case "i": return a.installDNSUnit() } return a, nil } func (a App) handleDNSManageMenu(key string) (tea.Model, tea.Cmd) { switch key { case "z": return a.loadDNSZones() case "h": return a.manageDNSHosts() case "b": return a.manageDNSBlocklist() case "f": return a.manageDNSForwarding() case "c": return a.flushDNSCache() case "q": return a.viewDNSQueryLog() case "t": return a.viewDNSTopDomains() case "e": return a.viewDNSEncryption() case "r": return a.dnsRootCheck() } return a, nil } func (a App) handleDNSZonesMenu(key string) (tea.Model, tea.Cmd) { switch key { case "n": return a.openDNSZoneForm() case "enter": if a.cursor >= 0 && a.cursor < len(a.listItems) { return a.viewDNSZoneRecords(a.listItems[a.cursor].Name) } case "d": if a.cursor >= 0 && a.cursor < len(a.listItems) { zone := a.listItems[a.cursor].Name a.confirmPrompt = fmt.Sprintf("Delete zone '%s' and all its records?", zone) a.confirmAction = func() tea.Cmd { return func() tea.Msg { return dnsAPIDelete("/api/zones/" + zone) } } a.pushView(ViewConfirm) } } return a, nil } // ── DNS Commands ──────────────────────────────────────────────────── func (a App) buildDNSServer() (App, tea.Cmd) { a.pushView(ViewDNSBuild) a.outputLines = nil a.outputDone = false a.progressStep = 0 a.progressTotal = 0 ch := make(chan tea.Msg, 256) a.outputCh = ch go func() { dir := findAutarchDir() dnsDir := dir + "/services/dns-server" steps := []CmdStep{ {Label: "Download Go dependencies", Args: []string{"go", "mod", "download"}, Dir: dnsDir}, {Label: "Build DNS server binary", Args: []string{"go", "build", "-o", "autarch-dns", "."}, Dir: dnsDir}, } streamSteps(ch, steps) }() return a, a.waitForOutput() } func (a App) toggleDNSService() (App, tea.Cmd) { return a.toggleService(1) // Index 1 = autarch-dns } func (a App) editDNSConfig() (App, tea.Cmd) { // Load DNS config as a settings section dir := findAutarchDir() configPath := dir + "/data/dns/config.json" data, err := readFileBytes(configPath) if err != nil { return a, func() tea.Msg { return ResultMsg{ Title: "DNS Config", Lines: []string{ "No DNS config file found at: " + configPath, "", "Start the DNS server once to generate a default config,", "or build and run: ./autarch-dns --config " + configPath, }, IsError: true, } } } var cfg map[string]interface{} if err := json.Unmarshal(data, &cfg); err != nil { return a, func() tea.Msg { return ResultMsg{Title: "Error", Lines: []string{"Invalid JSON: " + err.Error()}, IsError: true} } } // Flatten to key=value for editing var keys []string var vals []string for k, v := range cfg { keys = append(keys, k) vals = append(vals, fmt.Sprintf("%v", v)) } a.settingsSection = "dns-config" a.settingsKeys = keys a.settingsVals = vals a.pushView(ViewSettingsSection) return a, nil } func (a App) testDNSResolution() (App, tea.Cmd) { return a, func() tea.Msg { domains := []string{"google.com", "github.com", "cloudflare.com"} var lines []string for _, domain := range domains { out, err := exec.Command("dig", "@127.0.0.1", domain, "+short", "+time=2").Output() if err != nil { lines = append(lines, styleError.Render(fmt.Sprintf(" ✘ %s: %s", domain, err.Error()))) } else { result := strings.TrimSpace(string(out)) if result == "" { lines = append(lines, styleWarning.Render(fmt.Sprintf(" ⚠ %s: no answer", domain))) } else { lines = append(lines, styleSuccess.Render(fmt.Sprintf(" ✔ %s → %s", domain, result))) } } } return ResultMsg{Title: "DNS Resolution Test (@127.0.0.1)", Lines: lines} } } func (a App) viewDNSLogs() (App, tea.Cmd) { return a, func() tea.Msg { out, _ := exec.Command("journalctl", "-u", "autarch-dns", "-n", "30", "--no-pager").Output() lines := strings.Split(strings.TrimSpace(string(out)), "\n") return ResultMsg{Title: "DNS Server Logs (last 30)", Lines: lines} } } func (a App) installDNSUnit() (App, tea.Cmd) { // Delegate to the service installer for just the DNS unit return a, func() tea.Msg { dir := findAutarchDir() unit := fmt.Sprintf(`[Unit] Description=AUTARCH DNS Server After=network.target [Service] Type=simple User=root WorkingDirectory=%s ExecStart=%s/services/dns-server/autarch-dns --config %s/data/dns/config.json Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target `, dir, dir, dir) path := "/etc/systemd/system/autarch-dns.service" if err := writeFileAtomic(path, []byte(unit)); err != nil { return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true} } exec.Command("systemctl", "daemon-reload").Run() return ResultMsg{ Title: "DNS Service Unit Installed", Lines: []string{ "Installed: " + path, "", "Start with: systemctl start autarch-dns", "Enable on boot: systemctl enable autarch-dns", }, } } } // ── DNS API Management Commands ───────────────────────────────────── func (a App) loadDNSZones() (App, tea.Cmd) { a.pushView(ViewDNSZones) return a, func() tea.Msg { zones := dnsAPIGet("/api/zones") if zones == nil { return dnsZonesMsg{items: nil} } zoneList, ok := zones.([]interface{}) if !ok { return dnsZonesMsg{items: nil} } var items []ListItem for _, z := range zoneList { zMap, ok := z.(map[string]interface{}) if !ok { continue } name := fmt.Sprintf("%v", zMap["domain"]) recordCount := 0 if records, ok := zMap["records"].([]interface{}); ok { recordCount = len(records) } items = append(items, ListItem{ Name: name, Status: fmt.Sprintf("%d records", recordCount), }) } return dnsZonesMsg{items: items} } } type dnsZonesMsg struct{ items []ListItem } func (a App) openDNSZoneForm() (App, tea.Cmd) { a.labels = []string{"Domain", "Primary NS", "Admin Email", "Default TTL"} a.inputs = make([]textinput.Model, 4) defaults := []string{"", "ns1.example.com", "admin.example.com", "3600"} for i := range a.inputs { ti := textinput.New() ti.CharLimit = 256 ti.Width = 40 ti.SetValue(defaults[i]) if i == 0 { ti.Focus() ti.SetValue("") } a.inputs[i] = ti } a.focusIdx = 0 a.pushView(ViewDNSZoneEdit) return a, nil } func (a App) submitDNSZone() (App, tea.Cmd) { domain := a.inputs[0].Value() ns := a.inputs[1].Value() admin := a.inputs[2].Value() ttl := a.inputs[3].Value() if domain == "" { return a, func() tea.Msg { return ResultMsg{Title: "Error", Lines: []string{"Domain cannot be empty."}, IsError: true} } } a.popView() return a, func() tea.Msg { body := fmt.Sprintf(`{"domain":"%s","soa":{"primary_ns":"%s","admin_email":"%s","ttl":%s}}`, domain, ns, admin, ttl) return dnsAPIPost("/api/zones", body) } } func (a App) viewDNSZoneRecords(zone string) (App, tea.Cmd) { return a, func() tea.Msg { data := dnsAPIGet("/api/zones/" + zone + "/records") if data == nil { return ResultMsg{Title: "Zone: " + zone, Lines: []string{"No records or API unreachable."}, IsError: true} } records, ok := data.([]interface{}) if !ok { return ResultMsg{Title: "Zone: " + zone, Lines: []string{"Unexpected response format."}, IsError: true} } var lines []string lines = append(lines, fmt.Sprintf("Zone: %s — %d records", zone, len(records))) lines = append(lines, "") lines = append(lines, styleDim.Render(fmt.Sprintf(" %-8s %-30s %-6s %s", "TYPE", "NAME", "TTL", "VALUE"))) lines = append(lines, styleDim.Render(" "+strings.Repeat("─", 70))) for _, r := range records { rec, ok := r.(map[string]interface{}) if !ok { continue } lines = append(lines, fmt.Sprintf(" %-8v %-30v %-6v %v", rec["type"], rec["name"], rec["ttl"], rec["value"])) } return ResultMsg{Title: "Zone: " + zone, Lines: lines} } } func (a App) manageDNSHosts() (App, tea.Cmd) { return a, func() tea.Msg { data := dnsAPIGet("/api/hosts") if data == nil { return ResultMsg{Title: "DNS Hosts", Lines: []string{"API unreachable."}, IsError: true} } hosts, ok := data.([]interface{}) if !ok { return ResultMsg{Title: "DNS Hosts", Lines: []string{"No hosts entries."}} } var lines []string lines = append(lines, fmt.Sprintf("%d host entries", len(hosts))) lines = append(lines, "") for _, h := range hosts { hMap, _ := h.(map[string]interface{}) lines = append(lines, fmt.Sprintf(" %-16v %v", hMap["ip"], hMap["hostname"])) } return ResultMsg{Title: "DNS Hosts", Lines: lines} } } func (a App) manageDNSBlocklist() (App, tea.Cmd) { return a, func() tea.Msg { data := dnsAPIGet("/api/blocklist") if data == nil { return ResultMsg{Title: "DNS Blocklist", Lines: []string{"API unreachable."}, IsError: true} } bl, ok := data.(map[string]interface{}) if !ok { return ResultMsg{Title: "DNS Blocklist", Lines: []string{"Unexpected format."}} } var lines []string if domains, ok := bl["domains"].([]interface{}); ok { lines = append(lines, fmt.Sprintf("%d blocked domains", len(domains))) lines = append(lines, "") max := 30 if len(domains) < max { max = len(domains) } for _, d := range domains[:max] { lines = append(lines, " "+fmt.Sprintf("%v", d)) } if len(domains) > 30 { lines = append(lines, styleDim.Render(fmt.Sprintf(" ... and %d more", len(domains)-30))) } } else { lines = append(lines, "Blocklist is empty.") } return ResultMsg{Title: "DNS Blocklist", Lines: lines} } } func (a App) manageDNSForwarding() (App, tea.Cmd) { return a, func() tea.Msg { data := dnsAPIGet("/api/forwarding") if data == nil { return ResultMsg{Title: "DNS Forwarding", Lines: []string{"API unreachable."}, IsError: true} } rules, ok := data.([]interface{}) if !ok { return ResultMsg{Title: "DNS Forwarding", Lines: []string{"No forwarding rules configured."}} } var lines []string lines = append(lines, fmt.Sprintf("%d forwarding rules", len(rules))) lines = append(lines, "") for _, r := range rules { rMap, _ := r.(map[string]interface{}) lines = append(lines, fmt.Sprintf(" %v → %v", rMap["zone"], rMap["upstream"])) } return ResultMsg{Title: "DNS Forwarding Rules", Lines: lines} } } func (a App) flushDNSCache() (App, tea.Cmd) { return a, func() tea.Msg { return dnsAPIDelete("/api/cache") } } func (a App) viewDNSQueryLog() (App, tea.Cmd) { return a, func() tea.Msg { data := dnsAPIGet("/api/querylog?limit=30") if data == nil { return ResultMsg{Title: "DNS Query Log", Lines: []string{"API unreachable."}, IsError: true} } entries, ok := data.([]interface{}) if !ok { return ResultMsg{Title: "DNS Query Log", Lines: []string{"No entries."}} } var lines []string for _, e := range entries { eMap, _ := e.(map[string]interface{}) lines = append(lines, fmt.Sprintf(" %-20v %-6v %-30v %v", eMap["time"], eMap["type"], eMap["name"], eMap["client"])) } return ResultMsg{Title: "DNS Query Log (last 30)", Lines: lines} } } func (a App) viewDNSTopDomains() (App, tea.Cmd) { return a, func() tea.Msg { data := dnsAPIGet("/api/stats/top-domains?limit=20") if data == nil { return ResultMsg{Title: "Top Domains", Lines: []string{"API unreachable."}, IsError: true} } domains, ok := data.([]interface{}) if !ok { return ResultMsg{Title: "Top Domains", Lines: []string{"No data."}} } var lines []string for i, d := range domains { dMap, _ := d.(map[string]interface{}) lines = append(lines, fmt.Sprintf(" %2d. %-40v %v queries", i+1, dMap["domain"], dMap["count"])) } return ResultMsg{Title: "Top 20 Queried Domains", Lines: lines} } } func (a App) viewDNSEncryption() (App, tea.Cmd) { return a, func() tea.Msg { data := dnsAPIGet("/api/encryption") if data == nil { return ResultMsg{Title: "DNS Encryption", Lines: []string{"API unreachable."}, IsError: true} } enc, _ := data.(map[string]interface{}) var lines []string for k, v := range enc { status := styleStatusBad.Render("disabled") if v == true { status = styleStatusOK.Render("enabled") } lines = append(lines, fmt.Sprintf(" %-20s %s", k, status)) } return ResultMsg{Title: "DNS Encryption Status", Lines: lines} } } func (a App) dnsRootCheck() (App, tea.Cmd) { return a, func() tea.Msg { body := dnsAPIPostRaw("/api/rootcheck", "") if body == nil { return ResultMsg{Title: "Root Check", Lines: []string{"API unreachable."}, IsError: true} } results, ok := body.([]interface{}) if !ok { return ResultMsg{Title: "Root Check", Lines: []string{"Unexpected format."}} } var lines []string for _, r := range results { rMap, _ := r.(map[string]interface{}) latency := fmt.Sprintf("%v", rMap["latency"]) status := styleSuccess.Render("✔") if rMap["error"] != nil && rMap["error"] != "" { status = styleError.Render("✘") latency = fmt.Sprintf("%v", rMap["error"]) } lines = append(lines, fmt.Sprintf(" %s %-20v %s", status, rMap["server"], latency)) } return ResultMsg{Title: "Root Server Latency Check", Lines: lines} } } // ── DNS API Helpers ───────────────────────────────────────────────── func getDNSAPIBase() string { return "http://127.0.0.1:5380" } func getDNSAPIToken() string { dir := findAutarchDir() configPath := dir + "/data/dns/config.json" data, err := readFileBytes(configPath) if err != nil { return "" } var cfg map[string]interface{} if err := json.Unmarshal(data, &cfg); err != nil { return "" } if token, ok := cfg["api_token"].(string); ok { return token } return "" } func getDNSAPIStatus() map[string]interface{} { data := dnsAPIGet("/api/metrics") if data == nil { return nil } m, ok := data.(map[string]interface{}) if !ok { return nil } return m } func dnsAPIGet(path string) interface{} { url := getDNSAPIBase() + path token := getDNSAPIToken() req, err := http.NewRequest("GET", url, nil) if err != nil { return nil } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil } defer resp.Body.Close() var result interface{} json.NewDecoder(resp.Body).Decode(&result) return result } func dnsAPIPost(path, body string) tea.Msg { result := dnsAPIPostRaw(path, body) if result == nil { return ResultMsg{Title: "Error", Lines: []string{"API request failed."}, IsError: true} } return ResultMsg{Title: "Success", Lines: []string{"Operation completed."}} } func dnsAPIPostRaw(path, body string) interface{} { url := getDNSAPIBase() + path token := getDNSAPIToken() req, err := http.NewRequest("POST", url, strings.NewReader(body)) if err != nil { return nil } req.Header.Set("Content-Type", "application/json") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil } defer resp.Body.Close() var result interface{} json.NewDecoder(resp.Body).Decode(&result) return result } func dnsAPIDelete(path string) tea.Msg { url := getDNSAPIBase() + path token := getDNSAPIToken() req, err := http.NewRequest("DELETE", url, nil) if err != nil { return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true} } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } client := &http.Client{} resp, err := client.Do(req) if err != nil { return ResultMsg{Title: "Error", Lines: []string{err.Error()}, IsError: true} } defer resp.Body.Close() return ResultMsg{Title: "Success", Lines: []string{"Deleted."}} }