diff --git a/internal/api/controller/domain.go b/internal/api/controller/domain.go index a1bda054..2122f1a8 100644 --- a/internal/api/controller/domain.go +++ b/internal/api/controller/domain.go @@ -98,7 +98,7 @@ func (dc *DomainController) AddDomain(c *gin.Context) { return } - var uz happydns.Domain + var uz happydns.DomainCreationInput err := c.ShouldBindJSON(&uz) if err != nil { log.Printf("%s sends invalid Domain JSON: %s", c.ClientIP(), err.Error()) @@ -106,13 +106,13 @@ func (dc *DomainController) AddDomain(c *gin.Context) { return } - err = dc.domainService.CreateDomain(c.Request.Context(), user, &uz) + domain, err := dc.domainService.CreateDomain(c.Request.Context(), user, &uz) if err != nil { middleware.ErrorResponse(c, http.StatusInternalServerError, err) return } - c.JSON(http.StatusOK, uz) + c.JSON(http.StatusOK, domain) } // GetDomain retrieves information about a given Domain owned by the user. diff --git a/internal/service/analyzer.go b/internal/service/analyzer.go index 5d0cc2cc..585ad672 100644 --- a/internal/service/analyzer.go +++ b/internal/service/analyzer.go @@ -56,6 +56,7 @@ func (p *recordPool) SearchRR(arrs ...AnalyzerRecordFilter) (rrs []happydns.Reco rdtype := rhdr.Rrtype if strings.HasPrefix(rhdr.Name, arr.Prefix) && strings.HasSuffix(rhdr.Name, arr.SubdomainsOf) && + strings.Contains(rhdr.Name, arr.DomainContains) && (arr.Domain == "" || rhdr.Name == arr.Domain || rhdr.Name == strings.TrimSuffix(arr.Domain, ".")) && (arr.Type == 0 || rdtype == arr.Type) && (arr.Ttl == 0 || rhdr.Ttl == arr.Ttl) && @@ -133,12 +134,13 @@ func (sa *serviceAccumulator) addService(rr happydns.Record, domain string, svc // AnalyzerRecordFilter specifies criteria for matching DNS records. // Zero-value fields are treated as wildcards (match anything). type AnalyzerRecordFilter struct { - Prefix string - Domain string - SubdomainsOf string - Contains string - Type uint16 - Ttl uint32 + Prefix string + Domain string + SubdomainsOf string + DomainContains string + Contains string + Type uint16 + Ttl uint32 } // Analyzer holds the state for zone analysis. It is composed of a recordPool diff --git a/internal/usecase/domain/domain.go b/internal/usecase/domain/domain.go index 92c73f21..19c2c19d 100644 --- a/internal/usecase/domain/domain.go +++ b/internal/usecase/domain/domain.go @@ -66,23 +66,23 @@ func NewService( } // CreateDomain creates a new domain for the given user. -func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, uz *happydns.Domain) error { - uz, err := happydns.NewDomain(user, uz.DomainName, uz.ProviderId) +func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, input *happydns.DomainCreationInput) (*happydns.Domain, error) { + uz, err := happydns.NewDomain(user, input.DomainName, input.ProviderId) if err != nil { - return err + return nil, err } provider, err := s.providerService.GetUserProvider(ctx, user, uz.ProviderId) if err != nil { - return happydns.ValidationError{Msg: fmt.Sprintf("unable to find the provider.")} + return nil, happydns.ValidationError{Msg: fmt.Sprintf("unable to find the provider.")} } if err = s.domainExistence.TestDomainExistence(ctx, provider, uz.DomainName); err != nil { - return happydns.NotFoundError{Msg: err.Error()} + return nil, happydns.NotFoundError{Msg: err.Error()} } if err := s.store.CreateDomain(uz); err != nil { - return happydns.InternalError{ + return nil, happydns.InternalError{ Err: fmt.Errorf("unable to CreateDomain: %s", err), UserMessage: "Sorry, we are unable to create your domain now.", } @@ -93,7 +93,7 @@ func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, uz *hap s.domainLogAppender.AppendDomainLog(uz, happydns.NewDomainLog(user, happydns.LOG_INFO, fmt.Sprintf("Domain name %s added.", uz.DomainName))) } - return nil + return uz, nil } // GetUserDomain retrieves a domain by ID for the given user. diff --git a/internal/usecase/domain/domain_test.go b/internal/usecase/domain/domain_test.go index 70caa7f4..7c556332 100644 --- a/internal/usecase/domain/domain_test.go +++ b/internal/usecase/domain/domain_test.go @@ -155,12 +155,12 @@ func Test_CreateDomain(t *testing.T) { user := createTestUser(t, db, "test@example.com") providerId := createTestProvider(t, db, user, "Test Provider") - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: "example.com", ProviderId: providerId, } - err := service.CreateDomain(ctx, user, domainToCreate) + _, err := service.CreateDomain(ctx, user, domainToCreate) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -200,12 +200,12 @@ func Test_CreateDomain_InvalidProvider(t *testing.T) { user := createTestUser(t, db, "test@example.com") invalidProviderId := happydns.Identifier([]byte("invalid-provider")) - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: "example.com", ProviderId: invalidProviderId, } - err := service.CreateDomain(ctx, user, domainToCreate) + _, err := service.CreateDomain(ctx, user, domainToCreate) if err == nil { t.Error("expected error when creating domain with invalid provider") } @@ -219,11 +219,11 @@ func Test_GetUserDomain(t *testing.T) { providerId := createTestProvider(t, db, user, "Test Provider") // Create a domain - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: "example.com", ProviderId: providerId, } - err := service.CreateDomain(ctx, user, domainToCreate) + _, err := service.CreateDomain(ctx, user, domainToCreate) if err != nil { t.Fatalf("failed to create domain: %v", err) } @@ -259,11 +259,11 @@ func Test_GetUserDomain_WrongUser(t *testing.T) { providerId := createTestProvider(t, db, user1, "Test Provider") // Create a domain for user1 - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: "user1-domain.com", ProviderId: providerId, } - err := service.CreateDomain(ctx, user1, domainToCreate) + _, err := service.CreateDomain(ctx, user1, domainToCreate) if err != nil { t.Fatalf("failed to create domain: %v", err) } @@ -309,11 +309,11 @@ func Test_GetUserDomainByFQDN(t *testing.T) { providerId := createTestProvider(t, db, user, "Test Provider") // Create a domain - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: "example.com.", ProviderId: providerId, } - err := service.CreateDomain(ctx, user, domainToCreate) + _, err := service.CreateDomain(ctx, user, domainToCreate) if err != nil { t.Fatalf("failed to create domain: %v", err) } @@ -343,11 +343,11 @@ func Test_ListUserDomains(t *testing.T) { // Create multiple domains domainNames := []string{"example1.com", "example2.com", "example3.com"} for _, name := range domainNames { - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: name, ProviderId: providerId, } - err := service.CreateDomain(ctx, user, domainToCreate) + _, err := service.CreateDomain(ctx, user, domainToCreate) if err != nil { t.Fatalf("failed to create domain %s: %v", name, err) } @@ -375,22 +375,22 @@ func Test_ListUserDomains_MultipleUsers(t *testing.T) { // Create domains for user1 for i := 1; i <= 2; i++ { - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: fmt.Sprintf("user1-domain%d.com", i), ProviderId: providerId1, } - err := service.CreateDomain(ctx, user1, domainToCreate) + _, err := service.CreateDomain(ctx, user1, domainToCreate) if err != nil { t.Fatalf("failed to create domain: %v", err) } } // Create domain for user2 - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: "user2-domain.com", ProviderId: providerId2, } - err := service.CreateDomain(ctx, user2, domainToCreate) + _, err := service.CreateDomain(ctx, user2, domainToCreate) if err != nil { t.Fatalf("failed to create domain: %v", err) } @@ -439,11 +439,11 @@ func Test_UpdateDomain(t *testing.T) { providerId := createTestProvider(t, db, user, "Test Provider") // Create a domain - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: "example.com", ProviderId: providerId, } - err := service.CreateDomain(ctx, user, domainToCreate) + _, err := service.CreateDomain(ctx, user, domainToCreate) if err != nil { t.Fatalf("failed to create domain: %v", err) } @@ -490,11 +490,11 @@ func Test_UpdateDomain_PreventIdChange(t *testing.T) { providerId := createTestProvider(t, db, user, "Test Provider") // Create a domain - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: "example.com", ProviderId: providerId, } - err := service.CreateDomain(ctx, user, domainToCreate) + _, err := service.CreateDomain(ctx, user, domainToCreate) if err != nil { t.Fatalf("failed to create domain: %v", err) } @@ -532,11 +532,11 @@ func Test_UpdateDomain_WrongUser(t *testing.T) { providerId := createTestProvider(t, db, user1, "Test Provider") // Create a domain for user1 - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: "user1-domain.com", ProviderId: providerId, } - err := service.CreateDomain(ctx, user1, domainToCreate) + _, err := service.CreateDomain(ctx, user1, domainToCreate) if err != nil { t.Fatalf("failed to create domain: %v", err) } @@ -565,11 +565,11 @@ func Test_DeleteDomain(t *testing.T) { providerId := createTestProvider(t, db, user, "Test Provider") // Create a domain - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: "example.com", ProviderId: providerId, } - err := service.CreateDomain(ctx, user, domainToCreate) + _, err := service.CreateDomain(ctx, user, domainToCreate) if err != nil { t.Fatalf("failed to create domain: %v", err) } @@ -605,11 +605,11 @@ func Test_UpdateDomain_Alias(t *testing.T) { providerId := createTestProvider(t, db, user, "Test Provider") // Create a domain - domainToCreate := &happydns.Domain{ + domainToCreate := &happydns.DomainCreationInput{ DomainName: "example.com", ProviderId: providerId, } - err := service.CreateDomain(ctx, user, domainToCreate) + _, err := service.CreateDomain(ctx, user, domainToCreate) if err != nil { t.Fatalf("failed to create domain: %v", err) } diff --git a/model/domain.go b/model/domain.go index 30d032c5..4754d0f5 100644 --- a/model/domain.go +++ b/model/domain.go @@ -108,7 +108,7 @@ type Subdomain string type Origin string type DomainUsecase interface { - CreateDomain(context.Context, *User, *Domain) error + CreateDomain(context.Context, *User, *DomainCreationInput) (*Domain, error) DeleteDomain(Identifier) error ExtendsDomainWithZoneMeta(*Domain) (*DomainWithZoneMetadata, error) GetUserDomain(*User, Identifier) (*Domain, error) diff --git a/model/service.go b/model/service.go index 00c837dd..e0223737 100644 --- a/model/service.go +++ b/model/service.go @@ -93,10 +93,10 @@ type ServiceMeta struct { OwnerId Identifier `json:"_ownerid,omitempty" swaggertype:"string"` // Domain contains the abstract domain where this Service relates. - Domain string `json:"_domain" binding:"required"` + Domain string `json:"_domain" validate:"required"` // Ttl contains the specific TTL for the underlying Resources. - Ttl uint32 `json:"_ttl" binding:"required"` + Ttl uint32 `json:"_ttl" validate:"required"` // Comment is a string that helps user to distinguish the Service. Comment string `json:"_comment,omitempty"` @@ -109,7 +109,7 @@ type ServiceMeta struct { Aliases []string `json:"_aliases,omitempty"` // NbResources holds the number of Resources stored inside this Service. - NbResources int `json:"_tmp_hint_nb" binding:"required"` + NbResources int `json:"_tmp_hint_nb" validate:"required"` // PropagatedAt is the estimated time at which the last published changes // for this service will be fully propagated (old cached records expired). diff --git a/model/zone.go b/model/zone.go index 68d4308e..327b59b9 100644 --- a/model/zone.go +++ b/model/zone.go @@ -39,7 +39,7 @@ type ZoneMeta struct { ParentZone *Identifier `json:"parent,omitempty" swaggertype:"string"` // DefaultTTL is the TTL to use when no TTL has been defined for a record in this Zone. - DefaultTTL uint32 `json:"default_ttl" binding:"required"` + DefaultTTL uint32 `json:"default_ttl" validate:"required"` // LastModified holds the time when the last modification has been made on this Zone. LastModified time.Time `json:"last_modified" format:"date-time" binding:"required"` diff --git a/services/abstract/libravatar.go b/services/abstract/libravatar.go new file mode 100644 index 00000000..561f7c92 --- /dev/null +++ b/services/abstract/libravatar.go @@ -0,0 +1,112 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package abstract + +import ( + "strings" + + "github.com/miekg/dns" + + "git.happydns.org/happyDomain/internal/helpers" + "git.happydns.org/happyDomain/model" + "git.happydns.org/happyDomain/services" +) + +type LibravatarServer struct { + Records []*dns.SRV `json:"srv"` +} + +func (s *LibravatarServer) GetNbResources() int { + return len(s.Records) +} + +func (s *LibravatarServer) GenComment() string { + if len(s.Records) == 0 { + return "" + } + + return s.Records[0].Target +} + +func (s *LibravatarServer) GetRecords(domain string, ttl uint32, origin string) ([]happydns.Record, error) { + rrs := make([]happydns.Record, len(s.Records)) + for i, r := range s.Records { + srv := *r + srv.Target = helpers.DomainFQDN(srv.Target, origin) + rrs[i] = &srv + } + return rrs, nil +} + +func libavatar_analyze(a *svcs.Analyzer) error { + alreadyUsed := map[string]*LibravatarServer{} + + for _, record := range a.SearchRR(svcs.AnalyzerRecordFilter{Type: dns.TypeSRV, Prefix: "_avatars"}) { + domain := "" + if strings.HasPrefix(record.Header().Name, "_avatars._tcp.") { + domain = strings.TrimPrefix(record.Header().Name, "_avatars._tcp.") + } else if strings.HasPrefix(record.Header().Name, "_avatars-sec._tcp.") { + domain = strings.TrimPrefix(record.Header().Name, "_avatars-sec._tcp.") + } else { + continue + } + + if srv, ok := record.(*dns.SRV); ok { + var rr *LibravatarServer + + // Make record relative + srv.Target = helpers.DomainRelative(srv.Target, a.GetOrigin()) + + if ls, ok := alreadyUsed[srv.Target]; ok { + rr = ls + rr.Records = append(rr.Records, helpers.RRRelative(srv, a.GetOrigin()).(*dns.SRV)) + } else { + rr = &LibravatarServer{ + Records: []*dns.SRV{helpers.RRRelative(srv, a.GetOrigin()).(*dns.SRV)}, + } + + alreadyUsed[srv.Target] = rr + } + + a.UseRR(record, domain, rr) + } + } + return nil +} + +func init() { + svcs.RegisterService( + func() happydns.ServiceBody { + return &LibravatarServer{} + }, + libavatar_analyze, + happydns.ServiceInfos{ + Name: "Federated Avatar", + Description: "Declare a libravatar server for this subdomain.", + Family: happydns.SERVICE_FAMILY_ABSTRACT, + Categories: []string{ + "service", + }, + }, + 2, + ) +} diff --git a/services/dkim.go b/services/dkim.go index 795ad1a7..272cc094 100644 --- a/services/dkim.go +++ b/services/dkim.go @@ -164,7 +164,7 @@ func (s *DKIMRedirection) GetRecords(domain string, ttl uint32, origin string) ( } func dkim_analyze(a *svc.Analyzer) (err error) { - for _, record := range a.SearchRR(svc.AnalyzerRecordFilter{Type: dns.TypeTXT}) { + for _, record := range a.SearchRR(svc.AnalyzerRecordFilter{Type: dns.TypeTXT, DomainContains: "._domainkey."}) { dkidx := strings.Index(record.Header().Name, "._domainkey.") if dkidx <= 0 { continue diff --git a/services/dmarc-report.go b/services/dmarc-report.go new file mode 100644 index 00000000..f014afe5 --- /dev/null +++ b/services/dmarc-report.go @@ -0,0 +1,111 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package svcs + +import ( + "strings" + + "github.com/miekg/dns" + + "git.happydns.org/happyDomain/internal/helpers" + "git.happydns.org/happyDomain/model" +) + +type DMARCReport struct { + Records []*happydns.TXT `json:"txt"` +} + +func (s *DMARCReport) GetNbResources() int { + return len(s.Records) +} + +func (s *DMARCReport) GenComment() string { + var domains []string + + for _, rr := range s.Records { + domains = append(domains, strings.TrimSuffix(rr.Header().Name, "._report._dmarc")) + } + + return strings.Join(domains, ", ") +} + +func (t *DMARCReport) GetRecords(domain string, ttl uint32, origin string) (rrs []happydns.Record, e error) { + for _, rr := range t.Records { + rrs = append(rrs, rr) + } + + return +} +func dmarc_report_analyze(a *Analyzer) (err error) { + services := map[string]*DMARCReport{} + + for _, record := range a.SearchRR(AnalyzerRecordFilter{Type: dns.TypeTXT, DomainContains: "._report._dmarc"}) { + txt, ok := record.(*happydns.TXT) + dmidx := strings.Index(record.Header().Name, "._report._dmarc.") + if dmidx <= 0 || !ok || !strings.HasPrefix(strings.ToLower(txt.Txt), "v=dmarc1") { + continue + } + domain := record.Header().Name[dmidx+16:] + + if _, ok := services[domain]; !ok { + services[domain] = &DMARCReport{} + } + + services[domain].Records = append( + services[domain].Records, + helpers.RRRelative(record, domain).(*happydns.TXT), + ) + + err = a.UseRR(record, domain, services[domain]) + if err != nil { + return + } + } + + return +} + +func init() { + RegisterService( + func() happydns.ServiceBody { + return &DMARCReport{} + }, + dmarc_report_analyze, + happydns.ServiceInfos{ + Name: "DMARC allow receiving reports", + Description: "Allow a domain to receive DMARC reports for another domain.", + Categories: []string{ + "email", + }, + RecordTypes: []uint16{ + dns.TypeTXT, + }, + Restrictions: happydns.ServiceRestrictions{ + NearAlone: true, + NeedTypes: []uint16{ + dns.TypeTXT, + }, + }, + }, + 1, + ) +} diff --git a/web/src/routes/domains/[dn]/ServiceDetailsOffcanvas.svelte b/web/src/routes/domains/[dn]/ServiceDetailsOffcanvas.svelte index 24de1c89..9bd11db2 100644 --- a/web/src/routes/domains/[dn]/ServiceDetailsOffcanvas.svelte +++ b/web/src/routes/domains/[dn]/ServiceDetailsOffcanvas.svelte @@ -149,7 +149,7 @@ (isOpen = false)} /> {/each}