From 15b5b7f79279a10ecab193f4e9bf473fad0dccb8 Mon Sep 17 00:00:00 2001 From: benya Date: Tue, 21 Jul 2026 20:24:25 +0300 Subject: [PATCH] refactor: remove direct DNS mode --- .env.example | 2 - agent/README.md | 22 +- agent/go/cmd/rmm-agent/direct_dns.go | 150 ----------- agent/go/cmd/rmm-agent/main.go | 76 +----- agent/go/cmd/rmm-agent/main_test.go | 105 +------- agent/openwrt/rmm-agent-go.conf | 4 - agent/package/luci-app-rmm-agent/Makefile | 2 +- agent/package/luci-app-rmm-agent/README.md | 9 +- .../resources/view/services/rmm-agent.js | 36 --- .../package/rmm-agent-go-production/Makefile | 2 +- .../package/rmm-agent-go-production/README.md | 4 +- .../files/etc/config/rmm-agent | 4 - .../files/etc/rmm-agent.conf | 4 - .../files/usr/libexec/rmm-agent-uci-sync | 4 - agent/package/rmm-agent-go/Makefile | 2 +- .../rmm-agent-go/files/etc/rmm-agent-go.conf | 4 - agent/package/rmm-agent/Makefile | 2 +- .../rmm-agent/files/etc/config/rmm-agent | 4 - .../files/usr/libexec/rmm-agent-uci-sync | 4 - compose.yaml | 1 - docs/api.md | 54 ---- docs/keendns.md | 42 +--- docs/openwrt.md | 9 - server/README.md | 5 - server/cmd/rmm-server/main.go | 8 - server/internal/httpapi/dns.go | 238 ------------------ server/internal/httpapi/security_test.go | 91 +------ server/internal/httpapi/server.go | 26 -- server/internal/model/model.go | 22 -- server/internal/store/dns.go | 222 ---------------- server/internal/store/security.go | 3 - server/internal/store/sqlite.go | 9 - web/app.js | 2 +- 33 files changed, 24 insertions(+), 1148 deletions(-) delete mode 100644 agent/go/cmd/rmm-agent/direct_dns.go delete mode 100644 server/internal/httpapi/dns.go delete mode 100644 server/internal/store/dns.go diff --git a/.env.example b/.env.example index 6969fac..f8a1837 100644 --- a/.env.example +++ b/.env.example @@ -43,8 +43,6 @@ RMM_TUNNEL_KEY_PATH=./secrets/router_tunnel_key.pub # Optional HTTPS reverse proxy overlay. RMM_DOMAIN=rmm.example.com RMM_DEVICE_DOMAIN=routers.example.com -# Optional bearer token for an authoritative DNS synchronizer. Keep it secret. -RMM_DNS_SYNC_TOKEN= RMM_PROXY_HTTP_PORT=80 RMM_PROXY_HTTPS_PORT=443 diff --git a/agent/README.md b/agent/README.md index 4ee789f..33b077c 100644 --- a/agent/README.md +++ b/agent/README.md @@ -1,8 +1,7 @@ # OpenWrt RMM Agent -Current stable Go agent: `0.6.0`. It reports runtime health, pending command results, -the last heartbeat transport error after connectivity is restored, and optionally keeps -the router's direct DNS addresses current. +Current stable Go agent: `0.6.1`. It reports runtime health, pending command results, +and the last heartbeat transport error after connectivity is restored. Production Go agent for OpenWrt, with the shell implementation retained as a fallback runtime. @@ -29,10 +28,6 @@ SERVER_URL="https://rmm.example.com" ENROLLMENT_TOKEN="paste-a-one-time-grant-from-your-account" INTERVAL_SECONDS="30" TUNNEL_IDENTITY_FILE="/etc/rmm-agent/tunnel_key" -DIRECT_DNS_ENABLED="false" -DNS_UPDATE_INTERVAL_SECONDS="300" -PUBLIC_IPV4_URL="https://api.ipify.org" -PUBLIC_IPV6_URL="https://api6.ipify.org" ``` After enrollment the agent writes: @@ -84,19 +79,6 @@ The Go agent is available as the production OpenWrt package and supports the mig Other queued commands are reported as failed with a clear message. -### Direct DNS updates - -Direct DNS reporting is opt-in. When enabled, the agent queries the configured HTTPS -endpoints in parallel, validates that their responses are public IPv4/IPv6 addresses, and -sends the complete address state through `/api/agent/dns/update` using its existing device -token. A successful update is refreshed every five minutes by default; failures retry after -one minute and do not make the main heartbeat unhealthy. - -The default discovery endpoints are operated by ipify. Clear the IPv6 URL on routers without -public IPv6, or replace either endpoint with trusted HTTPS services under your control. The -agent refuses HTTP URLs, embedded URL credentials, redirects, oversized responses, and -private or reserved addresses. - Build locally: ```sh diff --git a/agent/go/cmd/rmm-agent/direct_dns.go b/agent/go/cmd/rmm-agent/direct_dns.go deleted file mode 100644 index 18cc0e6..0000000 --- a/agent/go/cmd/rmm-agent/direct_dns.go +++ /dev/null @@ -1,150 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/netip" - "net/url" - "strings" - "time" -) - -var nonPublicAddressPrefixes = []netip.Prefix{ - netip.MustParsePrefix("100.64.0.0/10"), - netip.MustParsePrefix("192.0.0.0/24"), - netip.MustParsePrefix("192.0.2.0/24"), - netip.MustParsePrefix("198.18.0.0/15"), - netip.MustParsePrefix("198.51.100.0/24"), - netip.MustParsePrefix("203.0.113.0/24"), - netip.MustParsePrefix("240.0.0.0/4"), - netip.MustParsePrefix("2001:db8::/32"), -} - -type directDNSUpdateState struct { - nextAttempt time.Time -} - -func (s directDNSUpdateState) due(now time.Time) bool { - return s.nextAttempt.IsZero() || !now.Before(s.nextAttempt) -} - -func (s *directDNSUpdateState) schedule(now time.Time, interval time.Duration, updateErr error) { - if interval < time.Minute { - interval = 5 * time.Minute - } - if updateErr != nil && interval > time.Minute { - interval = time.Minute - } - s.nextAttempt = now.Add(interval) -} - -func updateDirectDNS(ctx context.Context, client *http.Client, cfg config) error { - discoveryContext, cancel := context.WithTimeout(ctx, 12*time.Second) - defer cancel() - - type result struct { - address string - ipv4 bool - err error - } - results := make(chan result, 2) - requests := 0 - for _, request := range []struct { - endpoint string - ipv4 bool - }{{cfg.PublicIPv4URL, true}, {cfg.PublicIPv6URL, false}} { - if strings.TrimSpace(request.endpoint) == "" { - continue - } - requests++ - go func(endpoint string, ipv4 bool) { - address, err := fetchPublicIPAddress(discoveryContext, client, endpoint, ipv4) - results <- result{address: address, ipv4: ipv4, err: err} - }(request.endpoint, request.ipv4) - } - - var ipv4, ipv6 string - var discoveryErrors []string - for range requests { - result := <-results - if result.err != nil { - discoveryErrors = append(discoveryErrors, result.err.Error()) - continue - } - if result.ipv4 { - ipv4 = result.address - } else { - ipv6 = result.address - } - } - if ipv4 == "" && ipv6 == "" { - if len(discoveryErrors) == 0 { - return errors.New("no public IP discovery service is configured") - } - return fmt.Errorf("public IP discovery failed: %s", strings.Join(discoveryErrors, "; ")) - } - - body := map[string]string{ - "device_id": cfg.DeviceID, - "ipv4": ipv4, - "ipv6": ipv6, - } - return postJSON(ctx, client, cfg.ServerURL+"/api/agent/dns/update", cfg.DeviceToken, body, nil) -} - -func fetchPublicIPAddress(ctx context.Context, client *http.Client, endpoint string, wantIPv4 bool) (string, error) { - parsed, err := url.Parse(strings.TrimSpace(endpoint)) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { - return "", errors.New("public IP discovery URL must use HTTPS without credentials") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) - if err != nil { - return "", err - } - request.Header.Set("Accept", "text/plain") - discoveryClient := *client - discoveryClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - } - response, err := discoveryClient.Do(request) - if err != nil { - return "", err - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) - return "", fmt.Errorf("public IP discovery returned HTTP %d", response.StatusCode) - } - data, err := io.ReadAll(io.LimitReader(response.Body, 129)) - if err != nil { - return "", err - } - if len(data) > 128 { - return "", errors.New("public IP discovery response is too large") - } - address, err := normalizePublicAddress(string(data), wantIPv4) - if err != nil { - return "", err - } - return address, nil -} - -func normalizePublicAddress(raw string, wantIPv4 bool) (string, error) { - address, err := netip.ParseAddr(strings.TrimSpace(raw)) - if err != nil { - return "", errors.New("public IP discovery returned an invalid address") - } - address = address.Unmap() - if address.Is4() != wantIPv4 || !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsMulticast() || address.IsUnspecified() { - return "", errors.New("public IP discovery returned a non-public address") - } - for _, prefix := range nonPublicAddressPrefixes { - if prefix.Contains(address) { - return "", errors.New("public IP discovery returned a reserved address") - } - } - return address.String(), nil -} diff --git a/agent/go/cmd/rmm-agent/main.go b/agent/go/cmd/rmm-agent/main.go index 0d766fb..18e4d3b 100644 --- a/agent/go/cmd/rmm-agent/main.go +++ b/agent/go/cmd/rmm-agent/main.go @@ -23,7 +23,7 @@ import ( "time" ) -const agentVersion = "0.6.0" +const agentVersion = "0.6.1" type agentRuntimeHealth struct { StartedAt time.Time @@ -31,9 +31,6 @@ type agentRuntimeHealth struct { LastHeartbeatError string LastHeartbeatErrorAt time.Time LastHeartbeatSuccess time.Time - LastDNSUpdateError string - LastDNSUpdateErrorAt time.Time - LastDNSUpdateSuccess time.Time } func (h *agentRuntimeHealth) recordFailure(err error) { @@ -50,20 +47,6 @@ func (h *agentRuntimeHealth) recordSuccess() { h.LastHeartbeatSuccess = time.Now().UTC() } -func (h *agentRuntimeHealth) recordDirectDNSUpdate(err error) { - if err == nil { - h.LastDNSUpdateSuccess = time.Now().UTC() - h.LastDNSUpdateError = "" - h.LastDNSUpdateErrorAt = time.Time{} - return - } - h.LastDNSUpdateError = strings.TrimSpace(err.Error()) - if len(h.LastDNSUpdateError) > 512 { - h.LastDNSUpdateError = h.LastDNSUpdateError[:512] - } - h.LastDNSUpdateErrorAt = time.Now().UTC() -} - func (h *agentRuntimeHealth) snapshot(spoolDir string) map[string]any { result := map[string]any{ "started_at": h.StartedAt.UTC().Format(time.RFC3339), @@ -77,13 +60,6 @@ func (h *agentRuntimeHealth) snapshot(spoolDir string) map[string]any { result["last_heartbeat_error"] = h.LastHeartbeatError result["last_heartbeat_error_at"] = h.LastHeartbeatErrorAt.UTC().Format(time.RFC3339) } - if !h.LastDNSUpdateSuccess.IsZero() { - result["last_dns_update_success_at"] = h.LastDNSUpdateSuccess.UTC().Format(time.RFC3339) - } - if h.LastDNSUpdateError != "" { - result["last_dns_update_error"] = h.LastDNSUpdateError - result["last_dns_update_error_at"] = h.LastDNSUpdateErrorAt.UTC().Format(time.RFC3339) - } return result } @@ -101,10 +77,6 @@ type config struct { CheckTargets []string HostnameOverride string HostnameSuffix string - DirectDNSEnabled bool - DNSUpdateSeconds int - PublicIPv4URL string - PublicIPv6URL string ConfigFile string } @@ -166,7 +138,6 @@ func main() { backoff := time.Duration(cfg.IntervalSeconds) * time.Second health := &agentRuntimeHealth{StartedAt: time.Now().UTC()} - dnsState := directDNSUpdateState{} for { if err := heartbeatOnce(ctx, client, cfg, health.snapshot(cfg.SpoolDir)); err != nil { health.recordFailure(err) @@ -178,14 +149,6 @@ func main() { } else { health.recordSuccess() backoff = time.Duration(cfg.IntervalSeconds) * time.Second - if cfg.DirectDNSEnabled && dnsState.due(time.Now()) { - err := updateDirectDNS(ctx, client, cfg) - health.recordDirectDNSUpdate(err) - dnsState.schedule(time.Now(), time.Duration(cfg.DNSUpdateSeconds)*time.Second, err) - if err != nil { - logf("direct DNS update warning: %v", err) - } - } } if *once { return @@ -213,10 +176,6 @@ func loadConfig(path string) (config, error) { CheckTargets: splitWords(envDefault("CHECK_TARGETS", "1.1.1.1 8.8.8.8")), HostnameOverride: os.Getenv("HOSTNAME_OVERRIDE"), HostnameSuffix: os.Getenv("HOSTNAME_SUFFIX"), - DirectDNSEnabled: boolDefault(os.Getenv("DIRECT_DNS_ENABLED"), false), - DNSUpdateSeconds: intDefault(os.Getenv("DNS_UPDATE_INTERVAL_SECONDS"), 300), - PublicIPv4URL: envDefault("PUBLIC_IPV4_URL", "https://api.ipify.org"), - PublicIPv6URL: envDefault("PUBLIC_IPV6_URL", "https://api6.ipify.org"), ConfigFile: path, } data, err := os.ReadFile(path) @@ -266,28 +225,10 @@ func loadConfig(path string) (config, error) { if value := values["HOSTNAME_SUFFIX"]; value != "" { cfg.HostnameSuffix = value } - if value, ok := values["DIRECT_DNS_ENABLED"]; ok { - cfg.DirectDNSEnabled = boolDefault(value, cfg.DirectDNSEnabled) - } - if value := values["DNS_UPDATE_INTERVAL_SECONDS"]; value != "" { - cfg.DNSUpdateSeconds = intDefault(value, cfg.DNSUpdateSeconds) - } - if value, ok := values["PUBLIC_IPV4_URL"]; ok { - cfg.PublicIPv4URL = strings.TrimSpace(value) - } - if value, ok := values["PUBLIC_IPV6_URL"]; ok { - cfg.PublicIPv6URL = strings.TrimSpace(value) - } cfg.ServerURL = strings.TrimRight(cfg.ServerURL, "/") if cfg.IntervalSeconds <= 0 { cfg.IntervalSeconds = 30 } - if cfg.DNSUpdateSeconds < 60 || cfg.DNSUpdateSeconds > 86400 { - cfg.DNSUpdateSeconds = 300 - } - if cfg.DirectDNSEnabled && cfg.PublicIPv4URL == "" && cfg.PublicIPv6URL == "" { - return cfg, errors.New("direct DNS requires at least one public IP discovery URL") - } return cfg, nil } @@ -318,10 +259,6 @@ func saveConfig(cfg config) error { writeConfigLine(&b, "BACKUP_DIR", cfg.BackupDir) writeConfigLine(&b, "TUNNEL_IDENTITY_FILE", cfg.TunnelIdentity) writeConfigLine(&b, "TUNNEL_STATE_DIR", cfg.TunnelStateDir) - writeConfigLine(&b, "DIRECT_DNS_ENABLED", strconv.FormatBool(cfg.DirectDNSEnabled)) - writeConfigLine(&b, "DNS_UPDATE_INTERVAL_SECONDS", strconv.Itoa(cfg.DNSUpdateSeconds)) - writeConfigLine(&b, "PUBLIC_IPV4_URL", cfg.PublicIPv4URL) - writeConfigLine(&b, "PUBLIC_IPV6_URL", cfg.PublicIPv6URL) if cfg.HostnameOverride != "" { writeConfigLine(&b, "HOSTNAME_OVERRIDE", cfg.HostnameOverride) } @@ -1801,17 +1738,6 @@ func intDefault(value string, fallback int) int { return parsed } -func boolDefault(value string, fallback bool) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "1", "true", "yes", "on": - return true - case "0", "false", "no", "off": - return false - default: - return fallback - } -} - func splitWords(value string) []string { fields := strings.Fields(value) if len(fields) == 0 { diff --git a/agent/go/cmd/rmm-agent/main_test.go b/agent/go/cmd/rmm-agent/main_test.go index 416aedb..75eec0d 100644 --- a/agent/go/cmd/rmm-agent/main_test.go +++ b/agent/go/cmd/rmm-agent/main_test.go @@ -1,11 +1,7 @@ package main import ( - "context" - "encoding/json" "errors" - "net/http" - "net/http/httptest" "os" "path/filepath" "testing" @@ -13,102 +9,11 @@ import ( ) func TestAgentVersionIsStable(t *testing.T) { - if agentVersion != "0.6.0" { + if agentVersion != "0.6.1" { t.Fatalf("unexpected agent version %q", agentVersion) } } -func TestLoadConfigDirectDNS(t *testing.T) { - path := filepath.Join(t.TempDir(), "rmm-agent.conf") - data := `SERVER_URL="https://rmm.example.test" -DIRECT_DNS_ENABLED="1" -DNS_UPDATE_INTERVAL_SECONDS="600" -PUBLIC_IPV4_URL="https://ipv4.example.test" -PUBLIC_IPV6_URL="" -` - if err := os.WriteFile(path, []byte(data), 0o600); err != nil { - t.Fatal(err) - } - cfg, err := loadConfig(path) - if err != nil { - t.Fatal(err) - } - if !cfg.DirectDNSEnabled || cfg.DNSUpdateSeconds != 600 || cfg.PublicIPv4URL != "https://ipv4.example.test" || cfg.PublicIPv6URL != "" { - t.Fatalf("unexpected direct DNS config: %#v", cfg) - } -} - -func TestUpdateDirectDNS(t *testing.T) { - updates := make(chan map[string]string, 1) - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v4": - _, _ = w.Write([]byte("8.8.8.8\n")) - case "/v6": - _, _ = w.Write([]byte("2606:4700:4700::1111\n")) - case "/api/agent/dns/update": - if r.Header.Get("Authorization") != "Bearer device-secret" { - t.Errorf("unexpected authorization header: %q", r.Header.Get("Authorization")) - } - var request map[string]string - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - t.Errorf("decode update: %v", err) - } - updates <- request - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"changed":true}`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - cfg := config{ - ServerURL: server.URL, - DeviceID: "dev_test", - DeviceToken: "device-secret", - PublicIPv4URL: server.URL + "/v4", - PublicIPv6URL: server.URL + "/v6", - } - if err := updateDirectDNS(context.Background(), server.Client(), cfg); err != nil { - t.Fatal(err) - } - request := <-updates - if request["device_id"] != "dev_test" || request["ipv4"] != "8.8.8.8" || request["ipv6"] != "2606:4700:4700::1111" { - t.Fatalf("unexpected DNS update: %#v", request) - } -} - -func TestPublicIPAddressValidation(t *testing.T) { - if _, err := normalizePublicAddress("192.168.1.1", true); err == nil { - t.Fatal("private IPv4 address was accepted") - } - if _, err := normalizePublicAddress("2001:db8::1", false); err == nil { - t.Fatal("documentation IPv6 address was accepted") - } - if got, err := normalizePublicAddress("8.8.8.8", true); err != nil || got != "8.8.8.8" { - t.Fatalf("public IPv4 address rejected: got=%q err=%v", got, err) - } - if _, err := fetchPublicIPAddress(context.Background(), &http.Client{}, "http://example.test", true); err == nil { - t.Fatal("insecure discovery URL was accepted") - } -} - -func TestDirectDNSUpdateSchedule(t *testing.T) { - now := time.Unix(1000, 0) - state := directDNSUpdateState{} - if !state.due(now) { - t.Fatal("initial direct DNS update is not due") - } - state.schedule(now, 5*time.Minute, nil) - if state.due(now.Add(4*time.Minute)) || !state.due(now.Add(5*time.Minute)) { - t.Fatal("successful direct DNS interval was not respected") - } - state.schedule(now, 5*time.Minute, errors.New("temporary failure")) - if state.due(now.Add(59*time.Second)) || !state.due(now.Add(time.Minute)) { - t.Fatal("failed direct DNS update retry interval was not respected") - } -} - func TestAgentRuntimeHealthSnapshot(t *testing.T) { spoolDir := t.TempDir() if err := os.WriteFile(filepath.Join(spoolDir, "pending.json"), []byte(`{}`), 0o600); err != nil { @@ -127,14 +32,6 @@ func TestAgentRuntimeHealthSnapshot(t *testing.T) { if health.ConsecutiveFailures != 0 || health.LastHeartbeatSuccess.IsZero() { t.Fatalf("recordSuccess did not reset runtime state: %#v", health) } - health.recordDirectDNSUpdate(errors.New("discovery unavailable")) - if health.snapshot(spoolDir)["last_dns_update_error"] != "discovery unavailable" { - t.Fatalf("unexpected direct DNS health: %#v", health.snapshot(spoolDir)) - } - health.recordDirectDNSUpdate(nil) - if health.LastDNSUpdateSuccess.IsZero() || health.LastDNSUpdateError != "" { - t.Fatalf("direct DNS recovery was not recorded: %#v", health) - } } func TestAcquireLockRemovesDirectoryOnUnlock(t *testing.T) { diff --git a/agent/openwrt/rmm-agent-go.conf b/agent/openwrt/rmm-agent-go.conf index 545a614..f12dec2 100644 --- a/agent/openwrt/rmm-agent-go.conf +++ b/agent/openwrt/rmm-agent-go.conf @@ -2,10 +2,6 @@ SERVER_URL="https://rmm.example.com" ENROLLMENT_TOKEN="" INTERVAL_SECONDS="30" CHECK_TARGETS="1.1.1.1 8.8.8.8" -DIRECT_DNS_ENABLED="false" -DNS_UPDATE_INTERVAL_SECONDS="300" -PUBLIC_IPV4_URL="https://api.ipify.org" -PUBLIC_IPV6_URL="https://api6.ipify.org" TUNNEL_IDENTITY_FILE="/etc/rmm-agent/tunnel_key" LOCK_FILE="/tmp/rmm-agent-go.lock" SPOOL_DIR="/tmp/rmm-agent-go-results" diff --git a/agent/package/luci-app-rmm-agent/Makefile b/agent/package/luci-app-rmm-agent/Makefile index da75d9e..fbd0494 100644 --- a/agent/package/luci-app-rmm-agent/Makefile +++ b/agent/package/luci-app-rmm-agent/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-rmm-agent -PKG_VERSION:=0.2.0 +PKG_VERSION:=0.2.1 PKG_RELEASE:=1 PKG_MAINTAINER:=RMM OpenWrt diff --git a/agent/package/luci-app-rmm-agent/README.md b/agent/package/luci-app-rmm-agent/README.md index 03b7be2..9fe13c8 100644 --- a/agent/package/luci-app-rmm-agent/README.md +++ b/agent/package/luci-app-rmm-agent/README.md @@ -8,7 +8,6 @@ It provides: - HTTPS server URL and polling interval; - one-time enrollment grant input; - connectivity targets and tunnel identity path; -- opt-in public IPv4/IPv6 discovery and direct DNS update interval; - explicit lab-only HTTP override; - controlled device identity reset for re-enrollment. @@ -68,8 +67,8 @@ application to the router. Do not copy or install the shell runtime at the same cd dist/rmm-openwrt-25.12.4-ramips-mt7621 sha256sum -c SHA256SUMS scp \ - rmm-agent-go-production-0.6.0-r1.apk \ - luci-app-rmm-agent-0.2.0-r1.apk \ + rmm-agent-go-production-0.6.1-r1.apk \ + luci-app-rmm-agent-0.2.1-r1.apk \ root@ROUTER_IP:/tmp/ ``` @@ -77,8 +76,8 @@ Then install the locally built, unsigned packages over SSH: ```sh apk add --allow-untrusted \ - /tmp/rmm-agent-go-production-0.6.0-r1.apk \ - /tmp/luci-app-rmm-agent-0.2.0-r1.apk + /tmp/rmm-agent-go-production-0.6.1-r1.apk \ + /tmp/luci-app-rmm-agent-0.2.1-r1.apk /etc/init.d/rpcd restart /etc/init.d/uhttpd restart ``` diff --git a/agent/package/luci-app-rmm-agent/htdocs/luci-static/resources/view/services/rmm-agent.js b/agent/package/luci-app-rmm-agent/htdocs/luci-static/resources/view/services/rmm-agent.js index d4dfba6..b780b72 100644 --- a/agent/package/luci-app-rmm-agent/htdocs/luci-static/resources/view/services/rmm-agent.js +++ b/agent/package/luci-app-rmm-agent/htdocs/luci-static/resources/view/services/rmm-agent.js @@ -18,12 +18,6 @@ var callRcInit = rpc.declare({ params: [ 'name', 'action' ] }); -function validateDiscoveryURL(sectionId, value) { - if (!value || /^https:\/\/[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/.*)?$/.test(value)) - return true; - return _('Public IP discovery must use an HTTPS URL without embedded credentials.'); -} - return view.extend({ load: function() { return Promise.all([ uci.load('rmm-agent'), callServiceList('rmm-agent') ]); @@ -76,36 +70,6 @@ return view.extend({ option.placeholder = '1.1.1.1 8.8.8.8'; option.rmempty = false; - section = map.section(form.NamedSection, 'main', 'agent', _('Direct DNS')); - section.anonymous = true; - section.addremove = false; - - option = section.option(form.Flag, 'direct_dns_enabled', _('Update public DNS addresses')); - option.default = '0'; - option.rmempty = false; - option.description = _('Opt-in feature for Go agent 0.6.0 or newer. The router periodically reports its public addresses to RMM.'); - - option = section.option(form.Value, 'dns_update_interval_seconds', _('Update interval')); - option.datatype = 'range(60,86400)'; - option.default = '300'; - option.rmempty = false; - option.depends('direct_dns_enabled', '1'); - - option = section.option(form.Value, 'public_ipv4_url', _('Public IPv4 discovery URL')); - option.placeholder = 'https://api.ipify.org'; - option.default = 'https://api.ipify.org'; - option.rmempty = false; - option.depends('direct_dns_enabled', '1'); - option.validate = validateDiscoveryURL; - - option = section.option(form.Value, 'public_ipv6_url', _('Public IPv6 discovery URL')); - option.placeholder = 'https://api6.ipify.org'; - option.default = 'https://api6.ipify.org'; - option.rmempty = true; - option.description = _('Clear this field on routers without public IPv6.'); - option.depends('direct_dns_enabled', '1'); - option.validate = validateDiscoveryURL; - section = map.section(form.NamedSection, 'main', 'agent', _('Advanced settings')); section.anonymous = true; section.addremove = false; diff --git a/agent/package/rmm-agent-go-production/Makefile b/agent/package/rmm-agent-go-production/Makefile index 407a7cf..51bd7aa 100644 --- a/agent/package/rmm-agent-go-production/Makefile +++ b/agent/package/rmm-agent-go-production/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=rmm-agent-go-production -PKG_VERSION:=0.6.0 +PKG_VERSION:=0.6.1 PKG_RELEASE:=1 PKG_MAINTAINER:=RMM OpenWrt diff --git a/agent/package/rmm-agent-go-production/README.md b/agent/package/rmm-agent-go-production/README.md index c3b4457..7c3939e 100644 --- a/agent/package/rmm-agent-go-production/README.md +++ b/agent/package/rmm-agent-go-production/README.md @@ -34,5 +34,5 @@ opkg install /tmp/rmm-agent-go-production_*.ipk This package is intended for the final shell-to-Go migration when the router should keep the same RMM object identity. -Version `0.6.0` supports opt-in direct DNS updates through the UCI/LuCI settings supplied -by `luci-app-rmm-agent` `0.2.0`. +Version `0.6.1` uses the cloud tunnel exclusively and no longer discovers or publishes the +router's public WAN addresses. diff --git a/agent/package/rmm-agent-go-production/files/etc/config/rmm-agent b/agent/package/rmm-agent-go-production/files/etc/config/rmm-agent index 6d9df5e..10f73d9 100644 --- a/agent/package/rmm-agent-go-production/files/etc/config/rmm-agent +++ b/agent/package/rmm-agent-go-production/files/etc/config/rmm-agent @@ -3,10 +3,6 @@ config agent 'main' option server_url 'https://rmm.example.com' option interval_seconds '30' option check_targets '1.1.1.1 8.8.8.8' - option direct_dns_enabled '0' - option dns_update_interval_seconds '300' - option public_ipv4_url 'https://api.ipify.org' - option public_ipv6_url 'https://api6.ipify.org' option tunnel_identity_file '/etc/rmm-agent/tunnel_key' option allow_insecure_http '0' option reset_identity '0' diff --git a/agent/package/rmm-agent-go-production/files/etc/rmm-agent.conf b/agent/package/rmm-agent-go-production/files/etc/rmm-agent.conf index 3a4b3eb..351cd46 100644 --- a/agent/package/rmm-agent-go-production/files/etc/rmm-agent.conf +++ b/agent/package/rmm-agent-go-production/files/etc/rmm-agent.conf @@ -2,10 +2,6 @@ SERVER_URL="https://rmm.example.com" ENROLLMENT_TOKEN="" INTERVAL_SECONDS="30" CHECK_TARGETS="1.1.1.1 8.8.8.8" -DIRECT_DNS_ENABLED="false" -DNS_UPDATE_INTERVAL_SECONDS="300" -PUBLIC_IPV4_URL="https://api.ipify.org" -PUBLIC_IPV6_URL="https://api6.ipify.org" TUNNEL_IDENTITY_FILE="/etc/rmm-agent/tunnel_key" DEVICE_ID="" DEVICE_TOKEN="" diff --git a/agent/package/rmm-agent-go-production/files/usr/libexec/rmm-agent-uci-sync b/agent/package/rmm-agent-go-production/files/usr/libexec/rmm-agent-uci-sync index 1fc60dc..355cecf 100644 --- a/agent/package/rmm-agent-go-production/files/usr/libexec/rmm-agent-uci-sync +++ b/agent/package/rmm-agent-go-production/files/usr/libexec/rmm-agent-uci-sync @@ -60,10 +60,6 @@ temporary="${SHELL_CONFIG}.tmp" printf 'ENROLLMENT_TOKEN="%s"\n' "$(escape_value "$enrollment_token")" printf 'INTERVAL_SECONDS="%s"\n' "$(escape_value "$(uci_get interval_seconds)")" printf 'CHECK_TARGETS="%s"\n' "$(escape_value "$(uci_get check_targets)")" - printf 'DIRECT_DNS_ENABLED="%s"\n' "$(escape_value "$(uci_get direct_dns_enabled)")" - printf 'DNS_UPDATE_INTERVAL_SECONDS="%s"\n' "$(escape_value "$(uci_get dns_update_interval_seconds)")" - printf 'PUBLIC_IPV4_URL="%s"\n' "$(escape_value "$(uci_get public_ipv4_url)")" - printf 'PUBLIC_IPV6_URL="%s"\n' "$(escape_value "$(uci_get public_ipv6_url)")" printf 'TUNNEL_IDENTITY_FILE="%s"\n' "$(escape_value "$(uci_get tunnel_identity_file)")" printf 'DEVICE_ID="%s"\n' "$(escape_value "$device_id")" printf 'DEVICE_TOKEN="%s"\n' "$(escape_value "$device_token")" diff --git a/agent/package/rmm-agent-go/Makefile b/agent/package/rmm-agent-go/Makefile index 08c99be..8b82e62 100644 --- a/agent/package/rmm-agent-go/Makefile +++ b/agent/package/rmm-agent-go/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=rmm-agent-go -PKG_VERSION:=0.6.0 +PKG_VERSION:=0.6.1 PKG_RELEASE:=1 PKG_MAINTAINER:=RMM OpenWrt diff --git a/agent/package/rmm-agent-go/files/etc/rmm-agent-go.conf b/agent/package/rmm-agent-go/files/etc/rmm-agent-go.conf index 545a614..f12dec2 100644 --- a/agent/package/rmm-agent-go/files/etc/rmm-agent-go.conf +++ b/agent/package/rmm-agent-go/files/etc/rmm-agent-go.conf @@ -2,10 +2,6 @@ SERVER_URL="https://rmm.example.com" ENROLLMENT_TOKEN="" INTERVAL_SECONDS="30" CHECK_TARGETS="1.1.1.1 8.8.8.8" -DIRECT_DNS_ENABLED="false" -DNS_UPDATE_INTERVAL_SECONDS="300" -PUBLIC_IPV4_URL="https://api.ipify.org" -PUBLIC_IPV6_URL="https://api6.ipify.org" TUNNEL_IDENTITY_FILE="/etc/rmm-agent/tunnel_key" LOCK_FILE="/tmp/rmm-agent-go.lock" SPOOL_DIR="/tmp/rmm-agent-go-results" diff --git a/agent/package/rmm-agent/Makefile b/agent/package/rmm-agent/Makefile index b2f1ead..679f434 100644 --- a/agent/package/rmm-agent/Makefile +++ b/agent/package/rmm-agent/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=rmm-agent -PKG_VERSION:=0.1.0 +PKG_VERSION:=0.1.1 PKG_RELEASE:=2 PKG_MAINTAINER:=RMM OpenWrt diff --git a/agent/package/rmm-agent/files/etc/config/rmm-agent b/agent/package/rmm-agent/files/etc/config/rmm-agent index 6d9df5e..10f73d9 100644 --- a/agent/package/rmm-agent/files/etc/config/rmm-agent +++ b/agent/package/rmm-agent/files/etc/config/rmm-agent @@ -3,10 +3,6 @@ config agent 'main' option server_url 'https://rmm.example.com' option interval_seconds '30' option check_targets '1.1.1.1 8.8.8.8' - option direct_dns_enabled '0' - option dns_update_interval_seconds '300' - option public_ipv4_url 'https://api.ipify.org' - option public_ipv6_url 'https://api6.ipify.org' option tunnel_identity_file '/etc/rmm-agent/tunnel_key' option allow_insecure_http '0' option reset_identity '0' diff --git a/agent/package/rmm-agent/files/usr/libexec/rmm-agent-uci-sync b/agent/package/rmm-agent/files/usr/libexec/rmm-agent-uci-sync index 1fc60dc..355cecf 100644 --- a/agent/package/rmm-agent/files/usr/libexec/rmm-agent-uci-sync +++ b/agent/package/rmm-agent/files/usr/libexec/rmm-agent-uci-sync @@ -60,10 +60,6 @@ temporary="${SHELL_CONFIG}.tmp" printf 'ENROLLMENT_TOKEN="%s"\n' "$(escape_value "$enrollment_token")" printf 'INTERVAL_SECONDS="%s"\n' "$(escape_value "$(uci_get interval_seconds)")" printf 'CHECK_TARGETS="%s"\n' "$(escape_value "$(uci_get check_targets)")" - printf 'DIRECT_DNS_ENABLED="%s"\n' "$(escape_value "$(uci_get direct_dns_enabled)")" - printf 'DNS_UPDATE_INTERVAL_SECONDS="%s"\n' "$(escape_value "$(uci_get dns_update_interval_seconds)")" - printf 'PUBLIC_IPV4_URL="%s"\n' "$(escape_value "$(uci_get public_ipv4_url)")" - printf 'PUBLIC_IPV6_URL="%s"\n' "$(escape_value "$(uci_get public_ipv6_url)")" printf 'TUNNEL_IDENTITY_FILE="%s"\n' "$(escape_value "$(uci_get tunnel_identity_file)")" printf 'DEVICE_ID="%s"\n' "$(escape_value "$device_id")" printf 'DEVICE_TOKEN="%s"\n' "$(escape_value "$device_token")" diff --git a/compose.yaml b/compose.yaml index 6d432cb..dde1819 100644 --- a/compose.yaml +++ b/compose.yaml @@ -19,7 +19,6 @@ services: RMM_TUNNEL_PUBLIC_HOST: "${RMM_TUNNEL_PUBLIC_HOST:-}" RMM_TUNNEL_PUBLIC_PORT: "${RMM_TUNNEL_PUBLIC_PORT:-2222}" RMM_DEVICE_DOMAIN: "${RMM_DEVICE_DOMAIN:-}" - RMM_DNS_SYNC_TOKEN: "${RMM_DNS_SYNC_TOKEN:-}" RMM_PUBLIC_SCHEME: "${RMM_PUBLIC_SCHEME:-https}" RMM_PUBLIC_URL: "${RMM_PUBLIC_URL:-}" RMM_METRIC_RETENTION_DAYS: "${RMM_METRIC_RETENTION_DAYS:-30}" diff --git a/docs/api.md b/docs/api.md index e852acd..48c5c5f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -155,60 +155,6 @@ Response: } ``` -## Agent Direct DNS Update - -The agent may update only the device identified by its own bearer token. At least one -public address is required; private and reserved ranges are rejected. The endpoint allows -30 authenticated attempts per device per minute. - -```http -POST /api/agent/dns/update -Authorization: Bearer tok_... -Content-Type: application/json - -{ - "device_id": "dev_...", - "ipv4": "8.8.8.8", - "ipv6": "2606:4700:4700::1111" -} -``` - -The update is the complete desired address state: omitting one address family clears its -previous value. Use the router's actual public addresses in a real request. - -## Device DNS Records - -Authenticated users can access records only for routers in their account; administrators -can access all records: - -```http -GET /api/dns/records -GET /api/devices/{device_id}/dns -GET /api/devices/{device_id}/dns/history?limit=25 -PATCH /api/devices/{device_id}/dns -``` - -The settings request accepts any combination of these fields: - -```json -{ - "dns_label": "office-1", - "ttl": 60, - "enabled": true -} -``` - -`dns_label` is globally unique and `ttl` must be between 30 and 86400 seconds. Transferring -a router clears its address history and disables direct DNS until the new owner enables it. - -An external authoritative DNS synchronizer can fetch publishable records when -`RMM_DNS_SYNC_TOKEN` is configured: - -```http -GET /api/internal/dns/records -Authorization: Bearer -``` - ## Agent Next Command This endpoint is optimized for the MVP shell agent and returns at most one queued command as plain text. diff --git a/docs/keendns.md b/docs/keendns.md index 6352ee6..f2869c7 100644 --- a/docs/keendns.md +++ b/docs/keendns.md @@ -60,41 +60,9 @@ remain available only under the expert settings. Do not enable `RMM_ALLOW_LEGACY_LUCI_PROXY` in production. It exists only for migration from the old same-origin `/luci/...` route. -## Direct DNS mode backend +## Cloud-only addressing -The server now keeps a separate direct-DNS record for every enrolled router: unique DNS -label, public IPv4/IPv6 addresses, TTL, enabled state, last agent update, and the latest -100 address changes. Existing devices receive a record automatically during database -migration. A transfer to another account clears the addresses and history and disables -publishing, so the previous owner's WAN data is not exposed. - -Only the router's own device token can update its addresses. Private, loopback, -link-local, documentation, benchmark, CGNAT, multicast, and otherwise non-public -addresses are rejected. Updates are limited to 30 authenticated attempts per router per -minute. Browser APIs retain the normal per-user device isolation and record all settings -changes in the audit log. - -An authoritative DNS synchronizer can read enabled records through a separate bearer -credential. Generate a random secret of at least 32 characters and keep it outside Git: - -```text -RMM_DNS_SYNC_TOKEN= -``` - -```http -GET /api/internal/dns/records -Authorization: Bearer -``` - -When the variable is unset, this endpoint responds with `404`. Its response has -`Cache-Control: no-store` and contains only enabled records that have at least one public -address. - -Go agent `0.6.0` adds opt-in public-address discovery and periodic authenticated updates. -It can be configured from LuCI and uses separate HTTPS endpoints for IPv4 and IPv6. Direct -DNS is disabled by default and does not affect heartbeat health when a discovery service is -temporarily unavailable. - -Publishing records to an authoritative DNS provider, reachability checks, router-side -certificate issuance, firewall policy, and the RMM web interface remain separate follow-up -stages. Cloud-mode LuCI names continue to work unchanged. +Device names always resolve to the RMM reverse proxy. The agent never discovers or +publishes the router's public WAN addresses, and users do not need inbound firewall rules. +Legacy direct-DNS database tables are retained only for non-destructive upgrades and are +not exposed through the API or populated for new devices. diff --git a/docs/openwrt.md b/docs/openwrt.md index 23ac045..8808cde 100644 --- a/docs/openwrt.md +++ b/docs/openwrt.md @@ -20,10 +20,6 @@ ENROLLMENT_TOKEN="paste-a-one-time-grant-from-your-account" INTERVAL_SECONDS="30" CHECK_TARGETS="1.1.1.1 8.8.8.8" TUNNEL_IDENTITY_FILE="/etc/rmm-agent/tunnel_key" -DIRECT_DNS_ENABLED="false" -DNS_UPDATE_INTERVAL_SECONDS="300" -PUBLIC_IPV4_URL="https://api.ipify.org" -PUBLIC_IPV6_URL="https://api6.ipify.org" EOF chmod 600 /etc/rmm-agent.conf ``` @@ -123,11 +119,6 @@ curl -i http://10.10.10.2:18080/healthz Do not use `127.0.0.1` from the router for the RMM server unless the server is running on the router itself. -The production Go agent can update the direct DNS backend. Enable it in LuCI under -**Services -> RMM agent -> Direct DNS**, or set `DIRECT_DNS_ENABLED="true"` manually. -The feature uses HTTPS-only address discovery and is disabled by default because enabling -it sends the router's public IP address to the configured discovery services and RMM. - The shell agent redacts sensitive command output keys before sending command results to the server. This is a defense-in-depth measure; the server also redacts before storing results. The production agent should either: diff --git a/server/README.md b/server/README.md index 667c7c7..2bbbfcd 100644 --- a/server/README.md +++ b/server/README.md @@ -22,7 +22,6 @@ Environment variables: - `RMM_SMTP_SERVER_NAME` - optional TLS server name override - `RMM_COOKIE_SECURE` - defaults to `true` outside explicit development mode - `RMM_DEVICE_DOMAIN` - wildcard device domain, for example `routers.example.com` -- `RMM_DNS_SYNC_TOKEN` - optional random bearer secret (at least 32 characters in production) for an authoritative DNS synchronizer - `RMM_ALLOW_LEGACY_ENROLLMENT` - opt-in shared enrollment compatibility mode - `RMM_METRIC_RETENTION_DAYS` - metric history retention, default `30` - `RMM_WEB_DIR` - static web UI directory, default `web` @@ -43,7 +42,6 @@ Agent API: - `POST /api/agent/enroll` - `POST /api/agent/heartbeat` -- `POST /api/agent/dns/update` - `POST /api/agent/commands/next` - `POST /api/agent/commands/{id}/result` @@ -62,9 +60,6 @@ Operator API: - `POST /api/enrollment-grants` - `GET /api/devices` -- `GET /api/dns/records` -- `GET|PATCH /api/devices/{id}/dns` -- `GET /api/devices/{id}/dns/history` - `GET /api/events` (authenticated SSE stream; the client reloads user-scoped data on change) - `GET /api/devices/{id}` - `POST /api/devices/{id}/transfer` diff --git a/server/cmd/rmm-server/main.go b/server/cmd/rmm-server/main.go index 3fb094c..9a6e3eb 100644 --- a/server/cmd/rmm-server/main.go +++ b/server/cmd/rmm-server/main.go @@ -32,13 +32,6 @@ func main() { if operatorToken != "" && !insecureDevMode && insecurePlaceholder(operatorToken) { log.Fatal("RMM_OPERATOR_TOKEN still contains an insecure example value") } - dnsSyncToken := strings.TrimSpace(os.Getenv("RMM_DNS_SYNC_TOKEN")) - if dnsSyncToken != "" && !insecureDevMode && insecurePlaceholder(dnsSyncToken) { - log.Fatal("RMM_DNS_SYNC_TOKEN still contains an insecure example value") - } - if dnsSyncToken != "" && !insecureDevMode && len(dnsSyncToken) < 32 { - log.Fatal("RMM_DNS_SYNC_TOKEN must be at least 32 characters") - } allowLegacyEnrollment := envBool("RMM_ALLOW_LEGACY_ENROLLMENT", false) enrollmentToken := "" if allowLegacyEnrollment { @@ -104,7 +97,6 @@ func main() { PublicScheme: env("RMM_PUBLIC_SCHEME", "https"), PublicURL: publicURL, PasswordResetSender: passwordResetSender, - DNSSyncToken: dnsSyncToken, StaticDir: env("RMM_WEB_DIR", "web"), }) diff --git a/server/internal/httpapi/dns.go b/server/internal/httpapi/dns.go deleted file mode 100644 index 3152c36..0000000 --- a/server/internal/httpapi/dns.go +++ /dev/null @@ -1,238 +0,0 @@ -package httpapi - -import ( - "errors" - "net/http" - "net/netip" - "strconv" - "strings" - - "rmm-openwrt/server/internal/model" -) - -var nonPublicDNSPrefixes = []netip.Prefix{ - netip.MustParsePrefix("100.64.0.0/10"), - netip.MustParsePrefix("192.0.0.0/24"), - netip.MustParsePrefix("192.0.2.0/24"), - netip.MustParsePrefix("198.18.0.0/15"), - netip.MustParsePrefix("198.51.100.0/24"), - netip.MustParsePrefix("203.0.113.0/24"), - netip.MustParsePrefix("240.0.0.0/4"), - netip.MustParsePrefix("2001:db8::/32"), -} - -type dnsRecordUpdateRequest struct { - DNSLabel *string `json:"dns_label"` - TTL *int `json:"ttl"` - Enabled *bool `json:"enabled"` -} - -type agentDNSUpdateRequest struct { - DeviceID string `json:"device_id"` - IPv4 string `json:"ipv4"` - IPv6 string `json:"ipv6"` -} - -func (a *App) handleListDNSRecords(w http.ResponseWriter, r *http.Request) { - principal, _ := principalFromContext(r.Context()) - records, err := a.store.ListDNSRecordsForUser(r.Context(), principal.User.ID, principal.IsAdmin()) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to list DNS records") - return - } - a.decorateDNSRecords(records) - writeJSON(w, http.StatusOK, map[string]any{"records": records}) -} - -func (a *App) handleGetDeviceDNS(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - if len(parts) != 4 { - writeError(w, http.StatusNotFound, "not found") - return - } - record, found, err := a.store.GetDNSRecord(r.Context(), parts[2]) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to load DNS record") - return - } - if !found { - writeError(w, http.StatusNotFound, "DNS record not found") - return - } - a.decorateDNSRecord(&record) - writeJSON(w, http.StatusOK, record) -} - -func (a *App) handleUpdateDeviceDNS(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - if len(parts) != 4 { - writeError(w, http.StatusNotFound, "not found") - return - } - var req dnsRecordUpdateRequest - if !decodeJSON(w, r, &req) { - return - } - if req.DNSLabel == nil && req.TTL == nil && req.Enabled == nil { - writeError(w, http.StatusBadRequest, "dns_label, ttl or enabled is required") - return - } - if req.DNSLabel != nil { - label := strings.ToLower(strings.TrimSpace(*req.DNSLabel)) - if !dnsLabelPattern.MatchString(label) { - writeError(w, http.StatusBadRequest, "dns_label must be a valid single DNS label") - return - } - req.DNSLabel = &label - } - if req.TTL != nil && (*req.TTL < 30 || *req.TTL > 86400) { - writeError(w, http.StatusBadRequest, "ttl must be between 30 and 86400 seconds") - return - } - record, found, err := a.store.UpdateDNSRecordSettings(r.Context(), parts[2], req.DNSLabel, req.TTL, req.Enabled) - if err != nil { - if strings.Contains(strings.ToLower(err.Error()), "unique") || strings.Contains(strings.ToLower(err.Error()), "reserved") { - writeError(w, http.StatusConflict, "dns label is already in use") - return - } - writeError(w, http.StatusInternalServerError, "failed to update DNS record") - return - } - if !found { - writeError(w, http.StatusNotFound, "device not found") - return - } - a.decorateDNSRecord(&record) - principal, _ := principalFromContext(r.Context()) - _, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "device.dns_update", record.DeviceID, "", mustJSON(map[string]any{ - "dns_label": req.DNSLabel, - "ttl": req.TTL, - "enabled": req.Enabled, - "request_id": requestID(r.Context()), - })) - a.events.publish("devices") - writeJSON(w, http.StatusOK, record) -} - -func (a *App) handleListDeviceDNSHistory(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - if len(parts) != 5 { - writeError(w, http.StatusNotFound, "not found") - return - } - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - history, found, err := a.store.ListDNSAddressHistory(r.Context(), parts[2], limit) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to load DNS address history") - return - } - if !found { - writeError(w, http.StatusNotFound, "device not found") - return - } - writeJSON(w, http.StatusOK, map[string]any{"history": history}) -} - -func (a *App) handleAgentDNSUpdate(w http.ResponseWriter, r *http.Request) { - var req agentDNSUpdateRequest - if !decodeJSON(w, r, &req) { - return - } - if err := a.authorizeDevice(r, req.DeviceID); err != nil { - writeError(w, http.StatusUnauthorized, err.Error()) - return - } - limitKey := strings.TrimSpace(req.DeviceID) - if !a.dnsUpdateLimiter.Allow(limitKey) { - w.Header().Set("Retry-After", "60") - writeError(w, http.StatusTooManyRequests, "DNS update rate limit exceeded") - return - } - a.dnsUpdateLimiter.Fail(limitKey) - ipv4, err := normalizePublicDNSAddress(req.IPv4, true) - if err != nil { - writeError(w, http.StatusBadRequest, "ipv4 must be a public IPv4 address") - return - } - ipv6, err := normalizePublicDNSAddress(req.IPv6, false) - if err != nil { - writeError(w, http.StatusBadRequest, "ipv6 must be a public IPv6 address") - return - } - if ipv4 == "" && ipv6 == "" { - writeError(w, http.StatusBadRequest, "at least one public IP address is required") - return - } - record, found, changed, err := a.store.UpdateDNSAddresses(r.Context(), req.DeviceID, ipv4, ipv6, "agent") - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to update DNS addresses") - return - } - if !found { - writeError(w, http.StatusNotFound, "device not found") - return - } - a.decorateDNSRecord(&record) - if changed { - _, _ = a.store.AddAuditEvent(r.Context(), "agent", "agent.dns_update", record.DeviceID, "", mustJSON(map[string]any{ - "has_ipv4": ipv4 != "", - "has_ipv6": ipv6 != "", - "request_id": requestID(r.Context()), - })) - a.events.publish("devices") - } - writeJSON(w, http.StatusOK, map[string]any{"record": record, "changed": changed}) -} - -func (a *App) handleInternalDNSRecords(w http.ResponseWriter, r *http.Request) { - if a.dnsSyncToken == "" { - writeError(w, http.StatusNotFound, "not found") - return - } - token, ok := bearerToken(r) - if !ok || !constantTimeEqual(token, a.dnsSyncToken) { - writeError(w, http.StatusUnauthorized, "invalid DNS sync credentials") - return - } - records, err := a.store.ListPublishableDNSRecords(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to list publishable DNS records") - return - } - a.decorateDNSRecords(records) - w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, map[string]any{"records": records}) -} - -func (a *App) decorateDNSRecords(records []model.DNSRecord) { - for index := range records { - a.decorateDNSRecord(&records[index]) - } -} - -func (a *App) decorateDNSRecord(record *model.DNSRecord) { - if a.deviceDomain != "" && record.DNSLabel != "" { - record.DomainName = record.DNSLabel + "." + a.deviceDomain - } -} - -func normalizePublicDNSAddress(raw string, wantIPv4 bool) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", nil - } - address, err := netip.ParseAddr(raw) - if err != nil { - return "", err - } - address = address.Unmap() - if address.Is4() != wantIPv4 || !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsMulticast() || address.IsUnspecified() { - return "", errors.New("address is not publicly routable") - } - for _, prefix := range nonPublicDNSPrefixes { - if prefix.Contains(address) { - return "", errors.New("address is reserved") - } - } - return address.String(), nil -} diff --git a/server/internal/httpapi/security_test.go b/server/internal/httpapi/security_test.go index da3621d..94c9501 100644 --- a/server/internal/httpapi/security_test.go +++ b/server/internal/httpapi/security_test.go @@ -169,8 +169,8 @@ func TestPasswordResetIsOneTimeAndRevokesSessions(t *testing.T) { }, http.StatusOK, nil) } -func TestDNSRecordsAreIsolatedAndUpdatedByOwningAgent(t *testing.T) { - st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "dns-security.db")) +func TestDirectDNSRoutesAreRemoved(t *testing.T) { + st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "cloud-only.db")) if err != nil { t.Fatal(err) } @@ -179,94 +179,11 @@ func TestDNSRecordsAreIsolatedAndUpdatedByOwningAgent(t *testing.T) { srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{ OperatorUsername: "admin", OperatorPassword: "correct-horse-battery-staple", - DeviceDomain: "routers.example.test", - DNSSyncToken: "dns-sync-secret", })) defer srv.Close() - admin := authenticatedClient(t, srv.URL, "admin", "correct-horse-battery-staple") - for username, password := range map[string]string{"alice": "alice-password-long", "bob": "bob-password-long"} { - authRequestJSON(t, admin, http.MethodPost, srv.URL+"/api/users", map[string]any{ - "username": username, "password": password, "role": "user", - }, http.StatusCreated, nil) - } - alice := authenticatedClient(t, srv.URL, "alice", "alice-password-long") - bob := authenticatedClient(t, srv.URL, "bob", "bob-password-long") - aliceDevice := enrollForClient(t, srv.URL, alice, "alice-router", "alice-edge") - bobDevice := enrollForClient(t, srv.URL, bob, "bob-router", "bob-edge") - var aliceRecords struct { - Records []model.DNSRecord `json:"records"` - } - authRequestJSON(t, alice, http.MethodGet, srv.URL+"/api/dns/records", nil, http.StatusOK, &aliceRecords) - if len(aliceRecords.Records) != 1 || aliceRecords.Records[0].DeviceID != aliceDevice.DeviceID { - t.Fatalf("alice received DNS records outside her account: %#v", aliceRecords.Records) - } - authRequestJSON(t, bob, http.MethodGet, srv.URL+"/api/devices/"+aliceDevice.DeviceID+"/dns", nil, http.StatusNotFound, nil) - authRequestJSON(t, alice, http.MethodPatch, srv.URL+"/api/devices/"+aliceDevice.DeviceID+"/dns", map[string]any{ - "dns_label": "bob-edge", - }, http.StatusConflict, nil) - var settings model.DNSRecord - authRequestJSON(t, alice, http.MethodPatch, srv.URL+"/api/devices/"+aliceDevice.DeviceID+"/dns", map[string]any{ - "ttl": 120, "enabled": true, - }, http.StatusOK, &settings) - if settings.TTL != 120 || !settings.Enabled { - t.Fatalf("unexpected DNS settings: %#v", settings) - } - - requestJSON(t, http.MethodPost, srv.URL+"/api/agent/dns/update", aliceDevice.DeviceToken, map[string]any{ - "device_id": aliceDevice.DeviceID, "ipv4": "192.168.1.2", - }, http.StatusBadRequest, nil) - var update struct { - Record model.DNSRecord `json:"record"` - Changed bool `json:"changed"` - } - requestJSON(t, http.MethodPost, srv.URL+"/api/agent/dns/update", aliceDevice.DeviceToken, map[string]any{ - "device_id": aliceDevice.DeviceID, "ipv4": "8.8.8.8", "ipv6": "2606:4700:4700::1111", - }, http.StatusOK, &update) - if !update.Changed || update.Record.DomainName != "alice-edge.routers.example.test" || update.Record.IPv4 != "8.8.8.8" { - t.Fatalf("unexpected DNS update: %#v", update) - } - requestJSON(t, http.MethodPost, srv.URL+"/api/agent/dns/update", bobDevice.DeviceToken, map[string]any{ - "device_id": aliceDevice.DeviceID, "ipv4": "1.1.1.1", - }, http.StatusUnauthorized, nil) - - var history struct { - History []model.DNSAddressHistory `json:"history"` - } - authRequestJSON(t, alice, http.MethodGet, srv.URL+"/api/devices/"+aliceDevice.DeviceID+"/dns/history", nil, http.StatusOK, &history) - if len(history.History) != 1 || history.History[0].Source != "agent" { - t.Fatalf("unexpected DNS history: %#v", history.History) - } - var published struct { - Records []model.DNSRecord `json:"records"` - } - requestJSON(t, http.MethodGet, srv.URL+"/api/internal/dns/records", "wrong-token", nil, http.StatusUnauthorized, nil) - requestJSON(t, http.MethodGet, srv.URL+"/api/internal/dns/records", "dns-sync-secret", nil, http.StatusOK, &published) - if len(published.Records) != 1 || published.Records[0].DeviceID != aliceDevice.DeviceID { - t.Fatalf("unexpected publishable records: %#v", published.Records) - } - for range 28 { - requestJSON(t, http.MethodPost, srv.URL+"/api/agent/dns/update", aliceDevice.DeviceToken, map[string]any{ - "device_id": aliceDevice.DeviceID, "ipv4": "8.8.8.8", "ipv6": "2606:4700:4700::1111", - }, http.StatusOK, nil) - } - requestJSON(t, http.MethodPost, srv.URL+"/api/agent/dns/update", aliceDevice.DeviceToken, map[string]any{ - "device_id": aliceDevice.DeviceID, "ipv4": "8.8.8.8", - }, http.StatusTooManyRequests, nil) - - authRequestJSON(t, alice, http.MethodPost, srv.URL+"/api/devices/"+aliceDevice.DeviceID+"/transfer", map[string]any{ - "target_username": "bob", "current_password": "alice-password-long", - }, http.StatusOK, nil) - authRequestJSON(t, alice, http.MethodGet, srv.URL+"/api/devices/"+aliceDevice.DeviceID+"/dns", nil, http.StatusNotFound, nil) - var transferred model.DNSRecord - authRequestJSON(t, bob, http.MethodGet, srv.URL+"/api/devices/"+aliceDevice.DeviceID+"/dns", nil, http.StatusOK, &transferred) - if transferred.Enabled || transferred.IPv4 != "" || transferred.IPv6 != "" || transferred.LastAgentUpdateAt != nil { - t.Fatalf("transferred DNS state was not reset: %#v", transferred) - } - authRequestJSON(t, bob, http.MethodGet, srv.URL+"/api/devices/"+aliceDevice.DeviceID+"/dns/history", nil, http.StatusOK, &history) - if len(history.History) != 0 { - t.Fatalf("transferred DNS history was not cleared: %#v", history.History) - } + requestJSON(t, http.MethodPost, srv.URL+"/api/agent/dns/update", "unused", map[string]any{}, http.StatusNotFound, nil) + requestJSON(t, http.MethodGet, srv.URL+"/api/internal/dns/records", "unused", nil, http.StatusNotFound, nil) } func TestDeviceDomainUsesOneTimeLuCIAccessGrant(t *testing.T) { diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index de06e70..f5fb919 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -49,12 +49,6 @@ type Store interface { ListDevicesForUser(ctx context.Context, userID string, admin bool) ([]model.Device, error) DeviceAccessible(ctx context.Context, deviceID, userID string, admin bool) (bool, error) GetDeviceByDNSLabel(ctx context.Context, dnsLabel string) (model.Device, bool, error) - ListDNSRecordsForUser(ctx context.Context, userID string, admin bool) ([]model.DNSRecord, error) - ListPublishableDNSRecords(ctx context.Context) ([]model.DNSRecord, error) - GetDNSRecord(ctx context.Context, deviceID string) (model.DNSRecord, bool, error) - UpdateDNSRecordSettings(ctx context.Context, deviceID string, dnsLabel *string, ttl *int, enabled *bool) (model.DNSRecord, bool, error) - UpdateDNSAddresses(ctx context.Context, deviceID, ipv4, ipv6, source string) (model.DNSRecord, bool, bool, error) - ListDNSAddressHistory(ctx context.Context, deviceID string, limit int) ([]model.DNSAddressHistory, bool, error) TransferDevice(ctx context.Context, deviceID, targetUserID, requesterUserID string, admin bool) (model.Device, bool, error) CreateDeviceAccessGrant(ctx context.Context, tokenHash, userID, deviceID, remoteSessionID string, expiresAt time.Time) error ConsumeDeviceAccessGrant(ctx context.Context, grantHash, sessionHash, dnsLabel string, sessionExpiresAt time.Time) (store.AccessRoute, bool, error) @@ -104,7 +98,6 @@ type Config struct { PublicScheme string PublicURL string PasswordResetSender PasswordResetSender - DNSSyncToken string StaticDir string } @@ -125,10 +118,8 @@ type App struct { passwordResetSender PasswordResetSender loginLimiter *loginRateLimiter passwordResetLimiter *loginRateLimiter - dnsUpdateLimiter *loginRateLimiter loginSlots chan struct{} events *eventHub - dnsSyncToken string } type contextKey string @@ -292,10 +283,8 @@ func NewHandler(s Store, cfg Config) http.Handler { passwordResetSender: cfg.PasswordResetSender, loginLimiter: newLoginRateLimiter(5, 5*time.Minute), passwordResetLimiter: newLoginRateLimiter(3, time.Hour), - dnsUpdateLimiter: newLoginRateLimiter(30, time.Minute), loginSlots: make(chan struct{}, 4), events: newEventHub(), - dnsSyncToken: strings.TrimSpace(cfg.DNSSyncToken), } if a.tunnelHTTPHost == "" { a.tunnelHTTPHost = "tunnel-ssh" @@ -331,13 +320,10 @@ func NewHandler(s Store, cfg Config) http.Handler { mux.Handle("POST /api/enrollment-grants", a.operatorAuth(http.HandlerFunc(a.handleCreateEnrollmentGrant))) mux.HandleFunc("POST /api/agent/enroll", a.handleEnroll) mux.HandleFunc("POST /api/agent/heartbeat", a.handleHeartbeat) - mux.HandleFunc("POST /api/agent/dns/update", a.handleAgentDNSUpdate) mux.HandleFunc("POST /api/agent/commands/next", a.handleNextCommand) mux.HandleFunc("POST /api/agent/commands/", a.handleCommandResult) mux.Handle("GET /api/devices", a.operatorAuth(http.HandlerFunc(a.handleListDevices))) mux.Handle("GET /api/events", a.operatorAuth(http.HandlerFunc(a.handleEvents))) - mux.Handle("GET /api/dns/records", a.operatorAuth(http.HandlerFunc(a.handleListDNSRecords))) - mux.HandleFunc("GET /api/internal/dns/records", a.handleInternalDNSRecords) mux.Handle("POST /api/devices/bulk-commands", a.operatorAuth(http.HandlerFunc(a.handleCreateBulkCommand))) mux.Handle("GET /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree))) mux.Handle("POST /api/devices/", a.operatorAuth(http.HandlerFunc(a.handleDeviceSubtree))) @@ -589,18 +575,6 @@ func (a *App) handleDeviceSubtree(w http.ResponseWriter, r *http.Request) { a.handleUpdateDeviceFleet(w, r) return } - if len(parts) == 4 && parts[3] == "dns" && r.Method == http.MethodPatch { - a.handleUpdateDeviceDNS(w, r) - return - } - if len(parts) == 4 && parts[3] == "dns" && r.Method == http.MethodGet { - a.handleGetDeviceDNS(w, r) - return - } - if len(parts) == 5 && parts[3] == "dns" && parts[4] == "history" && r.Method == http.MethodGet { - a.handleListDeviceDNSHistory(w, r) - return - } if len(parts) == 4 && parts[3] == "transfer" && r.Method == http.MethodPost { a.handleTransferDevice(w, r) return diff --git a/server/internal/model/model.go b/server/internal/model/model.go index 040c52f..5743aee 100644 --- a/server/internal/model/model.go +++ b/server/internal/model/model.go @@ -42,28 +42,6 @@ type EnrollmentGrant struct { CreatedAt time.Time `json:"created_at"` } -type DNSRecord struct { - DeviceID string `json:"device_id"` - DNSLabel string `json:"dns_label"` - DomainName string `json:"domain_name,omitempty"` - IPv4 string `json:"ipv4,omitempty"` - IPv6 string `json:"ipv6,omitempty"` - TTL int `json:"ttl"` - Enabled bool `json:"enabled"` - LastAgentUpdateAt *time.Time `json:"last_agent_update_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type DNSAddressHistory struct { - ID string `json:"id"` - DeviceID string `json:"device_id"` - IPv4 string `json:"ipv4,omitempty"` - IPv6 string `json:"ipv6,omitempty"` - Source string `json:"source"` - CreatedAt time.Time `json:"created_at"` -} - type Command struct { ID string `json:"id"` DeviceID string `json:"device_id"` diff --git a/server/internal/store/dns.go b/server/internal/store/dns.go deleted file mode 100644 index f205958..0000000 --- a/server/internal/store/dns.go +++ /dev/null @@ -1,222 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "errors" - - "rmm-openwrt/server/internal/model" -) - -const dnsRecordSelect = ` -SELECT r.device_id, d.dns_label, r.ipv4, r.ipv6, r.ttl, r.enabled, - r.last_agent_update_at, r.created_at, r.updated_at -FROM device_dns_records r -JOIN devices d ON d.id = r.device_id -` - -func (s *Store) ListDNSRecordsForUser(ctx context.Context, userID string, admin bool) ([]model.DNSRecord, error) { - query := dnsRecordSelect + ` ORDER BY d.created_at DESC` - args := []any{} - if !admin { - query = dnsRecordSelect + ` WHERE d.owner_user_id = ? ORDER BY d.created_at DESC` - args = append(args, userID) - } - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - records := make([]model.DNSRecord, 0) - for rows.Next() { - record, err := scanDNSRecord(rows) - if err != nil { - return nil, err - } - records = append(records, record) - } - return records, rows.Err() -} - -func (s *Store) ListPublishableDNSRecords(ctx context.Context) ([]model.DNSRecord, error) { - rows, err := s.db.QueryContext(ctx, dnsRecordSelect+` -WHERE r.enabled = 1 AND d.dns_label != '' AND (r.ipv4 != '' OR r.ipv6 != '') -ORDER BY d.dns_label -`) - if err != nil { - return nil, err - } - defer rows.Close() - records := make([]model.DNSRecord, 0) - for rows.Next() { - record, err := scanDNSRecord(rows) - if err != nil { - return nil, err - } - records = append(records, record) - } - return records, rows.Err() -} - -func (s *Store) GetDNSRecord(ctx context.Context, deviceID string) (model.DNSRecord, bool, error) { - record, err := scanDNSRecord(s.db.QueryRowContext(ctx, dnsRecordSelect+` WHERE r.device_id = ?`, deviceID)) - if errors.Is(err, sql.ErrNoRows) { - return model.DNSRecord{}, false, nil - } - return record, err == nil, err -} - -func (s *Store) UpdateDNSRecordSettings(ctx context.Context, deviceID string, dnsLabel *string, ttl *int, enabled *bool) (model.DNSRecord, bool, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return model.DNSRecord{}, false, err - } - defer tx.Rollback() - var currentLabel string - if err := tx.QueryRowContext(ctx, `SELECT dns_label FROM devices WHERE id = ?`, deviceID).Scan(¤tLabel); errors.Is(err, sql.ErrNoRows) { - return model.DNSRecord{}, false, nil - } else if err != nil { - return model.DNSRecord{}, false, err - } - if dnsLabel != nil && *dnsLabel != currentLabel { - var reserved bool - if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM enrollment_grants WHERE dns_label = ? AND used_at IS NULL AND julianday(expires_at) > julianday(?))`, *dnsLabel, nowText()).Scan(&reserved); err != nil { - return model.DNSRecord{}, false, err - } - if reserved { - return model.DNSRecord{}, false, errors.New("dns label is reserved by an active enrollment grant") - } - if _, err := tx.ExecContext(ctx, `UPDATE devices SET dns_label = ? WHERE id = ?`, *dnsLabel, deviceID); err != nil { - return model.DNSRecord{}, false, err - } - } - now := nowText() - if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO device_dns_records (device_id, created_at, updated_at) VALUES (?, ?, ?)`, deviceID, now, now); err != nil { - return model.DNSRecord{}, false, err - } - if ttl != nil { - if _, err := tx.ExecContext(ctx, `UPDATE device_dns_records SET ttl = ?, updated_at = ? WHERE device_id = ?`, *ttl, now, deviceID); err != nil { - return model.DNSRecord{}, false, err - } - } - if enabled != nil { - if _, err := tx.ExecContext(ctx, `UPDATE device_dns_records SET enabled = ?, updated_at = ? WHERE device_id = ?`, *enabled, now, deviceID); err != nil { - return model.DNSRecord{}, false, err - } - } - if dnsLabel != nil && ttl == nil && enabled == nil { - if _, err := tx.ExecContext(ctx, `UPDATE device_dns_records SET updated_at = ? WHERE device_id = ?`, now, deviceID); err != nil { - return model.DNSRecord{}, false, err - } - } - if err := tx.Commit(); err != nil { - return model.DNSRecord{}, false, err - } - return s.GetDNSRecord(ctx, deviceID) -} - -func (s *Store) UpdateDNSAddresses(ctx context.Context, deviceID, ipv4, ipv6, source string) (model.DNSRecord, bool, bool, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return model.DNSRecord{}, false, false, err - } - defer tx.Rollback() - var exists bool - if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM devices WHERE id = ?)`, deviceID).Scan(&exists); err != nil { - return model.DNSRecord{}, false, false, err - } - if !exists { - return model.DNSRecord{}, false, false, nil - } - now := nowText() - if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO device_dns_records (device_id, created_at, updated_at) VALUES (?, ?, ?)`, deviceID, now, now); err != nil { - return model.DNSRecord{}, false, false, err - } - var currentIPv4, currentIPv6 string - if err := tx.QueryRowContext(ctx, `SELECT ipv4, ipv6 FROM device_dns_records WHERE device_id = ?`, deviceID).Scan(¤tIPv4, ¤tIPv6); err != nil { - return model.DNSRecord{}, false, false, err - } - changed := currentIPv4 != ipv4 || currentIPv6 != ipv6 - if _, err := tx.ExecContext(ctx, ` -UPDATE device_dns_records -SET ipv4 = ?, ipv6 = ?, last_agent_update_at = ?, updated_at = ? -WHERE device_id = ? -`, ipv4, ipv6, now, now, deviceID); err != nil { - return model.DNSRecord{}, false, false, err - } - if changed { - historyID, err := randomID("dns") - if err != nil { - return model.DNSRecord{}, false, false, err - } - if _, err := tx.ExecContext(ctx, `INSERT INTO dns_address_history (id, device_id, ipv4, ipv6, source, created_at) VALUES (?, ?, ?, ?, ?, ?)`, historyID, deviceID, ipv4, ipv6, source, now); err != nil { - return model.DNSRecord{}, false, false, err - } - if _, err := tx.ExecContext(ctx, ` -DELETE FROM dns_address_history -WHERE device_id = ? AND id IN ( - SELECT id FROM dns_address_history WHERE device_id = ? ORDER BY created_at DESC LIMIT -1 OFFSET 100 -) -`, deviceID, deviceID); err != nil { - return model.DNSRecord{}, false, false, err - } - } - if err := tx.Commit(); err != nil { - return model.DNSRecord{}, false, false, err - } - record, found, err := s.GetDNSRecord(ctx, deviceID) - return record, found, changed, err -} - -func (s *Store) ListDNSAddressHistory(ctx context.Context, deviceID string, limit int) ([]model.DNSAddressHistory, bool, error) { - if limit <= 0 || limit > 100 { - limit = 25 - } - var exists bool - if err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM devices WHERE id = ?)`, deviceID).Scan(&exists); err != nil { - return nil, false, err - } - if !exists { - return nil, false, nil - } - rows, err := s.db.QueryContext(ctx, ` -SELECT id, device_id, ipv4, ipv6, source, created_at -FROM dns_address_history -WHERE device_id = ? -ORDER BY created_at DESC -LIMIT ? -`, deviceID, limit) - if err != nil { - return nil, false, err - } - defer rows.Close() - history := make([]model.DNSAddressHistory, 0) - for rows.Next() { - var item model.DNSAddressHistory - var createdAt string - if err := rows.Scan(&item.ID, &item.DeviceID, &item.IPv4, &item.IPv6, &item.Source, &createdAt); err != nil { - return nil, false, err - } - item.CreatedAt = parseTime(createdAt) - history = append(history, item) - } - return history, true, rows.Err() -} - -func scanDNSRecord(s scanner) (model.DNSRecord, error) { - var record model.DNSRecord - var enabled bool - var lastAgentUpdate sql.NullString - var createdAt, updatedAt string - if err := s.Scan(&record.DeviceID, &record.DNSLabel, &record.IPv4, &record.IPv6, &record.TTL, &enabled, &lastAgentUpdate, &createdAt, &updatedAt); err != nil { - return record, err - } - record.Enabled = enabled - record.CreatedAt = parseTime(createdAt) - record.UpdatedAt = parseTime(updatedAt) - if lastAgentUpdate.Valid { - value := parseTime(lastAgentUpdate.String) - record.LastAgentUpdateAt = &value - } - return record, nil -} diff --git a/server/internal/store/security.go b/server/internal/store/security.go index 36da447..c635295 100644 --- a/server/internal/store/security.go +++ b/server/internal/store/security.go @@ -465,9 +465,6 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?) if err != nil { return EnrolledDevice{}, false, err } - if _, err := tx.ExecContext(ctx, `INSERT INTO device_dns_records (device_id, created_at, updated_at) VALUES (?, ?, ?)`, id, now, now); err != nil { - return EnrolledDevice{}, false, err - } if err := tx.Commit(); err != nil { return EnrolledDevice{}, false, err } diff --git a/server/internal/store/sqlite.go b/server/internal/store/sqlite.go index 48593b8..1a7eaa8 100644 --- a/server/internal/store/sqlite.go +++ b/server/internal/store/sqlite.go @@ -332,12 +332,6 @@ CREATE TABLE IF NOT EXISTS device_access_sessions ( return err } } - if _, err := s.db.ExecContext(ctx, ` -INSERT OR IGNORE INTO device_dns_records (device_id, created_at, updated_at) -SELECT id, created_at, created_at FROM devices -`); err != nil { - return err - } return s.migrateDeviceTokens(ctx) } @@ -381,9 +375,6 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?) if err != nil { return EnrolledDevice{}, err } - if _, err := tx.ExecContext(ctx, `INSERT INTO device_dns_records (device_id, created_at, updated_at) VALUES (?, ?, ?)`, id, now, now); err != nil { - return EnrolledDevice{}, err - } if err := tx.Commit(); err != nil { return EnrolledDevice{}, err } diff --git a/web/app.js b/web/app.js index 52fb783..6a81491 100644 --- a/web/app.js +++ b/web/app.js @@ -35,7 +35,7 @@ const state = { let eventSource = null; let liveRefreshTimer = null; -const EXPECTED_AGENT_VERSION = "0.6.0"; +const EXPECTED_AGENT_VERSION = "0.6.1"; const els = { loginView: document.querySelector("#loginView"),