From c31e52a90cfc5b55845822262c9a0962b600e1c0 Mon Sep 17 00:00:00 2001 From: benya Date: Tue, 21 Jul 2026 03:33:42 +0300 Subject: [PATCH] feat: add secure device DNS backend --- .env.example | 2 + compose.yaml | 1 + docs/api.md | 54 +++++ docs/keendns.md | 38 +++- server/README.md | 5 + server/cmd/rmm-server/main.go | 8 + server/internal/httpapi/accounts.go | 37 ---- server/internal/httpapi/dns.go | 238 +++++++++++++++++++++++ server/internal/httpapi/security_test.go | 100 ++++++++++ server/internal/httpapi/server.go | 27 ++- server/internal/model/model.go | 22 +++ server/internal/store/dns.go | 222 +++++++++++++++++++++ server/internal/store/security.go | 33 ++-- server/internal/store/sqlite.go | 47 ++++- 14 files changed, 766 insertions(+), 68 deletions(-) create mode 100644 server/internal/httpapi/dns.go create mode 100644 server/internal/store/dns.go diff --git a/.env.example b/.env.example index 17f0fc5..5248d9d 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,8 @@ 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/compose.yaml b/compose.yaml index 41db2a8..f6b1d07 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,6 +17,7 @@ services: RMM_COOKIE_SECURE: "${RMM_COOKIE_SECURE:-true}" RMM_TUNNEL_HTTP_HOST: tunnel-ssh 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 48c5c5f..e852acd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -155,6 +155,60 @@ 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 2dc06c5..97d1ebe 100644 --- a/docs/keendns.md +++ b/docs/keendns.md @@ -48,9 +48,37 @@ docker compose -f compose.yaml -f compose.proxy.yaml -f compose.keendns.yaml up Do not enable `RMM_ALLOW_LEGACY_LUCI_PROXY` in production. It exists only for migration from the old same-origin `/luci/...` route. -## Future direct mode +## Direct DNS mode backend -A direct mode can later publish a router's verified public WAN address and update it when -the agent reports a change. That mode needs authoritative DNS-provider integration, -reachability checks, certificate issuance on the router, and explicit firewall policy; -it is intentionally not mixed into the cloud-mode security boundary. +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. + +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. diff --git a/server/README.md b/server/README.md index 2bbbfcd..667c7c7 100644 --- a/server/README.md +++ b/server/README.md @@ -22,6 +22,7 @@ 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` @@ -42,6 +43,7 @@ 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` @@ -60,6 +62,9 @@ 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 db25814..24c6fd9 100644 --- a/server/cmd/rmm-server/main.go +++ b/server/cmd/rmm-server/main.go @@ -32,6 +32,13 @@ 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 { @@ -95,6 +102,7 @@ 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/accounts.go b/server/internal/httpapi/accounts.go index 44bc87a..3572756 100644 --- a/server/internal/httpapi/accounts.go +++ b/server/internal/httpapi/accounts.go @@ -260,43 +260,6 @@ func (a *App) handleCreateEnrollmentGrant(w http.ResponseWriter, r *http.Request }) } -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 dnsLabelRequest - if !decodeJSON(w, r, &req) { - return - } - 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 - } - device, found, err := a.store.UpdateDeviceDNSLabel(r.Context(), parts[2], label) - 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 label") - return - } - if !found { - writeError(w, http.StatusNotFound, "device not found") - return - } - a.decorateDevice(&device) - principal, _ := principalFromContext(r.Context()) - _, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "device.dns_update", device.ID, "", mustJSON(map[string]string{ - "dns_label": label, - "request_id": requestID(r.Context()), - })) - writeJSON(w, http.StatusOK, device) -} - func (a *App) decorateDevices(devices []model.Device) { for index := range devices { a.decorateDevice(&devices[index]) diff --git a/server/internal/httpapi/dns.go b/server/internal/httpapi/dns.go new file mode 100644 index 0000000..3152c36 --- /dev/null +++ b/server/internal/httpapi/dns.go @@ -0,0 +1,238 @@ +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 bbe1923..da3621d 100644 --- a/server/internal/httpapi/security_test.go +++ b/server/internal/httpapi/security_test.go @@ -169,6 +169,106 @@ 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")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + 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) + } +} + func TestDeviceDomainUsesOneTimeLuCIAccessGrant(t *testing.T) { st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "luci-security.db")) if err != nil { diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 000777f..789d8bf 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -48,7 +48,12 @@ 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) - UpdateDeviceDNSLabel(ctx context.Context, deviceID, 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) @@ -96,6 +101,7 @@ type Config struct { PublicScheme string PublicURL string PasswordResetSender PasswordResetSender + DNSSyncToken string StaticDir string } @@ -114,8 +120,10 @@ type App struct { passwordResetSender PasswordResetSender loginLimiter *loginRateLimiter passwordResetLimiter *loginRateLimiter + dnsUpdateLimiter *loginRateLimiter loginSlots chan struct{} events *eventHub + dnsSyncToken string } type contextKey string @@ -231,10 +239,6 @@ type enrollmentGrantRequest struct { ExpiresSeconds int `json:"expires_seconds"` } -type dnsLabelRequest struct { - DNSLabel string `json:"dns_label"` -} - func NewHandler(s Store, cfg Config) http.Handler { if strings.TrimSpace(cfg.OperatorUsername) == "" { cfg.OperatorUsername = "admin" @@ -274,8 +278,10 @@ 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" @@ -303,10 +309,13 @@ 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))) @@ -562,6 +571,14 @@ func (a *App) handleDeviceSubtree(w http.ResponseWriter, r *http.Request) { 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 6932e3b..5356964 100644 --- a/server/internal/model/model.go +++ b/server/internal/model/model.go @@ -42,6 +42,28 @@ 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 new file mode 100644 index 0000000..f205958 --- /dev/null +++ b/server/internal/store/dns.go @@ -0,0 +1,222 @@ +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 19f243c..36da447 100644 --- a/server/internal/store/security.go +++ b/server/internal/store/security.go @@ -465,6 +465,9 @@ 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 } @@ -515,24 +518,6 @@ func (s *Store) GetDeviceByDNSLabel(ctx context.Context, dnsLabel string) (model return device, err == nil, err } -func (s *Store) UpdateDeviceDNSLabel(ctx context.Context, deviceID, dnsLabel string) (model.Device, bool, error) { - var reserved bool - if err := s.db.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.Device{}, false, err - } - if reserved { - return model.Device{}, false, errors.New("dns label is reserved by an active enrollment grant") - } - result, err := s.db.ExecContext(ctx, `UPDATE devices SET dns_label = ? WHERE id = ?`, dnsLabel, deviceID) - if err != nil { - return model.Device{}, false, err - } - if affected, _ := result.RowsAffected(); affected == 0 { - return model.Device{}, false, nil - } - return s.GetDevice(ctx, deviceID) -} - func (s *Store) TransferDevice(ctx context.Context, deviceID, targetUserID, requesterUserID string, admin bool) (model.Device, bool, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -576,7 +561,17 @@ WHERE device_id = ? AND status IN ('queued', 'claimed') if _, err := tx.ExecContext(ctx, ` UPDATE remote_sessions SET status = 'closed', closed_at = ?, updated_at = ? WHERE device_id = ? AND status IN ('requested', 'queued', 'active') -`, now, now, deviceID); err != nil { + `, now, now, deviceID); err != nil { + return model.Device{}, false, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM dns_address_history WHERE device_id = ?`, deviceID); err != nil { + return model.Device{}, false, err + } + if _, err := tx.ExecContext(ctx, ` +UPDATE device_dns_records +SET ipv4 = '', ipv6 = '', enabled = 0, last_agent_update_at = NULL, updated_at = ? +WHERE device_id = ? +`, now, deviceID); err != nil { return model.Device{}, false, err } if err := tx.Commit(); err != nil { diff --git a/server/internal/store/sqlite.go b/server/internal/store/sqlite.go index 7c4e868..48593b8 100644 --- a/server/internal/store/sqlite.go +++ b/server/internal/store/sqlite.go @@ -233,6 +233,28 @@ CREATE TABLE IF NOT EXISTS enrollment_grants ( FOREIGN KEY(user_id) REFERENCES users(id) ); +CREATE TABLE IF NOT EXISTS device_dns_records ( + device_id TEXT PRIMARY KEY, + ipv4 TEXT NOT NULL DEFAULT '', + ipv6 TEXT NOT NULL DEFAULT '', + ttl INTEGER NOT NULL DEFAULT 60 CHECK(ttl BETWEEN 30 AND 86400), + enabled INTEGER NOT NULL DEFAULT 1, + last_agent_update_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(device_id) REFERENCES devices(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS dns_address_history ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + ipv4 TEXT NOT NULL DEFAULT '', + ipv6 TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(device_id) REFERENCES devices(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS device_access_grants ( token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -301,6 +323,7 @@ CREATE TABLE IF NOT EXISTS device_access_sessions ( `CREATE INDEX IF NOT EXISTS idx_operator_sessions_user_expires ON operator_sessions(user_id, expires_at)`, `CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_expires ON password_reset_tokens(user_id, expires_at)`, `CREATE INDEX IF NOT EXISTS idx_enrollment_grants_user_created ON enrollment_grants(user_id, created_at)`, + `CREATE INDEX IF NOT EXISTS idx_dns_address_history_device_created ON dns_address_history(device_id, created_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_device_access_sessions_device_expires ON device_access_sessions(device_id, expires_at)`, `CREATE INDEX IF NOT EXISTS idx_remote_sessions_device_created_at ON remote_sessions(device_id, created_at)`, `CREATE INDEX IF NOT EXISTS idx_remote_sessions_status_expires_at ON remote_sessions(status, expires_at)`, @@ -309,6 +332,12 @@ 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) } @@ -339,13 +368,25 @@ func (s *Store) EnrollDevice(ctx context.Context, hostname, openwrtVersion strin dnsLabel = generatedDNSLabel(hostname, id) } - _, err = s.db.ExecContext(ctx, ` + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return EnrolledDevice{}, err + } + defer tx.Rollback() + now := nowText() + _, err = tx.ExecContext(ctx, ` INSERT INTO devices (id, token, token_hash, owner_user_id, dns_label, hostname, openwrt_version, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) -`, id, "redacted:"+id, TokenHash(token), ownerUserID, dnsLabel, hostname, openwrtVersion, nowText()) +`, id, "redacted:"+id, TokenHash(token), ownerUserID, dnsLabel, hostname, openwrtVersion, now) 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 + } return EnrolledDevice{DeviceID: id, DeviceToken: token}, nil } @@ -605,6 +646,8 @@ func (s *Store) DeleteDevice(ctx context.Context, deviceID string) (bool, error) } defer tx.Rollback() for _, stmt := range []string{ + `DELETE FROM dns_address_history WHERE device_id = ?`, + `DELETE FROM device_dns_records WHERE device_id = ?`, `DELETE FROM device_access_sessions WHERE device_id = ?`, `DELETE FROM device_access_grants WHERE device_id = ?`, `DELETE FROM remote_sessions WHERE device_id = ?`,