feat(provider): new acme dns-01 provider: conohavps

This commit is contained in:
Fu Diwei
2026-05-29 23:47:06 +08:00
committed by RHQYZ
parent c89da616c6
commit 795e8df4ad
24 changed files with 1113 additions and 14 deletions
@@ -0,0 +1,42 @@
package conohavpsv2
import (
"fmt"
"time"
"github.com/go-acme/lego/v5/providers/dns/conoha"
"github.com/certimate-go/certimate/pkg/core/certifier"
)
type ChallengerConfig struct {
ApiUserName string `json:"apiUserName"`
ApiPassword string `json:"apiPassword"`
TenantId string `json:"tenantId"`
DnsPropagationTimeout int `json:"dnsPropagationTimeout,omitempty"`
DnsTTL int `json:"dnsTTL,omitempty"`
}
func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the acme challenge provider is nil")
}
providerConfig := conoha.NewDefaultConfig()
providerConfig.Username = config.ApiUserName
providerConfig.Password = config.ApiPassword
providerConfig.TenantID = config.TenantId
if config.DnsPropagationTimeout != 0 {
providerConfig.PropagationTimeout = time.Duration(config.DnsPropagationTimeout) * time.Second
}
if config.DnsTTL != 0 {
providerConfig.TTL = config.DnsTTL
}
provider, err := conoha.NewDNSProviderConfig(providerConfig)
if err != nil {
return nil, err
}
return provider, nil
}
@@ -0,0 +1,45 @@
package conohavpsv2
import (
"fmt"
"time"
"github.com/certimate-go/certimate/pkg/core/certifier"
"github.com/certimate-go/certimate/pkg/core/certifier/challengers/dns01/conohavpsv3/internal"
)
type ChallengerConfig struct {
ApiUserId string `json:"apiUserId"`
ApiUserName string `json:"apiUserName"`
ApiPassword string `json:"apiPassword"`
TenantId string `json:"tenantId"`
TenantName string `json:"tenantName"`
DnsPropagationTimeout int `json:"dnsPropagationTimeout,omitempty"`
DnsTTL int `json:"dnsTTL,omitempty"`
}
func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) {
if config == nil {
return nil, fmt.Errorf("the configuration of the acme challenge provider is nil")
}
providerConfig := internal.NewDefaultConfig()
providerConfig.UserID = config.ApiUserId
providerConfig.UserName = config.ApiUserName
providerConfig.Password = config.ApiPassword
providerConfig.TenantID = config.TenantId
providerConfig.TenantName = config.TenantName
if config.DnsPropagationTimeout != 0 {
providerConfig.PropagationTimeout = time.Duration(config.DnsPropagationTimeout) * time.Second
}
if config.DnsTTL != 0 {
providerConfig.TTL = config.DnsTTL
}
provider, err := internal.NewDNSProviderConfig(providerConfig)
if err != nil {
return nil, err
}
return provider, nil
}
@@ -0,0 +1,200 @@
package internal
import (
"context"
"fmt"
"sync"
"time"
"github.com/go-acme/lego/v5/challenge"
"github.com/go-acme/lego/v5/challenge/dns01"
"github.com/go-acme/lego/v5/platform/env"
"github.com/samber/lo"
conohavpssdk "github.com/certimate-go/certimate/pkg/sdk3rd/conoha/vps/v3"
)
const (
envNamespace = "CONOHAV3_"
EnvAPIUserID = envNamespace + "API_USER_ID"
EnvAPIUserName = envNamespace + "API_USER_NAME"
EnvAPIPassword = envNamespace + "API_PASSWORD"
EnvTenantID = envNamespace + "TENANT_ID"
EnvTenantName = envNamespace + "TENANT_NAME"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
type Config struct {
UserID string
UserName string
Password string
TenantID string
TenantName string
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
HTTPTimeout time.Duration
}
type DNSProvider struct {
config *Config
client *conohavpssdk.Client
zoneIDs map[string]string // Key: ZoneFQDN; Value: ZoneUUID
zoneIDsMu sync.Mutex
recordIDs map[string]string // Key: ChallengeToken; Value: RecordUUID
recordIDsMu sync.Mutex
}
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
}
}
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvAPIUserID, EnvAPIUserName, EnvAPIPassword, EnvTenantID, EnvTenantName)
if err != nil {
return nil, fmt.Errorf("conohavpsv3: %w", err)
}
config := NewDefaultConfig()
config.UserID = values[EnvAPIUserID]
config.UserName = values[EnvAPIUserName]
config.Password = values[EnvAPIPassword]
config.TenantID = values[EnvTenantID]
config.TenantName = values[EnvTenantName]
return NewDNSProviderConfig(config)
}
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, fmt.Errorf("conohavpsv3: the configuration of the DNS provider is nil")
}
client, err := conohavpssdk.NewClient(config.UserID, config.UserName, config.Password, config.TenantID, config.TenantName)
if err != nil {
return nil, fmt.Errorf("conohavpsv3: %w", err)
} else {
client.SetTimeout(config.HTTPTimeout)
}
return &DNSProvider{
config: config,
client: client,
zoneIDs: make(map[string]string),
zoneIDsMu: sync.Mutex{},
recordIDs: make(map[string]string),
recordIDsMu: sync.Mutex{},
}, nil
}
func (d *DNSProvider) Present(ctx context.Context, domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
authZone, err := dns01.DefaultClient().FindZoneByFqdn(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("conohavpsv3: could not find zone for domain %q: %w", domain, err)
}
zoneInfo, err := d.findZone(ctx, authZone)
if err != nil {
return fmt.Errorf("conohavpsv3: error when list zones: %w", err)
}
// REF: https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-create_record-v3/
response, err := d.client.DnsCreateRecordWithContext(ctx, zoneInfo.UUID, &conohavpssdk.DnsCreateRecordRequest{
Name: lo.ToPtr(info.EffectiveFQDN),
Type: lo.ToPtr("TXT"),
Data: lo.ToPtr(info.Value),
TTL: lo.ToPtr(d.config.TTL),
})
if err != nil {
return fmt.Errorf("conohavpsv3: error when create record: %w", err)
}
d.zoneIDsMu.Lock()
d.zoneIDs[authZone] = zoneInfo.UUID
d.zoneIDsMu.Unlock()
d.recordIDsMu.Lock()
d.recordIDs[token] = response.UUID
d.recordIDsMu.Unlock()
return nil
}
func (d *DNSProvider) CleanUp(ctx context.Context, domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
authZone, err := dns01.DefaultClient().FindZoneByFqdn(ctx, info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("conohavpsv3: could not find zone for domain %q: %w", domain, err)
}
d.zoneIDsMu.Lock()
zoneId, ok := d.zoneIDs[authZone]
d.zoneIDsMu.Unlock()
if !ok {
return fmt.Errorf("conohavpsv3: unknown zone ID for '%s'", authZone)
}
d.recordIDsMu.Lock()
recordId, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
if !ok {
return fmt.Errorf("conohavpsv3: unknown record ID for '%s'", info.EffectiveFQDN)
}
if _, err := d.client.DnsDeleteRecordWithContext(ctx, zoneId, recordId); err != nil {
return fmt.Errorf("conohavpsv3: error when delete record: %w", err)
}
return nil
}
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
func (d *DNSProvider) findZone(ctx context.Context, zoneName string) (*conohavpssdk.DnsDomainRecord, error) {
offset := 0
limit := 10
for {
// REF: https://doc.conoha.jp/reference/api-vps3/api-dns-vps3/dnsaas-get_domains_list-v3/
request := &conohavpssdk.DnsGetDomainsListRequest{
Offset: lo.ToPtr(offset),
Limit: lo.ToPtr(limit),
}
response, err := d.client.DnsGetDomainsListWithContext(ctx, request)
if err != nil {
return nil, err
}
for _, domainItem := range response.Domains {
if dns01.UnFqdn(domainItem.Name) == dns01.UnFqdn(zoneName) {
return domainItem, nil
}
}
if len(response.Domains) < limit || offset+limit >= response.TotalCount {
break
}
offset += limit
}
return nil, fmt.Errorf("could not find zone '%s'", zoneName)
}
@@ -32,9 +32,9 @@ type Config struct {
AccessKeyId string
SecretAccessKey string
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
HTTPTimeout time.Duration
}
@@ -30,9 +30,9 @@ var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
type Config struct {
HTTPToken string
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
HTTPTimeout time.Duration
}
@@ -40,7 +40,7 @@ type DNSProvider struct {
config *Config
client *dynv6sdk.Client
zoneIDs map[string]int64 // Key: ZoneName; Value: ZoneID
zoneIDs map[string]int64 // Key: ZoneFQDN; Value: ZoneID
zoneIDsMu sync.Mutex
recordIDs map[string]int64 // Key: ChallengeToken; Value: RecordID
recordIDsMu sync.Mutex
@@ -102,13 +102,13 @@ func (d *DNSProvider) Present(ctx context.Context, domain, token, keyAuth string
return fmt.Errorf("dynv6: %w", err)
}
zone, err := d.findZone(ctx, dns01.UnFqdn(authZone))
zoneInfo, err := d.findZone(ctx, authZone)
if err != nil {
return fmt.Errorf("dynv6: error when list zones: %w", err)
}
// REF: https://dynv6.github.io/api-spec/#tag/records/operation/addRecord
response, err := d.client.AddRecordWithContext(ctx, zone.ID, &dynv6sdk.AddRecordRequest{
response, err := d.client.AddRecordWithContext(ctx, zoneInfo.ID, &dynv6sdk.AddRecordRequest{
Type: lo.ToPtr("TXT"),
Name: lo.ToPtr(subDomain),
Data: lo.ToPtr(info.Value),
@@ -118,7 +118,7 @@ func (d *DNSProvider) Present(ctx context.Context, domain, token, keyAuth string
}
d.zoneIDsMu.Lock()
d.zoneIDs[zone.Name] = zone.ID
d.zoneIDs[authZone] = zoneInfo.ID
d.zoneIDsMu.Unlock()
d.recordIDsMu.Lock()
@@ -137,10 +137,10 @@ func (d *DNSProvider) CleanUp(ctx context.Context, domain, token, keyAuth string
}
d.zoneIDsMu.Lock()
zoneId, ok := d.zoneIDs[dns01.UnFqdn(authZone)]
zoneId, ok := d.zoneIDs[authZone]
d.zoneIDsMu.Unlock()
if !ok {
return fmt.Errorf("dynv6: unknown zone ID for '%s'", dns01.UnFqdn(authZone))
return fmt.Errorf("dynv6: unknown zone ID for '%s'", authZone)
}
d.recordIDsMu.Lock()
@@ -169,7 +169,7 @@ func (d *DNSProvider) findZone(ctx context.Context, zoneName string) (*dynv6sdk.
}
for _, zone := range *zones {
if zone.Name == zoneName {
if dns01.UnFqdn(zone.Name) == dns01.UnFqdn(zoneName) {
return zone, nil
}
}
@@ -32,9 +32,9 @@ type Config struct {
AccessKey string
AccessSecret string
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
HTTPTimeout time.Duration
}
@@ -0,0 +1,57 @@
package v3
import (
"context"
"fmt"
"net/http"
"net/url"
)
type DnsCreateRecordRequest struct {
Name *string `json:"name,omitempty"`
Type *string `json:"type,omitempty"`
Data *string `json:"data,omitempty"`
TTL *int `json:"ttl,omitempty"`
}
type DnsCreateRecordResponse struct {
sdkResponseBase
UUID string `json:"uuid"`
DomainUUID string `json:"domain_uuid"`
Name string `json:"name"`
Type string `json:"type"`
Data string `json:"data"`
TTL int `json:"ttl"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (c *Client) DnsCreateRecord(domainId string, req *DnsCreateRecordRequest) (*DnsCreateRecordResponse, error) {
return c.DnsCreateRecordWithContext(context.Background(), domainId, req)
}
func (c *Client) DnsCreateRecordWithContext(ctx context.Context, domainId string, req *DnsCreateRecordRequest) (*DnsCreateRecordResponse, error) {
if domainId == "" {
return nil, fmt.Errorf("sdkerr: unset domainId")
}
if err := c.ensureAccessTokenExists(); err != nil {
return nil, err
}
httpreq, err := c.newRequest(http.MethodPost, fmt.Sprintf("%s/v1/domains/%s/records", dnsBaseURL, url.PathEscape(domainId)))
if err != nil {
return nil, err
} else {
httpreq.SetBody(req)
httpreq.SetContext(ctx)
}
result := &DnsCreateRecordResponse{}
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
return result, err
}
return result, nil
}
@@ -0,0 +1,43 @@
package v3
import (
"context"
"fmt"
"net/http"
"net/url"
)
type DnsDeleteRecordResponse struct {
sdkResponseBase
}
func (c *Client) DnsDeleteRecord(domainId string, recordId string) (*DnsDeleteRecordResponse, error) {
return c.DnsDeleteRecordWithContext(context.Background(), domainId, recordId)
}
func (c *Client) DnsDeleteRecordWithContext(ctx context.Context, domainId string, recordId string) (*DnsDeleteRecordResponse, error) {
if domainId == "" {
return nil, fmt.Errorf("sdkerr: unset domainId")
}
if recordId == "" {
return nil, fmt.Errorf("sdkerr: unset recordId")
}
if err := c.ensureAccessTokenExists(); err != nil {
return nil, err
}
httpreq, err := c.newRequest(http.MethodDelete, fmt.Sprintf("%s/v1/domains/%s/records/%s", dnsBaseURL, url.PathEscape(domainId), url.PathEscape(recordId)))
if err != nil {
return nil, err
} else {
httpreq.SetContext(ctx)
}
result := &DnsDeleteRecordResponse{}
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
return result, err
}
return result, nil
}
@@ -0,0 +1,53 @@
package v3
import (
"context"
"fmt"
"net/http"
qs "github.com/google/go-querystring/query"
)
type DnsGetDomainsListRequest struct {
Limit *int `json:"limit,omitempty" url:"limit,omitempty"`
Offset *int `json:"offset,omitempty" url:"offset,omitempty"`
SortType *string `json:"sort_type,omitempty" url:"sort_type,omitempty"`
SortKey *string `json:"sort_key,omitempty" url:"sort_key,omitempty"`
}
type DnsGetDomainsListResponse struct {
sdkResponseBase
Domains []*DnsDomainRecord `json:"domains,omitempty"`
TotalCount int `json:"total_count,omitempty"`
}
func (c *Client) DnsGetDomainsList(req *DnsGetDomainsListRequest) (*DnsGetDomainsListResponse, error) {
return c.DnsGetDomainsListWithContext(context.Background(), req)
}
func (c *Client) DnsGetDomainsListWithContext(ctx context.Context, req *DnsGetDomainsListRequest) (*DnsGetDomainsListResponse, error) {
if err := c.ensureAccessTokenExists(); err != nil {
return nil, err
}
httpreq, err := c.newRequest(http.MethodGet, fmt.Sprintf("%s/v1/domains", dnsBaseURL))
if err != nil {
return nil, err
} else {
values, err := qs.Values(req)
if err != nil {
return nil, err
}
httpreq.SetQueryParamsFromValues(values)
httpreq.SetContext(ctx)
}
result := &DnsGetDomainsListResponse{}
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
return result, err
}
return result, nil
}
+202
View File
@@ -0,0 +1,202 @@
package v3
import (
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/go-resty/resty/v2"
"github.com/certimate-go/certimate/internal/app"
)
type Client struct {
userId string
userName string
userPassword string
tenantId string
tenantName string
accessToken string
accessTokenExp time.Time
accessTokenMtx sync.Mutex
client *resty.Client
}
func NewClient(userId, userName, userPassword, tenantId, tenantName string) (*Client, error) {
if userId == "" && userName == "" {
return nil, fmt.Errorf("sdkerr: unset userId or userName")
}
if userPassword == "" {
return nil, fmt.Errorf("sdkerr: unset userPassword")
}
if tenantId == "" || tenantName == "" {
return nil, fmt.Errorf("sdkerr: unset tenantId or tenantName")
}
client := &Client{
userId: userId,
userName: userName,
userPassword: userPassword,
tenantId: tenantId,
tenantName: tenantName,
}
client.client = resty.New().
SetHeader("Accept", "application/json").
SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", app.AppUserAgent).
SetPreRequestHook(func(c *resty.Client, req *http.Request) error {
if client.accessToken != "" {
req.Header.Set("X-Auth-Token", client.accessToken)
}
return nil
})
return client, nil
}
func (c *Client) SetTimeout(timeout time.Duration) *Client {
c.client.SetTimeout(timeout)
return c
}
func (c *Client) SetTLSConfig(config *tls.Config) *Client {
c.client.SetTLSClientConfig(config)
return c
}
func (c *Client) newRequest(method string, path string) (*resty.Request, error) {
if method == "" {
return nil, fmt.Errorf("sdkerr: unset method")
}
if path == "" {
return nil, fmt.Errorf("sdkerr: unset path")
}
req := c.client.R()
req.Method = method
req.URL = path
return req, nil
}
func (c *Client) doRequest(req *resty.Request) (*resty.Response, error) {
if req == nil {
return nil, fmt.Errorf("sdkerr: nil request")
}
// WARN:
// PLEASE DO NOT USE `req.SetResult` or `req.SetError` HERE! USE `doRequestWithResult` INSTEAD.
resp, err := req.Send()
if err != nil {
return resp, fmt.Errorf("sdkerr: failed to send request: %w", err)
} else if resp.IsError() {
return resp, fmt.Errorf("sdkerr: unexpected status code: %d (resp: %s)", resp.StatusCode(), resp.String())
}
return resp, nil
}
func (c *Client) doRequestWithResult(req *resty.Request, res sdkResponse) (*resty.Response, error) {
if req == nil {
return nil, fmt.Errorf("sdkerr: nil request")
}
resp, err := c.doRequest(req)
if err != nil {
if resp != nil {
json.Unmarshal(resp.Body(), &res)
}
return resp, err
}
if len(resp.Body()) != 0 {
if err := json.Unmarshal(resp.Body(), &res); err != nil {
return resp, fmt.Errorf("sdkerr: failed to unmarshal response: %w (resp: %s)", err, resp.String())
} else {
if tcode := res.GetCode(); tcode == 0 {
return resp, fmt.Errorf("sdkerr: code='%d', error='%s'", tcode, res.GetError())
}
}
}
return resp, nil
}
func (c *Client) ensureAccessTokenExists() error {
c.accessTokenMtx.Lock()
defer c.accessTokenMtx.Unlock()
if c.accessToken != "" && c.accessTokenExp.After(time.Now()) {
return nil
}
httpreq, err := c.newRequest(http.MethodPost, fmt.Sprintf("%s/v3/auth/tokens", identityBaseURL))
if err != nil {
return err
} else {
authUserParams := map[string]string{"password": c.userPassword}
if c.userId != "" {
authUserParams["id"] = c.userId
}
if c.userName != "" {
authUserParams["name"] = c.userName
}
authProjectParams := map[string]string{}
if c.tenantId != "" {
authProjectParams["id"] = c.tenantId
}
if c.tenantName != "" {
authProjectParams["name"] = c.tenantName
}
httpreq.SetBody(map[string]any{
"auth": map[string]any{
"identity": map[string]any{
"methods": []string{"password"},
"password": map[string]any{
"user": authUserParams,
},
"scope": map[string]any{
"project": authProjectParams,
},
},
},
})
}
type createAuthTokenResponse struct {
sdkResponseBase
Token *struct {
IssuedAt string `json:"issued_at"`
ExpiresAt string `json:"expires_at"`
} `json:"token,omitempty"`
}
result := &createAuthTokenResponse{}
if httpresp, err := c.doRequestWithResult(httpreq, result); err != nil {
return err
} else if code := result.GetCode(); code != 0 {
return fmt.Errorf("sdkerr: failed to get conoha access token: code='%d', error='%s'", code, result.GetError())
} else {
token := httpresp.Header().Get("X-Subject-Token")
if token == "" {
return fmt.Errorf("sdkerr: api error: received empty auth token")
}
tokenExp, err := time.Parse(time.RFC3339Nano, result.Token.ExpiresAt)
if err != nil {
return fmt.Errorf("sdkerr: api error: received invalid auth token expiration: %w", err)
}
c.accessToken = token
c.accessTokenExp = tokenExp
}
return nil
}
+10
View File
@@ -0,0 +1,10 @@
package v3
import "fmt"
const region = "c3j1"
var (
identityBaseURL = fmt.Sprintf("https://identity.%s.conoha.io", region)
dnsBaseURL = fmt.Sprintf("https://dns-service.%s.conoha.io", region)
)
+36
View File
@@ -0,0 +1,36 @@
package v3
type sdkResponse interface {
GetCode() int
GetError() string
}
type sdkResponseBase struct {
Code *int `json:"code,omitempty"`
Error *string `json:"error,omitempty"`
}
func (r *sdkResponseBase) GetCode() int {
if r.Code == nil {
return 0
}
return *r.Code
}
func (r *sdkResponseBase) GetError() string {
if r.Error == nil {
return ""
}
return *r.Error
}
var _ sdkResponse = (*sdkResponseBase)(nil)
type DnsDomainRecord struct {
UUID string `json:"uuid"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
+4 -4
View File
@@ -8,10 +8,10 @@ import (
)
type SSLInstallSSLRequest struct {
Domain *string `url:"domain,omitempty"`
Cert *string `url:"cert,omitempty"`
Key *string `url:"key,omitempty"`
CABundle *string `url:"cabundle,omitempty"`
Domain *string `json:"domain,omitempty" url:"domain,omitempty"`
Cert *string `json:"cert,omitempty" url:"cert,omitempty"`
Key *string `json:"key,omitempty" url:"key,omitempty"`
CABundle *string `json:"cabundle,omitempty" url:"cabundle,omitempty"`
}
type SSLInstallSSLResponse struct {