From 6e9b5a99c2dbb4715b0c8dc0764db507e19b57b0 Mon Sep 17 00:00:00 2001 From: benya Date: Tue, 21 Jul 2026 04:03:49 +0300 Subject: [PATCH] feat: add direct DNS updates to Go agent --- 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 | 3 + .../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 + .../rmm-agent/files/etc/config/rmm-agent | 4 + .../files/usr/libexec/rmm-agent-uci-sync | 4 + docs/keendns.md | 12 +- docs/openwrt.md | 9 ++ web/app.js | 2 +- 20 files changed, 442 insertions(+), 16 deletions(-) create mode 100644 agent/go/cmd/rmm-agent/direct_dns.go diff --git a/agent/README.md b/agent/README.md index eeba52b..4ee789f 100644 --- a/agent/README.md +++ b/agent/README.md @@ -1,7 +1,8 @@ # OpenWrt RMM Agent -Current stable Go agent: `0.5.2`. It reports runtime health, pending command results, -and the last heartbeat transport error after connectivity is restored. +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. Production Go agent for OpenWrt, with the shell implementation retained as a fallback runtime. @@ -28,6 +29,10 @@ 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: @@ -79,6 +84,19 @@ 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 new file mode 100644 index 0000000..18cc0e6 --- /dev/null +++ b/agent/go/cmd/rmm-agent/direct_dns.go @@ -0,0 +1,150 @@ +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 de8d57e..0d766fb 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.5.2" +const agentVersion = "0.6.0" type agentRuntimeHealth struct { StartedAt time.Time @@ -31,6 +31,9 @@ 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) { @@ -47,6 +50,20 @@ 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), @@ -60,6 +77,13 @@ 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 } @@ -77,6 +101,10 @@ type config struct { CheckTargets []string HostnameOverride string HostnameSuffix string + DirectDNSEnabled bool + DNSUpdateSeconds int + PublicIPv4URL string + PublicIPv6URL string ConfigFile string } @@ -138,6 +166,7 @@ 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) @@ -149,6 +178,14 @@ 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 @@ -176,6 +213,10 @@ 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) @@ -225,10 +266,28 @@ 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 } @@ -259,6 +318,10 @@ 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) } @@ -1738,6 +1801,17 @@ 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 f9c91cd..416aedb 100644 --- a/agent/go/cmd/rmm-agent/main_test.go +++ b/agent/go/cmd/rmm-agent/main_test.go @@ -1,7 +1,11 @@ package main import ( + "context" + "encoding/json" "errors" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -9,11 +13,102 @@ import ( ) func TestAgentVersionIsStable(t *testing.T) { - if agentVersion != "0.5.2" { + if agentVersion != "0.6.0" { 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 { @@ -32,6 +127,14 @@ 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 f12dec2..545a614 100644 --- a/agent/openwrt/rmm-agent-go.conf +++ b/agent/openwrt/rmm-agent-go.conf @@ -2,6 +2,10 @@ 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 b7d0b3c..da75d9e 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.1.0 +PKG_VERSION:=0.2.0 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 8c9db0f..03b7be2 100644 --- a/agent/package/luci-app-rmm-agent/README.md +++ b/agent/package/luci-app-rmm-agent/README.md @@ -8,6 +8,7 @@ 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. @@ -67,8 +68,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.5.2-r1.apk \ - luci-app-rmm-agent-0.1.0-r1.apk \ + rmm-agent-go-production-0.6.0-r1.apk \ + luci-app-rmm-agent-0.2.0-r1.apk \ root@ROUTER_IP:/tmp/ ``` @@ -76,8 +77,8 @@ Then install the locally built, unsigned packages over SSH: ```sh apk add --allow-untrusted \ - /tmp/rmm-agent-go-production-0.5.2-r1.apk \ - /tmp/luci-app-rmm-agent-0.1.0-r1.apk + /tmp/rmm-agent-go-production-0.6.0-r1.apk \ + /tmp/luci-app-rmm-agent-0.2.0-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 b780b72..d4dfba6 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,6 +18,12 @@ 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') ]); @@ -70,6 +76,36 @@ 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 1d32a3f..407a7cf 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.5.2 +PKG_VERSION:=0.6.0 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 4e2ad4d..c3b4457 100644 --- a/agent/package/rmm-agent-go-production/README.md +++ b/agent/package/rmm-agent-go-production/README.md @@ -33,3 +33,6 @@ opkg install /tmp/rmm-agent-go-production_*.ipk - `/etc/rmm-agent.conf` 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`. 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 10f73d9..6d9df5e 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,6 +3,10 @@ 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 351cd46..3a4b3eb 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,6 +2,10 @@ 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 355cecf..1fc60dc 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,6 +60,10 @@ 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 6273425..08c99be 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.5.2 +PKG_VERSION:=0.6.0 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 f12dec2..545a614 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,6 +2,10 @@ 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/files/etc/config/rmm-agent b/agent/package/rmm-agent/files/etc/config/rmm-agent index 10f73d9..6d9df5e 100644 --- a/agent/package/rmm-agent/files/etc/config/rmm-agent +++ b/agent/package/rmm-agent/files/etc/config/rmm-agent @@ -3,6 +3,10 @@ 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 355cecf..1fc60dc 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,6 +60,10 @@ 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/docs/keendns.md b/docs/keendns.md index 97d1ebe..7338406 100644 --- a/docs/keendns.md +++ b/docs/keendns.md @@ -78,7 +78,11 @@ 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. -This implements the data and security foundation only. Publishing records to an -authoritative DNS provider, public-address discovery in the Go agent, reachability checks, -router-side certificate issuance, firewall policy, and the user interface remain separate -follow-up stages. Cloud-mode LuCI names continue to work unchanged. +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. diff --git a/docs/openwrt.md b/docs/openwrt.md index 8808cde..23ac045 100644 --- a/docs/openwrt.md +++ b/docs/openwrt.md @@ -20,6 +20,10 @@ 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 ``` @@ -119,6 +123,11 @@ 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/web/app.js b/web/app.js index 2a3320a..11ea2e7 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.5.2"; +const EXPECTED_AGENT_VERSION = "0.6.0"; const els = { loginView: document.querySelector("#loginView"),