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": "天翼云",