diff --git a/providers/dns/allinkl/allinkl.go b/providers/dns/allinkl/allinkl.go index 4a0aadd2b..0ccce7226 100644 --- a/providers/dns/allinkl/allinkl.go +++ b/providers/dns/allinkl/allinkl.go @@ -11,6 +11,7 @@ import ( "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/allinkl/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" @@ -121,11 +122,6 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("allinkl: could not find zone for domain %q: %w", domain, err) - } - ctx := context.Background() credential, err := d.identifier.Authentication(ctx, 60, true) @@ -135,6 +131,24 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx = internal.WithContext(ctx, credential) + var authZone string + + for z := range dns01.DomainsSeq(info.EffectiveFQDN) { + _, errG := d.client.GetDNSSettings(ctx, z, "") + if errG != nil { + log.Infof("allinkl: get DNS settings zone[%q] %v", z, errG) + continue + } + + authZone = z + + break + } + + if authZone == "" { + return fmt.Errorf("allinkl: unable to find auth zone for '%s'", info.EffectiveFQDN) + } + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("allinkl: %w", err) diff --git a/providers/dns/allinkl/allinkl_test.go b/providers/dns/allinkl/allinkl_test.go index b42adce5d..7da47aee4 100644 --- a/providers/dns/allinkl/allinkl_test.go +++ b/providers/dns/allinkl/allinkl_test.go @@ -1,9 +1,18 @@ package allinkl import ( + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/allinkl/internal" "github.com/stretchr/testify/require" ) @@ -143,3 +152,108 @@ func TestLiveCleanUp(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Login = "user" + config.Password = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + p.identifier.BaseURL, _ = url.Parse(server.URL) + + return p, err + }, + ).Route("POST /KasAuth.php", + servermock.ResponseFromInternal("auth.xml"), + servermock.CheckRequestBodyFromInternal("auth-request.xml"). + IgnoreWhitespace(), + ) +} + +func extractKasRequest(reader io.Reader) (*internal.KasRequest, error) { + type ReqEnvelope struct { + XMLName xml.Name `xml:"Envelope"` + Body struct { + KasAPI struct { + Params string `xml:"Params"` + } `xml:"KasApi"` + } `xml:"Body"` + } + + raw, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + reqEnvelope := ReqEnvelope{} + + err = xml.Unmarshal(raw, &reqEnvelope) + if err != nil { + return nil, err + } + + var kReq internal.KasRequest + + err = json.Unmarshal([]byte(reqEnvelope.Body.KasAPI.Params), &kReq) + if err != nil { + return nil, err + } + + return &kReq, nil +} + +func TestDNSProvider_Present(t *testing.T) { + provider := mockBuilder(). + Route("POST /KasApi.php", + http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + kReq, err := extractKasRequest(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + switch kReq.Action { + case "get_dns_settings": + params := kReq.RequestParams.(map[string]any) + + if params["zone_host"] == "_acme-challenge.example.com." { + servermock.ResponseFromInternal("get_dns_settings_not_found.xml").ServeHTTP(rw, req) + } else { + servermock.ResponseFromInternal("get_dns_settings.xml").ServeHTTP(rw, req) + } + + case "add_dns_settings": + servermock.ResponseFromInternal("add_dns_settings.xml").ServeHTTP(rw, req) + + default: + http.Error(rw, fmt.Sprintf("unknown action: %v", kReq.Action), http.StatusBadRequest) + } + }), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider := mockBuilder(). + Route("POST /KasApi.php", + servermock.ResponseFromInternal("delete_dns_settings.xml"), + servermock.CheckRequestBodyFromInternal("delete_dns_settings-request.xml"). + IgnoreWhitespace()). + Build(t) + + provider.recordIDs["abc"] = "57347450" + + err := provider.CleanUp("example.com", "abc", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/allinkl/internal/client.go b/providers/dns/allinkl/internal/client.go index d747e9b36..d4403cac5 100644 --- a/providers/dns/allinkl/internal/client.go +++ b/providers/dns/allinkl/internal/client.go @@ -6,16 +6,21 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strconv" "strings" "sync" "time" + "github.com/cenkalti/backoff/v5" + "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-viper/mapstructure/v2" ) -const apiEndpoint = "https://kasapi.kasserver.com/soap/KasApi.php" +const defaultBaseURL = "https://kasapi.kasserver.com/soap/" + +const apiPath = "KasApi.php" type Authentication interface { Authentication(ctx context.Context, sessionLifetime int, sessionUpdateLifetime bool) (string, error) @@ -28,16 +33,21 @@ type Client struct { floodTime time.Time muFloodTime sync.Mutex - baseURL string + maxElapsedTime time.Duration + + BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(login string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + return &Client{ - login: login, - baseURL: apiEndpoint, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, + login: login, + BaseURL: baseURL, + maxElapsedTime: 3 * time.Minute, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -51,14 +61,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R requestParams["record_id"] = recordID } - req, err := c.newRequest(ctx, "get_dns_settings", requestParams) - if err != nil { - return nil, err - } + var g APIResponse[GetDNSSettingsResponse] - var g GetDNSSettingsAPIResponse - - err = c.do(req, &g) + err := c.doRequest(ctx, "get_dns_settings", requestParams, &g) if err != nil { return nil, err } @@ -70,14 +75,9 @@ func (c *Client) GetDNSSettings(ctx context.Context, zone, recordID string) ([]R // AddDNSSettings Creation of a DNS resource record. func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, error) { - req, err := c.newRequest(ctx, "add_dns_settings", record) - if err != nil { - return "", err - } + var g APIResponse[AddDNSSettingsResponse] - var g AddDNSSettingsAPIResponse - - err = c.do(req, &g) + err := c.doRequest(ctx, "add_dns_settings", record, &g) if err != nil { return "", err } @@ -91,14 +91,9 @@ func (c *Client) AddDNSSettings(ctx context.Context, record DNSRequest) (string, func (c *Client) DeleteDNSSettings(ctx context.Context, recordID string) (string, error) { requestParams := map[string]string{"record_id": recordID} - req, err := c.newRequest(ctx, "delete_dns_settings", requestParams) - if err != nil { - return "", err - } + var g APIResponse[DeleteDNSSettingsResponse] - var g DeleteDNSSettingsAPIResponse - - err = c.do(req, &g) + err := c.doRequest(ctx, "delete_dns_settings", requestParams, &g) if err != nil { return "", err } @@ -124,7 +119,9 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAPIEnvelope, body))) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(payload)) + endpoint := c.BaseURL.JoinPath(apiPath) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } @@ -132,6 +129,21 @@ func (c *Client) newRequest(ctx context.Context, action string, requestParams an return req, nil } +func (c *Client) doRequest(ctx context.Context, action string, requestParams, result any) error { + return wait.Retry(ctx, + func() error { + req, err := c.newRequest(ctx, action, requestParams) + if err != nil { + return backoff.Permanent(err) + } + + return c.do(req, result) + }, + backoff.WithBackOff(&backoff.ZeroBackOff{}), + backoff.WithMaxElapsedTime(c.maxElapsedTime), + ) +} + func (c *Client) do(req *http.Request, result any) error { c.muFloodTime.Lock() time.Sleep(time.Until(c.floodTime)) @@ -139,29 +151,40 @@ func (c *Client) do(req *http.Request, result any) error { resp, err := c.HTTPClient.Do(req) if err != nil { - return errutils.NewHTTPDoError(req, err) + return backoff.Permanent(errutils.NewHTTPDoError(req, err)) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return errutils.NewUnexpectedResponseStatusCodeError(req, resp) + return backoff.Permanent(errutils.NewUnexpectedResponseStatusCodeError(req, resp)) } envlp, err := decodeXML[KasAPIResponseEnvelope](resp.Body) if err != nil { - return err + return backoff.Permanent(err) } if envlp.Body.Fault != nil { - return envlp.Body.Fault + if envlp.Body.Fault.Message == "flood_protection" { + ft, errP := strconv.ParseFloat(envlp.Body.Fault.Detail, 64) + if errP != nil { + return fmt.Errorf("parse flood protection delay: %w", envlp.Body.Fault) + } + + c.updateFloodTime(ft) + + return envlp.Body.Fault + } + + return backoff.Permanent(envlp.Body.Fault) } raw := getValue(envlp.Body.KasAPIResponse.Return) err = mapstructure.Decode(raw, result) if err != nil { - return fmt.Errorf("response struct decode: %w", err) + return backoff.Permanent(fmt.Errorf("response struct decode: %w", err)) } return nil diff --git a/providers/dns/allinkl/internal/client_test.go b/providers/dns/allinkl/internal/client_test.go index 4b111e31c..949f45bf9 100644 --- a/providers/dns/allinkl/internal/client_test.go +++ b/providers/dns/allinkl/internal/client_test.go @@ -2,7 +2,9 @@ package internal import ( "net/http/httptest" + "net/url" "testing" + "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" @@ -11,15 +13,17 @@ import ( func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("user") - client.baseURL = server.URL + client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() + client.maxElapsedTime = 1 * time.Second + return client, nil } func TestClient_GetDNSSettings(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). - Route("POST /", servermock.ResponseFromFixture("get_dns_settings.xml"), + Route("POST /KasApi.php", servermock.ResponseFromFixture("get_dns_settings.xml"), servermock.CheckRequestBodyFromFixture("get_dns_settings-request.xml"). IgnoreWhitespace()). Build(t) @@ -96,9 +100,24 @@ func TestClient_GetDNSSettings(t *testing.T) { assert.Equal(t, expected, records) } +func TestClient_GetDNSSettings_error_flood_protection(t *testing.T) { + client := servermock.NewBuilder[*Client](setupClient). + Route("POST /KasApi.php", + servermock.ResponseFromFixture("flood_protection.xml"), + ). + Build(t) + + assert.Zero(t, client.floodTime) + + _, err := client.GetDNSSettings(mockContext(t), "example.com", "") + require.EqualError(t, err, "KasApi: SOAP-ENV:Server: flood_protection: 0.0688529014587") + + assert.NotZero(t, client.floodTime) +} + func TestClient_AddDNSSettings(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). - Route("POST /", servermock.ResponseFromFixture("add_dns_settings.xml"), + Route("POST /KasApi.php", servermock.ResponseFromFixture("add_dns_settings.xml"), servermock.CheckRequestBodyFromFixture("add_dns_settings-request.xml"). IgnoreWhitespace()). Build(t) @@ -118,7 +137,7 @@ func TestClient_AddDNSSettings(t *testing.T) { func TestClient_DeleteDNSSettings(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient). - Route("POST /", servermock.ResponseFromFixture("delete_dns_settings.xml"), + Route("POST /KasApi.php", servermock.ResponseFromFixture("delete_dns_settings.xml"), servermock.CheckRequestBodyFromFixture("delete_dns_settings-request.xml"). IgnoreWhitespace()). Build(t) diff --git a/providers/dns/allinkl/internal/fixtures/auth-request.xml b/providers/dns/allinkl/internal/fixtures/auth-request.xml new file mode 100644 index 000000000..1cba86f10 --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/auth-request.xml @@ -0,0 +1,7 @@ + + + + {"kas_login":"user","kas_auth_data":"secret","kas_auth_type":"plain","session_lifetime":60,"session_update_lifetime":"Y"} + + + diff --git a/providers/dns/allinkl/internal/fixtures/flood_protection.xml b/providers/dns/allinkl/internal/fixtures/flood_protection.xml new file mode 100644 index 000000000..b8da10fab --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/flood_protection.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + flood_protection + KasApi + 0.0688529014587 + + + diff --git a/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml new file mode 100644 index 000000000..478d07a3a --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_not_found.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + zone_not_found + KasApi + example.com + + + diff --git a/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml new file mode 100644 index 000000000..c77d733db --- /dev/null +++ b/providers/dns/allinkl/internal/fixtures/get_dns_settings-zone_syntax_incorrect.xml @@ -0,0 +1,11 @@ + + + + + SOAP-ENV:Server + zone_syntax_incorrect + KasApi + _acme-challenge.example.com + + + diff --git a/providers/dns/allinkl/internal/identity.go b/providers/dns/allinkl/internal/identity.go index ba8d4d90e..e95e78899 100644 --- a/providers/dns/allinkl/internal/identity.go +++ b/providers/dns/allinkl/internal/identity.go @@ -6,14 +6,14 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) -// authEndpoint represents the Identity API endpoint to call. -const authEndpoint = "https://kasapi.kasserver.com/soap/KasAuth.php" +const authPath = "KasAuth.php" type token string @@ -24,17 +24,19 @@ type Identifier struct { login string password string - authEndpoint string - HTTPClient *http.Client + BaseURL *url.URL + HTTPClient *http.Client } // NewIdentifier creates a new Identifier. func NewIdentifier(login, password string) *Identifier { + baseURL, _ := url.Parse(defaultBaseURL) + return &Identifier{ - login: login, - password: password, - authEndpoint: authEndpoint, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, + login: login, + password: password, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -62,7 +64,9 @@ func (c *Identifier) Authentication(ctx context.Context, sessionLifetime int, se payload := []byte(strings.TrimSpace(fmt.Sprintf(kasAuthEnvelope, body))) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authEndpoint, bytes.NewReader(payload)) + endpoint := c.BaseURL.JoinPath(authPath) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) if err != nil { return "", fmt.Errorf("unable to create request: %w", err) } diff --git a/providers/dns/allinkl/internal/identity_test.go b/providers/dns/allinkl/internal/identity_test.go index 7b93b7688..41d092b13 100644 --- a/providers/dns/allinkl/internal/identity_test.go +++ b/providers/dns/allinkl/internal/identity_test.go @@ -3,6 +3,7 @@ package internal import ( "context" "net/http/httptest" + "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" @@ -12,7 +13,7 @@ import ( func setupIdentifierClient(server *httptest.Server) (*Identifier, error) { client := NewIdentifier("user", "secret") - client.authEndpoint = server.URL + client.BaseURL, _ = url.Parse(server.URL) client.HTTPClient = server.Client() return client, nil @@ -26,10 +27,13 @@ func mockContext(t *testing.T) context.Context { func TestIdentifier_Authentication(t *testing.T) { client := servermock.NewBuilder[*Identifier](setupIdentifierClient). - Route("POST /", servermock.ResponseFromFixture("auth.xml")). + Route("POST /KasAuth.php", + servermock.ResponseFromFixture("auth.xml"), + servermock.CheckRequestBodyFromFixture("auth-request.xml"). + IgnoreWhitespace()). Build(t) - credentialToken, err := client.Authentication(t.Context(), 60, false) + credentialToken, err := client.Authentication(t.Context(), 60, true) require.NoError(t, err) assert.Equal(t, "593959ca04f0de9689b586c6a647d15d", credentialToken) @@ -37,7 +41,7 @@ func TestIdentifier_Authentication(t *testing.T) { func TestIdentifier_Authentication_error(t *testing.T) { client := servermock.NewBuilder[*Identifier](setupIdentifierClient). - Route("POST /", servermock.ResponseFromFixture("auth_fault.xml")). + Route("POST /KasAuth.php", servermock.ResponseFromFixture("auth_fault.xml")). Build(t) _, err := client.Authentication(t.Context(), 60, false) diff --git a/providers/dns/allinkl/internal/types.go b/providers/dns/allinkl/internal/types.go index b0aa9b4ff..51f7065b5 100644 --- a/providers/dns/allinkl/internal/types.go +++ b/providers/dns/allinkl/internal/types.go @@ -26,10 +26,11 @@ type Fault struct { Code string `xml:"faultcode"` Message string `xml:"faultstring"` Actor string `xml:"faultactor"` + Detail string `xml:"detail"` } -func (f Fault) Error() string { - return fmt.Sprintf("%s: %s: %s", f.Actor, f.Code, f.Message) +func (f *Fault) Error() string { + return fmt.Sprintf("%s: %s: %s: %s", f.Actor, f.Code, f.Message, f.Detail) } // KasResponse a KAS SOAP response. diff --git a/providers/dns/allinkl/internal/types_api.go b/providers/dns/allinkl/internal/types_api.go index 22f2c32ed..a11f3aac0 100644 --- a/providers/dns/allinkl/internal/types_api.go +++ b/providers/dns/allinkl/internal/types_api.go @@ -53,8 +53,8 @@ type DNSRequest struct { // --- -type GetDNSSettingsAPIResponse struct { - Response GetDNSSettingsResponse `json:"Response" mapstructure:"Response"` +type APIResponse[T any] struct { + Response T `json:"Response" mapstructure:"Response"` } type GetDNSSettingsResponse struct { @@ -73,20 +73,12 @@ type ReturnInfo struct { Aux int `json:"record_aux,omitempty" mapstructure:"record_aux"` } -type AddDNSSettingsAPIResponse struct { - Response AddDNSSettingsResponse `json:"Response" mapstructure:"Response"` -} - type AddDNSSettingsResponse struct { KasFloodDelay float64 `json:"KasFloodDelay" mapstructure:"KasFloodDelay"` ReturnInfo string `json:"ReturnInfo" mapstructure:"ReturnInfo"` ReturnString string `json:"ReturnString" mapstructure:"ReturnString"` } -type DeleteDNSSettingsAPIResponse struct { - Response DeleteDNSSettingsResponse `json:"Response"` -} - type DeleteDNSSettingsResponse struct { KasFloodDelay float64 `json:"KasFloodDelay"` ReturnString string `json:"ReturnString"`