diff --git a/internal/certacme/certifiers/sp_conohavps.go b/internal/certacme/certifiers/sp_conohavps.go new file mode 100644 index 000000000..35f6ec212 --- /dev/null +++ b/internal/certacme/certifiers/sp_conohavps.go @@ -0,0 +1,43 @@ +package certifiers + +import ( + "fmt" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/pkg/core" + conohavpsv2 "github.com/certimate-go/certimate/pkg/core/certifier/challengers/dns01/conohavpsv2" + conohavpsv3 "github.com/certimate-go/certimate/pkg/core/certifier/challengers/dns01/conohavpsv3" + xmaps "github.com/certimate-go/certimate/pkg/utils/maps" +) + +func init() { + ACMEDns01Registries.MustRegister(domain.ACMEDns01ProviderTypeConoHaVPS, func(options *ProviderFactoryOptions) (core.ACMEChallenger, error) { + credentials := domain.AccessConfigForConoHaVPS{} + if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + switch credentials.ApiVersion { + case "2", "2.0", "v2", "v2.0": + return conohavpsv2.NewChallenger(&conohavpsv2.ChallengerConfig{ + ApiUserName: credentials.ApiUserName, + ApiPassword: credentials.ApiPassword, + TenantId: credentials.TenantId, + DnsPropagationTimeout: options.DnsPropagationTimeout, + DnsTTL: options.DnsTTL, + }) + case "3", "3.0", "v3", "v3.0": + return conohavpsv3.NewChallenger(&conohavpsv3.ChallengerConfig{ + ApiUserId: credentials.ApiUserId, + ApiUserName: credentials.ApiUserName, + ApiPassword: credentials.ApiPassword, + TenantId: credentials.TenantId, + TenantName: credentials.TenantName, + DnsPropagationTimeout: options.DnsPropagationTimeout, + DnsTTL: options.DnsTTL, + }) + default: + return nil, fmt.Errorf("conohavps: unsupported api version: '%s'", credentials.ApiVersion) + } + }) +} diff --git a/internal/domain/access.go b/internal/domain/access.go index b0e234db8..c6491bcd4 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -160,6 +160,15 @@ type AccessConfigForCMCCCloud struct { AccessKeySecret string `json:"accessKeySecret"` } +type AccessConfigForConoHaVPS struct { + ApiVersion string `json:"apiVersion"` + ApiUserId string `json:"apiUserId,omitempty"` + ApiUserName string `json:"apiUserName"` + ApiPassword string `json:"apiPassword"` + TenantId string `json:"tenantId"` + TenantName string `json:"tenantName,omitempty"` +} + type AccessConfigForConstellix struct { ApiKey string `json:"apiKey"` SecretKey string `json:"secretKey"` diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 328a5feed..581d10fca 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -39,6 +39,7 @@ const ( AccessProviderTypeCloudflare = AccessProviderType("cloudflare") AccessProviderTypeClouDNS = AccessProviderType("cloudns") AccessProviderTypeCMCCCloud = AccessProviderType("cmcccloud") + AccessProviderTypeConoHaVPS = AccessProviderType("conohavps") AccessProviderTypeConstellix = AccessProviderType("constellix") AccessProviderTypeCPanel = AccessProviderType("cpanel") AccessProviderTypeCTCCCloud = AccessProviderType("ctcccloud") @@ -202,6 +203,7 @@ const ( ACMEDns01ProviderTypeClouDNS = ACMEDns01ProviderType(AccessProviderTypeClouDNS) ACMEDns01ProviderTypeCMCCCloud = ACMEDns01ProviderType(AccessProviderTypeCMCCCloud) // 兼容旧值,等同于 [ACMEDns01ProviderTypeCMCCCloudDNS] ACMEDns01ProviderTypeCMCCCloudDNS = ACMEDns01ProviderType(AccessProviderTypeCMCCCloud + "-dns") + ACMEDns01ProviderTypeConoHaVPS = ACMEDns01ProviderType(AccessProviderTypeConoHaVPS) ACMEDns01ProviderTypeConstellix = ACMEDns01ProviderType(AccessProviderTypeConstellix) ACMEDns01ProviderTypeCPanel = ACMEDns01ProviderType(AccessProviderTypeCPanel) ACMEDns01ProviderTypeCTCCCloud = ACMEDns01ProviderType(AccessProviderTypeCTCCCloud) // 兼容旧值,等同于 [ACMEDns01ProviderTypeCTCCCloudSmartDNS] diff --git a/pkg/core/certifier/challengers/dns01/conohavpsv2/conohavpsv2.go b/pkg/core/certifier/challengers/dns01/conohavpsv2/conohavpsv2.go new file mode 100644 index 000000000..61c73c918 --- /dev/null +++ b/pkg/core/certifier/challengers/dns01/conohavpsv2/conohavpsv2.go @@ -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 +} diff --git a/pkg/core/certifier/challengers/dns01/conohavpsv3/conohavpsv3.go b/pkg/core/certifier/challengers/dns01/conohavpsv3/conohavpsv3.go new file mode 100644 index 000000000..a69d2a558 --- /dev/null +++ b/pkg/core/certifier/challengers/dns01/conohavpsv3/conohavpsv3.go @@ -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 +} diff --git a/pkg/core/certifier/challengers/dns01/conohavpsv3/internal/lego.go b/pkg/core/certifier/challengers/dns01/conohavpsv3/internal/lego.go new file mode 100644 index 000000000..103efe19d --- /dev/null +++ b/pkg/core/certifier/challengers/dns01/conohavpsv3/internal/lego.go @@ -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) +} diff --git a/pkg/core/certifier/challengers/dns01/ctcccloud/internal/lego.go b/pkg/core/certifier/challengers/dns01/ctcccloud/internal/lego.go index e6b5a99e9..2d9dbd2e2 100644 --- a/pkg/core/certifier/challengers/dns01/ctcccloud/internal/lego.go +++ b/pkg/core/certifier/challengers/dns01/ctcccloud/internal/lego.go @@ -32,9 +32,9 @@ type Config struct { AccessKeyId string SecretAccessKey string + TTL int PropagationTimeout time.Duration PollingInterval time.Duration - TTL int HTTPTimeout time.Duration } diff --git a/pkg/core/certifier/challengers/dns01/dynv6/internal/lego.go b/pkg/core/certifier/challengers/dns01/dynv6/internal/lego.go index f9ce5555d..15a6d898d 100644 --- a/pkg/core/certifier/challengers/dns01/dynv6/internal/lego.go +++ b/pkg/core/certifier/challengers/dns01/dynv6/internal/lego.go @@ -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 } } diff --git a/pkg/core/certifier/challengers/dns01/qingcloud/internal/lego.go b/pkg/core/certifier/challengers/dns01/qingcloud/internal/lego.go index 5f83b0ecc..1c33c456b 100644 --- a/pkg/core/certifier/challengers/dns01/qingcloud/internal/lego.go +++ b/pkg/core/certifier/challengers/dns01/qingcloud/internal/lego.go @@ -32,9 +32,9 @@ type Config struct { AccessKey string AccessSecret string + TTL int PropagationTimeout time.Duration PollingInterval time.Duration - TTL int HTTPTimeout time.Duration } diff --git a/pkg/sdk3rd/conoha/vps/v3/api_dns_create_record.go b/pkg/sdk3rd/conoha/vps/v3/api_dns_create_record.go new file mode 100644 index 000000000..dd27c953a --- /dev/null +++ b/pkg/sdk3rd/conoha/vps/v3/api_dns_create_record.go @@ -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 +} diff --git a/pkg/sdk3rd/conoha/vps/v3/api_dns_delete_record.go b/pkg/sdk3rd/conoha/vps/v3/api_dns_delete_record.go new file mode 100644 index 000000000..002806e4e --- /dev/null +++ b/pkg/sdk3rd/conoha/vps/v3/api_dns_delete_record.go @@ -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 +} diff --git a/pkg/sdk3rd/conoha/vps/v3/api_dns_get_domains_list.go b/pkg/sdk3rd/conoha/vps/v3/api_dns_get_domains_list.go new file mode 100644 index 000000000..758cccf50 --- /dev/null +++ b/pkg/sdk3rd/conoha/vps/v3/api_dns_get_domains_list.go @@ -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 +} diff --git a/pkg/sdk3rd/conoha/vps/v3/client.go b/pkg/sdk3rd/conoha/vps/v3/client.go new file mode 100644 index 000000000..5ca9a67c1 --- /dev/null +++ b/pkg/sdk3rd/conoha/vps/v3/client.go @@ -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 +} diff --git a/pkg/sdk3rd/conoha/vps/v3/endpoint.go b/pkg/sdk3rd/conoha/vps/v3/endpoint.go new file mode 100644 index 000000000..4330d533b --- /dev/null +++ b/pkg/sdk3rd/conoha/vps/v3/endpoint.go @@ -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) +) diff --git a/pkg/sdk3rd/conoha/vps/v3/types.go b/pkg/sdk3rd/conoha/vps/v3/types.go new file mode 100644 index 000000000..fcfcea8d4 --- /dev/null +++ b/pkg/sdk3rd/conoha/vps/v3/types.go @@ -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"` +} diff --git a/pkg/sdk3rd/cpanel/api_ssl_install_ssl.go b/pkg/sdk3rd/cpanel/api_ssl_install_ssl.go index c08067229..4527396c1 100644 --- a/pkg/sdk3rd/cpanel/api_ssl_install_ssl.go +++ b/pkg/sdk3rd/cpanel/api_ssl_install_ssl.go @@ -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 { diff --git a/ui/public/imgs/providers/conohavps.svg b/ui/public/imgs/providers/conohavps.svg new file mode 100644 index 000000000..10841d52e --- /dev/null +++ b/ui/public/imgs/providers/conohavps.svg @@ -0,0 +1 @@ + diff --git a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx index 930c3242a..04d4e316e 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx @@ -28,6 +28,7 @@ import AccessConfigFieldsProviderCdnfly from "./AccessConfigFieldsProviderCdnfly import AccessConfigFieldsProviderCloudflare from "./AccessConfigFieldsProviderCloudflare"; import AccessConfigFieldsProviderClouDNS from "./AccessConfigFieldsProviderClouDNS"; import AccessConfigFieldsProviderCMCCCloud from "./AccessConfigFieldsProviderCMCCCloud"; +import AccessConfigFieldsProviderConoHaVPS from "./AccessConfigFieldsProviderConoHaVPS"; import AccessConfigFieldsProviderConstellix from "./AccessConfigFieldsProviderConstellix"; import AccessConfigFieldsProviderCPanel from "./AccessConfigFieldsProviderCPanel"; import AccessConfigFieldsProviderCTCCCloud from "./AccessConfigFieldsProviderCTCCCloud"; @@ -146,6 +147,7 @@ const providerComponentMap: Partial { + const { i18n, t } = useTranslation(); + + const { parentNamePath } = useFormNestedFieldsContext(); + const formSchema = z.object({ + [parentNamePath]: getSchema({ i18n }), + }); + const formRule = createSchemaFieldRule(formSchema); + const formInst = Form.useFormInstance>(); + const initialValues = getInitialValues(); + + const fieldApiVersion = Form.useWatch([parentNamePath, "apiVersion"], formInst); + + return ( + <> + + + + + } + > + + + + } + > + + + + + + } + > + + + + } + > + + + + } + > + + + + } + > + + + + } + > + + + + + + ); +}; + +const getInitialValues = (): Nullish>> => { + return { + apiVersion: API_VERSION_V3, + apiUserId: "", + apiUserName: "", + apiPassword: "", + tenantId: "", + tenantName: "", + }; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) => { + const { t } = i18n; + + return z + .object({ + apiVersion: z.enum([API_VERSION_V2, API_VERSION_V3]), + apiUserId: z.string().nullish(), + apiUserName: z.string().nullish(), + apiPassword: z.string().nonempty(), + tenantId: z.string().nullish(), + tenantName: z.string().nullish(), + }) + .superRefine((values, ctx) => { + switch (values.apiVersion) { + case API_VERSION_V2: + { + const scUserName = z.string().nonempty(); + const spUserName = scUserName.safeParse(values.apiUserName); + if (!spUserName.success) { + ctx.addIssue({ + code: "custom", + message: z.treeifyError(spUserName.error).errors.join(), + path: ["apiUserName"], + }); + } + + const scTenantId = z.string().nonempty(); + const spTenantId = scTenantId.safeParse(values.tenantId); + if (!spTenantId.success) { + ctx.addIssue({ + code: "custom", + message: z.treeifyError(spTenantId.error).errors.join(), + path: ["tenantId"], + }); + } + } + break; + + case API_VERSION_V3: + { + const scUserId = z.string().nonempty(); + const spUserId = scUserId.safeParse(values.apiUserId); + const scUserName = z.string().nonempty(); + const spUserName = scUserName.safeParse(values.apiUserName); + if (!spUserId.success && !spUserName.success) { + ctx.addIssue({ + code: "custom", + message: z.treeifyError(spUserName.error).errors.join(), + path: ["apiUserName"], + }); + } else if (spUserId.success && spUserName.success) { + ctx.addIssue({ + code: "custom", + message: t("access.form.conohavps_api_v3_user_id.errmsg.conflict"), + path: ["apiUserId"], + }); + ctx.addIssue({ + code: "custom", + message: t("access.form.conohavps_api_v3_user_name.errmsg.conflict"), + path: ["apiUserName"], + }); + } + + const scTenantId = z.string().nonempty(); + const spTenantId = scTenantId.safeParse(values.tenantId); + const scTenantName = z.string().nonempty(); + const spTenantName = scTenantName.safeParse(values.tenantName); + if (!spTenantId.success && !spTenantName.success) { + ctx.addIssue({ + code: "custom", + message: z.treeifyError(spTenantId.error).errors.join(), + path: ["tenantId"], + }); + } else if (spTenantId.success && spTenantName.success) { + ctx.addIssue({ + code: "custom", + message: t("access.form.conohavps_api_v3_project_id.errmsg.conflict"), + path: ["tenantId"], + }); + ctx.addIssue({ + code: "custom", + message: t("access.form.conohavps_api_v3_project_name.errmsg.conflict"), + path: ["tenantName"], + }); + } + } + break; + } + }); +}; + +const _default = Object.assign(AccessConfigFormFieldsProviderConoHaVPS, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index e56de0046..fea37e264 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -41,6 +41,7 @@ export const ACCESS_PROVIDERS = Object.freeze({ CLOUDFLARE: "cloudflare", CLOUDNS: "cloudns", CMCCCLOUD: "cmcccloud", + CONOHAVPS: "conohavps", CONSTELLIX: "constellix", CPANEL: "cpanel", CTCCCLOUD: "ctcccloud", @@ -213,6 +214,7 @@ export const accessProvidersMap: Maphttps://ecloud.10086.cn/op-help-center/doc/article/49739" }, + "conohavps_api_version": { + "label": "ConoHa VPS version", + "placeholder": "Please select ConoHa VPS version" + }, + "conohavps_api_v2_username": { + "label": "ConoHa VPS API username", + "placeholder": "Please enter ConoHa VPS 2.0 API username", + "tooltip": "For more information, see https://doc.conoha.jp/reference/api-vps2/api-guideline-vps2/api-guideline-v2/" + }, + "conohavps_api_v2_password": { + "label": "ConoHa VPS API password", + "placeholder": "Please enter ConoHa VPS 2.0 API password", + "tooltip": "For more information, see https://doc.conoha.jp/reference/api-vps2/api-guideline-vps2/api-guideline-v2/" + }, + "conohavps_api_v2_tenant_id": { + "label": "ConoHa VPS tenant ID", + "placeholder": "Please enter ConoHa VPS 2.0 tenant ID", + "tooltip": "For more information, see https://doc.conoha.jp/reference/api-vps2/api-identity-vps2/identity-post_tokens-v2/" + }, + "conohavps_api_v3_user_id": { + "label": "ConoHa VPS API user ID", + "placeholder": "Please enter ConoHa VPS 3.0 API user ID", + "errmsg": { + "conflict": "Conflict with the \"API user name\", please retain only one field" + }, + "tooltip": "For more information, see https://doc.conoha.jp/reference/api-vps3/api-cp-vps3/cp-create_api_user-v3/" + }, + "conohavps_api_v3_user_name": { + "label": "ConoHa VPS API user name", + "placeholder": "Please enter ConoHa VPS 3.0 API user name", + "errmsg": { + "conflict": "Conflict with the \"API user ID\", please retain only one field" + }, + "tooltip": "For more information, see https://doc.conoha.jp/reference/api-vps3/api-cp-vps3/cp-create_api_user-v3/" + }, + "conohavps_api_v3_password": { + "label": "ConoHa VPS API password", + "placeholder": "Please enter ConoHa VPS 3.0 API password", + "tooltip": "For more information, see https://doc.conoha.jp/reference/api-vps3/api-cp-vps3/cp-create_api_user-v3/" + }, + "conohavps_api_v3_project_id": { + "label": "ConoHa VPS project ID", + "placeholder": "Please enter ConoHa VPS 3.0 tenant project ID", + "errmsg": { + "conflict": "Conflict with the \"project name\", please retain only one field" + }, + "tooltip": "For more information, see https://doc.conoha.jp/reference/api-vps3/api-cp-vps3/cp-get_api_info-v3/" + }, + "conohavps_api_v3_project_name": { + "label": "ConoHa VPS project name", + "placeholder": "Please enter ConoHa VPS 3.0 tenant project name", + "errmsg": { + "conflict": "Conflict with the \"project ID\", please retain only one field" + }, + "tooltip": "For more information, see https://doc.conoha.jp/reference/api-vps3/api-cp-vps3/cp-get_api_info-v3/" + }, "constellix_api_key": { "label": "Constellix API key", "placeholder": "Please enter Constellix API key", diff --git a/ui/src/i18n/resources/en/nls.provider.json b/ui/src/i18n/resources/en/nls.provider.json index 828bd211f..81f28df36 100644 --- a/ui/src/i18n/resources/en/nls.provider.json +++ b/ui/src/i18n/resources/en/nls.provider.json @@ -95,6 +95,7 @@ "cloudns": "ClouDNS", "cmcccloud": "China Mobile ECloud", "cmcccloud_dns": "China Mobile ECloud - DNS", + "conohavps": "ConoHa VPS", "constellix": "Constellix", "cpanel": "cPanel", "ctcccloud": "China Telecom StateCloud", diff --git a/ui/src/i18n/resources/zh/nls.access.json b/ui/src/i18n/resources/zh/nls.access.json index b778ee1b4..c62fea365 100644 --- a/ui/src/i18n/resources/zh/nls.access.json +++ b/ui/src/i18n/resources/zh/nls.access.json @@ -375,6 +375,62 @@ "placeholder": "请输入移动云 AccessKeySecret", "tooltip": "这是什么?请参阅 https://ecloud.10086.cn/op-help-center/doc/article/49739" }, + "conohavps_api_version": { + "label": "ConoHa VPS 版本", + "placeholder": "请选择 ConoHa VPS 版本" + }, + "conohavps_api_v2_username": { + "label": "ConoHa VPS API 用户名", + "placeholder": "请输入 ConoHa VPS 2.0 API 用户名", + "tooltip": "这是什么?请参阅 https://doc.conoha.jp/reference/api-vps2/api-guideline-vps2/api-guideline-v2/" + }, + "conohavps_api_v2_password": { + "label": "ConoHa VPS API 密码", + "placeholder": "请输入 ConoHa VPS 2.0 API 密码", + "tooltip": "这是什么?请参阅 https://doc.conoha.jp/reference/api-vps2/api-guideline-vps2/api-guideline-v2/" + }, + "conohavps_api_v2_tenant_id": { + "label": "ConoHa VPS 租户 ID", + "placeholder": "请输入 ConoHa VPS 2.0 租户 ID", + "tooltip": "这是什么?请参阅 https://doc.conoha.jp/reference/api-vps2/api-identity-vps2/identity-post_tokens-v2/" + }, + "conohavps_api_v3_user_id": { + "label": "ConoHa VPS API 用户 ID", + "placeholder": "请输入 ConoHa VPS 3.0 API 用户 ID", + "errmsg": { + "conflict": "与字段「API 用户名」冲突,请二选一" + }, + "tooltip": "这是什么?请参阅 https://doc.conoha.jp/reference/api-vps3/api-cp-vps3/cp-create_api_user-v3/" + }, + "conohavps_api_v3_user_name": { + "label": "ConoHa VPS API 用户名", + "placeholder": "请输入 ConoHa VPS 3.0 API 用户名", + "errmsg": { + "conflict": "与字段「API 用户 ID」冲突,请二选一" + }, + "tooltip": "这是什么?请参阅 https://doc.conoha.jp/reference/api-vps3/api-cp-vps3/cp-create_api_user-v3/" + }, + "conohavps_api_v3_password": { + "label": "ConoHa VPS API 密码", + "placeholder": "请输入 ConoHa VPS 3.0 API 密码", + "tooltip": "这是什么?请参阅 https://doc.conoha.jp/reference/api-vps3/api-cp-vps3/cp-create_api_user-v3/" + }, + "conohavps_api_v3_project_id": { + "label": "ConoHa VPS 项目 ID", + "placeholder": "请输入 ConoHa VPS 3.0 租户项目 ID", + "errmsg": { + "conflict": "与字段「项目名称」冲突,请二选一" + }, + "tooltip": "这是什么?请参阅 https://doc.conoha.jp/reference/api-vps3/api-cp-vps3/cp-get_api_info-v3/" + }, + "conohavps_api_v3_project_name": { + "label": "ConoHa VPS 项目名称", + "placeholder": "请输入 ConoHa VPS 3.0 租户项目名称", + "errmsg": { + "conflict": "与字段「项目 ID」冲突,请二选一" + }, + "tooltip": "这是什么?请参阅 https://doc.conoha.jp/reference/api-vps3/api-cp-vps3/cp-get_api_info-v3/" + }, "constellix_api_key": { "label": "Constellix API Key", "placeholder": "请输入 Constellix API Key", diff --git a/ui/src/i18n/resources/zh/nls.provider.json b/ui/src/i18n/resources/zh/nls.provider.json index af694ba59..6015de294 100644 --- a/ui/src/i18n/resources/zh/nls.provider.json +++ b/ui/src/i18n/resources/zh/nls.provider.json @@ -95,6 +95,7 @@ "cloudns": "ClouDNS", "cmcccloud": "移动云", "cmcccloud_dns": "移动云 - 云解析 DNS", + "conohavps": "ConoHa VPS", "constellix": "Constellix", "cpanel": "cPanel", "ctcccloud": "天翼云",