mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Health check (checks_stale_internet_radio.go): detects stub INTERNET_RADIO
sources (empty credentials) left on devices initialised before the stub was
removed from the default source list. Quick-fix removes by ID; skips any
INTERNET_RADIO source that has real credentials.
Datastore: DeleteSourceByID and DeleteSourceByType (uniqueness-guarded).
API: DELETE /setup/sources/{account}/{device}/{sourceID}
CLI — two new commands:
soundtouch-cli cloud source remove --service-url ... --account ... --device ... [--id 10002 | --type INTERNET_RADIO]
Talks to AfterTouch (service side). --type resolves to canonical ID
locally; fails for unknown types.
soundtouch-cli source notify-updated --host <speaker-ip>
Talks to the speaker directly. Fetches device ID from /info, then
POSTs sourcesUpdated to :8090/notification so the speaker re-fetches
its source list immediately.
CloudCommonFlags (--service-url / AFTERTOUCH_URL) mirrors CommonFlags
(--host) for AfterTouch-facing command groups.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1373 lines
42 KiB
Go
1373 lines
42 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"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"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// HandleListDiscoveredDevices returns a list of all discovered devices.
|
|
func (s *Server) HandleListDiscoveredDevices(w http.ResponseWriter, _ *http.Request) {
|
|
devices, err := s.ds.ListAllDevices()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(devices); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleAddManualDevice adds a device manually by IP.
|
|
func (s *Server) HandleAddManualDevice(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
IP string `json:"ip"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if body.IP == "" {
|
|
http.Error(w, "IP address is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Try to get live info
|
|
liveInfo, err := s.sm.GetLiveDeviceInfo(body.IP)
|
|
if err != nil {
|
|
// Even if we can't get live info, we might want to add it?
|
|
// But usually we need at least the serial for proper account management.
|
|
http.Error(w, "Failed to reach device at "+body.IP+": "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
// Reuse handleDiscoveredDevice logic via a fake models.DiscoveredDevice
|
|
d := models.DiscoveredDevice{
|
|
Name: liveInfo.Name,
|
|
Host: body.IP,
|
|
ModelID: liveInfo.Type,
|
|
SerialNo: liveInfo.SerialNumber,
|
|
DiscoveryMethod: "manual",
|
|
}
|
|
|
|
s.handleDiscoveredDevice(d)
|
|
s.mergeOverlappingDevices()
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// HandleTriggerDiscovery triggers a new device discovery scan.
|
|
func (s *Server) HandleTriggerDiscovery(w http.ResponseWriter, _ *http.Request) {
|
|
//nolint:contextcheck
|
|
go s.DiscoverDevices(context.Background())
|
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
// HandleGetDiscoveryStatus returns the current discovery status.
|
|
func (s *Server) HandleGetDiscoveryStatus(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]bool{"discovering": s.discovering}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleRemoveDevice removes a device from the datastore.
|
|
func (s *Server) HandleRemoveDevice(w http.ResponseWriter, r *http.Request) {
|
|
deviceId := chi.URLParam(r, "deviceId")
|
|
if deviceId == "" {
|
|
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Find which account this device belongs to.
|
|
devices, err := s.ds.ListAllDevices()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var found bool
|
|
|
|
for i := range devices {
|
|
if devices[i].DeviceID == deviceId {
|
|
err = s.ds.RemoveDevice(devices[i].AccountID, devices[i].DeviceID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
found = true
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
http.Error(w, "Device not found", http.StatusNotFound)
|
|
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
|
|
}
|
|
}
|
|
|
|
// HandleGetSettings returns the current service settings.
|
|
func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
s.mu.RLock()
|
|
serverURL, httpsServerURL := s.serverURL, s.httpsServerURL
|
|
discoveryInterval := s.discoveryInterval.String()
|
|
discoveryEnabled := s.discoveryEnabled
|
|
dnsEnabled := s.dnsEnabled
|
|
dnsUpstream := s.dnsUpstream
|
|
dnsBindAddr := s.dnsBindAddr
|
|
internalPaths := s.internalPaths
|
|
redact, logBody, record := s.redactLogs, s.logBodies, s.recordEnabled
|
|
shortcuts := s.shortcuts
|
|
spotifyConfigured := s.spotifyService != nil
|
|
spotifyClientID := s.spotifyClientID
|
|
spotifyClientSecret := s.spotifyClientSecret
|
|
spotifyRedirectURI := s.spotifyRedirectURI
|
|
amazonConfigured := s.amazonService != nil
|
|
amazonClientID := s.amazonClientID
|
|
amazonClientSecret := s.amazonClientSecret
|
|
amazonRedirectURI := s.amazonRedirectURI
|
|
s.mu.RUnlock()
|
|
|
|
dnsRunning, actualBind := s.GetDNSRunning()
|
|
|
|
var serverURLResolvedIP, serverURLResolveError string
|
|
|
|
if ip, err := s.resolveServerURLIP(serverURL); err == nil {
|
|
serverURLResolvedIP = ip
|
|
} else {
|
|
serverURLResolveError = err.Error()
|
|
}
|
|
|
|
httpsListenerPort := PortFromHTTPSServerURL(httpsServerURL)
|
|
probe443 := Check443Reachability(httpsListenerPort, serverURL, s.resolveServerURLIP, ProbeDialTimeoutInline)
|
|
|
|
// Mask secrets: return "***" if set so the UI can show "configured" without exposing the value.
|
|
if spotifyClientSecret != "" {
|
|
spotifyClientSecret = "***"
|
|
}
|
|
|
|
if amazonClientSecret != "" {
|
|
amazonClientSecret = "***"
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"server_url": serverURL,
|
|
"server_url_resolved_ip": serverURLResolvedIP,
|
|
"server_url_resolve_error": serverURLResolveError,
|
|
"https_server_url": httpsServerURL,
|
|
"https_listener_port": httpsListenerPort,
|
|
"https_443_check_skipped": probe443.Skipped,
|
|
"https_443_not_applicable": probe443.NotApplicable,
|
|
"https_443_reason": probe443.Reason,
|
|
"tls_extra_hosts": s.persistedTLSExtraHosts(),
|
|
"tls_san_hosts": s.ExpectedHosts(),
|
|
"https_443_localhost_reachable": probe443.Localhost.Reachable,
|
|
"https_443_localhost_error": probe443.Localhost.Error,
|
|
"https_443_lan_reachable": probe443.LAN.Reachable,
|
|
"https_443_lan_error": probe443.LAN.Error,
|
|
"https_443_lan_host": probe443.LANHost,
|
|
"discovery_interval": discoveryInterval,
|
|
"discovery_enabled": discoveryEnabled,
|
|
"dns_enabled": dnsEnabled,
|
|
"dns_running": dnsRunning,
|
|
"dns_actual_bind": actualBind,
|
|
"dns_upstream": strings.Join(dnsUpstream, ","),
|
|
"dns_bind_addr": dnsBindAddr,
|
|
"internal_paths": internalPaths,
|
|
"redact_logs": redact,
|
|
"log_bodies": logBody,
|
|
"record_interactions": record,
|
|
"shortcuts": shortcuts,
|
|
"spotify_configured": spotifyConfigured,
|
|
"spotify_client_id": spotifyClientID,
|
|
"spotify_client_secret": spotifyClientSecret,
|
|
"spotify_redirect_uri": spotifyRedirectURI,
|
|
"amazon_configured": amazonConfigured,
|
|
"amazon_client_id": amazonClientID,
|
|
"amazon_client_secret": amazonClientSecret,
|
|
"amazon_redirect_uri": amazonRedirectURI,
|
|
}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleUpdateSettings updates the service settings.
|
|
func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|
var settings struct {
|
|
ServerURL string `json:"server_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"`
|
|
InternalPaths []string `json:"internal_paths"`
|
|
Shortcuts map[string]int `json:"shortcuts"`
|
|
SpotifyClientID string `json:"spotify_client_id"`
|
|
SpotifyClientSecret string `json:"spotify_client_secret"`
|
|
SpotifyRedirectURI string `json:"spotify_redirect_uri"`
|
|
AmazonClientID string `json:"amazon_client_id"`
|
|
AmazonClientSecret string `json:"amazon_client_secret"`
|
|
AmazonRedirectURI string `json:"amazon_redirect_uri"`
|
|
TLSExtraHosts *[]string `json:"tls_extra_hosts"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if settings.DNSEnabled && settings.DNSUpstream == "" {
|
|
// No strict requirement for DNSUpstream here as SetDNSSettings will
|
|
// try to fall back to system DNS. We only log it if both are empty later.
|
|
log.Printf("[DNS] DNS Discovery enabled without explicit upstreams, will try system DNS.")
|
|
}
|
|
|
|
// Validate server_url: the same value the DNS server uses to derive its
|
|
// intercept IP. Reject anything that does not resolve to a routable IP so
|
|
// users see the error in the UI instead of getting a silently-broken setup
|
|
// where DNS replies with `CNAME .` for every Bose hostname.
|
|
if _, err := s.resolveServerURLIP(settings.ServerURL); err != nil {
|
|
http.Error(w, "Invalid server_url: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
interval, err := time.ParseDuration(settings.DiscoveryInterval)
|
|
if err != nil && settings.DiscoveryInterval != "" {
|
|
http.Error(w, "Invalid discovery interval: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.serverURL = settings.ServerURL
|
|
|
|
s.discoveryEnabled = settings.DiscoveryEnabled
|
|
if settings.DiscoveryInterval != "" {
|
|
s.discoveryInterval = interval
|
|
}
|
|
|
|
if s.discoveryInterval == 0 {
|
|
s.discoveryEnabled = false
|
|
}
|
|
|
|
s.dnsEnabled = settings.DNSEnabled
|
|
|
|
// Handle comma-separated upstream DNS servers
|
|
var upstreamList []string
|
|
|
|
if settings.DNSUpstream != "" {
|
|
for _, u := range strings.Split(settings.DNSUpstream, ",") {
|
|
u = strings.TrimSpace(u)
|
|
if u != "" {
|
|
upstreamList = append(upstreamList, u)
|
|
}
|
|
}
|
|
}
|
|
|
|
s.dnsUpstream = upstreamList
|
|
s.dnsBindAddr = settings.DNSBindAddr
|
|
|
|
s.internalPaths = settings.InternalPaths
|
|
|
|
if settings.Shortcuts != nil {
|
|
s.shortcuts = settings.Shortcuts
|
|
}
|
|
|
|
if s.sm != nil {
|
|
s.sm.ServerURL = settings.ServerURL
|
|
}
|
|
|
|
// Update music service credentials (empty or "***" means "unchanged").
|
|
s.applyMusicServiceCredentials(
|
|
settings.SpotifyClientID, settings.SpotifyClientSecret, settings.SpotifyRedirectURI,
|
|
settings.AmazonClientID, settings.AmazonClientSecret, settings.AmazonRedirectURI,
|
|
)
|
|
|
|
// Persist to datastore
|
|
// Access fields directly since we already hold the lock
|
|
currentRedact := s.redactLogs
|
|
currentLogBody := s.logBodies
|
|
currentRecord := s.recordEnabled
|
|
currentHTTPS := s.httpsServerURL
|
|
|
|
// Resolve TLS extra hosts: nil pointer means "field omitted, preserve existing";
|
|
// non-nil (even empty) means "replace with this list".
|
|
resolvedTLSExtraHosts := s.persistedTLSExtraHosts()
|
|
if settings.TLSExtraHosts != nil {
|
|
resolvedTLSExtraHosts = normaliseTLSExtraHosts(*settings.TLSExtraHosts)
|
|
}
|
|
|
|
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
|
|
err = s.ds.SaveSettings(datastore.Settings{
|
|
ServerURL: s.serverURL,
|
|
HTTPServerURL: currentHTTPS,
|
|
RedactLogs: currentRedact,
|
|
LogBodies: currentLogBody,
|
|
RecordInteractions: currentRecord,
|
|
DiscoveryInterval: s.discoveryInterval.String(),
|
|
DiscoveryEnabled: s.discoveryEnabled,
|
|
DNSEnabled: s.dnsEnabled,
|
|
DNSUpstream: s.dnsUpstream,
|
|
DNSBindAddr: s.dnsBindAddr,
|
|
InternalPaths: s.internalPaths,
|
|
Shortcuts: s.shortcuts,
|
|
SpotifyClientID: s.spotifyClientID,
|
|
SpotifyClientSecret: s.spotifyClientSecret,
|
|
SpotifyRedirectURI: s.spotifyRedirectURI,
|
|
AmazonClientID: s.amazonClientID,
|
|
AmazonClientSecret: s.amazonClientSecret,
|
|
AmazonRedirectURI: s.amazonRedirectURI,
|
|
TLSExtraHosts: resolvedTLSExtraHosts,
|
|
})
|
|
|
|
dnsEnabled := s.dnsEnabled
|
|
dnsUpstreamStr := strings.Join(s.dnsUpstream, ",")
|
|
dnsBindAddr := s.dnsBindAddr
|
|
reinitSpotify := s.spotifyClientID != ""
|
|
reinitAmazon := s.amazonClientID != ""
|
|
|
|
s.mu.Unlock()
|
|
|
|
s.SetDNSSettings(dnsEnabled, dnsUpstreamStr, dnsBindAddr)
|
|
|
|
if reinitSpotify {
|
|
s.ReinitSpotifyService()
|
|
}
|
|
|
|
if reinitAmazon {
|
|
s.ReinitAmazonService()
|
|
}
|
|
|
|
if err != nil {
|
|
http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Settings updated"}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// normaliseTLSExtraHosts trims whitespace from each entry, drops empty
|
|
// values, and deduplicates while preserving the first occurrence's
|
|
// position. The settings endpoint applies this before persisting so the
|
|
// stored list is always canonical.
|
|
func normaliseTLSExtraHosts(in []string) []string {
|
|
out := make([]string, 0, len(in))
|
|
seen := make(map[string]bool, len(in))
|
|
|
|
for _, h := range in {
|
|
h = strings.TrimSpace(h)
|
|
if h == "" || seen[h] {
|
|
continue
|
|
}
|
|
|
|
seen[h] = true
|
|
|
|
out = append(out, h)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// HandleGetDeviceInfo returns live information for a device.
|
|
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(info); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleGetMigrationSummary returns a summary of the migration plan for a device.
|
|
func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
targetURL := r.URL.Query().Get("target_url")
|
|
proxyURL := r.URL.Query().Get("proxy_url")
|
|
|
|
options := parseMigrationOptions(r.URL.Query())
|
|
|
|
summary, err := s.sm.GetMigrationSummary(deviceIP, targetURL, proxyURL, options)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(summary); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleMigrateDevice starts the migration process for a device.
|
|
func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
targetURL := r.URL.Query().Get("target_url")
|
|
proxyURL := r.URL.Query().Get("proxy_url")
|
|
method := setup.MigrationMethod(r.URL.Query().Get("method"))
|
|
|
|
options := parseMigrationOptions(r.URL.Query())
|
|
|
|
output, err := s.sm.MigrateSpeaker(deviceIP, targetURL, proxyURL, options, method)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
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
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Migration started", "output": output}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleRevertMigration reverts the migration for a device.
|
|
func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
output, err := s.sm.RevertMigration(deviceIP)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
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
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Revert started", "output": output}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleGetDNSDiscoveries returns recorded DNS discoveries.
|
|
func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
|
|
result := s.getMergedDNSDiscoveries()
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// HandleDownloadDNSDiscoveries returns recorded DNS discoveries as a downloadable JSON file.
|
|
func (s *Server) HandleDownloadDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
|
|
result := s.getMergedDNSDiscoveries()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\"dns-discoveries.json\"")
|
|
|
|
encoder := json.NewEncoder(w)
|
|
encoder.SetIndent("", " ")
|
|
|
|
if err := encoder.Encode(result); err != nil {
|
|
log.Printf("Error encoding DNS discoveries for download: %v", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) getMergedDNSDiscoveries() []datastore.DNSDiscoveryEntry {
|
|
// 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)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// 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) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
output, err := s.sm.TrustCACert(deviceIP)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
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
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Root CA trusted", "output": output}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleEnsureRemoteServices ensures that remote services are configured on a device.
|
|
func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
output, err := s.sm.EnsureRemoteServices(deviceIP)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
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
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services enabled", "output": output}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleRemoveRemoteServices removes remote services configuration from a device.
|
|
func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
output, err := s.sm.RemoveRemoteServices(deviceIP)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
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
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Remote services removed", "output": output}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleBackupConfig creates a backup of the device configuration.
|
|
func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
output, err := s.sm.BackupConfig(deviceIP)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
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
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Config backed up", "output": output}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleGetLoggingSettings returns the current proxy settings.
|
|
func (s *Server) HandleGetLoggingSettings(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
redact, logBody, record := s.GetLoggingSettings()
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"redact": redact,
|
|
"log_body": logBody,
|
|
"record": record,
|
|
}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleGetCACert returns the Root CA certificate.
|
|
func (s *Server) HandleGetCACert(w http.ResponseWriter, _ *http.Request) {
|
|
caCertPath := s.sm.Crypto.GetCACertPath()
|
|
|
|
content, err := os.ReadFile(caCertPath)
|
|
if err != nil {
|
|
http.Error(w, "Failed to read CA certificate", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=soundtouch-ca.crt")
|
|
_, _ = w.Write(content)
|
|
}
|
|
|
|
// HandleUpdateLoggingSettings updates the proxy settings.
|
|
func (s *Server) HandleUpdateLoggingSettings(w http.ResponseWriter, r *http.Request) {
|
|
var settings struct {
|
|
Redact bool `json:"redact"`
|
|
LogBody bool `json:"log_body"`
|
|
Record bool `json:"record"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.redactLogs = settings.Redact
|
|
s.logBodies = settings.LogBody
|
|
s.recordEnabled = settings.Record
|
|
|
|
if s.recorder != nil {
|
|
s.recorder.Redact = settings.Redact
|
|
}
|
|
|
|
// Persist to datastore
|
|
// Access fields directly since we already hold the lock
|
|
serverURL, httpsServerURL := s.serverURL, s.httpsServerURL
|
|
discoveryInterval := s.discoveryInterval.String()
|
|
discoveryEnabled := s.discoveryEnabled
|
|
|
|
log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir)
|
|
err := s.ds.SaveSettings(datastore.Settings{
|
|
ServerURL: serverURL,
|
|
HTTPServerURL: httpsServerURL,
|
|
RedactLogs: s.redactLogs,
|
|
LogBodies: s.logBodies,
|
|
RecordInteractions: s.recordEnabled,
|
|
DiscoveryInterval: discoveryInterval,
|
|
DiscoveryEnabled: discoveryEnabled,
|
|
Shortcuts: s.shortcuts,
|
|
})
|
|
s.mu.Unlock()
|
|
|
|
if err != nil {
|
|
http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Proxy settings updated"}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleTestHostsRedirection performs a preliminary check for /etc/hosts redirection.
|
|
func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
targetURL := r.URL.Query().Get("target_url")
|
|
if targetURL == "" {
|
|
targetURL = s.serverURL
|
|
}
|
|
|
|
output, err := s.sm.TestHostsRedirection(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": "Hosts redirection test successful",
|
|
"output": output,
|
|
}); encodeErr != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// HandleTestDNSRedirection performs a check for DNS redirection to the AfterTouch service.
|
|
func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
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) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
http.Error(w, "Missing deviceId", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if err := s.sm.SyncDeviceData(deviceIP); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ok": true}`))
|
|
}
|
|
|
|
// HandleRebootDevice reboots a device.
|
|
func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
method := setup.RebootMethod(r.URL.Query().Get("method"))
|
|
|
|
output, err := s.sm.Reboot(deviceIP, method)
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
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
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Reboot started", "output": output}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleTestConnection performs a connection check from the device to the server.
|
|
func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "deviceId")
|
|
if deviceID == "" {
|
|
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
targetURL := r.URL.Query().Get("target_url")
|
|
if targetURL == "" {
|
|
http.Error(w, "Target URL is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
useExplicitCA := r.URL.Query().Get("use_explicit_ca") == "true"
|
|
|
|
output, err := s.sm.TestConnection(deviceIP, targetURL, useExplicitCA)
|
|
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": "Connection test successful",
|
|
"output": output,
|
|
}); encodeErr != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// HandleGetVersionInfo returns version information for the service.
|
|
func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) {
|
|
s.mu.RLock()
|
|
version := s.Version
|
|
commit := s.Commit
|
|
date := s.Date
|
|
repoURL := s.RepoURL
|
|
s.mu.RUnlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
var (
|
|
releaseURL string
|
|
commitURL string
|
|
)
|
|
|
|
if commit != "" && commit != "unknown" {
|
|
commitURL = fmt.Sprintf("%s/commit/%s", repoURL, commit)
|
|
}
|
|
|
|
// Release version: should point to the release, e.g. https://github.com/gesellix/Bose-SoundTouch/releases/tag/v0.58.0
|
|
// "dirty" versions don't get a release link (only the commit).
|
|
if version != "" && version != "dev" && version != "(devel)" && !strings.Contains(version, "dirty") {
|
|
releaseURL = fmt.Sprintf("%s/releases/tag/%s", repoURL, version)
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(map[string]string{
|
|
"version": version,
|
|
"commit": commit,
|
|
"date": date,
|
|
"repo_url": repoURL,
|
|
"release_url": releaseURL,
|
|
"commit_url": commitURL,
|
|
}); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleGetInteractionStats returns statistics about recorded interactions.
|
|
func (s *Server) HandleGetInteractionStats(w http.ResponseWriter, _ *http.Request) {
|
|
if s.recorder == nil {
|
|
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
stats, err := s.recorder.GetInteractionStats()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleListInteractions returns a list of recorded interactions.
|
|
func (s *Server) HandleListInteractions(w http.ResponseWriter, r *http.Request) {
|
|
if s.recorder == nil {
|
|
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
session := r.URL.Query().Get("session")
|
|
category := r.URL.Query().Get("category")
|
|
since := r.URL.Query().Get("since")
|
|
|
|
interactions, err := s.recorder.ListInteractions(session, category, since)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(interactions); err != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleGetInteractionContent returns the raw content of a recorded interaction.
|
|
func (s *Server) HandleGetInteractionContent(w http.ResponseWriter, r *http.Request) {
|
|
if s.recorder == nil {
|
|
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
file := r.URL.Query().Get("file")
|
|
if file == "" {
|
|
http.Error(w, "File parameter is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
content, err := s.recorder.GetInteractionContent(file)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write(content)
|
|
}
|
|
|
|
// HandleDeleteSession deletes a recorded interaction session.
|
|
func (s *Server) HandleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
|
if s.recorder == nil {
|
|
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
session := chi.URLParam(r, "session")
|
|
if session == "" {
|
|
http.Error(w, "Session ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := s.recorder.DeleteSession(session); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ok": true}`))
|
|
}
|
|
|
|
// HandleCleanupSessions deletes all but the most recent N sessions.
|
|
func (s *Server) HandleCleanupSessions(w http.ResponseWriter, r *http.Request) {
|
|
if s.recorder == nil {
|
|
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
keep := 10
|
|
|
|
keepStr := r.URL.Query().Get("keep")
|
|
if keepStr != "" {
|
|
if k, err := strconv.Atoi(keepStr); err == nil {
|
|
keep = k
|
|
}
|
|
}
|
|
|
|
if err := s.recorder.CleanupSessions(keep); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ok": true}`))
|
|
}
|
|
|
|
// HandleDownloadSession returns a .tar.gz archive of a recorded interaction session.
|
|
func (s *Server) HandleDownloadSession(w http.ResponseWriter, r *http.Request) {
|
|
if s.recorder == nil {
|
|
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
session := chi.URLParam(r, "session")
|
|
if session == "" {
|
|
http.Error(w, "Session ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.tar.gz\"", session))
|
|
|
|
if err := s.recorder.ArchiveSession(session, w); err != nil {
|
|
log.Printf("Error archiving session %s: %v", session, err)
|
|
// Since we already set headers, if we have an error here it might be partially written.
|
|
// But for now, simple error handling.
|
|
return
|
|
}
|
|
}
|
|
|
|
// HandleDeleteSource removes a source from a device's Sources.xml.
|
|
// DELETE /setup/sources/{account}/{device}/{sourceID}
|
|
func (s *Server) HandleDeleteSource(w http.ResponseWriter, r *http.Request) {
|
|
account := chi.URLParam(r, "account")
|
|
device := chi.URLParam(r, "device")
|
|
sourceID := chi.URLParam(r, "sourceID")
|
|
|
|
if account == "" || device == "" || sourceID == "" {
|
|
http.Error(w, "account, device, and sourceID are required", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
if err := s.ds.DeleteSourceByID(account, device, sourceID); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|