Compare commits

...

25 Commits

Author SHA1 Message Date
Yoan.liu
e24de70c02 split release into multiple jobs 2025-06-09 23:40:24 +08:00
Yoan.liu
bba2b25757 split release into multiple jobs 2025-06-09 23:11:50 +08:00
Yoan.liu
4132ec3617 split release into multiple jobs 2025-06-09 22:40:43 +08:00
Fu Diwei
9b3c7e16c0 fix: #769 2025-06-09 21:34:03 +08:00
Fu Diwei
0448538073 feat: bump version to v0.3.17 2025-06-09 21:16:58 +08:00
RHQYZ
80157496d5 Merge pull request #774 from lfffffy/main
fix(docs): 修复腾讯云文档链接使用错误的 com.cn 域名
2025-06-09 21:09:39 +08:00
RHQYZ
62e2ed2fb8 Merge pull request #768 from fudiwei/main
enhance & bugfix
2025-06-09 21:09:28 +08:00
Fu Diwei
a750592eb5 feat: duplicate workflow 2025-06-09 21:07:52 +08:00
Fu Diwei
5e6d729631 fix: #769 2025-06-09 20:44:48 +08:00
Fu Diwei
24fe824757 feat: allow skip notify nodes when all previous nodes were skipped 2025-06-09 20:40:06 +08:00
Fu Diwei
84a3f3346a fix: #769 2025-06-09 19:11:04 +08:00
Fu Diwei
bd26dfecb8 feat: improve workflow log 2025-06-09 10:06:41 +08:00
leun
43182de732 fix(docs): 修复腾讯云文档链接使用错误的 com.cn 域名
将文档链接中的 cloud.tencent.com.cn 统一替换为正确的 cloud.tencent.com,
以避免链接跳转失败或文档加载异常的问题。
2025-06-08 15:00:28 +08:00
Fu Diwei
d58109f4be feat: support wildcard domains on deployment to wangsu cdn 2025-06-05 20:28:50 +08:00
Fu Diwei
59935df6b1 fix: #766 2025-06-05 20:24:43 +08:00
Fu Diwei
252da5d7e1 refactor(ui): clean code 2025-06-05 10:24:39 +08:00
RHQYZ
c3e7590f53 bump version to v0.3.16 2025-06-03 23:50:48 +08:00
RHQYZ
65cd1dc850 Merge pull request #761 from fudiwei/feat/providers
new providers
2025-06-03 23:48:27 +08:00
RHQYZ
2203bb5268 Merge pull request #760 from fudiwei/bugfix
bugfix: could not add branches in workflows
2025-06-03 23:48:13 +08:00
Fu Diwei
8e5c36968a chore: improve error 2025-06-03 23:45:00 +08:00
Fu Diwei
9ad0e6fb57 feat: support ssh challenge-response 2025-06-03 23:39:48 +08:00
Fu Diwei
7d55383cf7 feat: new deployment provider: aws iam 2025-06-03 23:39:48 +08:00
Fu Diwei
6dc65eea2f feat: new acme dns-01 provider: ucloud udnr 2025-06-03 23:39:48 +08:00
Fu Diwei
7210f63884 feat: new acme dns-01 provider: constellix 2025-06-03 23:39:42 +08:00
Fu Diwei
f94db675fb fix: could not add branches in workflows 2025-06-03 22:37:03 +08:00
76 changed files with 1874 additions and 360 deletions

View File

@@ -6,7 +6,7 @@ on:
- "v[0-9]*"
jobs:
goreleaser:
prepare-ui:
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -19,34 +19,173 @@ jobs:
with:
node-version: 20.11.0
- name: Build WebUI
run: |
npm --prefix=./ui ci
npm --prefix=./ui run build
- name: Upload UI build artifacts
uses: actions/upload-artifact@v4
with:
name: ui-build
path: ./ui/dist
retention-days: 1
build-linux:
needs: prepare-ui
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
# - name: Install upx (optional)
# run: |
# sudo apt-get update
# sudo apt-get install -y upx
- name: Build WebUI
run: |
npm --prefix=./ui ci
npm --prefix=./ui run build
npm cache clean --force
rm -rf ./ui/node_modules
- name: Check disk usage
run: |
df -h
du -sh /opt/hostedtoolcache/go/*
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
- name: Download UI build artifacts
uses: actions/download-artifact@v4
with:
distribution: goreleaser
version: latest
args: release --clean
name: ui-build
path: ./ui/dist
- name: Build Linux binaries
env:
CGO_ENABLED: 0
GOOS: linux
run: |
mkdir -p dist/linux
for ARCH in amd64 arm64 arm; do
if [ "$ARCH" = "arm" ]; then
export GOARM=7
fi
go build -ldflags="-s -w -X github.com/usual2970/certimate.Version=${GITHUB_REF#refs/tags/}" -o dist/linux/certimate_${GITHUB_REF#refs/tags/}_linux_$ARCH
done
- name: Upload Linux binaries
uses: actions/upload-artifact@v4
with:
name: linux-binaries
path: dist/linux/
retention-days: 1
build-macos:
needs: prepare-ui
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
- name: Download UI build artifacts
uses: actions/download-artifact@v4
with:
name: ui-build
path: ./ui/dist
- name: Build macOS binaries
env:
CGO_ENABLED: 0
GOOS: darwin
run: |
mkdir -p dist/darwin
for ARCH in amd64 arm64; do
go build -ldflags="-s -w -X github.com/usual2970/certimate.Version=${GITHUB_REF#refs/tags/}" -o dist/darwin/certimate_${GITHUB_REF#refs/tags/}_darwin_$ARCH
done
- name: Upload macOS binaries
uses: actions/upload-artifact@v4
with:
name: macos-binaries
path: dist/darwin/
retention-days: 1
build-windows:
needs: prepare-ui
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
- name: Download UI build artifacts
uses: actions/download-artifact@v4
with:
name: ui-build
path: ./ui/dist
- name: Build Windows binaries
env:
CGO_ENABLED: 0
GOOS: windows
run: |
mkdir -p dist/windows
for ARCH in amd64 arm64; do
go build -ldflags="-s -w -X github.com/usual2970/certimate.Version=${GITHUB_REF#refs/tags/}" -o dist/windows/certimate_${GITHUB_REF#refs/tags/}_windows_$ARCH.exe
done
- name: Upload Windows binaries
uses: actions/upload-artifact@v4
with:
name: windows-binaries
path: dist/windows/
retention-days: 1
create-release:
needs: [build-linux, build-macos, build-windows]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download all binaries
uses: actions/download-artifact@v4
with:
path: ./artifacts
- name: Prepare release assets
run: |
mkdir -p dist
cp -r artifacts/linux-binaries/* dist/
cp -r artifacts/macos-binaries/* dist/
cp -r artifacts/windows-binaries/* dist/
# 为每个二进制文件创建 zip 包
cd dist
for bin in certimate_*; do
if [[ "$bin" == *".exe" ]]; then
zip "${bin%.exe}.zip" "${bin}"
else
zip "${bin}.zip" "${bin}"
fi
done
# 创建校验和文件
sha256sum *.zip > checksums.txt
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.zip
dist/checksums.txt
draft: true
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

52
.goreleaser.linux.yml Normal file
View File

@@ -0,0 +1,52 @@
# .goreleaser.linux.yml
project_name: certimate
dist: .builds/linux
before:
hooks:
- go mod tidy
builds:
- id: build_linux
main: ./
binary: certimate
ldflags:
- -s -w -X github.com/usual2970/certimate.Version={{ .Version }}
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
- arm
goarm:
- 7
release:
draft: true
ids:
- linux
archives:
- id: archive_linux
builds: [build_linux]
format: "zip"
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- CHANGELOG.md
- LICENSE.md
- README.md
checksum:
name_template: "checksums_linux.txt"
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^ui:"

49
.goreleaser.macos.yml Normal file
View File

@@ -0,0 +1,49 @@
# .goreleaser.macos.yml
project_name: certimate
dist: .builds/macos
before:
hooks:
- go mod tidy
builds:
- id: build_macos
main: ./
binary: certimate
ldflags:
- -s -w -X github.com/usual2970/certimate.Version={{ .Version }}
env:
- CGO_ENABLED=0
goos:
- darwin
goarch:
- amd64
- arm64
release:
draft: true
ids:
- macos
archives:
- id: archive_macos
builds: [build_macos]
format: "zip"
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- CHANGELOG.md
- LICENSE.md
- README.md
checksum:
name_template: "checksums_macos.txt"
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^ui:"

52
.goreleaser.windows.yml Normal file
View File

@@ -0,0 +1,52 @@
# .goreleaser.windows.yml
project_name: certimate
dist: .builds/windows
before:
hooks:
- go mod tidy
builds:
- id: build_windows
main: ./
binary: certimate
ldflags:
- -s -w -X github.com/usual2970/certimate.Version={{ .Version }}
env:
- CGO_ENABLED=0
goos:
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm
release:
draft: true
ids:
- windows
archives:
- id: archive_windows
builds: [build_windows]
format: "zip"
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- CHANGELOG.md
- LICENSE.md
- README.md
checksum:
name_template: "checksums_windows.txt"
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^ui:"

1
go.mod
View File

@@ -85,6 +85,7 @@ require (
github.com/alibabacloud-go/tea-oss-utils v1.1.0 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/avast/retry-go v3.0.0+incompatible // indirect
github.com/aws/aws-sdk-go-v2/service/iam v1.42.0 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.50.0 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/diskfs/go-diskfs v1.5.0 // indirect

2
go.sum
View File

@@ -235,6 +235,8 @@ github.com/aws/aws-sdk-go-v2/service/acm v1.32.0/go.mod h1:3sKYAgRbuBa2QMYGh/WEc
github.com/aws/aws-sdk-go-v2/service/cloudfront v1.46.1 h1:6xZNYtuVwzBs8k+TmraERt0vL68Ppg9aUi+aTQmPaVM=
github.com/aws/aws-sdk-go-v2/service/cloudfront v1.46.1/go.mod h1:FIBJ48TS+qJb+Ne4qJ+0NeIhtPTVXItXooTeNeVI4Po=
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o=
github.com/aws/aws-sdk-go-v2/service/iam v1.42.0 h1:G6+UzGvubaet9QOh0664E9JeT+b6Zvop3AChozRqkrA=
github.com/aws/aws-sdk-go-v2/service/iam v1.42.0/go.mod h1:mPJkGQzeCoPs82ElNILor2JzZgYENr4UaSKUT8K27+c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=

View File

@@ -16,6 +16,7 @@ import (
pCloudflare "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/cloudflare"
pClouDNS "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/cloudns"
pCMCCCloud "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/cmcccloud"
pConstellix "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/constellix"
pDeSEC "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/desec"
pDigitalOcean "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/digitalocean"
pDNSLA "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/dnsla"
@@ -38,6 +39,7 @@ import (
pRainYun "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/rainyun"
pTencentCloud "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/tencentcloud"
pTencentCloudEO "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/tencentcloud-eo"
pUCloudUDNR "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr"
pVercel "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/vercel"
pVolcEngine "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/volcengine"
pWestcn "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/westcn"
@@ -234,6 +236,22 @@ func createApplicantProvider(options *applicantProviderOptions) (challenge.Provi
return applicant, err
}
case domain.ACMEDns01ProviderTypeConstellix:
{
access := domain.AccessConfigForConstellix{}
if err := maputil.Populate(options.ProviderAccessConfig, &access); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
applicant, err := pConstellix.NewChallengeProvider(&pConstellix.ChallengeProviderConfig{
ApiKey: access.ApiKey,
SecretKey: access.SecretKey,
DnsPropagationTimeout: options.DnsPropagationTimeout,
DnsTTL: options.DnsTTL,
})
return applicant, err
}
case domain.ACMEDns01ProviderTypeDeSEC:
{
access := domain.AccessConfigForDeSEC{}
@@ -579,6 +597,22 @@ func createApplicantProvider(options *applicantProviderOptions) (challenge.Provi
}
}
case domain.ACMEDns01ProviderTypeUCloudUDNR:
{
access := domain.AccessConfigForUCloud{}
if err := maputil.Populate(options.ProviderAccessConfig, &access); err != nil {
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
applicant, err := pUCloudUDNR.NewChallengeProvider(&pUCloudUDNR.ChallengeProviderConfig{
PrivateKey: access.PrivateKey,
PublicKey: access.PublicKey,
DnsPropagationTimeout: options.DnsPropagationTimeout,
DnsTTL: options.DnsTTL,
})
return applicant, err
}
case domain.ACMEDns01ProviderTypeVercel:
{
access := domain.AccessConfigForVercel{}

View File

@@ -27,6 +27,7 @@ import (
pAliyunWAF "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-waf"
pAWSACM "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aws-acm"
pAWSCloudFront "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aws-cloudfront"
pAWSIAM "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aws-iam"
pAzureKeyVault "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/azure-keyvault"
pBaiduCloudAppBLB "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/baiducloud-appblb"
pBaiduCloudBLB "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/baiducloud-blb"
@@ -331,7 +332,7 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer
}
}
case domain.DeploymentProviderTypeAWSACM, domain.DeploymentProviderTypeAWSCloudFront:
case domain.DeploymentProviderTypeAWSACM, domain.DeploymentProviderTypeAWSCloudFront, domain.DeploymentProviderTypeAWSIAM:
{
access := domain.AccessConfigForAWS{}
if err := maputil.Populate(options.ProviderAccessConfig, &access); err != nil {
@@ -350,10 +351,20 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer
case domain.DeploymentProviderTypeAWSCloudFront:
deployer, err := pAWSCloudFront.NewDeployer(&pAWSCloudFront.DeployerConfig{
AccessKeyId: access.AccessKeyId,
SecretAccessKey: access.SecretAccessKey,
Region: maputil.GetString(options.ProviderServiceConfig, "region"),
DistributionId: maputil.GetString(options.ProviderServiceConfig, "distributionId"),
CertificateSource: maputil.GetOrDefaultString(options.ProviderServiceConfig, "certificateSource", "ACM"),
})
return deployer, err
case domain.DeploymentProviderTypeAWSIAM:
deployer, err := pAWSIAM.NewDeployer(&pAWSIAM.DeployerConfig{
AccessKeyId: access.AccessKeyId,
SecretAccessKey: access.SecretAccessKey,
Region: maputil.GetString(options.ProviderServiceConfig, "region"),
DistributionId: maputil.GetString(options.ProviderServiceConfig, "distributionId"),
CertificatePath: maputil.GetOrDefaultString(options.ProviderServiceConfig, "certificatePath", "/"),
})
return deployer, err
@@ -986,6 +997,7 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer
jumpServers[i] = pSSH.JumpServerConfig{
SshHost: jumpServer.Host,
SshPort: jumpServer.Port,
SshAuthMethod: jumpServer.AuthMethod,
SshUsername: jumpServer.Username,
SshPassword: jumpServer.Password,
SshKey: jumpServer.Key,
@@ -996,6 +1008,7 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer
deployer, err := pSSH.NewDeployer(&pSSH.DeployerConfig{
SshHost: access.Host,
SshPort: access.Port,
SshAuthMethod: access.AuthMethod,
SshUsername: access.Username,
SshPassword: access.Password,
SshKey: access.Key,

View File

@@ -109,6 +109,11 @@ type AccessConfigForCMCCCloud struct {
AccessKeySecret string `json:"accessKeySecret"`
}
type AccessConfigForConstellix struct {
ApiKey string `json:"apiKey"`
SecretKey string `json:"secretKey"`
}
type AccessConfigForDeSEC struct {
Token string `json:"token"`
}
@@ -310,14 +315,16 @@ type AccessConfigForSlackBot struct {
type AccessConfigForSSH struct {
Host string `json:"host"`
Port int32 `json:"port"`
Username string `json:"username"`
AuthMethod string `json:"authMethod,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Key string `json:"key,omitempty"`
KeyPassphrase string `json:"keyPassphrase,omitempty"`
JumpServers []struct {
Host string `json:"host"`
Port int32 `json:"port"`
Username string `json:"username"`
AuthMethod string `json:"authMethod,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Key string `json:"key,omitempty"`
KeyPassphrase string `json:"keyPassphrase,omitempty"`

View File

@@ -28,6 +28,7 @@ const (
AccessProviderTypeCloudflare = AccessProviderType("cloudflare")
AccessProviderTypeClouDNS = AccessProviderType("cloudns")
AccessProviderTypeCMCCCloud = AccessProviderType("cmcccloud")
AccessProviderTypeConstellix = AccessProviderType("constellix")
AccessProviderTypeCTCCCloud = AccessProviderType("ctcccloud") // 天翼云(预留)
AccessProviderTypeCUCCCloud = AccessProviderType("cucccloud") // 联通云(预留)
AccessProviderTypeDeSEC = AccessProviderType("desec")
@@ -131,6 +132,7 @@ const (
ACMEDns01ProviderTypeCloudflare = ACMEDns01ProviderType(AccessProviderTypeCloudflare)
ACMEDns01ProviderTypeClouDNS = ACMEDns01ProviderType(AccessProviderTypeClouDNS)
ACMEDns01ProviderTypeCMCCCloud = ACMEDns01ProviderType(AccessProviderTypeCMCCCloud)
ACMEDns01ProviderTypeConstellix = ACMEDns01ProviderType(AccessProviderTypeConstellix)
ACMEDns01ProviderTypeDeSEC = ACMEDns01ProviderType(AccessProviderTypeDeSEC)
ACMEDns01ProviderTypeDigitalOcean = ACMEDns01ProviderType(AccessProviderTypeDigitalOcean)
ACMEDns01ProviderTypeDNSLA = ACMEDns01ProviderType(AccessProviderTypeDNSLA)
@@ -156,6 +158,7 @@ const (
ACMEDns01ProviderTypeTencentCloud = ACMEDns01ProviderType(AccessProviderTypeTencentCloud) // 兼容旧值,等同于 [ACMEDns01ProviderTypeTencentCloudDNS]
ACMEDns01ProviderTypeTencentCloudDNS = ACMEDns01ProviderType(AccessProviderTypeTencentCloud + "-dns")
ACMEDns01ProviderTypeTencentCloudEO = ACMEDns01ProviderType(AccessProviderTypeTencentCloud + "-eo")
ACMEDns01ProviderTypeUCloudUDNR = ACMEDns01ProviderType(AccessProviderTypeUCloud + "-udnr")
ACMEDns01ProviderTypeVercel = ACMEDns01ProviderType(AccessProviderTypeVercel)
ACMEDns01ProviderTypeVolcEngine = ACMEDns01ProviderType(AccessProviderTypeVolcEngine) // 兼容旧值,等同于 [ACMEDns01ProviderTypeVolcEngineDNS]
ACMEDns01ProviderTypeVolcEngineDNS = ACMEDns01ProviderType(AccessProviderTypeVolcEngine + "-dns")
@@ -192,6 +195,7 @@ const (
DeploymentProviderTypeAliyunWAF = DeploymentProviderType(AccessProviderTypeAliyun + "-waf")
DeploymentProviderTypeAWSACM = DeploymentProviderType(AccessProviderTypeAWS + "-acm")
DeploymentProviderTypeAWSCloudFront = DeploymentProviderType(AccessProviderTypeAWS + "-cloudfront")
DeploymentProviderTypeAWSIAM = DeploymentProviderType(AccessProviderTypeAWS + "-iam")
DeploymentProviderTypeAzureKeyVault = DeploymentProviderType(AccessProviderTypeAzure + "-keyvault")
DeploymentProviderTypeBaiduCloudAppBLB = DeploymentProviderType(AccessProviderTypeBaiduCloud + "-appblb")
DeploymentProviderTypeBaiduCloudBLB = DeploymentProviderType(AccessProviderTypeBaiduCloud + "-blb")

View File

@@ -106,12 +106,13 @@ type WorkflowNodeConfigForDeploy struct {
}
type WorkflowNodeConfigForNotify struct {
Channel string `json:"channel,omitempty"` // Deprecated: v0.4.x 将废弃
Provider string `json:"provider"` // 通知提供商
ProviderAccessId string `json:"providerAccessId"` // 通知提供商授权记录 ID
ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 通知提供商额外配置
Subject string `json:"subject"` // 通知主题
Message string `json:"message"` // 通知内容
Channel string `json:"channel,omitempty"` // Deprecated: v0.4.x 将废弃
Provider string `json:"provider"` // 通知提供商
ProviderAccessId string `json:"providerAccessId"` // 通知提供商授权记录 ID
ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 通知提供商额外配置
Subject string `json:"subject"` // 通知主题
Message string `json:"message"` // 通知内容
SkipOnAllPrevSkipped bool `json:"skipOnAllPrevSkipped"` // 前序节点均已跳过时是否跳过
}
type WorkflowNodeConfigForCondition struct {
@@ -128,7 +129,7 @@ func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply {
CAProvider: maputil.GetString(n.Config, "caProvider"),
CAProviderAccessId: maputil.GetString(n.Config, "caProviderAccessId"),
CAProviderConfig: maputil.GetKVMapAny(n.Config, "caProviderConfig"),
KeyAlgorithm: maputil.GetString(n.Config, "keyAlgorithm"),
KeyAlgorithm: maputil.GetOrDefaultString(n.Config, "keyAlgorithm", string(CertificateKeyAlgorithmTypeRSA2048)),
Nameservers: maputil.GetString(n.Config, "nameservers"),
DnsPropagationWait: maputil.GetInt32(n.Config, "dnsPropagationWait"),
DnsPropagationTimeout: maputil.GetInt32(n.Config, "dnsPropagationTimeout"),
@@ -169,12 +170,13 @@ func (n *WorkflowNode) GetConfigForDeploy() WorkflowNodeConfigForDeploy {
func (n *WorkflowNode) GetConfigForNotify() WorkflowNodeConfigForNotify {
return WorkflowNodeConfigForNotify{
Channel: maputil.GetString(n.Config, "channel"),
Provider: maputil.GetString(n.Config, "provider"),
ProviderAccessId: maputil.GetString(n.Config, "providerAccessId"),
ProviderConfig: maputil.GetKVMapAny(n.Config, "providerConfig"),
Subject: maputil.GetString(n.Config, "subject"),
Message: maputil.GetString(n.Config, "message"),
Channel: maputil.GetString(n.Config, "channel"),
Provider: maputil.GetString(n.Config, "provider"),
ProviderAccessId: maputil.GetString(n.Config, "providerAccessId"),
ProviderConfig: maputil.GetKVMapAny(n.Config, "providerConfig"),
Subject: maputil.GetString(n.Config, "subject"),
Message: maputil.GetString(n.Config, "message"),
SkipOnAllPrevSkipped: maputil.GetBool(n.Config, "skipOnAllPrevSkipped"),
}
}

View File

@@ -1,9 +1,8 @@
package lego_aliyunesa
package internal
import (
"errors"
"fmt"
"strings"
"sync"
"time"
@@ -102,13 +101,13 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return fmt.Errorf("alicloud-esa: could not find zone for domain %q: %w", domain, err)
}
siteName := strings.TrimRight(authZone, ".")
siteName := dns01.UnFqdn(authZone)
siteId, err := d.getSiteId(siteName)
if err != nil {
return fmt.Errorf("alicloud-esa: could not find site for zone %q: %w", siteName, err)
}
if err := d.addOrUpdateDNSRecord(siteId, strings.TrimRight(info.EffectiveFQDN, "."), info.Value); err != nil {
if err := d.addOrUpdateDNSRecord(siteId, dns01.UnFqdn(info.EffectiveFQDN), info.Value); err != nil {
return fmt.Errorf("alicloud-esa: %w", err)
}
@@ -123,13 +122,13 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("alicloud-esa: could not find zone for domain %q: %w", domain, err)
}
siteName := strings.TrimRight(authZone, ".")
siteName := dns01.UnFqdn(authZone)
siteId, err := d.getSiteId(siteName)
if err != nil {
return fmt.Errorf("alicloud-esa: could not find site for zone %q: %w", siteName, err)
}
if err := d.removeDNSRecord(siteId, strings.TrimRight(info.EffectiveFQDN, ".")); err != nil {
if err := d.removeDNSRecord(siteId, dns01.UnFqdn(info.EffectiveFQDN)); err != nil {
return fmt.Errorf("alicloud-esa: %w", err)
}

View File

@@ -1,4 +1,4 @@
package lego_baiducloud
package internal
import (
"errors"

View File

@@ -0,0 +1,38 @@
package cloudns
import (
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/providers/dns/constellix"
)
type ChallengeProviderConfig struct {
ApiKey string `json:"apiKey"`
SecretKey string `json:"secretKey"`
DnsPropagationTimeout int32 `json:"dnsPropagationTimeout,omitempty"`
DnsTTL int32 `json:"dnsTTL,omitempty"`
}
func NewChallengeProvider(config *ChallengeProviderConfig) (challenge.Provider, error) {
if config == nil {
panic("config is nil")
}
providerConfig := constellix.NewDefaultConfig()
providerConfig.APIKey = config.ApiKey
providerConfig.SecretKey = config.SecretKey
if config.DnsPropagationTimeout != 0 {
providerConfig.PropagationTimeout = time.Duration(config.DnsPropagationTimeout) * time.Second
}
if config.DnsTTL != 0 {
providerConfig.TTL = int(config.DnsTTL)
}
provider, err := constellix.NewDNSProviderConfig(providerConfig)
if err != nil {
return nil, err
}
return provider, nil
}

View File

@@ -1,4 +1,4 @@
package lego_dnsla
package internal
import (
"errors"

View File

@@ -1,4 +1,4 @@
package lego_dynv6
package internal
import (
"context"

View File

@@ -1,4 +1,4 @@
package lego_gname
package internal
import (
"errors"

View File

@@ -1,4 +1,4 @@
package lego_jdcloud
package internal
import (
"errors"

View File

@@ -1,10 +1,9 @@
package lego_tencentcloudeo
package internal
import (
"errors"
"fmt"
"math"
"strings"
"time"
"github.com/go-acme/lego/v4/challenge"
@@ -91,7 +90,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
if err := d.addOrUpdateDNSRecord(strings.TrimRight(info.EffectiveFQDN, "."), info.Value); err != nil {
if err := d.addOrUpdateDNSRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value); err != nil {
return fmt.Errorf("tencentcloud-eo: %w", err)
}
@@ -101,7 +100,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
if err := d.removeDNSRecord(strings.TrimRight(info.EffectiveFQDN, ".")); err != nil {
if err := d.removeDNSRecord(dns01.UnFqdn(info.EffectiveFQDN)); err != nil {
return fmt.Errorf("tencentcloud-eo: %w", err)
}

View File

@@ -0,0 +1,165 @@
package internal
import (
"errors"
"fmt"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/ucloud/ucloud-sdk-go/ucloud"
"github.com/ucloud/ucloud-sdk-go/ucloud/auth"
"github.com/usual2970/certimate/internal/pkg/sdk3rd/ucloud/udnr"
)
const (
envNamespace = "UCLOUDUDNR_"
EnvPublicKey = envNamespace + "PUBLIC_KEY"
EnvPrivateKey = envNamespace + "PRIVATE_KEY"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
type Config struct {
PrivateKey string
PublicKey string
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int32
HTTPTimeout time.Duration
}
type DNSProvider struct {
client *udnr.UDNRClient
config *Config
}
func NewDefaultConfig() *Config {
return &Config{
TTL: int32(env.GetOrDefaultInt(EnvTTL, 300)),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
}
}
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvPrivateKey, EnvPublicKey)
if err != nil {
return nil, fmt.Errorf("ucloud-udnr: %w", err)
}
config := NewDefaultConfig()
config.PrivateKey = values[EnvPrivateKey]
config.PublicKey = values[EnvPublicKey]
return NewDNSProviderConfig(config)
}
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("ucloud-udnr: the configuration of the DNS provider is nil")
}
cfg := ucloud.NewConfig()
credential := auth.NewCredential()
credential.PrivateKey = config.PrivateKey
credential.PublicKey = config.PublicKey
client := udnr.NewClient(&cfg, &credential)
return &DNSProvider{
client: client,
config: config,
}, nil
}
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("ucloud-udnr: could not find zone for domain %q: %w", domain, err)
}
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
if err != nil {
return fmt.Errorf("ucloud-udnr: %w", err)
}
udnrDomainDNSQueryReq := d.client.NewQueryDomainDNSRequest()
udnrDomainDNSQueryReq.Dn = ucloud.String(authZone)
if udnrDomainDNSQueryResp, err := d.client.QueryDomainDNS(udnrDomainDNSQueryReq); err != nil {
return fmt.Errorf("ucloud-udnr: %w", err)
} else {
for _, record := range udnrDomainDNSQueryResp.Data {
if record.DnsType == "TXT" && record.RecordName == subDomain {
udnrDomainDNSDeleteReq := d.client.NewDeleteDomainDNSRequest()
udnrDomainDNSDeleteReq.Dn = ucloud.String(authZone)
udnrDomainDNSDeleteReq.DnsType = ucloud.String(record.DnsType)
udnrDomainDNSDeleteReq.RecordName = ucloud.String(record.RecordName)
udnrDomainDNSDeleteReq.Content = ucloud.String(record.Content)
d.client.DeleteDomainDNS(udnrDomainDNSDeleteReq)
break
}
}
}
udnrDomainDNSAddReq := d.client.NewAddDomainDNSRequest()
udnrDomainDNSAddReq.Dn = ucloud.String(authZone)
udnrDomainDNSAddReq.DnsType = ucloud.String("TXT")
udnrDomainDNSAddReq.RecordName = ucloud.String(subDomain)
udnrDomainDNSAddReq.Content = ucloud.String(info.Value)
udnrDomainDNSAddReq.TTL = ucloud.Int(int(d.config.TTL))
if _, err := d.client.AddDomainDNS(udnrDomainDNSAddReq); err != nil {
return fmt.Errorf("ucloud-udnr: %w", err)
}
return nil
}
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
if err != nil {
return fmt.Errorf("ucloud-udnr: could not find zone for domain %q: %w", domain, err)
}
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
if err != nil {
return fmt.Errorf("ucloud-udnr: %w", err)
}
udnrDomainDNSQueryReq := d.client.NewQueryDomainDNSRequest()
udnrDomainDNSQueryReq.Dn = ucloud.String(authZone)
if udnrDomainDNSQueryResp, err := d.client.QueryDomainDNS(udnrDomainDNSQueryReq); err != nil {
return fmt.Errorf("ucloud-udnr: %w", err)
} else {
for _, record := range udnrDomainDNSQueryResp.Data {
if record.DnsType == "TXT" && record.RecordName == subDomain {
udnrDomainDNSDeleteReq := d.client.NewDeleteDomainDNSRequest()
udnrDomainDNSDeleteReq.Dn = ucloud.String(authZone)
udnrDomainDNSDeleteReq.DnsType = ucloud.String(record.DnsType)
udnrDomainDNSDeleteReq.RecordName = ucloud.String(record.RecordName)
udnrDomainDNSDeleteReq.Content = ucloud.String(record.Content)
d.client.DeleteDomainDNS(udnrDomainDNSDeleteReq)
break
}
}
}
return nil
}
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}

View File

@@ -0,0 +1,40 @@
package ucloududnr
import (
"errors"
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr/internal"
)
type ChallengeProviderConfig struct {
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
DnsPropagationTimeout int32 `json:"dnsPropagationTimeout,omitempty"`
DnsTTL int32 `json:"dnsTTL,omitempty"`
}
func NewChallengeProvider(config *ChallengeProviderConfig) (challenge.Provider, error) {
if config == nil {
return nil, errors.New("config is nil")
}
providerConfig := internal.NewDefaultConfig()
providerConfig.PrivateKey = config.PrivateKey
providerConfig.PublicKey = config.PublicKey
if config.DnsTTL != 0 {
providerConfig.TTL = config.DnsTTL
}
if config.DnsPropagationTimeout != 0 {
providerConfig.PropagationTimeout = time.Duration(config.DnsPropagationTimeout) * time.Second
}
provider, err := internal.NewDNSProviderConfig(providerConfig)
if err != nil {
return nil, err
}
return provider, nil
}

View File

@@ -14,7 +14,8 @@ import (
"github.com/usual2970/certimate/internal/pkg/core/deployer"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
uploadersp "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/aws-acm"
uploaderspacm "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/aws-acm"
uploaderspiam "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/aws-iam"
)
type DeployerConfig struct {
@@ -26,6 +27,9 @@ type DeployerConfig struct {
Region string `json:"region"`
// AWS CloudFront 分配 ID。
DistributionId string `json:"distributionId"`
// AWS CloudFront 证书来源。
// 可取值 "ACM"、"IAM"。
CertificateSource string `json:"certificateSource"`
}
type DeployerProvider struct {
@@ -47,13 +51,28 @@ func NewDeployer(config *DeployerConfig) (*DeployerProvider, error) {
return nil, fmt.Errorf("failed to create sdk client: %w", err)
}
uploader, err := uploadersp.NewUploader(&uploadersp.UploaderConfig{
AccessKeyId: config.AccessKeyId,
SecretAccessKey: config.SecretAccessKey,
Region: config.Region,
})
if err != nil {
return nil, fmt.Errorf("failed to create ssl uploader: %w", err)
var uploader uploader.Uploader
if config.CertificateSource == "ACM" {
uploader, err = uploaderspacm.NewUploader(&uploaderspacm.UploaderConfig{
AccessKeyId: config.AccessKeyId,
SecretAccessKey: config.SecretAccessKey,
Region: config.Region,
})
if err != nil {
return nil, fmt.Errorf("failed to create ssl uploader: %w", err)
}
} else if config.CertificateSource == "IAM" {
uploader, err = uploaderspiam.NewUploader(&uploaderspiam.UploaderConfig{
AccessKeyId: config.AccessKeyId,
SecretAccessKey: config.SecretAccessKey,
Region: config.Region,
CertificatePath: "/cloudfront/",
})
if err != nil {
return nil, fmt.Errorf("failed to create ssl uploader: %w", err)
}
} else {
return nil, fmt.Errorf("unsupported certificate source: '%s'", config.CertificateSource)
}
return &DeployerProvider{
@@ -79,7 +98,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
return nil, errors.New("config `distribuitionId` is required")
}
// 上传证书到 ACM
// 上传证书到 ACM/IAM
upres, err := d.sslUploader.Upload(ctx, certPEM, privkeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to upload certificate file: %w", err)
@@ -109,7 +128,19 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
updateDistributionReq.DistributionConfig.ViewerCertificate = &types.ViewerCertificate{}
}
updateDistributionReq.DistributionConfig.ViewerCertificate.CloudFrontDefaultCertificate = aws.Bool(false)
updateDistributionReq.DistributionConfig.ViewerCertificate.ACMCertificateArn = aws.String(upres.CertId)
if d.config.CertificateSource == "ACM" {
updateDistributionReq.DistributionConfig.ViewerCertificate.ACMCertificateArn = aws.String(upres.CertId)
updateDistributionReq.DistributionConfig.ViewerCertificate.IAMCertificateId = nil
} else if d.config.CertificateSource == "IAM" {
updateDistributionReq.DistributionConfig.ViewerCertificate.ACMCertificateArn = nil
updateDistributionReq.DistributionConfig.ViewerCertificate.IAMCertificateId = aws.String(upres.CertId)
if updateDistributionReq.DistributionConfig.ViewerCertificate.MinimumProtocolVersion == "" {
updateDistributionReq.DistributionConfig.ViewerCertificate.MinimumProtocolVersion = types.MinimumProtocolVersionTLSv1
}
if updateDistributionReq.DistributionConfig.ViewerCertificate.SSLSupportMethod == "" {
updateDistributionReq.DistributionConfig.ViewerCertificate.SSLSupportMethod = types.SSLSupportMethodSniOnly
}
}
updateDistributionResp, err := d.sdkClient.UpdateDistribution(context.TODO(), updateDistributionReq)
d.logger.Debug("sdk request 'cloudfront.UpdateDistribution'", slog.Any("request", updateDistributionReq), slog.Any("response", updateDistributionResp))
if err != nil {

View File

@@ -0,0 +1,75 @@
package awsiam
import (
"context"
"fmt"
"log/slog"
"github.com/usual2970/certimate/internal/pkg/core/deployer"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
uploadersp "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/aws-iam"
)
type DeployerConfig struct {
// AWS AccessKeyId。
AccessKeyId string `json:"accessKeyId"`
// AWS SecretAccessKey。
SecretAccessKey string `json:"secretAccessKey"`
// AWS 区域。
Region string `json:"region"`
// IAM 证书路径。
// 选填。
CertificatePath string `json:"certificatePath,omitempty"`
}
type DeployerProvider struct {
config *DeployerConfig
logger *slog.Logger
sslUploader uploader.Uploader
}
var _ deployer.Deployer = (*DeployerProvider)(nil)
func NewDeployer(config *DeployerConfig) (*DeployerProvider, error) {
if config == nil {
panic("config is nil")
}
uploader, err := uploadersp.NewUploader(&uploadersp.UploaderConfig{
AccessKeyId: config.AccessKeyId,
SecretAccessKey: config.SecretAccessKey,
Region: config.Region,
CertificatePath: config.CertificatePath,
})
if err != nil {
return nil, fmt.Errorf("failed to create ssl uploader: %w", err)
}
return &DeployerProvider{
config: config,
logger: slog.Default(),
sslUploader: uploader,
}, nil
}
func (d *DeployerProvider) WithLogger(logger *slog.Logger) deployer.Deployer {
if logger == nil {
d.logger = slog.New(slog.DiscardHandler)
} else {
d.logger = logger
}
d.sslUploader.WithLogger(logger)
return d
}
func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*deployer.DeployResult, error) {
// 上传证书到 IAM
upres, err := d.sslUploader.Upload(ctx, certPEM, privkeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to upload certificate file: %w", err)
} else {
d.logger.Info("ssl certificate uploaded", slog.Any("result", upres))
}
return &deployer.DeployResult{}, nil
}

View File

@@ -285,6 +285,7 @@ func (d *DeployerProvider) updateHttpsListenerCertificate(ctx context.Context, c
ClientToken: generateClientToken(),
ListenerPort: uint16(cloudHttpsListenerPort),
Scheduler: describeAppHTTPSListenersResp.ListenerList[0].Scheduler,
CertIds: describeAppHTTPSListenersResp.ListenerList[0].CertIds,
AdditionalCertDomains: sliceutil.Map(describeAppHTTPSListenersResp.ListenerList[0].AdditionalCertDomains, func(domain bceappblb.AdditionalCertDomainsModel) bceappblb.AdditionalCertDomainsModel {
if domain.Host == d.config.Domain {
return bceappblb.AdditionalCertDomainsModel{

View File

@@ -283,6 +283,7 @@ func (d *DeployerProvider) updateHttpsListenerCertificate(ctx context.Context, c
updateHTTPSListenerReq := &bceblb.UpdateHTTPSListenerArgs{
ClientToken: generateClientToken(),
ListenerPort: uint16(cloudHttpsListenerPort),
CertIds: describeHTTPSListenersResp.ListenerList[0].CertIds,
AdditionalCertDomains: sliceutil.Map(describeHTTPSListenersResp.ListenerList[0].AdditionalCertDomains, func(domain bceblb.AdditionalCertDomainsModel) bceblb.AdditionalCertDomainsModel {
if domain.Host == d.config.Domain {
return bceblb.AdditionalCertDomainsModel{

View File

@@ -8,6 +8,7 @@ import (
"net"
"os"
"path/filepath"
"strings"
"github.com/pkg/sftp"
"github.com/povsister/scp"
@@ -24,7 +25,12 @@ type JumpServerConfig struct {
// SSH 端口。
// 零值时默认值 22。
SshPort int32 `json:"sshPort,omitempty"`
// SSH 认证方式。
// 可取值 "none"、"password"、"key"。
// 零值时根据有无密码或私钥字段决定。
SshAuthMethod string `json:"sshAuthMethod,omitempty"`
// SSH 登录用户名。
// 零值时默认值 "root"。
SshUsername string `json:"sshUsername,omitempty"`
// SSH 登录密码。
SshPassword string `json:"sshPassword,omitempty"`
@@ -41,7 +47,12 @@ type DeployerConfig struct {
// SSH 端口。
// 零值时默认值 22。
SshPort int32 `json:"sshPort,omitempty"`
// SSH 认证方式。
// 可取值 "none"、"password" 或 "key"。
// 零值时根据有无密码或私钥字段决定。
SshAuthMethod string `json:"sshAuthMethod,omitempty"`
// SSH 登录用户名。
// 零值时默认值 "root"。
SshUsername string `json:"sshUsername,omitempty"`
// SSH 登录密码。
SshPassword string `json:"sshPassword,omitempty"`
@@ -141,6 +152,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
jumpConn,
jumpServerConf.SshHost,
jumpServerConf.SshPort,
jumpServerConf.SshAuthMethod,
jumpServerConf.SshUsername,
jumpServerConf.SshPassword,
jumpServerConf.SshKey,
@@ -174,6 +186,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
targetConn,
d.config.SshHost,
d.config.SshPort,
d.config.SshAuthMethod,
d.config.SshUsername,
d.config.SshPassword,
d.config.SshKey,
@@ -262,7 +275,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
return &deployer.DeployResult{}, nil
}
func createSshClient(conn net.Conn, host string, port int32, username string, password string, key string, keyPassphrase string) (*ssh.Client, error) {
func createSshClient(conn net.Conn, host string, port int32, authMethod string, username, password, key, keyPassphrase string) (*ssh.Client, error) {
if host == "" {
host = "localhost"
}
@@ -271,28 +284,65 @@ func createSshClient(conn net.Conn, host string, port int32, username string, pa
port = 22
}
var authMethod ssh.AuthMethod
if key != "" {
var signer ssh.Signer
var err error
if username == "" {
username = "root"
}
if keyPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(keyPassphrase))
const AUTH_METHOD_NONE = "none"
const AUTH_METHOD_PASSWORD = "password"
const AUTH_METHOD_KEY = "key"
if authMethod == "" {
if key != "" {
authMethod = AUTH_METHOD_KEY
} else if password != "" {
authMethod = AUTH_METHOD_PASSWORD
} else {
signer, err = ssh.ParsePrivateKey([]byte(key))
authMethod = AUTH_METHOD_NONE
}
}
authentications := make([]ssh.AuthMethod, 0)
switch authMethod {
case AUTH_METHOD_NONE:
{
}
if err != nil {
return nil, err
case AUTH_METHOD_PASSWORD:
{
authentications = append(authentications, ssh.Password(password))
authentications = append(authentications, ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
if len(questions) == 1 {
return []string{password}, nil
}
return nil, fmt.Errorf("unexpected keyboard interactive question [%s]", strings.Join(questions, ", "))
}))
}
authMethod = ssh.PublicKeys(signer)
} else {
authMethod = ssh.Password(password)
case AUTH_METHOD_KEY:
{
var signer ssh.Signer
var err error
if keyPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(keyPassphrase))
} else {
signer, err = ssh.ParsePrivateKey([]byte(key))
}
if err != nil {
return nil, err
}
authentications = append(authentications, ssh.PublicKeys(signer))
}
default:
return nil, fmt.Errorf("unsupported auth method '%s'", authMethod)
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, fmt.Sprintf("%s:%d", host, port), &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{authMethod},
Auth: authentications,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {

View File

@@ -136,7 +136,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
}
// 循环获取部署任务详情,等待任务状态变更
// REF: https://cloud.tencent.com.cn/document/api/400/91658
// REF: https://cloud.tencent.com/document/api/400/91658
for {
select {
case <-ctx.Done():

View File

@@ -153,7 +153,7 @@ func (d *DeployerProvider) deployViaSslService(ctx context.Context, cloudCertId
}
// 循环获取部署任务详情,等待任务状态变更
// REF: https://cloud.tencent.com.cn/document/api/400/91658
// REF: https://cloud.tencent.com/document/api/400/91658
for {
select {
case <-ctx.Done():

View File

@@ -104,7 +104,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
}
// 循环获取部署任务详情,等待任务状态变更
// REF: https://cloud.tencent.com.cn/document/api/400/91658
// REF: https://cloud.tencent.com/document/api/400/91658
for {
select {
case <-ctx.Done():

View File

@@ -119,7 +119,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
}
// 循环获取部署任务详情,等待任务状态变更
// REF: https://cloud.tencent.com.cn/document/api/400/91658
// REF: https://cloud.tencent.com/document/api/400/91658
for {
select {
case <-ctx.Done():

View File

@@ -106,7 +106,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
}
// 循环获取部署任务详情,等待任务状态变更
// REF: https://cloud.tencent.com.cn/document/api/400/91658
// REF: https://cloud.tencent.com/document/api/400/91658
for {
select {
case <-ctx.Done():

View File

@@ -6,11 +6,13 @@ import (
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/usual2970/certimate/internal/pkg/core/deployer"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
uploadersp "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/wangsu-certificate"
wangsusdk "github.com/usual2970/certimate/internal/pkg/sdk3rd/wangsu/cdn"
sliceutil "github.com/usual2970/certimate/internal/pkg/utils/slice"
)
type DeployerConfig struct {
@@ -18,7 +20,7 @@ type DeployerConfig struct {
AccessKeyId string `json:"accessKeyId"`
// 网宿云 AccessKeySecret。
AccessKeySecret string `json:"accessKeySecret"`
// 加速域名数组。
// 加速域名数组(支持泛域名)
Domains []string `json:"domains"`
}
@@ -80,7 +82,10 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
certId, _ := strconv.ParseInt(upres.CertId, 10, 64)
batchUpdateCertificateConfigReq := &wangsusdk.BatchUpdateCertificateConfigRequest{
CertificateId: certId,
DomainNames: d.config.Domains,
DomainNames: sliceutil.Map(d.config.Domains, func(domain string) string {
// "*.example.com" → ".example.com",适配网宿云 CDN 要求的泛域名格式
return strings.TrimPrefix(domain, "*")
}),
}
batchUpdateCertificateConfigResp, err := d.sdkClient.BatchUpdateCertificateConfig(batchUpdateCertificateConfigReq)
d.logger.Debug("sdk request 'cdn.BatchUpdateCertificateConfig'", slog.Any("request", batchUpdateCertificateConfigReq), slog.Any("response", batchUpdateCertificateConfigResp))

View File

@@ -74,7 +74,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPE
// 获取证书列表,避免重复上传
// REF: https://docs.aws.amazon.com/en_us/acm/latest/APIReference/API_ListCertificates.html
var listCertificatesNextToken *string = nil
listCertificatesMaxItems := int32(1000)
var listCertificatesMaxItems int32 = 1000
for {
select {
case <-ctx.Done():
@@ -107,7 +107,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPE
}
// 最后对比证书内容
// REF: https://docs.aws.amazon.com/en_us/acm/latest/APIReference/API_ListTagsForCertificate.html
// REF: https://docs.aws.amazon.com/en_us/acm/latest/APIReference/API_GetCertificate.html
getCertificateReq := &awsacm.GetCertificateInput{
CertificateArn: certSummary.CertificateArn,
}
@@ -115,11 +115,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPE
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'acm.GetCertificate': %w", err)
} else {
oldCertPEM := aws.ToString(getCertificateResp.CertificateChain)
if oldCertPEM == "" {
oldCertPEM = aws.ToString(getCertificateResp.Certificate)
}
oldCertPEM := aws.ToString(getCertificateResp.Certificate)
oldCertX509, err := certutil.ParseCertificateFromPEM(oldCertPEM)
if err != nil {
continue
@@ -158,7 +154,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPE
}
return &uploader.UploadResult{
CertId: *importCertificateResp.CertificateArn,
CertId: aws.ToString(importCertificateResp.CertificateArn),
}, nil
}

View File

@@ -0,0 +1,185 @@
package awsiam
import (
"context"
"fmt"
"log/slog"
"time"
aws "github.com/aws/aws-sdk-go-v2/aws"
awscfg "github.com/aws/aws-sdk-go-v2/config"
awscred "github.com/aws/aws-sdk-go-v2/credentials"
awsiam "github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
certutil "github.com/usual2970/certimate/internal/pkg/utils/cert"
)
type UploaderConfig struct {
// AWS AccessKeyId。
AccessKeyId string `json:"accessKeyId"`
// AWS SecretAccessKey。
SecretAccessKey string `json:"secretAccessKey"`
// AWS 区域。
Region string `json:"region"`
// IAM 证书路径。
// 选填。
CertificatePath string `json:"certificatePath,omitempty"`
}
type UploaderProvider struct {
config *UploaderConfig
logger *slog.Logger
sdkClient *awsiam.Client
}
var _ uploader.Uploader = (*UploaderProvider)(nil)
func NewUploader(config *UploaderConfig) (*UploaderProvider, error) {
if config == nil {
panic("config is nil")
}
client, err := createSdkClient(config.AccessKeyId, config.SecretAccessKey, config.Region)
if err != nil {
return nil, fmt.Errorf("failed to create sdk client: %w", err)
}
return &UploaderProvider{
config: config,
logger: slog.Default(),
sdkClient: client,
}, nil
}
func (u *UploaderProvider) WithLogger(logger *slog.Logger) uploader.Uploader {
if logger == nil {
u.logger = slog.New(slog.DiscardHandler)
} else {
u.logger = logger
}
return u
}
func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPEM string) (*uploader.UploadResult, error) {
// 解析证书内容
certX509, err := certutil.ParseCertificateFromPEM(certPEM)
if err != nil {
return nil, err
}
// 提取服务器证书
serverCertPEM, intermediaCertPEM, err := certutil.ExtractCertificatesFromPEM(certPEM)
if err != nil {
return nil, fmt.Errorf("failed to extract certs: %w", err)
}
// 获取证书列表,避免重复上传
// REF: https://docs.aws.amazon.com/en_us/IAM/latest/APIReference/API_ListServerCertificates.html
var listServerCertificatesMarker *string = nil
var listServerCertificatesMaxItems int32 = 1000
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
listServerCertificatesReq := &awsiam.ListServerCertificatesInput{
Marker: listServerCertificatesMarker,
MaxItems: aws.Int32(listServerCertificatesMaxItems),
}
if u.config.CertificatePath != "" {
listServerCertificatesReq.PathPrefix = aws.String(u.config.CertificatePath)
}
listServerCertificatesResp, err := u.sdkClient.ListServerCertificates(context.TODO(), listServerCertificatesReq)
u.logger.Debug("sdk request 'iam.ListServerCertificates'", slog.Any("request", listServerCertificatesReq), slog.Any("response", listServerCertificatesResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'iam.ListServerCertificates': %w", err)
}
for _, certMeta := range listServerCertificatesResp.ServerCertificateMetadataList {
// 先对比证书路径
if u.config.CertificatePath != "" && aws.ToString(certMeta.Path) != u.config.CertificatePath {
continue
}
// 先对比证书有效期
if certMeta.Expiration == nil || !certMeta.Expiration.Equal(certX509.NotAfter) {
continue
}
// 最后对比证书内容
// REF: https://docs.aws.amazon.com/en_us/IAM/latest/APIReference/API_GetServerCertificate.html
getServerCertificateReq := &awsiam.GetServerCertificateInput{
ServerCertificateName: certMeta.ServerCertificateName,
}
getServerCertificateResp, err := u.sdkClient.GetServerCertificate(context.TODO(), getServerCertificateReq)
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'iam.GetServerCertificate': %w", err)
} else {
oldCertPEM := aws.ToString(getServerCertificateResp.ServerCertificate.CertificateBody)
oldCertX509, err := certutil.ParseCertificateFromPEM(oldCertPEM)
if err != nil {
continue
}
if !certutil.EqualCertificate(certX509, oldCertX509) {
continue
}
}
// 如果以上信息都一致,则视为已存在相同证书,直接返回
u.logger.Info("ssl certificate already exists")
return &uploader.UploadResult{
CertId: aws.ToString(certMeta.ServerCertificateId),
CertName: aws.ToString(certMeta.ServerCertificateName),
}, nil
}
if listServerCertificatesResp.Marker == nil || len(listServerCertificatesResp.ServerCertificateMetadataList) < int(listServerCertificatesMaxItems) {
break
} else {
listServerCertificatesMarker = listServerCertificatesResp.Marker
}
}
// 生成新证书名(需符合 AWS IAM 命名规则)
certName := fmt.Sprintf("certimate-%d", time.Now().UnixMilli())
// 导入证书
// REF: https://docs.aws.amazon.com/en_us/IAM/latest/APIReference/API_UploadServerCertificate.html
uploadServerCertificateReq := &awsiam.UploadServerCertificateInput{
ServerCertificateName: aws.String(certName),
Path: aws.String(u.config.CertificatePath),
CertificateBody: aws.String(serverCertPEM),
CertificateChain: aws.String(intermediaCertPEM),
PrivateKey: aws.String(privkeyPEM),
}
if u.config.CertificatePath == "" {
uploadServerCertificateReq.Path = aws.String("/")
}
uploadServerCertificateResp, err := u.sdkClient.UploadServerCertificate(context.TODO(), uploadServerCertificateReq)
u.logger.Debug("sdk request 'iam.UploadServerCertificate'", slog.Any("request", uploadServerCertificateReq), slog.Any("response", uploadServerCertificateResp))
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'iam.UploadServerCertificate': %w", err)
}
return &uploader.UploadResult{
CertId: aws.ToString(uploadServerCertificateResp.ServerCertificateMetadata.ServerCertificateId),
CertName: certName,
}, nil
}
func createSdkClient(accessKeyId, secretAccessKey, region string) (*awsiam.Client, error) {
cfg, err := awscfg.LoadDefaultConfig(context.TODO())
if err != nil {
return nil, err
}
client := awsiam.NewFromConfig(cfg, func(o *awsiam.Options) {
o.Region = region
o.Credentials = aws.NewCredentialsCache(awscred.NewStaticCredentialsProvider(accessKeyId, secretAccessKey, ""))
})
return client, nil
}

View File

@@ -0,0 +1,115 @@
package udnr
import (
"github.com/ucloud/ucloud-sdk-go/ucloud/request"
"github.com/ucloud/ucloud-sdk-go/ucloud/response"
)
type QueryDomainDNSRequest struct {
request.CommonBase
Dn *string `required:"true"`
}
type QueryDomainDNSResponse struct {
response.CommonBase
Data []DomainDNSRecord
}
func (c *UDNRClient) NewQueryDomainDNSRequest() *QueryDomainDNSRequest {
req := &QueryDomainDNSRequest{}
c.Client.SetupRequest(req)
req.SetRetryable(false)
return req
}
func (c *UDNRClient) QueryDomainDNS(req *QueryDomainDNSRequest) (*QueryDomainDNSResponse, error) {
var err error
var res QueryDomainDNSResponse
reqCopier := *req
err = c.Client.InvokeAction("UdnrDomainDNSQuery", &reqCopier, &res)
if err != nil {
return &res, err
}
return &res, nil
}
type AddDomainDNSRequest struct {
request.CommonBase
Dn *string `required:"true"`
DnsType *string `required:"true"`
RecordName *string `required:"true"`
Content *string `required:"true"`
TTL *int `required:"true"`
Prio *int `required:"false"`
}
type AddDomainDNSResponse struct {
response.CommonBase
}
func (c *UDNRClient) NewAddDomainDNSRequest() *AddDomainDNSRequest {
req := &AddDomainDNSRequest{}
c.Client.SetupRequest(req)
req.SetRetryable(false)
return req
}
func (c *UDNRClient) AddDomainDNS(req *AddDomainDNSRequest) (*AddDomainDNSResponse, error) {
var err error
var res AddDomainDNSResponse
reqCopier := *req
err = c.Client.InvokeAction("UdnrDomainDNSAdd", &reqCopier, &res)
if err != nil {
return &res, err
}
return &res, nil
}
type DeleteDomainDNSRequest struct {
request.CommonBase
Dn *string `required:"true"`
DnsType *string `required:"true"`
RecordName *string `required:"true"`
Content *string `required:"true"`
}
type DeleteDomainDNSResponse struct {
response.CommonBase
}
func (c *UDNRClient) NewDeleteDomainDNSRequest() *DeleteDomainDNSRequest {
req := &DeleteDomainDNSRequest{}
c.Client.SetupRequest(req)
req.SetRetryable(false)
return req
}
func (c *UDNRClient) DeleteDomainDNS(req *DeleteDomainDNSRequest) (*DeleteDomainDNSResponse, error) {
var err error
var res DeleteDomainDNSResponse
reqCopier := *req
err = c.Client.InvokeAction("UdnrDeleteDnsRecord", &reqCopier, &res)
if err != nil {
return &res, err
}
return &res, nil
}

View File

@@ -0,0 +1,18 @@
package udnr
import (
"github.com/ucloud/ucloud-sdk-go/ucloud"
"github.com/ucloud/ucloud-sdk-go/ucloud/auth"
)
type UDNRClient struct {
*ucloud.Client
}
func NewClient(config *ucloud.Config, credential *auth.Credential) *UDNRClient {
meta := ucloud.ClientMeta{Product: "UDNR"}
client := ucloud.NewClientWithMeta(config, credential, meta)
return &UDNRClient{
client,
}
}

View File

@@ -0,0 +1,9 @@
package udnr
type DomainDNSRecord struct {
DnsType string
RecordName string
Content string
TTL int
Prio int
}

View File

@@ -77,13 +77,14 @@ func (r *CertificateRepository) GetByWorkflowNodeId(ctx context.Context, workflo
return r.castRecordToModel(records[0])
}
func (r *CertificateRepository) GetByWorkflowRunId(ctx context.Context, workflowRunId string) (*domain.Certificate, error) {
func (r *CertificateRepository) GetByWorkflowRunIdAndNodeId(ctx context.Context, workflowRunId string, workflowNodeId string) (*domain.Certificate, error) {
records, err := app.GetApp().FindRecordsByFilter(
domain.CollectionNameCertificate,
"workflowRunId={:workflowRunId} && deleted=null",
"workflowRunId={:workflowRunId} && workflowNodeId={:workflowNodeId} && deleted=null",
"-created",
1, 0,
dbx.Params{"workflowRunId": workflowRunId},
dbx.Params{"workflowNodeId": workflowNodeId},
)
if err != nil {
return nil, err

View File

@@ -112,6 +112,7 @@ func (w *workflowInvoker) processNode(ctx context.Context, node *domain.Workflow
break
}
// TODO: 优化可读性
if procErr != nil && current.Type == domain.WorkflowNodeTypeCondition {
current = nil

View File

@@ -3,6 +3,7 @@ package nodeprocessor
import (
"context"
"fmt"
"log/slog"
"strconv"
"time"
@@ -35,7 +36,8 @@ func NewApplyNode(node *domain.WorkflowNode) *applyNode {
}
func (n *applyNode) Process(ctx context.Context) error {
n.logger.Info("ready to obtain certificiate ...")
nodeCfg := n.node.GetConfigForApply()
n.logger.Info("ready to obtain certificiate ...", slog.Any("config", nodeCfg))
// 查询上次执行结果
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
@@ -45,6 +47,7 @@ func (n *applyNode) Process(ctx context.Context) error {
// 检测是否可以跳过本次执行
if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable {
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true)
n.logger.Info(fmt.Sprintf("skip this application, because %s", reason))
return nil
} else if reason != "" {
@@ -101,8 +104,8 @@ func (n *applyNode) Process(ctx context.Context) error {
}
// 保存 ARI 记录
if applyResult.ARIReplaced {
lastCertificate, _ := n.certRepo.GetByWorkflowRunId(ctx, lastOutput.RunId)
if applyResult.ARIReplaced && lastOutput != nil {
lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId)
if lastCertificate != nil {
lastCertificate.ACMERenewed = true
n.certRepo.Save(ctx, lastCertificate)
@@ -110,6 +113,7 @@ func (n *applyNode) Process(ctx context.Context) error {
}
// 记录中间结果
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false)
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ExpireAt).Hours()/24), 10)
@@ -120,39 +124,40 @@ func (n *applyNode) Process(ctx context.Context) error {
func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) {
if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致
currentNodeConfig := n.node.GetConfigForApply()
lastNodeConfig := lastOutput.Node.GetConfigForApply()
if currentNodeConfig.Domains != lastNodeConfig.Domains {
thisNodeCfg := n.node.GetConfigForApply()
lastNodeCfg := lastOutput.Node.GetConfigForApply()
if thisNodeCfg.Domains != lastNodeCfg.Domains {
return false, "the configuration item 'Domains' changed"
}
if currentNodeConfig.ContactEmail != lastNodeConfig.ContactEmail {
if thisNodeCfg.ContactEmail != lastNodeCfg.ContactEmail {
return false, "the configuration item 'ContactEmail' changed"
}
if currentNodeConfig.Provider != lastNodeConfig.Provider {
if thisNodeCfg.Provider != lastNodeCfg.Provider {
return false, "the configuration item 'Provider' changed"
}
if currentNodeConfig.ProviderAccessId != lastNodeConfig.ProviderAccessId {
if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId {
return false, "the configuration item 'ProviderAccessId' changed"
}
if !maps.Equal(currentNodeConfig.ProviderConfig, lastNodeConfig.ProviderConfig) {
if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) {
return false, "the configuration item 'ProviderConfig' changed"
}
if currentNodeConfig.CAProvider != lastNodeConfig.CAProvider {
if thisNodeCfg.CAProvider != lastNodeCfg.CAProvider {
return false, "the configuration item 'CAProvider' changed"
}
if currentNodeConfig.CAProviderAccessId != lastNodeConfig.CAProviderAccessId {
if thisNodeCfg.CAProviderAccessId != lastNodeCfg.CAProviderAccessId {
return false, "the configuration item 'CAProviderAccessId' changed"
}
if !maps.Equal(currentNodeConfig.CAProviderConfig, lastNodeConfig.CAProviderConfig) {
if !maps.Equal(thisNodeCfg.CAProviderConfig, lastNodeCfg.CAProviderConfig) {
return false, "the configuration item 'CAProviderConfig' changed"
}
if currentNodeConfig.KeyAlgorithm != lastNodeConfig.KeyAlgorithm {
if thisNodeCfg.KeyAlgorithm != lastNodeCfg.KeyAlgorithm {
return false, "the configuration item 'KeyAlgorithm' changed"
}
lastCertificate, _ := n.certRepo.GetByWorkflowRunId(ctx, lastOutput.RunId)
lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId)
if lastCertificate != nil {
renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24
renewalInterval := time.Duration(thisNodeCfg.SkipBeforeExpiryDays) * time.Hour * 24
expirationTime := time.Until(lastCertificate.ExpireAt)
if expirationTime > renewalInterval {
daysLeft := int(expirationTime.Hours() / 24)
@@ -160,7 +165,7 @@ func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workflo
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(daysLeft), 10)
return true, fmt.Sprintf("the certificate has already been issued (expires in %d day(s), next renewal in %d day(s))", daysLeft, currentNodeConfig.SkipBeforeExpiryDays)
return true, fmt.Sprintf("the certificate has already been issued (expires in %d day(s), next renewal in %d day(s))", daysLeft, thisNodeCfg.SkipBeforeExpiryDays)
}
}
}

View File

@@ -47,6 +47,6 @@ func (n *conditionNode) Process(ctx context.Context) error {
}
func (n *conditionNode) evalExpr(ctx context.Context, expression expr.Expr) (*expr.EvalResult, error) {
variables := GetNodeOutputs(ctx)
variables := GetAllNodeOutputs(ctx)
return expression.Eval(variables)
}

View File

@@ -3,4 +3,5 @@ package nodeprocessor
const (
outputKeyForCertificateValidity = "certificate.validity"
outputKeyForCertificateDaysLeft = "certificate.daysLeft"
outputKeyForNodeSkipped = "node.skipped"
)

View File

@@ -25,6 +25,15 @@ func newNodeOutputsContainer() *nodeOutputsContainer {
}
}
// 获取节点输出容器
func getNodeOutputsContainer(ctx context.Context) *nodeOutputsContainer {
value := ctx.Value(nodeOutputsKey)
if value == nil {
return nil
}
return value.(*nodeOutputsContainer)
}
// 添加节点输出到上下文
func AddNodeOutput(ctx context.Context, nodeId string, output map[string]any) context.Context {
container := getNodeOutputsContainer(ctx)
@@ -50,7 +59,7 @@ func AddNodeOutput(ctx context.Context, nodeId string, output map[string]any) co
func GetNodeOutput(ctx context.Context, nodeId string) map[string]any {
container := getNodeOutputsContainer(ctx)
if container == nil {
return nil
container = newNodeOutputsContainer()
}
container.RLock()
@@ -69,22 +78,11 @@ func GetNodeOutput(ctx context.Context, nodeId string) map[string]any {
return outputCopy
}
// 获取特定节点的特定输出项
func GetNodeOutputValue(ctx context.Context, nodeId string, key string) (any, bool) {
output := GetNodeOutput(ctx, nodeId)
if output == nil {
return nil, false
}
value, exists := output[key]
return value, exists
}
// 获取所有节点输出
func GetNodeOutputs(ctx context.Context) map[string]map[string]any {
func GetAllNodeOutputs(ctx context.Context) map[string]map[string]any {
container := getNodeOutputsContainer(ctx)
if container == nil {
return nil
container = newNodeOutputsContainer()
}
container.RLock()
@@ -103,26 +101,3 @@ func GetNodeOutputs(ctx context.Context) map[string]map[string]any {
return allOutputs
}
// 获取节点输出容器
func getNodeOutputsContainer(ctx context.Context) *nodeOutputsContainer {
value := ctx.Value(nodeOutputsKey)
if value == nil {
return nil
}
return value.(*nodeOutputsContainer)
}
// 检查节点是否有输出
func HasNodeOutput(ctx context.Context, nodeId string) bool {
container := getNodeOutputsContainer(ctx)
if container == nil {
return false
}
container.RLock()
defer container.RUnlock()
_, exists := container.outputs[nodeId]
return exists
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/usual2970/certimate/internal/deployer"
@@ -33,7 +34,8 @@ func NewDeployNode(node *domain.WorkflowNode) *deployNode {
}
func (n *deployNode) Process(ctx context.Context) error {
n.logger.Info("ready to deploy certificate ...")
nodeCfg := n.node.GetConfigForDeploy()
n.logger.Info("ready to deploy certificate ...", slog.Any("config", nodeCfg))
// 查询上次执行结果
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
@@ -58,6 +60,7 @@ func (n *deployNode) Process(ctx context.Context) error {
// 检测是否可以跳过本次执行
if lastOutput != nil && certificate.CreatedAt.Before(lastOutput.UpdatedAt) {
if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable {
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true)
n.logger.Info(fmt.Sprintf("skip this deployment, because %s", reason))
return nil
} else if reason != "" {
@@ -96,6 +99,9 @@ func (n *deployNode) Process(ctx context.Context) error {
return err
}
// 记录中间结果
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false)
n.logger.Info("deployment completed")
return nil
}
@@ -103,16 +109,17 @@ func (n *deployNode) Process(ctx context.Context) error {
func (n *deployNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) {
if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致
currentNodeConfig := n.node.GetConfigForDeploy()
lastNodeConfig := lastOutput.Node.GetConfigForDeploy()
if currentNodeConfig.ProviderAccessId != lastNodeConfig.ProviderAccessId {
thisNodeCfg := n.node.GetConfigForDeploy()
lastNodeCfg := lastOutput.Node.GetConfigForDeploy()
if thisNodeCfg.ProviderAccessId != lastNodeCfg.ProviderAccessId {
return false, "the configuration item 'ProviderAccessId' changed"
}
if !maps.Equal(currentNodeConfig.ProviderConfig, lastNodeConfig.ProviderConfig) {
if !maps.Equal(thisNodeCfg.ProviderConfig, lastNodeCfg.ProviderConfig) {
return false, "the configuration item 'ProviderConfig' changed"
}
if currentNodeConfig.SkipOnLastSucceeded {
if thisNodeCfg.SkipOnLastSucceeded {
return true, "the certificate has already been deployed"
}
}

View File

@@ -5,7 +5,9 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"math"
"net"
"net/http"
"strconv"
"strings"
@@ -30,13 +32,12 @@ func NewMonitorNode(node *domain.WorkflowNode) *monitorNode {
}
func (n *monitorNode) Process(ctx context.Context) error {
n.logger.Info("ready to monitor certificate ...")
nodeCfg := n.node.GetConfigForMonitor()
n.logger.Info("ready to monitor certificate ...", slog.Any("config", nodeCfg))
targetAddr := fmt.Sprintf("%s:%d", nodeCfg.Host, nodeCfg.Port)
targetAddr := net.JoinHostPort(nodeCfg.Host, fmt.Sprintf("%d", nodeCfg.Port))
if nodeCfg.Port == 0 {
targetAddr = fmt.Sprintf("%s:443", nodeCfg.Host)
targetAddr = net.JoinHostPort(nodeCfg.Host, "443")
}
targetDomain := nodeCfg.Domain

View File

@@ -2,7 +2,9 @@ package nodeprocessor
import (
"context"
"fmt"
"log/slog"
"strconv"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/notify"
@@ -28,9 +30,8 @@ func NewNotifyNode(node *domain.WorkflowNode) *notifyNode {
}
func (n *notifyNode) Process(ctx context.Context) error {
n.logger.Info("ready to send notification ...")
nodeCfg := n.node.GetConfigForNotify()
n.logger.Info("ready to send notification ...", slog.Any("config", nodeCfg))
if nodeCfg.Provider == "" {
// Deprecated: v0.4.x 将废弃
@@ -59,6 +60,12 @@ func (n *notifyNode) Process(ctx context.Context) error {
return nil
}
// 检测是否可以跳过本次执行
if skippable := n.checkCanSkip(ctx); skippable {
n.logger.Info(fmt.Sprintf("skip this notification, because all the previous nodes have been skipped"))
return nil
}
// 初始化通知器
deployer, err := notify.NewWithWorkflowNode(notify.NotifierWithWorkflowNodeConfig{
Node: n.node,
@@ -80,3 +87,21 @@ func (n *notifyNode) Process(ctx context.Context) error {
n.logger.Info("notification completed")
return nil
}
func (n *notifyNode) checkCanSkip(ctx context.Context) (_skip bool) {
thisNodeCfg := n.node.GetConfigForNotify()
if !thisNodeCfg.SkipOnAllPrevSkipped {
return false
}
prevNodeOutputs := GetAllNodeOutputs(ctx)
for _, nodeOutput := range prevNodeOutputs {
if nodeOutput[outputKeyForNodeSkipped] != nil {
if nodeOutput[outputKeyForNodeSkipped].(string) != strconv.FormatBool(true) {
return false
}
}
}
return true
}

View File

@@ -50,7 +50,7 @@ func (n *nodeOutputer) GetOutputs() map[string]any {
type certificateRepository interface {
GetByWorkflowNodeId(ctx context.Context, workflowNodeId string) (*domain.Certificate, error)
GetByWorkflowRunId(ctx context.Context, workflowRunId string) (*domain.Certificate, error)
GetByWorkflowRunIdAndNodeId(ctx context.Context, workflowRunId string, workflowNodeId string) (*domain.Certificate, error)
Save(ctx context.Context, certificate *domain.Certificate) (*domain.Certificate, error)
}

View File

@@ -3,6 +3,7 @@ package nodeprocessor
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
@@ -32,9 +33,8 @@ func NewUploadNode(node *domain.WorkflowNode) *uploadNode {
}
func (n *uploadNode) Process(ctx context.Context) error {
n.logger.Info("ready to upload certiticate ...")
nodeCfg := n.node.GetConfigForUpload()
n.logger.Info("ready to upload certiticate ...", slog.Any("config", nodeCfg))
// 查询上次执行结果
lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id)
@@ -44,6 +44,7 @@ func (n *uploadNode) Process(ctx context.Context) error {
// 检测是否可以跳过本次执行
if skippable, reason := n.checkCanSkip(ctx, lastOutput); skippable {
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(true)
n.logger.Info(fmt.Sprintf("skip this uploading, because %s", reason))
return nil
} else if reason != "" {
@@ -71,6 +72,7 @@ func (n *uploadNode) Process(ctx context.Context) error {
}
// 记录中间结果
n.outputs[outputKeyForNodeSkipped] = strconv.FormatBool(false)
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(true)
n.outputs[outputKeyForCertificateDaysLeft] = strconv.FormatInt(int64(time.Until(certificate.ExpireAt).Hours()/24), 10)
@@ -81,16 +83,17 @@ func (n *uploadNode) Process(ctx context.Context) error {
func (n *uploadNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (_skip bool, _reason string) {
if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次上传时的关键配置(即影响证书上传的)参数是否一致
currentNodeConfig := n.node.GetConfigForUpload()
lastNodeConfig := lastOutput.Node.GetConfigForUpload()
if strings.TrimSpace(currentNodeConfig.Certificate) != strings.TrimSpace(lastNodeConfig.Certificate) {
thisNodeCfg := n.node.GetConfigForUpload()
lastNodeCfg := lastOutput.Node.GetConfigForUpload()
if strings.TrimSpace(thisNodeCfg.Certificate) != strings.TrimSpace(lastNodeCfg.Certificate) {
return false, "the configuration item 'Certificate' changed"
}
if strings.TrimSpace(currentNodeConfig.PrivateKey) != strings.TrimSpace(lastNodeConfig.PrivateKey) {
if strings.TrimSpace(thisNodeCfg.PrivateKey) != strings.TrimSpace(lastNodeCfg.PrivateKey) {
return false, "the configuration item 'PrivateKey' changed"
}
lastCertificate, _ := n.certRepo.GetByWorkflowRunId(ctx, lastOutput.RunId)
lastCertificate, _ := n.certRepo.GetByWorkflowRunIdAndNodeId(ctx, lastOutput.RunId, lastOutput.NodeId)
if lastCertificate != nil {
daysLeft := int(time.Until(lastCertificate.ExpireAt).Hours() / 24)
n.outputs[outputKeyForCertificateValidity] = strconv.FormatBool(daysLeft > 0)

View File

@@ -0,0 +1,62 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
tracer := NewTracer("(v0.3)1748959200")
tracer.Printf("go ...")
// migrate data
{
collection, err := app.FindCollectionByNameOrId("4yzbv8urny5ja1e")
if err != nil {
return err
}
records, err := app.FindAllRecords(collection)
if err != nil {
return err
}
for _, record := range records {
changed := false
if record.GetString("provider") == "ssh" {
config := make(map[string]any)
if err := record.UnmarshalJSONField("config", &config); err != nil {
return err
}
if config["authMethod"] == nil || config["authMethod"] == "" {
if config["key"] != nil && config["key"] != "" {
config["authMethod"] = "key"
} else if config["password"] != nil && config["password"] != "" {
config["authMethod"] = "password"
} else {
config["authMethod"] = "none"
}
record.Set("config", config)
changed = true
}
}
if changed {
if err := app.Save(record); err != nil {
return err
}
tracer.Printf("record #%s in collection '%s' updated", record.Id, collection.Name)
}
}
}
tracer.Printf("done")
return nil
}, func(app core.App) error {
return nil
})
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -28,6 +28,7 @@ import AccessFormCdnflyConfig from "./AccessFormCdnflyConfig";
import AccessFormCloudflareConfig from "./AccessFormCloudflareConfig";
import AccessFormClouDNSConfig from "./AccessFormClouDNSConfig";
import AccessFormCMCCCloudConfig from "./AccessFormCMCCCloudConfig";
import AccessFormConstellixConfig from "./AccessFormConstellixConfig";
import AccessFormDeSECConfig from "./AccessFormDeSECConfig";
import AccessFormDigitalOceanConfig from "./AccessFormDigitalOceanConfig";
import AccessFormDingTalkBotConfig from "./AccessFormDingTalkBotConfig";
@@ -219,6 +220,8 @@ const AccessForm = forwardRef<AccessFormInstance, AccessFormProps>(({ className,
return <AccessFormClouDNSConfig {...nestedFormProps} />;
case ACCESS_PROVIDERS.CMCCCLOUD:
return <AccessFormCMCCCloudConfig {...nestedFormProps} />;
case ACCESS_PROVIDERS.CONSTELLIX:
return <AccessFormConstellixConfig {...nestedFormProps} />;
case ACCESS_PROVIDERS.DESEC:
return <AccessFormDeSECConfig {...nestedFormProps} />;
case ACCESS_PROVIDERS.DIGITALOCEAN:

View File

@@ -0,0 +1,67 @@
import { useTranslation } from "react-i18next";
import { Form, type FormInstance, Input } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import { type AccessConfigForConstellix } from "@/domain/access";
type AccessFormConstellixConfigFieldValues = Nullish<AccessConfigForConstellix>;
export type AccessFormConstellixConfigProps = {
form: FormInstance;
formName: string;
disabled?: boolean;
initialValues?: AccessFormConstellixConfigFieldValues;
onValuesChange?: (values: AccessFormConstellixConfigFieldValues) => void;
};
const initFormModel = (): AccessFormConstellixConfigFieldValues => {
return {
apiKey: "",
secretKey: "",
};
};
const AccessFormConstellixConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange: onValuesChange }: AccessFormConstellixConfigProps) => {
const { t } = useTranslation();
const formSchema = z.object({
apiKey: z.string().trim().nonempty(t("access.form.constellix_api_key.placeholder")),
secretKey: z.string().trim().nonempty(t("access.form.constellix_secret_key.placeholder")),
});
const formRule = createSchemaFieldRule(formSchema);
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
onValuesChange?.(values);
};
return (
<Form
form={formInst}
disabled={disabled}
initialValues={initialValues ?? initFormModel()}
layout="vertical"
name={formName}
onValuesChange={handleFormChange}
>
<Form.Item
name="apiKey"
label={t("access.form.constellix_api_key.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.constellix_api_key.tooltip") }}></span>}
>
<Input autoComplete="new-password" placeholder={t("access.form.constellix_api_key.placeholder")} />
</Form.Item>
<Form.Item
name="secretKey"
label={t("access.form.constellix_secret_key.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.constellix_secret_key.tooltip") }}></span>}
>
<Input.Password autoComplete="new-password" placeholder={t("access.form.constellix_secret_key.placeholder")} />
</Form.Item>
</Form>
);
};
export default AccessFormConstellixConfig;

View File

@@ -1,9 +1,10 @@
import { useTranslation } from "react-i18next";
import { ArrowDownOutlined, ArrowUpOutlined, CloseOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, Collapse, Form, type FormInstance, Input, InputNumber, Space } from "antd";
import { Button, Collapse, Form, type FormInstance, Input, InputNumber, Select, Space } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import Show from "@/components/Show";
import TextFileInput from "@/components/TextFileInput";
import { type AccessConfigForSSH } from "@/domain/access";
import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators";
@@ -18,10 +19,15 @@ export type AccessFormSSHConfigProps = {
onValuesChange?: (values: AccessFormSSHConfigFieldValues) => void;
};
const AUTH_METHOD_NONE = "none" as const;
const AUTH_METHOD_PASSWORD = "password" as const;
const AUTH_METHOD_KEY = "key" as const;
const initFormModel = (): AccessFormSSHConfigFieldValues => {
return {
host: "127.0.0.1",
port: 22,
authMethod: AUTH_METHOD_PASSWORD,
username: "root",
};
};
@@ -38,6 +44,9 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
.int(t("access.form.ssh_port.placeholder"))
.refine((v) => validPortNumber(v), t("common.errmsg.port_invalid"))
),
authMethod: z.union([z.literal(AUTH_METHOD_NONE), z.literal(AUTH_METHOD_PASSWORD), z.literal(AUTH_METHOD_KEY)], {
message: t("access.form.ssh_auth_method.placeholder"),
}),
username: z
.string()
.min(1, t("access.form.ssh_username.placeholder"))
@@ -45,11 +54,13 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
password: z
.string()
.max(64, t("common.errmsg.string_max", { max: 64 }))
.nullish(),
.nullish()
.refine((v) => fieldAuthMethod !== AUTH_METHOD_PASSWORD || !!v?.trim(), t("access.form.ssh_password.placeholder")),
key: z
.string()
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
.nullish(),
.nullish()
.refine((v) => fieldAuthMethod !== AUTH_METHOD_KEY || !!v?.trim(), t("access.form.ssh_key.placeholder")),
keyPassphrase: z
.string()
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
@@ -57,47 +68,43 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
.refine((v) => !v || formInst.getFieldValue("key"), t("access.form.ssh_key.placeholder")),
jumpServers: z
.array(
z
.object({
host: z.string().refine((v) => validDomainName(v) || validIPv4Address(v) || validIPv6Address(v), t("common.errmsg.host_invalid")),
port: z.preprocess(
(v) => Number(v),
z
.number()
.int(t("access.form.ssh_port.placeholder"))
.refine((v) => validPortNumber(v), t("common.errmsg.port_invalid"))
),
username: z
.string()
.min(1, t("access.form.ssh_username.placeholder"))
.max(64, t("common.errmsg.string_max", { max: 64 })),
password: z
.string()
.max(64, t("common.errmsg.string_max", { max: 64 }))
.nullish(),
key: z
.string()
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
.nullish(),
keyPassphrase: z
.string()
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
.nullish(),
})
.superRefine((data, ctx) => {
if (data.keyPassphrase && !data.key) {
ctx.addIssue({
path: ["keyPassphrase"],
code: z.ZodIssueCode.custom,
message: t("access.form.ssh_key.placeholder"),
});
}
})
z.object({
host: z.string().refine((v) => validDomainName(v) || validIPv4Address(v) || validIPv6Address(v), t("common.errmsg.host_invalid")),
port: z.preprocess(
(v) => Number(v),
z
.number()
.int(t("access.form.ssh_port.placeholder"))
.refine((v) => validPortNumber(v), t("common.errmsg.port_invalid"))
),
authMethod: z.union([z.literal(AUTH_METHOD_NONE), z.literal(AUTH_METHOD_PASSWORD), z.literal(AUTH_METHOD_KEY)], {
message: t("access.form.ssh_auth_method.placeholder"),
}),
username: z
.string()
.min(1, t("access.form.ssh_username.placeholder"))
.max(64, t("common.errmsg.string_max", { max: 64 })),
password: z
.string()
.max(64, t("common.errmsg.string_max", { max: 64 }))
.nullish(),
key: z
.string()
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
.nullish(),
keyPassphrase: z
.string()
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
.nullish(),
}),
{ message: t("access.form.ssh_jump_servers.errmsg.invalid") }
)
.nullish(),
});
const formRule = createSchemaFieldRule(formSchema);
const fieldAuthMethod = Form.useWatch("authMethod", formInst);
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
onValuesChange?.(values);
};
@@ -125,36 +132,39 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
</div>
</div>
<Form.Item name="authMethod" label={t("access.form.ssh_auth_method.label")} rules={[formRule]}>
<Select placeholder={t("access.form.ssh_auth_method.placeholder")}>
<Select.Option key={AUTH_METHOD_NONE} value={AUTH_METHOD_NONE}>
{t("access.form.ssh_auth_method.option.none.label")}
</Select.Option>
<Select.Option key={AUTH_METHOD_PASSWORD} value={AUTH_METHOD_PASSWORD}>
{t("access.form.ssh_auth_method.option.password.label")}
</Select.Option>
<Select.Option key={AUTH_METHOD_KEY} value={AUTH_METHOD_KEY}>
{t("access.form.ssh_auth_method.option.key.label")}
</Select.Option>
</Select>
</Form.Item>
<Form.Item name="username" label={t("access.form.ssh_username.label")} rules={[formRule]}>
<Input autoComplete="new-password" placeholder={t("access.form.ssh_username.placeholder")} />
</Form.Item>
<Form.Item
name="password"
label={t("access.form.ssh_password.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_password.tooltip") }}></span>}
>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_password.placeholder")} />
</Form.Item>
<Show when={fieldAuthMethod === AUTH_METHOD_PASSWORD}>
<Form.Item name="password" label={t("access.form.ssh_password.label")} rules={[formRule]}>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_password.placeholder")} />
</Form.Item>
</Show>
<Form.Item
name="key"
label={t("access.form.ssh_key.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_key.tooltip") }}></span>}
>
<TextFileInput allowClear autoSize={{ minRows: 1, maxRows: 5 }} placeholder={t("access.form.ssh_key.placeholder")} />
</Form.Item>
<Show when={fieldAuthMethod === AUTH_METHOD_KEY}>
<Form.Item name="key" label={t("access.form.ssh_key.label")} rules={[formRule]}>
<TextFileInput allowClear autoSize={{ minRows: 1, maxRows: 5 }} placeholder={t("access.form.ssh_key.placeholder")} />
</Form.Item>
<Form.Item
name="keyPassphrase"
label={t("access.form.ssh_key_passphrase.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_key_passphrase.tooltip") }}></span>}
>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
</Form.Item>
<Form.Item name="keyPassphrase" label={t("access.form.ssh_key_passphrase.label")} rules={[formRule]}>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
</Form.Item>
</Show>
<Form.Item name="jumpServers" label={t("access.form.ssh_jump_servers.label")} rules={[formRule]}>
<Form.List name="jumpServers">
@@ -174,6 +184,60 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
);
};
const Fields = () => {
const authMethod = Form.useWatch(["jumpServers", field.name, "authMethod"], formInst);
return (
<>
<div className="flex space-x-2">
<div className="w-2/3">
<Form.Item name={[field.name, "host"]} label={t("access.form.ssh_host.label")} rules={[formRule]}>
<Input placeholder={t("access.form.ssh_host.placeholder")} />
</Form.Item>
</div>
<div className="w-1/3">
<Form.Item name={[field.name, "port"]} label={t("access.form.ssh_port.label")} rules={[formRule]}>
<InputNumber className="w-full" placeholder={t("access.form.ssh_port.placeholder")} min={1} max={65535} />
</Form.Item>
</div>
</div>
<Form.Item name={[field.name, "authMethod"]} label={t("access.form.ssh_auth_method.label")} rules={[formRule]}>
<Select placeholder={t("access.form.ssh_auth_method.placeholder")}>
<Select.Option key={AUTH_METHOD_NONE} value={AUTH_METHOD_NONE}>
{t("access.form.ssh_auth_method.option.none.label")}
</Select.Option>
<Select.Option key={AUTH_METHOD_PASSWORD} value={AUTH_METHOD_PASSWORD}>
{t("access.form.ssh_auth_method.option.password.label")}
</Select.Option>
<Select.Option key={AUTH_METHOD_KEY} value={AUTH_METHOD_KEY}>
{t("access.form.ssh_auth_method.option.key.label")}
</Select.Option>
</Select>
</Form.Item>
<Form.Item name={[field.name, "username"]} label={t("access.form.ssh_username.label")} rules={[formRule]}>
<Input autoComplete="new-password" placeholder={t("access.form.ssh_username.placeholder")} />
</Form.Item>
<Show when={authMethod === AUTH_METHOD_PASSWORD}>
<Form.Item name={[field.name, "password"]} label={t("access.form.ssh_password.label")} rules={[formRule]}>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_password.placeholder")} />
</Form.Item>
</Show>
<Show when={authMethod === AUTH_METHOD_KEY}>
<Form.Item name={[field.name, "key"]} label={t("access.form.ssh_key.label")} rules={[formRule]}>
<TextFileInput allowClear autoSize={{ minRows: 1, maxRows: 5 }} placeholder={t("access.form.ssh_key.placeholder")} />
</Form.Item>
<Form.Item name={[field.name, "keyPassphrase"]} label={t("access.form.ssh_key_passphrase.label")} rules={[formRule]}>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
</Form.Item>
</Show>
</>
);
};
return {
key: field.key,
label: <Label />,
@@ -214,58 +278,12 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
/>
</Space.Compact>
),
children: (
<>
<div className="flex space-x-2">
<div className="w-2/3">
<Form.Item name={[field.name, "host"]} label={t("access.form.ssh_host.label")} rules={[formRule]}>
<Input placeholder={t("access.form.ssh_host.placeholder")} />
</Form.Item>
</div>
<div className="w-1/3">
<Form.Item name={[field.name, "port"]} label={t("access.form.ssh_port.label")} rules={[formRule]}>
<InputNumber className="w-full" placeholder={t("access.form.ssh_port.placeholder")} min={1} max={65535} />
</Form.Item>
</div>
</div>
<Form.Item name={[field.name, "username"]} label={t("access.form.ssh_username.label")} rules={[formRule]}>
<Input autoComplete="new-password" placeholder={t("access.form.ssh_username.placeholder")} />
</Form.Item>
<Form.Item
name={[field.name, "password"]}
label={t("access.form.ssh_password.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_password.tooltip") }}></span>}
>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_password.placeholder")} />
</Form.Item>
<Form.Item
name={[field.name, "key"]}
label={t("access.form.ssh_key.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_key.tooltip") }}></span>}
>
<TextFileInput allowClear autoSize={{ minRows: 1, maxRows: 5 }} placeholder={t("access.form.ssh_key.placeholder")} />
</Form.Item>
<Form.Item
name={[field.name, "keyPassphrase"]}
label={t("access.form.ssh_key_passphrase.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_key_passphrase.tooltip") }}></span>}
>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
</Form.Item>
</>
),
children: <Fields />,
};
})}
/>
) : null}
<Button className="w-full" type="dashed" icon={<PlusOutlined />} onClick={() => add()}>
<Button className="w-full" type="dashed" icon={<PlusOutlined />} onClick={() => add(initFormModel())}>
{t("access.form.ssh_jump_servers.add")}
</Button>
</Space>

View File

@@ -28,7 +28,7 @@ import ACMEDns01ProviderSelect from "@/components/provider/ACMEDns01ProviderSele
import CAProviderSelect from "@/components/provider/CAProviderSelect";
import Show from "@/components/Show";
import { ACCESS_USAGES, ACME_DNS01_PROVIDERS, accessProvidersMap, acmeDns01ProvidersMap, caProvidersMap } from "@/domain/provider";
import { type WorkflowNodeConfigForApply } from "@/domain/workflow";
import { type WorkflowNodeConfigForApply, defaultNodeConfigForApply } from "@/domain/workflow";
import { useAntdForm, useAntdFormName, useZustandShallowSelector } from "@/hooks";
import { useAccessesStore } from "@/stores/access";
import { useContactEmailsStore } from "@/stores/contact";
@@ -59,11 +59,7 @@ export type ApplyNodeConfigFormInstance = {
const MULTIPLE_INPUT_SEPARATOR = ";";
const initFormModel = (): ApplyNodeConfigFormFieldValues => {
return {
challengeType: "dns-01",
keyAlgorithm: "RSA2048",
skipBeforeExpiryDays: 30,
};
return defaultNodeConfigForApply();
};
const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeConfigFormProps>(

View File

@@ -4,7 +4,7 @@ import { Form, type FormInstance } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import { type Expr, type WorkflowNodeConfigForCondition } from "@/domain/workflow";
import { type Expr, type WorkflowNodeConfigForCondition, defaultNodeConfigForCondition } from "@/domain/workflow";
import { useAntdForm } from "@/hooks";
import ConditionNodeConfigFormExpressionEditor, { type ConditionNodeConfigFormExpressionEditorInstance } from "./ConditionNodeConfigFormExpressionEditor";
@@ -29,7 +29,7 @@ export type ConditionNodeConfigFormInstance = {
};
const initFormModel = (): ConditionNodeConfigFormFieldValues => {
return {};
return defaultNodeConfigForCondition();
};
const ConditionNodeConfigForm = forwardRef<ConditionNodeConfigFormInstance, ConditionNodeConfigFormProps>(

View File

@@ -11,7 +11,7 @@ import DeploymentProviderPicker from "@/components/provider/DeploymentProviderPi
import DeploymentProviderSelect from "@/components/provider/DeploymentProviderSelect.tsx";
import Show from "@/components/Show";
import { ACCESS_USAGES, DEPLOYMENT_PROVIDERS, accessProvidersMap, deploymentProvidersMap } from "@/domain/provider";
import { type WorkflowNodeConfigForDeploy, WorkflowNodeType } from "@/domain/workflow";
import { type WorkflowNodeConfigForDeploy, WorkflowNodeType, defaultNodeConfigForDeploy } from "@/domain/workflow";
import { useAntdForm, useAntdFormName, useZustandShallowSelector } from "@/hooks";
import { useWorkflowStore } from "@/stores/workflow";
@@ -35,6 +35,7 @@ import DeployNodeConfigFormAliyunVODConfig from "./DeployNodeConfigFormAliyunVOD
import DeployNodeConfigFormAliyunWAFConfig from "./DeployNodeConfigFormAliyunWAFConfig";
import DeployNodeConfigFormAWSACMConfig from "./DeployNodeConfigFormAWSACMConfig";
import DeployNodeConfigFormAWSCloudFrontConfig from "./DeployNodeConfigFormAWSCloudFrontConfig";
import DeployNodeConfigFormAWSIAMConfig from "./DeployNodeConfigFormAWSIAMConfig";
import DeployNodeConfigFormAzureKeyVaultConfig from "./DeployNodeConfigFormAzureKeyVaultConfig";
import DeployNodeConfigFormBaiduCloudAppBLBConfig from "./DeployNodeConfigFormBaiduCloudAppBLBConfig";
import DeployNodeConfigFormBaiduCloudBLBConfig from "./DeployNodeConfigFormBaiduCloudBLBConfig";
@@ -116,9 +117,7 @@ export type DeployNodeConfigFormInstance = {
};
const initFormModel = (): DeployNodeConfigFormFieldValues => {
return {
skipOnLastSucceeded: true,
};
return defaultNodeConfigForDeploy();
};
const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNodeConfigFormProps>(
@@ -238,6 +237,8 @@ const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNode
return <DeployNodeConfigFormAWSACMConfig {...nestedFormProps} />;
case DEPLOYMENT_PROVIDERS.AWS_CLOUDFRONT:
return <DeployNodeConfigFormAWSCloudFrontConfig {...nestedFormProps} />;
case DEPLOYMENT_PROVIDERS.AWS_IAM:
return <DeployNodeConfigFormAWSIAMConfig {...nestedFormProps} />;
case DEPLOYMENT_PROVIDERS.AZURE_KEYVAULT:
return <DeployNodeConfigFormAzureKeyVaultConfig {...nestedFormProps} />;
case DEPLOYMENT_PROVIDERS.BAIDUCLOUD_APPBLB:

View File

@@ -1,11 +1,12 @@
import { useTranslation } from "react-i18next";
import { Form, type FormInstance, Input } from "antd";
import { Form, type FormInstance, Input, Select } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
type DeployNodeConfigFormAWSCloudFrontConfigFieldValues = Nullish<{
region: string;
distributionId: string;
certificateSource: string;
}>;
export type DeployNodeConfigFormAWSCloudFrontConfigProps = {
@@ -17,7 +18,9 @@ export type DeployNodeConfigFormAWSCloudFrontConfigProps = {
};
const initFormModel = (): DeployNodeConfigFormAWSCloudFrontConfigFieldValues => {
return {};
return {
certificateSource: "ACM",
};
};
const DeployNodeConfigFormAWSCloudFrontConfig = ({
@@ -30,15 +33,9 @@ const DeployNodeConfigFormAWSCloudFrontConfig = ({
const { t } = useTranslation();
const formSchema = z.object({
region: z
.string({ message: t("workflow_node.deploy.form.aws_cloudfront_region.placeholder") })
.nonempty(t("workflow_node.deploy.form.aws_cloudfront_region.placeholder"))
.trim(),
distributionId: z
.string({ message: t("workflow_node.deploy.form.aws_cloudfront_distribution_id.placeholder") })
.nonempty(t("workflow_node.deploy.form.aws_cloudfront_distribution_id.placeholder"))
.max(64, t("common.errmsg.string_max", { max: 64 }))
.trim(),
region: z.string().trim().nonempty(t("workflow_node.deploy.form.aws_cloudfront_region.placeholder")),
distributionId: z.string().trim().nonempty(t("workflow_node.deploy.form.aws_cloudfront_distribution_id.placeholder")),
certificateSource: z.string().trim().nonempty(t("workflow_node.deploy.form.aws_cloudfront_certificate_source.placeholder")),
});
const formRule = createSchemaFieldRule(formSchema);
@@ -72,6 +69,17 @@ const DeployNodeConfigFormAWSCloudFrontConfig = ({
>
<Input placeholder={t("workflow_node.deploy.form.aws_cloudfront_distribution_id.placeholder")} />
</Form.Item>
<Form.Item name="certificateSource" label={t("workflow_node.deploy.form.aws_cloudfront_certificate_source.label")} rules={[formRule]}>
<Select placeholder={t("workflow_node.deploy.form.aws_cloudfront_certificate_source.placeholder")}>
<Select.Option key="ACM" value="ACM">
ACM
</Select.Option>
<Select.Option key="IAM" value="IAM">
IAM
</Select.Option>
</Select>
</Form.Item>
</Form>
);
};

View File

@@ -0,0 +1,77 @@
import { useTranslation } from "react-i18next";
import { Form, type FormInstance, Input } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
type DeployNodeConfigFormAWSIAMConfigFieldValues = Nullish<{
region: string;
certificatePath?: string;
}>;
export type DeployNodeConfigFormAWSIAMConfigProps = {
form: FormInstance;
formName: string;
disabled?: boolean;
initialValues?: DeployNodeConfigFormAWSIAMConfigFieldValues;
onValuesChange?: (values: DeployNodeConfigFormAWSIAMConfigFieldValues) => void;
};
const initFormModel = (): DeployNodeConfigFormAWSIAMConfigFieldValues => {
return {
certificatePath: "/",
};
};
const DeployNodeConfigFormAWSIAMConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange }: DeployNodeConfigFormAWSIAMConfigProps) => {
const { t } = useTranslation();
const formSchema = z.object({
region: z
.string({ message: t("workflow_node.deploy.form.aws_iam_region.placeholder") })
.nonempty(t("workflow_node.deploy.form.aws_iam_region.placeholder"))
.trim(),
certificatePath: z
.string()
.nullish()
.refine((v) => {
if (!v) return true;
return v.startsWith("/") && v.endsWith("/");
}, t("workflow_node.deploy.form.aws_iam_certificate_path.errmsg.invalid")),
});
const formRule = createSchemaFieldRule(formSchema);
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
onValuesChange?.(values);
};
return (
<Form
form={formInst}
disabled={disabled}
initialValues={initialValues ?? initFormModel()}
layout="vertical"
name={formName}
onValuesChange={handleFormChange}
>
<Form.Item
name="region"
label={t("workflow_node.deploy.form.aws_iam_region.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.aws_iam_region.tooltip") }}></span>}
>
<Input placeholder={t("workflow_node.deploy.form.aws_iam_region.placeholder")} />
</Form.Item>
<Form.Item
name="certificatePath"
label={t("workflow_node.deploy.form.aws_iam_certificate_path.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.aws_iam_certificate_path.tooltip") }}></span>}
>
<Input allowClear placeholder={t("workflow_node.deploy.form.aws_iam_certificate_path.placeholder")} />
</Form.Item>
</Form>
);
};
export default DeployNodeConfigFormAWSIAMConfig;

View File

@@ -43,7 +43,7 @@ const DeployNodeConfigFormWangsuCDNConfig = ({
if (!v) return false;
return String(v)
.split(MULTIPLE_INPUT_SEPARATOR)
.every((e) => validDomainName(e));
.every((e) => validDomainName(e, { allowWildcard: true }));
}, t("workflow_node.deploy.form.wangsu_cdn_domains.placeholder")),
});
const formRule = createSchemaFieldRule(formSchema);

View File

@@ -4,7 +4,7 @@ import { Alert, Form, type FormInstance, Input, InputNumber } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import { type WorkflowNodeConfigForMonitor } from "@/domain/workflow";
import { type WorkflowNodeConfigForMonitor, defaultNodeConfigForMonitor } from "@/domain/workflow";
import { useAntdForm } from "@/hooks";
import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators";
@@ -25,11 +25,7 @@ export type MonitorNodeConfigFormInstance = {
};
const initFormModel = (): MonitorNodeConfigFormFieldValues => {
return {
host: "",
port: 443,
requestPath: "/",
};
return defaultNodeConfigForMonitor();
};
const MonitorNodeConfigForm = forwardRef<MonitorNodeConfigFormInstance, MonitorNodeConfigFormProps>(

View File

@@ -2,7 +2,7 @@ import { forwardRef, memo, useEffect, useImperativeHandle, useMemo, useState } f
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { PlusOutlined as PlusOutlinedIcon, RightOutlined as RightOutlinedIcon } from "@ant-design/icons";
import { Button, Divider, Form, type FormInstance, Input, Select, Typography } from "antd";
import { Button, Divider, Flex, Form, type FormInstance, Input, Select, Switch, Typography } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
@@ -12,7 +12,7 @@ import NotificationProviderSelect from "@/components/provider/NotificationProvid
import Show from "@/components/Show";
import { ACCESS_USAGES, NOTIFICATION_PROVIDERS, accessProvidersMap, notificationProvidersMap } from "@/domain/provider";
import { notifyChannelsMap } from "@/domain/settings";
import { type WorkflowNodeConfigForNotify } from "@/domain/workflow";
import { type WorkflowNodeConfigForNotify, defaultNodeConfigForNotify } from "@/domain/workflow";
import { useAntdForm, useAntdFormName, useZustandShallowSelector } from "@/hooks";
import { useAccessesStore } from "@/stores/access";
import { useNotifyChannelsStore } from "@/stores/notify";
@@ -41,7 +41,7 @@ export type NotifyNodeConfigFormInstance = {
};
const initFormModel = (): NotifyNodeConfigFormFieldValues => {
return {};
return defaultNodeConfigForNotify();
};
const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNodeConfigFormProps>(
@@ -74,6 +74,7 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
.string({ message: t("workflow_node.notify.form.provider_access.placeholder") })
.nonempty(t("workflow_node.notify.form.provider_access.placeholder")),
providerConfig: z.any().nullish(),
skipOnAllPrevSkipped: z.boolean().nullish(),
});
const formRule = createSchemaFieldRule(formSchema);
const { form: formInst, formProps } = useAntdForm({
@@ -281,6 +282,27 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
{nestedFormEl}
</Show>
<Divider size="small">
<Typography.Text className="text-xs font-normal" type="secondary">
{t("workflow_node.notify.form.strategy_config.label")}
</Typography.Text>
</Divider>
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
<Form.Item label={t("workflow_node.notify.form.skip_on_all_prev_skipped.label")}>
<Flex align="center" gap={8} wrap="wrap">
<div>{t("workflow_node.notify.form.skip_on_all_prev_skipped.prefix")}</div>
<Form.Item name="skipOnAllPrevSkipped" noStyle rules={[formRule]}>
<Switch
checkedChildren={t("workflow_node.notify.form.skip_on_all_prev_skipped.switch.on")}
unCheckedChildren={t("workflow_node.notify.form.skip_on_all_prev_skipped.switch.off")}
/>
</Form.Item>
<div>{t("workflow_node.notify.form.skip_on_all_prev_skipped.suffix")}</div>
</Flex>
</Form.Item>
</Form>
</Form>
);
}

View File

@@ -6,7 +6,7 @@ import dayjs from "dayjs";
import { z } from "zod";
import Show from "@/components/Show";
import { WORKFLOW_TRIGGERS, type WorkflowNodeConfigForStart, type WorkflowTriggerType } from "@/domain/workflow";
import { WORKFLOW_TRIGGERS, type WorkflowNodeConfigForStart, type WorkflowTriggerType, defaultNodeConfigForStart } from "@/domain/workflow";
import { useAntdForm } from "@/hooks";
import { getNextCronExecutions, validCronExpression } from "@/utils/cron";
@@ -27,10 +27,7 @@ export type StartNodeConfigFormInstance = {
};
const initFormModel = (): StartNodeConfigFormFieldValues => {
return {
trigger: WORKFLOW_TRIGGERS.AUTO,
triggerCron: "0 0 * * *",
};
return defaultNodeConfigForStart();
};
const StartNodeConfigForm = forwardRef<StartNodeConfigFormInstance, StartNodeConfigFormProps>(

View File

@@ -6,7 +6,7 @@ import { z } from "zod";
import { validateCertificate, validatePrivateKey } from "@/api/certificates";
import TextFileInput from "@/components/TextFileInput";
import { type WorkflowNodeConfigForUpload } from "@/domain/workflow";
import { type WorkflowNodeConfigForUpload, defaultNodeConfigForUpload } from "@/domain/workflow";
import { useAntdForm } from "@/hooks";
import { getErrMsg } from "@/utils/error";
@@ -27,7 +27,7 @@ export type UploadNodeConfigFormInstance = {
};
const initFormModel = (): UploadNodeConfigFormFieldValues => {
return {};
return defaultNodeConfigForUpload();
};
const UploadNodeConfigForm = forwardRef<UploadNodeConfigFormInstance, UploadNodeConfigFormProps>(

View File

@@ -23,6 +23,7 @@ export interface AccessModel extends BaseModel {
| AccessConfigForCloudflare
| AccessConfigForClouDNS
| AccessConfigForCMCCCloud
| AccessConfigForConstellix
| AccessConfigForDeSEC
| AccessConfigForDigitalOcean
| AccessConfigForDingTalkBot
@@ -172,6 +173,11 @@ export type AccessConfigForCMCCCloud = {
accessKeySecret: string;
};
export type AccessConfigForConstellix = {
apiKey: string;
secretKey: string;
};
export type AccessConfigForDeSEC = {
token: string;
};
@@ -373,7 +379,8 @@ export type AccessConfigForSlackBot = {
export type AccessConfigForSSH = {
host: string;
port: number;
username: string;
authMethod?: string;
username?: string;
password?: string;
key?: string;
keyPassphrase?: string;

View File

@@ -22,6 +22,7 @@ export const ACCESS_PROVIDERS = Object.freeze({
CLOUDFLARE: "cloudflare",
CLOUDNS: "cloudns",
CMCCCLOUD: "cmcccloud",
CONSTELLIX: "constellix",
DESEC: "desec",
DIGITALOCEAN: "digitalocean",
DINGTALKBOT: "dingtalkbot",
@@ -120,6 +121,7 @@ export const accessProvidersMap: Map<AccessProvider["type"] | string, AccessProv
[ACCESS_PROVIDERS.GCORE, "provider.gcore", "/imgs/providers/gcore.png", [ACCESS_USAGES.DNS, ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.NETLIFY, "provider.netlify", "/imgs/providers/netlify.png", [ACCESS_USAGES.DNS, ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.RAINYUN, "provider.rainyun", "/imgs/providers/rainyun.svg", [ACCESS_USAGES.DNS, ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.UCLOUD, "provider.ucloud", "/imgs/providers/ucloud.svg", [ACCESS_USAGES.DNS, ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.QINIU, "provider.qiniu", "/imgs/providers/qiniu.svg", [ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.UPYUN, "provider.upyun", "/imgs/providers/upyun.svg", [ACCESS_USAGES.HOSTING]],
@@ -127,7 +129,6 @@ export const accessProvidersMap: Map<AccessProvider["type"] | string, AccessProv
[ACCESS_PROVIDERS.WANGSU, "provider.wangsu", "/imgs/providers/wangsu.svg", [ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.DOGECLOUD, "provider.dogecloud", "/imgs/providers/dogecloud.png", [ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.BYTEPLUS, "provider.byteplus", "/imgs/providers/byteplus.svg", [ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.UCLOUD, "provider.ucloud", "/imgs/providers/ucloud.svg", [ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.UNICLOUD, "provider.unicloud", "/imgs/providers/unicloud.png", [ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS["1PANEL"], "provider.1panel", "/imgs/providers/1panel.svg", [ACCESS_USAGES.HOSTING]],
[ACCESS_PROVIDERS.BAOTAPANEL, "provider.baotapanel", "/imgs/providers/baotapanel.svg", [ACCESS_USAGES.HOSTING]],
@@ -144,6 +145,7 @@ export const accessProvidersMap: Map<AccessProvider["type"] | string, AccessProv
[ACCESS_PROVIDERS.CLOUDFLARE, "provider.cloudflare", "/imgs/providers/cloudflare.svg", [ACCESS_USAGES.DNS]],
[ACCESS_PROVIDERS.CLOUDNS, "provider.cloudns", "/imgs/providers/cloudns.png", [ACCESS_USAGES.DNS]],
[ACCESS_PROVIDERS.CONSTELLIX, "provider.constellix", "/imgs/providers/constellix.png", [ACCESS_USAGES.DNS]],
[ACCESS_PROVIDERS.DESEC, "provider.desec", "/imgs/providers/desec.svg", [ACCESS_USAGES.DNS]],
[ACCESS_PROVIDERS.DIGITALOCEAN, "provider.digitalocean", "/imgs/providers/digitalocean.svg", [ACCESS_USAGES.DNS]],
[ACCESS_PROVIDERS.DNSLA, "provider.dnsla", "/imgs/providers/dnsla.svg", [ACCESS_USAGES.DNS]],
@@ -264,6 +266,7 @@ export const ACME_DNS01_PROVIDERS = Object.freeze({
CLOUDFLARE: `${ACCESS_PROVIDERS.CLOUDFLARE}`,
CLOUDNS: `${ACCESS_PROVIDERS.CLOUDNS}`,
CMCCCLOUD: `${ACCESS_PROVIDERS.CMCCCLOUD}`,
CONSTELLIX: `${ACCESS_PROVIDERS.CONSTELLIX}`,
DESEC: `${ACCESS_PROVIDERS.DESEC}`,
DIGITALOCEAN: `${ACCESS_PROVIDERS.DIGITALOCEAN}`,
DNSLA: `${ACCESS_PROVIDERS.DNSLA}`,
@@ -286,6 +289,7 @@ export const ACME_DNS01_PROVIDERS = Object.freeze({
PORKBUN: `${ACCESS_PROVIDERS.PORKBUN}`,
POWERDNS: `${ACCESS_PROVIDERS.POWERDNS}`,
RAINYUN: `${ACCESS_PROVIDERS.RAINYUN}`,
UCLOUD_UDNR: `${ACCESS_PROVIDERS.UCLOUD}-udnr`,
TENCENTCLOUD: `${ACCESS_PROVIDERS.TENCENTCLOUD}`, // 兼容旧值,等同于 `TENCENTCLOUD_DNS`
TENCENTCLOUD_DNS: `${ACCESS_PROVIDERS.TENCENTCLOUD}-dns`,
TENCENTCLOUD_EO: `${ACCESS_PROVIDERS.TENCENTCLOUD}-eo`,
@@ -323,6 +327,7 @@ export const acmeDns01ProvidersMap: Map<ACMEDns01Provider["type"] | string, ACME
[ACME_DNS01_PROVIDERS.BUNNY, "provider.bunny"],
[ACME_DNS01_PROVIDERS.CLOUDFLARE, "provider.cloudflare"],
[ACME_DNS01_PROVIDERS.CLOUDNS, "provider.cloudns"],
[ACME_DNS01_PROVIDERS.CONSTELLIX, "provider.constellix"],
[ACME_DNS01_PROVIDERS.DESEC, "provider.desec"],
[ACME_DNS01_PROVIDERS.DIGITALOCEAN, "provider.digitalocean"],
[ACME_DNS01_PROVIDERS.DNSLA, "provider.dnsla"],
@@ -342,6 +347,7 @@ export const acmeDns01ProvidersMap: Map<ACMEDns01Provider["type"] | string, ACME
[ACME_DNS01_PROVIDERS.VERCEL, "provider.vercel"],
[ACME_DNS01_PROVIDERS.CMCCCLOUD, "provider.cmcccloud"],
[ACME_DNS01_PROVIDERS.RAINYUN, "provider.rainyun"],
[ACME_DNS01_PROVIDERS.UCLOUD_UDNR, "provider.ucloud.udnr"],
[ACME_DNS01_PROVIDERS.WESTCN, "provider.westcn"],
[ACME_DNS01_PROVIDERS.POWERDNS, "provider.powerdns"],
[ACME_DNS01_PROVIDERS.ACMEHTTPREQ, "provider.acmehttpreq"],
@@ -383,6 +389,7 @@ export const DEPLOYMENT_PROVIDERS = Object.freeze({
ALIYUN_WAF: `${ACCESS_PROVIDERS.ALIYUN}-waf`,
AWS_ACM: `${ACCESS_PROVIDERS.AWS}-acm`,
AWS_CLOUDFRONT: `${ACCESS_PROVIDERS.AWS}-cloudfront`,
AWS_IAM: `${ACCESS_PROVIDERS.AWS}-iam`,
AZURE_KEYVAULT: `${ACCESS_PROVIDERS.AZURE}-keyvault`,
BAIDUCLOUD_APPBLB: `${ACCESS_PROVIDERS.BAIDUCLOUD}-appblb`,
BAIDUCLOUD_BLB: `${ACCESS_PROVIDERS.BAIDUCLOUD}-blb`,
@@ -555,6 +562,7 @@ export const deploymentProvidersMap: Map<DeploymentProvider["type"] | string, De
[DEPLOYMENT_PROVIDERS.UNICLOUD_WEBHOST, "provider.unicloud.webhost", DEPLOYMENT_CATEGORIES.WEBSITE],
[DEPLOYMENT_PROVIDERS.AWS_CLOUDFRONT, "provider.aws.cloudfront", DEPLOYMENT_CATEGORIES.CDN],
[DEPLOYMENT_PROVIDERS.AWS_ACM, "provider.aws.acm", DEPLOYMENT_CATEGORIES.SSL],
[DEPLOYMENT_PROVIDERS.AWS_IAM, "provider.aws.iam", DEPLOYMENT_CATEGORIES.SSL],
[DEPLOYMENT_PROVIDERS.AZURE_KEYVAULT, "provider.azure.keyvault", DEPLOYMENT_CATEGORIES.SSL],
[DEPLOYMENT_PROVIDERS.BUNNY_CDN, "provider.bunny.cdn", DEPLOYMENT_CATEGORIES.CDN],
[DEPLOYMENT_PROVIDERS.CACHEFLY, "provider.cachefly", DEPLOYMENT_CATEGORIES.CDN],

View File

@@ -1 +1 @@
export const version = "v0.3.15";
export const version = "v0.3.17";

View File

@@ -133,6 +133,13 @@ export type WorkflowNodeConfigForStart = {
triggerCron?: string;
};
export const defaultNodeConfigForStart = (): Partial<WorkflowNodeConfigForStart> => {
return {
trigger: WORKFLOW_TRIGGERS.AUTO,
triggerCron: "0 0 * * *",
};
};
export type WorkflowNodeConfigForApply = {
domains: string;
contactEmail: string;
@@ -152,6 +159,14 @@ export type WorkflowNodeConfigForApply = {
skipBeforeExpiryDays: number;
};
export const defaultNodeConfigForApply = (): Partial<WorkflowNodeConfigForApply> => {
return {
challengeType: "dns-01",
keyAlgorithm: "RSA2048",
skipBeforeExpiryDays: 30,
};
};
export type WorkflowNodeConfigForUpload = {
certificateId: string;
domains: string;
@@ -159,6 +174,10 @@ export type WorkflowNodeConfigForUpload = {
privateKey: string;
};
export const defaultNodeConfigForUpload = (): Partial<WorkflowNodeConfigForUpload> => {
return {};
};
export type WorkflowNodeConfigForMonitor = {
host: string;
port: number;
@@ -166,6 +185,13 @@ export type WorkflowNodeConfigForMonitor = {
requestPath?: string;
};
export const defaultNodeConfigForMonitor = (): Partial<WorkflowNodeConfigForMonitor> => {
return {
port: 443,
requestPath: "/",
};
};
export type WorkflowNodeConfigForDeploy = {
certificate: string;
provider: string;
@@ -174,6 +200,12 @@ export type WorkflowNodeConfigForDeploy = {
skipOnLastSucceeded: boolean;
};
export const defaultNodeConfigForDeploy = (): Partial<WorkflowNodeConfigForDeploy> => {
return {
skipOnLastSucceeded: true,
};
};
export type WorkflowNodeConfigForNotify = {
subject: string;
message: string;
@@ -184,12 +216,21 @@ export type WorkflowNodeConfigForNotify = {
provider: string;
providerAccessId: string;
providerConfig?: Record<string, unknown>;
skipOnAllPrevSkipped?: boolean;
};
export const defaultNodeConfigForNotify = (): Partial<WorkflowNodeConfigForNotify> => {
return {};
};
export type WorkflowNodeConfigForCondition = {
expression?: Expr;
};
export const defaultNodeConfigForCondition = (): Partial<WorkflowNodeConfigForCondition> => {
return {};
};
export type WorkflowNodeConfigForBranch = never;
export type WorkflowNodeConfigForEnd = never;
@@ -243,15 +284,18 @@ type InitWorkflowOptions = {
};
export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel => {
const root = newNode(WorkflowNodeType.Start, {}) as WorkflowNode;
root.config = { trigger: WORKFLOW_TRIGGERS.MANUAL };
const root = newNode(WorkflowNodeType.Start, {
nodeConfig: { trigger: WORKFLOW_TRIGGERS.MANUAL },
});
switch (options.template) {
case "standard":
{
let current = root;
const applyNode = newNode(WorkflowNodeType.Apply);
const applyNode = newNode(WorkflowNodeType.Apply, {
nodeConfig: defaultNodeConfigForApply(),
});
current.next = applyNode;
current = current.next;
@@ -260,6 +304,7 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
current = current.next!.branches![1];
current.next = newNode(WorkflowNodeType.Notify, {
nodeConfig: {
...defaultNodeConfigForNotify(),
subject: "[Certimate] Workflow Failure Alert!",
message: "Your workflow run for the certificate application has failed. Please check the details.",
} as WorkflowNodeConfigForNotify,
@@ -268,8 +313,8 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
current = applyNode.next!.branches![0];
current.next = newNode(WorkflowNodeType.Deploy, {
nodeConfig: {
...defaultNodeConfigForDeploy(),
certificate: `${applyNode.id}#certificate`,
skipOnLastSucceeded: true,
} as WorkflowNodeConfigForDeploy,
});
@@ -279,6 +324,7 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
current = current.next!.branches![1];
current.next = newNode(WorkflowNodeType.Notify, {
nodeConfig: {
...defaultNodeConfigForNotify(),
subject: "[Certimate] Workflow Failure Alert!",
message: "Your workflow run for the certificate deployment has failed. Please check the details.",
} as WorkflowNodeConfigForNotify,
@@ -290,7 +336,9 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
{
let current = root;
const monitorNode = newNode(WorkflowNodeType.Monitor);
const monitorNode = newNode(WorkflowNodeType.Monitor, {
nodeConfig: defaultNodeConfigForMonitor(),
});
current.next = monitorNode;
current = current.next;
@@ -299,6 +347,7 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
current = current.next!.branches![1];
current.next = newNode(WorkflowNodeType.Notify, {
nodeConfig: {
...defaultNodeConfigForNotify(),
subject: "[Certimate] Workflow Failure Alert!",
message: "Your workflow run for the certificate monitoring has failed. Please check the details.",
} as WorkflowNodeConfigForNotify,
@@ -352,6 +401,7 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
} as WorkflowNodeConfigForCondition;
current.next = newNode(WorkflowNodeType.Notify, {
nodeConfig: {
...defaultNodeConfigForNotify(),
subject: "[Certimate] Certificate Expiry Alert!",
message: "The certificate will expire soon. Please pay attention to your website.",
} as WorkflowNodeConfigForNotify,
@@ -380,6 +430,7 @@ export const initWorkflow = (options: InitWorkflowOptions = {}): WorkflowModel =
} as WorkflowNodeConfigForCondition;
current.next = newNode(WorkflowNodeType.Notify, {
nodeConfig: {
...defaultNodeConfigForNotify(),
subject: "[Certimate] Certificate Expiry Alert!",
message: "The certificate has already expired. Please pay attention to your website.",
} as WorkflowNodeConfigForNotify,
@@ -458,18 +509,22 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {}
return node;
};
export const cloneNode = (sourceNode: WorkflowNode): WorkflowNode => {
type CloneNodeOptions = {
withCopySuffix?: boolean;
};
export const cloneNode = (sourceNode: WorkflowNode, { withCopySuffix }: CloneNodeOptions = { withCopySuffix: true }): WorkflowNode => {
const { produce } = new Immer({ autoFreeze: false });
const deepClone = (node: WorkflowNode): WorkflowNode => {
return produce(node, (draft) => {
draft.id = nanoid();
if (draft.next) {
draft.next = cloneNode(draft.next);
draft.next = cloneNode(draft.next, { withCopySuffix });
}
if (draft.branches) {
draft.branches = draft.branches.map((branch) => cloneNode(branch));
draft.branches = draft.branches.map((branch) => cloneNode(branch, { withCopySuffix }));
}
return draft;
@@ -477,16 +532,12 @@ export const cloneNode = (sourceNode: WorkflowNode): WorkflowNode => {
};
const copyNode = produce(sourceNode, (draft) => {
draft.name = `${draft.name}-copy`;
draft.name = withCopySuffix ? `${draft.name}-copy` : draft.name;
});
return deepClone(copyNode);
};
export const addNode = (root: WorkflowNode, targetNode: WorkflowNode, previousNodeId: string) => {
if (isBranchNode(targetNode)) {
throw new Error("Cannot add a branch node directly. Use `addBranch` instead.");
}
return produce(root, (draft) => {
let current = draft;
while (current) {

View File

@@ -146,6 +146,12 @@
"access.form.cmcccloud_access_key_secret.label": "CMCC ECloud AccessKeySecret",
"access.form.cmcccloud_access_key_secret.placeholder": "Please enter CMCC ECloud AccessKeySecret",
"access.form.cmcccloud_access_key_secret.tooltip": "For more information, see <a href=\"https://ecloud.10086.cn/op-help-center/doc/article/49739\" target=\"_blank\">https://ecloud.10086.cn/op-help-center/doc/article/49739</a>",
"access.form.constellix_api_key.label": "Constellix API key",
"access.form.constellix_api_key.placeholder": "Please enter Constellix API key",
"access.form.constellix_api_key.tooltip": "For more information, see <a href=\"https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key\" target=\"_blank\">https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key</a>",
"access.form.constellix_secret_key.label": "Constellix API secret key",
"access.form.constellix_secret_key.placeholder": "Please enter Constellix API secret key",
"access.form.constellix_secret_key.tooltip": "For more information, see <a href=\"https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key\" target=\"_blank\">https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key</a>",
"access.form.desec_token.label": "deSEC token",
"access.form.desec_token.placeholder": "Please enter deSEC token",
"access.form.desec_token.tooltip": "For more information, see <a href=\"https://desec.readthedocs.io/en/latest/auth/tokens.html#manage-tokens\" target=\"_blank\">https://desec.readthedocs.io/en/latest/auth/tokens.html</a>",
@@ -372,18 +378,21 @@
"access.form.ssh_host.placeholder": "Please enter server host",
"access.form.ssh_port.label": "Server port",
"access.form.ssh_port.placeholder": "Please enter server port",
"access.form.ssh_auth_method.label": "Authentication method",
"access.form.ssh_auth_method.placeholder": "Please select authentication method",
"access.form.ssh_auth_method.option.none.label": "None",
"access.form.ssh_auth_method.option.password.label": "Password",
"access.form.ssh_auth_method.option.key.label": "SSH key",
"access.form.ssh_username.label": "Username",
"access.form.ssh_username.placeholder": "Please enter username",
"access.form.ssh_password.label": "Password (Optional)",
"access.form.ssh_password.label": "Password",
"access.form.ssh_password.placeholder": "Please enter password",
"access.form.ssh_password.tooltip": "Required when using password to connect to SSH.",
"access.form.ssh_key.label": "SSH key (Optional)",
"access.form.ssh_key.label": "SSH key",
"access.form.ssh_key.placeholder": "Please enter SSH key",
"access.form.ssh_key.tooltip": "Required when using key to connect to SSH.",
"access.form.ssh_key_passphrase.label": "SSH key passphrase (Optional)",
"access.form.ssh_key_passphrase.placeholder": "Please enter SSH key passphrase",
"access.form.ssh_key_passphrase.tooltip": "Optional when using key to connect to SSH.",
"access.form.ssh_jump_servers.label": "SSH jump server (Optional)",
"access.form.ssh_jump_servers.errmsg.invalid": "Please configure a valid jump server",
"access.form.ssh_jump_servers.item.label": "Jump server",
"access.form.ssh_jump_servers.add": "Add jump server",
"access.form.sslcom_eab_kid.label": "ACME EAB KID",

View File

@@ -27,13 +27,14 @@
"provider.aws": "AWS",
"provider.aws.acm": "AWS - ACM (Amazon Certificate Manager)",
"provider.aws.cloudfront": "AWS - CloudFront",
"provider.aws.iam": "AWS - IAM (Identity and Access Management)",
"provider.aws.route53": "AWS - Route53",
"provider.azure": "Azure",
"provider.azure.dns": "Azure - DNS",
"provider.azure.keyvault": "Azure - KeyVault",
"provider.baiducloud": "Baidu Cloud",
"provider.baiducloud.appblb": "Baidu Cloud - AppBLB (Application Baidu Load Balancer)",
"provider.baiducloud.blb": "Baidu Cloud - BLB (Baidu Load Balancer)",
"provider.baiducloud.blb": "Baidu Cloud - BLB (Load Balancer)",
"provider.baiducloud.cdn": "Baidu Cloud - CDN (Content Delivery Network)",
"provider.baiducloud.cert_upload": "Baidu Cloud - Upload to SSL Certificate Service",
"provider.baiducloud.dns": "Baidu Cloud - DNS (Domain Name Service)",
@@ -55,6 +56,7 @@
"provider.cloudflare": "Cloudflare",
"provider.cloudns": "ClouDNS",
"provider.cmcccloud": "China Mobile Cloud (ECloud)",
"provider.constellix": "Constellix",
"provider.ctcccloud": "China Telecom Cloud (State Cloud)",
"provider.cucccloud": "China Unicom Cloud",
"provider.desec": "deSEC",
@@ -113,7 +115,7 @@
"provider.qiniu.kodo": "Qiniu - Kodo",
"provider.qiniu.pili": "Qiniu - Pili",
"provider.rainyun": "Rain Yun",
"provider.rainyun.rcdn": "Rain Yun - RCDN (Rain Content Delivery Network)",
"provider.rainyun.rcdn": "Rain Yun - RCDN (Content Delivery Network)",
"provider.ratpanel": "RatPanel",
"provider.ratpanel.console": "RatPanel - Console",
"provider.ratpanel.site": "RatPanel - Website",
@@ -136,8 +138,9 @@
"provider.tencentcloud.vod": "Tencent Cloud - VOD (Video on Demand)",
"provider.tencentcloud.waf": "Tencent Cloud - WAF (Web Application Firewall)",
"provider.ucloud": "UCloud",
"provider.ucloud.ucdn": "UCloud - UCDN (UCloud Content Delivery Network)",
"provider.ucloud.us3": "UCloud - US3 (UCloud Object-based Storage)",
"provider.ucloud.ucdn": "UCloud - UCDN (Content Delivery Network)",
"provider.ucloud.udnr": "UCloud - UDNR (Domain Name Registrar)",
"provider.ucloud.us3": "UCloud - US3 (Object-based Storage)",
"provider.unicloud": "uniCloud (DCloud)",
"provider.unicloud.webhost": "uniCloud (DCloud) - Web Host",
"provider.upyun": "UPYUN",

View File

@@ -7,6 +7,8 @@
"workflow.action.create": "Create workflow",
"workflow.action.edit": "Edit workflow",
"workflow.action.duplicate": "Duplicate workflow",
"workflow.action.duplicate.confirm": "Are you sure to duplicate this workflow?",
"workflow.action.delete": "Delete workflow",
"workflow.action.delete.confirm": "Are you sure to delete this workflow?",
"workflow.action.enable": "Enable",

View File

@@ -297,6 +297,15 @@
"workflow_node.deploy.form.aws_cloudfront_distribution_id.label": "AWS CloudFront distribution ID",
"workflow_node.deploy.form.aws_cloudfront_distribution_id.placeholder": "Please enter AWS CloudFront distribution ID",
"workflow_node.deploy.form.aws_cloudfront_distribution_id.tooltip": "For more information, see <a href=\"https://docs.aws.amazon.com/en_us/AmazonCloudFront/latest/DeveloperGuide/distribution-working-with.html\" target=\"_blank\">https://docs.aws.amazon.com/en_us/AmazonCloudFront/latest/DeveloperGuide/distribution-working-with.html</a>",
"workflow_node.deploy.form.aws_cloudfront_certificate_source.label": "AWS CloudFront certificate source",
"workflow_node.deploy.form.aws_cloudfront_certificate_source.placeholder": "Please select AWS CloudFront certificate source",
"workflow_node.deploy.form.aws_iam_region.label": "AWS IAM Region",
"workflow_node.deploy.form.aws_iam_region.placeholder": "Please enter AWS IAM region (e.g. us-east-1)",
"workflow_node.deploy.form.aws_iam_region.tooltip": "For more information, see <a href=\"https://docs.aws.amazon.com/en_us/general/latest/gr/rande.html#regional-endpoints\" target=\"_blank\">https://docs.aws.amazon.com/en_us/general/latest/gr/rande.html#regional-endpoints</a>",
"workflow_node.deploy.form.aws_iam_certificate_path.label": "AWS IAM certificate path (Optional)",
"workflow_node.deploy.form.aws_iam_certificate_path.placeholder": "Please enter AWS IAM certificate path",
"workflow_node.deploy.form.aws_iam_certificate_path.errmsg.invalid": "Please enter a valid AWS IAM certificate path",
"workflow_node.deploy.form.aws_iam_certificate_path.tooltip": "For more information, see <a href=\"https://docs.aws.amazon.com/en_us/IAM/latest/UserGuide/reference_identifiers.html\" target=\"_blank\">https://docs.aws.amazon.com/en_us/IAM/latest/UserGuide/reference_identifiers.html</a>",
"workflow_node.deploy.form.azure_keyvault_name.label": "Azure KeyVault name",
"workflow_node.deploy.form.azure_keyvault_name.placeholder": "Please enter Azure KeyVault name",
"workflow_node.deploy.form.azure_keyvault_name.tooltip": "For more information, see <a href=\"https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates\" target=\"_blank\">https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates</a>",
@@ -673,11 +682,11 @@
"workflow_node.deploy.form.tencentcloud_ssl_deploy_region.tooltip": "For more information, see <a href=\"https://www.tencentcloud.com/document/product/1007/36573\" target=\"_blank\">https://www.tencentcloud.com/document/product/1007/36573</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.label": "Tencent Cloud resource type",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.placeholder": "Please enter Tencent Cloud resource type",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.tooltip": "For more information, see <a href=\"https://cloud.tencent.com.cn/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com.cn/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.tooltip": "For more information, see <a href=\"https://cloud.tencent.com/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.label": "Tencent Cloud resource IDs",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.placeholder": "Please enter Tencent Cloud resource IDs (separated by semicolons)",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.errmsg.invalid": "Please enter a valid Tencent Cloud resource ID",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.tooltip": "For more information, see <a href=\"https://cloud.tencent.com.cn/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com.cn/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.tooltip": "For more information, see <a href=\"https://cloud.tencent.com/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.multiple_input_modal.title": "Change Tencent Cloud resource IDs",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.multiple_input_modal.placeholder": "Please enter Tencent Cloud resouce ID",
"workflow_node.deploy.form.tencentcloud_vod_sub_app_id.label": "Tencent Cloud VOD App ID",
@@ -869,6 +878,12 @@
"workflow_node.notify.form.webhook_data.tooltip": "Leave it blank to use the default Webhook data provided by the authorization.",
"workflow_node.notify.form.webhook_data.guide": "<details><summary>Supported variables: </summary><ol style=\"margin-left: 1.25em; list-style: disc;\"><li><strong>${SUBJECT}</strong>: The subject of notification.</li><li><strong>${MESSAGE}</strong>: The message of notification.</li></ol></details><br>Please visit the authorization management page for addtional notes.",
"workflow_node.notify.form.webhook_data.errmsg.json_invalid": "Please enter a valiod JSON string",
"workflow_node.notify.form.strategy_config.label": "Strategy settings",
"workflow_node.notify.form.skip_on_all_prev_skipped.label": "Silent behavior",
"workflow_node.notify.form.skip_on_all_prev_skipped.prefix": "If all the previous nodes were skipped, ",
"workflow_node.notify.form.skip_on_all_prev_skipped.suffix": " to notify.",
"workflow_node.notify.form.skip_on_all_prev_skipped.switch.on": "skip",
"workflow_node.notify.form.skip_on_all_prev_skipped.switch.off": "not skip",
"workflow_node.end.label": "End",
"workflow_node.end.default_name": "End",

View File

@@ -146,6 +146,12 @@
"access.form.cmcccloud_access_key_secret.label": "移动云 AccessKeySecret",
"access.form.cmcccloud_access_key_secret.placeholder": "请输入移动云 AccessKeySecret",
"access.form.cmcccloud_access_key_secret.tooltip": "这是什么?请参阅 <a href=\"https://ecloud.10086.cn/op-help-center/doc/article/49739\" target=\"_blank\">https://ecloud.10086.cn/op-help-center/doc/article/49739</a>",
"access.form.constellix_api_key.label": "Constellix API Key",
"access.form.constellix_api_key.placeholder": "请输入 Constellix API Key",
"access.form.constellix_api_key.tooltip": "这是什么?请参阅 <a href=\"https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key\" target=\"_blank\">https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key</a>",
"access.form.constellix_secret_key.label": "Constellix Secret Key",
"access.form.constellix_secret_key.placeholder": "请输入 Constellix Secret Key",
"access.form.constellix_secret_key.tooltip": "这是什么?请参阅 <a href=\"https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key\" target=\"_blank\">https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key</a>",
"access.form.desec_token.label": "deSEC Token",
"access.form.desec_token.placeholder": "请输入 deSEC Token",
"access.form.desec_token.tooltip": "这是什么?请参阅 <a href=\"https://desec.readthedocs.io/en/latest/auth/tokens.html#manage-tokens\" target=\"_blank\">https://desec.readthedocs.io/en/latest/auth/tokens.html</a>",
@@ -372,18 +378,21 @@
"access.form.ssh_host.placeholder": "请输入服务器地址",
"access.form.ssh_port.label": "服务器端口",
"access.form.ssh_port.placeholder": "请输入服务器端口",
"access.form.ssh_auth_method.label": "认证方式",
"access.form.ssh_auth_method.placeholder": "请选择认证方式",
"access.form.ssh_auth_method.option.none.label": "无",
"access.form.ssh_auth_method.option.password.label": "密码",
"access.form.ssh_auth_method.option.key.label": "密钥",
"access.form.ssh_username.label": "用户名",
"access.form.ssh_username.placeholder": "请输入用户名",
"access.form.ssh_password.label": "密码(可选)",
"access.form.ssh_password.label": "密码",
"access.form.ssh_password.placeholder": "请输入密码",
"access.form.ssh_password.tooltip": "使用密码连接到 SSH 时必填。<br>该字段与密钥文件字段二选一,如果同时填写优先使用 SSH 密钥登录。",
"access.form.ssh_key.label": "SSH 密钥(可选)",
"access.form.ssh_key.label": "SSH 密钥",
"access.form.ssh_key.placeholder": "请输入 SSH 密钥文件内容",
"access.form.ssh_key.tooltip": "使用 SSH 密钥连接到 SSH 时必填。<br>该字段与密码字段二选一,如果同时填写优先使用 SSH 密钥登录。",
"access.form.ssh_key_passphrase.label": "SSH 密钥口令(可选)",
"access.form.ssh_key_passphrase.placeholder": "请输入 SSH 密钥口令",
"access.form.ssh_key_passphrase.tooltip": "使用 SSH 密钥连接到 SSH 时选填。",
"access.form.ssh_jump_servers.label": "SSH 跳板机(可选)",
"access.form.ssh_jump_servers.errmsg.invalid": "请配置有效的 SSH 跳板机",
"access.form.ssh_jump_servers.item.label": "跳板机",
"access.form.ssh_jump_servers.add": "添加跳板机",
"access.form.sslcom_eab_kid.label": "ACME EAB KID",

View File

@@ -27,6 +27,7 @@
"provider.aws": "AWS",
"provider.aws.acm": "AWS - ACM (Amazon Certificate Manager)",
"provider.aws.cloudfront": "AWS - CloudFront",
"provider.aws.iam": "AWS - IAM (Identity and Access Management)",
"provider.aws.route53": "AWS - Route53",
"provider.azure": "Azure",
"provider.azure.dns": "Azure - DNS",
@@ -55,6 +56,7 @@
"provider.cloudflare": "Cloudflare",
"provider.cloudns": "ClouDNS",
"provider.cmcccloud": "移动云",
"provider.constellix": "Constellix",
"provider.ctcccloud": "联通云",
"provider.cucccloud": "天翼云",
"provider.desec": "deSEC",
@@ -137,6 +139,7 @@
"provider.tencentcloud.waf": "腾讯云 - Web 应用防火墙 WAF",
"provider.ucloud": "优刻得",
"provider.ucloud.ucdn": "优刻得 - 内容分发 UCDN",
"provider.ucloud.udnr": "优刻得 - 域名服务 UDNR",
"provider.ucloud.us3": "优刻得 - 对象存储 US3",
"provider.unicloud": "uniCloud (DCloud)",
"provider.unicloud.webhost": "uniCloud (DCloud) - 前端网页托管",

View File

@@ -7,6 +7,8 @@
"workflow.action.create": "新建工作流",
"workflow.action.edit": "编辑工作流",
"workflow.action.duplicate": "复制工作流",
"workflow.action.duplicate.confirm": "确定要复制此工作流吗?",
"workflow.action.delete": "删除工作流",
"workflow.action.delete.confirm": "确定要删除此工作流吗?",
"workflow.action.enable": "启用",

View File

@@ -296,6 +296,15 @@
"workflow_node.deploy.form.aws_cloudfront_distribution_id.label": "AWS CloudFront 分配 ID",
"workflow_node.deploy.form.aws_cloudfront_distribution_id.placeholder": "请输入 AWS CloudFront 分配 ID",
"workflow_node.deploy.form.aws_cloudfront_distribution_id.tooltip": "这是什么?请参阅 <a href=\"https://docs.aws.amazon.com/zh_cn/AmazonCloudFront/latest/DeveloperGuide/distribution-working-with.html\" target=\"_blank\">https://docs.aws.amazon.com/zh_cn/AmazonCloudFront/latest/DeveloperGuide/distribution-working-with.html</a>",
"workflow_node.deploy.form.aws_cloudfront_certificate_source.label": "AWS CloudFront 证书来源",
"workflow_node.deploy.form.aws_cloudfront_certificate_source.placeholder": "请选择 AWS CloudFront 证书来源",
"workflow_node.deploy.form.aws_iam_region.label": "AWS IAM 服务区域",
"workflow_node.deploy.form.aws_iam_region.placeholder": "请输入 AWS IAM 服务区域例如us-east-1",
"workflow_node.deploy.form.aws_iam_region.tooltip": "这是什么?请参阅 <a href=\"https://docs.aws.amazon.com/zh_cn/general/latest/gr/rande.html#regional-endpoints\" target=\"_blank\">https://docs.aws.amazon.com/zh_cn/general/latest/gr/rande.html#regional-endpoints</a>",
"workflow_node.deploy.form.aws_iam_certificate_path.label": "AWS IAM 证书路径(可选)",
"workflow_node.deploy.form.aws_iam_certificate_path.placeholder": "请输入 AWS IAM 证书路径",
"workflow_node.deploy.form.aws_iam_certificate_path.errmsg.invalid": "请输入正确的 AWS IAM 证书路径",
"workflow_node.deploy.form.aws_iam_certificate_path.tooltip": "这是什么?请参阅 <a href=\"https://docs.aws.amazon.com/zh_cn/IAM/latest/UserGuide/reference_identifiers.html\" target=\"_blank\">https://docs.aws.amazon.com/zh_cn/IAM/latest/UserGuide/reference_identifiers.html</a>",
"workflow_node.deploy.form.azure_keyvault_name.label": "Azure KeyVault 名称",
"workflow_node.deploy.form.azure_keyvault_name.placeholder": "请输入 Azure KeyVault 名称",
"workflow_node.deploy.form.azure_keyvault_name.tooltip": "这是什么?请参阅 <a href=\"https://learn.microsoft.com/zh-cn/azure/key-vault/general/about-keys-secrets-certificates\" target=\"_blank\">https://learn.microsoft.com/zh-cn/azure/key-vault/general/about-keys-secrets-certificates</a>",
@@ -669,14 +678,14 @@
"workflow_node.deploy.form.tencentcloud_ssl_deploy.guide": "小贴士:由于腾讯云证书部署任务是异步的,此节点若执行成功仅代表已创建部署任务,实际部署结果需要你自行前往腾讯云控制台查询。",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_region.label": "腾讯云云产品地域",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_region.placeholder": "请输入腾讯云云产品地域例如ap-guangzhou",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_region.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com.cn/document/product/400/41659\" target=\"_blank\">https://cloud.tencent.com.cn/document/product/400/41659</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_region.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com/document/product/400/41659\" target=\"_blank\">https://cloud.tencent.com/document/product/400/41659</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.label": "腾讯云云产品资源类型",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.placeholder": "请输入腾讯云产品资源类型",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com.cn/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com.cn/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_type.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com/document/product/400/91667</a>",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.label": "腾讯云云产品资源 ID",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.placeholder": "请输入腾讯云云产品资源 ID多个值请用半角分号隔开",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.errmsg.invalid": "请输入正确的腾讯云云产品资源 ID",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com.cn/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com.cn/document/product/400/91667</a><br><br>注意与各产品本身的实例 ID 区分。",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.tooltip": "这是什么?请参阅 <a href=\"https://cloud.tencent.com/document/product/400/91667\" target=\"_blank\">https://cloud.tencent.com/document/product/400/91667</a><br><br>注意与各产品本身的实例 ID 区分。",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.multiple_input_modal.title": "修改腾讯云云产品资源 ID",
"workflow_node.deploy.form.tencentcloud_ssl_deploy_resource_ids.multiple_input_modal.placeholder": "请输入腾讯云云产品资源 ID",
"workflow_node.deploy.form.tencentcloud_vod_sub_app_id.label": "腾讯云云点播应用 ID",
@@ -786,7 +795,7 @@
"workflow_node.deploy.form.volcengine_tos_domain.placeholder": "请输入火山引擎 TOS 自定义域名",
"workflow_node.deploy.form.volcengine_tos_domain.tooltip": "这是什么?请参阅 see <a href=\"https://console.volcengine.com/tos\" target=\"_blank\">https://console.volcengine.com/tos</a>",
"workflow_node.deploy.form.wangsu_cdn_domains.label": "网宿云 CDN 加速域名",
"workflow_node.deploy.form.wangsu_cdn_domains.placeholder": "请输入网宿云 CDN 加速域名(多个值请用半角分号隔开)",
"workflow_node.deploy.form.wangsu_cdn_domains.placeholder": "请输入网宿云 CDN 加速域名(支持泛域名;多个值请用半角分号隔开)",
"workflow_node.deploy.form.wangsu_cdn_domains.tooltip": "这是什么?请参阅 <a href=\"https://cdn.console.wangsu.com/v2/index/#/property/list\" target=\"_blank\">https://cdn.console.wangsu.com/v2/index/#/property/list</a>",
"workflow_node.deploy.form.wangsu_cdn_domains.multiple_input_modal.title": "修改网宿云 CDN 加速域名",
"workflow_node.deploy.form.wangsu_cdn_domains.multiple_input_modal.placeholder": "请输入网宿云 CDN 加速域名",
@@ -836,7 +845,7 @@
"workflow_node.notify.form.subject.placeholder": "请输入通知主题",
"workflow_node.notify.form.message.label": "通知内容",
"workflow_node.notify.form.message.placeholder": "请输入通知内容",
"workflow_node.notify.form.channel.label": "通知渠道(废弃,请使用「通知渠道授权」字段)",
"workflow_node.notify.form.channel.label": "通知渠道(即将废弃,请使用「通知渠道授权」字段)",
"workflow_node.notify.form.channel.placeholder": "请选择通知渠道",
"workflow_node.notify.form.channel.button": "设置",
"workflow_node.notify.form.provider.label": "通知渠道",
@@ -868,6 +877,12 @@
"workflow_node.notify.form.webhook_data.tooltip": "不填写时,将使用所选部署目标授权的默认 Webhook 回调数据。",
"workflow_node.notify.form.webhook_data.guide": "<details><summary>支持的变量:</summary><ol style=\"margin-left: 1.25em; list-style: disc;\"><li><strong>${SUBJECT}</strong>:通知主题。</li><li><strong>${MESSAGE}</strong>:通知内容。</ol></details><br>其他注意事项请前往授权管理页面查看。",
"workflow_node.notify.form.webhook_data.errmsg.json_invalid": "请输入有效的 JSON 格式字符串",
"workflow_node.notify.form.strategy_config.label": "执行策略",
"workflow_node.notify.form.skip_on_all_prev_skipped.label": "静默行为",
"workflow_node.notify.form.skip_on_all_prev_skipped.prefix": "当前序申请、上传、部署等节点均已跳过执行时,",
"workflow_node.notify.form.skip_on_all_prev_skipped.suffix": "此通知节点。",
"workflow_node.notify.form.skip_on_all_prev_skipped.switch.on": "跳过",
"workflow_node.notify.form.skip_on_all_prev_skipped.switch.off": "不跳过",
"workflow_node.end.label": "结束",
"workflow_node.end.default_name": "结束",

View File

@@ -9,6 +9,7 @@ import {
EditOutlined as EditOutlinedIcon,
PlusOutlined as PlusOutlinedIcon,
ReloadOutlined as ReloadOutlinedIcon,
SnippetsOutlined as SnippetsOutlinedIcon,
StopOutlined as StopOutlinedIcon,
SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons";
@@ -39,7 +40,7 @@ import {
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
import { WORKFLOW_TRIGGERS, type WorkflowModel, isAllNodesValidated } from "@/domain/workflow";
import { WORKFLOW_TRIGGERS, type WorkflowModel, cloneNode, initWorkflow, isAllNodesValidated } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun";
import { list as listWorkflows, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow";
import { getErrMsg } from "@/utils/error";
@@ -219,6 +220,17 @@ const WorkflowList = () => {
/>
</Tooltip>
<Tooltip title={t("workflow.action.duplicate")}>
<Button
color="primary"
icon={<SnippetsOutlinedIcon />}
variant="text"
onClick={() => {
handleDuplicateClick(record);
}}
/>
</Tooltip>
<Tooltip title={t("workflow.action.delete")}>
<Button
color="danger"
@@ -321,6 +333,36 @@ const WorkflowList = () => {
}
};
const handleDuplicateClick = (workflow: WorkflowModel) => {
modalApi.confirm({
title: t("workflow.action.duplicate"),
content: t("workflow.action.duplicate.confirm"),
onOk: async () => {
try {
const workflowCopy = {
name: `${workflow.name}-copy`,
description: workflow.description,
trigger: workflow.trigger,
triggerCron: workflow.triggerCron,
draft: workflow.content
? cloneNode(workflow.content, { withCopySuffix: false })
: workflow.draft
? cloneNode(workflow.draft, { withCopySuffix: false })
: initWorkflow().draft,
hasDraft: true,
} as WorkflowModel;
const resp = await saveWorkflow(workflowCopy);
if (resp) {
refreshData();
}
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
const handleDeleteClick = (workflow: WorkflowModel) => {
modalApi.confirm({
title: t("workflow.action.delete"),