NEW PROVIDER: ADGUARDHOME (#3638)

This commit is contained in:
Ishan Jain 2025-07-09 21:36:34 +05:30 committed by GitHub
parent 4ce19352e9
commit e1830abb58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 637 additions and 6 deletions

View file

@ -39,7 +39,7 @@ changelog:
regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$" regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$"
order: 1 order: 1
- title: 'Provider-specific changes:' - title: 'Provider-specific changes:'
regexp: "(?i)((akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cnr|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi|gcloud|gcore|hedns|hetzner|hexonet|hostingde|huaweicloud|inwx|linode|loopia|luadns|msdns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vultr).*:)+.*" regexp: "(?i)((adguardhome|akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cnr|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi|gcloud|gcore|hedns|hetzner|hexonet|hostingde|huaweicloud|inwx|linode|loopia|luadns|msdns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vultr).*:)+.*"
order: 2 order: 2
- title: 'Documentation:' - title: 'Documentation:'
regexp: "(?i)^.*(docs)[(\\w)]*:+.*$" regexp: "(?i)^.*(docs)[(\\w)]*:+.*$"

1
OWNERS
View file

@ -1,3 +1,4 @@
providers/adguardhome @ishanjain28
providers/akamaiedgedns @edglynes providers/akamaiedgedns @edglynes
providers/autodns @arnoschoon providers/autodns @arnoschoon
providers/axfrddns @hnrgrgr providers/axfrddns @hnrgrgr

View file

@ -220,6 +220,44 @@ declare function A(name: string, address: string | number, ...modifiers: RecordM
*/ */
declare function AAAA(name: string, address: string, ...modifiers: RecordModifier[]): DomainModifier; declare function AAAA(name: string, address: string, ...modifiers: RecordModifier[]): DomainModifier;
/**
* `ADGUARDHOME_AAAA_PASSTHROUGH` represents the literal 'A'. AdGuardHome uses this to passthrough
* the original values of a record type.
*
* The second argument to this record type must be empty.
*
* See [this](https://github.com/AdguardTeam/Adguardhome/wiki/Configuration) page for
* more information.
*
* ```javascript
* D("example.com", REG_MY_PROVIDER, DnsProvider(DSP_MY_PROVIDER),
* ADGUARDHOME_AAAA_PASSTHROUGH("foo", ""),
* );
* ```
*
* @see https://docs.dnscontrol.org/language-reference/domain-modifiers/service-provider-specific//adguardhome_aaaa_passthrough
*/
declare function ADGUARDHOME_AAAA_PASSTHROUGH(source: string, destination: string): DomainModifier;
/**
* `ADGUARDHOME_A_PASSTHROUGH` represents the literal 'A'. AdGuardHome uses this to passthrough
* the original values of a record type.
*
* The second argument to this record type must be empty.
*
* See [this](https://github.com/AdguardTeam/Adguardhome/wiki/Configuration) page for
* more information.
*
* ```javascript
* D("example.com", REG_MY_PROVIDER, DnsProvider(DSP_MY_PROVIDER),
* ADGUARDHOME_A_PASSTHROUGH("foo", ""),
* );
* ```
*
* @see https://docs.dnscontrol.org/language-reference/domain-modifiers/service-provider-specific//adguardhome_a_passthrough
*/
declare function ADGUARDHOME_A_PASSTHROUGH(source: string, destination: string): DomainModifier;
/** /**
* AKAMAICDN is a proprietary record type that is used to configure [Zone Apex Mapping](https://www.akamai.com/blog/security/edge-dns--zone-apex-mapping---dnssec). * AKAMAICDN is a proprietary record type that is used to configure [Zone Apex Mapping](https://www.akamai.com/blog/security/edge-dns--zone-apex-mapping---dnssec).
* The AKAMAICDN target must be preconfigured in the Akamai network. * The AKAMAICDN target must be preconfigured in the Akamai network.

View file

@ -78,6 +78,9 @@
* [URL](language-reference/domain-modifiers/URL.md) * [URL](language-reference/domain-modifiers/URL.md)
* [URL301](language-reference/domain-modifiers/URL301.md) * [URL301](language-reference/domain-modifiers/URL301.md)
* Service Provider specific * Service Provider specific
* AdGuard Home
* [ADGUARDHOME_A_PASSTHROUGH](language-reference/domain-modifiers/ADGUARDHOME_A_PASSTHROUGH.md)
* [ADGUARDHOME_AAAA_PASSTHROUGH](language-reference/domain-modifiers/ADGUARDHOME_AAAA_PASSTHROUGH.md)
* Akamai Edge Dns * Akamai Edge Dns
* [AKAMAICDN](language-reference/domain-modifiers/AKAMAICDN.md) * [AKAMAICDN](language-reference/domain-modifiers/AKAMAICDN.md)
* Amazon Route 53 * Amazon Route 53

View file

@ -0,0 +1,26 @@
---
name: ADGUARDHOME_AAAA_PASSTHROUGH
parameters:
- source
- destination
provider: ADGUARDHOME
parameter_types:
source: string
destination: string
---
`ADGUARDHOME_AAAA_PASSTHROUGH` represents the literal 'A'. AdGuardHome uses this to passthrough
the original values of a record type.
The second argument to this record type must be empty.
See [this](https://github.com/AdguardTeam/Adguardhome/wiki/Configuration) page for
more information.
{% code title="dnsconfig.js" %}
```javascript
D("example.com", REG_MY_PROVIDER, DnsProvider(DSP_MY_PROVIDER),
ADGUARDHOME_AAAA_PASSTHROUGH("foo", ""),
);
```
{% endcode %}

View file

@ -0,0 +1,26 @@
---
name: ADGUARDHOME_A_PASSTHROUGH
parameters:
- source
- destination
provider: ADGUARDHOME
parameter_types:
source: string
destination: string
---
`ADGUARDHOME_A_PASSTHROUGH` represents the literal 'A'. AdGuardHome uses this to passthrough
the original values of a record type.
The second argument to this record type must be empty.
See [this](https://github.com/AdguardTeam/Adguardhome/wiki/Configuration) page for
more information.
{% code title="dnsconfig.js" %}
```javascript
D("example.com", REG_MY_PROVIDER, DnsProvider(DSP_MY_PROVIDER),
ADGUARDHOME_A_PASSTHROUGH("foo", ""),
);
```
{% endcode %}

View file

@ -0,0 +1,57 @@
This is the provider for [AdGuardHome](https://github.com/AdguardTeam/AdGuardHome).
## Important notes
This provider only supports the following record types.
* A
* AAAA
* CNAME
* ALIAS
* ADGUARDHOME_A_PASSTHROUGH
* ADGUARDHOME_AAAA_PASSTHROUGH
## Configuration
To use this provider, add an entry to `creds.json` with `TYPE` set to `ADGUARDHOME`.
Required fields include:
* `username` and `password`: Authentication information
* `host`: The hostname/address of AdGuard Home instance
Example:
{% code title="creds.json" %}
```json
{
"adguard_home": {
"TYPE": "ADGUARDHOME",
"username": "admin",
"password": "your-password",
"host": "https://foo.com"
}
}
```
{% endcode %}
## Usage
An example configuration:
{% code title="dnsconfig.js" %}
```javascript
var REG_NONE = NewRegistrar("none");
var DSP_ADGUARDHOME = NewDnsProvider("adguard_home");
// Example domain where the CF proxy abides by the default (off).
D("example.com", REG_NONE, DnsProvider(DSP_ADGUARDHOME),
A("foo", "1.2.3.4"),
AAAA("another", "2003::1"),
ALIAS("@", "www.example.com."),
CNAME("myalias", "www.example.com."),
ADGUARDHOME_A_PASSTHROUGH("abc", ""),
ADGUARDHOME_AAAA_PASSTHROUGH("abc", ""),
);
```
{% endcode %}

View file

@ -25,6 +25,7 @@ Jump to a table:
| Provider name | Official Support | DNS Provider | Registrar | | Provider name | Official Support | DNS Provider | Registrar |
| ------------- | ---------------- | ------------ | --------- | | ------------- | ---------------- | ------------ | --------- |
| [`ADGUARDHOME`](adguardhome.md) | ❌ | ✅ | ❌ |
| [`AKAMAIEDGEDNS`](akamaiedgedns.md) | ❌ | ✅ | ❌ | | [`AKAMAIEDGEDNS`](akamaiedgedns.md) | ❌ | ✅ | ❌ |
| [`AUTODNS`](autodns.md) | ❌ | ✅ | ✅ | | [`AUTODNS`](autodns.md) | ❌ | ✅ | ✅ |
| [`AXFRDDNS`](axfrddns.md) | ❌ | ✅ | ❌ | | [`AXFRDDNS`](axfrddns.md) | ❌ | ✅ | ❌ |
@ -85,6 +86,7 @@ Jump to a table:
| Provider name | [Concurrency Verified](../advanced-features/concurrency-verified.md) | [dual host](../advanced-features/dual-host.md) | create-domains | get-zones | | Provider name | [Concurrency Verified](../advanced-features/concurrency-verified.md) | [dual host](../advanced-features/dual-host.md) | create-domains | get-zones |
| ------------- | -------------------------------------------------------------------- | ---------------------------------------------- | -------------- | --------- | | ------------- | -------------------------------------------------------------------- | ---------------------------------------------- | -------------- | --------- |
| [`ADGUARDHOME`](adguardhome.md) | ❔ | ❔ | ❌ | ❌ |
| [`AKAMAIEDGEDNS`](akamaiedgedns.md) | ❔ | ✅ | ✅ | ✅ | | [`AKAMAIEDGEDNS`](akamaiedgedns.md) | ❔ | ✅ | ✅ | ✅ |
| [`AUTODNS`](autodns.md) | ✅ | ❌ | ❌ | ✅ | | [`AUTODNS`](autodns.md) | ✅ | ❌ | ❌ | ✅ |
| [`AXFRDDNS`](axfrddns.md) | ✅ | ❌ | ❌ | ❌ | | [`AXFRDDNS`](axfrddns.md) | ✅ | ❌ | ❌ | ❌ |
@ -144,6 +146,7 @@ Jump to a table:
| Provider name | [`ALIAS`](../language-reference/domain-modifiers/ALIAS.md) | [`DNAME`](../language-reference/domain-modifiers/DNAME.md) | [`LOC`](../language-reference/domain-modifiers/LOC.md) | [`PTR`](../language-reference/domain-modifiers/PTR.md) | [`SOA`](../language-reference/domain-modifiers/SOA.md) | | Provider name | [`ALIAS`](../language-reference/domain-modifiers/ALIAS.md) | [`DNAME`](../language-reference/domain-modifiers/DNAME.md) | [`LOC`](../language-reference/domain-modifiers/LOC.md) | [`PTR`](../language-reference/domain-modifiers/PTR.md) | [`SOA`](../language-reference/domain-modifiers/SOA.md) |
| ------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | | ------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ |
| [`ADGUARDHOME`](adguardhome.md) | ✅ | ❔ | ❔ | ❔ | ❔ |
| [`AKAMAIEDGEDNS`](akamaiedgedns.md) | ❌ | ❔ | ✅ | ✅ | ❌ | | [`AKAMAIEDGEDNS`](akamaiedgedns.md) | ❌ | ❔ | ✅ | ✅ | ❌ |
| [`AUTODNS`](autodns.md) | ✅ | ❔ | ❔ | ✅ | ❔ | | [`AUTODNS`](autodns.md) | ✅ | ❔ | ❔ | ✅ | ❔ |
| [`AXFRDDNS`](axfrddns.md) | ❌ | ✅ | ✅ | ✅ | ❌ | | [`AXFRDDNS`](axfrddns.md) | ❌ | ✅ | ✅ | ✅ | ❌ |

View file

@ -359,6 +359,16 @@ func cfRedirTemp(pattern, target string) *models.RecordConfig {
return r return r
} }
func aghAPassthrough(pattern, target string) *models.RecordConfig {
r := makeRec(pattern, target, "ADGUARDHOME_A_PASSTHROUGH")
return r
}
func aghAAAAPassthrough(pattern, target string) *models.RecordConfig {
r := makeRec(pattern, target, "ADGUARDHOME_AAAA_PASSTHROUGH")
return r
}
func cname(name, target string) *models.RecordConfig { func cname(name, target string) *models.RecordConfig {
return makeRec(name, target, "CNAME") return makeRec(name, target, "CNAME")
} }

View file

@ -1316,6 +1316,16 @@ func makeTests() []*TestGroup {
), ),
), ),
testgroup("ADGUARDHOME_A_PASSTHROUGH",
only("ADGUARDHOME"),
tc("simple", aghAPassthrough("foo", "")),
),
testgroup("ADGUARDHOME_AAAA_PASSTHROUGH",
only("ADGUARDHOME"),
tc("simple", aghAAAAPassthrough("foo", "")),
),
//// IGNORE* features //// IGNORE* features
// Narrative: You're basically done now. These remaining tests // Narrative: You're basically done now. These remaining tests

View file

@ -1,4 +1,11 @@
{ {
"ADGUARDHOME": {
"TYPE": "ADGUARDHOME",
"username": "$ADGUARDHOME_USERNAME",
"password": "$ADGUARDHOME_PASSWORD",
"host": "$ADGUARDHOME_HOST",
"domain": "$ADGUARDHOME_DOMAIN"
},
"AKAMAIEDGEDNS": { "AKAMAIEDGEDNS": {
"TYPE": "AKAMAIEDGEDNS", "TYPE": "AKAMAIEDGEDNS",
"access_token": "$AED_ACCESS_TOKEN", "access_token": "$AED_ACCESS_TOKEN",

View file

@ -138,7 +138,7 @@ func (dc *DomainConfig) Punycode() error {
if err := rec.SetTarget(t); err != nil { if err := rec.SetTarget(t); err != nil {
return err return err
} }
case "CLOUDFLAREAPI_SINGLE_REDIRECT", "CF_REDIRECT", "CF_TEMP_REDIRECT", "CF_WORKER_ROUTE": case "CLOUDFLAREAPI_SINGLE_REDIRECT", "CF_REDIRECT", "CF_TEMP_REDIRECT", "CF_WORKER_ROUTE", "ADGUARDHOME_A_PASSTHROUGH", "ADGUARDHOME_AAAA_PASSTHROUGH":
if err := rec.SetTarget(rec.GetTargetField()); err != nil { if err := rec.SetTarget(rec.GetTargetField()); err != nil {
return err return err
} }

View file

@ -619,7 +619,7 @@ func Downcase(recs []*RecordConfig) {
// Target is case insensitive. Downcase it. // Target is case insensitive. Downcase it.
r.target = strings.ToLower(r.target) r.target = strings.ToLower(r.target)
// BUGFIX(tlim): isn't ALIAS in the wrong case statement? // BUGFIX(tlim): isn't ALIAS in the wrong case statement?
case "A", "CAA", "CLOUDFLAREAPI_SINGLE_REDIRECT", "CF_REDIRECT", "CF_TEMP_REDIRECT", "CF_WORKER_ROUTE", "DHCID", "IMPORT_TRANSFORM", "LOC", "SSHFP", "TXT": case "A", "CAA", "CLOUDFLAREAPI_SINGLE_REDIRECT", "CF_REDIRECT", "CF_TEMP_REDIRECT", "CF_WORKER_ROUTE", "DHCID", "IMPORT_TRANSFORM", "LOC", "SSHFP", "TXT", "ADGUARDHOME_A_PASSTHROUGH", "ADGUARDHOME_AAAA_PASSTHROUGH":
// Do nothing. (IP address or case sensitive target) // Do nothing. (IP address or case sensitive target)
case "SOA": case "SOA":
if r.target != "DEFAULT_NOT_SET." { if r.target != "DEFAULT_NOT_SET." {
@ -643,7 +643,7 @@ func CanonicalizeTargets(recs []*RecordConfig, origin string) {
case "ALIAS", "ANAME", "CNAME", "DNAME", "DS", "DNSKEY", "MX", "NS", "NAPTR", "PTR", "SRV": case "ALIAS", "ANAME", "CNAME", "DNAME", "DS", "DNSKEY", "MX", "NS", "NAPTR", "PTR", "SRV":
// Target is a hostname that might be a shortname. Turn it into a FQDN. // Target is a hostname that might be a shortname. Turn it into a FQDN.
r.target = dnsutil.AddOrigin(r.target, originFQDN) r.target = dnsutil.AddOrigin(r.target, originFQDN)
case "A", "AKAMAICDN", "CAA", "DHCID", "CLOUDFLAREAPI_SINGLE_REDIRECT", "CF_REDIRECT", "CF_TEMP_REDIRECT", "CF_WORKER_ROUTE", "HTTPS", "IMPORT_TRANSFORM", "LOC", "SSHFP", "SVCB", "TLSA", "TXT": case "A", "AKAMAICDN", "CAA", "DHCID", "CLOUDFLAREAPI_SINGLE_REDIRECT", "CF_REDIRECT", "CF_TEMP_REDIRECT", "CF_WORKER_ROUTE", "HTTPS", "IMPORT_TRANSFORM", "LOC", "SSHFP", "SVCB", "TLSA", "TXT", "ADGUARDHOME_A_PASSTHROUGH", "ADGUARDHOME_AAAA_PASSTHROUGH":
// Do nothing. // Do nothing.
case "SOA": case "SOA":
if r.target != "DEFAULT_NOT_SET." { if r.target != "DEFAULT_NOT_SET." {

View file

@ -1262,7 +1262,9 @@ function recordBuilder(type, opts) {
record.type != 'CF_SINGLE_REDIRECT' && record.type != 'CF_SINGLE_REDIRECT' &&
record.type != 'CF_REDIRECT' && record.type != 'CF_REDIRECT' &&
record.type != 'CF_TEMP_REDIRECT' && record.type != 'CF_TEMP_REDIRECT' &&
record.type != 'CF_WORKER_ROUTE' record.type != 'CF_WORKER_ROUTE' &&
record.type != "ADGUARDHOME_A_PASSTHROUGH" &&
record.type != "ADGUARDHOME_AAAA_PASSTHROUGH"
) { ) {
record.subdomain = d.subdomain; record.subdomain = d.subdomain;
@ -1430,6 +1432,10 @@ var CF_WORKER_ROUTE = recordBuilder('CF_WORKER_ROUTE', {
}, },
}); });
var ADGUARDHOME_A_PASSTHROUGH = recordBuilder('ADGUARDHOME_A_PASSTHROUGH');
var ADGUARDHOME_AAAA_PASSTHROUGH = recordBuilder('ADGUARDHOME_AAAA_PASSTHROUGH');
var URL = recordBuilder('URL'); var URL = recordBuilder('URL');
var URL301 = recordBuilder('URL301'); var URL301 = recordBuilder('URL301');
var FRAME = recordBuilder('FRAME'); var FRAME = recordBuilder('FRAME');

View file

@ -121,7 +121,7 @@ func TestParsedFiles(t *testing.T) {
} else { } else {
zoneFile = filepath.Join(testDir, testName, dc.Name+".zone") zoneFile = filepath.Join(testDir, testName, dc.Name+".zone")
} }
//fmt.Printf("DEBUG: zonefile = %q\n", zoneFile) // fmt.Printf("DEBUG: zonefile = %q\n", zoneFile)
expectedZone, err := os.ReadFile(zoneFile) expectedZone, err := os.ReadFile(zoneFile)
if err != nil { if err != nil {
continue continue
@ -161,6 +161,8 @@ func TestErrors(t *testing.T) {
{"CF_REDIRECT With comma", `D("foo.com","reg",CF_REDIRECT("foo.com,","baaa"))`}, {"CF_REDIRECT With comma", `D("foo.com","reg",CF_REDIRECT("foo.com,","baaa"))`},
{"CF_TEMP_REDIRECT With comma", `D("foo.com","reg",CF_TEMP_REDIRECT("foo.com","baa,a"))`}, {"CF_TEMP_REDIRECT With comma", `D("foo.com","reg",CF_TEMP_REDIRECT("foo.com","baa,a"))`},
{"CF_WORKER_ROUTE With comma", `D("foo.com","reg",CF_WORKER_ROUTE("foo.com","baa,a"))`}, {"CF_WORKER_ROUTE With comma", `D("foo.com","reg",CF_WORKER_ROUTE("foo.com","baa,a"))`},
{"ADGUARDHOME_A_PASSTHROUGH With non-empty value", `D("foo.com","reg",ADGUARDHOME_A_PASSTHROUGH("foo","baaa"))`},
{"ADGUARDHOME_AAAA_PASSTHROUGH With non-empty value", `D("foo.com","reg",ADGUARDHOME_AAAA_PASSTHROUGH("foo,","baaa"))`},
{"Bad cidr", `D(reverse("foo.com"), "reg")`}, {"Bad cidr", `D(reverse("foo.com"), "reg")`},
{"Dup domains", `D("example.org", "reg"); D("example.org", "reg")`}, {"Dup domains", `D("example.org", "reg"); D("example.org", "reg")`},
{"Bad NAMESERVER", `D("example.com","reg", NAMESERVER("@","ns1.foo.com."))`}, {"Bad NAMESERVER", `D("example.com","reg", NAMESERVER("@","ns1.foo.com."))`},

View file

@ -3,6 +3,7 @@ package all
import ( import (
// Define all known providers here. They should each register themselves with the providers package via init function. // Define all known providers here. They should each register themselves with the providers package via init function.
_ "github.com/StackExchange/dnscontrol/v4/providers/adguardhome"
_ "github.com/StackExchange/dnscontrol/v4/providers/akamaiedgedns" _ "github.com/StackExchange/dnscontrol/v4/providers/akamaiedgedns"
_ "github.com/StackExchange/dnscontrol/v4/providers/autodns" _ "github.com/StackExchange/dnscontrol/v4/providers/autodns"
_ "github.com/StackExchange/dnscontrol/v4/providers/axfrddns" _ "github.com/StackExchange/dnscontrol/v4/providers/axfrddns"

View file

@ -0,0 +1,216 @@
package adguardhome
import (
"encoding/json"
"errors"
"fmt"
"net"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
"github.com/StackExchange/dnscontrol/v4/pkg/printer"
"github.com/StackExchange/dnscontrol/v4/providers"
"github.com/miekg/dns/dnsutil"
)
func newDsp(conf map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
return newAdguardHome(conf, metadata)
}
// newAdguardHome creates the provider.
func newAdguardHome(m map[string]string, _ json.RawMessage) (*adguardHomeProvider, error) {
c := &adguardHomeProvider{}
c.username, c.password, c.host = m["username"], m["password"], m["host"]
if c.username == "" {
return nil, errors.New("missing adguard home username")
}
if c.password == "" {
return nil, errors.New("missing adguard home password")
}
if c.host == "" {
return nil, errors.New("missing adguard home endpoint")
}
return c, nil
}
var features = providers.DocumentationNotes{
providers.CanConcur: providers.Unimplemented(),
providers.CanUseAlias: providers.Can(),
providers.CanGetZones: providers.Cannot(),
providers.DocOfficiallySupported: providers.Cannot(),
}
func init() {
const providerName = "ADGUARDHOME"
const providerMaintainer = "@ishanjain28"
fns := providers.DspFuncs{
Initializer: newDsp,
RecordAuditor: AuditRecords,
}
providers.RegisterCustomRecordType("ADGUARDHOME_A_PASSTHROUGH", providerName, "")
providers.RegisterCustomRecordType("ADGUARDHOME_AAAA_PASSTHROUGH", providerName, "")
providers.RegisterDomainServiceProviderType(providerName, fns, features)
providers.RegisterMaintainer(providerName, providerMaintainer)
}
// GetNameservers returns the nameservers for a domain.
func (c *adguardHomeProvider) GetNameservers(domain string) ([]*models.Nameserver, error) {
return []*models.Nameserver{}, nil
}
// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records.
func (c *adguardHomeProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) {
// TTLs don't matter in ADGUARDHOME and
// we use the default value of 300
for _, record := range dc.Records {
record.TTL = 300
}
var corrections []*models.Correction
changes, actualChangeCount, err := diff2.ByRecord(existingRecords, dc,
func(rec *models.RecordConfig) string { return "" },
)
if err != nil {
return nil, 0, err
}
for _, change := range changes {
var corr *models.Correction
switch change.Type {
case diff2.REPORT:
printer.Warnf("diff2 report message\n")
corr = &models.Correction{Msg: change.MsgsJoined}
case diff2.CREATE:
re, err := toRewriteEntry(dc.Name, change.New[0])
if err != nil {
return nil, 0, err
}
corr = &models.Correction{
Msg: change.Msgs[0],
F: func() error {
return c.createRecord(re)
},
}
case diff2.CHANGE:
oldRe, err := toRewriteEntry(dc.Name, change.Old[0])
if err != nil {
return nil, 0, err
}
newRe, err := toRewriteEntry(dc.Name, change.New[0])
if err != nil {
return nil, 0, err
}
corr = &models.Correction{
Msg: change.Msgs[0],
F: func() error {
return c.modifyRecord(oldRe, newRe)
},
}
case diff2.DELETE:
re, err := toRewriteEntry(dc.Name, change.Old[0])
if err != nil {
return nil, 0, err
}
corr = &models.Correction{
Msg: change.Msgs[0],
F: func() error {
return c.deleteRecord(re)
},
}
default:
panic(fmt.Sprintf("unhandled change.Type %s", change.Type))
}
corrections = append(corrections, corr)
}
return corrections, actualChangeCount, nil
}
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
func (c *adguardHomeProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) {
records, err := c.getRecords(domain)
if err != nil {
return nil, err
}
existingRecords := make([]*models.RecordConfig, 0, len(records))
for _, r := range records {
newRec, err := toRc(domain, r)
if err != nil {
return nil, err
}
existingRecords = append(existingRecords, newRec)
}
return existingRecords, nil
}
func toRewriteEntry(domain string, rc *models.RecordConfig) (rewriteEntry, error) {
re := rewriteEntry{
Domain: rc.NameFQDN,
}
switch rc.Type {
case "A", "AAAA":
re.Answer = rc.GetTargetIP().String()
case "CNAME", "ALIAS":
re.Answer = rc.GetTargetField()
re.Answer = dnsutil.TrimDomainName(re.Answer, domain)
case "ADGUARDHOME_A_PASSTHROUGH":
re.Answer = "A"
case "ADGUARDHOME_AAAA_PASSTHROUGH":
re.Answer = "AAAA"
default:
return re, fmt.Errorf("rtype %s is not supported", rc.Type)
}
return re, nil
}
func toRc(domain string, r rewriteEntry) (*models.RecordConfig, error) {
rc := &models.RecordConfig{
TTL: 300,
Original: r,
}
rc.SetLabelFromFQDN(r.Domain, domain)
addr := net.ParseIP(r.Answer)
if addr != nil {
rc.SetTargetIP(addr)
if addr.To4() != nil {
rc.Type = "A"
} else {
rc.Type = "AAAA"
}
} else if r.Answer == "A" {
rc.Type = "ADGUARDHOME_A_PASSTHROUGH"
} else if r.Answer == "AAAA" {
rc.Type = "ADGUARDHOME_AAAA_PASSTHROUGH"
} else {
answer := dnsutil.TrimDomainName(r.Answer, domain)
rc.SetTarget(answer)
if r.Domain == domain {
rc.Type = "ALIAS"
} else {
rc.Type = "CNAME"
}
}
if (rc.Type == "ADGUARDHOME_A_PASSTHROUGH" && r.Answer != "A") ||
(rc.Type == "ADGUARDHOME_AAAA_PASSTHROUGH" && r.Answer != "AAAA") {
return rc, errors.New("found invalid values for ADGUARDHOME_A_PASSTHROUGH or ADGUARDHOME_AAAA_PASSTHROUGH record")
}
return rc, nil
}

View file

@ -0,0 +1,176 @@
package adguardhome
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/StackExchange/dnscontrol/v4/pkg/printer"
)
type adguardHomeProvider struct {
username string
password string
host string
}
type requestParams map[string]any
type errorResponse struct {
Message string `json:"message"`
}
type rewriteEntry struct {
Domain string `json:"domain"`
Answer string `json:"answer"`
}
func (c *adguardHomeProvider) write(method, endpoint string, params requestParams) ([]byte, error) {
authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(c.username+":"+c.password))
reqBodyJSON, err := json.Marshal(params)
if err != nil {
return []byte{}, err
}
client := &http.Client{}
req, _ := http.NewRequest(method, c.host+endpoint, bytes.NewBuffer(reqBodyJSON))
req.Header.Add("Authorization", authHeader)
req.Header.Add("Content-Type", "application/json")
retrycnt := 0
retry:
resp, err := client.Do(req)
if err != nil {
return []byte{}, err
}
bodyString, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable {
retrycnt++
if retrycnt == 5 {
return bodyString, errors.New("rate limit exceeded")
}
printer.Warnf("rate limiting.. waiting for %d second(s)\n", retrycnt*10)
time.Sleep(time.Second * time.Duration(retrycnt*10))
goto retry
}
var errResp errorResponse
err = json.Unmarshal(bodyString, &errResp)
if err == nil {
return bodyString, fmt.Errorf("AdguardHome API error: %s URL:%s%s ", errResp.Message, req.Host, req.URL.RequestURI())
}
if resp.StatusCode == http.StatusOK {
return bodyString, nil
} else {
return nil, errors.New(string(bodyString))
}
}
func (c *adguardHomeProvider) get(endpoint string) ([]byte, error) {
authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(c.username+":"+c.password))
client := &http.Client{}
req, _ := http.NewRequest(http.MethodGet, c.host+endpoint, nil)
req.Header.Add("Authorization", authHeader)
retrycnt := 0
retry:
resp, err := client.Do(req)
if err != nil {
return []byte{}, err
}
bodyString, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable {
retrycnt++
if retrycnt == 5 {
return bodyString, errors.New("rate limit exceeded")
}
printer.Warnf("rate limiting.. waiting for %d second(s)\n", retrycnt*10)
time.Sleep(time.Second * time.Duration(retrycnt*10))
goto retry
}
var errResp errorResponse
err = json.Unmarshal(bodyString, &errResp)
if err == nil {
return bodyString, fmt.Errorf("AdguardHome API error: %s URL:%s%s ", errResp.Message, req.Host, req.URL.RequestURI())
}
if resp.StatusCode == http.StatusOK {
return bodyString, nil
} else {
return nil, errors.New(string(bodyString))
}
}
func (c *adguardHomeProvider) createRecord(r rewriteEntry) error {
rec := requestParams{
"domain": r.Domain,
"answer": r.Answer,
}
if _, err := c.write(http.MethodPost, "/control/rewrite/add", rec); err != nil {
return fmt.Errorf("failed to create record (adguard home): %w", err)
}
return nil
}
func (c *adguardHomeProvider) deleteRecord(r rewriteEntry) error {
rec := requestParams{
"domain": r.Domain,
"answer": r.Answer,
}
if _, err := c.write(http.MethodPost, "/control/rewrite/delete", rec); err != nil {
return fmt.Errorf("failed to delete record (adguard home): %w", err)
}
return nil
}
func (c *adguardHomeProvider) modifyRecord(oldRe, newRe rewriteEntry) error {
rec := requestParams{
"target": oldRe,
"update": newRe,
}
if _, err := c.write(http.MethodPut, "/control/rewrite/update", rec); err != nil {
return fmt.Errorf("failed to update record (adguard home): %w", err)
}
return nil
}
func (c *adguardHomeProvider) getRecords(domain string) ([]rewriteEntry, error) {
bodyString, err := c.get("/control/rewrite/list")
if err != nil {
return nil, fmt.Errorf("failed to fetch records from adguardhome: %w", err)
}
var resp []rewriteEntry
err = json.Unmarshal(bodyString, &resp)
if err != nil {
return nil, fmt.Errorf("failed to parse records list from adguardhome: %w", err)
}
records := make([]rewriteEntry, 0, len(resp))
for _, r := range resp {
if !strings.HasSuffix(r.Domain, "."+domain) && r.Domain != domain {
continue
}
records = append(records, r)
}
return records, nil
}

View file

@ -0,0 +1,49 @@
package adguardhome
import (
"fmt"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/rejectif"
)
var supportedRTypes = map[string]struct{}{
"A": {},
"AAAA": {},
"CNAME": {},
"ALIAS": {},
"ADGUARDHOME_A_PASSTHROUGH": {},
"ADGUARDHOME_AAAA_PASSTHROUGH": {},
}
// AuditRecords returns a list of errors corresponding to the records
// that aren't supported by this provider. If all records are
// supported, an empty list is returned.
func AuditRecords(records []*models.RecordConfig) []error {
a := rejectif.Auditor{}
a.Add("ALIAS", rejectif.LabelNotApex)
a.Add("ADGUARDHOME_A_PASSTHROUGH", nonNullValue)
a.Add("ADGUARDHOME_AAAA_PASSTHROUGH", nonNullValue)
errors := []error{}
errors = append(errors, a.Audit(records)...)
for _, r := range records {
if _, ok := supportedRTypes[r.Type]; !ok {
errors = append(errors, fmt.Errorf("%s rtype is not supported", r.Type))
}
}
return errors
}
func nonNullValue(v *models.RecordConfig) error {
if len(v.GetTargetField()) != 0 {
return fmt.Errorf("%s rtype value should be empty", v.Type)
}
return nil
}