package service import ( "context" "errors" "fmt" "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") // 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 { return nil, provider.Credentials{}, ref, diff.Changeset{}, 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{}, 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 }