762 lines
21 KiB
Go
Raw Normal View History

2026-03-12 20:51:38 -07:00
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."}}
}