diff --git a/README.md b/README.md
index 00c3966f7..d946f7b85 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ This readme and the [docs/](docs/) directory are **versioned** to match the prog
- Updates periodically A records for different DNS providers:
- Aliyun
- AllInkl
+ - Azure
- Changeip
- Cloudflare
- DD24
@@ -209,6 +210,7 @@ Check the documentation for your DNS provider:
- [Aliyun](docs/aliyun.md)
- [Allinkl](docs/allinkl.md)
+- [Azure](docs/azure.md)
- [ChangeIP](docs/changeip.md)
- [Cloudflare](docs/cloudflare.md)
- [Custom](docs/custom.md)
diff --git a/docs/azure.md b/docs/azure.md
new file mode 100644
index 000000000..9cb974e1c
--- /dev/null
+++ b/docs/azure.md
@@ -0,0 +1,64 @@
+# Azure
+
+## Configuration
+
+### Example
+
+```json
+{
+ "settings": [
+ {
+ "provider": "azure",
+ "domain": "domain.com",
+ "host": "@",
+ "tenant_id": "",
+ "client_id": "",
+ "client_secret": "",
+ "subscription_id": "",
+ "resource_group_name": ""
+ }
+ ]
+}
+```
+
+### Compulsory parameters
+
+- `"domain"`
+- `"host"`
+- `"tenant_id"`
+- `"client_id"`
+- `"client_secret"`
+- `"subscription_id"` found in the properties section of Azure DNS
+- `"resource_group_name"` found in the properties section of Azure DNS
+
+### Optional parameters
+
+- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), defaults to `ipv4 or ipv6`
+
+## Domain setup
+
+Thanks to @danimart1991 for describing the following steps!
+
+1. Create Domain
+1. Activate Azure DNS Zone for that domain
+1. Find the following parameters in the Properties section of Azure DNS:
+ - The name or URL `AnyNameOrUrl` for the query below **TODO**
+ - `subscription_id`
+ - `resource_group_name`
+1. In the Azure Console (inside the portal), run:
+
+ ```sh
+ az ad sp create-for-rbac -n "$AnyNameOrUrl" --scopes "/subscriptions/$subscription_id/resourceGroups/$resource_group_name/providers/Microsoft.Network/dnszones/$zone_name"
+ ```
+
+ This gives you the rest of the parameters:
+
+ ```json
+ {
+ "appId": "{app_id/client_id}",
+ "displayName": "not important",
+ "name": "not important",
+ "password": "{app_password}",
+ "tenant": "not important"
+ }
+ ```
diff --git a/go.mod b/go.mod
index 168bd6535..33c4a8cdc 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,9 @@ module github.com/qdm12/ddns-updater
go 1.22
require (
+ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1
+ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0
+ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0
github.com/breml/rootcerts v0.2.17
github.com/containrrr/shoutrrr v0.8.0
github.com/go-chi/chi/v5 v5.0.12
@@ -21,16 +24,24 @@ require (
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 // indirect
+ github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
+ github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+ golang.org/x/crypto v0.24.0 // indirect
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.21.0 // indirect
+ golang.org/x/text v0.16.0 // indirect
golang.org/x/tools v0.22.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum
index e7105fbcd..b1dd5b344 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,15 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/breml/rootcerts v0.2.17 h1:0/M2BE2Apw0qEJCXDOkaiu7d5Sx5ObNfe1BkImJ4u1I=
github.com/breml/rootcerts v0.2.17/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw=
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
@@ -15,6 +25,8 @@ github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
+github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
+github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -23,8 +35,16 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -36,6 +56,8 @@ github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qdm12/goservices v0.1.0 h1:9sODefm/yuIGS7ynCkEnNlMTAYn9GzPhtcK4F69JWvc=
@@ -48,6 +70,8 @@ github.com/qdm12/gotree v0.2.0 h1:+58ltxkNLUyHtATFereAcOjBVfY6ETqRex8XK90Fb/c=
github.com/qdm12/gotree v0.2.0/go.mod h1:1SdFaqKZuI46U1apbXIf25pDMNnrPuYLEqMF/qL4lY4=
github.com/qdm12/log v0.1.0 h1:jYBd/xscHYpblzZAd2kjZp2YmuYHjAAfbTViJWxoPTw=
github.com/qdm12/log v0.1.0/go.mod h1:Vchi5M8uBvHfPNIblN4mjXn/oSbiWguQIbsgF1zdQPI=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
@@ -55,6 +79,8 @@ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
+golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -77,6 +103,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -94,8 +121,9 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/provider/constants/providers.go b/internal/provider/constants/providers.go
index d7050a65a..851fd4ead 100644
--- a/internal/provider/constants/providers.go
+++ b/internal/provider/constants/providers.go
@@ -6,6 +6,7 @@ import "github.com/qdm12/ddns-updater/internal/models"
const (
Aliyun models.Provider = "aliyun"
AllInkl models.Provider = "allinkl"
+ Azure models.Provider = "azure"
Changeip models.Provider = "changeip"
Cloudflare models.Provider = "cloudflare"
Custom models.Provider = "custom"
@@ -57,6 +58,7 @@ func ProviderChoices() []models.Provider {
return []models.Provider{
Aliyun,
AllInkl,
+ Azure,
Changeip,
Cloudflare,
Dd24,
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index db750bb6a..4c0457035 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -12,6 +12,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/provider/providers/aliyun"
"github.com/qdm12/ddns-updater/internal/provider/providers/allinkl"
+ "github.com/qdm12/ddns-updater/internal/provider/providers/azure"
"github.com/qdm12/ddns-updater/internal/provider/providers/changeip"
"github.com/qdm12/ddns-updater/internal/provider/providers/cloudflare"
"github.com/qdm12/ddns-updater/internal/provider/providers/custom"
@@ -82,6 +83,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin
return aliyun.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.AllInkl:
return allinkl.New(data, domain, owner, ipVersion, ipv6Suffix)
+ case constants.Azure:
+ return azure.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Changeip:
return changeip.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Cloudflare:
diff --git a/internal/provider/providers/azure/api.go b/internal/provider/providers/azure/api.go
new file mode 100644
index 000000000..f562d7777
--- /dev/null
+++ b/internal/provider/providers/azure/api.go
@@ -0,0 +1,77 @@
+package azure
+
+import (
+ "context"
+ "fmt"
+ "net/netip"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
+ "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns"
+)
+
+func (p *Provider) createClient() (client *armdns.RecordSetsClient, err error) {
+ credential, err := azidentity.NewClientSecretCredential(p.tenantID, p.clientID, p.clientSecret, nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating client secret credential: %w", err)
+ }
+
+ client, err = armdns.NewRecordSetsClient(p.subscriptionID, credential, nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating record sets client: %w", err)
+ }
+
+ return client, nil
+}
+
+func (p *Provider) getRecordSet(ctx context.Context, client *armdns.RecordSetsClient,
+ recordType armdns.RecordType) (response armdns.RecordSetsClientGetResponse, err error) {
+ return client.Get(ctx, p.resourceGroupName, p.domain, p.owner, recordType, nil)
+}
+
+func (p *Provider) createRecordSet(ctx context.Context, client *armdns.RecordSetsClient,
+ ip netip.Addr) (err error) {
+ rrSet := armdns.RecordSet{Properties: &armdns.RecordSetProperties{}}
+ recordType := armdns.RecordTypeA
+ if ip.Is4() {
+ rrSet.Properties.ARecords = []*armdns.ARecord{{IPv4Address: ptrTo(ip.String())}}
+ } else {
+ recordType = armdns.RecordTypeAAAA
+ rrSet.Properties.AaaaRecords = []*armdns.AaaaRecord{{IPv6Address: ptrTo(ip.String())}}
+ }
+ _, err = client.CreateOrUpdate(ctx, p.resourceGroupName, p.domain,
+ p.owner, recordType, rrSet, nil)
+ if err != nil {
+ return fmt.Errorf("creating record set: %w", err)
+ }
+ return nil
+}
+
+func (p *Provider) updateRecordSet(ctx context.Context, client *armdns.RecordSetsClient,
+ response armdns.RecordSetsClientGetResponse, ip netip.Addr) (err error) {
+ properties := response.Properties
+ recordType := armdns.RecordTypeA
+ if ip.Is4() {
+ if len(properties.ARecords) == 0 {
+ properties.ARecords = make([]*armdns.ARecord, 1)
+ }
+ for i := range properties.ARecords {
+ properties.ARecords[i].IPv4Address = ptrTo(ip.String())
+ }
+ } else {
+ recordType = armdns.RecordTypeAAAA
+ if len(properties.AaaaRecords) == 0 {
+ properties.AaaaRecords = make([]*armdns.AaaaRecord, 1)
+ }
+ for i := range properties.AaaaRecords {
+ properties.AaaaRecords[i].IPv6Address = ptrTo(ip.String())
+ }
+ }
+ rrSet := armdns.RecordSet{
+ Etag: response.Etag,
+ Properties: properties,
+ }
+
+ _, err = client.CreateOrUpdate(ctx, p.resourceGroupName, p.domain,
+ p.owner, recordType, rrSet, nil)
+ return err
+}
diff --git a/internal/provider/providers/azure/provider.go b/internal/provider/providers/azure/provider.go
new file mode 100644
index 000000000..8aa43b783
--- /dev/null
+++ b/internal/provider/providers/azure/provider.go
@@ -0,0 +1,155 @@
+package azure
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/netip"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
+ "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns"
+ "github.com/qdm12/ddns-updater/internal/models"
+ "github.com/qdm12/ddns-updater/internal/provider/constants"
+ ddnserrors "github.com/qdm12/ddns-updater/internal/provider/errors"
+ "github.com/qdm12/ddns-updater/internal/provider/utils"
+ "github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
+)
+
+type Provider struct {
+ domain string // aka zoneName
+ owner string // aka relativeRecordSetName
+ ipVersion ipversion.IPVersion
+ ipv6Suffix netip.Prefix
+ tenantID string
+ clientID string
+ clientSecret string
+ subscriptionID string
+ resourceGroupName string
+}
+
+func New(data json.RawMessage, domain, owner string,
+ ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (
+ p *Provider, err error) {
+ var providerSpecificSettings struct {
+ TenantID string `json:"tenant_id"`
+ ClientID string `json:"client_id"`
+ ClientSecret string `json:"client_secret"`
+ SubscriptionID string `json:"subscription_id"`
+ ResourceGroupName string `json:"resource_group_name"`
+ }
+ err = json.Unmarshal(data, &providerSpecificSettings)
+ if err != nil {
+ return nil, fmt.Errorf("json decoding provider specific settings: %w", err)
+ }
+ err = validateSettings(domain, owner, providerSpecificSettings.TenantID,
+ providerSpecificSettings.ClientID, providerSpecificSettings.ClientSecret,
+ providerSpecificSettings.SubscriptionID, providerSpecificSettings.ResourceGroupName)
+ if err != nil {
+ return nil, fmt.Errorf("validating settings: %w", err)
+ }
+ return &Provider{
+ domain: domain,
+ owner: owner,
+ ipVersion: ipVersion,
+ ipv6Suffix: ipv6Suffix,
+ tenantID: providerSpecificSettings.TenantID,
+ clientID: providerSpecificSettings.ClientID,
+ clientSecret: providerSpecificSettings.ClientSecret,
+ subscriptionID: providerSpecificSettings.SubscriptionID,
+ resourceGroupName: providerSpecificSettings.ResourceGroupName,
+ }, nil
+}
+
+func validateSettings(domain, owner, tenantID, clientID,
+ clientSecret, subscriptionID, resourceGroupName string) error {
+ switch {
+ case domain == "":
+ return fmt.Errorf("%w", ddnserrors.ErrDomainNotSet)
+ case owner == "":
+ return fmt.Errorf("%w", ddnserrors.ErrOwnerNotSet)
+ case tenantID == "":
+ return fmt.Errorf("%w: tenant id", ddnserrors.ErrCredentialsNotSet)
+ case clientID == "":
+ return fmt.Errorf("%w: client id", ddnserrors.ErrCredentialsNotSet)
+ case clientSecret == "":
+ return fmt.Errorf("%w: client secret", ddnserrors.ErrCredentialsNotSet)
+ case subscriptionID == "":
+ return fmt.Errorf("%w: subscription id", ddnserrors.ErrKeyNotSet)
+ case resourceGroupName == "":
+ return fmt.Errorf("%w: resource group name", ddnserrors.ErrKeyNotSet)
+ }
+ return nil
+}
+
+func (p *Provider) String() string {
+ return utils.ToString(p.domain, p.owner, constants.Azure, p.ipVersion)
+}
+
+func (p *Provider) Domain() string {
+ return p.domain
+}
+
+func (p *Provider) Owner() string {
+ return p.owner
+}
+
+func (p *Provider) IPVersion() ipversion.IPVersion {
+ return p.ipVersion
+}
+
+func (p *Provider) IPv6Suffix() netip.Prefix {
+ return p.ipv6Suffix
+}
+
+func (p *Provider) Proxied() bool {
+ return false
+}
+
+func (p *Provider) BuildDomainName() string {
+ return utils.BuildDomainName(p.owner, p.domain)
+}
+
+func (p *Provider) HTML() models.HTMLRow {
+ return models.HTMLRow{
+ Domain: fmt.Sprintf("%s", p.BuildDomainName(), p.BuildDomainName()),
+ Owner: p.Owner(),
+ Provider: "Azure",
+ IPVersion: p.ipVersion.String(),
+ }
+}
+
+func ptrTo[T any](v T) *T { return &v }
+
+func (p *Provider) Update(ctx context.Context, _ *http.Client, ip netip.Addr) (newIP netip.Addr, err error) {
+ var recordType armdns.RecordType
+ if ip.Is4() {
+ recordType = armdns.RecordTypeA
+ } else {
+ recordType = armdns.RecordTypeAAAA
+ }
+
+ client, err := p.createClient()
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("creating client: %w", err)
+ }
+
+ response, err := p.getRecordSet(ctx, client, recordType)
+ if err != nil {
+ azureErr := &azcore.ResponseError{}
+ if errors.As(err, &azureErr) && azureErr.StatusCode == http.StatusNotFound {
+ err = p.createRecordSet(ctx, client, ip)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("creating record set: %w", err)
+ }
+ }
+ return netip.Addr{}, fmt.Errorf("getting record set: %w", err)
+ }
+
+ err = p.updateRecordSet(ctx, client, response, ip)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("updating record set: %w", err)
+ }
+ return ip, nil
+}