Skip to content

Commit e9b6f54

Browse files
authored
Add Synology DSM certificate deployer integration
1 parent 5d2768f commit e9b6f54

29 files changed

+1211
-0
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ require (
5858
github.com/pocketbase/dbx v1.11.0
5959
github.com/pocketbase/pocketbase v0.35.0
6060
github.com/povsister/scp v0.0.0-20250701154629-777cf82de5df
61+
github.com/pquerna/otp v1.5.0
6162
github.com/qiniu/go-sdk/v7 v7.25.5
6263
github.com/samber/lo v1.52.0
6364
github.com/spf13/cobra v1.10.2
@@ -194,6 +195,7 @@ require (
194195
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
195196
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
196197
github.com/aws/smithy-go v1.24.0 // indirect
198+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
197199
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
198200
github.com/clbanning/mxj/v2 v2.7.0 // indirect
199201
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
247247
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
248248
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
249249
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
250+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
251+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
250252
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
251253
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
252254
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
764766
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
765767
github.com/povsister/scp v0.0.0-20250701154629-777cf82de5df h1:zEgSHrxo8f6hGG1xCaqunfBq8hlfDmFd1JM0QXiQi7o=
766768
github.com/povsister/scp v0.0.0-20250701154629-777cf82de5df/go.mod h1:CiJNEeV6v0tUCNul/+gTjl+FgjfImoiuptJB9AEzqjE=
769+
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
770+
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
767771
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
768772
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
769773
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package deployers
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/certimate-go/certimate/internal/domain"
7+
"github.com/certimate-go/certimate/pkg/core/deployer"
8+
"github.com/certimate-go/certimate/pkg/core/deployer/providers/synologydsm"
9+
xmaps "github.com/certimate-go/certimate/pkg/utils/maps"
10+
)
11+
12+
func init() {
13+
Registries.MustRegister(domain.DeploymentProviderTypeSynologyDSM, func(options *ProviderFactoryOptions) (deployer.Provider, error) {
14+
credentials := domain.AccessConfigForSynologyDSM{}
15+
if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil {
16+
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
17+
}
18+
19+
provider, err := synologydsm.NewDeployer(&synologydsm.DeployerConfig{
20+
ServerUrl: credentials.ServerUrl,
21+
Username: credentials.Username,
22+
Password: credentials.Password,
23+
TotpSecret: credentials.TotpSecret,
24+
AllowInsecureConnections: credentials.AllowInsecureConnections,
25+
CertificateIdOrDescription: xmaps.GetString(options.ProviderExtendedConfig, "certificateIdOrDesc"),
26+
IsDefault: xmaps.GetBool(options.ProviderExtendedConfig, "isDefault"),
27+
})
28+
return provider, err
29+
})
30+
}

internal/domain/access.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,14 @@ type AccessConfigForSSLCom struct {
516516
AccessConfigForACMEExternalAccountBinding
517517
}
518518

519+
type AccessConfigForSynologyDSM struct {
520+
ServerUrl string `json:"serverUrl"`
521+
Username string `json:"username"`
522+
Password string `json:"password"`
523+
TotpSecret string `json:"totpSecret,omitempty"`
524+
AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"`
525+
}
526+
519527
type AccessConfigForTechnitiumDNS struct {
520528
ServerUrl string `json:"serverUrl"`
521529
ApiToken string `json:"apiToken"`

internal/domain/provider.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ const (
103103
AccessProviderTypeSpaceship = AccessProviderType("spaceship")
104104
AccessProviderTypeSSH = AccessProviderType("ssh")
105105
AccessProviderTypeSSLCOM = AccessProviderType("sslcom")
106+
AccessProviderTypeSynologyDSM = AccessProviderType("synologydsm")
106107
AccessProviderTypeTechnitiumDNS = AccessProviderType("technitiumdns")
107108
AccessProviderTypeTelegramBot = AccessProviderType("telegrambot")
108109
AccessProviderTypeTencentCloud = AccessProviderType("tencentcloud")
@@ -330,6 +331,7 @@ const (
330331
DeploymentProviderTypeRatPanelConsole = DeploymentProviderType(AccessProviderTypeRatPanel + "-console")
331332
DeploymentProviderTypeSafeLine = DeploymentProviderType(AccessProviderTypeSafeLine)
332333
DeploymentProviderTypeSSH = DeploymentProviderType(AccessProviderTypeSSH)
334+
DeploymentProviderTypeSynologyDSM = DeploymentProviderType(AccessProviderTypeSynologyDSM)
333335
DeploymentProviderTypeTencentCloudCDN = DeploymentProviderType(AccessProviderTypeTencentCloud + "-cdn")
334336
DeploymentProviderTypeTencentCloudCLB = DeploymentProviderType(AccessProviderTypeTencentCloud + "-clb")
335337
DeploymentProviderTypeTencentCloudCOS = DeploymentProviderType(AccessProviderTypeTencentCloud + "-cos")
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
package synologydsm
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"errors"
7+
"fmt"
8+
"log/slog"
9+
"time"
10+
11+
"github.com/pquerna/otp/totp"
12+
"github.com/samber/lo"
13+
14+
"github.com/certimate-go/certimate/pkg/core/deployer"
15+
dsmsdk "github.com/certimate-go/certimate/pkg/sdk3rd/synologydsm"
16+
xcert "github.com/certimate-go/certimate/pkg/utils/cert"
17+
xwait "github.com/certimate-go/certimate/pkg/utils/wait"
18+
)
19+
20+
type DeployerConfig struct {
21+
// 群晖 DSM 服务地址。
22+
ServerUrl string `json:"serverUrl"`
23+
// 群晖 DSM 用户名。
24+
Username string `json:"username"`
25+
// 群晖 DSM 用户密码。
26+
Password string `json:"password"`
27+
// 群晖 DSM 2FA TOTP 密钥。
28+
TotpSecret string `json:"totpSecret,omitempty"`
29+
// 是否允许不安全的连接。
30+
AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"`
31+
// 证书 ID 或描述。
32+
// 选填。零值时表示新建证书;否则表示更新证书。
33+
CertificateIdOrDescription string `json:"certificateIdOrDesc,omitempty"`
34+
// 是否设为默认证书。
35+
IsDefault bool `json:"isDefault,omitempty"`
36+
}
37+
38+
type Deployer struct {
39+
config *DeployerConfig
40+
logger *slog.Logger
41+
sdkClient *dsmsdk.Client
42+
}
43+
44+
var _ deployer.Provider = (*Deployer)(nil)
45+
46+
func NewDeployer(config *DeployerConfig) (*Deployer, error) {
47+
if config == nil {
48+
return nil, errors.New("the configuration of the deployer provider is nil")
49+
}
50+
51+
client, err := createSDKClient(config.ServerUrl, config.AllowInsecureConnections)
52+
if err != nil {
53+
return nil, fmt.Errorf("could not create client: %w", err)
54+
}
55+
56+
return &Deployer{
57+
config: config,
58+
logger: slog.Default(),
59+
sdkClient: client,
60+
}, nil
61+
}
62+
63+
func (d *Deployer) SetLogger(logger *slog.Logger) {
64+
if logger == nil {
65+
d.logger = slog.Default()
66+
} else {
67+
d.logger = logger
68+
}
69+
}
70+
71+
func (d *Deployer) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*deployer.DeployResult, error) {
72+
// 提取服务器证书和中间证书
73+
serverCertPEM, intermediateCertPEM, err := xcert.ExtractCertificatesFromPEM(certPEM)
74+
if err != nil {
75+
return nil, fmt.Errorf("failed to extract certs: %w", err)
76+
}
77+
78+
// 如果启用了 TOTP,则等到下一个时间窗口后生成 OTP 动态密码
79+
var otpCode string
80+
if d.config.TotpSecret != "" {
81+
now := time.Now()
82+
wait := time.Duration(30-now.Unix()%30) * time.Second
83+
if wait > 0 {
84+
wait = wait + 1*time.Second
85+
d.logger.Info("waiting for the next TOTP time step ...", slog.Int("wait", int(wait.Seconds())))
86+
xwait.DelayWithContext(ctx, wait)
87+
}
88+
89+
now = time.Now()
90+
otpCodeStr, err := totp.GenerateCode(d.config.TotpSecret, now)
91+
if err != nil {
92+
return nil, fmt.Errorf("failed to generate TOTP code: %w", err)
93+
}
94+
95+
otpCode = otpCodeStr
96+
}
97+
98+
// 登录到群晖 DSM
99+
loginReq := &dsmsdk.LoginRequest{
100+
Account: d.config.Username,
101+
Password: d.config.Password,
102+
OtpCode: otpCode,
103+
}
104+
loginResp, err := d.sdkClient.Login(loginReq)
105+
d.logger.Debug("sdk request 'SYNO.API.Auth:login'", slog.Any("request", loginReq), slog.Any("response", loginResp))
106+
if err != nil {
107+
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.API.Auth:login': %w", err)
108+
}
109+
defer func() {
110+
logoutResp, _ := d.sdkClient.Logout()
111+
d.logger.Debug("sdk request 'SYNO.API.Auth:logout'", slog.Any("response", logoutResp))
112+
}()
113+
114+
// 如果原证书 ID 或描述为空,则创建证书;否则更新证书。
115+
if d.config.CertificateIdOrDescription == "" {
116+
// 导入证书
117+
importCertificateReq := &dsmsdk.ImportCertificateRequest{
118+
ID: "",
119+
Description: fmt.Sprintf("certimate-%d", time.Now().UnixMilli()),
120+
Key: privkeyPEM,
121+
Cert: serverCertPEM,
122+
InterCert: intermediateCertPEM,
123+
AsDefault: d.config.IsDefault,
124+
}
125+
importCertificateResp, err := d.sdkClient.ImportCertificate(importCertificateReq)
126+
d.logger.Debug("sdk request 'SYNO.Core.Certificate:import'", slog.Any("request", importCertificateReq), slog.Any("response", importCertificateResp))
127+
if err != nil {
128+
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.Core.Certificate:import': %w", err)
129+
}
130+
} else {
131+
// 查找证书列表,找到已有证书
132+
var certInfo *dsmsdk.CertificateInfo
133+
listCertificatesResp, err := d.sdkClient.ListCertificates()
134+
d.logger.Debug("sdk request 'SYNO.Core.Certificate.CRT:list'", slog.Any("response", listCertificatesResp))
135+
if err != nil {
136+
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.Core.Certificate.CRT:list': %w", err)
137+
} else {
138+
matchedCerts := lo.Filter(listCertificatesResp.Data.Certificates, func(certItem *dsmsdk.CertificateInfo, _ int) bool {
139+
return certItem.ID == d.config.CertificateIdOrDescription
140+
})
141+
if len(matchedCerts) == 0 {
142+
matchedCerts = lo.Filter(listCertificatesResp.Data.Certificates, func(certItem *dsmsdk.CertificateInfo, _ int) bool {
143+
return certItem.Description == d.config.CertificateIdOrDescription
144+
})
145+
}
146+
if len(matchedCerts) == 0 {
147+
return nil, fmt.Errorf("could not find certificate '%s'", d.config.CertificateIdOrDescription)
148+
} else {
149+
if len(matchedCerts) > 1 {
150+
d.logger.Warn("found several certificates matched '%s', using the first one")
151+
}
152+
certInfo = matchedCerts[0]
153+
}
154+
}
155+
156+
// 导入证书
157+
importCertificateReq := &dsmsdk.ImportCertificateRequest{
158+
ID: certInfo.ID,
159+
Description: certInfo.Description,
160+
Key: privkeyPEM,
161+
Cert: serverCertPEM,
162+
InterCert: intermediateCertPEM,
163+
AsDefault: d.config.IsDefault || certInfo.IsDefault,
164+
}
165+
importCertificateResp, err := d.sdkClient.ImportCertificate(importCertificateReq)
166+
d.logger.Debug("sdk request 'SYNO.Core.Certificate:import'", slog.Any("request", importCertificateReq), slog.Any("response", importCertificateResp))
167+
if err != nil {
168+
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.Core.Certificate:import': %w", err)
169+
}
170+
}
171+
172+
if d.config.IsDefault {
173+
// 查找证书列表,找到默认证书
174+
listCertificatesResp, err := d.sdkClient.ListCertificates()
175+
d.logger.Debug("sdk request 'SYNO.Core.Certificate.CRT:list'", slog.Any("response", listCertificatesResp))
176+
if err != nil {
177+
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.Core.Certificate.CRT:list': %w", err)
178+
} else {
179+
var defaultCertId string
180+
for _, certItem := range listCertificatesResp.Data.Certificates {
181+
if certItem.IsDefault {
182+
defaultCertId = certItem.ID
183+
break
184+
}
185+
}
186+
187+
if defaultCertId != "" {
188+
settings := make([]*dsmsdk.ServiceCertificateSetting, 0)
189+
for _, certItem := range listCertificatesResp.Data.Certificates {
190+
if certItem.ID == defaultCertId {
191+
continue
192+
}
193+
194+
for _, service := range certItem.Services {
195+
settings = append(settings, &dsmsdk.ServiceCertificateSetting{
196+
Service: service,
197+
CertID: defaultCertId,
198+
OldCertID: certItem.ID,
199+
})
200+
}
201+
}
202+
203+
// 应用到所有服务并重启
204+
if len(settings) > 0 {
205+
setServiceCertificateReq := &dsmsdk.SetServiceCertificateRequest{
206+
Settings: settings,
207+
}
208+
setServiceCertificateResp, err := d.sdkClient.SetServiceCertificate(setServiceCertificateReq)
209+
d.logger.Debug("sdk request 'SYNO.Core.Certificate.Service:set'", slog.Any("request", setServiceCertificateReq), slog.Any("response", setServiceCertificateResp))
210+
if err != nil {
211+
return nil, fmt.Errorf("failed to execute sdk request 'SYNO.Core.Certificate.Service:set': %w", err)
212+
}
213+
}
214+
}
215+
}
216+
}
217+
218+
return &deployer.DeployResult{}, nil
219+
}
220+
221+
func createSDKClient(serverUrl string, skipTlsVerify bool) (*dsmsdk.Client, error) {
222+
client, err := dsmsdk.NewClient(serverUrl)
223+
if err != nil {
224+
return nil, err
225+
}
226+
227+
if skipTlsVerify {
228+
client.SetTLSConfig(&tls.Config{InsecureSkipVerify: true})
229+
}
230+
231+
return client, nil
232+
}

0 commit comments

Comments
 (0)