From ab2bf0731ad7f54900c7370ec907743e1949cab5 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Mon, 16 Feb 2026 11:26:25 +0100 Subject: [PATCH] Add DNS-based discovery and migration via /etc/resolv.conf --- README.md | 4 +- cmd/soundtouch-service/main.go | 67 ++++ data/.gitignore | 1 + docs/guides/MIGRATION-SAFETY.md | 5 +- docs/guides/SOUNDTOUCH-SERVICE.md | 84 ++++- pkg/discovery/dns.go | 342 ++++++++++++++++++ pkg/discovery/dns_test.go | 194 ++++++++++ pkg/service/datastore/datastore.go | 79 ++++ pkg/service/datastore/dns_persistence_test.go | 75 ++++ pkg/service/handlers/handlers_setup.go | 140 +++++++ pkg/service/handlers/server.go | 83 +++++ pkg/service/handlers/web/index.html | 73 +++- pkg/service/handlers/web/js/script.js | 166 ++++++++- pkg/service/setup/setup.go | 270 +++++++++++++- pkg/service/setup/setup_test.go | 267 ++++++++++++++ 15 files changed, 1830 insertions(+), 20 deletions(-) create mode 100644 pkg/discovery/dns.go create mode 100644 pkg/discovery/dns_test.go create mode 100644 pkg/service/datastore/dns_persistence_test.go diff --git a/README.md b/README.md index 3c48237..e20d97d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices, - ๐ŸŽ™๏ธ **Station Management**: Add and play radio stations without presets - ๐Ÿ–ฅ๏ธ **CLI Tool**: Comprehensive command-line interface - ๐ŸŒ **SoundTouch Service**: Emulate Bose cloud services for offline device operation -- ๐Ÿ”ง **Service Migration**: Migrate devices to use local services instead of Bose cloud +- ๐Ÿ”ง **Service Migration**: Migrate devices to use local services instead of Bose cloud (XML, Hosts, or DNS redirection) +- ๐Ÿ” **DNS Discovery & Interception**: Dynamic DNS server for intercepting and logging Bose service queries (requires port 53) +- ๐Ÿ“Š **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames - ๐Ÿ“Š **Traffic Analysis**: Proxy and log device communications - ๐Ÿ“ **HTTP Recording**: Persist interactions as re-playable `.http` files - ๐Ÿงน **Session Management**: Manage and cleanup recorded interaction sessions diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 5653fe8..db86c0c 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/gesellix/bose-soundtouch/pkg/discovery" "github.com/gesellix/bose-soundtouch/pkg/service/certmanager" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/service/handlers" @@ -137,6 +138,23 @@ func main() { Value: "5m", EnvVars: []string{"DISCOVERY_INTERVAL"}, }, + &cli.BoolFlag{ + Name: "dns-discovery", + Usage: "Enable DNS discovery server", + EnvVars: []string{"ENABLE_DNS_DISCOVERY"}, + }, + &cli.StringFlag{ + Name: "dns-upstream", + Usage: "Upstream DNS server for non-Bose queries", + Value: "8.8.8.8", + EnvVars: []string{"DNS_UPSTREAM"}, + }, + &cli.StringFlag{ + Name: "dns-bind", + Usage: "Bind address for the DNS discovery server", + Value: ":53", + EnvVars: []string{"DNS_BIND_ADDR"}, + }, }, Action: func(c *cli.Context) error { config := loadConfig(c) @@ -160,10 +178,32 @@ func main() { cm := initCertificateManager(config.dataDir) sm := setup.NewManager(config.serverURL, ds, cm) server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy) + sm.GetDNSRunning = server.GetDNSRunning server.SetSoundcorkURL(config.soundcorkURL) server.SetHTTPServerURL(config.httpsServerURL) server.SetVersionInfo(version, commit, date) server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled) + server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr) + + // Load and set initial DNS discoveries + dnsDiscoveries, err := ds.LoadDNSDiscoveries() + if err == nil && len(dnsDiscoveries) > 0 { + initial := make(map[string]*discovery.DiscoveredHost) + for _, entry := range dnsDiscoveries { + initial[entry.Hostname] = &discovery.DiscoveredHost{ + Hostname: entry.Hostname, + FirstSeen: entry.FirstSeen, + LastSeen: entry.LastSeen, + QueryCount: entry.QueryCount, + IsBoseService: entry.IsBoseService, + IsIntercepted: entry.IsIntercepted, + RemoteAddr: entry.RemoteAddr, + } + } + + server.SetDNSDiscoveries(initial) + } + server.SetShortcuts(persisted.Shortcuts) for path, status := range persisted.Shortcuts { @@ -253,6 +293,9 @@ type serviceConfig struct { logBody bool record bool enableSoundcorkProxy bool + dnsEnabled bool + dnsUpstream string + dnsBind string discoveryInterval time.Duration domains []string } @@ -300,6 +343,10 @@ func loadConfig(c *cli.Context) serviceConfig { record := c.Bool("record-interactions") enableSoundcorkProxy := c.Bool("enable-soundcork-proxy") + dnsEnabled := c.Bool("dns-discovery") + dnsUpstream := c.String("dns-upstream") + dnsBind := c.String("dns-bind") + discoveryIntervalStr := c.String("discovery-interval") discoveryInterval, err := time.ParseDuration(discoveryIntervalStr) @@ -322,6 +369,9 @@ func loadConfig(c *cli.Context) serviceConfig { logBody: logBody, record: record, enableSoundcorkProxy: enableSoundcorkProxy, + dnsEnabled: dnsEnabled, + dnsUpstream: dnsUpstream, + dnsBind: dnsBind, discoveryInterval: discoveryInterval, domains: domains, } @@ -385,6 +435,15 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data config.record = persisted.RecordInteractions config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy + config.dnsEnabled = persisted.DNSEnabled + if persisted.DNSUpstream != "" { + config.dnsUpstream = persisted.DNSUpstream + } + + if persisted.DNSBindAddr != "" { + config.dnsBind = persisted.DNSBindAddr + } + return persisted } @@ -399,6 +458,9 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast DiscoveryInterval: config.discoveryInterval.String(), DiscoveryEnabled: true, EnableSoundcorkProxy: config.enableSoundcorkProxy, + DNSEnabled: config.dnsEnabled, + DNSUpstream: config.dnsUpstream, + DNSBindAddr: config.dnsBind, Shortcuts: map[string]int{ "/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound, "/sw.js": http.StatusNotFound, @@ -546,6 +608,7 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Post("/sync/{deviceIP}", server.HandleInitialSync) r.Post("/test-connection/{deviceIP}", server.HandleTestConnection) r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection) + r.Post("/test-dns/{deviceIP}", server.HandleTestDNSRedirection) r.Get("/ca.crt", server.HandleGetCACert) r.Get("/proxy-settings", server.HandleGetProxySettings) r.Post("/proxy-settings", server.HandleUpdateProxySettings) @@ -556,6 +619,10 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession) r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession) r.Delete("/interactions/sessions", server.HandleCleanupSessions) + + r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries) + r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries) + r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents) }) diff --git a/data/.gitignore b/data/.gitignore index ce3800f..0e508eb 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,6 +1,7 @@ accounts/ certs/ default/ +dns/ interactions/ patterns.json settings.json diff --git a/docs/guides/MIGRATION-SAFETY.md b/docs/guides/MIGRATION-SAFETY.md index ceb8689..e294c9a 100644 --- a/docs/guides/MIGRATION-SAFETY.md +++ b/docs/guides/MIGRATION-SAFETY.md @@ -27,7 +27,10 @@ Before you proceed with the actual migration, follow these steps: 4. **Validate SSH Access**: Confirm the device responds to SSH without a password. - In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows โœ… Success. - This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware. -5. **Use XML Migration First**: The `XML` migration method is less invasive than the `Hosts` method. It only changes the application config and doesn't require modifying the system's DNS/CA trust store if you don't need full HTTPS interception initially. +5. **Migration Methods**: + - **XML Migration (Default)**: Less invasive, only changes the application config. Best for simple redirection. + - **Hosts Migration**: Modifies `/etc/hosts` on the device. Good for system-wide redirection of specific domains. + - **ResolvConf Migration**: Points the device to the AfterTouch DNS server. Best for discovering unknown Bose endpoints and dynamic interception. **Note**: This method requires the DNS Discovery Server to be running on port 53. The service includes a pre-flight check to ensure the server is properly bound before allowing this migration. 6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration. #### ๐Ÿ”„ Rollback Strategy diff --git a/docs/guides/SOUNDTOUCH-SERVICE.md b/docs/guides/SOUNDTOUCH-SERVICE.md index f5dedb5..0f5fd6f 100644 --- a/docs/guides/SOUNDTOUCH-SERVICE.md +++ b/docs/guides/SOUNDTOUCH-SERVICE.md @@ -7,7 +7,8 @@ The `soundtouch-service` is a comprehensive local server that emulates Bose's cl The service provides: - **๐Ÿ  Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation -- **๐Ÿ”ง Device Migration**: Seamlessly migrate devices from Bose cloud to local services +- **๐Ÿ”ง Device Migration**: Seamlessly migrate devices from Bose cloud to local services via XML config, `/etc/hosts`, or `/etc/resolv.conf` +- **๐Ÿ” DNS Discovery & Interception**: Built-in DNS server to discover unknown Bose endpoints and selectively intercept cloud traffic - **๐Ÿ“Š Traffic Proxying**: Inspect and log all device communications for debugging - **๐ŸŒ Web Management UI**: Browser-based interface for device management - **๐Ÿ’พ Persistent Data**: Store device configurations, presets, and usage statistics @@ -150,20 +151,23 @@ The service supports multiple ways to configure its behavior. When multiple sour ### Configuration Options -| Variable | Flag | Description | Default | -|------------------------------------|----------------------------|--------------------------------------------------|---------------------------| -| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` | -| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) | -| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` | -| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://:8000` | -| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` | -| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://:8443` | -| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` | -| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` | -| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` | -| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` | -| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` | -| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` | +| Variable | Flag | Description | Default | +|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------| +| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` | +| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) | +| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` | +| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://:8000` | +| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` | +| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://:8443` | +| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` | +| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` | +| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` | +| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` | +| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` | +| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` | +| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` | +| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for `resolv.conf` migration) | `:53` | +| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` | ### Configuration Examples @@ -237,6 +241,42 @@ curl "http://192.168.1.100:8090/presets" curl "http://localhost:8000/events/192.168.1.100" ``` +#### ResolvConf Migration (DNS Redirection) + +The most robust migration method. Instead of modifying specific host files, it configures the device to use the AfterTouch service as its primary DNS server. + +> **Note**: This method requires the DNS Discovery Server to be bound to **port 53** on your local IP and **actually running**. Most devices do not support custom DNS ports in `/etc/resolv.conf`. If you use a custom port for testing, remember to switch back to `:53` and ensure the server has successfully bound to it (check Settings for status) before the actual migration. + +**Advantages:** +- **Discovery**: Automatically discover all Bose endpoints queried by the device. +- **Dynamic Interception**: Intercept new or unknown services without further device modifications. +- **Fail-Safe**: Can fall back to upstream DNS for non-intercepted queries. + +**Setup:** +1. Enable DNS discovery in the service settings. +2. Select the `resolv` method when migrating a device. +3. The service will automatically make `/etc/resolv.conf` immutable (`chattr +i`) to prevent DHCP overrides. + +### DNS Discovery Server + +The SoundTouch service includes a built-in DNS server specifically designed for Bose devices. + +#### How it Works +When enabled, the DNS server: +1. Receives DNS queries from migrated SoundTouch devices. +2. **Intercepts** known Bose domains (e.g., `api.bose.com`, `streaming.bose.com`, `bmx.bose.com`) and resolves them to the AfterTouch service IP. +3. **Logs** all other queries for discovery purposes, allowing you to identify new Bose cloud endpoints. +4. **Forwards** unknown or non-Bose queries to the configured upstream DNS server (default: `8.8.8.8`). + +#### Configuration +You can enable and configure the DNS server via the Web UI or environment variables: +- `ENABLE_DNS_DISCOVERY=true`: Turns on the DNS server. +- `DNS_BIND_ADDR=:53`: The port to listen on (requires root privileges for port 53). +- `DNS_UPSTREAM=1.1.1.1`: Your preferred upstream DNS provider. + +#### Manual Discovery via DNS +Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service. + ## API Reference ### Discovery & Setup @@ -384,6 +424,7 @@ The web management interface provides a comprehensive dashboard for managing you - **Interaction Viewer**: View raw `.http` recording content directly in the browser. - **Session Management**: Delete individual sessions or perform bulk cleanup to keep only recent sessions. - **Session Download**: Download complete interaction sessions as `.tar.gz` archives for offline analysis or bug reports. +- **DNS Discoveries**: Real-time table of all hostnames discovered via the AfterTouch DNS server, categorized by interception status (Self/Upstream). ### Usage Tips @@ -464,6 +505,8 @@ data/ โ”‚ โ”‚ โ””โ”€โ”€ {PATH}/ โ”‚ โ”‚ โ””โ”€โ”€ {SEQ}-{TIME}-{METHOD}.http โ”‚ โ””โ”€โ”€ http-client.env.json +โ”œโ”€โ”€ dns/ +โ”‚ โ””โ”€โ”€ discoveries.json โ”œโ”€โ”€ stats/ โ”‚ โ”œโ”€โ”€ usage/ โ”‚ โ”‚ โ””โ”€โ”€ *.json @@ -485,6 +528,9 @@ data/ - **Presets.xml**: Cross-device preset synchronization - **Recents.xml**: Recent playback history +#### DNS Data (`dns/`) +- **discoveries.json**: Persisted DNS discovery logs with hostname deduplication + #### Statistics (`stats/`) - **usage/**: Device usage analytics and patterns - **error/**: Error logs and diagnostic information @@ -564,6 +610,14 @@ Deletes all recordings associated with a specific session. #### `DELETE /setup/interactions/sessions?keep={N}` Bulk cleanup: deletes all but the most recent `N` sessions. +### DNS Discovery API + +#### `GET /setup/dns-discoveries` +Returns merged in-memory and persisted DNS discoveries, sorted by last seen timestamp. + +#### `DELETE /setup/dns-discoveries` +Clears all recorded DNS discovery data from memory and disk. + ### Emulated Services - `/bmx/registry/v1/services`: BMX service registry. - `/bmx/tunein/v1/*`: TuneIn radio emulation. diff --git a/pkg/discovery/dns.go b/pkg/discovery/dns.go new file mode 100644 index 0000000..12f8b0e --- /dev/null +++ b/pkg/discovery/dns.go @@ -0,0 +1,342 @@ +// Package discovery provides DNS-based discovery and interception for Bose SoundTouch devices. +package discovery + +import ( + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/miekg/dns" +) + +// DNSDiscovery handles DNS queries and records discovered hosts. +type DNSDiscovery struct { + // Configuration + upstreamDNS string + serviceIP string + + // State + discovered map[string]*DiscoveredHost + mu sync.RWMutex + + // Callbacks + onNewDiscovery func(hostname string) + + // Servers for Shutdown + udpServer *dns.Server + tcpServer *dns.Server +} + +// DiscoveredHost represents a host discovered via DNS queries. +type DiscoveredHost struct { + Hostname string `json:"hostname"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + QueryCount int `json:"query_count"` + IsBoseService bool `json:"is_bose_service"` + IsIntercepted bool `json:"is_intercepted"` + RemoteAddr string `json:"remote_addr,omitempty"` +} + +// NewDNSDiscovery creates a new DNSDiscovery instance. +func NewDNSDiscovery(upstreamDNS, serviceIP string) *DNSDiscovery { + return &DNSDiscovery{ + upstreamDNS: upstreamDNS, + serviceIP: serviceIP, + discovered: make(map[string]*DiscoveredHost), + } +} + +// ServeDNS implements the dns.Handler interface. +func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { + if len(r.Question) == 0 { + return + } + + q := r.Question[0] + hostname := strings.TrimSuffix(q.Name, ".") + + remoteAddr := "" + if w.RemoteAddr() != nil { + remoteAddr = w.RemoteAddr().String() + } + + // Decide how to respond + isIntercepted := d.shouldIntercept(hostname) || hostname == "aftertouch.test" + + // Record discovery + d.recordQuery(hostname, isIntercepted, remoteAddr) + + if isIntercepted { + // Return your service IP + d.respondWithIP(w, r, d.serviceIP) + log.Printf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP) + } else { + // Forward to real DNS + log.Printf("[DNS] Forwarding %s (type %d) to %s", hostname, q.Qtype, d.upstreamDNS) + d.forward(w, r) + } +} + +// recordQuery logs a DNS query and updates the internal state. +func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAddr string) { + d.mu.Lock() + defer d.mu.Unlock() + + host, exists := d.discovered[hostname] + if !exists { + // New discovery! + host = &DiscoveredHost{ + Hostname: hostname, + FirstSeen: time.Now(), + LastSeen: time.Now(), + QueryCount: 1, + IsBoseService: d.isBoseRelated(hostname), + IsIntercepted: isIntercepted, + RemoteAddr: remoteAddr, + } + d.discovered[hostname] = host + + log.Printf("[NEW DISCOVERY] %s (Bose: %v, Intercepted: %v)", + hostname, host.IsBoseService, host.IsIntercepted) + + if d.onNewDiscovery != nil { + go d.onNewDiscovery(hostname) + } + } else { + host.LastSeen = time.Now() + host.QueryCount++ + + host.IsIntercepted = isIntercepted + if remoteAddr != "" { + host.RemoteAddr = remoteAddr + } + } +} + +func (d *DNSDiscovery) shouldIntercept(hostname string) bool { + // Intercept known Bose cloud services + interceptList := []string{ + "api.bose.com", + "marge.bose.com", + "bmx.bose.com", + "streaming.bose.com", + "updates.bose.com", + "stats.bose.com", + "content.api.bose.io", + "events.api.bosecm.com", + "bose-prod.apigee.net", + "worldwide.bose.com", + "music.api.bose.com", + } + + for _, service := range interceptList { + if strings.Contains(hostname, service) { + return true + } + } + + return false +} + +func (d *DNSDiscovery) isBoseRelated(hostname string) bool { + return strings.Contains(hostname, "bose") || + strings.Contains(hostname, "soundtouch") +} + +func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string) { + m := new(dns.Msg) + m.SetReply(r) + m.Compress = false // Embedded clients sometimes don't like compression + m.Authoritative = true + m.RecursionAvailable = true + + q := r.Question[0] + log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr()) + + switch q.Qtype { + case dns.TypeA, dns.TypeANY: + rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, ip)) + if err == nil { + m.Answer = append(m.Answer, rr) + + log.Printf("[DNS] Returning A record %s -> %s", q.Name, ip) + } else { + log.Printf("[DNS] Error creating A record: %v", err) + } + case dns.TypeAAAA: + // Explicitly return SUCCESS with no data for AAAA to prevent fallback issues + log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name) + default: + log.Printf("[DNS] Returning empty success for type %d", q.Qtype) + } + + if err := w.WriteMsg(m); err != nil { + log.Printf("[DNS ERROR] Failed to write response: %v", err) + } +} + +func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) { + if len(r.Question) == 0 { + return + } + + // Don't forward PTR queries for our own service IP to avoid loops or slow timeouts + if r.Question[0].Qtype == dns.TypePTR { + m := new(dns.Msg) + m.SetReply(r) + + m.Rcode = dns.RcodeNameError + if err := w.WriteMsg(m); err != nil { + log.Printf("[DNS ERROR] Failed to write NXDOMAIN: %v", err) + } + + return + } + + c := new(dns.Client) + // Add port 53 if not present + upstream := d.upstreamDNS + if !strings.Contains(upstream, ":") { + upstream += ":53" + } + + in, _, err := c.Exchange(r, upstream) + if err != nil { + log.Printf("[DNS ERROR] Forward failed for %s (type %d): %v", r.Question[0].Name, r.Question[0].Qtype, err) + // Return a failure response instead of just dropping + m := new(dns.Msg) + m.SetReply(r) + + m.Rcode = dns.RcodeServerFailure + if err := w.WriteMsg(m); err != nil { + log.Printf("[DNS ERROR] Failed to write failure response: %v", err) + } + + return + } + + if err := w.WriteMsg(in); err != nil { + log.Printf("[DNS ERROR] Failed to write forwarded response: %v", err) + } +} + +// GetDiscovered returns a map of all discovered hosts. +func (d *DNSDiscovery) GetDiscovered() map[string]*DiscoveredHost { + d.mu.RLock() + defer d.mu.RUnlock() + + // Return copy + result := make(map[string]*DiscoveredHost) + for k, v := range d.discovered { + result[k] = v + } + + return result +} + +// GetBoseHosts returns a slice of all discovered Bose-related hosts. +func (d *DNSDiscovery) GetBoseHosts() []*DiscoveredHost { + d.mu.RLock() + defer d.mu.RUnlock() + + var result []*DiscoveredHost + + for _, host := range d.discovered { + if host.IsBoseService { + result = append(result, host) + } + } + + return result +} + +// SetDiscovered sets the map of discovered hosts. +func (d *DNSDiscovery) SetDiscovered(discovered map[string]*DiscoveredHost) { + d.mu.Lock() + defer d.mu.Unlock() + + d.discovered = discovered +} + +// Start DNS server starts both UDP and TCP listeners +func (d *DNSDiscovery) Start(addr string) error { + mux := dns.NewServeMux() + mux.HandleFunc(".", d.ServeDNS) + + d.mu.Lock() + d.udpServer = &dns.Server{ + Addr: addr, + Net: "udp", + Handler: mux, + } + + d.tcpServer = &dns.Server{ + Addr: addr, + Net: "tcp", + Handler: mux, + } + d.mu.Unlock() + + errChan := make(chan error, 2) + + go func() { + log.Printf("[DNS] UDP Discovery server starting on %s", addr) + + if err := d.udpServer.ListenAndServe(); err != nil { + errChan <- fmt.Errorf("UDP server failed: %w", err) + } + }() + + go func() { + log.Printf("[DNS] TCP Discovery server starting on %s", addr) + + if err := d.tcpServer.ListenAndServe(); err != nil { + errChan <- fmt.Errorf("TCP server failed: %w", err) + } + }() + + log.Printf("[DNS] Discovery servers starting on %s (upstream: %s, intercept IP: %s)", addr, d.upstreamDNS, d.serviceIP) + + // Wait for first error + return <-errChan +} + +// IsRunning returns true if the DNS server is active and bound to the specified address. +func (d *DNSDiscovery) IsRunning(addr string) bool { + d.mu.RLock() + defer d.mu.RUnlock() + + if d.udpServer == nil || d.tcpServer == nil { + return false + } + + // We check if the address matches what we expect + return d.udpServer.Addr == addr && d.tcpServer.Addr == addr +} + +// Shutdown stops the DNS server listeners +func (d *DNSDiscovery) Shutdown() error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.udpServer != nil { + if err := d.udpServer.Shutdown(); err != nil { + log.Printf("[DNS] Error shutting down UDP server: %v", err) + } + + d.udpServer = nil + } + + if d.tcpServer != nil { + if err := d.tcpServer.Shutdown(); err != nil { + log.Printf("[DNS] Error shutting down TCP server: %v", err) + } + + d.tcpServer = nil + } + + return nil +} diff --git a/pkg/discovery/dns_test.go b/pkg/discovery/dns_test.go new file mode 100644 index 0000000..f67f536 --- /dev/null +++ b/pkg/discovery/dns_test.go @@ -0,0 +1,194 @@ +package discovery + +import ( + "net" + "testing" + "time" + + "github.com/miekg/dns" +) + +func TestDNSDiscovery_Interception(t *testing.T) { + serviceIP := "192.168.1.100" + upstreamDNS := "8.8.8.8" + d := NewDNSDiscovery(upstreamDNS, serviceIP) + + // Test intercepting Bose service + m := new(dns.Msg) + m.SetQuestion("api.bose.com.", dns.TypeA) + + rw := &mockResponseWriter{} + d.ServeDNS(rw, m) + + if rw.msg == nil { + t.Fatal("Expected a response message, got nil") + } + + if len(rw.msg.Answer) == 0 { + t.Fatal("Expected an answer in the response") + } + + if a, ok := rw.msg.Answer[0].(*dns.A); ok { + if a.A.String() != serviceIP { + t.Errorf("Expected intercepted IP %s, got %s", serviceIP, a.A.String()) + } + } else { + t.Errorf("Expected A record, got %T", rw.msg.Answer[0]) + } + + // Test aftertouch.test + m2 := new(dns.Msg) + m2.SetQuestion("aftertouch.test.", dns.TypeA) + rw2 := &mockResponseWriter{} + d.ServeDNS(rw2, m2) + + if rw2.msg == nil || len(rw2.msg.Answer) == 0 { + t.Fatal("Expected response for aftertouch.test") + } + + if a, ok := rw2.msg.Answer[0].(*dns.A); ok { + if a.A.String() != serviceIP { + t.Errorf("Expected intercepted IP %s for aftertouch.test, got %s", serviceIP, a.A.String()) + } + } else { + t.Errorf("Expected A record for aftertouch.test, got %T", rw2.msg.Answer[0]) + } +} + +func TestDNSDiscovery_Forwarding(t *testing.T) { + // This test is harder because it needs a real upstream or a mock. + // For now, let's just test that it calls forward and record. + serviceIP := "192.168.1.100" + upstreamDNS := "127.0.0.1:5353" // Use a port that is likely closed or we can mock + d := NewDNSDiscovery(upstreamDNS, serviceIP) + + m := new(dns.Msg) + m.SetQuestion("google.com.", dns.TypeA) + + rw := &mockResponseWriter{} + + // Start a mock upstream DNS server + mux := dns.NewServeMux() + mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + _ = w.WriteMsg(m) + }) + ts := &dns.Server{Addr: "127.0.0.1:5353", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond} + go func() { + _ = ts.ListenAndServe() + }() + defer func() { _ = ts.Shutdown() }() + + // Give it a moment to start + time.Sleep(100 * time.Millisecond) + + // We expect forward to succeed + d.ServeDNS(rw, m) + + d.mu.RLock() + host, exists := d.discovered["google.com"] + d.mu.RUnlock() + + if !exists { + t.Error("Expected google.com to be recorded in discovery") + } + if host.IsBoseService { + t.Error("google.com should not be identified as a Bose service") + } +} + +func TestDNSDiscovery_StartTCP(t *testing.T) { + serviceIP := "192.168.1.100" + upstreamDNS := "8.8.8.8" + d := NewDNSDiscovery(upstreamDNS, serviceIP) + + addr := "127.0.0.1:5354" + go func() { + _ = d.Start(addr) + }() + + // Give it a moment to start + time.Sleep(200 * time.Millisecond) + + // Test TCP resolution + m := new(dns.Msg) + m.SetQuestion("api.bose.com.", dns.TypeA) + + c := new(dns.Client) + c.Net = "tcp" + in, _, err := c.Exchange(m, addr) + if err != nil { + t.Fatalf("Failed to exchange via TCP: %v", err) + } + + if len(in.Answer) == 0 { + t.Fatal("Expected answer in TCP response") + } + + if a, ok := in.Answer[0].(*dns.A); ok { + if a.A.String() != serviceIP { + t.Errorf("Expected intercepted IP %s via TCP, got %s", serviceIP, a.A.String()) + } + } else { + t.Errorf("Expected A record via TCP, got %T", in.Answer[0]) + } + + // Test Shutdown + err = d.Shutdown() + if err != nil { + t.Errorf("Shutdown failed: %v", err) + } + + // Verify it's really shut down by trying to connect + _, _, err = c.Exchange(m, addr) + if err == nil { + t.Error("Expected error after shutdown, but could still exchange") + } +} + +func TestDNSDiscovery_IsRunning(t *testing.T) { + serviceIP := "192.168.1.100" + upstreamDNS := "8.8.8.8" + d := NewDNSDiscovery(upstreamDNS, serviceIP) + + addr := "127.0.0.1:5355" + + if d.IsRunning(addr) { + t.Error("Expected IsRunning to be false before Start") + } + + go func() { + _ = d.Start(addr) + }() + + // Give it a moment to start + time.Sleep(200 * time.Millisecond) + + if !d.IsRunning(addr) { + t.Error("Expected IsRunning to be true after Start") + } + + if d.IsRunning("127.0.0.1:9999") { + t.Error("Expected IsRunning to be false for wrong address") + } + + _ = d.Shutdown() + + if d.IsRunning(addr) { + t.Error("Expected IsRunning to be false after Shutdown") + } +} + +type mockResponseWriter struct { + msg *dns.Msg +} + +func (m *mockResponseWriter) LocalAddr() net.Addr { return nil } +func (m *mockResponseWriter) RemoteAddr() net.Addr { return nil } +func (m *mockResponseWriter) WriteMsg(msg *dns.Msg) error { m.msg = msg; return nil } +func (m *mockResponseWriter) Write([]byte) (int, error) { return 0, nil } +func (m *mockResponseWriter) Close() error { return nil } +func (m *mockResponseWriter) TsigStatus() error { return nil } +func (m *mockResponseWriter) TsigTimersOnly(bool) {} +func (m *mockResponseWriter) Hijack() {} diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index e05d032..a3a4d09 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "sync" "time" @@ -707,6 +708,9 @@ type Settings struct { DiscoveryInterval string `json:"discovery_interval,omitempty"` DiscoveryEnabled bool `json:"discovery_enabled"` EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"` + DNSEnabled bool `json:"dns_enabled"` + DNSUpstream string `json:"dns_upstream,omitempty"` + DNSBindAddr string `json:"dns_bind_addr,omitempty"` Shortcuts map[string]int `json:"shortcuts,omitempty"` } @@ -822,3 +826,78 @@ func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent { return copiedEvents } + +// DNSDiscoveryEntry represents a persisted DNS discovery. +type DNSDiscoveryEntry struct { + Hostname string `json:"hostname"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + QueryCount int `json:"query_count"` + IsBoseService bool `json:"is_bose_service"` + IsIntercepted bool `json:"is_intercepted"` + RemoteAddr string `json:"remote_addr,omitempty"` +} + +// SaveDNSDiscoveries saves DNS discoveries to the datastore. +func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error { + if ds == nil || ds.DataDir == "" { + return nil + } + + dir := filepath.Join(ds.DataDir, "dns") + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create dns directory: %w", err) + } + + path := filepath.Join(dir, "discoveries.json") + + // Sort by last seen descending + sort.Slice(discoveries, func(i, j int) bool { + return discoveries[i].LastSeen.After(discoveries[j].LastSeen) + }) + + data, err := json.MarshalIndent(discoveries, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +// LoadDNSDiscoveries loads DNS discoveries from the datastore. +func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) { + if ds == nil || ds.DataDir == "" { + return []DNSDiscoveryEntry{}, nil + } + + path := filepath.Join(ds.DataDir, "dns", "discoveries.json") + if !exists(path) { + return []DNSDiscoveryEntry{}, nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var discoveries []DNSDiscoveryEntry + if err := json.Unmarshal(data, &discoveries); err != nil { + return nil, err + } + + return discoveries, nil +} + +// ClearDNSDiscoveries removes all DNS discoveries from the datastore. +func (ds *DataStore) ClearDNSDiscoveries() error { + if ds == nil || ds.DataDir == "" { + return nil + } + + path := filepath.Join(ds.DataDir, "dns", "discoveries.json") + if !exists(path) { + return nil + } + + return os.Remove(path) +} diff --git a/pkg/service/datastore/dns_persistence_test.go b/pkg/service/datastore/dns_persistence_test.go new file mode 100644 index 0000000..5686d7b --- /dev/null +++ b/pkg/service/datastore/dns_persistence_test.go @@ -0,0 +1,75 @@ +package datastore + +import ( + "os" + "testing" + "time" +) + +func TestDNSDiscoveryPersistence(t *testing.T) { + tempDir, err := os.MkdirTemp("", "datastore-dns-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + ds := NewDataStore(tempDir) + + now := time.Now().Round(time.Second) + discoveries := []DNSDiscoveryEntry{ + { + Hostname: "api.bose.com", + FirstSeen: now.Add(-1 * time.Hour), + LastSeen: now, + QueryCount: 10, + IsBoseService: true, + IsIntercepted: true, + RemoteAddr: "192.168.1.100", + }, + { + Hostname: "google.com", + FirstSeen: now.Add(-2 * time.Hour), + LastSeen: now.Add(-1 * time.Hour), + QueryCount: 5, + IsBoseService: false, + IsIntercepted: false, + RemoteAddr: "192.168.1.101", + }, + } + + // Test Save + err = ds.SaveDNSDiscoveries(discoveries) + if err != nil { + t.Fatalf("SaveDNSDiscoveries failed: %v", err) + } + + // Test Load + loaded, err := ds.LoadDNSDiscoveries() + if err != nil { + t.Fatalf("LoadDNSDiscoveries failed: %v", err) + } + + if len(loaded) != 2 { + t.Errorf("Expected 2 discoveries, got %d", len(loaded)) + } + + // Check if sorted by LastSeen (SaveDNSDiscoveries sorts them) + if loaded[0].Hostname != "api.bose.com" { + t.Errorf("Expected api.bose.com to be first, got %s", loaded[0].Hostname) + } + + // Test Clear + err = ds.ClearDNSDiscoveries() + if err != nil { + t.Fatalf("ClearDNSDiscoveries failed: %v", err) + } + + loadedAfterClear, err := ds.LoadDNSDiscoveries() + if err != nil { + t.Fatalf("LoadDNSDiscoveries after clear failed: %v", err) + } + + if len(loadedAfterClear) != 0 { + t.Errorf("Expected 0 discoveries after clear, got %d", len(loadedAfterClear)) + } +} diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index dc891bc..173545f 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -6,11 +6,13 @@ import ( "log" "net/http" "os" + "sort" "strconv" "time" "fmt" + "github.com/gesellix/bose-soundtouch/pkg/discovery" "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/service/setup" @@ -148,17 +150,27 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL discoveryInterval := s.discoveryInterval.String() discoveryEnabled := s.discoveryEnabled + dnsEnabled := s.dnsEnabled + dnsUpstream := s.dnsUpstream + dnsBindAddr := s.dnsBindAddr enableSoundcorkProxy := s.enableSoundcorkProxy redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled shortcuts := s.shortcuts s.mu.RUnlock() + dnsRunning, actualBind := s.GetDNSRunning() + if err := json.NewEncoder(w).Encode(map[string]interface{}{ "server_url": serverURL, "soundcork_url": soundcorkURL, "https_server_url": httpsServerURL, "discovery_interval": discoveryInterval, "discovery_enabled": discoveryEnabled, + "dns_enabled": dnsEnabled, + "dns_running": dnsRunning, + "dns_actual_bind": actualBind, + "dns_upstream": dnsUpstream, + "dns_bind_addr": dnsBindAddr, "enable_soundcork_proxy": enableSoundcorkProxy, "redact_logs": redact, "log_bodies": logBody, @@ -177,6 +189,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { SoundcorkURL string `json:"soundcork_url"` DiscoveryInterval string `json:"discovery_interval"` DiscoveryEnabled bool `json:"discovery_enabled"` + DNSEnabled bool `json:"dns_enabled"` + DNSUpstream string `json:"dns_upstream"` + DNSBindAddr string `json:"dns_bind_addr"` EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"` Shortcuts map[string]int `json:"shortcuts"` } @@ -200,6 +215,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { } s.discoveryEnabled = settings.DiscoveryEnabled + s.dnsEnabled = settings.DNSEnabled + s.dnsUpstream = settings.DNSUpstream + s.dnsBindAddr = settings.DNSBindAddr s.enableSoundcorkProxy = settings.EnableSoundcorkProxy if settings.Shortcuts != nil { @@ -227,6 +245,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { RecordInteractions: currentRecord, DiscoveryInterval: s.discoveryInterval.String(), DiscoveryEnabled: s.discoveryEnabled, + DNSEnabled: s.dnsEnabled, + DNSUpstream: s.dnsUpstream, + DNSBindAddr: s.dnsBindAddr, EnableSoundcorkProxy: s.enableSoundcorkProxy, Shortcuts: s.shortcuts, }) @@ -384,6 +405,85 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) { } } +// HandleGetDNSDiscoveries returns recorded DNS discoveries. +func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request) { + // 1. Get current in-memory discoveries + inMemory := s.GetDNSDiscovery() + + // 2. Load persisted discoveries + persisted, err := s.ds.LoadDNSDiscoveries() + if err != nil { + log.Printf("Warning: Failed to load DNS discoveries: %v", err) + } + + // 3. Merge them + merged := make(map[string]datastore.DNSDiscoveryEntry) + for _, p := range persisted { + merged[p.Hostname] = p + } + + for hostname, h := range inMemory { + m, exists := merged[hostname] + if !exists || h.LastSeen.After(m.LastSeen) { + merged[hostname] = datastore.DNSDiscoveryEntry{ + Hostname: h.Hostname, + FirstSeen: h.FirstSeen, + LastSeen: h.LastSeen, + QueryCount: h.QueryCount, + IsBoseService: h.IsBoseService, + IsIntercepted: h.IsIntercepted, + RemoteAddr: h.RemoteAddr, + } + } else if h.QueryCount > m.QueryCount { + // If exists and persisted is newer (rare but possible), update query count if higher + m.QueryCount = h.QueryCount + merged[hostname] = m + } + } + + // Convert to slice + result := make([]datastore.DNSDiscoveryEntry, 0, len(merged)) + for _, entry := range merged { + result = append(result, entry) + } + + // Sort by last seen descending + sort.Slice(result, func(i, j int) bool { + return result[i].LastSeen.After(result[j].LastSeen) + }) + + // 4. Update persistence with merged results + if err := s.ds.SaveDNSDiscoveries(result); err != nil { + log.Printf("Warning: Failed to persist merged DNS discoveries: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(result); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// HandleClearDNSDiscoveries clears recorded DNS discoveries. +func (s *Server) HandleClearDNSDiscoveries(w http.ResponseWriter, _ *http.Request) { + // 1. Clear in-memory + s.SetDNSDiscoveries(make(map[string]*discovery.DiscoveredHost)) + + // 2. Clear persistence + if err := s.ds.ClearDNSDiscoveries(); err != nil { + http.Error(w, "Failed to clear DNS discoveries: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(map[string]bool{"ok": true}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + // HandleTrustCACert injects the local Root CA into the device's shared trust store. func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) { deviceIP := chi.URLParam(r, "deviceIP") @@ -657,6 +757,46 @@ func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Reque } } +// HandleTestDNSRedirection performs a check for DNS redirection to the AfterTouch service. +func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request) { + deviceIP := chi.URLParam(r, "deviceIP") + if deviceIP == "" { + http.Error(w, "Device IP is required", http.StatusBadRequest) + return + } + + targetURL := r.URL.Query().Get("target_url") + if targetURL == "" { + targetURL = s.serverURL + } + + output, err := s.sm.TestDNSRedirection(deviceIP, targetURL) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) // Return 200 but ok: false so UI can show the output + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": false, + "message": err.Error(), + "output": output, + }); encodeErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } + + return + } + + w.Header().Set("Content-Type", "application/json") + + if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "message": "DNS redirection test successful", + "output": output, + }); encodeErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + // HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore. func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) { deviceIP := chi.URLParam(r, "deviceIP") diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 89cdce4..460aedd 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -4,6 +4,7 @@ import ( "context" "log" "net/http" + "net/url" "sync" "time" @@ -28,9 +29,13 @@ type Server struct { recordEnabled bool discoveryInterval time.Duration discoveryEnabled bool + dnsEnabled bool + dnsUpstream string + dnsBindAddr string enableSoundcorkProxy bool shortcuts map[string]int recorder *proxy.Recorder + dnsDiscovery *discovery.DNSDiscovery UpstreamProxy http.Handler Version string Commit string @@ -73,6 +78,84 @@ func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) { s.discoveryEnabled = enabled } +// SetDNSSettings sets the DNS discovery settings for the server. +func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) { + s.mu.Lock() + defer s.mu.Unlock() + + oldBind := s.dnsBindAddr + oldUpstream := s.dnsUpstream + + s.dnsEnabled = enabled + s.dnsUpstream = upstream + s.dnsBindAddr = bind + + if s.dnsDiscovery != nil { + if !enabled || bind != oldBind || upstream != oldUpstream { + log.Printf("[DNS] Settings changed, stopping DNS discovery server") + + _ = s.dnsDiscovery.Shutdown() + s.dnsDiscovery = nil + } + } + + if enabled && s.dnsDiscovery == nil { + log.Printf("[DNS] Starting DNS discovery server on %s", bind) + + u, _ := url.Parse(s.serverURL) + + serviceIP := u.Hostname() + if serviceIP == "localhost" || serviceIP == "" { + serviceIP = "127.0.0.1" + } + + if s.sm != nil { + serviceIP = s.sm.GetResolvedIP(serviceIP) + } + + s.dnsDiscovery = discovery.NewDNSDiscovery(upstream, serviceIP) + go func(d *discovery.DNSDiscovery, addr string) { + if err := d.Start(addr); err != nil { + log.Printf("Warning: DNS discovery server error: %v", err) + } + }(s.dnsDiscovery, bind) + } +} + +// GetDNSRunning returns whether DNS discovery is active and its bind address. +func (s *Server) GetDNSRunning() (bool, string) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.dnsDiscovery == nil { + return false, "" + } + + return s.dnsDiscovery.IsRunning(s.dnsBindAddr), s.dnsBindAddr +} + +// SetDNSDiscoveries sets the initial DNS discoveries for the server. +func (s *Server) SetDNSDiscoveries(discoveries map[string]*discovery.DiscoveredHost) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.dnsDiscovery != nil { + s.dnsDiscovery.SetDiscovered(discoveries) + } +} + +// GetDNSDiscovery returns the current DNS discoveries. +func (s *Server) GetDNSDiscovery() map[string]*discovery.DiscoveredHost { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.dnsDiscovery == nil { + return nil + } + + return s.dnsDiscovery.GetDiscovered() +} + // SetShortcuts sets the request shortcuts for the server. func (s *Server) SetShortcuts(shortcuts map[string]int) { s.mu.Lock() diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index 6b61457..41370e1 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -100,6 +100,24 @@ +
+ DNS Discovery: +
+ +
+ + + (For non-intercepted queries) +
+
+ + + (e.g., :53 or 0.0.0.0:53. Port 53 is required for actual migration) +
+
+
Proxy Logging:
@@ -194,12 +212,31 @@
+ +
+ +
+ +
+
@@ -314,7 +358,9 @@
-

Browse Recordings

+
+

Browse Recordings

+
@@ -357,6 +403,31 @@
+
+
+

DNS Discoveries

+ +
+

Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.

+
+ + + + + + + + + + + + + + +
HostnameLast SeenQueriesBose?CategoryLast Client IP
No DNS discoveries found.
+
+
+