Compare commits
7 commits
ca9dc450c3
...
987c1bb72e
| Author | SHA1 | Date | |
|---|---|---|---|
| 987c1bb72e | |||
| 25f37af35d | |||
| e1eb4dec90 | |||
| d298992b63 | |||
| aba39001d8 | |||
| d600bbcbd9 | |||
| e103d2262a |
31 changed files with 2226 additions and 165 deletions
5
go.mod
5
go.mod
|
|
@ -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
80
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
317
internal/adapters/libdns-providers.go
Normal file
317
internal/adapters/libdns-providers.go
Normal 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
|
||||
}
|
||||
331
internal/adapters/libdns-providers_test.go
Normal file
331
internal/adapters/libdns-providers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
188
internal/adapters/libdns-records.go
Normal file
188
internal/adapters/libdns-records.go
Normal 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
|
||||
}
|
||||
343
internal/adapters/libdns-records_test.go
Normal file
343
internal/adapters/libdns-records_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
130
internal/usecase/orchestrator/propagation.go
Normal file
130
internal/usecase/orchestrator/propagation.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
427
internal/usecase/orchestrator/zone_correction_applier_test.go
Normal file
427
internal/usecase/orchestrator/zone_correction_applier_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
BIN
providers/IonosAPI.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 851 B |
|
|
@ -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
53
providers/ionos.go
Normal 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)
|
||||
}
|
||||
73
web/src/lib/components/domains/DomainTableRow.svelte
Normal file
73
web/src/lib/components/domains/DomainTableRow.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
57
web/src/lib/components/providers/ProviderLink.svelte
Normal file
57
web/src/lib/components/providers/ProviderLink.svelte
Normal 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}
|
||||
73
web/src/lib/components/services/PropagationCountdown.svelte
Normal file
73
web/src/lib/components/services/PropagationCountdown.svelte
Normal 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}
|
||||
51
web/src/lib/components/services/PropagationStatus.svelte
Normal file
51
web/src/lib/components/services/PropagationStatus.svelte
Normal 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}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 !",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ interface Params {
|
|||
name?: string;
|
||||
nbDiffs?: number;
|
||||
nbSelected?: number;
|
||||
countdown?: string;
|
||||
// add more parameters that are used here
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue