239 lines
7.3 KiB
Go
239 lines
7.3 KiB
Go
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
|
|
}
|