879e9e14b1
resolve (shared by Check/Apply) and Apply now wrap GetRecords/ApplyChanges failures in service.ErrProviderUnavailable, matching ZoneRecords' existing behavior. handleApply/handleCheck use errors.Is against it to return 502 with the real provider message (e.g. Selectel's 409 conflict body) instead of masking every failure as a generic 500 "internal error"; non-provider errors (decrypt/db/loader) are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
186 lines
6.5 KiB
Go
186 lines
6.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/vasyakrg/dns-autoresolver/internal/crypto"
|
|
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
|
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
|
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
|
|
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
|
"github.com/vasyakrg/dns-autoresolver/internal/tmpl"
|
|
)
|
|
|
|
// ErrProviderUnavailable marks failures that happened while talking to the
|
|
// DNS provider itself (network, auth, rate limit, ...), as opposed to
|
|
// failures resolving the domain/zone locally (not found, bad credentials
|
|
// stored, unknown provider name). Callers use errors.Is against this to
|
|
// pick 502 vs 404 without leaking provider error details as "not found".
|
|
var ErrProviderUnavailable = errors.New("service: provider unavailable")
|
|
|
|
// ProviderMessage extracts the provider's own error text from an error
|
|
// wrapped with ErrProviderUnavailable, stripping the sentinel prefix so
|
|
// callers can surface it to the user as-is (e.g. Selectel's "409: conflicting
|
|
// CNAME record exists"). Only meant to be called on errors that
|
|
// errors.Is(err, ErrProviderUnavailable) — otherwise it just returns
|
|
// err.Error() unchanged.
|
|
func ProviderMessage(err error) string {
|
|
return strings.TrimPrefix(err.Error(), ErrProviderUnavailable.Error()+": ")
|
|
}
|
|
|
|
// DomainRef is the minimal data the service needs about a domain.
|
|
type DomainRef struct {
|
|
ZoneID string
|
|
ZoneName string
|
|
Provider string
|
|
SecretEnc string
|
|
Template dto.TemplateDoc
|
|
}
|
|
|
|
// ZoneRef is the provider-access subset of a domain, without a template —
|
|
// enough to read a zone's live records.
|
|
type ZoneRef struct {
|
|
ZoneID string
|
|
Provider string
|
|
SecretEnc string
|
|
}
|
|
|
|
type Loader interface {
|
|
LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (DomainRef, error)
|
|
LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (ZoneRef, error)
|
|
}
|
|
|
|
type Recorder interface {
|
|
SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error
|
|
}
|
|
|
|
type ApplyRequest struct {
|
|
Updates []string // record keys (RecordDiff.Key) to add/update
|
|
Prunes []string // record keys to delete
|
|
}
|
|
|
|
type DomainService struct {
|
|
loader Loader
|
|
rec Recorder
|
|
reg *registry.Registry
|
|
cipher *crypto.Cipher
|
|
}
|
|
|
|
func New(loader Loader, rec Recorder, reg *registry.Registry, cipher *crypto.Cipher) *DomainService {
|
|
return &DomainService{loader: loader, rec: rec, reg: reg, cipher: cipher}
|
|
}
|
|
|
|
// resolve loads the domain, its provider and decrypted credentials, and computes the diff.
|
|
// projectID scopes the lookup so a domainID belonging to another tenant's
|
|
// project can never be resolved here (closes IDOR).
|
|
func (s *DomainService) resolve(ctx context.Context, projectID, domainID uuid.UUID) (provider.Provider, provider.Credentials, DomainRef, diff.Changeset, error) {
|
|
ref, err := s.loader.LoadDomain(ctx, projectID, domainID)
|
|
if err != nil {
|
|
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
|
|
}
|
|
p, err := s.reg.ByName(ref.Provider)
|
|
if err != nil {
|
|
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
|
|
}
|
|
secret, err := s.cipher.Decrypt(ref.SecretEnc)
|
|
if err != nil {
|
|
return nil, provider.Credentials{}, ref, diff.Changeset{}, err
|
|
}
|
|
creds := provider.Credentials{Secret: string(secret)}
|
|
actual, err := p.GetRecords(ctx, creds, ref.ZoneID)
|
|
if err != nil {
|
|
// Only a failure of the provider call itself is "provider unavailable" —
|
|
// LoadDomain/ByName/Decrypt errors above are local resolution failures
|
|
// (e.g. domain not found, bad stored credentials) and must not be
|
|
// conflated with it.
|
|
return nil, provider.Credentials{}, ref, diff.Changeset{}, fmt.Errorf("%w: %v", ErrProviderUnavailable, err)
|
|
}
|
|
cs := diff.Diff(tmpl.Materialize(ref.Template, ref.ZoneName), actual)
|
|
return p, creds, ref, cs, nil
|
|
}
|
|
|
|
// Check computes and records the diff between template and zone.
|
|
func (s *DomainService) Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error) {
|
|
_, _, _, cs, err := s.resolve(ctx, projectID, domainID)
|
|
if err != nil {
|
|
return diff.Changeset{}, err
|
|
}
|
|
if err := s.rec.SaveCheckRun(ctx, domainID, cs); err != nil {
|
|
return diff.Changeset{}, err
|
|
}
|
|
return cs, nil
|
|
}
|
|
|
|
// ZoneRecords reads a zone's current records straight from the provider,
|
|
// with no diff and no template required. Used for read-only zone viewing and
|
|
// as the source for a snapshot template.
|
|
func (s *DomainService) ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error) {
|
|
ref, err := s.loader.LoadZone(ctx, projectID, domainID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p, err := s.reg.ByName(ref.Provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
secret, err := s.cipher.Decrypt(ref.SecretEnc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
recs, err := p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID)
|
|
if err != nil {
|
|
// Only a failure of the provider call itself is "provider unavailable" —
|
|
// LoadZone/ByName/Decrypt errors above are local resolution failures
|
|
// (e.g. domain not found) and must not be conflated with it.
|
|
return nil, fmt.Errorf("%w: %v", ErrProviderUnavailable, err)
|
|
}
|
|
return recs, nil
|
|
}
|
|
|
|
// Apply applies exactly the diffs whose keys are selected in req.Updates and
|
|
// req.Prunes. Selected prunes are added to the applied set BEFORE selected
|
|
// updates: deletes first is an invariant, not an option — the provider
|
|
// rejects an Add/Update whose name still has a conflicting record (e.g. a
|
|
// CNAME cannot be created while an A on the same name exists), so pruning the
|
|
// old records before applying updates avoids that.
|
|
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
|
|
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
|
|
if err != nil {
|
|
return diff.Changeset{}, err
|
|
}
|
|
selPrunes := toSet(req.Prunes)
|
|
selUpdates := toSet(req.Updates)
|
|
var toApply []diff.RecordDiff
|
|
for _, d := range cs.Prunes() {
|
|
if selPrunes[d.Key()] {
|
|
toApply = append(toApply, d)
|
|
}
|
|
}
|
|
for _, d := range cs.Updates() {
|
|
if selUpdates[d.Key()] {
|
|
toApply = append(toApply, d)
|
|
}
|
|
}
|
|
applied := diff.Changeset{Diffs: toApply}
|
|
if len(toApply) > 0 {
|
|
if err := p.ApplyChanges(ctx, creds, ref.ZoneID, applied); err != nil {
|
|
return diff.Changeset{}, fmt.Errorf("%w: %v", ErrProviderUnavailable, err)
|
|
}
|
|
}
|
|
return applied, nil
|
|
}
|
|
|
|
func toSet(keys []string) map[string]bool {
|
|
m := make(map[string]bool, len(keys))
|
|
for _, k := range keys {
|
|
m[k] = true
|
|
}
|
|
return m
|
|
}
|