Compare commits

...

6 commits

Author SHA1 Message Date
2772e6905e New service: libravatar
Some checks failed
continuous-integration/drone/push Build is failing
2026-04-11 15:34:17 +07:00
a2c6040974 New service: DMARC-reports 2026-04-11 15:34:17 +07:00
330c675da5 New domain filtering method: domain contains 2026-04-11 15:34:17 +07:00
effb588d3c fix: use one-way binding for record line rr prop
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-11 12:25:10 +07:00
e73ed40d5c fix: use validate instead of binding for zero-value fields
Switch ServiceMeta.Domain, ServiceMeta.Ttl, ServiceMeta.NbResources,
and ZoneMeta.DefaultTTL from binding:"required" to validate:"required".
This keeps them marked as required in the OpenAPI spec (swaggo reads
both tags) without gin rejecting valid zero values (0 for uint32,
"" for root domain).
2026-04-11 12:25:10 +07:00
99edd8a66c fix: use DomainCreationInput for domain creation endpoint and service
CreateDomain now takes a DomainCreationInput and returns the created
Domain, so the controller no longer needs to construct an intermediate
Domain struct and the response includes the server-assigned fields.

Fixes: https://github.com/happyDomain/happydomain/issues/44
2026-04-11 12:25:10 +07:00
11 changed files with 274 additions and 49 deletions

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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)
}

View file

@ -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)

View file

@ -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).

View file

@ -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"`

View file

@ -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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
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,
)
}

View file

@ -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

111
services/dmarc-report.go Normal file
View file

@ -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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
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,
)
}

View file

@ -149,7 +149,7 @@
<RecordLine
dn={service._domain || ""}
origin={domain}
bind:rr={rrs[i]}
rr={rrs[i]}
onopen={() => (isOpen = false)}
/>
{/each}