diff --git a/.github/workflows/pr_integration_tests.yml b/.github/workflows/pr_integration_tests.yml index 608d2964d..7ed7dc144 100644 --- a/.github/workflows/pr_integration_tests.yml +++ b/.github/workflows/pr_integration_tests.yml @@ -52,7 +52,7 @@ jobs: Write-Host "Integration test providers: $Providers" echo "integration_test_providers=$(ConvertTo-Json -InputObject $Providers -Compress)" >> $env:GITHUB_OUTPUT env: - PROVIDERS: "['AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','HEDNS','HEXONET','HUAWEICLOUD','INWX','MYTHICBEASTS', 'NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP']" + PROVIDERS: "['AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','HEDNS','HEXONET','HUAWEICLOUD','INWX','JOKER','MYTHICBEASTS', 'NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP']" ENV_CONTEXT: ${{ toJson(env) }} VARS_CONTEXT: ${{ toJson(vars) }} SECRETS_CONTEXT: ${{ toJson(secrets) }} @@ -89,6 +89,7 @@ jobs: HEDNS_DOMAIN: ${{ vars.HEDNS_DOMAIN }} HEXONET_DOMAIN: ${{ vars.HEXONET_DOMAIN }} HUAWEICLOUD_DOMAIN: ${{ vars.HUAWEICLOUD_DOMAIN }} + JOKER_DOMAIN: ${{ vars.JOKER_DOMAIN }} MYTHICBEASTS_DOMAIN: ${{ vars.MYTHICBEASTS_DOMAIN }} NAMEDOTCOM_DOMAIN: ${{ vars.NAMEDOTCOM_DOMAIN }} NS1_DOMAIN: ${{ vars.NS1_DOMAIN }} @@ -161,6 +162,9 @@ jobs: HUAWEICLOUD_KEY_ID: ${{ secrets.HUAWEICLOUD_KEY_ID }} HUAWEICLOUD_KEY: ${{ secrets.HUAWEICLOUD_KEY }} # + JOKER_USERNAME: ${{ secrets.JOKER_USERNAME }} + JOKER_PASSWORD: ${{ secrets.JOKER_PASSWORD }} + # MYTHICBEASTS_KEYID: ${{ secrets.MYTHICBEASTS_KEYID }} MYTHICBEASTS_SECRET: ${{ secrets.MYTHICBEASTS_SECRET }} # diff --git a/.goreleaser.yml b/.goreleaser.yml index b07732f56..68ec15037 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -39,7 +39,7 @@ changelog: regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$" order: 1 - title: 'Provider-specific changes:' - 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|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|joker|linode|loopia|luadns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vultr).*:)+.*" order: 2 - title: 'Documentation:' regexp: "(?i)^.*(docs)[(\\w)]*:+.*$" diff --git a/OWNERS b/OWNERS index 53d051d76..5058e9d51 100644 --- a/OWNERS +++ b/OWNERS @@ -30,6 +30,7 @@ providers/hostingde @juliusrickert providers/huaweicloud @huihuimoe providers/internetbs @pragmaton providers/inwx @patschi +providers/joker @atrull providers/linode @koesie10 providers/loopia @systemcrash providers/luadns @riku22 diff --git a/README.md b/README.md index 3ff316ed6..8bbcc7bfd 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Currently supported DNS providers: - Huawei Cloud DNS - Hurricane Electric DNS - INWX +- Joker - Linode - Loopia - LuaDNS diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index c8520f72b..59cca1db8 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -139,6 +139,7 @@ * [Hurricane Electric DNS](provider/hedns.md) * [Internet.bs](provider/internetbs.md) * [INWX](provider/inwx.md) +* [Joker](provider/joker.md) * [Linode](provider/linode.md) * [Loopia](provider/loopia.md) * [LuaDNS](provider/luadns.md) diff --git a/documentation/provider/index.md b/documentation/provider/index.md index 500b514bc..b544bab20 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -57,6 +57,7 @@ Jump to a table: | [`HUAWEICLOUD`](huaweicloud.md) | ❌ | ✅ | ❌ | | [`INTERNETBS`](internetbs.md) | ❌ | ❌ | ✅ | | [`INWX`](inwx.md) | ❌ | ✅ | ✅ | +| [`JOKER`](joker.md) | ❌ | ✅ | ❌ | | [`LINODE`](linode.md) | ❌ | ✅ | ❌ | | [`LOOPIA`](loopia.md) | ❌ | ✅ | ✅ | | [`LUADNS`](luadns.md) | ❌ | ✅ | ❌ | @@ -116,6 +117,7 @@ Jump to a table: | [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ✅ | ✅ | ✅ | | [`INTERNETBS`](internetbs.md) | ❔ | ❔ | ❌ | ❔ | | [`INWX`](inwx.md) | ❔ | ✅ | ✅ | ✅ | +| [`JOKER`](joker.md) | ❌ | ❌ | ✅ | ✅ | | [`LINODE`](linode.md) | ❔ | ❌ | ❌ | ✅ | | [`LOOPIA`](loopia.md) | ❔ | ✅ | ❌ | ✅ | | [`LUADNS`](luadns.md) | ✅ | ✅ | ✅ | ✅ | @@ -171,6 +173,7 @@ Jump to a table: | [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ❌ | ✅ | ✅ | | [`HUAWEICLOUD`](huaweicloud.md) | ❌ | ❔ | ❌ | ❌ | ❌ | | [`INWX`](inwx.md) | ✅ | ❔ | ❔ | ✅ | ❔ | +| [`JOKER`](joker.md) | ❌ | ❔ | ❌ | ❌ | ❌ | | [`LINODE`](linode.md) | ❔ | ❔ | ❌ | ❔ | ❔ | | [`LOOPIA`](loopia.md) | ❌ | ❔ | ✅ | ❌ | ❌ | | [`LUADNS`](luadns.md) | ✅ | ❔ | ❌ | ✅ | ❔ | @@ -224,6 +227,7 @@ Jump to a table: | [`HOSTINGDE`](hostingde.md) | ❔ | ❌ | ✅ | ❔ | | [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ❌ | ✅ | ❌ | | [`INWX`](inwx.md) | ❔ | ✅ | ✅ | ✅ | +| [`JOKER`](joker.md) | ❔ | ✅ | ✅ | ❌ | | [`LOOPIA`](loopia.md) | ❌ | ✅ | ✅ | ❌ | | [`LUADNS`](luadns.md) | ❔ | ❔ | ✅ | ❔ | | [`MYTHICBEASTS`](mythicbeasts.md) | ❔ | ❔ | ✅ | ❔ | @@ -276,6 +280,7 @@ Jump to a table: | [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ✅ | ✅ | | [`HUAWEICLOUD`](huaweicloud.md) | ✅ | ❌ | ❌ | ❌ | | [`INWX`](inwx.md) | ✅ | ✅ | ✅ | ✅ | +| [`JOKER`](joker.md) | ✅ | ❌ | ❌ | ❌ | | [`LINODE`](linode.md) | ✅ | ❔ | ❔ | ❔ | | [`LOOPIA`](loopia.md) | ✅ | ❌ | ✅ | ✅ | | [`LUADNS`](luadns.md) | ✅ | ✅ | ✅ | ✅ | @@ -318,6 +323,7 @@ Jump to a table: | [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ✅ | | [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ❔ | ❌ | | [`INWX`](inwx.md) | ✅ | ❔ | ❔ | +| [`JOKER`](joker.md) | ❔ | ❌ | ❌ | | [`LOOPIA`](loopia.md) | ❌ | ❌ | ❌ | | [`NETLIFY`](netlify.md) | ❌ | ❔ | ❌ | | [`NS1`](ns1.md) | ✅ | ❔ | ✅ | diff --git a/documentation/provider/joker.md b/documentation/provider/joker.md new file mode 100644 index 000000000..c050d6da9 --- /dev/null +++ b/documentation/provider/joker.md @@ -0,0 +1,92 @@ +# Joker DNS Provider + +## Configuration + +To use this provider, add an entry to `creds.json` with your Joker.com credentials: + +{% code title="creds.json" %} +```json +{ + "joker": { + "TYPE": "JOKER", + "username": "your-username@joker.com", + "password": "your-password" + } +} +``` +{% endcode %} + +You must have a reseller account in joker.com to use the DMAPI. + +Alternatively, you can use an API key (if you have created one on the Joker.com website): + +{% code title="creds.json" %} +```json +{ + "joker": { + "TYPE": "JOKER", + "api-key": "your-api-key" + } +} +``` +{% endcode %} + +## Metadata + +This provider does not recognize any special metadata fields unique to Joker. + +## Usage + +An example configuration: + +{% code title="dnsconfig.js" %} +```javascript +var REG_NONE = NewRegistrar("none"); +var DSP_JOKER = NewDnsProvider("joker"); + +D("example.tld", REG_NONE, DnsProvider(DSP_JOKER), + A("test", "1.2.3.4"), + CNAME("www", "test"), + MX("@", 10, "mail.example.tld."), + TXT("_dmarc", "v=DMARC1; p=reject; rua=mailto:dmarc@example.tld"), +END); +``` +{% endcode %} + +## Limitations + +- This provider updates entire zones, not individual records +- Concurrent operations are not supported due to session-based authentication +- Some record types are not supported (see provider capabilities) +- Minimum TTL is 300 seconds + +## Notes + +- The provider uses Joker's DMAPI (Domain Management API) +- Authentication uses session-based tokens that expire after inactivity +- Zone updates replace the entire zone content +- The provider supports both username/password and API key authentication + +## Supported Record Types + +- A +- AAAA +- CNAME +- MX +- TXT +- SRV +- CAA +- NAPTR + +## Unsupported Record Types + +- ALIAS +- DS +- DNSKEY +- HTTPS +- LOC +- PTR +- SOA +- SSHFP +- SVCB +- TLSA \ No newline at end of file diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index 8fcb4c83c..ffc240a0e 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -406,6 +406,7 @@ func makeTests() []*TestGroup { "DNSIMPLE", // Does not support NS records nor subdomains. "EXOSCALE", // Not supported. "GANDI_V5", // "Gandi does not support changing apex NS records. Ignoring ns1.foo.com." + "JOKER", // Not supported via the Zone API. "NAMEDOTCOM", // "Ignores @ for NS records" "NETCUP", // NS records not currently supported. "SAKURACLOUD", // Silently ignores requests to remove NS at @. diff --git a/integrationTest/profiles.json b/integrationTest/profiles.json index bc2faef8c..eddf83f6c 100644 --- a/integrationTest/profiles.json +++ b/integrationTest/profiles.json @@ -204,6 +204,12 @@ "sandbox": "1", "username": "$INWX_USER" }, + "JOKER": { + "TYPE": "JOKER", + "domain": "$JOKER_DOMAIN", + "password": "$JOKER_PASSWORD", + "username": "$JOKER_USERNAME" + }, "LINODE": { "TYPE": "LINODE", "domain": "$LINODE_DOMAIN", diff --git a/pkg/rejectif/caa.go b/pkg/rejectif/caa.go index 3c21eb138..9ee6529cf 100644 --- a/pkg/rejectif/caa.go +++ b/pkg/rejectif/caa.go @@ -27,6 +27,22 @@ func CaaTargetContainsWhitespace(rc *models.RecordConfig) error { return nil } +// CaaHasEmptyTag detects CAA records with empty tags. +func CaaHasEmptyTag(rc *models.RecordConfig) error { + if rc.CaaTag == "" { + return errors.New("caa has empty tag") + } + return nil +} + +// CaaHasEmptyTarget detects CAA records with empty targets. +func CaaHasEmptyTarget(rc *models.RecordConfig) error { + if rc.GetTargetField() == "" { + return errors.New("caa has empty target") + } + return nil +} + // // CaaTargetHasSemicolon identifies CAA records that contain semicolons. // func CaaTargetHasSemicolon(rc *models.RecordConfig) error { // if strings.Contains(rc.GetTargetField(), ";") { diff --git a/pkg/rejectif/naptr.go b/pkg/rejectif/naptr.go new file mode 100644 index 000000000..cb3c3bd20 --- /dev/null +++ b/pkg/rejectif/naptr.go @@ -0,0 +1,17 @@ +package rejectif + +import ( + "errors" + + "github.com/StackExchange/dnscontrol/v4/models" +) + +// Keep these in alphabetical order. + +// NaptrHasEmptyTarget detects NAPTR records with empty targets. +func NaptrHasEmptyTarget(rc *models.RecordConfig) error { + if rc.GetTargetField() == "" { + return errors.New("naptr has empty target") + } + return nil +} diff --git a/pkg/rejectif/ns.go b/pkg/rejectif/ns.go new file mode 100644 index 000000000..3c833c745 --- /dev/null +++ b/pkg/rejectif/ns.go @@ -0,0 +1,18 @@ +package rejectif + +import ( + "errors" + + "github.com/StackExchange/dnscontrol/v4/models" +) + +// Keep these in alphabetical order. + +// NsAtApex detects NS records at the apex/root domain. +// Use this when a provider doesn't support custom NS records at the apex. +func NsAtApex(rc *models.RecordConfig) error { + if rc.GetLabel() == "" { + return errors.New("NS records not supported at apex") + } + return nil +} diff --git a/pkg/rejectif/srv.go b/pkg/rejectif/srv.go index 8071ed6b9..eee7328a0 100644 --- a/pkg/rejectif/srv.go +++ b/pkg/rejectif/srv.go @@ -15,3 +15,19 @@ func SrvHasNullTarget(rc *models.RecordConfig) error { } return nil } + +// SrvHasEmptyTarget detects SRV records with empty targets. +func SrvHasEmptyTarget(rc *models.RecordConfig) error { + if rc.GetTargetField() == "" { + return errors.New("srv has empty target") + } + return nil +} + +// SrvHasZeroPort detects SRV records with port set to zero. +func SrvHasZeroPort(rc *models.RecordConfig) error { + if rc.SrvPort == 0 { + return errors.New("srv has zero port") + } + return nil +} diff --git a/providers/_all/all.go b/providers/_all/all.go index 15c3c91b7..92b403e0d 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -35,6 +35,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v4/providers/huaweicloud" _ "github.com/StackExchange/dnscontrol/v4/providers/internetbs" _ "github.com/StackExchange/dnscontrol/v4/providers/inwx" + _ "github.com/StackExchange/dnscontrol/v4/providers/joker" _ "github.com/StackExchange/dnscontrol/v4/providers/linode" _ "github.com/StackExchange/dnscontrol/v4/providers/loopia" _ "github.com/StackExchange/dnscontrol/v4/providers/luadns" diff --git a/providers/joker/api.go b/providers/joker/api.go new file mode 100644 index 000000000..c169eeb4f --- /dev/null +++ b/providers/joker/api.go @@ -0,0 +1,127 @@ +package joker + +import ( + "fmt" + "io" + "net/url" + "strings" +) + +// authenticate logs in to Joker DMAPI and stores the session ID. +func (api *jokerProvider) authenticate() error { + data := url.Values{} + + if api.apiKey != "" { + data.Set("api-key", api.apiKey) + } else { + data.Set("username", api.username) + data.Set("password", api.password) + } + + resp, err := api.httpClient.PostForm(api.apiURL+"login", data) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + // Parse the response headers and body + respStr := string(body) + headers, _ := api.parseResponse(respStr) + + if headers["Status-Code"] != "" && headers["Status-Code"] != "0" { + return fmt.Errorf("login failed: %s", headers["Status-Text"]) + } + + authSID := headers["Auth-Sid"] + if authSID == "" { + return fmt.Errorf("no Auth-Sid received from login. Response: %s", respStr) + } + + api.authSID = authSID + return nil +} + +// parseResponse parses the Joker DMAPI response format. +func (api *jokerProvider) parseResponse(response string) (map[string]string, string) { + headers := make(map[string]string) + lines := strings.Split(response, "\n") + + var bodyStart int + for i, line := range lines { + if line == "" { + bodyStart = i + 1 + break + } + if strings.Contains(line, ":") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + } + + body := "" + if bodyStart < len(lines) { + body = strings.Join(lines[bodyStart:], "\n") + } + + return headers, body +} + +// makeRequest makes an authenticated request to Joker DMAPI. +func (api *jokerProvider) makeRequest(endpoint string, params url.Values) (map[string]string, string, error) { + if params == nil { + params = url.Values{} + } + params.Set("auth-sid", api.authSID) + + resp, err := api.httpClient.PostForm(api.apiURL+endpoint, params) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + + headers, responseBody := api.parseResponse(string(body)) + + if headers["Status-Code"] != "" && headers["Status-Code"] != "0" { + statusText := headers["Status-Text"] + // Check for session expiration and attempt to renew + if strings.Contains(statusText, "Auth-Sid") || strings.Contains(statusText, "session") || + strings.Contains(statusText, "authorization") || strings.Contains(statusText, "authentication") { + // Try to re-authenticate + if authErr := api.authenticate(); authErr == nil { + // Retry the request with new session + params.Set("auth-sid", api.authSID) + resp2, err2 := api.httpClient.PostForm(api.apiURL+endpoint, params) + if err2 != nil { + return nil, "", err2 + } + defer resp2.Body.Close() + + body2, err2 := io.ReadAll(resp2.Body) + if err2 != nil { + return nil, "", err2 + } + + headers2, responseBody2 := api.parseResponse(string(body2)) + if headers2["Status-Code"] != "" && headers2["Status-Code"] != "0" { + return nil, "", fmt.Errorf("API error after re-auth: %s (Status-Code: %s)", headers2["Status-Text"], headers2["Status-Code"]) + } + return headers2, responseBody2, nil + } + } + return nil, "", fmt.Errorf("API error: %s (Status-Code: %s)", statusText, headers["Status-Code"]) + } + + return headers, responseBody, nil +} diff --git a/providers/joker/auditrecords.go b/providers/joker/auditrecords.go new file mode 100644 index 000000000..018b23a94 --- /dev/null +++ b/providers/joker/auditrecords.go @@ -0,0 +1,62 @@ +package joker + +import ( + "fmt" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/rejectif" +) + +var supportedRTypes = map[string]struct{}{ + "A": {}, + "AAAA": {}, + "CAA": {}, + "CNAME": {}, + "MX": {}, + "NAPTR": {}, + "NS": {}, + "SRV": {}, + "TXT": {}, +} + +// 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{} + + // Joker does not support custom NS records at apex (domain root) + // Joker automatically manages apex NS records + a.Add("NS", rejectif.NsAtApex) + + // Joker has round-trip issues with TXT records containing unbalanced quotes + a.Add("TXT", rejectif.TxtHasUnpairedDoubleQuotes) + + // Joker has round-trip issues with TXT records containing backslashes + a.Add("TXT", rejectif.TxtHasBackslash) + + // Joker has round-trip issues with TXT records containing backslashes + a.Add("TXT", rejectif.TxtHasTrailingSpace) + + // SRV records must have valid port and target + a.Add("SRV", rejectif.SrvHasZeroPort) + a.Add("SRV", rejectif.SrvHasEmptyTarget) + + // CAA records must have valid tag and target + a.Add("CAA", rejectif.CaaHasEmptyTag) + a.Add("CAA", rejectif.CaaHasEmptyTarget) + + // NAPTR records must have a replacement + a.Add("NAPTR", rejectif.NaptrHasEmptyTarget) + + errors := a.Audit(records) + + // Check for unsupported record types + for _, r := range records { + if _, ok := supportedRTypes[r.Type]; !ok { + errors = append(errors, fmt.Errorf("joker does not support %s records", r.Type)) + } + } + + return errors +} diff --git a/providers/joker/jokerProvider.go b/providers/joker/jokerProvider.go new file mode 100644 index 000000000..deceb1077 --- /dev/null +++ b/providers/joker/jokerProvider.go @@ -0,0 +1,91 @@ +package joker + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/StackExchange/dnscontrol/v4/providers" + "net/http" + "time" +) + +/* + +Joker DMAPI provider: + +Info required in `creds.json`: + - username + - password + OR + - api-key + +*/ + +var features = providers.DocumentationNotes{ + // The default for unlisted capabilities is 'Cannot'. + // See providers/capabilities.go for the entire list of capabilities. + providers.CanGetZones: providers.Can(), + providers.CanConcur: providers.Cannot("Joker API has session-based authentication"), + providers.CanUseAlias: providers.Cannot(), + providers.CanUseCAA: providers.Can(), + providers.CanUseDNSKEY: providers.Cannot(), + providers.CanUseDS: providers.Cannot(), + providers.CanUseDSForChildren: providers.Cannot(), + providers.CanUseHTTPS: providers.Cannot(), + providers.CanUseLOC: providers.Cannot(), + providers.CanUseNAPTR: providers.Can(), + providers.CanUsePTR: providers.Cannot(), + providers.CanUseSOA: providers.Cannot(), + providers.CanUseSRV: providers.Can(), + providers.CanUseSSHFP: providers.Cannot(), + providers.CanUseSVCB: providers.Cannot(), + providers.CanUseTLSA: providers.Cannot(), + providers.DocCreateDomains: providers.Can(), + providers.DocDualHost: providers.Cannot(), + providers.DocOfficiallySupported: providers.Cannot(), +} + +func init() { + const providerName = "JOKER" + const providerMaintainer = "@atrull" + fns := providers.DspFuncs{ + Initializer: newJoker, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType(providerName, fns, features) + providers.RegisterMaintainer(providerName, providerMaintainer) +} + +// jokerProvider is the handle for API calls. +type jokerProvider struct { + apiURL string + username string + password string + apiKey string + authSID string + httpClient *http.Client +} + +// newJoker creates a new Joker DMAPI provider. +func newJoker(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { + api := &jokerProvider{ + apiURL: "https://dmapi.joker.com/request/", + httpClient: &http.Client{Timeout: 30 * time.Second}, + } + + // Check for authentication methods + api.username = m["username"] + api.password = m["password"] + api.apiKey = m["api-key"] + + if api.apiKey == "" && (api.username == "" || api.password == "") { + return nil, errors.New("missing Joker credentials: either 'api-key' or both 'username' and 'password' required") + } + + // Authenticate to get session ID + if err := api.authenticate(); err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + return api, nil +} diff --git a/providers/joker/nameservers.go b/providers/joker/nameservers.go new file mode 100644 index 000000000..2e12b3593 --- /dev/null +++ b/providers/joker/nameservers.go @@ -0,0 +1,10 @@ +package joker + +import "github.com/StackExchange/dnscontrol/v4/models" + +// GetNameservers returns the nameservers for a domain. +func (api *jokerProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { + // For DNS-only providers like Joker, we can return an empty list + // since nameserver management is typically handled separately + return []*models.Nameserver{}, nil +} diff --git a/providers/joker/records.go b/providers/joker/records.go new file mode 100644 index 000000000..e7dfd2d20 --- /dev/null +++ b/providers/joker/records.go @@ -0,0 +1,415 @@ +package joker + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/diff2" +) + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (api *jokerProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) { + params := url.Values{} + params.Set("domain", domain) + + _, body, err := api.makeRequest("dns-zone-get", params) + if err != nil { + return nil, err + } + + records, err := api.parseZoneRecords(domain, body) + if err != nil { + return nil, err + } + + return records, nil +} + +// parseZoneLine parses a zone file line while preserving quoted strings. +func parseZoneLine(line string) []string { + var parts []string + var current strings.Builder + inQuotes := false + escaped := false + + for _, r := range line { + if escaped { + current.WriteRune(r) + escaped = false + continue + } + + if r == '\\' { + escaped = true + current.WriteRune(r) + continue + } + + if r == '"' { + inQuotes = !inQuotes + current.WriteRune(r) + continue + } + + if !inQuotes && (r == ' ' || r == '\t') { + // Skip multiple consecutive spaces + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + continue + } + + current.WriteRune(r) + } + + // Add the final part if any + if current.Len() > 0 { + parts = append(parts, current.String()) + } + + return parts +} + +// parseZoneRecords parses Joker zone format into RecordConfig format. +func (api *jokerProvider) parseZoneRecords(domain, zoneData string) (models.Records, error) { + var records models.Records + + lines := strings.Split(strings.TrimSpace(zoneData), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "$") { + continue + } + + // Parse the line while preserving quoted strings + parts := parseZoneLine(line) + if len(parts) < 4 { + continue + } + + label := parts[0] + recordType := parts[1] + priority := parts[2] + + // For TXT records, we need to handle quoted content specially + var target string + var ttl uint32 = 300 + + if recordType == "TXT" { + // Use parseZoneLine to handle proper quoting + if len(parts) >= 4 { + // For TXT, the content is in parts[3] and may be quoted + target = parts[3] + // Remove surrounding quotes if present + if strings.HasPrefix(target, "\"") && strings.HasSuffix(target, "\"") && len(target) > 1 { + target = target[1 : len(target)-1] + } + // Properly handle escaped characters - reverse the generation order + target = strings.ReplaceAll(target, "\\\"", "\"") + target = strings.ReplaceAll(target, "\\\\", "\\") + // Parse TTL if present + if len(parts) >= 5 { + if ttlParsed, err := strconv.ParseUint(parts[4], 10, 32); err == nil { + ttl = uint32(ttlParsed) + } + } + } + } else { + target = parts[3] + // Default TTL if not specified in zone record + if len(parts) >= 5 { + if ttlParsed, err := strconv.ParseUint(parts[4], 10, 32); err == nil { + ttl = uint32(ttlParsed) + } + } + } + + // Convert @ to empty string for root domain + if label == "@" { + label = "" + } + + rc := &models.RecordConfig{ + TTL: ttl, + } + + // Set the label and domain correctly + rc.SetLabel(label, domain) + + // Handle different record types + switch recordType { + case "A", "AAAA": + rc.Type = recordType + if err := rc.SetTarget(target); err != nil { + continue + } + case "CNAME": + rc.Type = recordType + // Ensure CNAME targets are fully qualified + if !strings.HasSuffix(target, ".") { + target = target + "." + } + if err := rc.SetTarget(target); err != nil { + continue + } + case "NS": + rc.Type = recordType + // Ensure NS targets are fully qualified + if !strings.HasSuffix(target, ".") { + target = target + "." + } + if err := rc.SetTarget(target); err != nil { + continue + } + case "TXT": + rc.Type = recordType + // TXT target is already extracted without quotes in the parsing above + if err := rc.SetTarget(target); err != nil { + continue + } + case "MX": + rc.Type = recordType + if prio, err := strconv.ParseUint(priority, 10, 16); err == nil { + rc.MxPreference = uint16(prio) + } + // Ensure MX targets are fully qualified + if !strings.HasSuffix(target, ".") { + target = target + "." + } + if err := rc.SetTarget(target); err != nil { + continue + } + case "SRV": + rc.Type = recordType + // SRV format: priority/weight target:port + if strings.Contains(priority, "/") { + priorityParts := strings.Split(priority, "/") + if len(priorityParts) == 2 { + if prio, err := strconv.ParseUint(priorityParts[0], 10, 16); err == nil { + rc.SrvPriority = uint16(prio) + } + if weight, err := strconv.ParseUint(priorityParts[1], 10, 16); err == nil { + rc.SrvWeight = uint16(weight) + } + } + } + if strings.Contains(target, ":") { + targetParts := strings.Split(target, ":") + if len(targetParts) == 2 { + if port, err := strconv.ParseUint(targetParts[1], 10, 16); err == nil { + rc.SrvPort = uint16(port) + } + srvTarget := targetParts[0] + // Ensure SRV targets are fully qualified + if !strings.HasSuffix(srvTarget, ".") { + srvTarget = srvTarget + "." + } + if err := rc.SetTarget(srvTarget); err != nil { + continue + } + } + } + case "CAA": + rc.Type = recordType + // CAA format: flags tag value [ttl] + if len(parts) >= 4 { + flags := priority // priority field contains flags for CAA + tag := parts[3] // tag field + // Value might be quoted + value := "" + if len(parts) >= 5 { + value = parts[4] + // Remove surrounding quotes if present + if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") && len(value) > 1 { + value = value[1 : len(value)-1] + } + // Parse TTL from the end if present (position 5) + if len(parts) >= 6 { + if ttlParsed, err := strconv.ParseUint(parts[5], 10, 32); err == nil { + ttl = uint32(ttlParsed) + } + } + } + + if flagsInt, err := strconv.ParseUint(flags, 10, 8); err == nil { + rc.CaaFlag = uint8(flagsInt) + } + rc.CaaTag = tag + if err := rc.SetTarget(value); err != nil { + continue + } + } + case "NAPTR": + rc.Type = recordType + // NAPTR format for Joker: order/preference replacement ttl 0 0 "flags" "service" "regex" + if len(parts) >= 9 { + // Parse order/preference from priority field (parts[2]) + if strings.Contains(priority, "/") { + priorityParts := strings.Split(priority, "/") + if len(priorityParts) == 2 { + if order, err := strconv.ParseUint(priorityParts[0], 10, 16); err == nil { + rc.NaptrOrder = uint16(order) + } + if pref, err := strconv.ParseUint(priorityParts[1], 10, 16); err == nil { + rc.NaptrPreference = uint16(pref) + } + } + } + // Replacement is in position 3 + if len(parts) > 3 { + target = parts[3] + } + // Parse TTL from position 4 + if len(parts) >= 5 { + if ttlParsed, err := strconv.ParseUint(parts[4], 10, 32); err == nil { + ttl = uint32(ttlParsed) + } + } + // Parse flags, service, and regex from positions 7, 8, 9 + if len(parts) > 7 { + rc.NaptrFlags = strings.Trim(parts[7], "\"") + } + if len(parts) > 8 { + rc.NaptrService = strings.Trim(parts[8], "\"") + } + if len(parts) > 9 { + rc.NaptrRegexp = strings.Trim(parts[9], "\"") + } + // Ensure NAPTR targets are fully qualified if they're not empty or "." + if target != "" && target != "." && !strings.HasSuffix(target, ".") { + target = target + "." + } + if err := rc.SetTarget(target); err != nil { + continue + } + } + default: + // Skip unsupported record types + continue + } + + records = append(records, rc) + } + + return records, nil +} + +// fixTTLs enforces minimum TTL requirements for Joker provider. +// NAPTR and SVC records can have TTL=0, others need >= 300. +func fixTTLs(records models.Records) { + for _, rc := range records { + if rc.TTL != 0 && rc.TTL < 300 { + if rc.Type != "NAPTR" && rc.Type != "SVC" { + rc.TTL = 300 + } + } + } +} + +// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records. +func (api *jokerProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) { + // Apply TTL fixes to desired records before comparison + fixTTLs(dc.Records) + + result, err := diff2.ByZone(existingRecords, dc, nil) + if err != nil { + return nil, 0, err + } + + if !result.HasChanges { + return []*models.Correction{}, result.ActualChangeCount, nil + } + + msg := fmt.Sprintf("Zone update for %s\n%s", dc.Name, strings.Join(result.Msgs, "\n")) + + correction := &models.Correction{ + Msg: msg, + F: func() error { + return api.updateZoneRecords(dc.Name, result.DesiredPlus) + }, + } + + return []*models.Correction{correction}, result.ActualChangeCount, nil +} + +// updateZoneRecords replaces the entire zone with new records. +func (api *jokerProvider) updateZoneRecords(domain string, records models.Records) error { + zoneData := api.recordsToZoneFormat(domain, records) + + // Joker API doesn't support empty zones (returns Status-Code: 2400) + // If the zone would be empty, we need to skip the update + if zoneData == "" { + // Return success for empty zone updates since Joker maintains minimal DNS infrastructure + // (SOA, NS records) automatically and doesn't allow completely empty zones + return nil + } + + params := url.Values{} + params.Set("domain", domain) + params.Set("zone", zoneData) + + _, _, err := api.makeRequest("dns-zone-put", params) + return err +} + +// recordsToZoneFormat converts RecordConfig records to Joker zone format. +func (api *jokerProvider) recordsToZoneFormat(domain string, records models.Records) string { + var lines []string + + for _, rc := range records { + label := rc.Name + if label == "" { + label = "@" + } + + // Joker format: