suporte a dynamic dns

This commit is contained in:
2026-07-12 19:13:40 -03:00
parent e629b829a3
commit d184bcbdc2
13 changed files with 1020 additions and 31 deletions

View File

@@ -5,15 +5,20 @@ import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"pdns_admin/internal/appdb"
"pdns_admin/internal/pdns"
)
type fakeClient struct {
deletedRRSetType string
createdRRSet pdns.RRSet
replacedRRSet pdns.RRSet
replacedZoneID string
zone pdns.Zone
}
type fakeAuth struct {
@@ -45,8 +50,8 @@ func (fakeClient) DeleteZone(context.Context, string) error {
return nil
}
func (fakeClient) GetZone(context.Context, string) (pdns.Zone, error) {
return pdns.Zone{}, nil
func (f *fakeClient) GetZone(context.Context, string) (pdns.Zone, error) {
return f.zone, nil
}
func (f *fakeClient) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet) error {
@@ -54,11 +59,66 @@ func (f *fakeClient) CreateRRSet(_ context.Context, _ string, rrset pdns.RRSet)
return nil
}
func (f *fakeClient) ReplaceRRSet(_ context.Context, zoneID string, rrset pdns.RRSet) error {
f.replacedZoneID = zoneID
f.replacedRRSet = rrset
return nil
}
func (f *fakeClient) DeleteRRSet(_ context.Context, _, _, recordType string) error {
f.deletedRRSetType = recordType
return nil
}
type fakeDynamicStore struct {
records map[string]appdb.DynamicRecord
}
func (f *fakeDynamicStore) ListDynamicRecords(context.Context, string) ([]appdb.DynamicRecord, error) {
records := make([]appdb.DynamicRecord, 0, len(f.records))
for _, record := range f.records {
if record.Enabled {
records = append(records, record)
}
}
return records, nil
}
func (f *fakeDynamicStore) FindDynamicRecordByNameAndTokenHash(_ context.Context, name, tokenHash string) (appdb.DynamicRecord, error) {
for _, record := range f.records {
if record.Name == name && record.TokenHash == tokenHash && record.Enabled {
return record, nil
}
}
return appdb.DynamicRecord{}, appdb.ErrNotFound
}
func (f *fakeDynamicStore) GetDynamicRecord(_ context.Context, zoneID, name, recordType string) (appdb.DynamicRecord, error) {
record, ok := f.records[dynamicRecordKey(name, recordType)]
if !ok || record.ZoneID != zoneID || !record.Enabled {
return appdb.DynamicRecord{}, appdb.ErrNotFound
}
return record, nil
}
func (f *fakeDynamicStore) UpsertDynamicRecord(_ context.Context, record appdb.DynamicRecord) error {
if f.records == nil {
f.records = make(map[string]appdb.DynamicRecord)
}
record.Enabled = true
f.records[dynamicRecordKey(record.Name, record.Type)] = record
return nil
}
func (f *fakeDynamicStore) DisableDynamicRecord(_ context.Context, _, name, recordType string) error {
record, ok := f.records[dynamicRecordKey(name, recordType)]
if ok {
record.Enabled = false
f.records[dynamicRecordKey(name, recordType)] = record
}
return nil
}
func TestNewRequiresClient(t *testing.T) {
if _, err := New(Config{}, nil, nil); err == nil {
t.Fatal("expected error for missing client")
@@ -297,6 +357,38 @@ func TestDeleteRRSetRejectsSOA(t *testing.T) {
}
}
func TestDeleteRRSetDisablesDynamicDNS(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 300,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader("name=www.example.org.&type=A")
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/delete", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
record := store.records[dynamicRecordKey("www.example.org.", "A")]
if record.Enabled {
t.Fatalf("dynamic record was not disabled: %#v", record)
}
}
func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) {
client := &fakeClient{}
srv, err := New(Config{}, client, nil)
@@ -314,10 +406,245 @@ func TestSaveEditedRRSetUsesQueryIdentity(t *testing.T) {
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
if client.createdRRSet.Name != "www.example.org." {
t.Fatalf("name came from form: %#v", client.createdRRSet)
if client.replacedRRSet.Name != "www.example.org." {
t.Fatalf("name came from form: %#v", client.replacedRRSet)
}
if client.createdRRSet.Type != "A" {
t.Fatalf("type came from form: %#v", client.createdRRSet)
if client.replacedRRSet.Type != "A" {
t.Fatalf("type came from form: %#v", client.replacedRRSet)
}
}
func TestSaveRRSetEnablesDynamicDNS(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
form := url.Values{}
form.Set("name", "www.example.org.")
form.Set("type", "A")
form.Set("ttl", "300")
form.Set("records", "192.0.2.10")
form.Set("dynamic_dns", "on")
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
record, err := store.GetDynamicRecord(context.Background(), "example.org.", "www.example.org.", "A")
if err != nil {
t.Fatalf("GetDynamicRecord returned error: %v", err)
}
if record.TokenHash == "" || record.TTL != 300 {
t.Fatalf("unexpected dynamic record: %#v", record)
}
if len(rec.Result().Cookies()) == 0 {
t.Fatal("expected flash cookie")
}
}
func TestRegenerateDynamicToken(t *testing.T) {
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 300,
TokenHash: tokenHash("old-token"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, &fakeClient{}, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
form := url.Values{}
form.Set("name", "www.example.org.")
form.Set("type", "A")
req := httptest.NewRequest(http.MethodPost, "/zones/example.org./rrsets/dyndns/regenerate", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("unexpected status: %d", rec.Code)
}
record := store.records[dynamicRecordKey("www.example.org.", "A")]
if record.TokenHash == tokenHash("old-token") || record.TokenHash == "" {
t.Fatalf("token hash was not regenerated: %#v", record)
}
}
func TestDynamicUpdateEndpointUpdatesRecord(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "198.51.100.20:12345"
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if client.replacedZoneID != "example.org." {
t.Fatalf("unexpected zone id: %q", client.replacedZoneID)
}
if client.replacedRRSet.Name != "www.example.org." || client.replacedRRSet.Type != "A" || client.replacedRRSet.TTL != 120 {
t.Fatalf("unexpected rrset: %#v", client.replacedRRSet)
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.20" {
t.Fatalf("unexpected update address: %q", got)
}
if !strings.Contains(rec.Body.String(), `"status":"OK"`) {
t.Fatalf("unexpected response: %s", rec.Body.String())
}
}
func TestDynamicUpdateEndpointUsesForwardedFor(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "10.0.0.10:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.30, 10.0.0.10")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.30" {
t.Fatalf("unexpected update address: %q", got)
}
}
func TestDynamicUpdateEndpointUsesRealIP(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "10.0.0.10:12345"
req.Header.Set("X-Real-IP", "198.51.100.31")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.31" {
t.Fatalf("unexpected update address: %q", got)
}
}
func TestDynamicUpdateEndpointAddressOverridesForwardedHeaders(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret","address":"198.51.100.40"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
req.RemoteAddr = "10.0.0.10:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.30")
req.Header.Set("X-Real-IP", "198.51.100.31")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := client.replacedRRSet.Records[0].Content; got != "198.51.100.40" {
t.Fatalf("unexpected update address: %q", got)
}
}
func TestDynamicUpdateEndpointRejectsAddressTypeMismatch(t *testing.T) {
client := &fakeClient{}
store := &fakeDynamicStore{records: map[string]appdb.DynamicRecord{}}
store.records[dynamicRecordKey("www.example.org.", "A")] = appdb.DynamicRecord{
ZoneID: "example.org.",
Name: "www.example.org.",
Type: "A",
TTL: 120,
TokenHash: tokenHash("secret"),
Enabled: true,
}
srv, err := New(Config{DynamicStore: store}, client, nil)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
body := strings.NewReader(`{"name":"www.example.org","token":"secret","address":"2001:db8::1"}`)
req := httptest.NewRequest(http.MethodPost, "/api/dyndns", body)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if client.replacedRRSet.Name != "" {
t.Fatalf("unexpected PowerDNS update: %#v", client.replacedRRSet)
}
}