Merge pull request #1 from signalwerk/provider/infomaniak

Provider/infomaniak
This commit is contained in:
Jonathan Beliën 2025-12-02 21:02:59 +01:00 committed by GitHub
commit dcc2392e06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 293 additions and 26 deletions

View file

@ -56,6 +56,7 @@ Jump to a table:
| [`HEXONET`](hexonet.md) | ❌ | ✅ | ✅ |
| [`HOSTINGDE`](hostingde.md) | ❌ | ✅ | ✅ |
| [`HUAWEICLOUD`](huaweicloud.md) | ❌ | ✅ | ❌ |
| [`INFOMANIAK`](infomaniak.md) | ❌ | ✅ | ❌ |
| [`INTERNETBS`](internetbs.md) | ❌ | ❌ | ✅ |
| [`INWX`](inwx.md) | ❌ | ✅ | ✅ |
| [`JOKER`](joker.md) | ❌ | ✅ | ❌ |
@ -118,6 +119,7 @@ Jump to a table:
| [`HEXONET`](hexonet.md) | ❔ | ✅ | ✅ | ❔ |
| [`HOSTINGDE`](hostingde.md) | ❔ | ✅ | ✅ | ✅ |
| [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ✅ | ✅ | ✅ |
| [`INFOMANIAK`](infomaniak.md) | ❔ | ❔ | ❌ | ✅ |
| [`INTERNETBS`](internetbs.md) | ❔ | ❔ | ❌ | ❔ |
| [`INWX`](inwx.md) | ✅ | ✅ | ✅ | ✅ |
| [`JOKER`](joker.md) | ❌ | ❌ | ✅ | ✅ |
@ -177,6 +179,7 @@ Jump to a table:
| [`HEXONET`](hexonet.md) | ❌ | ❔ | ❔ | ✅ | ❔ |
| [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ❌ | ✅ | ✅ |
| [`HUAWEICLOUD`](huaweicloud.md) | ❌ | ❔ | ❌ | ❌ | ❌ |
| [`INFOMANIAK`](infomaniak.md) | ❌ | ✅ | ❌ | ❌ | ❌ |
| [`INWX`](inwx.md) | ✅ | ❔ | ❔ | ✅ | ❔ |
| [`JOKER`](joker.md) | ❌ | ❔ | ❌ | ❌ | ❌ |
| [`LINODE`](linode.md) | ❔ | ❔ | ❌ | ❔ | ❔ |
@ -233,6 +236,7 @@ Jump to a table:
| [`HEXONET`](hexonet.md) | ❔ | ❔ | ✅ | ❔ |
| [`HOSTINGDE`](hostingde.md) | ❔ | ❌ | ✅ | ❔ |
| [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ❌ | ✅ | ❌ |
| [`INFOMANIAK`](infomaniak.md) | ❌ | ❌ | ✅ | ❌ |
| [`INWX`](inwx.md) | ❔ | ✅ | ✅ | ✅ |
| [`JOKER`](joker.md) | ❔ | ✅ | ✅ | ❌ |
| [`LOOPIA`](loopia.md) | ❌ | ✅ | ✅ | ❌ |
@ -288,6 +292,7 @@ Jump to a table:
| [`HEXONET`](hexonet.md) | ✅ | ❔ | ❔ | ❔ | ✅ |
| [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ❔ | ✅ | ✅ |
| [`HUAWEICLOUD`](huaweicloud.md) | ✅ | ❌ | ❔ | ❌ | ❌ |
| [`INFOMANIAK`](infomaniak.md) | ✅ | ❌ | ❌ | ✅ | ✅ |
| [`INWX`](inwx.md) | ✅ | ✅ | ❔ | ✅ | ✅ |
| [`JOKER`](joker.md) | ✅ | ❌ | ❔ | ❌ | ❌ |
| [`LINODE`](linode.md) | ✅ | ❔ | ❔ | ❔ | ❔ |
@ -333,6 +338,7 @@ Jump to a table:
| [`HETZNER_V2`](hetzner_v2.md) | ❌ | ❔ | ✅ |
| [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ✅ |
| [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ❔ | ❌ |
| [`INFOMANIAK`](infomaniak.md) | ❌ | ❌ | ✅ |
| [`INWX`](inwx.md) | ✅ | ❔ | ❔ |
| [`JOKER`](joker.md) | ❔ | ❌ | ❌ |
| [`LOOPIA`](loopia.md) | ❌ | ❌ | ❌ |
@ -414,6 +420,7 @@ Providers in this category and their maintainers are:
|[`HEXONET`](hexonet.md)|@KaiSchwarz-cnic|
|[`HOSTINGDE`](hostingde.md)|@membero|
|[`HUAWEICLOUD`](huaweicloud.md)|@huihuimoe|
|[`INFOMANIAK`](infomaniak.md)|@jbelien|
|[`INTERNETBS`](internetbs.md)|@pragmaton|
|[`INWX`](inwx.md)|@patschi|
|[`LINODE`](linode.md)|@koesie10|

View file

@ -64,6 +64,11 @@ type dnsRecordCreate struct {
Target string `json:"target,omitempty"`
}
type dnsRecordUpdate struct {
Target string `json:"target,omitempty"`
TTL int64 `json:"ttl,omitempty"`
}
// Get zone information
// See https://developer.infomaniak.com/docs/api/get/2/zones/%7Bzone%7D
func (p *infomaniakProvider) getDNSZone(zone string) (*dnsZone, error) {
@ -184,3 +189,39 @@ func (p *infomaniakProvider) createDNSRecord(zone string, rec *dnsRecordCreate)
return &response.Data, nil
}
// Update a dns record in a given zone
// See https://developer.infomaniak.com/docs/api/put/2/zones/%7Bzone%7D/records/%7Brecord%7D
func (p *infomaniakProvider) updateDNSRecord(zone string, recordID string, rec *dnsRecordUpdate) (*dnsRecord, error) {
reqURL := fmt.Sprintf("%s/zones/%s/records/%s", baseURL, zone, recordID)
data, err := json.Marshal(rec)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPut, reqURL, bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Add("Authorization", "Bearer "+p.apiToken)
req.Header.Add("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
response := &dnsRecordResponse{}
err = json.NewDecoder(res.Body).Decode(response)
if err != nil {
return nil, err
}
if response.Result == "error" {
return nil, fmt.Errorf("failed to update record %s in zone %s: %s", recordID, zone, response.Error.Description)
}
return &response.Data, nil
}

View file

@ -4,10 +4,12 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
"github.com/StackExchange/dnscontrol/v4/providers"
"github.com/miekg/dns/dnsutil"
)
// infomaniakProvider is the handle for operations.
@ -18,7 +20,6 @@ type infomaniakProvider struct {
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.CanUseCAA: providers.Can(),
providers.CanUseDNAME: providers.Can(),
providers.CanUseDS: providers.Can(),
@ -58,23 +59,236 @@ func (p *infomaniakProvider) GetNameservers(domain string) ([]*models.Nameserver
return models.ToNameservers(zone.Nameservers)
}
// addTrailingDot adds a trailing dot if it's missing.
func addTrailingDot(target string) string {
if target == "" || target == "." {
return target
}
if !strings.HasSuffix(target, ".") {
return target + "."
}
return target
}
// toRecordConfig converts a DNS record from Infomaniak API to RecordConfig.
func toRecordConfig(domain string, r dnsRecord) (*models.RecordConfig, error) {
rc := &models.RecordConfig{
TTL: uint32(r.TTL),
Original: r,
}
// Handle the source/label - Infomaniak uses empty string or "." for apex
label := r.Source
if label == "" || label == "." {
label = "@"
}
rc.SetLabel(label, domain)
// Parse the target based on record type
rtype := r.Type
target := r.Target
var err error
switch rtype {
case "A", "AAAA":
rc.Type = rtype
err = rc.SetTarget(target)
case "CNAME", "NS", "DNAME":
rc.Type = rtype
// Add trailing dot and use AddOrigin to properly qualify the target
err = rc.SetTarget(dnsutil.AddOrigin(addTrailingDot(target), domain))
case "MX":
// Infomaniak returns MX as "priority target" (e.g., "5 mta-gw.infomaniak.ch")
rc.Type = rtype
err = rc.SetTargetMXString(addTrailingDot(target))
case "TXT":
rc.Type = rtype
// Infomaniak API returns TXT values wrapped in quotes, strip them
if len(target) >= 2 && strings.HasPrefix(target, "\"") && strings.HasSuffix(target, "\"") {
target = target[1 : len(target)-1]
}
err = rc.SetTargetTXT(target)
case "SRV":
// Infomaniak returns SRV as "priority weight port target"
rc.Type = rtype
err = rc.SetTargetSRVString(addTrailingDot(target))
case "CAA":
// Infomaniak returns CAA as "flags tag value" (e.g., "0 issue letsencrypt.org")
rc.Type = rtype
err = rc.SetTargetCAAString(target)
case "DS":
// Infomaniak returns DS as "keytag algorithm digesttype digest"
// Note: Infomaniak may split long digest data with spaces, so we need to rejoin them
rc.Type = rtype
parts := strings.Fields(target)
if len(parts) >= 4 {
// Rejoin all parts after the first 3 (keytag, algorithm, digesttype) as the digest
digest := strings.Join(parts[3:], "")
target = fmt.Sprintf("%s %s %s %s", parts[0], parts[1], parts[2], digest)
}
err = rc.SetTargetDSString(target)
case "SSHFP":
// Infomaniak returns SSHFP as "algorithm fingerprint_type fingerprint"
// Note: Infomaniak may split long fingerprint data with spaces, so we need to rejoin them
rc.Type = rtype
parts := strings.Fields(target)
if len(parts) >= 3 {
// Rejoin all parts after the first 2 (algorithm, fingerprint_type) as the fingerprint
fingerprint := strings.Join(parts[2:], "")
target = fmt.Sprintf("%s %s %s", parts[0], parts[1], fingerprint)
}
err = rc.SetTargetSSHFPString(target)
case "TLSA":
// Infomaniak returns TLSA as "usage selector matching_type certificate"
// Note: Infomaniak may split long certificate data with spaces, so we need to rejoin them
rc.Type = rtype
parts := strings.Fields(target)
if len(parts) >= 4 {
// Rejoin all parts after the first 3 (usage, selector, matching_type) as the certificate
certificate := strings.Join(parts[3:], "")
target = fmt.Sprintf("%s %s %s %s", parts[0], parts[1], parts[2], certificate)
}
err = rc.SetTargetTLSAString(target)
default:
rc.Type = rtype
err = rc.SetTarget(target)
}
if err != nil {
return nil, fmt.Errorf("unparsable record type=%q target=%q received from Infomaniak: %w", rtype, target, err)
}
return rc, nil
}
// fromRecordConfig converts a RecordConfig to the API format for creation.
func fromRecordConfig(rc *models.RecordConfig) *dnsRecordCreate {
// Get the label - Infomaniak uses empty string for apex
label := rc.GetLabel()
if label == "@" {
label = ""
}
// Get the target in the format expected by Infomaniak API
var target string
switch rc.Type {
case "A", "AAAA":
target = rc.GetTargetField()
case "CNAME", "NS", "DNAME":
// Remove trailing dot for the API
target = strings.TrimSuffix(rc.GetTargetField(), ".")
case "MX":
// Format: "priority target" (without trailing dot)
target = fmt.Sprintf("%d %s", rc.MxPreference, strings.TrimSuffix(rc.GetTargetField(), "."))
case "TXT":
target = rc.GetTargetField()
case "SRV":
// Format: "priority weight port target" (without trailing dot)
target = fmt.Sprintf("%d %d %d %s", rc.SrvPriority, rc.SrvWeight, rc.SrvPort, strings.TrimSuffix(rc.GetTargetField(), "."))
case "CAA":
// Format: "flags tag value"
target = fmt.Sprintf("%d %s %s", rc.CaaFlag, rc.CaaTag, rc.GetTargetField())
case "DS":
// Format: "keytag algorithm digesttype digest"
target = fmt.Sprintf("%d %d %d %s", rc.DsKeyTag, rc.DsAlgorithm, rc.DsDigestType, rc.DsDigest)
case "SSHFP":
// Format: "algorithm fingerprint_type fingerprint"
target = fmt.Sprintf("%d %d %s", rc.SshfpAlgorithm, rc.SshfpFingerprint, rc.GetTargetField())
case "TLSA":
// Format: "usage selector matching_type certificate"
target = fmt.Sprintf("%d %d %d %s", rc.TlsaUsage, rc.TlsaSelector, rc.TlsaMatchingType, rc.GetTargetField())
default:
target = rc.GetTargetField()
}
return &dnsRecordCreate{
Source: label,
Type: rc.Type,
TTL: int64(rc.TTL),
Target: target,
}
}
// toRecordUpdate converts a RecordConfig to the API format for updating.
func toRecordUpdate(rc *models.RecordConfig) *dnsRecordUpdate {
// Get the target in the format expected by Infomaniak API
var target string
switch rc.Type {
case "A", "AAAA":
target = rc.GetTargetField()
case "CNAME", "NS", "DNAME":
// Remove trailing dot for the API
target = strings.TrimSuffix(rc.GetTargetField(), ".")
case "MX":
// Format: "priority target" (without trailing dot)
target = fmt.Sprintf("%d %s", rc.MxPreference, strings.TrimSuffix(rc.GetTargetField(), "."))
case "TXT":
target = rc.GetTargetField()
case "SRV":
// Format: "priority weight port target" (without trailing dot)
target = fmt.Sprintf("%d %d %d %s", rc.SrvPriority, rc.SrvWeight, rc.SrvPort, strings.TrimSuffix(rc.GetTargetField(), "."))
case "CAA":
// Format: "flags tag value"
target = fmt.Sprintf("%d %s %s", rc.CaaFlag, rc.CaaTag, rc.GetTargetField())
case "DS":
// Format: "keytag algorithm digesttype digest"
target = fmt.Sprintf("%d %d %d %s", rc.DsKeyTag, rc.DsAlgorithm, rc.DsDigestType, rc.DsDigest)
case "SSHFP":
// Format: "algorithm fingerprint_type fingerprint"
target = fmt.Sprintf("%d %d %s", rc.SshfpAlgorithm, rc.SshfpFingerprint, rc.GetTargetField())
case "TLSA":
// Format: "usage selector matching_type certificate"
target = fmt.Sprintf("%d %d %d %s", rc.TlsaUsage, rc.TlsaSelector, rc.TlsaMatchingType, rc.GetTargetField())
default:
target = rc.GetTargetField()
}
return &dnsRecordUpdate{
TTL: int64(rc.TTL),
Target: target,
}
}
func (p *infomaniakProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) {
records, err := p.getDNSRecords(domain)
if err != nil {
return nil, err
}
cleanRecords := make(models.Records, 0)
cleanRecords := make(models.Records, 0, len(records))
for _, r := range records {
recConfig := &models.RecordConfig{
Original: r,
TTL: uint32(r.TTL),
Type: r.Type,
recConfig, err := toRecordConfig(domain, r)
if err != nil {
return nil, err
}
recConfig.SetLabelFromFQDN(r.Source, domain)
recConfig.SetTarget(r.Target)
cleanRecords = append(cleanRecords, recConfig)
}
@ -83,6 +297,7 @@ func (p *infomaniakProvider) GetZoneRecords(domain string, meta map[string]strin
func (p *infomaniakProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) {
var corrections []*models.Correction
domain := dc.Name
changes, actualChangeCount, err := diff2.ByRecord(existingRecords, dc, nil)
if err != nil {
@ -94,28 +309,32 @@ func (p *infomaniakProvider) GetZoneRecordsCorrections(dc *models.DomainConfig,
case diff2.REPORT:
corrections = append(corrections, &models.Correction{Msg: change.MsgsJoined})
case diff2.CHANGE:
fmt.Printf("CHANGE: %+v\n", change.New)
// corrections = append(corrections, &models.Correction{
// Msg: change.Msgs[0],
// F: func() error {
// return p.updateRecord(change.Old[0].Original.(dnsRecord), change.New[0], dc.Name)
// },
// })
oldRec := change.Old[0].Original.(dnsRecord)
newRec := change.New[0]
corrections = append(corrections, &models.Correction{
Msg: change.MsgsJoined,
F: func() error {
_, err := p.updateDNSRecord(domain, fmt.Sprintf("%v", oldRec.ID), toRecordUpdate(newRec))
return err
},
})
case diff2.CREATE:
fmt.Printf("CREATE: %+v\n", change.New)
// corrections = append(corrections, &models.Correction{
// Msg: change.Msgs[0],
// F: func() error {
// _, err := p.createDNSRecord(dc.Name, change.New[0])
// return err
// },
// })
rec := change.New[0]
corrections = append(corrections, &models.Correction{
Msg: change.MsgsJoined,
F: func() error {
_, err := p.createDNSRecord(domain, fromRecordConfig(rec))
return err
},
})
case diff2.DELETE:
rec := change.Old[0].Original.(dnsRecord)
corrections = append(corrections, &models.Correction{
Msg: change.Msgs[0],
Msg: change.MsgsJoined,
F: func() error {
return p.deleteDNSRecord(dc.Name, fmt.Sprintf("%v", rec.ID))
return p.deleteDNSRecord(domain, fmt.Sprintf("%v", rec.ID))
},
})
}