Add Synology DSM certificate deployer integration

This commit is contained in:
Masterain
2026-01-15 22:48:19 -08:00
committed by GitHub
parent 5d2768f066
commit e9b6f54598
29 changed files with 1211 additions and 0 deletions
+2
View File
@@ -58,6 +58,7 @@ require (
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.35.0
github.com/povsister/scp v0.0.0-20250701154629-777cf82de5df
github.com/pquerna/otp v1.5.0
github.com/qiniu/go-sdk/v7 v7.25.5
github.com/samber/lo v1.52.0
github.com/spf13/cobra v1.10.2
@@ -194,6 +195,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+4
View File
@@ -247,6 +247,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
github.com/byteplus-sdk/byteplus-sdk-golang v1.0.61 h1:IJHXAInb/ALRosopHPzXH/x2mt7LyfaawILEHYZHRRo=
@@ -764,6 +766,8 @@ github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndr
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/povsister/scp v0.0.0-20250701154629-777cf82de5df h1:zEgSHrxo8f6hGG1xCaqunfBq8hlfDmFd1JM0QXiQi7o=
github.com/povsister/scp v0.0.0-20250701154629-777cf82de5df/go.mod h1:CiJNEeV6v0tUCNul/+gTjl+FgjfImoiuptJB9AEzqjE=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
@@ -0,0 +1,30 @@
package deployers
import (
"fmt"
"github.com/certimate-go/certimate/internal/domain"
"github.com/certimate-go/certimate/pkg/core/deployer"
"github.com/certimate-go/certimate/pkg/core/deployer/providers/synologydsm"
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
)
func init() {
Registries.MustRegister(domain.DeploymentProviderTypeSynologyDSM, func(options *ProviderFactoryOptions) (deployer.Provider, error) {
credentials := domain.AccessConfigForSynologyDSM{}
if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
provider, err := synologydsm.NewDeployer(&synologydsm.DeployerConfig{
ServerUrl: credentials.ServerUrl,
Username: credentials.Username,
Password: credentials.Password,
TotpSecret: credentials.TotpSecret,
AllowInsecureConnections: credentials.AllowInsecureConnections,
CertificateIdOrDescription: xmaps.GetString(options.ProviderExtendedConfig, "certificateIdOrDesc"),
IsDefault: xmaps.GetBool(options.ProviderExtendedConfig, "isDefault"),
})
return provider, err
})
}
+8
View File
@@ -516,6 +516,14 @@ type AccessConfigForSSLCom struct {
AccessConfigForACMEExternalAccountBinding
}
type AccessConfigForSynologyDSM struct {
ServerUrl string `json:"serverUrl"`
Username string `json:"username"`
Password string `json:"password"`
TotpSecret string `json:"totpSecret,omitempty"`
AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"`
}
type AccessConfigForTechnitiumDNS struct {
ServerUrl string `json:"serverUrl"`
ApiToken string `json:"apiToken"`
+2
View File
@@ -103,6 +103,7 @@ const (
AccessProviderTypeSpaceship = AccessProviderType("spaceship")
AccessProviderTypeSSH = AccessProviderType("ssh")
AccessProviderTypeSSLCOM = AccessProviderType("sslcom")
AccessProviderTypeSynologyDSM = AccessProviderType("synologydsm")
AccessProviderTypeTechnitiumDNS = AccessProviderType("technitiumdns")
AccessProviderTypeTelegramBot = AccessProviderType("telegrambot")
AccessProviderTypeTencentCloud = AccessProviderType("tencentcloud")
@@ -330,6 +331,7 @@ const (
DeploymentProviderTypeRatPanelConsole = DeploymentProviderType(AccessProviderTypeRatPanel + "-console")
DeploymentProviderTypeSafeLine = DeploymentProviderType(AccessProviderTypeSafeLine)
DeploymentProviderTypeSSH = DeploymentProviderType(AccessProviderTypeSSH)
DeploymentProviderTypeSynologyDSM = DeploymentProviderType(AccessProviderTypeSynologyDSM)
DeploymentProviderTypeTencentCloudCDN = DeploymentProviderType(AccessProviderTypeTencentCloud + "-cdn")
DeploymentProviderTypeTencentCloudCLB = DeploymentProviderType(AccessProviderTypeTencentCloud + "-clb")
DeploymentProviderTypeTencentCloudCOS = DeploymentProviderType(AccessProviderTypeTencentCloud + "-cos")
@@ -0,0 +1,232 @@
package synologydsm
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"time"
"github.com/pquerna/otp/totp"
"github.com/samber/lo"
"github.com/certimate-go/certimate/pkg/core/deployer"
dsmsdk "github.com/certimate-go/certimate/pkg/sdk3rd/synologydsm"
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
xwait "github.com/certimate-go/certimate/pkg/utils/wait"
)
type DeployerConfig struct {
// 群晖 DSM 服务地址。
ServerUrl string `json:"serverUrl"`
// 群晖 DSM 用户名。
Username string `json:"username"`
// 群晖 DSM 用户密码。
Password string `json:"password"`
// 群晖 DSM 2FA TOTP 密钥。
TotpSecret string `json:"totpSecret,omitempty"`
// 是否允许不安全的连接。
AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"`
// 证书 ID 或描述。
// 选填。零值时表示新建证书;否则表示更新证书。
CertificateIdOrDescription string `json:"certificateIdOrDesc,omitempty"`
// 是否设为默认证书。
IsDefault bool `json:"isDefault,omitempty"`
}
type Deployer struct {
config *DeployerConfig
logger *slog.Logger
sdkClient *dsmsdk.Client
}
var _ deployer.Provider = (*Deployer)(nil)
func NewDeployer(config *DeployerConfig) (*Deployer, error) {
if config == nil {
return nil, errors.New("the configuration of the deployer provider is nil")
}
client, err := createSDKClient(config.ServerUrl, config.AllowInsecureConnections)
if err != nil {
return nil, fmt.Errorf("could not create client: %w", err)
}
return &Deployer{
config: config,
logger: slog.Default(),
sdkClient: client,
}, nil
}
func (d *Deployer) SetLogger(logger *slog.Logger) {
if logger == nil {
d.logger = slog.Default()
} else {
d.logger = logger
}
}
func (d *Deployer) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*deployer.DeployResult, error) {
// 提取服务器证书和中间证书
serverCertPEM, intermediateCertPEM, err := xcert.ExtractCertificatesFromPEM(certPEM)
if err != nil {
return nil, fmt.Errorf("failed to extract certs: %w", err)
}
// 如果启用了 TOTP,则等到下一个时间窗口后生成 OTP 动态密码
var otpCode string
if d.config.TotpSecret != "" {
now := time.Now()
wait := time.Duration(30-now.Unix()%30) * time.Second
if wait > 0 {
wait = wait + 1*time.Second
d.logger.Info("waiting for the next TOTP time step ...", slog.Int("wait", int(wait.Seconds())))
xwait.DelayWithContext(ctx, wait)
}
now = time.Now()
otpCodeStr, err := totp.GenerateCode(d.config.TotpSecret, now)
if err != nil {
return nil, fmt.Errorf("failed to generate TOTP code: %w", err)
}
otpCode = otpCodeStr
}
// 登录到群晖 DSM
loginReq := &dsmsdk.LoginRequest{
Account: d.config.Username,
Password: d.config.Password,
OtpCode: otpCode,
}
loginResp, err := d.sdkClient.Login(loginReq)
d.logger.Debug("sdk request 'SYNO.API.Auth:login'", slog.Any("request", loginReq), slog.Any("response", loginResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.API.Auth:login': %w", err)
}
defer func() {
logoutResp, _ := d.sdkClient.Logout()
d.logger.Debug("sdk request 'SYNO.API.Auth:logout'", slog.Any("response", logoutResp))
}()
// 如果原证书 ID 或描述为空,则创建证书;否则更新证书。
if d.config.CertificateIdOrDescription == "" {
// 导入证书
importCertificateReq := &dsmsdk.ImportCertificateRequest{
ID: "",
Description: fmt.Sprintf("certimate-%d", time.Now().UnixMilli()),
Key: privkeyPEM,
Cert: serverCertPEM,
InterCert: intermediateCertPEM,
AsDefault: d.config.IsDefault,
}
importCertificateResp, err := d.sdkClient.ImportCertificate(importCertificateReq)
d.logger.Debug("sdk request 'SYNO.Core.Certificate:import'", slog.Any("request", importCertificateReq), slog.Any("response", importCertificateResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.Core.Certificate:import': %w", err)
}
} else {
// 查找证书列表,找到已有证书
var certInfo *dsmsdk.CertificateInfo
listCertificatesResp, err := d.sdkClient.ListCertificates()
d.logger.Debug("sdk request 'SYNO.Core.Certificate.CRT:list'", slog.Any("response", listCertificatesResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.Core.Certificate.CRT:list': %w", err)
} else {
matchedCerts := lo.Filter(listCertificatesResp.Data.Certificates, func(certItem *dsmsdk.CertificateInfo, _ int) bool {
return certItem.ID == d.config.CertificateIdOrDescription
})
if len(matchedCerts) == 0 {
matchedCerts = lo.Filter(listCertificatesResp.Data.Certificates, func(certItem *dsmsdk.CertificateInfo, _ int) bool {
return certItem.Description == d.config.CertificateIdOrDescription
})
}
if len(matchedCerts) == 0 {
return nil, fmt.Errorf("could not find certificate '%s'", d.config.CertificateIdOrDescription)
} else {
if len(matchedCerts) > 1 {
d.logger.Warn("found several certificates matched '%s', using the first one")
}
certInfo = matchedCerts[0]
}
}
// 导入证书
importCertificateReq := &dsmsdk.ImportCertificateRequest{
ID: certInfo.ID,
Description: certInfo.Description,
Key: privkeyPEM,
Cert: serverCertPEM,
InterCert: intermediateCertPEM,
AsDefault: d.config.IsDefault || certInfo.IsDefault,
}
importCertificateResp, err := d.sdkClient.ImportCertificate(importCertificateReq)
d.logger.Debug("sdk request 'SYNO.Core.Certificate:import'", slog.Any("request", importCertificateReq), slog.Any("response", importCertificateResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.Core.Certificate:import': %w", err)
}
}
if d.config.IsDefault {
// 查找证书列表,找到默认证书
listCertificatesResp, err := d.sdkClient.ListCertificates()
d.logger.Debug("sdk request 'SYNO.Core.Certificate.CRT:list'", slog.Any("response", listCertificatesResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.Core.Certificate.CRT:list': %w", err)
} else {
var defaultCertId string
for _, certItem := range listCertificatesResp.Data.Certificates {
if certItem.IsDefault {
defaultCertId = certItem.ID
break
}
}
if defaultCertId != "" {
settings := make([]*dsmsdk.ServiceCertificateSetting, 0)
for _, certItem := range listCertificatesResp.Data.Certificates {
if certItem.ID == defaultCertId {
continue
}
for _, service := range certItem.Services {
settings = append(settings, &dsmsdk.ServiceCertificateSetting{
Service: service,
CertID: defaultCertId,
OldCertID: certItem.ID,
})
}
}
// 应用到所有服务并重启
if len(settings) > 0 {
setServiceCertificateReq := &dsmsdk.SetServiceCertificateRequest{
Settings: settings,
}
setServiceCertificateResp, err := d.sdkClient.SetServiceCertificate(setServiceCertificateReq)
d.logger.Debug("sdk request 'SYNO.Core.Certificate.Service:set'", slog.Any("request", setServiceCertificateReq), slog.Any("response", setServiceCertificateResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.Core.Certificate.Service:set': %w", err)
}
}
}
}
}
return &deployer.DeployResult{}, nil
}
func createSDKClient(serverUrl string, skipTlsVerify bool) (*dsmsdk.Client, error) {
client, err := dsmsdk.NewClient(serverUrl)
if err != nil {
return nil, err
}
if skipTlsVerify {
client.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
}
return client, nil
}
@@ -0,0 +1,90 @@
package synologydsm_test
import (
"context"
"flag"
"fmt"
"os"
"strings"
"testing"
provider "github.com/certimate-go/certimate/pkg/core/deployer/providers/synologydsm"
)
var (
fInputCertPath string
fInputKeyPath string
fServerUrl string
fUsername string
fPassword string
fTotpSecret string
fCertificateIdOrDesc string
fIsDefault bool
)
func init() {
argsPrefix := "SYNOLOGYDSM_"
flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "")
flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "")
flag.StringVar(&fServerUrl, argsPrefix+"SERVERURL", "", "")
flag.StringVar(&fUsername, argsPrefix+"USERNAME", "", "")
flag.StringVar(&fPassword, argsPrefix+"PASSWORD", "", "")
flag.StringVar(&fTotpSecret, argsPrefix+"TOTPSECRET", "", "")
flag.StringVar(&fCertificateIdOrDesc, argsPrefix+"CERTIFICATEIDORDESC", "", "")
flag.BoolVar(&fIsDefault, argsPrefix+"ISDEFAULT", false, "")
}
/*
Shell command to run this test:
go test -v ./synology_dsm_test.go -args \
--SYNOLOGYDSM_INPUTCERTPATH="/path/to/your-input-cert.pem" \
--SYNOLOGYDSM_INPUTKEYPATH="/path/to/your-input-key.pem" \
--SYNOLOGYDSM_SERVERURL="http://127.0.0.1:5000/" \
--SYNOLOGYDSM_USERNAME="admin" \
--SYNOLOGYDSM_PASSWORD="password" \
--SYNOLOGYDSM_CERTIFICATEIDORDESC="your-certificate-id-or-desc" \
--SYNOLOGYDSM_ISDEFAULT=true
*/
func TestDeploy(t *testing.T) {
flag.Parse()
t.Run("Deploy", func(t *testing.T) {
t.Log(strings.Join([]string{
"args:",
fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath),
fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath),
fmt.Sprintf("SERVERURL: %v", fServerUrl),
fmt.Sprintf("USERNAME: %v", fUsername),
fmt.Sprintf("PASSWORD: %v", fPassword),
fmt.Sprintf("TOTPSECRET: %v", fTotpSecret),
fmt.Sprintf("CERTIFICATEIDORDESC: %v", fCertificateIdOrDesc),
fmt.Sprintf("ISDEFAULT: %v", fIsDefault),
}, "\n"))
deployer, err := provider.NewDeployer(&provider.DeployerConfig{
ServerUrl: fServerUrl,
Username: fUsername,
Password: fPassword,
TotpSecret: fTotpSecret,
AllowInsecureConnections: true,
CertificateIdOrDescription: fCertificateIdOrDesc,
IsDefault: fIsDefault,
})
if err != nil {
t.Errorf("err: %+v", err)
return
}
fInputCertData, _ := os.ReadFile(fInputCertPath)
fInputKeyData, _ := os.ReadFile(fInputKeyPath)
res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData))
if err != nil {
t.Errorf("err: %+v", err)
return
}
t.Logf("ok: %v", res)
})
}
+91
View File
@@ -0,0 +1,91 @@
package synologydsm
import (
"fmt"
"net/http"
"net/url"
"strconv"
qs "github.com/google/go-querystring/query"
)
type LoginRequest struct {
Account string `json:"account" url:"account"`
Password string `json:"passwd" url:"passwd"`
OtpCode string `json:"otp_code,omitempty" url:"otp_code,omitempty"`
}
type LoginResponse struct {
sdkResponseBase
Data *struct {
Sid string `json:"sid"`
SynoToken string `json:"synotoken"`
DeviceId string `json:"device_id,omitempty"`
Did string `json:"did,omitempty"`
} `json:"data,omitempty"`
}
func (c *Client) Login(req *LoginRequest) (*LoginResponse, error) {
const AUTH_API_NAME = "SYNO.API.Auth"
if c.authApiPath == "" || c.authApiVersion == 0 {
queryInfoReq := &QueryAPIInfoRequest{
Query: AUTH_API_NAME,
}
queryInfoResp, err := c.QueryAPIInfo(queryInfoReq)
if err != nil {
return nil, fmt.Errorf("sdkerr: failed to query API info: %w", err)
} else {
authApiInfo, ok := queryInfoResp.Data[AUTH_API_NAME]
if !ok {
return nil, fmt.Errorf("sdkerr: failed to query API info: \"%s\" not found", AUTH_API_NAME)
}
c.authApiPath = authApiInfo.Path
c.authApiVersion = authApiInfo.MaxVersion
}
}
params := url.Values{
"api": {AUTH_API_NAME},
"version": {strconv.Itoa(c.authApiVersion)},
"method": {"login"},
"format": {"sid"},
"enable_syno_token": {"yes"},
"enable_device_token": {"yes"},
"device_name": {"Certimate"},
}
values, err := qs.Values(req)
if err != nil {
return nil, err
}
for k := range values {
params.Set(k, values.Get(k))
}
httpreq, err := c.newRequest(http.MethodGet, fmt.Sprintf("/webapi/%s?%s", c.authApiPath, params.Encode()))
if err != nil {
return nil, err
}
result := &LoginResponse{}
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
if result != nil && result.GetErrorCode() > 0 {
errcode := result.GetErrorCode()
errdesc := getAuthErrorDescription(errcode)
return result, fmt.Errorf("sdkerr: code='%d', desc='%s'", errcode, errdesc)
}
return result, err
}
if result.Data.Sid == "" || result.Data.SynoToken == "" {
return result, fmt.Errorf("sdkerr: login succeeded but the sid or synotoken is empty")
}
c.synoTokenMtx.Lock()
defer c.synoTokenMtx.Unlock()
c.sid = result.Data.Sid
c.synoToken = result.Data.SynoToken
return result, nil
}
+44
View File
@@ -0,0 +1,44 @@
package synologydsm
import (
"fmt"
"net/http"
"net/url"
"strconv"
)
type LogoutResponse struct {
sdkResponseBase
}
func (c *Client) Logout() (*LogoutResponse, error) {
if c.sid == "" {
result := &LogoutResponse{}
result.Success = true
return result, nil
}
params := url.Values{
"api": {"SYNO.API.Auth"},
"version": {strconv.Itoa(c.authApiVersion)},
"method": {"logout"},
"_sid": {c.sid},
}
httpreq, err := c.newRequest(http.MethodGet, fmt.Sprintf("/webapi/%s?%s", c.authApiPath, params.Encode()))
if err != nil {
return nil, err
}
result := &LogoutResponse{}
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
return result, err
}
c.synoTokenMtx.Lock()
defer c.synoTokenMtx.Unlock()
c.sid = ""
c.synoToken = ""
return result, nil
}
@@ -0,0 +1,37 @@
package synologydsm
import (
"net/http"
"net/url"
)
type ListCertificatesResponse struct {
sdkResponseBase
Data *struct {
Certificates []*CertificateInfo `json:"certificates"`
} `json:"data,omitempty"`
}
func (c *Client) ListCertificates() (*ListCertificatesResponse, error) {
params := url.Values{
"api": {"SYNO.Core.Certificate.CRT"},
"method": {"list"},
"version": {"1"},
"_sid": {c.sid},
}
httpreq, err := c.newRequest(http.MethodPost, "/webapi/entry.cgi")
if err != nil {
return nil, err
} else {
httpreq.SetHeader("Content-Type", "application/x-www-form-urlencoded")
httpreq.SetFormDataFromValues(params)
}
result := &ListCertificatesResponse{}
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
return result, err
}
return result, nil
}
@@ -0,0 +1,55 @@
package synologydsm
import (
"fmt"
"net/http"
"net/url"
"strings"
)
type ImportCertificateRequest struct {
ID string `json:"id" url:"id"`
Description string `json:"desc" url:"desc"`
Key string `json:"key" url:"key"`
Cert string `json:"cert" url:"cert"`
InterCert string `json:"inter_cert" url:"inter_cert"`
AsDefault bool `json:"as_default" url:"as_default"`
}
type ImportCertificateResponse struct {
sdkResponseBase
Data *struct {
RestartHttpd bool `json:"restart_httpd"`
} `json:"data,omitempty"`
}
func (c *Client) ImportCertificate(req *ImportCertificateRequest) (*ImportCertificateResponse, error) {
params := url.Values{
"api": {"SYNO.Core.Certificate"},
"method": {"import"},
"version": {"1"},
"_sid": {c.sid},
"SynoToken": {c.synoToken},
}
httpreq, err := c.newRequest(http.MethodPost, fmt.Sprintf("/webapi/entry.cgi?%s", params.Encode()))
if err != nil {
return nil, err
} else {
httpreq.SetMultipartField("key", "key.pem", "text/plain", strings.NewReader(req.Key))
httpreq.SetMultipartField("cert", "cert.pem", "text/plain", strings.NewReader(req.Cert))
httpreq.SetMultipartField("inter_cert", "chain.pem", "text/plain", strings.NewReader(req.InterCert))
httpreq.SetMultipartField("id", "", "", strings.NewReader(req.ID))
httpreq.SetMultipartField("desc", "", "", strings.NewReader(req.Description))
if req.AsDefault {
httpreq.SetMultipartField("as_default", "", "", strings.NewReader("true"))
}
}
result := &ImportCertificateResponse{}
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
return result, err
}
return result, nil
}
@@ -0,0 +1,47 @@
package synologydsm
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
type ServiceCertificateSetting struct {
Service *CertificateService `json:"service"`
OldCertID string `json:"old_id"`
CertID string `json:"id"`
}
type SetServiceCertificateRequest struct {
Settings []*ServiceCertificateSetting `json:"settings"`
}
type SetServiceCertificateResponse struct {
sdkResponseBase
}
func (c *Client) SetServiceCertificate(req *SetServiceCertificateRequest) (*SetServiceCertificateResponse, error) {
bsettings, _ := json.Marshal(req.Settings)
params := url.Values{
"api": {"SYNO.Core.Certificate.Service"},
"method": {"set"},
"version": {"1"},
"settings": {string(bsettings)},
}
httpreq, err := c.newRequest(http.MethodPost, fmt.Sprintf("/webapi/entry.cgi?_sid=%s", c.sid))
if err != nil {
return nil, err
} else {
httpreq.SetHeader("Content-Type", "application/x-www-form-urlencoded")
httpreq.SetFormDataFromValues(params)
}
result := &SetServiceCertificateResponse{}
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
return result, err
}
return result, nil
}
+46
View File
@@ -0,0 +1,46 @@
package synologydsm
import (
"fmt"
"net/http"
"net/url"
qs "github.com/google/go-querystring/query"
)
type QueryAPIInfoRequest struct {
Query string `json:"query" url:"query"`
}
type QueryAPIInfoResponse struct {
sdkResponseBase
Data map[string]APIInfo `json:"data,omitempty"`
}
func (c *Client) QueryAPIInfo(req *QueryAPIInfoRequest) (*QueryAPIInfoResponse, error) {
params := url.Values{
"api": {"SYNO.API.Info"},
"version": {"1"},
"method": {"query"},
}
values, err := qs.Values(req)
if err != nil {
return nil, err
}
for k := range values {
params.Set(k, values.Get(k))
}
httpreq, err := c.newRequest(http.MethodGet, fmt.Sprintf("/webapi/query.cgi?%s", params.Encode()))
if err != nil {
return nil, err
}
result := &QueryAPIInfoResponse{}
if _, err := c.doRequestWithResult(httpreq, result); err != nil {
return result, err
}
return result, nil
}
+116
View File
@@ -0,0 +1,116 @@
package synologydsm
import (
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/go-resty/resty/v2"
"github.com/certimate-go/certimate/internal/app"
)
type Client struct {
authApiPath string
authApiVersion int
sid string
synoToken string
synoTokenMtx sync.Mutex
client *resty.Client
}
func NewClient(serverUrl string) (*Client, error) {
if serverUrl == "" {
return nil, fmt.Errorf("sdkerr: unset serverUrl")
}
if _, err := url.Parse(serverUrl); err != nil {
return nil, fmt.Errorf("sdkerr: invalid serverUrl: %w", err)
}
client := &Client{}
client.client = resty.New().
SetBaseURL(strings.TrimRight(serverUrl, "/")).
SetHeader("User-Agent", app.AppUserAgent).
SetPreRequestHook(func(c *resty.Client, req *http.Request) error {
if client.synoToken != "" {
req.Header.Set("X-SYNO-TOKEN", client.synoToken)
}
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 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 tsuccess := res.GetSuccess(); !tsuccess {
return resp, fmt.Errorf("sdkerr: code='%d'", res.GetErrorCode())
}
}
return resp, nil
}
+70
View File
@@ -0,0 +1,70 @@
package synologydsm
type sdkResponse interface {
GetSuccess() bool
GetErrorCode() int
}
type sdkResponseBase struct {
Success bool `json:"success"`
Error *APIError `json:"error,omitempty"`
}
func (r *sdkResponseBase) GetSuccess() bool {
return r.Success
}
func (r *sdkResponseBase) GetErrorCode() int {
if r.Error == nil {
if r.Success {
return 0
}
return -1
}
return r.Error.Code
}
var _ sdkResponse = (*sdkResponseBase)(nil)
type APIError struct {
Code int `json:"code,omitempty"`
}
type APIInfo struct {
Path string `json:"path"`
MinVersion int `json:"minVersion"`
MaxVersion int `json:"maxVersion"`
}
type CertificateInfo struct {
ID string `json:"id"`
Description string `json:"desc"`
IsDefault bool `json:"is_default"`
IsBroken bool `json:"is_broken"`
Issuer struct {
CommonName string `json:"common_name"`
Country string `json:"country"`
Organization string `json:"organization"`
} `json:"issuer"`
Subject struct {
CommonName string `json:"common_name"`
Country string `json:"country"`
Organization string `json:"organization"`
SAN []string `json:"sub_alt_name"`
} `json:"subject"`
ValidFrom string `json:"valid_from"`
ValidTill string `json:"valid_till"`
SignatureAlgorithm string `json:"signature_algorithm"`
Renewable bool `json:"renewable"`
Services []*CertificateService `json:"services"`
}
type CertificateService struct {
DisplayName string `json:"display_name"`
DisplayNameI18N string `json:"display_name_i18n,omitempty"`
IsPkg bool `json:"isPkg"`
Owner string `json:"owner"`
Service string `json:"service"`
Subscriber string `json:"subscriber"`
}
+46
View File
@@ -0,0 +1,46 @@
package synologydsm
func getAuthErrorDescription(code int) string {
switch code {
case 100:
return "Unknown error"
case 101:
return "Invalid parameters"
case 102:
return "API does not exist"
case 103:
return "Method does not exist"
case 104:
return "This API version is not supported"
case 105:
return "Insufficient user privilege"
case 106:
return "Connection time out"
case 107:
return "Multiple login detected"
case 400:
return "Invalid password or account does not exist"
case 401:
return "Guest or disabled account"
case 402:
return "Permission denied"
case 403:
return "2-factor authentication code required (OTP)"
case 404:
return "Failed to authenticate 2-factor authentication code"
case 405:
return "Server version is too low or not supported"
case 406:
return "2-factor authentication code expired"
case 407:
return "Login failed: IP has been blocked"
case 408:
return "Expired password"
case 409:
return "Password must be changed (password policy)"
case 410:
return "Account locked (too many failed login attempts)"
default:
return "Unknown authentication error"
}
}
+88
View File
@@ -0,0 +1,88 @@
package wait
import (
"context"
"time"
)
// 等待,直到指定时间。
//
// 入参:
// - wait: 等待时间。
//
// 出参:
// - err: 错误。
func Delay(wait time.Duration) error {
return DelayWithContext(context.Background(), wait)
}
// 等待,直到指定时间,或上下文被取消。
//
// 入参:
// - ctx: 上下文。
// - wait: 等待时间。
//
// 出参:
// - err: 错误。
func DelayWithContext(ctx context.Context, wait time.Duration) error {
ticker := time.NewTimer(wait)
defer ticker.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
return nil
}
}
// 等待,直到条件满足或超时。
//
// 入参:
// - condition: 条件函数,接收尝试次数作为参数,返回是否满足条件和错误。
// - timeout: 超时时间。
// - interval: 执行条件函数的间隔时间。
//
// 出参:
// - ret: 是否满足条件。
// - err: 错误。
func For(condition func(int) (bool, error), timeout time.Duration, interval time.Duration) (bool, error) {
conditionWithContext := func(_ context.Context, i int) (bool, error) {
return condition(i)
}
return ForWithContext(context.Background(), conditionWithContext, timeout, interval)
}
// 等待,直到条件满足或超时,或上下文被取消。
//
// 入参:
// - ctx: 上下文。
// - condition: 条件函数,接收上下文和尝试次数作为参数,返回是否满足条件和错误。
// - timeout: 超时时间。
// - interval: 执行条件函数的间隔时间。
//
// 出参:
// - ret: 是否满足条件。
// - err: 错误。
func ForWithContext(ctx context.Context, condition func(context.Context, int) (bool, error), timeout time.Duration, interval time.Duration) (bool, error) {
ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ticker := time.NewTicker(interval)
defer ticker.Stop()
attempt := 0
for {
select {
case <-ctxWithTimeout.Done():
return false, ctxWithTimeout.Err()
case <-ticker.C:
attempt++
ret, err := condition(ctxWithTimeout, attempt)
if ret || err != nil {
return ret, err
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -91,6 +91,7 @@ import AccessConfigFieldsProviderSlackBot from "./AccessConfigFieldsProviderSlac
import AccessConfigFieldsProviderSpaceship from "./AccessConfigFieldsProviderSpaceship";
import AccessConfigFieldsProviderSSH from "./AccessConfigFieldsProviderSSH";
import AccessConfigFieldsProviderSSLCom from "./AccessConfigFieldsProviderSSLCom";
import AccessConfigFieldsProviderSynologyDSM from "./AccessConfigFieldsProviderSynologyDSM";
import AccessConfigFieldsProviderTechnitiumDNS from "./AccessConfigFieldsProviderTechnitiumDNS";
import AccessConfigFieldsProviderTelegramBot from "./AccessConfigFieldsProviderTelegramBot";
import AccessConfigFieldsProviderTencentCloud from "./AccessConfigFieldsProviderTencentCloud";
@@ -201,6 +202,7 @@ const providerComponentMap: Partial<Record<AccessProviderType, React.ComponentTy
[ACCESS_PROVIDERS.SPACESHIP]: AccessConfigFieldsProviderSpaceship,
[ACCESS_PROVIDERS.SSLCOM]: AccessConfigFieldsProviderSSLCom,
[ACCESS_PROVIDERS.SSH]: AccessConfigFieldsProviderSSH,
[ACCESS_PROVIDERS.SYNOLOGYDSM]: AccessConfigFieldsProviderSynologyDSM,
[ACCESS_PROVIDERS.TECHNITIUMDNS]: AccessConfigFieldsProviderTechnitiumDNS,
[ACCESS_PROVIDERS.TELEGRAMBOT]: AccessConfigFieldsProviderTelegramBot,
[ACCESS_PROVIDERS.TENCENTCLOUD]: AccessConfigFieldsProviderTencentCloud,
@@ -0,0 +1,101 @@
import { getI18n, useTranslation } from "react-i18next";
import { Form, Input, Switch } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import { useFormNestedFieldsContext } from "./_context";
const AccessConfigFieldsProviderSynologyDSM = () => {
const { i18n, t } = useTranslation();
const { parentNamePath } = useFormNestedFieldsContext();
const formSchema = z.object({
[parentNamePath]: getSchema({ i18n }),
});
const formRule = createSchemaFieldRule(formSchema);
const initialValues = getInitialValues();
return (
<>
<Form.Item
name={[parentNamePath, "serverUrl"]}
initialValue={initialValues.serverUrl}
label={t("access.form.synologydsm_server_url.label")}
rules={[formRule]}
>
<Input type="url" placeholder={t("access.form.synologydsm_server_url.placeholder")} />
</Form.Item>
<Form.Item
name={[parentNamePath, "username"]}
initialValue={initialValues.username}
label={t("access.form.synologydsm_username.label")}
rules={[formRule]}
>
<Input autoComplete="new-password" placeholder={t("access.form.synologydsm_username.placeholder")} />
</Form.Item>
<Form.Item
name={[parentNamePath, "password"]}
initialValue={initialValues.password}
label={t("access.form.synologydsm_password.label")}
rules={[formRule]}
>
<Input.Password autoComplete="new-password" placeholder={t("access.form.synologydsm_password.placeholder")} />
</Form.Item>
<Form.Item
name={[parentNamePath, "totpSecret"]}
initialValue={initialValues.totpSecret}
label={t("access.form.synologydsm_totp_secret.label")}
extra={t("access.form.synologydsm_totp_secret.help")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.synologydsm_totp_secret.tooltip") }}></span>}
>
<Input.Password autoComplete="new-password" placeholder={t("access.form.synologydsm_totp_secret.placeholder")} />
</Form.Item>
<Form.Item
name={[parentNamePath, "allowInsecureConnections"]}
initialValue={initialValues.allowInsecureConnections}
rules={[formRule]}
label={t("access.form.shared_allow_insecure_conns.label")}
>
<Switch />
</Form.Item>
</>
);
};
const getInitialValues = (): Nullish<z.infer<ReturnType<typeof getSchema>>> => {
return {
serverUrl: "http://<your-host-addr>:5000/",
username: "",
password: "",
};
};
const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType<typeof getI18n> }) => {
const { t } = i18n;
return z.object({
serverUrl: z.url(t("common.errmsg.url_invalid")),
username: z.string().nonempty(t("access.form.synologydsm_username.placeholder")),
password: z.string().nonempty(t("access.form.synologydsm_password.placeholder")),
totpSecret: z
.string()
.nullish()
.refine((v) => {
if (!v) return true;
return /^[A-Z2-7]{16,32}$/.test(v);
}),
allowInsecureConnections: z.boolean().nullish(),
});
};
const _default = Object.assign(AccessConfigFieldsProviderSynologyDSM, {
getInitialValues,
getSchema,
});
export default _default;
@@ -72,6 +72,7 @@ import BizDeployNodeConfigFieldsProviderRainYunRCDN from "./BizDeployNodeConfigF
import BizDeployNodeConfigFieldsProviderRatPanel from "./BizDeployNodeConfigFieldsProviderRatPanel.tsx";
import BizDeployNodeConfigFieldsProviderSafeLine from "./BizDeployNodeConfigFieldsProviderSafeLine.tsx";
import BizDeployNodeConfigFieldsProviderSSH from "./BizDeployNodeConfigFieldsProviderSSH";
import BizDeployNodeConfigFieldsProviderSynologyDSM from "./BizDeployNodeConfigFieldsProviderSynologyDSM";
import BizDeployNodeConfigFieldsProviderTencentCloudCDN from "./BizDeployNodeConfigFieldsProviderTencentCloudCDN";
import BizDeployNodeConfigFieldsProviderTencentCloudCLB from "./BizDeployNodeConfigFieldsProviderTencentCloudCLB";
import BizDeployNodeConfigFieldsProviderTencentCloudCOS from "./BizDeployNodeConfigFieldsProviderTencentCloudCOS";
@@ -184,6 +185,7 @@ const providerComponentMap: Partial<Record<DeploymentProviderType, React.Compone
[DEPLOYMENT_PROVIDERS.RATPANEL]: BizDeployNodeConfigFieldsProviderRatPanel,
[DEPLOYMENT_PROVIDERS.SAFELINE]: BizDeployNodeConfigFieldsProviderSafeLine,
[DEPLOYMENT_PROVIDERS.SSH]: BizDeployNodeConfigFieldsProviderSSH,
[DEPLOYMENT_PROVIDERS.SYNOLOGYDSM]: BizDeployNodeConfigFieldsProviderSynologyDSM,
[DEPLOYMENT_PROVIDERS.TENCENTCLOUD_CDN]: BizDeployNodeConfigFieldsProviderTencentCloudCDN,
[DEPLOYMENT_PROVIDERS.TENCENTCLOUD_CLB]: BizDeployNodeConfigFieldsProviderTencentCloudCLB,
[DEPLOYMENT_PROVIDERS.TENCENTCLOUD_COS]: BizDeployNodeConfigFieldsProviderTencentCloudCOS,
@@ -0,0 +1,61 @@
import { getI18n, useTranslation } from "react-i18next";
import { Form, Input, Switch } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import { useFormNestedFieldsContext } from "./_context";
const BizDeployNodeConfigFieldsProviderSynologyDSM = () => {
const { i18n, t } = useTranslation();
const { parentNamePath } = useFormNestedFieldsContext();
const formSchema = z.object({
[parentNamePath]: getSchema({ i18n }),
});
const formRule = createSchemaFieldRule(formSchema);
const initialValues = getInitialValues();
return (
<>
<Form.Item
name={[parentNamePath, "certificateIdOrDesc"]}
initialValue={initialValues.certificateIdOrDesc}
label={t("workflow_node.deploy.form.synologydsm_certificate_id_or_desc.label")}
extra={t("workflow_node.deploy.form.synologydsm_certificate_id_or_desc.help")}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.synologydsm_certificate_id_or_desc.tooltip") }}></span>}
rules={[formRule]}
>
<Input placeholder={t("workflow_node.deploy.form.synologydsm_certificate_id_or_desc.placeholder")} />
</Form.Item>
<Form.Item
name={[parentNamePath, "isDefault"]}
initialValue={initialValues.isDefault}
label={t("workflow_node.deploy.form.synologydsm_is_default.label")}
rules={[formRule]}
>
<Switch />
</Form.Item>
</>
);
};
const getInitialValues = (): Nullish<z.infer<ReturnType<typeof getSchema>>> => {
return {
isDefault: true,
};
};
const getSchema = ({ i18n: _i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> }) => {
return z.object({
certificateIdOrDesc: z.string().nullish(),
isDefault: z.boolean().nullish(),
});
};
const _default = Object.assign(BizDeployNodeConfigFieldsProviderSynologyDSM, {
getInitialValues,
getSchema,
});
export default _default;
+4
View File
@@ -107,6 +107,7 @@ export const ACCESS_PROVIDERS = Object.freeze({
SPACESHIP: "spaceship",
SSH: "ssh",
SSLCOM: "sslcom",
SYNOLOGYDSM: "synologydsm",
TECHNITIUMDNS: "technitiumdns",
TELEGRAMBOT: "telegrambot",
TENCENTCLOUD: "tencentcloud",
@@ -193,6 +194,7 @@ export const accessProvidersMap: Map<AccessProvider["type"] | string, AccessProv
[ACCESS_PROVIDERS.PROXMOXVE, "provider.proxmoxve", "/imgs/providers/proxmoxve.svg", [ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.RATPANEL, "provider.ratpanel", "/imgs/providers/ratpanel.png", [ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.SAFELINE, "provider.safeline", "/imgs/providers/safeline.svg", [ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.SYNOLOGYDSM, "provider.synologydsm", "/imgs/providers/synologydsm.png", [ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.AKAMAI, "provider.akamai", "/imgs/providers/akamai.svg", [ACCESS_USAGES.DNS]],
[ACCESS_PROVIDERS.ARVANCLOUD, "provider.arvancloud", "/imgs/providers/arvancloud.svg", [ACCESS_USAGES.DNS]],
@@ -615,6 +617,7 @@ export const DEPLOYMENT_PROVIDERS = Object.freeze({
RATPANEL_CONSOLE: `${ACCESS_PROVIDERS.RATPANEL}-console`,
SAFELINE: `${ACCESS_PROVIDERS.SAFELINE}`,
SSH: `${ACCESS_PROVIDERS.SSH}`,
SYNOLOGYDSM: `${ACCESS_PROVIDERS.SYNOLOGYDSM}`,
TENCENTCLOUD_CDN: `${ACCESS_PROVIDERS.TENCENTCLOUD}-cdn`,
TENCENTCLOUD_CLB: `${ACCESS_PROVIDERS.TENCENTCLOUD}-clb`,
TENCENTCLOUD_COS: `${ACCESS_PROVIDERS.TENCENTCLOUD}-cos`,
@@ -796,6 +799,7 @@ export const deploymentProvidersMap: Map<DeploymentProvider["type"] | string, De
[DEPLOYMENT_PROVIDERS.DOKPLOY, "provider.dokploy", DEPLOYMENT_CATEGORIES.WEBSITE],
[DEPLOYMENT_PROVIDERS.NGINXPROXYMANAGER, "provider.nginxproxymanager", DEPLOYMENT_CATEGORIES.WEBSITE],
[DEPLOYMENT_PROVIDERS.PROXMOXVE, "provider.proxmoxve", DEPLOYMENT_CATEGORIES.OTHER],
[DEPLOYMENT_PROVIDERS.SYNOLOGYDSM, "provider.synologydsm", DEPLOYMENT_CATEGORIES.OTHER],
] satisfies Array<[DeploymentProviderType, string, DeploymentCategoryType, "builtin"] | [DeploymentProviderType, string, DeploymentCategoryType]>
).map(([type, name, category, builtin]) => [
type,
+11
View File
@@ -517,6 +517,17 @@
"access.form.proxmoxve_api_token_secret.label": "Proxmox VE API token secret (Optional)",
"access.form.proxmoxve_api_token_secret.placeholder": "Please enter Proxmox VE API token secret",
"access.form.proxmoxve_api_token_secret.tooltip": "For more information, see <a href=\"https://pve.proxmox.com/pve-docs/pve-admin-guide.html#pveum_tokens\" target=\"_blank\">https://pve.proxmox.com/pve-docs/pve-admin-guide.html#pveum_tokens</a>",
"access.form.synologydsm_server_url.label": "Synology DSM server URL",
"access.form.synologydsm_server_url.placeholder": "http://192.168.1.100:5000",
"access.form.synologydsm_server_url.tooltip": "Format: http(s)://IP-or-hostname:port. Default port is 5000 for HTTP, 5001 for HTTPS. Example: http://192.168.1.100:5000 or https://nas.example.com:5001",
"access.form.synologydsm_username.label": "Synology DSM username",
"access.form.synologydsm_username.placeholder": "Please enter Synology DSM username",
"access.form.synologydsm_password.label": "Synology DSM password",
"access.form.synologydsm_password.placeholder": "Please enter Synology DSM password",
"access.form.synologydsm_totp_secret.label": "Synology DSM 2FA TOTP secret key (Optional)",
"access.form.synologydsm_totp_secret.placeholder": "Please enter Synology DSM 2FA TOTP secret key",
"access.form.synologydsm_totp_secret.help": "Notes: Only required when you set up 2FA sign-in to Synology DSM.",
"access.form.synologydsm_totp_secret.tooltip": "How to get it? The secret key can be found by clicking \"Can't scan it\" when setting up 2FA, or by using a QRCode scanning tool.",
"access.form.qingcloud_access_key_id.label": "QingCloud AccessKeyID",
"access.form.qingcloud_access_key_id.placeholder": "Please enter QingCloud AccessKeyID",
"access.form.qingcloud_access_key_id.tooltip": "For more information, see <a href=\"https://console.qingcloud.com/access_keys/\" target=\"_blank\">https://console.qingcloud.com/access_keys/</a>",
+1
View File
@@ -165,6 +165,7 @@
"provider.spaceship": "Spaceship",
"provider.ssh": "Remote host (SSH)",
"provider.sslcom": "SSL.com",
"provider.synologydsm": "Synology DSM",
"provider.technitiumdns": "Technitium DNS",
"provider.telegrambot": "Telegram Bot",
"provider.tencentcloud": "Tencent Cloud",
@@ -750,6 +750,11 @@
"workflow_node.deploy.form.proxmoxve_node_name.label": "Proxmox VE cluster node name",
"workflow_node.deploy.form.proxmoxve_node_name.placeholder": "Please enter Proxmox VE cluster node name",
"workflow_node.deploy.form.proxmoxve_auto_restart.label": "Auto restart Proxmox VE after deployment",
"workflow_node.deploy.form.synologydsm_certificate_id_or_desc.label": "Synology DSM certificate ID or description (Optional)",
"workflow_node.deploy.form.synologydsm_certificate_id_or_desc.placeholder": "Please enter Synology DSM certificate ID or description",
"workflow_node.deploy.form.synologydsm_certificate_id_or_desc.help": "Notes: Leave it blank to import a new certificate; otherwise, to replace the existing one.",
"workflow_node.deploy.form.synologydsm_certificate_id_or_desc.tooltip": "You can find it on Synology DSM dashboard. <br><br>(Please open the browser devtools, and visit the \"Control Panel -> Security -> Certificate\" page to capture network requests, then you can get the certificate ID.)",
"workflow_node.deploy.form.synologydsm_is_default.label": "Set as default certificate and applied to all DSM services",
"workflow_node.deploy.form.qiniu_cdn_domain.label": "Qiniu CDN domain",
"workflow_node.deploy.form.qiniu_cdn_domain.placeholder": "Please enter Qiniu CDN domain name",
"workflow_node.deploy.form.qiniu_kodo_bucket.label": "Qiniu Kodo bucket",
+10
View File
@@ -517,6 +517,16 @@
"access.form.proxmoxve_api_token_secret.label": "Proxmox VE API Token Secret(可选)",
"access.form.proxmoxve_api_token_secret.placeholder": "请输入 Proxmox VE API Token Secret",
"access.form.proxmoxve_api_token_secret.tooltip": "这是什么?请参阅 <a href=\"https://pve.proxmox.com/pve-docs/pve-admin-guide.html#pveum_tokens\" target=\"_blank\">https://pve.proxmox.com/pve-docs/pve-admin-guide.html#pveum_tokens</a>",
"access.form.synologydsm_server_url.label": "群晖 DSM 服务地址",
"access.form.synologydsm_server_url.placeholder": "请输入群晖 DSM 服务地址",
"access.form.synologydsm_username.label": "群晖 DSM 用户名",
"access.form.synologydsm_username.placeholder": "请输入群晖 DSM 用户名",
"access.form.synologydsm_password.label": "群晖 DSM 用户密码",
"access.form.synologydsm_password.placeholder": "请输入群晖 DSM 用户密码",
"access.form.synologydsm_totp_secret.label": "群晖 DSM 双重验证 TOTP 密钥(可选)",
"access.form.synologydsm_totp_secret.placeholder": "请输入群晖 DSM 双重验证 TOTP 密钥",
"access.form.synologydsm_totp_secret.help": "提示:仅当群晖开启双重验证登录时需要填写,用于生成动态登录验证码。",
"access.form.synologydsm_totp_secret.tooltip": "如何获取此参数?可在设置双重验证时点击「无法扫描?」查看,或将二维码识别为文本后、提取其中的 <em>secret</em> 部分。注意,这不是 6 位动态 OTP 码。",
"access.form.qingcloud_access_key_id.label": "青云 AccessKeyID",
"access.form.qingcloud_access_key_id.placeholder": "请输入青云 AccessKeyID",
"access.form.qingcloud_access_key_id.tooltip": "这是什么?请参阅 <a href=\"https://console.qingcloud.com/access_keys/\" target=\"_blank\">https://console.qingcloud.com/access_keys/</a>",
+1
View File
@@ -165,6 +165,7 @@
"provider.spaceship": "Spaceship",
"provider.ssh": "远程主机(SSH",
"provider.sslcom": "SSL.com",
"provider.synologydsm": "群晖 DSM",
"provider.technitiumdns": "Technitium DNS",
"provider.telegrambot": "Telegram 机器人",
"provider.tencentcloud": "腾讯云",
@@ -748,6 +748,11 @@
"workflow_node.deploy.form.proxmoxve_node_name.label": "Proxmox VE 集群节点名称",
"workflow_node.deploy.form.proxmoxve_node_name.placeholder": "请输入 Proxmox VE 集群节点名称",
"workflow_node.deploy.form.proxmoxve_auto_restart.label": "部署后自动重启 Proxmox VE 服务",
"workflow_node.deploy.form.synologydsm_certificate_id_or_desc.label": "群晖 DSM 原证书 ID 或描述(可选)",
"workflow_node.deploy.form.synologydsm_certificate_id_or_desc.placeholder": "请输入群晖 DSM 原证书 ID 或描述",
"workflow_node.deploy.form.synologydsm_certificate_id_or_desc.help": "提示:不填写时,将上传新证书;否则,将替换原证书。",
"workflow_node.deploy.form.synologydsm_certificate_id_or_desc.tooltip": "请登录群晖 DSM 面板查看<br><br>(目前需要在「控制面板 -> 安全性 -> 证书」页面,并打开浏览器开发者工具抓取网络请求后得到证书 ID)",
"workflow_node.deploy.form.synologydsm_is_default.label": "设为默认证书并应用到所有 DSM 服务",
"workflow_node.deploy.form.qiniu_cdn_domain.label": "七牛云 CDN 加速域名",
"workflow_node.deploy.form.qiniu_cdn_domain.placeholder": "请输入七牛云 CDN 加速域名",
"workflow_node.deploy.form.qiniu_kodo_bucket.label": "七牛云对象存储桶名",