Compare commits

...

7 commits

Author SHA1 Message Date
987c1bb72e Add IONOS libdns provider implementations
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-30 11:58:03 +07:00
25f37af35d Add libdns provider adapter for supporting libdns-based DNS providers
Introduce a new adapter layer that allows happyDomain to use providers
from the libdns ecosystem alongside the existing dnscontrol providers.
The adapter implements ProviderActuator by converting between miekg/dns
and libdns record formats, reusing the existing DNSControl diff engine
for computing corrections, and generating executable correction functions
that call libdns Append/Delete/Set methods.
2026-03-30 11:58:02 +07:00
e1eb4dec90 Fix zone parenting after deploy 2026-03-30 11:58:02 +07:00
d298992b63 Re-fetch zone after deploy to capture actual SOA serial
For providers that manage SOA serials (like AXFRDDNS), re-fetch the
zone after applying corrections to capture the real published state.
The snapshot and WIP zone's Origin service are both updated with the
actual serial.

This is gated on a new "manages-soa-serial" capability, preserving
the existing behavior for providers that abstract SOA handling.

Closes: https://github.com/happyDomain/happydomain/issues/35
2026-03-30 11:58:02 +07:00
aba39001d8 New helper to check provider capability
Some checks are pending
continuous-integration/drone/push Build is running
2026-03-30 11:58:02 +07:00
d600bbcbd9 web: Refactor domain Table component to improve single responsibility
Extract DomainTableRow and ProviderLink components from the monolithic
Table.svelte, which was mixing table structure, provider name resolution,
row navigation, and action button logic into a single component.
2026-03-30 11:58:02 +07:00
e103d2262a Add DNS propagation time tracking per service
After publishing zone corrections, compute and store a PropagatedAt
timestamp on each affected service indicating when old cached records
will have expired. For updated/deleted services, this is publish_time +
old service TTL. For new services, it uses the SOA minimum TTL
(negative cache duration), falling back to the zone's DefaultTTL.

The propagation detection reuses the same service matching technique as
ReassociateMetadata (subdomain + type + ServiceRDataHash). Both the
published snapshot and the WIP zone are stamped.
2026-03-30 11:58:02 +07:00
31 changed files with 2226 additions and 165 deletions

5
go.mod
View file

@ -17,6 +17,8 @@ require (
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0
github.com/lib/pq v1.12.0
github.com/libdns/ionos v1.2.0
github.com/libdns/libdns v1.1.1
github.com/miekg/dns v1.1.72
github.com/mileusna/useragent v1.3.5
github.com/oracle/nosql-go-sdk v1.4.7
@ -74,7 +76,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/billputer/go-namecheap v0.0.0-20210108011502-994a912fb7f9 // indirect
@ -90,7 +91,6 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deepmap/oapi-codegen v1.16.3 // indirect
github.com/digitalocean/godo v1.176.0 // indirect
github.com/dnsimple/dnsimple-go v1.7.0 // indirect
github.com/dnsimple/dnsimple-go/v8 v8.1.0 // indirect
github.com/exoscale/egoscale v0.102.4 // indirect
github.com/failsafe-go/failsafe-go v0.9.6 // indirect
@ -216,7 +216,6 @@ require (
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect

80
go.sum
View file

@ -5,8 +5,6 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
codeberg.org/miekg/dns v0.6.62 h1:3Uua303EC8Og75QqT+pGRrcvKNTOouehHOQS36KbSqc=
codeberg.org/miekg/dns v0.6.62/go.mod h1:fIxAzBMDPnXWSw0fp8+pfZMRiAqYY4+HHYLzUo/S6Dg=
codeberg.org/miekg/dns v0.6.67 h1:vsVNsqAOE9uYscJHIHNtoCxiEySQn/B9BEvAUYI5Zmc=
codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
@ -75,74 +73,40 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.2 h1:zoD/SoiVQi8l8tuQn//VexrXS2yorg/+717JNA4Ble8=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.2/go.mod h1:Ll1DCasPTBFtHK5t/U5WIwGIyRuY3xY+x8/LmqIlqpM=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3 h1:JRPXnIr0WwFsSHBmuCvT/uh0Vgys+crvwkOghbJEqi8=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3/go.mod h1:DHddp7OO4bY467WVCqWBzk5+aEWn7vqYkap7UigJzGk=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.16 h1:k+TqYbG/WtL43wSCALuuPjLPEt//Ck/ZDKpCWrzhjUU=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.16/go.mod h1:yEr1gPPNbetOFxQV0J9ZLL5cR4U4ujEBgwk6p6oKYc8=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17 h1:Fw2SIR63jhfLpFZr6955zU3g9V8ouHC/pRpmmiHmIFM=
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17/go.mod h1:x9PRRtbCQ/gv1ziQPXFB7nQwQgVLQ+FSvPIkVAhRcYY=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0=
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@ -181,12 +145,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA=
github.com/deepmap/oapi-codegen v1.16.3/go.mod h1:JD6ErqeX0nYnhdciLc61Konj3NBASREMlkHOgHn8WAM=
github.com/digitalocean/godo v1.175.0 h1:tpfwJFkBzpePxvvFazOn69TXctdxuFlOs7DMVXsI7oU=
github.com/digitalocean/godo v1.175.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
github.com/digitalocean/godo v1.176.0 h1:P379vPO5TUre+bUHPEsdSAbl5vIrRRhP91tMIEPoWYU=
github.com/digitalocean/godo v1.176.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
github.com/dnsimple/dnsimple-go v1.7.0 h1:JKu9xJtZ3SqOC+BuYgAWeab7+EEx0sz422vu8j611ZY=
github.com/dnsimple/dnsimple-go v1.7.0/go.mod h1:EKpuihlWizqYafSnQHGCd/gyvy3HkEQJ7ODB4KdV8T8=
github.com/dnsimple/dnsimple-go/v8 v8.1.0 h1:U4ENaNCe5aUFHLiF7lj2NNpLPzFY3YIriu/UzrdfUbg=
github.com/dnsimple/dnsimple-go/v8 v8.1.0/go.mod h1:61MdYHRL+p2TBBUVEkxo1n4iRF6s3R9fZcvQvyt5du8=
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
@ -200,8 +160,6 @@ github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elB
github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
@ -216,14 +174,10 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-gandi/go-gandi v0.7.0 h1:gsP33dUspsN1M+ZW9HEgHchK9HiaSkYnltO73RHhSZA=
@ -355,8 +309,6 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/happyDomain/dnscontrol/v4 v4.35.101 h1:9GL4OZ05AXOBUNjRdU72UUJGFAQz6OTCRVQw3WTQuo8=
github.com/happyDomain/dnscontrol/v4 v4.35.101/go.mod h1:R6j+Fv+etKriXI3runhnv42nPZPLcn81NNRt9gl1hTs=
github.com/happyDomain/dnscontrol/v4 v4.36.100 h1:wrNaUV3Ihcqd9t9+AEIyBiyF1QNAeuFbCj+j8w0a/sM=
github.com/happyDomain/dnscontrol/v4 v4.36.100/go.mod h1:7fgVrun0ecnT8fJhcFHQQXBg6yVIfEWRRQOj27hxm+s=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
@ -434,10 +386,12 @@ github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/libdns/ionos v1.2.0 h1:FQ2xQTBfsjc7aMArRBBCs9l48Squt76GHXbxDsqOKgw=
github.com/libdns/ionos v1.2.0/go.mod h1:g/JYno/+VXdujTGPBDMDeCfeLF0PJyJynsCrFu+2EFQ=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/luadns/luadns-go v0.3.0 h1:mN2yhFv/LnGvPw/HmvYUhXe+lc95oXUqjlYVeJeOJng=
github.com/luadns/luadns-go v0.3.0/go.mod h1:DmPXbrGMpynq1YNDpvgww3NP5Zf4wXM5raAbGrp5L+8=
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
@ -494,8 +448,6 @@ github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:Ff
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/oracle/nosql-go-sdk v1.4.7 h1:dqVBSMulObDj0JHm1mAncTHrQg8wIiQJNC0JRNKPACg=
github.com/oracle/nosql-go-sdk v1.4.7/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
github.com/oracle/oci-go-sdk/v65 v65.108.3 h1:n2G4QwGoRNhtLE8r24/+Ny+WpEMdc9ggGpnPvVYM2Yk=
github.com/oracle/oci-go-sdk/v65 v65.108.3/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
github.com/oracle/oci-go-sdk/v65 v65.109.0 h1:EsbFVvcV+uid9SoQnFQbTKS6FgqsM9NtoKmUIovKsbk=
github.com/oracle/oci-go-sdk/v65 v65.109.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
@ -522,8 +474,6 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 h1:wSmWgpuccqS2IOfmYrbRiUgv+g37W5suLLLxwwniTSc=
@ -644,14 +594,6 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3Ifn
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.0 h1:mxoLcGPka9ac7V4GfJ3X/bu4KYxT64J61l/uHDFET3M=
github.com/yuin/goldmark v1.8.0/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.1 h1:id2TeYXe5FpqwLco0Pso4cNM5Z6Okt4g7kDw9QBMhTA=
github.com/yuin/goldmark v1.8.1/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
@ -698,8 +640,6 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -746,13 +686,9 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -765,8 +701,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -796,8 +730,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
@ -821,8 +753,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@ -855,8 +785,6 @@ gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E
gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
google.golang.org/api v0.268.0 h1:hgA3aS4lt9rpF5RCCkX0Q2l7DvHgvlb53y4T4u6iKkA=
google.golang.org/api v0.268.0/go.mod h1:HXMyMH496wz+dAJwD/GkAPLd3ZL33Kh0zEG32eNvy9w=
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=

View file

@ -0,0 +1,317 @@
// 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 adapter
import (
"context"
"fmt"
"strings"
"github.com/libdns/libdns"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
// LibdnsConfigAdapter is an interface that provider configurations must implement
// to work with libdns. It allows retrieving the underlying libdns provider instance.
type LibdnsConfigAdapter interface {
// LibdnsProvider returns the underlying libdns provider instance.
// The returned value must implement at least libdns.RecordGetter.
LibdnsProvider() any
}
// RegisterLibdnsProviderAdapter registers a DNS provider that uses libdns as its backend.
// It automatically populates the provider's capabilities by checking which libdns
// interfaces the provider implements.
func RegisterLibdnsProviderAdapter(creator happydns.ProviderCreatorFunc, infos happydns.ProviderInfos, registerFunc happydns.RegisterProviderFunc) {
prvInstance := creator().(LibdnsConfigAdapter)
infos.Capabilities = append(infos.Capabilities, GetLibdnsProviderCapabilities(prvInstance)...)
registerFunc(creator, infos)
}
// GetLibdnsProviderCapabilities checks which libdns interfaces the provider implements
// and returns the corresponding capability strings. Since libdns providers are type-agnostic,
// common record types are declared for all providers.
func GetLibdnsProviderCapabilities(prvd LibdnsConfigAdapter) (caps []string) {
p := prvd.LibdnsProvider()
if _, ok := p.(libdns.ZoneLister); ok {
caps = append(caps, "ListDomains")
}
// libdns providers are type-agnostic, so declare support for common RR types.
for _, v := range []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, dns.TypeMX,
dns.TypeNS, dns.TypeTXT, dns.TypeSRV, dns.TypeCAA,
dns.TypePTR,
} {
caps = append(caps, fmt.Sprintf("rr-%d-%s", v, dns.TypeToString[v]))
}
return
}
// NewLibdnsProviderAdapter creates a new provider actuator instance from a libdns configuration.
// It discovers the provider's capabilities by checking which libdns interfaces it implements.
// The provider must implement at least libdns.RecordGetter.
func NewLibdnsProviderAdapter(configAdapter LibdnsConfigAdapter) (happydns.ProviderActuator, error) {
p := configAdapter.LibdnsProvider()
adapter := &LibdnsAdapterNSProvider{
provider: p,
}
if g, ok := p.(libdns.RecordGetter); ok {
adapter.getter = g
} else {
return nil, fmt.Errorf("libdns provider must implement RecordGetter")
}
if s, ok := p.(libdns.RecordSetter); ok {
adapter.setter = s
}
if a, ok := p.(libdns.RecordAppender); ok {
adapter.appender = a
}
if d, ok := p.(libdns.RecordDeleter); ok {
adapter.deleter = d
}
if z, ok := p.(libdns.ZoneLister); ok {
adapter.zoneLister = z
}
return adapter, nil
}
// LibdnsAdapterNSProvider wraps a libdns provider to implement the happyDomain ProviderActuator interface.
type LibdnsAdapterNSProvider struct {
provider any
getter libdns.RecordGetter
setter libdns.RecordSetter
appender libdns.RecordAppender
deleter libdns.RecordDeleter
zoneLister libdns.ZoneLister
}
// normalizeZone ensures the zone name has a trailing dot (FQDN format expected by libdns).
func normalizeZone(domain string) string {
zone := strings.TrimSuffix(domain, ".")
return zone + "."
}
// CanListZones checks if the provider supports listing zones.
func (p *LibdnsAdapterNSProvider) CanListZones() bool {
return p.zoneLister != nil
}
// CanCreateDomain returns false since libdns has no zone creation interface.
func (p *LibdnsAdapterNSProvider) CanCreateDomain() bool {
return false
}
// CreateDomain is not supported by libdns providers.
func (p *LibdnsAdapterNSProvider) CreateDomain(fqdn string) error {
return fmt.Errorf("libdns provider does not support domain creation")
}
// ListZones retrieves the list of all zones managed by this provider.
func (p *LibdnsAdapterNSProvider) ListZones() ([]string, error) {
if p.zoneLister == nil {
return nil, fmt.Errorf("libdns provider does not support zone listing")
}
zones, err := p.zoneLister.ListZones(context.TODO())
if err != nil {
return nil, err
}
result := make([]string, len(zones))
for i, z := range zones {
result[i] = z.Name
}
return result, nil
}
// GetZoneRecords retrieves all DNS records for the specified domain from the provider.
func (p *LibdnsAdapterNSProvider) GetZoneRecords(domain string) ([]happydns.Record, error) {
zone := normalizeZone(domain)
recs, err := p.getter.GetRecords(context.TODO(), zone)
if err != nil {
return nil, err
}
return libdnsRecordsToHappyDNS(recs, zone)
}
// GetZoneCorrections compares desired records against the current zone state and returns
// the changes needed to synchronize them. It uses the DNSControl diff engine to compute
// the diff, then creates correction functions that call the libdns provider's API.
func (p *LibdnsAdapterNSProvider) GetZoneCorrections(domain string, wantedRecords []happydns.Record) ([]*happydns.Correction, int, error) {
zone := normalizeZone(domain)
// Step 1: Fetch current records from the provider.
currentLibdnsRecs, err := p.getter.GetRecords(context.TODO(), zone)
if err != nil {
return nil, 0, fmt.Errorf("unable to get current zone records: %w", err)
}
currentRecords, err := libdnsRecordsToHappyDNS(currentLibdnsRecs, zone)
if err != nil {
return nil, 0, fmt.Errorf("unable to convert current zone records: %w", err)
}
// Step 2: Compute diff using existing DNSControl diff engine.
diffs, nbDiffs, err := DNSControlDiffByRecord(currentRecords, wantedRecords, domain)
if err != nil {
return nil, nbDiffs, fmt.Errorf("unable to compute zone diff: %w", err)
}
// Build a lookup from happydns Record string → original libdns records (with ProviderData).
// This ensures delete operations use the provider's record IDs.
libdnsRecordsByKey := make(map[string][]libdns.Record)
for _, rec := range currentLibdnsRecs {
rr := rec.RR()
key := fmt.Sprintf("%s\t%s\t%s", rr.Name, rr.Type, rr.Data)
libdnsRecordsByKey[key] = append(libdnsRecordsByKey[key], rec)
}
// Step 3: Create corrections with executable F closures.
corrections := make([]*happydns.Correction, len(diffs))
for i, diff := range diffs {
corrections[i] = &happydns.Correction{
Id: diff.Id,
Msg: diff.Msg,
Kind: diff.Kind,
OldRecords: diff.OldRecords,
NewRecords: diff.NewRecords,
}
corrections[i].F = p.makeCorrectionFunc(zone, diff, libdnsRecordsByKey)
}
return corrections, nbDiffs, nil
}
// makeCorrectionFunc creates an executable function for a single correction.
func (p *LibdnsAdapterNSProvider) makeCorrectionFunc(
zone string,
diff *happydns.Correction,
libdnsRecordsByKey map[string][]libdns.Record,
) func() error {
kind := diff.Kind
// Resolve old records to their original libdns Records (with ProviderData).
oldRecs := p.resolveOriginalRecords(diff.OldRecords, zone, libdnsRecordsByKey)
newRecs := happyDNSRecordsToLibdnsRecords(diff.NewRecords, zone)
// If we have both appender and deleter, use granular operations.
if p.appender != nil && p.deleter != nil {
return func() error {
ctx := context.TODO()
switch kind {
case happydns.CorrectionKindAddition:
_, err := p.appender.AppendRecords(ctx, zone, newRecs)
return err
case happydns.CorrectionKindDeletion:
_, err := p.deleter.DeleteRecords(ctx, zone, oldRecs)
return err
case happydns.CorrectionKindUpdate:
if _, err := p.deleter.DeleteRecords(ctx, zone, oldRecs); err != nil {
return fmt.Errorf("delete phase of update: %w", err)
}
_, err := p.appender.AppendRecords(ctx, zone, newRecs)
if err != nil {
return fmt.Errorf("append phase of update: %w", err)
}
return nil
}
return nil
}
}
// Fallback: use SetRecords if available.
if p.setter != nil {
return func() error {
ctx := context.TODO()
switch kind {
case happydns.CorrectionKindAddition:
// SetRecords with the new records will add them to the zone
// for their (name, type) pair.
_, err := p.setter.SetRecords(ctx, zone, newRecs)
return err
case happydns.CorrectionKindDeletion:
// To delete, we need to set the (name, type) pair to empty.
// DeleteRecords would be better, but we only have SetRecords.
// Use DeleteRecords-style wildcard via setter: set with empty set
// is not directly possible with SetRecords semantics.
// Fall through to delete if we have deleter, otherwise error.
return fmt.Errorf("cannot delete records: provider only supports SetRecords, not DeleteRecords")
case happydns.CorrectionKindUpdate:
// SetRecords replaces all records for the (name, type) pair.
_, err := p.setter.SetRecords(ctx, zone, newRecs)
return err
}
return nil
}
}
return func() error {
return fmt.Errorf("libdns provider does not support record modification")
}
}
// resolveOriginalRecords tries to find the original libdns Records (with ProviderData)
// for the given happydns Records. This ensures that delete operations use the provider's
// record identifiers.
func (p *LibdnsAdapterNSProvider) resolveOriginalRecords(
records []happydns.Record,
zone string,
libdnsRecordsByKey map[string][]libdns.Record,
) []libdns.Record {
result := make([]libdns.Record, 0, len(records))
for _, rec := range records {
rr := happyDNSRecordToLibdnsRR(rec, zone)
key := fmt.Sprintf("%s\t%s\t%s", rr.Name, rr.Type, rr.Data)
if originals, ok := libdnsRecordsByKey[key]; ok && len(originals) > 0 {
// Use the original record and consume it from the map.
result = append(result, originals[0])
libdnsRecordsByKey[key] = originals[1:]
} else {
// Fallback: use the converted RR (without ProviderData).
result = append(result, rr)
}
}
return result
}
// happyDNSRecordsToLibdnsRecords converts happydns Records to libdns Records (the interface).
func happyDNSRecordsToLibdnsRecords(rrs []happydns.Record, zone string) []libdns.Record {
result := make([]libdns.Record, len(rrs))
for i, rr := range rrs {
result[i] = happyDNSRecordToLibdnsRR(rr, zone)
}
return result
}

View file

@ -0,0 +1,331 @@
// 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 adapter
import (
"context"
"net/netip"
"testing"
"time"
"github.com/libdns/libdns"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
// mockLibdnsProvider implements RecordGetter, RecordAppender, RecordDeleter for testing.
type mockLibdnsProvider struct {
records []libdns.Record
appended []libdns.Record
deleted []libdns.Record
zones []libdns.Zone
appendErr error
deleteErr error
getErr error
listZoneErr error
}
func (m *mockLibdnsProvider) GetRecords(_ context.Context, _ string) ([]libdns.Record, error) {
if m.getErr != nil {
return nil, m.getErr
}
return m.records, nil
}
func (m *mockLibdnsProvider) AppendRecords(_ context.Context, _ string, recs []libdns.Record) ([]libdns.Record, error) {
if m.appendErr != nil {
return nil, m.appendErr
}
m.appended = append(m.appended, recs...)
return recs, nil
}
func (m *mockLibdnsProvider) DeleteRecords(_ context.Context, _ string, recs []libdns.Record) ([]libdns.Record, error) {
if m.deleteErr != nil {
return nil, m.deleteErr
}
m.deleted = append(m.deleted, recs...)
return recs, nil
}
func (m *mockLibdnsProvider) ListZones(_ context.Context) ([]libdns.Zone, error) {
if m.listZoneErr != nil {
return nil, m.listZoneErr
}
return m.zones, nil
}
// mockLibdnsConfig implements LibdnsConfigAdapter.
type mockLibdnsConfig struct {
provider any
}
func (m *mockLibdnsConfig) LibdnsProvider() any {
return m.provider
}
func (m *mockLibdnsConfig) InstantiateProvider() (happydns.ProviderActuator, error) {
return NewLibdnsProviderAdapter(m)
}
func TestNewLibdnsProviderAdapter(t *testing.T) {
mock := &mockLibdnsProvider{}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !adapter.CanListZones() {
t.Error("expected CanListZones to be true")
}
if adapter.CanCreateDomain() {
t.Error("expected CanCreateDomain to be false")
}
}
func TestLibdnsAdapter_GetZoneRecords(t *testing.T) {
mock := &mockLibdnsProvider{
records: []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
libdns.TXT{
Name: "@",
TTL: 300 * time.Second,
Text: "v=spf1 ~all",
},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
records, err := adapter.GetZoneRecords("example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(records) != 2 {
t.Fatalf("expected 2 records, got %d", len(records))
}
// Check A record
if records[0].Header().Rrtype != dns.TypeA {
t.Errorf("expected first record to be A, got %s", dns.TypeToString[records[0].Header().Rrtype])
}
// Check TXT record
txt, ok := records[1].(*happydns.TXT)
if !ok {
t.Fatalf("expected second record to be *happydns.TXT, got %T", records[1])
}
if txt.Txt != "v=spf1 ~all" {
t.Errorf("expected TXT 'v=spf1 ~all', got %q", txt.Txt)
}
}
func TestLibdnsAdapter_ListZones(t *testing.T) {
mock := &mockLibdnsProvider{
zones: []libdns.Zone{
{Name: "example.com."},
{Name: "example.org."},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
zones, err := adapter.ListZones()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(zones) != 2 {
t.Fatalf("expected 2 zones, got %d", len(zones))
}
if zones[0] != "example.com." {
t.Errorf("expected first zone 'example.com.', got %q", zones[0])
}
}
func TestLibdnsAdapter_GetZoneCorrections_NoChanges(t *testing.T) {
records := []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
}
mock := &mockLibdnsProvider{records: records}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Pass the same records as wanted
aRR, _ := dns.NewRR("www.example.com. 300 IN A 192.0.2.1")
corrections, _, err := adapter.GetZoneCorrections("example.com.", []happydns.Record{aRR})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(corrections) != 0 {
t.Errorf("expected 0 corrections, got %d", len(corrections))
}
}
func TestLibdnsAdapter_GetZoneCorrections_Addition(t *testing.T) {
// Provider has one A record, we want to add a CNAME.
mock := &mockLibdnsProvider{
records: []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
aRR, _ := dns.NewRR("www.example.com. 300 IN A 192.0.2.1")
cnameRR, _ := dns.NewRR("blog.example.com. 300 IN CNAME www.example.com.")
corrections, _, err := adapter.GetZoneCorrections("example.com.", []happydns.Record{aRR, cnameRR})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(corrections) == 0 {
t.Fatal("expected at least 1 correction")
}
// Execute the correction
for _, c := range corrections {
if c.Kind == happydns.CorrectionKindAddition {
if err := c.F(); err != nil {
t.Fatalf("unexpected error executing correction: %v", err)
}
}
}
if len(mock.appended) == 0 {
t.Error("expected records to be appended")
}
}
func TestLibdnsAdapter_GetZoneCorrections_Deletion(t *testing.T) {
// Provider has two records, we want only one.
mock := &mockLibdnsProvider{
records: []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
libdns.Address{
Name: "old",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.2"),
},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
aRR, _ := dns.NewRR("www.example.com. 300 IN A 192.0.2.1")
corrections, _, err := adapter.GetZoneCorrections("example.com.", []happydns.Record{aRR})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(corrections) == 0 {
t.Fatal("expected at least 1 correction")
}
// Execute the deletion correction
for _, c := range corrections {
if c.Kind == happydns.CorrectionKindDeletion {
if err := c.F(); err != nil {
t.Fatalf("unexpected error executing correction: %v", err)
}
}
}
if len(mock.deleted) == 0 {
t.Error("expected records to be deleted")
}
}
func TestGetLibdnsProviderCapabilities(t *testing.T) {
mock := &mockLibdnsProvider{}
config := &mockLibdnsConfig{provider: mock}
caps := GetLibdnsProviderCapabilities(config)
// Should include ListDomains since mock implements ZoneLister
found := false
for _, c := range caps {
if c == "ListDomains" {
found = true
break
}
}
if !found {
t.Error("expected ListDomains capability")
}
// Should include common RR types
expectedTypes := []string{"rr-1-A", "rr-28-AAAA", "rr-5-CNAME", "rr-15-MX", "rr-16-TXT"}
for _, expected := range expectedTypes {
found = false
for _, c := range caps {
if c == expected {
found = true
break
}
}
if !found {
t.Errorf("expected capability %s", expected)
}
}
}

View file

@ -0,0 +1,188 @@
// 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 adapter
import (
"fmt"
"strings"
"time"
"github.com/StackExchange/dnscontrol/v4/pkg/txtutil"
"github.com/libdns/libdns"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/internal/helpers"
"git.happydns.org/happyDomain/model"
)
// libdnsToHappyDNSRecord converts a libdns Record to a happydns Record.
// The zone parameter should be the FQDN with trailing dot (e.g. "example.com.").
// For TXT records, it produces happydns.TXT directly (single concatenated string).
func libdnsToHappyDNSRecord(rec libdns.Record, zone string) (happydns.Record, error) {
rr := rec.RR()
fqdn := libdns.AbsoluteName(rr.Name, zone)
if !strings.HasSuffix(fqdn, ".") {
fqdn += "."
}
ttlSec := uint32(rr.TTL.Seconds())
// For TXT records, the libdns Data field may be either raw text or
// RFC1035 presentation-format with quotes and escaping (depends on provider).
// Use txtutil.ParseQuoted to decode presentation-format data.
if rr.Type == "TXT" {
return &happydns.TXT{
Hdr: dns.RR_Header{
Name: fqdn,
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: ttlSec,
},
Txt: decodeTXTData(rr.Data),
}, nil
}
// For SPF records (if any provider returns them)
if rr.Type == "SPF" {
return &happydns.SPF{
Hdr: dns.RR_Header{
Name: fqdn,
Rrtype: dns.TypeSPF,
Class: dns.ClassINET,
Ttl: ttlSec,
},
Txt: decodeTXTData(rr.Data),
}, nil
}
// For all other record types, build a zone-file line and parse it.
line := fmt.Sprintf("%s %d IN %s %s", fqdn, ttlSec, rr.Type, rr.Data)
return helpers.ParseRecord(line, zone)
}
// happyDNSRecordToLibdnsRR converts a happydns Record to a libdns RR.
// The zone parameter should be the FQDN with trailing dot (e.g. "example.com.").
func happyDNSRecordToLibdnsRR(record happydns.Record, zone string) libdns.RR {
hdr := record.Header()
name := libdns.RelativeName(hdr.Name, zone)
typStr := dns.TypeToString[hdr.Rrtype]
ttl := time.Duration(hdr.Ttl) * time.Second
// For happydns.TXT / happydns.SPF, extract the raw text directly.
if txt, ok := record.(*happydns.TXT); ok {
return libdns.RR{
Name: name,
TTL: ttl,
Type: typStr,
Data: txt.Txt,
}
}
if spf, ok := record.(*happydns.SPF); ok {
return libdns.RR{
Name: name,
TTL: ttl,
Type: typStr,
Data: spf.Txt,
}
}
// For ConvertibleRecord types, convert to dns.RR first.
var dnsRR dns.RR
if cr, ok := record.(happydns.ConvertibleRecord); ok {
dnsRR = cr.ToRR()
} else if rr, ok := record.(dns.RR); ok {
dnsRR = rr
} else {
// Fallback: try to extract rdata from string representation.
return libdns.RR{
Name: name,
TTL: ttl,
Type: typStr,
Data: extractRdata(record.String(), typStr),
}
}
return libdns.RR{
Name: name,
TTL: ttl,
Type: typStr,
Data: extractRdata(dnsRR.String(), typStr),
}
}
// decodeTXTData decodes TXT record data that may be in RFC1035 presentation
// format (quoted, with escaping) or raw text. Some libdns providers (e.g.
// PowerDNS) return quoted data like `"value"`, while others (e.g. libdns.TXT)
// return raw unquoted text. ParseQuoted handles quoted data correctly but
// treats unquoted spaces as separators, so we only use it when quotes are present.
func decodeTXTData(s string) string {
if strings.ContainsRune(s, '"') {
if decoded, err := txtutil.ParseQuoted(s); err == nil {
return decoded
}
}
return s
}
// extractRdata extracts the rdata portion from a miekg/dns RR string.
// The format is: "name.\t<TTL>\tIN\t<TYPE>\t<rdata...>"
func extractRdata(rrString string, rrType string) string {
// miekg/dns uses tab-separated fields
marker := "\tIN\t" + rrType + "\t"
idx := strings.Index(rrString, marker)
if idx != -1 {
return rrString[idx+len(marker):]
}
// Fallback: try space-separated (shouldn't happen with miekg/dns)
marker = " IN " + rrType + " "
idx = strings.Index(rrString, marker)
if idx != -1 {
return rrString[idx+len(marker):]
}
return ""
}
// libdnsRecordsToHappyDNS converts a slice of libdns Records to happydns Records.
func libdnsRecordsToHappyDNS(recs []libdns.Record, zone string) ([]happydns.Record, error) {
result := make([]happydns.Record, 0, len(recs))
for _, rec := range recs {
hdr, err := libdnsToHappyDNSRecord(rec, zone)
if err != nil {
return nil, fmt.Errorf("converting libdns record %v: %w", rec.RR(), err)
}
result = append(result, hdr)
}
return result, nil
}
// happyDNSRecordsToLibdns converts a slice of happydns Records to libdns RR values.
func happyDNSRecordsToLibdns(rrs []happydns.Record, zone string) []libdns.RR {
result := make([]libdns.RR, len(rrs))
for i, rr := range rrs {
result[i] = happyDNSRecordToLibdnsRR(rr, zone)
}
return result
}

View file

@ -0,0 +1,343 @@
// 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 adapter
import (
"net/netip"
"testing"
"time"
"github.com/libdns/libdns"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
func TestLibdnsToHappyDNS_A(t *testing.T) {
rec := libdns.Address{}
rec.Name = "www"
rec.TTL = 300 * time.Second
rec.IP = mustParseAddr("192.0.2.1")
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Header().Name != "www.example.com." {
t.Errorf("expected name www.example.com., got %s", result.Header().Name)
}
if result.Header().Rrtype != dns.TypeA {
t.Errorf("expected type A, got %s", dns.TypeToString[result.Header().Rrtype])
}
if result.Header().Ttl != 300 {
t.Errorf("expected TTL 300, got %d", result.Header().Ttl)
}
}
func TestLibdnsToHappyDNS_AAAA(t *testing.T) {
rec := libdns.Address{}
rec.Name = "@"
rec.TTL = 600 * time.Second
rec.IP = mustParseAddr("2001:db8::1")
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Header().Name != "example.com." {
t.Errorf("expected name example.com., got %s", result.Header().Name)
}
if result.Header().Rrtype != dns.TypeAAAA {
t.Errorf("expected type AAAA, got %s", dns.TypeToString[result.Header().Rrtype])
}
}
func TestLibdnsToHappyDNS_TXT(t *testing.T) {
rec := libdns.TXT{
Name: "@",
TTL: 300 * time.Second,
Text: "v=spf1 include:_spf.google.com ~all",
}
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
txt, ok := result.(*happydns.TXT)
if !ok {
t.Fatalf("expected *happydns.TXT, got %T", result)
}
if txt.Txt != "v=spf1 include:_spf.google.com ~all" {
t.Errorf("expected TXT value 'v=spf1 include:_spf.google.com ~all', got %q", txt.Txt)
}
if txt.Hdr.Name != "example.com." {
t.Errorf("expected name example.com., got %s", txt.Hdr.Name)
}
}
func TestLibdnsToHappyDNS_CNAME(t *testing.T) {
rec := libdns.CNAME{
Name: "www",
TTL: 3600 * time.Second,
Target: "example.com.",
}
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Header().Rrtype != dns.TypeCNAME {
t.Errorf("expected type CNAME, got %s", dns.TypeToString[result.Header().Rrtype])
}
}
func TestLibdnsToHappyDNS_MX(t *testing.T) {
rec := libdns.MX{
Name: "@",
TTL: 3600 * time.Second,
Preference: 10,
Target: "mail.example.com.",
}
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Header().Rrtype != dns.TypeMX {
t.Errorf("expected type MX, got %s", dns.TypeToString[result.Header().Rrtype])
}
}
func TestHappyDNSToLibdns_A(t *testing.T) {
rr, _ := dns.NewRR("www.example.com. 300 IN A 192.0.2.1")
result := happyDNSRecordToLibdnsRR(rr, "example.com.")
if result.Name != "www" {
t.Errorf("expected name 'www', got %q", result.Name)
}
if result.Type != "A" {
t.Errorf("expected type A, got %s", result.Type)
}
if result.TTL != 300*time.Second {
t.Errorf("expected TTL 300s, got %v", result.TTL)
}
if result.Data != "192.0.2.1" {
t.Errorf("expected data '192.0.2.1', got %q", result.Data)
}
}
func TestHappyDNSToLibdns_TXT(t *testing.T) {
txt := &happydns.TXT{
Hdr: dns.RR_Header{
Name: "example.com.",
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: 300,
},
Txt: "v=spf1 include:_spf.google.com ~all",
}
result := happyDNSRecordToLibdnsRR(txt, "example.com.")
if result.Name != "@" {
t.Errorf("expected name '@', got %q", result.Name)
}
if result.Type != "TXT" {
t.Errorf("expected type TXT, got %s", result.Type)
}
if result.Data != "v=spf1 include:_spf.google.com ~all" {
t.Errorf("expected data 'v=spf1 include:_spf.google.com ~all', got %q", result.Data)
}
}
func TestHappyDNSToLibdns_Apex(t *testing.T) {
rr, _ := dns.NewRR("example.com. 300 IN A 192.0.2.1")
result := happyDNSRecordToLibdnsRR(rr, "example.com.")
if result.Name != "@" {
t.Errorf("expected name '@', got %q", result.Name)
}
}
func TestRoundTrip_A(t *testing.T) {
original := libdns.Address{}
original.Name = "www"
original.TTL = 300 * time.Second
original.IP = mustParseAddr("192.0.2.1")
zone := "example.com."
hdRecord, err := libdnsToHappyDNSRecord(original, zone)
if err != nil {
t.Fatalf("unexpected error converting to happydns: %v", err)
}
roundtripped := happyDNSRecordToLibdnsRR(hdRecord, zone)
origRR := original.RR()
if roundtripped.Name != origRR.Name {
t.Errorf("name mismatch: got %q, want %q", roundtripped.Name, origRR.Name)
}
if roundtripped.Type != origRR.Type {
t.Errorf("type mismatch: got %q, want %q", roundtripped.Type, origRR.Type)
}
if roundtripped.TTL != origRR.TTL {
t.Errorf("TTL mismatch: got %v, want %v", roundtripped.TTL, origRR.TTL)
}
if roundtripped.Data != origRR.Data {
t.Errorf("data mismatch: got %q, want %q", roundtripped.Data, origRR.Data)
}
}
func TestRoundTrip_TXT(t *testing.T) {
original := libdns.TXT{
Name: "test",
TTL: 600 * time.Second,
Text: "hello world with spaces and special chars: @#$%",
}
zone := "example.com."
hdRecord, err := libdnsToHappyDNSRecord(original, zone)
if err != nil {
t.Fatalf("unexpected error converting to happydns: %v", err)
}
txt, ok := hdRecord.(*happydns.TXT)
if !ok {
t.Fatalf("expected *happydns.TXT, got %T", hdRecord)
}
if txt.Txt != original.Text {
t.Errorf("TXT text mismatch after first conversion: got %q, want %q", txt.Txt, original.Text)
}
roundtripped := happyDNSRecordToLibdnsRR(hdRecord, zone)
origRR := original.RR()
if roundtripped.Name != origRR.Name {
t.Errorf("name mismatch: got %q, want %q", roundtripped.Name, origRR.Name)
}
if roundtripped.Type != origRR.Type {
t.Errorf("type mismatch: got %q, want %q", roundtripped.Type, origRR.Type)
}
if roundtripped.Data != origRR.Data {
t.Errorf("data mismatch: got %q, want %q", roundtripped.Data, origRR.Data)
}
}
func TestLibdnsToHappyDNS_TXT_QuotedData(t *testing.T) {
// Some libdns providers (e.g. PowerDNS) return TXT data in RFC1035 presentation format.
tests := []struct {
name string
data string
expected string
}{
{"simple quoted", `"some-acme-challenge-value"`, "some-acme-challenge-value"},
{"escaped quote", `"foo\"bar"`, `foo"bar`},
{"escaped backslash", `"foo\\bar"`, `foo\bar`},
{"multi-chunk", `"chunk1" "chunk2"`, "chunk1chunk2"},
{"unquoted passthrough", "v=spf1 ~all", "v=spf1 ~all"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := libdns.RR{
Name: "_acme-challenge",
TTL: 3600 * time.Second,
Type: "TXT",
Data: tt.data,
}
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
txt, ok := result.(*happydns.TXT)
if !ok {
t.Fatalf("expected *happydns.TXT, got %T", result)
}
if txt.Txt != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, txt.Txt)
}
})
}
}
func TestLibdnsToHappyDNS_TXT_UnquotedData(t *testing.T) {
// libdns.TXT returns raw unquoted text — should pass through unchanged.
rec := libdns.TXT{
Name: "@",
TTL: 300 * time.Second,
Text: "v=spf1 ~all",
}
result, err := libdnsToHappyDNSRecord(rec, "example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
txt, ok := result.(*happydns.TXT)
if !ok {
t.Fatalf("expected *happydns.TXT, got %T", result)
}
if txt.Txt != "v=spf1 ~all" {
t.Errorf("expected unquoted TXT value, got %q", txt.Txt)
}
}
func TestExtractRdata(t *testing.T) {
tests := []struct {
input string
rrType string
want string
}{
{"www.example.com.\t300\tIN\tA\t192.0.2.1", "A", "192.0.2.1"},
{"example.com.\t3600\tIN\tMX\t10 mail.example.com.", "MX", "10 mail.example.com."},
{"example.com.\t300\tIN\tAAAA\t2001:db8::1", "AAAA", "2001:db8::1"},
}
for _, tt := range tests {
got := extractRdata(tt.input, tt.rrType)
if got != tt.want {
t.Errorf("extractRdata(%q, %q) = %q, want %q", tt.input, tt.rrType, got, tt.want)
}
}
}
func mustParseAddr(s string) netip.Addr {
addr, err := netip.ParseAddr(s)
if err != nil {
panic(err)
}
return addr
}

View file

@ -25,6 +25,7 @@ import (
"fmt"
"log"
"reflect"
"slices"
"git.happydns.org/happyDomain/model"
)
@ -50,6 +51,15 @@ func GetProviders() map[string]happydns.ProviderCreator {
return providerRegistry
}
// ProviderHasCapability checks if the registered provider type has the given capability.
func ProviderHasCapability(provider *happydns.Provider, capability string) bool {
creator, ok := providerRegistry[provider.Type]
if !ok {
return false
}
return slices.Contains(creator.Infos.Capabilities, capability)
}
// FindProvider returns the Provider corresponding to the given name, or an error if it doesn't exist.
func FindProvider(name string) (happydns.ProviderBody, error) {
src, ok := providerRegistry[name]

View file

@ -87,7 +87,7 @@ func NewOrchestrator(
zoneCorrectionLister := NewZoneCorrectionListerUsecase(providerService, listRecords, zoneCorrectorService, zoneRetrieverService)
return &Orchestrator{
RemoteZoneImporter: NewRemoteZoneImporterUsecase(appendDomainLog, providerService, zoneImporter, zoneRetrieverService),
ZoneCorrectionApplier: NewZoneCorrectionApplierUsecase(appendDomainLog, domainUpdater, zoneCorrectionLister, zoneCreator, zoneGetter, zoneUpdater),
ZoneCorrectionApplier: NewZoneCorrectionApplierUsecase(appendDomainLog, domainUpdater, zoneCorrectionLister, zoneCreator, zoneGetter, zoneRetrieverService, zoneUpdater),
ZoneImporter: zoneImporter,
}
}

View file

@ -0,0 +1,130 @@
// 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 orchestrator
import (
"time"
"github.com/miekg/dns"
svc "git.happydns.org/happyDomain/internal/service"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
)
// SetPropagationTimes stamps each service in newServices with a PropagatedAt
// time based on whether the service changed compared to the provider state.
// It reuses the same matching technique as ReassociateMetadata (subdomain +
// type + ServiceRDataHash).
//
// For changed/updated services: PropagatedAt = publishTime + old service TTL.
// For new services (additions): PropagatedAt = publishTime + SOA minimum TTL
// (negative cache duration), falling back to defaultTTL.
func SetPropagationTimes(
newServices map[happydns.Subdomain][]*happydns.Service,
providerRecords []happydns.Record,
origin string,
defaultTTL uint32,
publishTime time.Time,
) {
// Find SOA minimum TTL for negative cache duration (used for additions).
negativeCacheTTL := defaultTTL
for _, rr := range providerRecords {
if rr.Header().Rrtype == dns.TypeSOA {
if soa, ok := rr.(*dns.SOA); ok {
negativeCacheTTL = soa.Minttl
}
break
}
}
// Analyze provider records into old services for comparison.
oldServices, oldDefaultTTL, err := svc.AnalyzeZone(origin, providerRecords)
if err != nil {
return
}
for dn, newSvcs := range newServices {
oldSvcs := oldServices[dn]
// Group old services by type.
oldByType := map[string][]*happydns.Service{}
for _, s := range oldSvcs {
oldByType[s.Type] = append(oldByType[s.Type], s)
}
for _, newSvc := range newSvcs {
candidates := oldByType[newSvc.Type]
if len(candidates) == 0 {
// New service (addition): use SOA negative cache TTL.
propagatedAt := publishTime.Add(time.Duration(negativeCacheTTL) * time.Second)
newSvc.PropagatedAt = &propagatedAt
continue
}
newHash := zoneUC.ServiceRDataHash(newSvc, origin, defaultTTL)
if len(candidates) == 1 {
oldSvc := candidates[0]
oldHash := zoneUC.ServiceRDataHash(oldSvc, origin, oldDefaultTTL)
if newHash != oldHash {
// Service changed: use old service TTL.
oldTTL := oldDefaultTTL
if oldSvc.Ttl != 0 {
oldTTL = oldSvc.Ttl
}
propagatedAt := publishTime.Add(time.Duration(oldTTL) * time.Second)
newSvc.PropagatedAt = &propagatedAt
}
continue
}
// Multiple candidates: try to find exact RDATA match.
matched := false
for _, oldSvc := range candidates {
if zoneUC.ServiceRDataHash(oldSvc, origin, oldDefaultTTL) == newHash {
// Exact match: service unchanged, don't touch PropagatedAt.
matched = true
break
}
}
if !matched {
// No exact match: service was modified. Use the max TTL
// across all candidates of the same type as a conservative
// upper bound.
var maxOldTTL uint32
for _, oldSvc := range candidates {
ttl := oldDefaultTTL
if oldSvc.Ttl != 0 {
ttl = oldSvc.Ttl
}
if ttl > maxOldTTL {
maxOldTTL = ttl
}
}
propagatedAt := publishTime.Add(time.Duration(maxOldTTL) * time.Second)
newSvc.PropagatedAt = &propagatedAt
}
}
}
}

View file

@ -28,10 +28,12 @@ import (
"time"
adapter "git.happydns.org/happyDomain/internal/adapters"
providerReg "git.happydns.org/happyDomain/internal/provider"
svc "git.happydns.org/happyDomain/internal/service"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services/abstract"
)
// ZoneCorrectionApplierUsecase applies a user-selected subset of zone
@ -43,6 +45,7 @@ type ZoneCorrectionApplierUsecase struct {
domainUpdater DomainUpdater
zoneCreator *zoneUC.CreateZoneUsecase
zoneGetter *zoneUC.GetZoneUsecase
zoneRetriever ZoneRetriever
zoneUpdater *zoneUC.UpdateZoneUsecase
clock func() time.Time
}
@ -56,6 +59,7 @@ func NewZoneCorrectionApplierUsecase(
lister *ZoneCorrectionListerUsecase,
zoneCreator *zoneUC.CreateZoneUsecase,
zoneGetter *zoneUC.GetZoneUsecase,
zoneRetriever ZoneRetriever,
zoneUpdater *zoneUC.UpdateZoneUsecase,
) *ZoneCorrectionApplierUsecase {
return &ZoneCorrectionApplierUsecase{
@ -64,6 +68,7 @@ func NewZoneCorrectionApplierUsecase(
domainUpdater: domainUpdater,
zoneCreator: zoneCreator,
zoneGetter: zoneGetter,
zoneRetriever: zoneRetriever,
zoneUpdater: zoneUpdater,
clock: time.Now,
}
@ -78,11 +83,11 @@ func (uc *ZoneCorrectionApplierUsecase) computeExecutableCorrections(
domain *happydns.Domain,
zone *happydns.Zone,
wantedCorrections []happydns.Identifier,
) (execCorrections []*happydns.Correction, targetRecords []happydns.Record, nbDiffs int, err error) {
) (execCorrections []*happydns.Correction, targetRecords []happydns.Record, providerRecords []happydns.Record, nbDiffs int, err error) {
// Step 1: Compute the diff and get provider/WIP records.
corrections, providerRecords, _, nbDiffs, err := uc.listWithRecords(ctx, user, domain, zone)
if err != nil {
return nil, nil, nbDiffs, err
return nil, nil, nil, nbDiffs, err
}
// Step 2: Build target records from selected corrections.
@ -91,15 +96,15 @@ func (uc *ZoneCorrectionApplierUsecase) computeExecutableCorrections(
// Step 3: Get executable corrections from the provider for the target state.
provider, err := uc.providerService.GetUserProvider(user, domain.ProviderId)
if err != nil {
return nil, nil, nbDiffs, err
return nil, nil, nil, nbDiffs, err
}
execCorrections, nbDiffs, err = uc.zoneCorrector.ListZoneCorrections(ctx, provider, domain, targetRecords)
if err != nil {
return nil, nil, nbDiffs, fmt.Errorf("unable to compute executable corrections: %w", err)
return nil, nil, nil, nbDiffs, fmt.Errorf("unable to compute executable corrections: %w", err)
}
return execCorrections, targetRecords, nbDiffs, nil
return execCorrections, targetRecords, providerRecords, nbDiffs, nil
}
// Prepare computes the executable corrections for the given selection without
@ -112,7 +117,7 @@ func (uc *ZoneCorrectionApplierUsecase) Prepare(
zone *happydns.Zone,
form *happydns.PrepareZoneForm,
) (*happydns.PrepareZoneResponse, error) {
execCorrections, _, nbDiffs, err := uc.computeExecutableCorrections(ctx, user, domain, zone, form.WantedCorrections)
execCorrections, _, _, nbDiffs, err := uc.computeExecutableCorrections(ctx, user, domain, zone, form.WantedCorrections)
if err != nil {
return nil, err
}
@ -142,7 +147,7 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
zone *happydns.Zone,
form *happydns.ApplyZoneForm,
) (*happydns.Zone, error) {
executableCorrections, targetRecords, _, err := uc.computeExecutableCorrections(ctx, user, domain, zone, form.WantedCorrections)
executableCorrections, targetRecords, providerRecords, _, err := uc.computeExecutableCorrections(ctx, user, domain, zone, form.WantedCorrections)
if err != nil {
return nil, err
}
@ -171,8 +176,22 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
log.Printf("unable to append domain log for %s: %s", domain.DomainName, logErr.Error())
}
// Step 5: Create a published snapshot zone from target records.
services, defaultTTL, err := svc.AnalyzeZone(domain.DomainName, targetRecords)
// Step 4b: If provider manages SOA serial, re-fetch to get the actual published state.
publishedRecords := targetRecords
refetched := false
provider, provErr := uc.providerService.GetUserProvider(user, domain.ProviderId)
if provErr == nil && providerReg.ProviderHasCapability(provider, "manages-soa-serial") {
fetched, fetchErr := uc.zoneRetriever.RetrieveZone(ctx, provider, domain.DomainName)
if fetchErr != nil {
log.Printf("%s: unable to re-fetch zone after deploy, using target records: %s", domain.DomainName, fetchErr)
} else {
publishedRecords = fetched
refetched = true
}
}
// Step 5: Create a published snapshot zone from published records.
services, defaultTTL, err := svc.AnalyzeZone(domain.DomainName, publishedRecords)
if err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to analyze target zone: %w", err),
@ -196,6 +215,10 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
}
now := uc.clock()
// Compute propagation times for changed services on the snapshot.
SetPropagationTimes(services, providerRecords, domain.DomainName, defaultTTL, now)
snapshot := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{
IdAuthor: user.Id,
@ -204,7 +227,7 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
CommitMsg: &form.CommitMsg,
CommitDate: &now,
Published: &now,
ParentZone: &zone.ZoneMeta.Id,
ParentZone: zone.ParentZone,
},
Services: services,
}
@ -217,6 +240,28 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
}
}
// Update the parent zone of the WIP zone
zone.ParentZone = &snapshot.Id
// Step 5b: If we re-fetched, update the WIP zone's Origin SOA serial to match.
if refetched {
if newSerial, ok := extractOriginSOASerial(snapshot); ok {
if updateErr := uc.zoneUpdater.Update(zone.Id, func(z *happydns.Zone) {
if services, exists := z.Services[""]; exists {
for _, s := range services {
if s.Type == "abstract.Origin" {
if origin, ok := s.Service.(*abstract.Origin); ok && origin.SOA != nil {
origin.SOA.Serial = newSerial
}
}
}
}
}); updateErr != nil {
log.Printf("%s: unable to update WIP zone SOA serial: %s", domain.DomainName, updateErr)
}
}
}
// Step 6: Insert snapshot at ZoneHistory[1] (after WIP at position 0).
err = uc.domainUpdater.Update(domain.Id, user, func(domain *happydns.Domain) {
if len(domain.ZoneHistory) == 0 {
@ -236,5 +281,27 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
}
}
// Update propagation times on the WIP zone as well.
if updateErr := uc.zoneUpdater.Update(zone.Id, func(wipZone *happydns.Zone) {
SetPropagationTimes(wipZone.Services, providerRecords, domain.DomainName, wipZone.DefaultTTL, now)
}); updateErr != nil {
log.Printf("%s: unable to update WIP zone propagation times: %s", domain.DomainName, updateErr)
}
return snapshot, nil
}
// extractOriginSOASerial extracts the SOA serial from the Origin service
// at the zone apex, if present.
func extractOriginSOASerial(zone *happydns.Zone) (uint32, bool) {
if services, exists := zone.Services[""]; exists {
for _, s := range services {
if s.Type == "abstract.Origin" {
if origin, ok := s.Service.(*abstract.Origin); ok && origin.SOA != nil {
return origin.SOA.Serial, true
}
}
}
}
return 0, false
}

View file

@ -0,0 +1,427 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 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 orchestrator_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/miekg/dns"
providerReg "git.happydns.org/happyDomain/internal/provider"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services/abstract"
// Import AXFRDDNS provider to register its capabilities.
_ "git.happydns.org/happyDomain/providers"
)
// mockDomainUpdater implements DomainUpdater for testing.
type mockDomainUpdater struct {
domain *happydns.Domain
err error
}
func (m *mockDomainUpdater) Update(_ happydns.Identifier, _ *happydns.User, updateFn func(*happydns.Domain)) error {
if m.err != nil {
return m.err
}
if m.domain != nil {
updateFn(m.domain)
}
return nil
}
// inMemoryZoneStorage implements ZoneStorage for testing.
type inMemoryZoneStorage struct {
zones map[string]*happydns.Zone
}
func newInMemoryZoneStorage() *inMemoryZoneStorage {
return &inMemoryZoneStorage{zones: map[string]*happydns.Zone{}}
}
func (s *inMemoryZoneStorage) ListAllZones() (happydns.Iterator[happydns.ZoneMessage], error) {
return nil, fmt.Errorf("not implemented")
}
func (s *inMemoryZoneStorage) GetZoneMeta(zoneid happydns.Identifier) (*happydns.ZoneMeta, error) {
z, ok := s.zones[zoneid.String()]
if !ok {
return nil, fmt.Errorf("zone not found: %s", zoneid)
}
return &z.ZoneMeta, nil
}
func (s *inMemoryZoneStorage) GetZone(zoneid happydns.Identifier) (*happydns.ZoneMessage, error) {
z, ok := s.zones[zoneid.String()]
if !ok {
return nil, fmt.Errorf("zone not found: %s", zoneid)
}
// Convert Zone to ZoneMessage by marshaling services.
msg := &happydns.ZoneMessage{
ZoneMeta: z.ZoneMeta,
Services: map[happydns.Subdomain][]*happydns.ServiceMessage{},
}
for subdn, svcs := range z.Services {
for _, svc := range svcs {
body, err := json.Marshal(svc.Service)
if err != nil {
return nil, err
}
msg.Services[subdn] = append(msg.Services[subdn], &happydns.ServiceMessage{
ServiceMeta: svc.ServiceMeta,
Service: body,
})
}
}
return msg, nil
}
func (s *inMemoryZoneStorage) CreateZone(zone *happydns.Zone) error {
if zone.Id == nil {
zone.Id = happydns.Identifier([]byte(fmt.Sprintf("zone-%d", len(s.zones))))
}
s.zones[zone.Id.String()] = zone
return nil
}
func (s *inMemoryZoneStorage) UpdateZone(zone *happydns.Zone) error {
s.zones[zone.Id.String()] = zone
return nil
}
func (s *inMemoryZoneStorage) DeleteZone(zoneid happydns.Identifier) error {
delete(s.zones, zoneid.String())
return nil
}
func (s *inMemoryZoneStorage) ClearZones() error {
s.zones = map[string]*happydns.Zone{}
return nil
}
// mockZoneRetrieverFailOnNth returns records until the Nth call, then fails.
type mockZoneRetrieverFailOnNth struct {
records []happydns.Record
failOnNth int
failErr error
calls int
}
func (m *mockZoneRetrieverFailOnNth) RetrieveZone(_ context.Context, _ *happydns.Provider, _ string) ([]happydns.Record, error) {
m.calls++
if m.calls >= m.failOnNth {
return nil, m.failErr
}
return m.records, nil
}
// testZoneRetriever is an interface matching orchestrator.ZoneRetriever.
type testZoneRetriever interface {
RetrieveZone(ctx context.Context, provider *happydns.Provider, name string) ([]happydns.Record, error)
}
// buildTestApplier creates a ZoneCorrectionApplierUsecase with the given overrides.
func buildTestApplier(
providerGetter *mockProviderGetter,
zoneCorrector *mockZoneCorrector,
retriever testZoneRetriever,
domainUpdater *mockDomainUpdater,
storage *inMemoryZoneStorage,
) *orchestrator.ZoneCorrectionApplierUsecase {
listRecords := zoneUC.NewListRecordsUsecase(serviceUC.NewListRecordsUsecase())
lister := orchestrator.NewZoneCorrectionListerUsecase(
providerGetter,
listRecords,
zoneCorrector,
retriever,
)
zoneGetter := zoneUC.NewGetZoneUsecase(storage)
zoneCreator := zoneUC.NewCreateZoneUsecase(storage)
zoneUpdater := zoneUC.NewUpdateZoneUsease(storage, zoneGetter)
return orchestrator.NewZoneCorrectionApplierUsecase(
domainlogUC.NoopDomainLogAppender{},
domainUpdater,
lister,
zoneCreator,
zoneGetter,
retriever,
zoneUpdater,
)
}
func TestApply_NoRefetch_WhenProviderLacksCapability(t *testing.T) {
// Provider without manages-soa-serial capability.
provider := &happydns.Provider{
ProviderMeta: happydns.ProviderMeta{
Type: "NoSuchProvider",
},
}
storage := newInMemoryZoneStorage()
wipZoneID := happydns.Identifier([]byte("wip-zone"))
wipZone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{
Id: wipZoneID,
DefaultTTL: 3600,
},
Services: map[happydns.Subdomain][]*happydns.Service{},
}
storage.zones[wipZoneID.String()] = wipZone
domain := &happydns.Domain{
Id: happydns.Identifier([]byte("test-domain")),
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
ZoneHistory: []happydns.Identifier{wipZoneID},
}
retriever := &mockZoneRetriever{records: nil}
uc := buildTestApplier(
&mockProviderGetter{provider: provider},
&mockZoneCorrector{corrections: nil, nbDiff: 0},
retriever,
&mockDomainUpdater{domain: domain},
storage,
)
snapshot, err := uc.Apply(
context.Background(),
&happydns.User{Id: happydns.Identifier([]byte("test-user"))},
domain,
wipZone,
&happydns.ApplyZoneForm{
WantedCorrections: nil,
CommitMsg: "test deploy",
},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if snapshot == nil {
t.Fatal("expected snapshot, got nil")
}
}
func TestApply_Refetch_WhenProviderManagesSOASerial(t *testing.T) {
// Use the DDNSServer type which has manages-soa-serial capability.
// First verify it's registered.
creators := providerReg.GetProviders()
_, hasDDNS := creators["DDNSServer"]
if !hasDDNS {
t.Skip("DDNSServer provider not registered")
}
provider := &happydns.Provider{
ProviderMeta: happydns.ProviderMeta{
Type: "DDNSServer",
},
}
storage := newInMemoryZoneStorage()
// Create WIP zone with an Origin service containing old SOA serial.
wipZoneID := happydns.Identifier([]byte("wip-zone"))
oldSerial := uint32(2024010100)
wipZone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{
Id: wipZoneID,
DefaultTTL: 3600,
},
Services: map[happydns.Subdomain][]*happydns.Service{
"": {
{
ServiceMeta: happydns.ServiceMeta{
Id: happydns.Identifier([]byte("origin-svc")),
Type: "abstract.Origin",
},
Service: &abstract.Origin{
SOA: &dns.SOA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 3600},
Ns: "ns1.example.com.",
Mbox: "admin.example.com.",
Serial: oldSerial,
},
NameServers: []*dns.NS{
{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 3600}, Ns: "ns1.example.com."},
},
},
},
},
},
}
storage.zones[wipZoneID.String()] = wipZone
domain := &happydns.Domain{
Id: happydns.Identifier([]byte("test-domain")),
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
ZoneHistory: []happydns.Identifier{wipZoneID},
}
// The re-fetched records contain a new SOA serial.
newSerial := uint32(2024010101)
refetchedRecords := []happydns.Record{
&dns.SOA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 3600},
Ns: "ns1.example.com.",
Mbox: "admin.example.com.",
Serial: newSerial,
},
&dns.NS{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 3600},
Ns: "ns1.example.com.",
},
}
retriever := &mockZoneRetriever{records: refetchedRecords}
uc := buildTestApplier(
&mockProviderGetter{provider: provider},
&mockZoneCorrector{corrections: nil, nbDiff: 0},
retriever,
&mockDomainUpdater{domain: domain},
storage,
)
snapshot, err := uc.Apply(
context.Background(),
&happydns.User{Id: happydns.Identifier([]byte("test-user"))},
domain,
wipZone,
&happydns.ApplyZoneForm{
WantedCorrections: nil,
CommitMsg: "test deploy with SOA",
},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify snapshot has the new serial.
snapshotSerial := getOriginSOASerial(t, snapshot)
if snapshotSerial != newSerial {
t.Errorf("snapshot SOA serial: got %d, want %d", snapshotSerial, newSerial)
}
// Verify WIP zone was patched with new serial.
updatedWIP := storage.zones[wipZoneID.String()]
wipSerial := getOriginSOASerial(t, updatedWIP)
if wipSerial != newSerial {
t.Errorf("WIP zone SOA serial: got %d, want %d", wipSerial, newSerial)
}
}
func TestApply_RefetchFails_FallsBackToTargetRecords(t *testing.T) {
creators := providerReg.GetProviders()
_, hasDDNS := creators["DDNSServer"]
if !hasDDNS {
t.Skip("DDNSServer provider not registered")
}
provider := &happydns.Provider{
ProviderMeta: happydns.ProviderMeta{
Type: "DDNSServer",
},
}
storage := newInMemoryZoneStorage()
wipZoneID := happydns.Identifier([]byte("wip-zone"))
wipZone := &happydns.Zone{
ZoneMeta: happydns.ZoneMeta{
Id: wipZoneID,
DefaultTTL: 3600,
},
Services: map[happydns.Subdomain][]*happydns.Service{},
}
storage.zones[wipZoneID.String()] = wipZone
domain := &happydns.Domain{
Id: happydns.Identifier([]byte("test-domain")),
ProviderId: happydns.Identifier([]byte("test-provider")),
DomainName: "example.com.",
ZoneHistory: []happydns.Identifier{wipZoneID},
}
// Retriever succeeds on first call (lister diff), fails on second (re-fetch).
retriever := &mockZoneRetrieverFailOnNth{
records: nil,
failOnNth: 2,
failErr: fmt.Errorf("connection refused"),
}
uc := buildTestApplier(
&mockProviderGetter{provider: provider},
&mockZoneCorrector{corrections: nil, nbDiff: 0},
retriever,
&mockDomainUpdater{domain: domain},
storage,
)
snapshot, err := uc.Apply(
context.Background(),
&happydns.User{Id: happydns.Identifier([]byte("test-user"))},
domain,
wipZone,
&happydns.ApplyZoneForm{
WantedCorrections: nil,
CommitMsg: "test deploy fallback",
},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if snapshot == nil {
t.Fatal("expected snapshot, got nil")
}
}
// getOriginSOASerial extracts the SOA serial from the Origin service in a zone.
func getOriginSOASerial(t *testing.T, zone *happydns.Zone) uint32 {
t.Helper()
if services, ok := zone.Services[""]; ok {
for _, svc := range services {
if svc.Type == "abstract.Origin" {
if origin, ok := svc.Service.(*abstract.Origin); ok && origin.SOA != nil {
return origin.SOA.Serial
}
}
}
}
t.Fatal("no Origin service with SOA found in zone")
return 0
}

View file

@ -65,6 +65,7 @@ func transferMetadata(oldSvc, newSvc *happydns.Service, origin string, defaultTT
newSvc.UserComment = oldSvc.UserComment
newSvc.OwnerId = oldSvc.OwnerId
newSvc.Aliases = oldSvc.Aliases
newSvc.PropagatedAt = oldSvc.PropagatedAt
if oldSvc.Ttl != 0 {
serviceTtl := oldSvc.Ttl

View file

@ -23,6 +23,7 @@ package happydns
import (
"encoding/json"
"time"
)
type Service struct {
@ -109,6 +110,10 @@ type ServiceMeta struct {
// NbResources holds the number of Resources stored inside this Service.
NbResources int `json:"_tmp_hint_nb"`
// PropagatedAt is the estimated time at which the last published changes
// for this service will be fully propagated (old cached records expired).
PropagatedAt *time.Time `json:"_propagated_at,omitempty"`
}
// ServiceCombined combined ServiceMeta + Service

BIN
providers/IonosAPI.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

View file

@ -68,7 +68,8 @@ func init() {
adapter.RegisterDNSControlProviderAdapter(func() happydns.ProviderBody {
return &DDNSServer{}
}, happydns.ProviderInfos{
Name: "Dynamic DNS",
Description: "If your zone is hosted on an authoritative name server that support Dynamic DNS (RFC 2136), such as Bind, Knot, ...",
Name: "Dynamic DNS",
Description: "If your zone is hosted on an authoritative name server that support Dynamic DNS (RFC 2136), such as Bind, Knot, ...",
Capabilities: []string{"manages-soa-serial"},
}, providerReg.RegisterProvider)
}

File diff suppressed because one or more lines are too long

53
providers/ionos.go Normal file
View file

@ -0,0 +1,53 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 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 providers // import "git.happydns.org/happyDomain/providers"
import (
"github.com/libdns/ionos"
"git.happydns.org/happyDomain/internal/adapters"
providerReg "git.happydns.org/happyDomain/internal/provider"
"git.happydns.org/happyDomain/model"
)
type IonosAPI struct {
AuthAPIToken string `json:"auth_api_token,omitempty" happydomain:"label=Auth API Token,secret,required,description=Your IONOS Auth API token"`
}
func (s *IonosAPI) LibdnsProvider() any {
return &ionos.Provider{
AuthAPIToken: s.AuthAPIToken,
}
}
func (s *IonosAPI) InstantiateProvider() (happydns.ProviderActuator, error) {
return adapter.NewLibdnsProviderAdapter(s)
}
func init() {
adapter.RegisterLibdnsProviderAdapter(func() happydns.ProviderBody {
return &IonosAPI{}
}, happydns.ProviderInfos{
Name: "IONOS",
Description: "German hosting provider (1&1 IONOS)",
}, providerReg.RegisterProvider)
}

View file

@ -0,0 +1,73 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 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/>.
-->
<script lang="ts">
import { Badge, Button, ButtonGroup, Icon } from "@sveltestrap/sveltestrap";
import ProviderLink from "$lib/components/providers/ProviderLink.svelte";
import type { Domain } from "$lib/model/domain";
import { navigate } from "$lib/stores/config";
import { t } from "$lib/translations";
interface Props {
domain: Domain;
ondelete: (event: Event, domain: Domain) => void;
}
let { domain, ondelete }: Props = $props();
</script>
<tr
style="cursor: pointer"
onclick={() => navigate("/domains/" + encodeURIComponent(domain.domain))}
>
<td class="fw-semibold">{domain.domain}</td>
<td>{domain.group || ""}</td>
<td>
<ProviderLink id_provider={domain.id_provider} onclick={(e) => e.stopPropagation()} />
</td>
<td>
<Badge color="success">OK</Badge>
</td>
<td class="text-end">
<ButtonGroup size="sm">
<Button
color="outline-secondary"
title={$t("domains.actions.view")}
onclick={(e) => {
e.stopPropagation();
navigate("/domains/" + encodeURIComponent(domain.domain));
}}
>
<Icon name="eye" />
</Button>
<Button
color="outline-danger"
title={$t("domains.stop")}
onclick={(e) => ondelete(e, domain)}
>
<Icon name="trash" />
</Button>
</ButtonGroup>
</td>
</tr>

View file

@ -24,14 +24,13 @@
<script lang="ts">
import type { ClassValue } from "svelte/elements";
import { Badge, Button, ButtonGroup, Icon, Spinner, Table } from "@sveltestrap/sveltestrap";
import { Spinner, Table } from "@sveltestrap/sveltestrap";
import { deleteDomain } from "$lib/api/domains";
import ImgProvider from "$lib/components/providers/ImgProvider.svelte";
import DomainTableRow from "$lib/components/domains/DomainTableRow.svelte";
import type { Domain } from "$lib/model/domain";
import { navigate } from "$lib/stores/config";
import { refreshDomains } from "$lib/stores/domains";
import { providers_idx, providersSpecs, refreshProvidersSpecs } from "$lib/stores/providers";
import { providersSpecs, refreshProvidersSpecs } from "$lib/stores/providers";
import { t } from "$lib/translations";
interface Props {
@ -76,61 +75,7 @@
</thead>
<tbody>
{#each items as item (item.id)}
<tr
style="cursor: pointer"
onclick={() => navigate("/domains/" + encodeURIComponent(item.domain))}
>
<td class="fw-semibold">{item.domain}</td>
<td>{item.group || ""}</td>
<td>
{#if $providers_idx && $providers_idx[item.id_provider]}
{@const provider = $providers_idx[item.id_provider]}
<a
href="/providers/{encodeURIComponent(item.id_provider)}"
class="d-flex align-items-center gap-2 text-decoration-none"
onclick={(e) => e.stopPropagation()}
>
<ImgProvider
id_provider={item.id_provider}
style="max-width: 1.5em; max-height: 1.5em; object-fit: contain;"
/>
{#if provider._comment}
{provider._comment}
{:else if $providersSpecs && $providersSpecs[provider._srctype]}
{$providersSpecs[provider._srctype].name}
{:else}
{provider._srctype}
{/if}
</a>
{:else}
<em class="text-muted">{item.id_provider}</em>
{/if}
</td>
<td>
<Badge color="success">OK</Badge>
</td>
<td class="text-end">
<ButtonGroup size="sm">
<Button
color="outline-secondary"
title={$t("domains.actions.view")}
onclick={(e) => {
e.stopPropagation();
navigate("/domains/" + encodeURIComponent(item.domain));
}}
>
<Icon name="eye" />
</Button>
<Button
color="outline-danger"
title={$t("domains.stop")}
onclick={(e) => delDomain(e, item)}
>
<Icon name="trash" />
</Button>
</ButtonGroup>
</td>
</tr>
<DomainTableRow domain={item} ondelete={delDomain} />
{/each}
</tbody>
</Table>

View file

@ -0,0 +1,57 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 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/>.
-->
<script lang="ts">
import ImgProvider from "$lib/components/providers/ImgProvider.svelte";
import { providers_idx, providersSpecs } from "$lib/stores/providers";
interface Props {
id_provider: string;
onclick?: (e: MouseEvent) => void;
}
let { id_provider, onclick }: Props = $props();
</script>
{#if $providers_idx && $providers_idx[id_provider]}
{@const provider = $providers_idx[id_provider]}
<a
href="/providers/{encodeURIComponent(id_provider)}"
class="d-flex align-items-center gap-2 text-decoration-none"
{onclick}
>
<ImgProvider
{id_provider}
style="max-width: 1.5em; max-height: 1.5em; object-fit: contain;"
/>
{#if provider._comment}
{provider._comment}
{:else if $providersSpecs && $providersSpecs[provider._srctype]}
{$providersSpecs[provider._srctype].name}
{:else}
{provider._srctype}
{/if}
</a>
{:else}
<em class="text-muted">{id_provider}</em>
{/if}

View file

@ -0,0 +1,73 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 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/>.
-->
<script lang="ts">
import { onDestroy } from "svelte";
import { formatCountdown } from "$lib/utils/datetime";
import { t } from "$lib/translations";
interface Props {
isPropagating?: boolean;
localeString?: string;
propagatedAt?: string | null;
}
let {
isPropagating = $bindable(false),
propagatedAt,
localeString = "service.propagation-remaining",
}: Props = $props();
let _propagatedAt = $derived(propagatedAt ? new Date(propagatedAt) : null);
let countdown = $state("");
let interval: ReturnType<typeof setInterval>;
onDestroy(() => {
if (interval) clearInterval(interval);
});
$effect(() => {
if (_propagatedAt) {
isPropagating = _propagatedAt > new Date();
if (interval) clearInterval(interval);
countdown = formatCountdown(_propagatedAt);
interval = setInterval(() => {
countdown = formatCountdown(_propagatedAt);
if (_propagatedAt <= new Date()) {
isPropagating = false;
clearInterval(interval);
}
}, 1000);
} else if (interval) {
clearInterval(interval);
}
});
</script>
{#if propagatedAt}
<span style="font-variant-numeric: tabular-nums">{$t(localeString, { countdown })}</span>
{/if}

View file

@ -0,0 +1,51 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 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/>.
-->
<script lang="ts">
import { Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import PropagationCountdown from "./PropagationCountdown.svelte";
interface Props {
propagatedAt?: string | null;
}
let { propagatedAt }: Props = $props();
let _propagatedAt = $derived(propagatedAt ? new Date(propagatedAt) : null);
let isPropagating = $derived(_propagatedAt && _propagatedAt > new Date());
</script>
{#if isPropagating}
<div class="border rounded p-2 mt-3 bg-white">
<div class="d-flex align-items-center gap-2 mb-1">
<Icon name="broadcast-pin" class="text-warning" />
<strong class="small">{$t("service.propagation-in-progress")}</strong>
</div>
<p class="small text-muted mb-2">
<PropagationCountdown bind:isPropagating {propagatedAt} />
</p>
</div>
{/if}

View file

@ -415,7 +415,10 @@
"default-ttl": "Default TTL",
"ttl": "TTL",
"ttl-long": "Time-To-Live",
"ttl-tip": "0 means default TTL, others values are exprimed in seconds"
"ttl-tip": "0 means default TTL, others values are exprimed in seconds",
"propagating": "Propagating {{countdown}}",
"propagation-in-progress": "DNS propagation in progress",
"propagation-remaining": "Propagation complete in {{countdown}}"
},
"records": {
"add": "Add record",

View file

@ -373,7 +373,10 @@
"default-ttl": "TTL par défaut",
"ttl": "TTL",
"ttl-long": "Time-To-Live",
"ttl-tip": "0 correspond au TTL par défaut, les autres valeurs sont exprimées en secondes"
"ttl-tip": "0 correspond au TTL par défaut, les autres valeurs sont exprimées en secondes",
"propagating": "Propagation {{countdown}}",
"propagation-in-progress": "Propagation DNS en cours",
"propagation-remaining": "Propagation complète dans {{countdown}}"
},
"upgrade": {
"title": "Une mise à jour est disponible!",

View file

@ -29,6 +29,7 @@ export class ServiceMeta {
_mycomment? = $state<string | undefined>(undefined);
_aliases? = $state<Array<string> | undefined>(undefined);
_tmp_hint_nb? = $state<number | undefined>(undefined);
_propagated_at? = $state<string | undefined>(undefined);
constructor(init?: {
_svctype: string;
@ -40,6 +41,7 @@ export class ServiceMeta {
_mycomment?: string;
_aliases?: Array<string>;
_tmp_hint_nb?: number;
_propagated_at?: string;
}) {
if (init) {
this._svctype = init._svctype;
@ -51,6 +53,7 @@ export class ServiceMeta {
if (init._mycomment !== undefined) this._mycomment = init._mycomment;
if (init._aliases !== undefined) this._aliases = init._aliases;
if (init._tmp_hint_nb !== undefined) this._tmp_hint_nb = init._tmp_hint_nb;
if (init._propagated_at !== undefined) this._propagated_at = init._propagated_at;
}
}
@ -65,6 +68,7 @@ export class ServiceMeta {
_mycomment: this._mycomment,
_aliases: this._aliases,
_tmp_hint_nb: this._tmp_hint_nb,
_propagated_at: this._propagated_at,
};
}
}
@ -82,6 +86,7 @@ export class ServiceCombined extends ServiceMeta {
_mycomment?: string;
_aliases?: Array<string>;
_tmp_hint_nb?: number;
_propagated_at?: string;
Service?: any;
}) {
super(init);

View file

@ -19,7 +19,6 @@
// 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/>.
import { get } from "svelte/store";
import { derived, writable, type Writable } from "svelte/store";
import { domainCompare } from "$lib/dns";
import { retrieveZone as APIRetrieveZone, getZone as APIGetZone } from "$lib/api/zone";
@ -83,11 +82,6 @@ export const sortedDomainsWithIntermediate = derived(sortedDomains, ($sortedDoma
// getZone retrieve a given zone
export async function getZone(domain: Domain, zoneId: string) {
const currentZone = get(thisZone);
if (currentZone?.id === zoneId) {
return currentZone;
}
thisZone.set(null);
const zone = await APIGetZone(domain, zoneId);

View file

@ -43,6 +43,7 @@ interface Params {
name?: string;
nbDiffs?: number;
nbSelected?: number;
countdown?: string;
// add more parameters that are used here
}

View file

@ -31,3 +31,31 @@ export function fromDatetimeLocal(datetimeLocal: string): string | null {
return null;
}
}
/**
* Format a countdown timer string from a target date.
* Displays the remaining time in a human-readable format (e.g., "2d 5h", "3h 20m").
* @param date Target date to count down to
* @returns Formatted countdown string (e.g., "2d 5h", "30m", "45s"), or "0m" if date is in the past
*/
export function formatCountdown(date: Date): string {
const diff = date.getTime() - Date.now();
if (diff <= 0) return "0m";
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${days}d ${hours % 24}h`;
} else if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 9) {
return `${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}

View file

@ -43,6 +43,7 @@
import { getServiceSpec } from "$lib/api/service_specs";
import { deleteZoneService, updateZoneService } from "$lib/api/zone";
import ServiceBadges from "./[[historyid]]/ServiceBadges.svelte";
import PropagationStatus from "$lib/components/services/PropagationStatus.svelte";
import RecordLine from "$lib/components/services/editors/RecordLine.svelte";
import { collectRRs } from "$lib/dns";
import type { Domain } from "$lib/model/domain";
@ -154,6 +155,7 @@
{/each}
{/await}
{/if}
<PropagationStatus propagatedAt={service._propagated_at} />
<div class="flex-fill"></div>
{#if service._id}
<div class="d-flex align-items-center gap-2 mt-2">

View file

@ -1,7 +1,7 @@
import { get } from "svelte/store";
import { error, redirect, type Load } from "@sveltejs/kit";
import { domains_idx } from "$lib/stores/domains";
import { getZone } from "$lib/stores/thiszone";
import { getZone, thisZone } from "$lib/stores/thiszone";
export const load: Load = async ({ parent, params }) => {
const data = await parent();
@ -32,7 +32,10 @@ export const load: Load = async ({ parent, params }) => {
const zoneId: string = domain.zone_history[zhidx];
getZone(domain, zoneId);
const currentZone = get(thisZone);
if (currentZone?.id !== zoneId) {
getZone(domain, zoneId);
}
return {
...data,

View file

@ -30,6 +30,7 @@
CardSubtitle,
Icon,
Spinner,
Progress,
} from "@sveltestrap/sveltestrap";
import ServiceBadges from "./ServiceBadges.svelte";
@ -38,8 +39,8 @@
import type { Domain } from "$lib/model/domain";
import type { ServiceCombined } from "$lib/model/service.svelte";
import { servicesSpecs, servicesSpecsLoaded } from "$lib/stores/services";
import { navigate } from "$lib/stores/config";
import { t } from "$lib/translations";
import PropagationCountdown from "$lib/components/services/PropagationCountdown.svelte";
interface Props {
dn: string;
@ -50,6 +51,9 @@
let { dn, origin, service = $bindable(null), zoneId }: Props = $props();
// Will be changed by PropagationCountdown
let isPropagating = $state(true);
function openService() {
if (service) {
const subdomainParam = dn === "" ? "@" : dn;
@ -62,7 +66,7 @@
<Card
class="card-hover h-100"
style={"cursor: pointer;" + (!service ? "border-style: dashed; border-width: 2px" : "")}
style={"cursor: pointer; " + (!service ? "border-style: dashed; border-width: 2px" : "")}
on:click={openService}
>
{#if !$servicesSpecsLoaded}
@ -79,7 +83,24 @@
<Icon name="plus-circle" /> {$t("service.new")}
{/if}
</CardTitle>
<ServiceBadges {service} />
{#if service?._propagated_at && isPropagating}
<Progress
class="rounded"
barClassName="px-2 text-end"
value={100}
color="primary"
striped
animated
>
<PropagationCountdown
bind:isPropagating
propagatedAt={service?._propagated_at}
localeString="service.propagating"
/>
</Progress>
{:else}
<ServiceBadges {service} />
{/if}
</div>
<CardSubtitle class="mb-2 text-muted fst-italic">
{#if service}