From ee616417a181239b0b77f6fe00acff3a22ae35b9 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Tue, 23 Dec 2025 13:52:22 +0100 Subject: [PATCH] f5xc: add an option to configure the domain of the server (#2767) --- cmd/zz_gen_cmd_dnshelp.go | 1 + docs/content/dns/zz_gen_f5xc.md | 1 + providers/dns/f5xc/f5xc.go | 5 +- providers/dns/f5xc/f5xc.toml | 1 + providers/dns/f5xc/f5xc_test.go | 7 +- providers/dns/f5xc/internal/client.go | 29 ++++++-- providers/dns/f5xc/internal/client_test.go | 87 +++++++++++++++++++--- 7 files changed, 111 insertions(+), 20 deletions(-) diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 7677818c9..601222903 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -1449,6 +1449,7 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(` - "F5XC_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`) ew.writeln(` - "F5XC_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 2)`) ew.writeln(` - "F5XC_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 60)`) + ew.writeln(` - "F5XC_SERVER": Server domain (Default: console.ves.volterra.io)`) ew.writeln(` - "F5XC_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`) ew.writeln() diff --git a/docs/content/dns/zz_gen_f5xc.md b/docs/content/dns/zz_gen_f5xc.md index c8a664a00..52488f1f7 100644 --- a/docs/content/dns/zz_gen_f5xc.md +++ b/docs/content/dns/zz_gen_f5xc.md @@ -54,6 +54,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). | `F5XC_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) | | `F5XC_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 2) | | `F5XC_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 60) | +| `F5XC_SERVER` | Server domain (Default: console.ves.volterra.io) | | `F5XC_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. diff --git a/providers/dns/f5xc/f5xc.go b/providers/dns/f5xc/f5xc.go index 6f8a8c493..76a6e0262 100644 --- a/providers/dns/f5xc/f5xc.go +++ b/providers/dns/f5xc/f5xc.go @@ -22,6 +22,7 @@ const ( EnvToken = envNamespace + "API_TOKEN" EnvTenantName = envNamespace + "TENANT_NAME" + EnvServer = envNamespace + "SERVER" EnvGroupName = envNamespace + "GROUP_NAME" EnvTTL = envNamespace + "TTL" @@ -34,6 +35,7 @@ const ( type Config struct { APIToken string TenantName string + Server string GroupName string PropagationTimeout time.Duration @@ -71,6 +73,7 @@ func NewDNSProvider() (*DNSProvider, error) { config.APIToken = values[EnvToken] config.TenantName = values[EnvTenantName] config.GroupName = values[EnvGroupName] + config.Server = env.GetOrFile(EnvServer) return NewDNSProviderConfig(config) } @@ -85,7 +88,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, errors.New("f5xc: missing group name") } - client, err := internal.NewClient(config.APIToken, config.TenantName) + client, err := internal.NewClient(config.APIToken, config.TenantName, config.Server) if err != nil { return nil, fmt.Errorf("f5xc: %w", err) } diff --git a/providers/dns/f5xc/f5xc.toml b/providers/dns/f5xc/f5xc.toml index 7a4cab419..f5a843c97 100644 --- a/providers/dns/f5xc/f5xc.toml +++ b/providers/dns/f5xc/f5xc.toml @@ -17,6 +17,7 @@ lego --email you@example.com --dns f5xc -d '*.example.com' -d example.com run F5XC_TENANT_NAME = "XC Tenant shortname" F5XC_GROUP_NAME = "Group name" [Configuration.Additional] + F5XC_SERVER = "Server domain (Default: console.ves.volterra.io)" F5XC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" F5XC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" F5XC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" diff --git a/providers/dns/f5xc/f5xc_test.go b/providers/dns/f5xc/f5xc_test.go index 98f7484e7..890a4cf09 100644 --- a/providers/dns/f5xc/f5xc_test.go +++ b/providers/dns/f5xc/f5xc_test.go @@ -9,7 +9,12 @@ import ( const envDomain = envNamespace + "DOMAIN" -var envTest = tester.NewEnvTest(EnvToken, EnvTenantName, EnvGroupName).WithDomain(envDomain) +var envTest = tester.NewEnvTest( + EnvToken, + EnvTenantName, + EnvServer, + EnvGroupName, +).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { diff --git a/providers/dns/f5xc/internal/client.go b/providers/dns/f5xc/internal/client.go index b0b5d0468..7beab0d03 100644 --- a/providers/dns/f5xc/internal/client.go +++ b/providers/dns/f5xc/internal/client.go @@ -14,7 +14,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) -const defaultHost = "console.ves.volterra.io" +const defaultServer = "console.ves.volterra.io" const authorizationHeader = "Authorization" @@ -27,18 +27,14 @@ type Client struct { } // NewClient creates a new Client. -func NewClient(apiToken, tenantName string) (*Client, error) { +func NewClient(apiToken, tenantName, server string) (*Client, error) { if apiToken == "" { return nil, errors.New("credentials missing") } - if tenantName == "" { - return nil, errors.New("missing tenant name") - } - - baseURL, err := url.Parse(fmt.Sprintf("https://%s.%s", tenantName, defaultHost)) + baseURL, err := createBaseURL(tenantName, server) if err != nil { - return nil, fmt.Errorf("parse base URL: %w", err) + return nil, err } return &Client{ @@ -209,3 +205,20 @@ func parseError(req *http.Request, resp *http.Response) error { return &apiErr } + +func createBaseURL(tenant, server string) (*url.URL, error) { + if tenant == "" { + return nil, errors.New("missing tenant name") + } + + if server == "" { + server = defaultServer + } + + baseURL, err := url.Parse(fmt.Sprintf("https://%s.%s", tenant, server)) + if err != nil { + return nil, fmt.Errorf("parse base URL: %w", err) + } + + return baseURL, nil +} diff --git a/providers/dns/f5xc/internal/client_test.go b/providers/dns/f5xc/internal/client_test.go index 0357abb16..bb188ef3f 100644 --- a/providers/dns/f5xc/internal/client_test.go +++ b/providers/dns/f5xc/internal/client_test.go @@ -14,7 +14,7 @@ import ( func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { - client, err := NewClient("secret", "shortname") + client, err := NewClient("secret", "shortname", "") if err != nil { return nil, err } @@ -28,7 +28,7 @@ func mockBuilder() *servermock.Builder[*Client] { WithAuthorization("APIToken secret")) } -func TestClient_Create(t *testing.T) { +func TestClient_CreateRRSet(t *testing.T) { client := mockBuilder(). Route("POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA", servermock.ResponseFromFixture("create.json"), @@ -62,7 +62,7 @@ func TestClient_Create(t *testing.T) { assert.Equal(t, expected, result) } -func TestClient_Create_error(t *testing.T) { +func TestClient_CreateRRSet_error(t *testing.T) { client := mockBuilder(). Route("POST /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA", servermock.Noop().WithStatusCode(http.StatusBadRequest)). @@ -81,7 +81,7 @@ func TestClient_Create_error(t *testing.T) { require.Error(t, err) } -func TestClient_Get(t *testing.T) { +func TestClient_GetRRSet(t *testing.T) { client := mockBuilder(). Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.ResponseFromFixture("get.json")). @@ -108,7 +108,7 @@ func TestClient_Get(t *testing.T) { assert.Equal(t, expected, result) } -func TestClient_Get_not_found(t *testing.T) { +func TestClient_GetRRSet_not_found(t *testing.T) { client := mockBuilder(). Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.ResponseFromFixture("error_404.json").WithStatusCode(http.StatusNotFound)). @@ -120,7 +120,7 @@ func TestClient_Get_not_found(t *testing.T) { assert.Nil(t, result) } -func TestClient_Get_error(t *testing.T) { +func TestClient_GetRRSet_error(t *testing.T) { client := mockBuilder(). Route("GET /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.Noop().WithStatusCode(http.StatusBadRequest)). @@ -130,7 +130,7 @@ func TestClient_Get_error(t *testing.T) { require.Error(t, err) } -func TestClient_Delete(t *testing.T) { +func TestClient_DeleteRRSet(t *testing.T) { client := mockBuilder(). Route("DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.ResponseFromFixture("get.json")). @@ -157,7 +157,7 @@ func TestClient_Delete(t *testing.T) { assert.Equal(t, expected, result) } -func TestClient_Delete_error(t *testing.T) { +func TestClient_DeleteRRSet_error(t *testing.T) { client := mockBuilder(). Route("DELETE /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.Noop().WithStatusCode(http.StatusBadRequest)). @@ -167,7 +167,7 @@ func TestClient_Delete_error(t *testing.T) { require.Error(t, err) } -func TestClient_Replace(t *testing.T) { +func TestClient_ReplaceRRSet(t *testing.T) { client := mockBuilder(). Route("PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.ResponseFromFixture("get.json"), @@ -204,7 +204,7 @@ func TestClient_Replace(t *testing.T) { assert.Equal(t, expected, result) } -func TestClient_Replace_error(t *testing.T) { +func TestClient_ReplaceRRSet_error(t *testing.T) { client := mockBuilder(). Route("PUT /api/config/dns/namespaces/system/dns_zones/example.com/rrsets/groupA/www/TXT", servermock.Noop().WithStatusCode(http.StatusBadRequest)). @@ -222,3 +222,70 @@ func TestClient_Replace_error(t *testing.T) { _, err := client.ReplaceRRSet(t.Context(), "example.com", "groupA", "www", "TXT", rrSet) require.Error(t, err) } + +func Test_createBaseURL(t *testing.T) { + testCases := []struct { + desc string + tenant string + server string + expected string + }{ + { + desc: "only tenant", + tenant: "foo", + expected: "https://foo.console.ves.volterra.io", + }, + { + desc: "custom server", + tenant: "foo", + server: "example.com", + expected: "https://foo.example.com", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + baseURL, err := createBaseURL(test.tenant, test.server) + require.NoError(t, err) + + assert.Equal(t, test.expected, baseURL.String()) + }) + } +} + +func Test_createBaseURL_error(t *testing.T) { + testCases := []struct { + desc string + tenant string + server string + expected string + }{ + { + desc: "no tenant", + tenant: "", + expected: "missing tenant name", + }, + { + desc: "invalid tenant", + tenant: "%31", + expected: `parse base URL: parse "https://%31.console.ves.volterra.io": invalid URL escape "%31"`, + }, + { + desc: "invalid host", + tenant: "foo", + server: "192.168.0.%31", + expected: `parse base URL: parse "https://foo.192.168.0.%31": invalid URL escape "%31"`, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + _, err := createBaseURL(test.tenant, test.server) + require.EqualError(t, err, test.expected) + }) + } +}