From 2846f26e835c798ffe356d734612b604261ef6cd Mon Sep 17 00:00:00 2001 From: Vladimir Ermakov Date: Fri, 3 Apr 2026 19:10:41 +0200 Subject: [PATCH 1/8] Add PowerDNS-compatible API surface --- internal/htpasswd/htpasswd.go | 12 + internal/htpasswd/middleware.go | 27 +++ internal/server/pdns.go | 408 ++++++++++++++++++++++++++++++++ internal/server/server.go | 8 + internal/server/server_test.go | 200 ++++++++++++++++ internal/zone/controller.go | 8 + internal/zone/pdns.go | 281 ++++++++++++++++++++++ internal/zone/pdns_test.go | 79 +++++++ 8 files changed, 1023 insertions(+) create mode 100644 internal/server/pdns.go create mode 100644 internal/zone/pdns.go create mode 100644 internal/zone/pdns_test.go diff --git a/internal/htpasswd/htpasswd.go b/internal/htpasswd/htpasswd.go index ecb4229..d2b76d4 100644 --- a/internal/htpasswd/htpasswd.go +++ b/internal/htpasswd/htpasswd.go @@ -12,6 +12,7 @@ import ( type HTPasswd interface { Authenticate(user, password string) (ok, present bool) + AuthenticateAny(password string) (ok bool) } type HTPasswdFile map[string]string @@ -53,3 +54,14 @@ func (s HTPasswdFile) Authenticate(user, password string) (ok bool, present bool return } + +func (s HTPasswdFile) AuthenticateAny(password string) bool { + for user := range s { + ok, present := s.Authenticate(user, password) + if ok && present { + return true + } + } + + return false +} diff --git a/internal/htpasswd/middleware.go b/internal/htpasswd/middleware.go index 2f6c708..cf51c08 100644 --- a/internal/htpasswd/middleware.go +++ b/internal/htpasswd/middleware.go @@ -36,3 +36,30 @@ func NewBasicAuthMiddleware(ht HTPasswd) func(http.Handler) http.Handler { }) } } + +func NewAPIKeyMiddleware(ht HTPasswd) func(http.Handler) http.Handler { + return func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, password, ok := r.BasicAuth() + if ok { + ok, _ = ht.Authenticate(user, password) + } else { + password = r.Header.Get("X-API-Key") + ok = password != "" && ht.AuthenticateAny(password) + } + + if ok { + h.ServeHTTP(w, r) + return + } + + err := fuego.HTTPError{ + Title: "unauthorized access", + Detail: "wrong api key", + Status: http.StatusUnauthorized, + } + + fuego.SendJSONError(w, nil, err) + }) + } +} diff --git a/internal/server/pdns.go b/internal/server/pdns.go new file mode 100644 index 0000000..0fd76ea --- /dev/null +++ b/internal/server/pdns.go @@ -0,0 +1,408 @@ +package server + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/go-fuego/fuego" + "github.com/go-fuego/fuego/option" + + "github.com/vooon/zoneomatic/internal/htpasswd" + "github.com/vooon/zoneomatic/internal/zone" +) + +const pdnsServerID = "localhost" + +type pdnsErrorResponse struct { + Error string `json:"error"` + Errors []string `json:"errors,omitempty"` +} + +type pdnsServer struct { + Type string `json:"type"` + ID string `json:"id"` + DaemonType string `json:"daemon_type"` + Version string `json:"version"` + URL string `json:"url"` + ConfigURL string `json:"config_url"` + ZonesURL string `json:"zones_url"` +} + +type pdnsRecord struct { + Content string `json:"content"` + Disabled bool `json:"disabled"` +} + +type pdnsRRSet struct { + Name string `json:"name"` + Type string `json:"type"` + TTL int `json:"ttl,omitempty"` + ChangeType string `json:"changetype,omitempty"` + Records []pdnsRecord `json:"records"` + Comments []any `json:"comments,omitempty"` +} + +type pdnsZone struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` + Kind string `json:"kind"` + RRsets []pdnsRRSet `json:"rrsets,omitempty"` + Serial uint32 `json:"serial"` + NotifiedSerial uint32 `json:"notified_serial"` + EditedSerial uint32 `json:"edited_serial"` + Masters []string `json:"masters"` + DNSSEC bool `json:"dnssec"` + Account string `json:"account"` + Nameservers []string `json:"nameservers,omitempty"` + SOAEditAPI string `json:"soa_edit_api,omitempty"` + APIRectify bool `json:"api_rectify"` + Zone string `json:"zone,omitempty"` + Catalog string `json:"catalog,omitempty"` + LastCheck uint32 `json:"last_check"` + Presigned bool `json:"presigned"` + NSEC3Narrow bool `json:"nsec3narrow"` + NSEC3Param string `json:"nsec3param"` + MasterTSIGKeys []string `json:"master_tsig_key_ids"` + SlaveTSIGKeys []string `json:"slave_tsig_key_ids"` +} + +type pdnsPatchZoneRequest struct { + RRsets []pdnsRRSet `json:"rrsets"` +} + +func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.Controller) { + pdnsAuth := newPDNSAPIKeyMiddleware(htp) + pdnsSecurity := option.Security(openapi3.SecurityRequirement{ + "pdnsApiKeyAuth": []string{}, + }) + + fuego.GetStd(srv, "/api/v1/servers", + func(w http.ResponseWriter, r *http.Request) { + server := pdnsServerInfo() + sendPDNSJSON(w, http.StatusOK, []pdnsServer{server}) + }, + option.Summary("pdns list servers"), + option.Description("List the forged PowerDNS-compatible server instance"), + option.Middleware(pdnsAuth), + pdnsSecurity, + ) + + fuego.GetStd(srv, "/api/v1/servers/{server_id}", + func(w http.ResponseWriter, r *http.Request) { + if !requirePDNSServerID(w, r) { + return + } + + sendPDNSJSON(w, http.StatusOK, pdnsServerInfo()) + }, + option.Summary("pdns get server"), + option.Description("Return the forged PowerDNS-compatible server instance"), + option.Middleware(pdnsAuth), + pdnsSecurity, + ) + + fuego.GetStd(srv, "/api/v1/servers/{server_id}/zones", + func(w http.ResponseWriter, r *http.Request) { + if !requirePDNSServerID(w, r) { + return + } + + zones, err := zctl.ListZones(r.Context()) + if err != nil { + sendPDNSError(w, http.StatusInternalServerError, err.Error()) + return + } + + zoneFilter := r.URL.Query().Get("zone") + result := make([]pdnsZone, 0, len(zones)) + for _, zoneData := range zones { + if zoneFilter != "" && zoneData.Name != dnsFQDN(zoneFilter) { + continue + } + + result = append(result, zoneSnapshotToPDNSZone(zoneData, false)) + } + + sendPDNSJSON(w, http.StatusOK, result) + }, + option.Summary("pdns list zones"), + option.Description("List managed zones in a PowerDNS-compatible format"), + option.Middleware(pdnsAuth), + pdnsSecurity, + ) + + fuego.GetStd(srv, "/api/v1/servers/{server_id}/zones/{zone_id}", + func(w http.ResponseWriter, r *http.Request) { + if !requirePDNSServerID(w, r) { + return + } + + zoneData, err := zctl.GetZone(r.Context(), r.PathValue("zone_id")) + if err != nil { + sendPDNSZoneError(w, err) + return + } + + includeRRsets := !strings.EqualFold(r.URL.Query().Get("rrsets"), "false") + rrsetName := r.URL.Query().Get("rrset_name") + rrsetType := r.URL.Query().Get("rrset_type") + if rrsetType != "" && rrsetName == "" { + sendPDNSError(w, http.StatusUnprocessableEntity, "rrset_type requires rrset_name") + return + } + + zoneResp := zoneSnapshotToPDNSZone(zoneData, includeRRsets) + if includeRRsets && rrsetName != "" { + zoneResp.RRsets = filterPDNSRRsets(zoneResp.RRsets, rrsetName, rrsetType) + } + + sendPDNSJSON(w, http.StatusOK, zoneResp) + }, + option.Summary("pdns get zone"), + option.Description("Return a managed zone in PowerDNS-compatible format"), + option.Middleware(pdnsAuth), + pdnsSecurity, + ) + + fuego.PatchStd(srv, "/api/v1/servers/{server_id}/zones/{zone_id}", + func(w http.ResponseWriter, r *http.Request) { + if !requirePDNSServerID(w, r) { + return + } + + defer r.Body.Close() // nolint:errcheck + + var req pdnsPatchZoneRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendPDNSError(w, http.StatusBadRequest, "invalid json body", err.Error()) + return + } + + zoneName := r.PathValue("zone_id") + for _, rrset := range req.RRsets { + if rrset.Name == "" || rrset.Type == "" { + sendPDNSError(w, http.StatusUnprocessableEntity, "rrset name and type are required") + return + } + + switch strings.ToUpper(strings.TrimSpace(rrset.ChangeType)) { + case "DELETE": + if _, err := zctl.DeleteRRSet(r.Context(), zoneName, rrset.Name, rrset.Type); err != nil { + sendPDNSZoneError(w, err) + return + } + case "REPLACE": + if len(rrset.Records) == 0 { + if _, err := zctl.DeleteRRSet(r.Context(), zoneName, rrset.Name, rrset.Type); err != nil { + sendPDNSZoneError(w, err) + return + } + + continue + } + + if rrset.TTL <= 0 { + sendPDNSError(w, http.StatusUnprocessableEntity, "ttl must be greater than zero for REPLACE") + return + } + + values := make([]string, 0, len(rrset.Records)) + for _, record := range rrset.Records { + if record.Disabled { + sendPDNSError(w, http.StatusNotImplemented, "disabled records are not supported") + return + } + + values = append(values, record.Content) + } + + if _, err := zctl.ReplaceRRSet(r.Context(), zoneName, rrset.Name, rrset.Type, rrset.TTL, values); err != nil { + sendPDNSZoneError(w, err) + return + } + default: + sendPDNSError(w, http.StatusNotImplemented, "unsupported changetype: "+rrset.ChangeType) + return + } + } + + w.WriteHeader(http.StatusNoContent) + }, + option.Summary("pdns patch zone"), + option.Description("Replace or delete managed RRSets in PowerDNS-compatible format"), + option.Middleware(pdnsAuth), + pdnsSecurity, + ) + + registerPDNSUnsupportedZoneRoute(srv, pdnsAuth, "/api/v1/servers/{server_id}/zones", http.MethodPost, "create zone") + registerPDNSUnsupportedZoneRoute(srv, pdnsAuth, "/api/v1/servers/{server_id}/zones/{zone_id}", http.MethodPut, "update zone") + registerPDNSUnsupportedZoneRoute(srv, pdnsAuth, "/api/v1/servers/{server_id}/zones/{zone_id}", http.MethodDelete, "delete zone") + registerPDNSUnsupportedZoneRoute(srv, pdnsAuth, "/api/v1/servers/{server_id}/zones/{zone_id}/notify", http.MethodPut, "notify zone") + registerPDNSUnsupportedZoneRoute(srv, pdnsAuth, "/api/v1/servers/{server_id}/zones/{zone_id}/rectify", http.MethodPut, "rectify zone") +} + +func registerPDNSUnsupportedZoneRoute(srv *fuego.Server, authMw func(http.Handler) http.Handler, path, method, operation string) { + handler := func(w http.ResponseWriter, r *http.Request) { + if !requirePDNSServerID(w, r) { + return + } + + slog.WarnContext(r.Context(), "Unsupported PDNS operation", "operation", operation, "path", r.URL.Path) + sendPDNSError(w, http.StatusNotImplemented, operation+" is not implemented") + } + + switch method { + case http.MethodPost: + fuego.PostStd(srv, path, handler, option.Middleware(authMw), option.Security(openapi3.SecurityRequirement{"pdnsApiKeyAuth": []string{}})) + case http.MethodPut: + fuego.PutStd(srv, path, handler, option.Middleware(authMw), option.Security(openapi3.SecurityRequirement{"pdnsApiKeyAuth": []string{}})) + case http.MethodDelete: + fuego.DeleteStd(srv, path, handler, option.Middleware(authMw), option.Security(openapi3.SecurityRequirement{"pdnsApiKeyAuth": []string{}})) + } +} + +func pdnsServerInfo() pdnsServer { + return pdnsServer{ + Type: "Server", + ID: pdnsServerID, + DaemonType: "authoritative", + Version: "zoneomatic", + URL: "/api/v1/servers/localhost", + ConfigURL: "/api/v1/servers/localhost/config", + ZonesURL: "/api/v1/servers/localhost/zones", + } +} + +func zoneSnapshotToPDNSZone(zoneData zone.ZoneSnapshot, includeRRsets bool) pdnsZone { + resp := pdnsZone{ + ID: zoneData.ID, + Name: zoneData.Name, + Type: "Zone", + URL: "/api/v1/servers/localhost/zones/" + zoneData.ID, + Kind: "Native", + Serial: zoneData.Serial, + EditedSerial: zoneData.Serial, + NotifiedSerial: 0, + Masters: []string{}, + DNSSEC: false, + Account: "", + Nameservers: zoneData.Nameservers, + SOAEditAPI: "", + APIRectify: false, + LastCheck: 0, + Presigned: false, + NSEC3Narrow: false, + NSEC3Param: "", + MasterTSIGKeys: []string{}, + SlaveTSIGKeys: []string{}, + } + + if includeRRsets { + resp.RRsets = make([]pdnsRRSet, 0, len(zoneData.RRsets)) + for _, rrset := range zoneData.RRsets { + records := make([]pdnsRecord, 0, len(rrset.Records)) + for _, record := range rrset.Records { + records = append(records, pdnsRecord{Content: record, Disabled: false}) + } + + resp.RRsets = append(resp.RRsets, pdnsRRSet{ + Name: rrset.Name, + Type: rrset.Type, + TTL: rrset.TTL, + Records: records, + }) + } + } + + return resp +} + +func filterPDNSRRsets(rrsets []pdnsRRSet, rrsetName, rrsetType string) []pdnsRRSet { + rrsetName = dnsFQDN(rrsetName) + rrsetType = strings.ToUpper(strings.TrimSpace(rrsetType)) + + filtered := make([]pdnsRRSet, 0, len(rrsets)) + for _, rrset := range rrsets { + if rrset.Name != rrsetName { + continue + } + if rrsetType != "" && rrset.Type != rrsetType { + continue + } + + filtered = append(filtered, rrset) + } + + return filtered +} + +func requirePDNSServerID(w http.ResponseWriter, r *http.Request) bool { + if r.PathValue("server_id") == pdnsServerID { + return true + } + + sendPDNSError(w, http.StatusNotFound, "server not found") + return false +} + +func sendPDNSZoneError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, zone.ErrZoneNotFound): + sendPDNSError(w, http.StatusNotFound, err.Error()) + case errors.Is(err, zone.ErrRecordNotFound): + sendPDNSError(w, http.StatusNotFound, err.Error()) + default: + sendPDNSError(w, http.StatusUnprocessableEntity, err.Error()) + } +} + +func sendPDNSError(w http.ResponseWriter, status int, msg string, errs ...string) { + sendPDNSJSON(w, status, pdnsErrorResponse{Error: msg, Errors: errs}) +} + +func sendPDNSJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body == nil || status == http.StatusNoContent { + return + } + + if err := json.NewEncoder(w).Encode(body); err != nil { + slog.Error("Failed to encode PDNS response", "error", err) + } +} + +func dnsFQDN(name string) string { + if name == "" { + return "" + } + + return strings.TrimSpace(strings.TrimSuffix(name, ".")) + "." +} + +func newPDNSAPIKeyMiddleware(ht htpasswd.HTPasswd) func(http.Handler) http.Handler { + return func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, password, ok := r.BasicAuth() + if ok { + ok, _ = ht.Authenticate(user, password) + } else { + password = r.Header.Get("X-API-Key") + ok = password != "" && ht.AuthenticateAny(password) + } + + if ok { + h.ServeHTTP(w, r) + return + } + + sendPDNSError(w, http.StatusUnauthorized, "unauthorized") + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 8450340..56f3af0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -74,6 +74,13 @@ func NewServer(cli *Cli) (*fuego.Server, net.Listener, error) { WithType("http"). WithScheme("basic"), }, + "pdnsApiKeyAuth": { + Value: openapi3.NewSecurityScheme(). + WithType("apiKey"). + WithIn("header"). + WithName("X-API-Key"). + WithDescription("static api key"), + }, "apiUserAuth": { Value: openapi3.NewSecurityScheme(). WithType("apiKey"). @@ -98,6 +105,7 @@ func NewServer(cli *Cli) (*fuego.Server, net.Listener, error) { func RegisterEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.Controller) { authMw := htpasswd.NewBasicAuthMiddleware(htp) + registerPDNSEndpoints(srv, htp, zctl) fuego.Get(srv, "/health", func(ctx fuego.ContextNoBody) (string, error) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a8a0241..941c725 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -2,11 +2,13 @@ package server import ( "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" "net/netip" "slices" + "strings" "testing" "github.com/getkin/kin-openapi/openapi3" @@ -28,10 +30,55 @@ func (f fakeHTPasswd) Authenticate(user, password string) (ok, present bool) { return password == f.pass, true } +func (f fakeHTPasswd) AuthenticateAny(password string) bool { + return password == f.pass +} + +type fakeRRSetReplaceCall struct { + zoneName string + name string + typ string + ttl int + values []string +} + +type fakeRRSetDeleteCall struct { + zoneName string + name string + typ string +} + type fakeZoneController struct { lastDomain string lastAddrs []netip.Addr ddnsErr error + zones map[string]zone.ZoneSnapshot + getZoneErr error + replaceErr error + deleteErr error + replaced []fakeRRSetReplaceCall + deleted []fakeRRSetDeleteCall +} + +func (f *fakeZoneController) ListZones(_ context.Context) ([]zone.ZoneSnapshot, error) { + ret := make([]zone.ZoneSnapshot, 0, len(f.zones)) + for _, zoneData := range f.zones { + ret = append(ret, zoneData) + } + + return ret, nil +} + +func (f *fakeZoneController) GetZone(_ context.Context, zoneName string) (zone.ZoneSnapshot, error) { + if f.getZoneErr != nil { + return zone.ZoneSnapshot{}, f.getZoneErr + } + + if zoneData, ok := f.zones[zoneName]; ok { + return zoneData, nil + } + + return zone.ZoneSnapshot{}, fmt.Errorf("wrapped: %w", zone.ErrZoneNotFound) } func (f *fakeZoneController) UpdateDDNSAddress(_ context.Context, domain string, addrs []netip.Addr) error { @@ -47,6 +94,36 @@ func (f *fakeZoneController) UpdateACMEChallenge(_ context.Context, _ string, _, return nil } +func (f *fakeZoneController) ReplaceRRSet(_ context.Context, zoneName, name, typ string, ttl int, values []string) (changed bool, err error) { + if f.replaceErr != nil { + return false, f.replaceErr + } + + f.replaced = append(f.replaced, fakeRRSetReplaceCall{ + zoneName: zoneName, + name: name, + typ: typ, + ttl: ttl, + values: append([]string(nil), values...), + }) + + return true, nil +} + +func (f *fakeZoneController) DeleteRRSet(_ context.Context, zoneName, name, typ string) (changed bool, err error) { + if f.deleteErr != nil { + return false, f.deleteErr + } + + f.deleted = append(f.deleted, fakeRRSetDeleteCall{ + zoneName: zoneName, + name: name, + typ: typ, + }) + + return true, nil +} + func (f *fakeZoneController) ZMUpdateRecord(_ context.Context, _ string, _ string, _ []string) (changed bool, err error) { return false, nil } @@ -60,6 +137,12 @@ func newTestServer(htp fakeHTPasswd, zctl *fakeZoneController) *fuego.Server { WithType("http"). WithScheme("basic"), }, + "pdnsApiKeyAuth": { + Value: openapi3.NewSecurityScheme(). + WithType("apiKey"). + WithIn("header"). + WithName("X-API-Key"), + }, "apiUserAuth": { Value: openapi3.NewSecurityScheme(). WithType("apiKey"). @@ -162,3 +245,120 @@ func TestNICUpdate_ZoneNotFoundMappedTo404(t *testing.T) { assert.Equal(t, http.StatusNotFound, rec.Code) } + +func TestPDNSServerDiscovery(t *testing.T) { + htp := fakeHTPasswd{user: "u", pass: "p"} + zctl := &fakeZoneController{} + srv := newTestServer(htp, zctl) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/servers/localhost", nil) + req.Header.Set("X-API-Key", "p") + rec := httptest.NewRecorder() + srv.Mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var body map[string]any + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "localhost", body["id"]) + assert.Equal(t, "authoritative", body["daemon_type"]) +} + +func TestPDNSZoneGet(t *testing.T) { + htp := fakeHTPasswd{user: "u", pass: "p"} + zctl := &fakeZoneController{ + zones: map[string]zone.ZoneSnapshot{ + "example.com.": { + ID: "example.com.", + Name: "example.com.", + Serial: 123, + RRsets: []zone.RRSet{{ + Name: "www.example.com.", + Type: "A", + TTL: 60, + Records: []string{"1.2.3.4"}, + }}, + }, + }, + } + srv := newTestServer(htp, zctl) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/servers/localhost/zones/example.com.?rrsets=false", nil) + req.Header.Set("X-API-Key", "p") + rec := httptest.NewRecorder() + srv.Mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var body map[string]any + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "example.com.", body["id"]) + _, hasRRsets := body["rrsets"] + assert.False(t, hasRRsets) + + req = httptest.NewRequest(http.MethodGet, "/api/v1/servers/localhost/zones/example.com.", nil) + req.Header.Set("X-API-Key", "p") + rec = httptest.NewRecorder() + srv.Mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + rrsets, hasRRsets := body["rrsets"].([]any) + assert.True(t, hasRRsets) + assert.Len(t, rrsets, 1) +} + +func TestPDNSPatchZoneReplaceAndDelete(t *testing.T) { + htp := fakeHTPasswd{user: "u", pass: "p"} + zctl := &fakeZoneController{} + srv := newTestServer(htp, zctl) + + patchBody := `{"rrsets":[{"name":"www.example.com.","type":"A","ttl":60,"changetype":"REPLACE","records":[{"content":"1.2.3.4","disabled":false}]},{"name":"old.example.com.","type":"TXT","changetype":"DELETE","records":[]}]}` + req := httptest.NewRequest(http.MethodPatch, "/api/v1/servers/localhost/zones/example.com.", strings.NewReader(patchBody)) + req.Header.Set("X-API-Key", "p") + rec := httptest.NewRecorder() + srv.Mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Len(t, zctl.replaced, 1) + assert.Equal(t, fakeRRSetReplaceCall{ + zoneName: "example.com.", + name: "www.example.com.", + typ: "A", + ttl: 60, + values: []string{"1.2.3.4"}, + }, zctl.replaced[0]) + assert.Len(t, zctl.deleted, 1) + assert.Equal(t, fakeRRSetDeleteCall{ + zoneName: "example.com.", + name: "old.example.com.", + typ: "TXT", + }, zctl.deleted[0]) +} + +func TestPDNSUnauthorized(t *testing.T) { + htp := fakeHTPasswd{user: "u", pass: "p"} + zctl := &fakeZoneController{} + srv := newTestServer(htp, zctl) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/servers/localhost", nil) + rec := httptest.NewRecorder() + srv.Mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.JSONEq(t, `{"error":"unauthorized"}`, rec.Body.String()) +} + +func TestPDNSUnsupportedZoneOperation(t *testing.T) { + htp := fakeHTPasswd{user: "u", pass: "p"} + zctl := &fakeZoneController{} + srv := newTestServer(htp, zctl) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/servers/localhost/zones", nil) + req.Header.Set("X-API-Key", "p") + rec := httptest.NewRecorder() + srv.Mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotImplemented, rec.Code) + assert.JSONEq(t, `{"error":"create zone is not implemented"}`, rec.Body.String()) +} diff --git a/internal/zone/controller.go b/internal/zone/controller.go index 6fffb10..899b886 100644 --- a/internal/zone/controller.go +++ b/internal/zone/controller.go @@ -34,10 +34,18 @@ const EmptyPlaceholder = "placeholder" // Controller implements zone file modification methods type Controller interface { + // ListZones returns all managed zones. + ListZones(ctx context.Context) ([]ZoneSnapshot, error) + // GetZone returns a managed zone by its origin name. + GetZone(ctx context.Context, zoneName string) (ZoneSnapshot, error) // UpdateDDNSAddress changes DDNS A/AAAA records UpdateDDNSAddress(ctx context.Context, domain string, addrs []netip.Addr) error // UpdateACMEChallenge changes ACME TXT record for DNS-01 challenge UpdateACMEChallenge(ctx context.Context, domain string, newToken, oldToken string) error + // ReplaceRRSet replaces or creates the requested RRSet in a specific zone. + ReplaceRRSet(ctx context.Context, zoneName, name, typ string, ttl int, values []string) (changed bool, err error) + // DeleteRRSet removes the requested RRSet from a specific zone. + DeleteRRSet(ctx context.Context, zoneName, name, typ string) (changed bool, err error) // ZMUpdateRecord replace record values ZMUpdateRecord(ctx context.Context, domain string, typ string, values []string) (changed bool, err error) } diff --git a/internal/zone/pdns.go b/internal/zone/pdns.go new file mode 100644 index 0000000..3f7900d --- /dev/null +++ b/internal/zone/pdns.go @@ -0,0 +1,281 @@ +package zone + +import ( + "bytes" + "context" + "errors" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/miekg/dns" + "github.com/vooon/zoneomatic/pkg/zonefile" +) + +type RRSet struct { + Name string + Type string + TTL int + Records []string +} + +type ZoneSnapshot struct { + ID string + Name string + Serial uint32 + RRsets []RRSet + Nameservers []string +} + +func (s *DomainCtrl) ListZones(ctx context.Context) ([]ZoneSnapshot, error) { + ret := make([]ZoneSnapshot, 0, len(s.files)) + for _, fl := range s.files { + zoneData, err := fl.Snapshot(ctx) + if err != nil { + return nil, err + } + + ret = append(ret, zoneData) + } + + slices.SortFunc(ret, func(a, b ZoneSnapshot) int { + return strings.Compare(a.Name, b.Name) + }) + + return ret, nil +} + +func (s *DomainCtrl) GetZone(ctx context.Context, zoneName string) (ZoneSnapshot, error) { + fl := s.findExactZoneFile(zoneName) + if fl == nil { + return ZoneSnapshot{}, fmt.Errorf("%w: %s", ErrZoneNotFound, zoneName) + } + + return fl.Snapshot(ctx) +} + +func (s *DomainCtrl) ReplaceRRSet(ctx context.Context, zoneName, name, typ string, ttl int, values []string) (changed bool, err error) { + fl := s.findExactZoneFile(zoneName) + if fl == nil { + return false, fmt.Errorf("%w: %s", ErrZoneNotFound, zoneName) + } + + return fl.ReplaceRRSet(ctx, name, typ, ttl, values) +} + +func (s *DomainCtrl) DeleteRRSet(ctx context.Context, zoneName, name, typ string) (changed bool, err error) { + fl := s.findExactZoneFile(zoneName) + if fl == nil { + return false, fmt.Errorf("%w: %s", ErrZoneNotFound, zoneName) + } + + return fl.DeleteRRSet(ctx, name, typ) +} + +func (s *DomainCtrl) findExactZoneFile(zoneName string) *File { + zoneName = normalizeZoneName(zoneName) + for _, fl := range s.files { + if normalizeZoneName(fl.origin) == zoneName { + return fl + } + } + + return nil +} + +func (s *File) Snapshot(_ context.Context) (ZoneSnapshot, error) { + s.mu.Lock() + defer s.mu.Unlock() + + zf, soa, err := s.load() + if err != nil { + return ZoneSnapshot{}, err + } + + origin := normalizeZoneName(s.origin) + zoneData := ZoneSnapshot{ + ID: origin, + Name: origin, + } + + if soa != nil { + soaValues := soa.ValuesStrings() + if len(soaValues) >= 3 { + serial, err := strconv.ParseUint(soaValues[2], 10, 32) + if err == nil { + zoneData.Serial = uint32(serial) + } + } + } + + currentTTL := 0 + rrsetsByKey := make(map[string]*RRSet) + rrsetOrder := make([]string, 0) + + for _, ent := range zf.Entries() { + if ent.IsComment { + continue + } + + if ent.IsControl { + if bytes.Equal(ent.Command(), []byte("$TTL")) && len(ent.Values()) > 0 { + if ttl, ok := zonefile.StringToTTL(string(ent.Values()[0])); ok { + currentTTL = int(ttl) + } + } + continue + } + + rrType := ent.RRType() + if rrType == 0 { + continue + } + + name := absoluteRecordName(ent.Domain(), origin) + typeName := strings.ToUpper(string(ent.Type())) + ttl := currentTTL + if entTTL := ent.TTL(); entTTL != nil { + ttl = *entTTL + } + + key := name + "\x00" + typeName + rrset, ok := rrsetsByKey[key] + if !ok { + rrset = &RRSet{ + Name: name, + Type: typeName, + TTL: ttl, + } + rrsetsByKey[key] = rrset + rrsetOrder = append(rrsetOrder, key) + } + + content := entryContent(ent) + rrset.Records = append(rrset.Records, content) + + if rrType == dns.TypeNS && name == origin { + zoneData.Nameservers = append(zoneData.Nameservers, content) + } + } + + zoneData.RRsets = make([]RRSet, 0, len(rrsetOrder)) + for _, key := range rrsetOrder { + zoneData.RRsets = append(zoneData.RRsets, *rrsetsByKey[key]) + } + + return zoneData, nil +} + +func (s *File) ReplaceRRSet(ctx context.Context, name, typ string, ttl int, values []string) (changed bool, err error) { + s.mu.Lock() + defer s.mu.Unlock() + + if ttl <= 0 { + return false, fmt.Errorf("invalid ttl: %d", ttl) + } + + lg := s.lg.With("rr_name", name, "rr_type", typ, "ttl", ttl, "record_count", len(values)) + + typ = strings.ToUpper(strings.TrimSpace(typ)) + rrType, ok := dns.StringToType[typ] + if !ok { + return false, fmt.Errorf("unknown rrtype: %s", typ) + } + + shortName, err := s.relativeRecordName(name) + if err != nil { + return false, err + } + + newentbuf := bytes.NewBuffer(nil) + for _, val := range values { + _, _ = fmt.Fprintf(newentbuf, "\n%s %d IN %s %s\n", shortName, ttl, typ, formatRecordValue(rrType, val)) + } + + entries, err := parseEntries(newentbuf) + if err != nil { + return false, err + } + + matchers := []Matcher{{ + Domain: []byte(shortName), + RRType: rrType, + }} + + return s.updateRecords(ctx, lg, matchers, entries, true) +} + +func (s *File) DeleteRRSet(ctx context.Context, name, typ string) (changed bool, err error) { + s.mu.Lock() + defer s.mu.Unlock() + + lg := s.lg.With("rr_name", name, "rr_type", typ) + + typ = strings.ToUpper(strings.TrimSpace(typ)) + rrType, ok := dns.StringToType[typ] + if !ok { + return false, fmt.Errorf("unknown rrtype: %s", typ) + } + + shortName, err := s.relativeRecordName(name) + if err != nil { + return false, err + } + + matchers := []Matcher{{ + Domain: []byte(shortName), + RRType: rrType, + }} + + changed, err = s.updateRecords(ctx, lg, matchers, nil, false) + if errors.Is(err, ErrRecordNotFound) { + return false, nil + } + + return changed, err +} + +func (s *File) relativeRecordName(name string) (string, error) { + name = normalizeZoneName(name) + if !domainMatchesOrigin(name, s.origin) { + return "", fmt.Errorf("record name not in zone %s: %s", s.origin, name) + } + + return StripOrigin(name, s.origin), nil +} + +func normalizeZoneName(name string) string { + return dns.Fqdn(strings.TrimSpace(name)) +} + +func absoluteRecordName(name []byte, origin string) string { + if len(name) == 0 || bytes.Equal(name, []byte("@")) { + return origin + } + + if dns.IsFqdn(string(name)) { + return string(name) + } + + return dns.Fqdn(string(name) + "." + strings.TrimSuffix(origin, ".")) +} + +func entryContent(ent zonefile.Entry) string { + values := ent.ValuesStrings() + switch ent.RRType() { + case dns.TypeTXT, dns.TypeSPF: + return strings.Join(values, "") + default: + return strings.Join(values, " ") + } +} + +func formatRecordValue(rrType uint16, value string) string { + switch rrType { + case dns.TypeTXT, dns.TypeSPF: + return quoteTXT(value) + default: + return value + } +} diff --git a/internal/zone/pdns_test.go b/internal/zone/pdns_test.go new file mode 100644 index 0000000..ed4e68a --- /dev/null +++ b/internal/zone/pdns_test.go @@ -0,0 +1,79 @@ +package zone + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFile_Snapshot(t *testing.T) { + f := newZoneTemp(t, "./testdata/at.example.com.zone") + + snapshot, err := f.Snapshot(context.Background()) + require.NoError(t, err) + assert.Equal(t, "at.example.com.", snapshot.ID) + assert.Equal(t, uint32(1763822925), snapshot.Serial) + assert.Contains(t, snapshot.Nameservers, "ns1.example.com.") + assert.Contains(t, snapshot.Nameservers, "ns2.example.com.") + + loopA := findRRSet(t, snapshot.RRsets, "loop.at.example.com.", "A") + assert.Equal(t, 60, loopA.TTL) + assert.Equal(t, []string{"127.0.0.1"}, loopA.Records) + + acmeTXT := findRRSet(t, snapshot.RRsets, "_acme-challenge.zot.at.example.com.", "TXT") + assert.Equal(t, []string{"8NwtedqEdkceTHTZILXsMU2UWEeEon24tXw0dSSDkrs"}, acmeTXT.Records) +} + +func TestFile_ReplaceRRSet(t *testing.T) { + f := newZoneTemp(t, "./testdata/at.example.com.zone") + + changed, err := f.ReplaceRRSet(context.Background(), "new-entry.at.example.com.", "A", 120, []string{"1.2.3.4"}) + require.NoError(t, err) + assert.True(t, changed) + + snapshot, err := f.Snapshot(context.Background()) + require.NoError(t, err) + rrset := findRRSet(t, snapshot.RRsets, "new-entry.at.example.com.", "A") + assert.Equal(t, 120, rrset.TTL) + assert.Equal(t, []string{"1.2.3.4"}, rrset.Records) +} + +func TestFile_DeleteRRSet(t *testing.T) { + f := newZoneTemp(t, "./testdata/at.example.com.zone") + + changed, err := f.DeleteRRSet(context.Background(), "loop.at.example.com.", "AAAA") + require.NoError(t, err) + assert.True(t, changed) + + snapshot, err := f.Snapshot(context.Background()) + require.NoError(t, err) + assert.False(t, hasRRSet(snapshot.RRsets, "loop.at.example.com.", "AAAA")) + + changed, err = f.DeleteRRSet(context.Background(), "missing.at.example.com.", "A") + require.NoError(t, err) + assert.False(t, changed) +} + +func findRRSet(t *testing.T, rrsets []RRSet, name, typ string) RRSet { + t.Helper() + for _, rrset := range rrsets { + if rrset.Name == name && rrset.Type == typ { + return rrset + } + } + + t.Fatalf("rrset not found: %s %s", name, typ) + return RRSet{} +} + +func hasRRSet(rrsets []RRSet, name, typ string) bool { + for _, rrset := range rrsets { + if rrset.Name == name && rrset.Type == typ { + return true + } + } + + return false +} From ba4cbb8625de3fa1746811a38de5dc2c35a4414c Mon Sep 17 00:00:00 2001 From: Vladimir Ermakov Date: Fri, 3 Apr 2026 19:20:49 +0200 Subject: [PATCH 2/8] Add end-to-end PDNS API test --- .github/workflows/e2e.yml | 33 +++++ tests/e2e/e2e_test.go | 298 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 tests/e2e/e2e_test.go diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..048a9d5 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,33 @@ +name: E2E Tests + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Run e2e tests + run: go test -tags=e2e -v -timeout=5m -count=1 ./tests/e2e/... \ No newline at end of file diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go new file mode 100644 index 0000000..47dd814 --- /dev/null +++ b/tests/e2e/e2e_test.go @@ -0,0 +1,298 @@ +//go:build e2e + +package e2e_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +const e2eAPIKey = "e2e-secret" + +var ( + buildOnce sync.Once + binaryPath string + buildErr error +) + +type runningServer struct { + baseURL string + zonePath string + cmd *exec.Cmd + logs *bytes.Buffer +} + +type pdnsServer struct { + ID string `json:"id"` + DaemonType string `json:"daemon_type"` + Version string `json:"version"` +} + +type pdnsRecord struct { + Content string `json:"content"` +} + +type pdnsRRSet struct { + Name string `json:"name"` + Type string `json:"type"` + TTL int `json:"ttl"` + Records []pdnsRecord `json:"records"` +} + +type pdnsZone struct { + ID string `json:"id"` + Name string `json:"name"` + RRsets []pdnsRRSet `json:"rrsets"` +} + +func TestZoneomaticPDNSE2E(t *testing.T) { + srv := startZoneomatic(t) + client := &http.Client{Timeout: 5 * time.Second} + + t.Run("server discovery", func(t *testing.T) { + server := httpJSON[pdnsServer](t, client, http.MethodGet, srv.baseURL+"/api/v1/servers/localhost", nil) + assert.Equal(t, "localhost", server.ID) + assert.Equal(t, "authoritative", server.DaemonType) + assert.Equal(t, "zoneomatic", server.Version) + }) + + t.Run("zone patch and read", func(t *testing.T) { + payload := strings.NewReader(`{"rrsets":[{"name":"e2e.at.example.com.","type":"A","ttl":120,"changetype":"REPLACE","records":[{"content":"192.0.2.55","disabled":false}]}]}`) + resp := httpDo(t, client, http.MethodPatch, srv.baseURL+"/api/v1/servers/localhost/zones/at.example.com.", payload) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + zoneResp := httpJSON[pdnsZone](t, client, http.MethodGet, srv.baseURL+"/api/v1/servers/localhost/zones/at.example.com.", nil) + assert.Equal(t, "at.example.com.", zoneResp.ID) + + rrset := findRRSet(t, zoneResp.RRsets, "e2e.at.example.com.", "A") + assert.Equal(t, 120, rrset.TTL) + assert.Equal(t, []pdnsRecord{{Content: "192.0.2.55"}}, rrset.Records) + + zoneBuf, err := os.ReadFile(srv.zonePath) + require.NoError(t, err) + assert.Contains(t, string(zoneBuf), "e2e") + assert.Contains(t, string(zoneBuf), "192.0.2.55") + }) + + t.Run("unsupported zone create", func(t *testing.T) { + resp := httpDo(t, client, http.MethodPost, srv.baseURL+"/api/v1/servers/localhost/zones", nil) + assert.Equal(t, http.StatusNotImplemented, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"error":"create zone is not implemented"}`, string(body)) + }) + + t.Run("unauthorized without api key", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, srv.baseURL+"/api/v1/servers/localhost", nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() // nolint:errcheck + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func startZoneomatic(t *testing.T) *runningServer { + t.Helper() + + repoRoot := repositoryRoot(t) + zonePath := copyFixture(t, filepath.Join(repoRoot, "internal", "zone", "testdata", "at.example.com.zone")) + htpasswdPath := writeHTPasswd(t) + listenAddr := freeListenAddr(t) + baseURL := "http://" + listenAddr + + logs := bytes.NewBuffer(nil) + cmd := exec.Command(zoneomaticBinary(t), + "--htpasswd", htpasswdPath, + "--zone", zonePath, + "--listen", listenAddr, + "--debug", + ) + cmd.Dir = repoRoot + cmd.Stdout = logs + cmd.Stderr = logs + + require.NoError(t, cmd.Start()) + + srv := &runningServer{ + baseURL: baseURL, + zonePath: zonePath, + cmd: cmd, + logs: logs, + } + + t.Cleanup(func() { + stopServer(t, srv) + if t.Failed() { + t.Logf("zoneomatic logs:\n%s", srv.logs.String()) + } + }) + + require.Eventually(t, func() bool { + resp, err := http.Get(baseURL + "/health") + if err != nil { + return false + } + defer resp.Body.Close() // nolint:errcheck + + return resp.StatusCode == http.StatusOK + }, 10*time.Second, 100*time.Millisecond, "zoneomatic did not become ready\n%s", srv.logs.String()) + + return srv +} + +func stopServer(t *testing.T, srv *runningServer) { + t.Helper() + + if srv.cmd.Process == nil || (srv.cmd.ProcessState != nil && srv.cmd.ProcessState.Exited()) { + return + } + + _ = srv.cmd.Process.Signal(syscall.SIGTERM) + + done := make(chan error, 1) + go func() { + done <- srv.cmd.Wait() + }() + + select { + case <-time.After(5 * time.Second): + _ = srv.cmd.Process.Kill() + <-done + case <-done: + } +} + +func zoneomaticBinary(t *testing.T) string { + t.Helper() + + buildOnce.Do(func() { + repoRoot := repositoryRoot(t) + buildDir, err := os.MkdirTemp("", "zoneomatic-e2e-build-") + if err != nil { + buildErr = err + return + } + + binaryPath = filepath.Join(buildDir, "zoneomatic") + + cmd := exec.Command("go", "build", "-o", binaryPath, "./cmd/zoneomatic") + cmd.Dir = repoRoot + output, err := cmd.CombinedOutput() + if err != nil { + buildErr = fmt.Errorf("go build failed: %w\n%s", err, output) + } + }) + + require.NoError(t, buildErr) + return binaryPath +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + + _, fileName, _, ok := runtime.Caller(0) + require.True(t, ok) + + return filepath.Clean(filepath.Join(filepath.Dir(fileName), "..", "..")) +} + +func copyFixture(t *testing.T, src string) string { + t.Helper() + + dst := filepath.Join(t.TempDir(), filepath.Base(src)) + buf, err := os.ReadFile(src) + require.NoError(t, err) + require.NoError(t, os.WriteFile(dst, buf, 0600)) + + return dst +} + +func writeHTPasswd(t *testing.T) string { + t.Helper() + + hash, err := bcrypt.GenerateFromPassword([]byte(e2eAPIKey), bcrypt.DefaultCost) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "test.htpasswd") + require.NoError(t, os.WriteFile(path, []byte("e2e:"+string(hash)+"\n"), 0600)) + + return path +} + +func freeListenAddr(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() // nolint:errcheck + + return listener.Addr().String() +} + +func httpDo(t *testing.T, client *http.Client, method, url string, body io.Reader) *http.Response { + t.Helper() + + req, err := http.NewRequest(method, url, body) + require.NoError(t, err) + req.Header.Set("X-API-Key", e2eAPIKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + resp.Body.Close() // nolint:errcheck + }) + + return resp +} + +func httpJSON[T any](t *testing.T, client *http.Client, method, url string, body io.Reader) T { + t.Helper() + + resp := httpDo(t, client, method, url, body) + defer resp.Body.Close() // nolint:errcheck + + buf, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Less(t, resp.StatusCode, 400, string(buf)) + + var ret T + require.NoError(t, json.Unmarshal(buf, &ret)) + return ret +} + +func findRRSet(t *testing.T, rrsets []pdnsRRSet, name, typ string) pdnsRRSet { + t.Helper() + + for _, rrset := range rrsets { + if rrset.Name == name && rrset.Type == typ { + return rrset + } + } + + t.Fatalf("rrset not found: %s %s", name, typ) + return pdnsRRSet{} +} From 14255d3b5ea0924ed17d5b9ca7c915938823a0a0 Mon Sep 17 00:00:00 2001 From: Vladimir Ermakov Date: Sat, 4 Apr 2026 10:05:57 +0200 Subject: [PATCH 3/8] Refine PDNS auth and responses --- internal/htpasswd/htpasswd.go | 12 --- internal/htpasswd/middleware.go | 45 +++++++++--- internal/server/pdns.go | 125 ++++++++++++++++++-------------- internal/server/server_test.go | 19 ++--- tests/e2e/e2e_test.go | 7 +- 5 files changed, 124 insertions(+), 84 deletions(-) diff --git a/internal/htpasswd/htpasswd.go b/internal/htpasswd/htpasswd.go index d2b76d4..ecb4229 100644 --- a/internal/htpasswd/htpasswd.go +++ b/internal/htpasswd/htpasswd.go @@ -12,7 +12,6 @@ import ( type HTPasswd interface { Authenticate(user, password string) (ok, present bool) - AuthenticateAny(password string) (ok bool) } type HTPasswdFile map[string]string @@ -54,14 +53,3 @@ func (s HTPasswdFile) Authenticate(user, password string) (ok bool, present bool return } - -func (s HTPasswdFile) AuthenticateAny(password string) bool { - for user := range s { - ok, present := s.Authenticate(user, password) - if ok && present { - return true - } - } - - return false -} diff --git a/internal/htpasswd/middleware.go b/internal/htpasswd/middleware.go index cf51c08..ede5399 100644 --- a/internal/htpasswd/middleware.go +++ b/internal/htpasswd/middleware.go @@ -1,7 +1,9 @@ package htpasswd import ( + "encoding/base64" "net/http" + "strings" "github.com/go-fuego/fuego" ) @@ -38,14 +40,25 @@ func NewBasicAuthMiddleware(ht HTPasswd) func(http.Handler) http.Handler { } func NewAPIKeyMiddleware(ht HTPasswd) func(http.Handler) http.Handler { + return NewAPIKeyMiddlewareWithUnauthorized(ht, func(w http.ResponseWriter, _ *http.Request) { + err := fuego.HTTPError{ + Title: "unauthorized access", + Detail: "wrong api key", + Status: http.StatusUnauthorized, + } + + fuego.SendJSONError(w, nil, err) + }) +} + +func NewAPIKeyMiddlewareWithUnauthorized(ht HTPasswd, onUnauthorized func(http.ResponseWriter, *http.Request)) func(http.Handler) http.Handler { return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, password, ok := r.BasicAuth() if ok { ok, _ = ht.Authenticate(user, password) } else { - password = r.Header.Get("X-API-Key") - ok = password != "" && ht.AuthenticateAny(password) + ok = AuthenticateAPIKeyHeader(ht, r.Header.Get("X-API-Key")) } if ok { @@ -53,13 +66,27 @@ func NewAPIKeyMiddleware(ht HTPasswd) func(http.Handler) http.Handler { return } - err := fuego.HTTPError{ - Title: "unauthorized access", - Detail: "wrong api key", - Status: http.StatusUnauthorized, - } - - fuego.SendJSONError(w, nil, err) + onUnauthorized(w, r) }) } } + +func AuthenticateAPIKeyHeader(ht HTPasswd, headerValue string) bool { + headerValue = strings.TrimSpace(headerValue) + if headerValue == "" { + return false + } + + decoded, err := base64.StdEncoding.DecodeString(headerValue) + if err != nil { + return false + } + + user, password, ok := strings.Cut(string(decoded), ":") + if !ok || user == "" { + return false + } + + ok, _ = ht.Authenticate(user, password) + return ok +} diff --git a/internal/server/pdns.go b/internal/server/pdns.go index 0fd76ea..1c8db71 100644 --- a/internal/server/pdns.go +++ b/internal/server/pdns.go @@ -1,6 +1,7 @@ package server import ( + "bytes" "encoding/json" "errors" "log/slog" @@ -17,11 +18,16 @@ import ( const pdnsServerID = "localhost" -type pdnsErrorResponse struct { - Error string `json:"error"` - Errors []string `json:"errors,omitempty"` +type pdnsHTTPError struct { + status int + Message string `json:"error"` + Errors []string `json:"errors,omitempty"` } +func (e pdnsHTTPError) Error() string { return e.Message } + +func (e pdnsHTTPError) StatusCode() int { return e.status } + type pdnsServer struct { Type string `json:"type"` ID string `json:"id"` @@ -77,7 +83,9 @@ type pdnsPatchZoneRequest struct { } func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.Controller) { - pdnsAuth := newPDNSAPIKeyMiddleware(htp) + pdnsAuth := htpasswd.NewAPIKeyMiddlewareWithUnauthorized(htp, func(w http.ResponseWriter, r *http.Request) { + sendPDNSError(w, r, http.StatusUnauthorized, "unauthorized") + }) pdnsSecurity := option.Security(openapi3.SecurityRequirement{ "pdnsApiKeyAuth": []string{}, }) @@ -85,7 +93,7 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C fuego.GetStd(srv, "/api/v1/servers", func(w http.ResponseWriter, r *http.Request) { server := pdnsServerInfo() - sendPDNSJSON(w, http.StatusOK, []pdnsServer{server}) + sendPDNSJSON(w, r, http.StatusOK, []pdnsServer{server}) }, option.Summary("pdns list servers"), option.Description("List the forged PowerDNS-compatible server instance"), @@ -99,7 +107,7 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C return } - sendPDNSJSON(w, http.StatusOK, pdnsServerInfo()) + sendPDNSJSON(w, r, http.StatusOK, pdnsServerInfo()) }, option.Summary("pdns get server"), option.Description("Return the forged PowerDNS-compatible server instance"), @@ -115,7 +123,7 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C zones, err := zctl.ListZones(r.Context()) if err != nil { - sendPDNSError(w, http.StatusInternalServerError, err.Error()) + sendPDNSError(w, r, http.StatusInternalServerError, err.Error()) return } @@ -129,7 +137,7 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C result = append(result, zoneSnapshotToPDNSZone(zoneData, false)) } - sendPDNSJSON(w, http.StatusOK, result) + sendPDNSJSON(w, r, http.StatusOK, result) }, option.Summary("pdns list zones"), option.Description("List managed zones in a PowerDNS-compatible format"), @@ -145,7 +153,7 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C zoneData, err := zctl.GetZone(r.Context(), r.PathValue("zone_id")) if err != nil { - sendPDNSZoneError(w, err) + sendPDNSZoneError(w, r, err) return } @@ -153,7 +161,7 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C rrsetName := r.URL.Query().Get("rrset_name") rrsetType := r.URL.Query().Get("rrset_type") if rrsetType != "" && rrsetName == "" { - sendPDNSError(w, http.StatusUnprocessableEntity, "rrset_type requires rrset_name") + sendPDNSError(w, r, http.StatusUnprocessableEntity, "rrset_type requires rrset_name") return } @@ -162,7 +170,7 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C zoneResp.RRsets = filterPDNSRRsets(zoneResp.RRsets, rrsetName, rrsetType) } - sendPDNSJSON(w, http.StatusOK, zoneResp) + sendPDNSJSON(w, r, http.StatusOK, zoneResp) }, option.Summary("pdns get zone"), option.Description("Return a managed zone in PowerDNS-compatible format"), @@ -180,27 +188,27 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C var req pdnsPatchZoneRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - sendPDNSError(w, http.StatusBadRequest, "invalid json body", err.Error()) + sendPDNSError(w, r, http.StatusBadRequest, "invalid json body", err.Error()) return } zoneName := r.PathValue("zone_id") for _, rrset := range req.RRsets { if rrset.Name == "" || rrset.Type == "" { - sendPDNSError(w, http.StatusUnprocessableEntity, "rrset name and type are required") + sendPDNSError(w, r, http.StatusUnprocessableEntity, "rrset name and type are required") return } switch strings.ToUpper(strings.TrimSpace(rrset.ChangeType)) { case "DELETE": if _, err := zctl.DeleteRRSet(r.Context(), zoneName, rrset.Name, rrset.Type); err != nil { - sendPDNSZoneError(w, err) + sendPDNSZoneError(w, r, err) return } case "REPLACE": if len(rrset.Records) == 0 { if _, err := zctl.DeleteRRSet(r.Context(), zoneName, rrset.Name, rrset.Type); err != nil { - sendPDNSZoneError(w, err) + sendPDNSZoneError(w, r, err) return } @@ -208,14 +216,14 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C } if rrset.TTL <= 0 { - sendPDNSError(w, http.StatusUnprocessableEntity, "ttl must be greater than zero for REPLACE") + sendPDNSError(w, r, http.StatusUnprocessableEntity, "ttl must be greater than zero for REPLACE") return } values := make([]string, 0, len(rrset.Records)) for _, record := range rrset.Records { if record.Disabled { - sendPDNSError(w, http.StatusNotImplemented, "disabled records are not supported") + sendPDNSError(w, r, http.StatusNotImplemented, "disabled records are not supported") return } @@ -223,11 +231,11 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C } if _, err := zctl.ReplaceRRSet(r.Context(), zoneName, rrset.Name, rrset.Type, rrset.TTL, values); err != nil { - sendPDNSZoneError(w, err) + sendPDNSZoneError(w, r, err) return } default: - sendPDNSError(w, http.StatusNotImplemented, "unsupported changetype: "+rrset.ChangeType) + sendPDNSError(w, r, http.StatusNotImplemented, "unsupported changetype: "+rrset.ChangeType) return } } @@ -254,7 +262,7 @@ func registerPDNSUnsupportedZoneRoute(srv *fuego.Server, authMw func(http.Handle } slog.WarnContext(r.Context(), "Unsupported PDNS operation", "operation", operation, "path", r.URL.Path) - sendPDNSError(w, http.StatusNotImplemented, operation+" is not implemented") + sendPDNSError(w, r, http.StatusNotImplemented, operation+" is not implemented") } switch method { @@ -347,62 +355,73 @@ func requirePDNSServerID(w http.ResponseWriter, r *http.Request) bool { return true } - sendPDNSError(w, http.StatusNotFound, "server not found") + sendPDNSError(w, r, http.StatusNotFound, "server not found") return false } -func sendPDNSZoneError(w http.ResponseWriter, err error) { +func sendPDNSZoneError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, zone.ErrZoneNotFound): - sendPDNSError(w, http.StatusNotFound, err.Error()) + sendPDNSError(w, r, http.StatusNotFound, err.Error()) case errors.Is(err, zone.ErrRecordNotFound): - sendPDNSError(w, http.StatusNotFound, err.Error()) + sendPDNSError(w, r, http.StatusNotFound, err.Error()) default: - sendPDNSError(w, http.StatusUnprocessableEntity, err.Error()) + sendPDNSError(w, r, http.StatusUnprocessableEntity, err.Error()) } } -func sendPDNSError(w http.ResponseWriter, status int, msg string, errs ...string) { - sendPDNSJSON(w, status, pdnsErrorResponse{Error: msg, Errors: errs}) +func sendPDNSError(w http.ResponseWriter, r *http.Request, status int, msg string, errs ...string) { + fuego.SendJSONError(w, r, pdnsHTTPError{status: status, Message: msg, Errors: errs}) } -func sendPDNSJSON(w http.ResponseWriter, status int, body any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) +func sendPDNSJSON(w http.ResponseWriter, r *http.Request, status int, body any) { if body == nil || status == http.StatusNoContent { + w.WriteHeader(status) return } - if err := json.NewEncoder(w).Encode(body); err != nil { - slog.Error("Failed to encode PDNS response", "error", err) + bw := &pdnsBufferedWriter{header: make(http.Header), status: status} + bw.WriteHeader(status) + if err := fuego.SendJSON(bw, r, body); err != nil { + fuego.SendJSONError(w, r, pdnsHTTPError{ + status: http.StatusInternalServerError, + Message: "failed to encode response", + Errors: []string{err.Error()}, + }) + return } -} -func dnsFQDN(name string) string { - if name == "" { - return "" + copyHeader(w.Header(), bw.Header()) + w.WriteHeader(bw.status) + if _, err := w.Write(bw.body.Bytes()); err != nil { + slog.ErrorContext(r.Context(), "Failed to write PDNS response", "error", err) } +} - return strings.TrimSpace(strings.TrimSuffix(name, ".")) + "." +type pdnsBufferedWriter struct { + header http.Header + status int + body bytes.Buffer } -func newPDNSAPIKeyMiddleware(ht htpasswd.HTPasswd) func(http.Handler) http.Handler { - return func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, password, ok := r.BasicAuth() - if ok { - ok, _ = ht.Authenticate(user, password) - } else { - password = r.Header.Get("X-API-Key") - ok = password != "" && ht.AuthenticateAny(password) - } +func (w *pdnsBufferedWriter) Header() http.Header { return w.header } - if ok { - h.ServeHTTP(w, r) - return - } +func (w *pdnsBufferedWriter) WriteHeader(status int) { w.status = status } - sendPDNSError(w, http.StatusUnauthorized, "unauthorized") - }) +func (w *pdnsBufferedWriter) Write(p []byte) (int, error) { return w.body.Write(p) } + +func copyHeader(dst, src http.Header) { + for key, values := range src { + for _, value := range values { + dst.Add(key, value) + } + } +} + +func dnsFQDN(name string) string { + if name == "" { + return "" } + + return strings.TrimSpace(strings.TrimSuffix(name, ".")) + "." } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 941c725..d753da2 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/base64" "encoding/json" "fmt" "net/http" @@ -30,10 +31,6 @@ func (f fakeHTPasswd) Authenticate(user, password string) (ok, present bool) { return password == f.pass, true } -func (f fakeHTPasswd) AuthenticateAny(password string) bool { - return password == f.pass -} - type fakeRRSetReplaceCall struct { zoneName string name string @@ -252,7 +249,7 @@ func TestPDNSServerDiscovery(t *testing.T) { srv := newTestServer(htp, zctl) req := httptest.NewRequest(http.MethodGet, "/api/v1/servers/localhost", nil) - req.Header.Set("X-API-Key", "p") + req.Header.Set("X-API-Key", testPDNSAPIKey("u", "p")) rec := httptest.NewRecorder() srv.Mux.ServeHTTP(rec, req) @@ -284,7 +281,7 @@ func TestPDNSZoneGet(t *testing.T) { srv := newTestServer(htp, zctl) req := httptest.NewRequest(http.MethodGet, "/api/v1/servers/localhost/zones/example.com.?rrsets=false", nil) - req.Header.Set("X-API-Key", "p") + req.Header.Set("X-API-Key", testPDNSAPIKey("u", "p")) rec := httptest.NewRecorder() srv.Mux.ServeHTTP(rec, req) @@ -297,7 +294,7 @@ func TestPDNSZoneGet(t *testing.T) { assert.False(t, hasRRsets) req = httptest.NewRequest(http.MethodGet, "/api/v1/servers/localhost/zones/example.com.", nil) - req.Header.Set("X-API-Key", "p") + req.Header.Set("X-API-Key", testPDNSAPIKey("u", "p")) rec = httptest.NewRecorder() srv.Mux.ServeHTTP(rec, req) @@ -315,7 +312,7 @@ func TestPDNSPatchZoneReplaceAndDelete(t *testing.T) { patchBody := `{"rrsets":[{"name":"www.example.com.","type":"A","ttl":60,"changetype":"REPLACE","records":[{"content":"1.2.3.4","disabled":false}]},{"name":"old.example.com.","type":"TXT","changetype":"DELETE","records":[]}]}` req := httptest.NewRequest(http.MethodPatch, "/api/v1/servers/localhost/zones/example.com.", strings.NewReader(patchBody)) - req.Header.Set("X-API-Key", "p") + req.Header.Set("X-API-Key", testPDNSAPIKey("u", "p")) rec := httptest.NewRecorder() srv.Mux.ServeHTTP(rec, req) @@ -355,10 +352,14 @@ func TestPDNSUnsupportedZoneOperation(t *testing.T) { srv := newTestServer(htp, zctl) req := httptest.NewRequest(http.MethodPost, "/api/v1/servers/localhost/zones", nil) - req.Header.Set("X-API-Key", "p") + req.Header.Set("X-API-Key", testPDNSAPIKey("u", "p")) rec := httptest.NewRecorder() srv.Mux.ServeHTTP(rec, req) assert.Equal(t, http.StatusNotImplemented, rec.Code) assert.JSONEq(t, `{"error":"create zone is not implemented"}`, rec.Body.String()) } + +func testPDNSAPIKey(user, password string) string { + return base64.StdEncoding.EncodeToString([]byte(user + ":" + password)) +} diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 47dd814..169ef00 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -4,6 +4,7 @@ package e2e_test import ( "bytes" + "encoding/base64" "encoding/json" "fmt" "io" @@ -255,7 +256,7 @@ func httpDo(t *testing.T, client *http.Client, method, url string, body io.Reade req, err := http.NewRequest(method, url, body) require.NoError(t, err) - req.Header.Set("X-API-Key", e2eAPIKey) + req.Header.Set("X-API-Key", pdnsAPIKey("e2e", e2eAPIKey)) if body != nil { req.Header.Set("Content-Type", "application/json") } @@ -296,3 +297,7 @@ func findRRSet(t *testing.T, rrsets []pdnsRRSet, name, typ string) pdnsRRSet { t.Fatalf("rrset not found: %s %s", name, typ) return pdnsRRSet{} } + +func pdnsAPIKey(user, password string) string { + return base64.StdEncoding.EncodeToString([]byte(user + ":" + password)) +} From 2952ea8a4e3da3732b4925be2a1d8f1096fd3ebd Mon Sep 17 00:00:00 2001 From: Vladimir Ermakov Date: Sat, 4 Apr 2026 10:50:18 +0200 Subject: [PATCH 4/8] Refactor PDNS GET handlers --- internal/server/pdns.go | 78 ++++++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/internal/server/pdns.go b/internal/server/pdns.go index 1c8db71..6c00612 100644 --- a/internal/server/pdns.go +++ b/internal/server/pdns.go @@ -90,10 +90,9 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C "pdnsApiKeyAuth": []string{}, }) - fuego.GetStd(srv, "/api/v1/servers", - func(w http.ResponseWriter, r *http.Request) { - server := pdnsServerInfo() - sendPDNSJSON(w, r, http.StatusOK, []pdnsServer{server}) + fuego.Get(srv, "/api/v1/servers", + func(ctx fuego.ContextNoBody) ([]pdnsServer, error) { + return []pdnsServer{pdnsServerInfo()}, nil }, option.Summary("pdns list servers"), option.Description("List the forged PowerDNS-compatible server instance"), @@ -101,13 +100,14 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C pdnsSecurity, ) - fuego.GetStd(srv, "/api/v1/servers/{server_id}", - func(w http.ResponseWriter, r *http.Request) { - if !requirePDNSServerID(w, r) { - return + fuego.Get(srv, "/api/v1/servers/{server_id}", + func(ctx fuego.ContextNoBody) (pdnsServer, error) { + serverID := ctx.PathParam("server_id") + if serverID != pdnsServerID { + return pdnsServer{}, newPDNSError(http.StatusNotFound, "server not found") } - sendPDNSJSON(w, r, http.StatusOK, pdnsServerInfo()) + return pdnsServerInfo(), nil }, option.Summary("pdns get server"), option.Description("Return the forged PowerDNS-compatible server instance"), @@ -115,19 +115,19 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C pdnsSecurity, ) - fuego.GetStd(srv, "/api/v1/servers/{server_id}/zones", - func(w http.ResponseWriter, r *http.Request) { - if !requirePDNSServerID(w, r) { - return + fuego.Get(srv, "/api/v1/servers/{server_id}/zones", + func(ctx fuego.ContextNoBody) ([]pdnsZone, error) { + serverID := ctx.PathParam("server_id") + if serverID != pdnsServerID { + return nil, newPDNSError(http.StatusNotFound, "server not found") } - zones, err := zctl.ListZones(r.Context()) + zones, err := zctl.ListZones(ctx) if err != nil { - sendPDNSError(w, r, http.StatusInternalServerError, err.Error()) - return + return nil, newPDNSError(http.StatusInternalServerError, err.Error()) } - zoneFilter := r.URL.Query().Get("zone") + zoneFilter := ctx.QueryParam("zone") result := make([]pdnsZone, 0, len(zones)) for _, zoneData := range zones { if zoneFilter != "" && zoneData.Name != dnsFQDN(zoneFilter) { @@ -137,32 +137,39 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C result = append(result, zoneSnapshotToPDNSZone(zoneData, false)) } - sendPDNSJSON(w, r, http.StatusOK, result) + return result, nil }, option.Summary("pdns list zones"), option.Description("List managed zones in a PowerDNS-compatible format"), option.Middleware(pdnsAuth), pdnsSecurity, + option.Query("zone", "Filter zones by fully-qualified zone name"), ) - fuego.GetStd(srv, "/api/v1/servers/{server_id}/zones/{zone_id}", - func(w http.ResponseWriter, r *http.Request) { - if !requirePDNSServerID(w, r) { - return + fuego.Get(srv, "/api/v1/servers/{server_id}/zones/{zone_id}", + func(ctx fuego.ContextNoBody) (*pdnsZone, error) { + serverID := ctx.PathParam("server_id") + if serverID != pdnsServerID { + return nil, newPDNSError(http.StatusNotFound, "server not found") } - zoneData, err := zctl.GetZone(r.Context(), r.PathValue("zone_id")) + zoneData, err := zctl.GetZone(ctx, ctx.PathParam("zone_id")) if err != nil { - sendPDNSZoneError(w, r, err) - return + switch { + case errors.Is(err, zone.ErrZoneNotFound): + return nil, newPDNSError(http.StatusNotFound, err.Error()) + case errors.Is(err, zone.ErrRecordNotFound): + return nil, newPDNSError(http.StatusNotFound, err.Error()) + default: + return nil, newPDNSError(http.StatusUnprocessableEntity, err.Error()) + } } - includeRRsets := !strings.EqualFold(r.URL.Query().Get("rrsets"), "false") - rrsetName := r.URL.Query().Get("rrset_name") - rrsetType := r.URL.Query().Get("rrset_type") + includeRRsets := !strings.EqualFold(ctx.QueryParam("rrsets"), "false") + rrsetName := ctx.QueryParam("rrset_name") + rrsetType := ctx.QueryParam("rrset_type") if rrsetType != "" && rrsetName == "" { - sendPDNSError(w, r, http.StatusUnprocessableEntity, "rrset_type requires rrset_name") - return + return nil, newPDNSError(http.StatusUnprocessableEntity, "rrset_type requires rrset_name") } zoneResp := zoneSnapshotToPDNSZone(zoneData, includeRRsets) @@ -170,12 +177,15 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C zoneResp.RRsets = filterPDNSRRsets(zoneResp.RRsets, rrsetName, rrsetType) } - sendPDNSJSON(w, r, http.StatusOK, zoneResp) + return &zoneResp, nil }, option.Summary("pdns get zone"), option.Description("Return a managed zone in PowerDNS-compatible format"), option.Middleware(pdnsAuth), pdnsSecurity, + option.QueryBool("rrsets", "Include rrsets in the zone response. Defaults to true."), + option.Query("rrset_name", "Filter returned rrsets by fully-qualified record name"), + option.Query("rrset_type", "Filter returned rrsets by record type; requires rrset_name"), ) fuego.PatchStd(srv, "/api/v1/servers/{server_id}/zones/{zone_id}", @@ -371,7 +381,7 @@ func sendPDNSZoneError(w http.ResponseWriter, r *http.Request, err error) { } func sendPDNSError(w http.ResponseWriter, r *http.Request, status int, msg string, errs ...string) { - fuego.SendJSONError(w, r, pdnsHTTPError{status: status, Message: msg, Errors: errs}) + fuego.SendJSONError(w, r, newPDNSError(status, msg, errs...)) } func sendPDNSJSON(w http.ResponseWriter, r *http.Request, status int, body any) { @@ -418,6 +428,10 @@ func copyHeader(dst, src http.Header) { } } +func newPDNSError(status int, msg string, errs ...string) pdnsHTTPError { + return pdnsHTTPError{status: status, Message: msg, Errors: errs} +} + func dnsFQDN(name string) string { if name == "" { return "" From c3c05e1f58a0bb2b64ab24c198f130dc222a6e59 Mon Sep 17 00:00:00 2001 From: Vladimir Ermakov Date: Sat, 4 Apr 2026 10:55:02 +0200 Subject: [PATCH 5/8] Document PDNS patch schemas --- internal/server/pdns.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/internal/server/pdns.go b/internal/server/pdns.go index 6c00612..95f00b5 100644 --- a/internal/server/pdns.go +++ b/internal/server/pdns.go @@ -256,6 +256,30 @@ func registerPDNSEndpoints(srv *fuego.Server, htp htpasswd.HTPasswd, zctl zone.C option.Description("Replace or delete managed RRSets in PowerDNS-compatible format"), option.Middleware(pdnsAuth), pdnsSecurity, + option.RequestBody( + fuego.RequestBody{ + Type: new(pdnsPatchZoneRequest), + ContentTypes: []string{"application/json"}, + }, + ), + option.AddResponse(http.StatusNoContent, "RRSets updated", + fuego.Response{Type: struct{}{}}, + ), + option.AddResponse(http.StatusBadRequest, "Invalid request body", + fuego.Response{Type: new(pdnsHTTPError)}, + ), + option.AddResponse(http.StatusUnauthorized, "Unauthorized", + fuego.Response{Type: new(pdnsHTTPError)}, + ), + option.AddResponse(http.StatusNotFound, "Zone or server not found", + fuego.Response{Type: new(pdnsHTTPError)}, + ), + option.AddResponse(http.StatusUnprocessableEntity, "Invalid rrset request", + fuego.Response{Type: new(pdnsHTTPError)}, + ), + option.AddResponse(http.StatusNotImplemented, "Unsupported patch request", + fuego.Response{Type: new(pdnsHTTPError)}, + ), ) registerPDNSUnsupportedZoneRoute(srv, pdnsAuth, "/api/v1/servers/{server_id}/zones", http.MethodPost, "create zone") From dd03592105916d7fdf04a22e32268b6221c953d6 Mon Sep 17 00:00:00 2001 From: Vladimir Ermakov Date: Sat, 4 Apr 2026 11:01:21 +0200 Subject: [PATCH 6/8] Fix golangci-lint issues --- internal/server/pdns.go | 45 ---------------------------------------- pkg/zonefile/set.go | 8 +++---- pkg/zonefile/zonefile.go | 15 -------------- 3 files changed, 3 insertions(+), 65 deletions(-) diff --git a/internal/server/pdns.go b/internal/server/pdns.go index 95f00b5..5d30ce7 100644 --- a/internal/server/pdns.go +++ b/internal/server/pdns.go @@ -1,7 +1,6 @@ package server import ( - "bytes" "encoding/json" "errors" "log/slog" @@ -408,50 +407,6 @@ func sendPDNSError(w http.ResponseWriter, r *http.Request, status int, msg strin fuego.SendJSONError(w, r, newPDNSError(status, msg, errs...)) } -func sendPDNSJSON(w http.ResponseWriter, r *http.Request, status int, body any) { - if body == nil || status == http.StatusNoContent { - w.WriteHeader(status) - return - } - - bw := &pdnsBufferedWriter{header: make(http.Header), status: status} - bw.WriteHeader(status) - if err := fuego.SendJSON(bw, r, body); err != nil { - fuego.SendJSONError(w, r, pdnsHTTPError{ - status: http.StatusInternalServerError, - Message: "failed to encode response", - Errors: []string{err.Error()}, - }) - return - } - - copyHeader(w.Header(), bw.Header()) - w.WriteHeader(bw.status) - if _, err := w.Write(bw.body.Bytes()); err != nil { - slog.ErrorContext(r.Context(), "Failed to write PDNS response", "error", err) - } -} - -type pdnsBufferedWriter struct { - header http.Header - status int - body bytes.Buffer -} - -func (w *pdnsBufferedWriter) Header() http.Header { return w.header } - -func (w *pdnsBufferedWriter) WriteHeader(status int) { w.status = status } - -func (w *pdnsBufferedWriter) Write(p []byte) (int, error) { return w.body.Write(p) } - -func copyHeader(dst, src http.Header) { - for key, values := range src { - for _, value := range values { - dst.Add(key, value) - } - } -} - func newPDNSError(status int, msg string, errs ...string) pdnsHTTPError { return pdnsHTTPError{status: status, Message: msg, Errors: errs} } diff --git a/pkg/zonefile/set.go b/pkg/zonefile/set.go index 537a662..820e4d5 100644 --- a/pkg/zonefile/set.go +++ b/pkg/zonefile/set.go @@ -9,20 +9,18 @@ func (t *token) SetValue(v []byte) { if !t.IsItem() { panic("not implemented") // XXX } + tmp := bytes.ReplaceAll(v, []byte("\\"), []byte("\\\\")) + tmp = bytes.ReplaceAll(tmp, []byte("\""), []byte("\\\"")) + if bytes.IndexByte(v, ' ') >= 0 { // XXX replace non-printable characters (even though the rfc // would allow them). - tmp := bytes.Replace(v, []byte("\\"), []byte("\\\\"), -1) - tmp = bytes.Replace(v, []byte("\""), []byte("\\\""), -1) t.typ = tokenQuotedItem t.val = []byte("\"" + string(tmp) + "\"") return } - tmp := bytes.Replace(v, []byte("\\"), []byte("\\\\"), -1) - tmp = bytes.Replace(v, []byte("\""), []byte("\\\""), -1) t.typ = tokenItem t.val = tmp - return } // Set the the ith value of the entry diff --git a/pkg/zonefile/zonefile.go b/pkg/zonefile/zonefile.go index b4b55e0..f054678 100644 --- a/pkg/zonefile/zonefile.go +++ b/pkg/zonefile/zonefile.go @@ -522,21 +522,6 @@ func (l *lexer) backup() { } } -func (l *lexer) peek() byte { - r := l.next() - l.backup() - return r -} - -// Consumes next byte if it's in the given string -func (l *lexer) accept(valid string) bool { - if strings.ContainsRune(valid, rune(l.next())) { - return true - } - l.backup() - return false -} - // Consumes run of bytes from the given string func (l *lexer) acceptRun(valid string) { for strings.ContainsRune(valid, rune(l.next())) { From 5f441313cfcbfde904726dd83e18491674ba8585 Mon Sep 17 00:00:00 2001 From: Vladimir Ermakov Date: Sat, 4 Apr 2026 11:10:01 +0200 Subject: [PATCH 7/8] Use GoReleaser for container images --- .github/workflows/build.yaml | 32 ++++++++++++++++++ .github/workflows/release.yml | 62 ++++++++++------------------------- .goreleaser.yml | 24 ++++++++++++++ Dockerfile | 12 ++----- Dockerfile.dev | 17 ++++++++++ 5 files changed, 92 insertions(+), 55 deletions(-) create mode 100644 Dockerfile.dev diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0ee7698..639a143 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -35,3 +35,35 @@ jobs: GOARCH: ${{ matrix.goarch }} CGO_ENABLED: 0 run: go build -trimpath ./cmd/... + + docker-dev: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Build local dev container + run: docker build -f Dockerfile.dev . + + goreleaser-snapshot: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Validate GoReleaser snapshot + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --snapshot --clean --skip=publish + env: + IMAGE_REPO: ghcr.io/${{ github.repository }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85f8c10..5c945d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,56 +24,28 @@ jobs: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache: true - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 - with: - version: "~> v2" - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - container-image: - runs-on: ubuntu-latest - needs: goreleaser - timeout-minutes: 30 - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 +======= - - name: Docker meta - id: meta - uses: docker/metadata-action@v6 - with: - images: | - ghcr.io/${{ github.repository }} - tags: | - type=ref,event=tag - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha +>>>>>>> 3cb2357 (Use GoReleaser for container images) - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Build and push to registry - uses: docker/build-push-action@v7 +<<<<<<< HEAD + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 +>>>>>>> 3cb2357 (Use GoReleaser for container images) with: - push: true - context: . - file: ./Dockerfile - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64,linux/arm64 - provenance: true - sbom: true + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IMAGE_REPO: ghcr.io/${{ github.repository }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 09449e6..6dc1d09 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -42,6 +42,30 @@ archives: - LICENSE - README.md +dockers_v2: + - id: zoneomatic-container + dockerfile: Dockerfile + ids: + - zoneomatic + images: + - "{{ .Env.IMAGE_REPO }}" + tags: + - "{{ .Tag }}" + - "{{ .Version }}" + - "{{ .Major }}.{{ .Minor }}" + - "sha-{{ .ShortCommit }}" + labels: + org.opencontainers.image.description: "Zone-o-matic DNS API Server" + org.opencontainers.image.created: "{{ .Date }}" + org.opencontainers.image.name: "zoneomatic" + org.opencontainers.image.revision: "{{ .FullCommit }}" + org.opencontainers.image.source: "{{ .GitURL }}" + org.opencontainers.image.title: "zoneomatic" + org.opencontainers.image.version: "{{ .Version }}" + platforms: + - linux/amd64 + - linux/arm64 + changelog: sort: asc filters: diff --git a/Dockerfile b/Dockerfile index c6507cb..1298828 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,8 @@ -FROM ghcr.io/goreleaser/goreleaser-cross:v1.26 AS builder - -WORKDIR /build - -COPY . . - -RUN goreleaser build --snapshot --single-target - FROM scratch -LABEL org.opencontainers.image.description="Zone-o-matic DNS API Server" +ARG TARGETPLATFORM -COPY --from=builder /build/dist/zoneomatic*/zoneomatic /zoneomatic +COPY $TARGETPLATFORM/zoneomatic /zoneomatic EXPOSE 9999 diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..ed22be2 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,17 @@ +FROM ghcr.io/goreleaser/goreleaser-cross:v1.26 AS builder + +WORKDIR /build + +COPY . . + +RUN goreleaser build --snapshot --single-target + +FROM scratch + +LABEL org.opencontainers.image.description="Zone-o-matic DNS API Server" + +COPY --from=builder /build/dist/zoneomatic*/zoneomatic /zoneomatic + +EXPOSE 9999 + +ENTRYPOINT ["/zoneomatic"] \ No newline at end of file From ba4b5340e9f367b9287a8c8ee858c704dd990ab6 Mon Sep 17 00:00:00 2001 From: Vladimir Ermakov Date: Sat, 4 Apr 2026 11:18:34 +0200 Subject: [PATCH 8/8] Fix release workflow merge artifact --- .github/workflows/release.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c945d3..22131f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,27 +22,28 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + - name: Set up Go uses: actions/setup-go@v6 -======= + with: + go-version-file: go.mod + cache: true ->>>>>>> 3cb2357 (Use GoReleaser for container images) - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} - + password: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU -<<<<<<< HEAD + uses: docker/setup-qemu-action@v4 - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 ->>>>>>> 3cb2357 (Use GoReleaser for container images) + uses: goreleaser/goreleaser-action@v7 with: version: "~> v2" args: release --clean